diff --git a/cmd/handlers.go b/cmd/handlers.go index c75b3c1e..27c3ea13 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -128,6 +128,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/v1/contacts/{id}/notes", perm(handleCreateContactNote, "contact_notes:write")) g.DELETE("/api/v1/contacts/{id}/notes/{note_id}", perm(handleDeleteContactNote, "contact_notes:delete")) + // Organizations. + g.GET("/api/v1/organizations/compact", auth(handleGetOrganizationsCompact)) + g.GET("/api/v1/organizations", perm(handleGetOrganizations, "organizations:manage")) + g.GET("/api/v1/organizations/{id}", perm(handleGetOrganization, "organizations:manage")) + g.POST("/api/v1/organizations", perm(handleCreateOrganization, "organizations:manage")) + g.PUT("/api/v1/organizations/{id}", perm(handleUpdateOrganization, "organizations:manage")) + g.DELETE("/api/v1/organizations/{id}", perm(handleDeleteOrganization, "organizations:manage")) + // Teams. g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact)) g.GET("/api/v1/teams", perm(handleGetTeams, "teams:manage")) diff --git a/cmd/init.go b/cmd/init.go index 0c194f60..53471fd6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -35,6 +35,7 @@ import ( notifier "github.com/abhinavxd/libredesk/internal/notification" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" "github.com/abhinavxd/libredesk/internal/oidc" + "github.com/abhinavxd/libredesk/internal/organization" "github.com/abhinavxd/libredesk/internal/report" "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/search" @@ -249,6 +250,20 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager { return mgr } +// initOrganization inits organization manager. +func initOrganization(db *sqlx.DB, i18n *i18n.I18n) *organization.Manager { + var lo = initLogger("organization_manager") + mgr, err := organization.New(organization.Opts{ + DB: db, + Lo: lo, + I18n: i18n, + }) + if err != nil { + log.Fatalf("error initializing organizations: %v", err) + } + return mgr +} + // initViews inits view manager. func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager { var lo = initLogger("view_manager") diff --git a/cmd/main.go b/cmd/main.go index 2842e843..d9126381 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -36,6 +36,7 @@ import ( "github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/role" + "github.com/abhinavxd/libredesk/internal/organization" "github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/tag" "github.com/abhinavxd/libredesk/internal/team" @@ -78,6 +79,7 @@ type App struct { status *status.Manager priority *priority.Manager tag *tag.Manager + organization *organization.Manager inbox *inbox.Manager tmpl *template.Manager macro *macro.Manager @@ -247,6 +249,7 @@ func main() { search: initSearch(db, i18n), role: initRole(db, i18n), tag: initTag(db, i18n), + organization: initOrganization(db, i18n), macro: initMacro(db, i18n), ai: initAI(db, i18n), webhook: webhook, diff --git a/cmd/organizations.go b/cmd/organizations.go new file mode 100644 index 00000000..3819e3b1 --- /dev/null +++ b/cmd/organizations.go @@ -0,0 +1,134 @@ +package main + +import ( + "strconv" + + "github.com/abhinavxd/libredesk/internal/envelope" + omodels "github.com/abhinavxd/libredesk/internal/organization/models" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +// handleGetOrganizations returns a list of organizations with pagination and filtering. +func handleGetOrganizations(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + order = string(r.RequestCtx.QueryArgs().Peek("order")) + orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by")) + filters = string(r.RequestCtx.QueryArgs().Peek("filters")) + page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) + pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) + total = 0 + ) + + organizations, err := app.organization.GetAll(page, pageSize, order, orderBy, filters) + if err != nil { + return sendErrorEnvelope(r, err) + } + + if len(organizations) > 0 { + total = organizations[0].Total + } + + return r.SendEnvelope(envelope.PageResults{ + Results: organizations, + Total: total, + PerPage: pageSize, + TotalPages: (total + pageSize - 1) / pageSize, + Page: page, + }) +} + +// handleGetOrganization returns a single organization by ID. +func handleGetOrganization(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id = r.RequestCtx.UserValue("id").(string) + ) + + if id == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + org, err := app.organization.Get(id) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(org) +} + +// handleCreateOrganization creates a new organization. +func handleCreateOrganization(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + org = omodels.Organization{} + ) + + if err := r.Decode(&org, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) + } + + createdOrg, err := app.organization.Create(org) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(createdOrg) +} + +// handleUpdateOrganization updates an existing organization. +func handleUpdateOrganization(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id = r.RequestCtx.UserValue("id").(string) + org = omodels.Organization{} + ) + + if id == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := r.Decode(&org, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError) + } + + updatedOrg, err := app.organization.Update(id, org) + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(updatedOrg) +} + +// handleDeleteOrganization deletes an organization. +func handleDeleteOrganization(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + id = r.RequestCtx.UserValue("id").(string) + ) + + if id == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) + } + + if err := app.organization.Delete(id); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} + +// handleGetOrganizationsCompact returns a compact list of all organizations (for dropdowns). +func handleGetOrganizationsCompact(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + + organizations, err := app.organization.GetCompact() + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(organizations) +} diff --git a/cmd/upgrade.go b/cmd/upgrade.go index b72e67e8..9ef47a87 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -36,6 +36,7 @@ var migList = []migFunc{ {"v0.6.0", migrations.V0_6_0}, {"v0.7.0", migrations.V0_7_0}, {"v0.7.4", migrations.V0_7_4}, + {"v0.8.0", migrations.V0_8_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/go.sum b/go.sum index 56d292e7..573710c2 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM= github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= -github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE= -github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rhnvrm/simples3 v0.9.2 h1:XrwsiMnwWf7t/kskvhMYXW6keqp5u3u6t5Va3ltzCQI= github.com/rhnvrm/simples3 v0.9.2/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/internal/migrations/v0.8.0.go b/internal/migrations/v0.8.0.go new file mode 100644 index 00000000..11101b00 --- /dev/null +++ b/internal/migrations/v0.8.0.go @@ -0,0 +1,74 @@ +package migrations + +import ( + "github.com/jmoiron/sqlx" + "github.com/knadh/koanf/v2" + "github.com/knadh/stuffbin" +) + +// V0_8_0 updates the database schema to v0.8.0. +// This migration adds the organizations table and organization_id to users table. +func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { + // Create organizations table if it doesn't exist + _, err := db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'organizations' + ) THEN + CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + "name" TEXT NOT NULL, + website TEXT NULL, + email_domain TEXT NULL, + phone TEXT NULL, + CONSTRAINT constraint_organizations_on_name CHECK (length("name") <= 255), + CONSTRAINT constraint_organizations_on_website CHECK (length(website) <= 255), + CONSTRAINT constraint_organizations_on_email_domain CHECK (length(email_domain) <= 255), + CONSTRAINT constraint_organizations_on_phone CHECK (length(phone) <= 50) + ); + + CREATE INDEX index_organizations_on_name ON organizations USING btree ("name"); + CREATE INDEX index_organizations_on_email_domain ON organizations USING btree (email_domain); + END IF; + END $$; + `) + if err != nil { + return err + } + + // Add organization_id column to users table if it doesn't exist + _, err = db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'organization_id' + ) THEN + ALTER TABLE users + ADD COLUMN organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL ON UPDATE CASCADE NULL; + + CREATE INDEX index_users_on_organization_id ON users(organization_id); + END IF; + END $$; + `) + if err != nil { + return err + } + + // Add organizations:manage permission to Admin role if not already present + _, err = db.Exec(` + UPDATE roles + SET permissions = array_append(permissions, 'organizations:manage') + WHERE name = 'Admin' + AND NOT ('organizations:manage' = ANY(permissions)); + `) + if err != nil { + return err + } + + return nil +} diff --git a/internal/organization/models/models.go b/internal/organization/models/models.go new file mode 100644 index 00000000..9e9c6681 --- /dev/null +++ b/internal/organization/models/models.go @@ -0,0 +1,30 @@ +package models + +import ( + "time" + + "github.com/volatiletech/null/v9" +) + +// Organization represents a company or organization that contacts belong to. +type Organization struct { + ID string `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + Website null.String `db:"website" json:"website"` + EmailDomain null.String `db:"email_domain" json:"email_domain"` + Phone null.String `db:"phone" json:"phone"` + + // Total is used for pagination count + Total int `db:"total" json:"-"` +} + +// OrganizationCompact represents a minimal organization for list views. +type OrganizationCompact struct { + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + + // Total is used for pagination count + Total int `db:"total" json:"-"` +} diff --git a/internal/organization/organization.go b/internal/organization/organization.go new file mode 100644 index 00000000..ef244f5d --- /dev/null +++ b/internal/organization/organization.go @@ -0,0 +1,239 @@ +// Package organization handles the management of organizations. +package organization + +import ( + "context" + "database/sql" + "embed" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/abhinavxd/libredesk/internal/dbutil" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/organization/models" + "github.com/jmoiron/sqlx" + "github.com/knadh/go-i18n" + "github.com/zerodha/logf" +) + +const ( + maxListPageSize = 100 +) + +var ( + //go:embed queries.sql + efs embed.FS + + // Simple domain validation regex + domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) +) + +type Manager struct { + q queries + lo *logf.Logger + i18n *i18n.I18n + db *sqlx.DB +} + +// Opts contains options for initializing the Manager. +type Opts struct { + DB *sqlx.DB + Lo *logf.Logger + I18n *i18n.I18n +} + +// queries contains prepared SQL queries. +type queries struct { + GetOrganization *sqlx.Stmt `query:"get-organization"` + InsertOrganization *sqlx.Stmt `query:"insert-organization"` + UpdateOrganization *sqlx.Stmt `query:"update-organization"` + DeleteOrganization *sqlx.Stmt `query:"delete-organization"` + GetOrganizationContactsCount *sqlx.Stmt `query:"get-organization-contacts-count"` +} + +// New creates and returns a new instance of the Manager. +func New(opts Opts) (*Manager, error) { + var q queries + + if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { + return nil, err + } + + return &Manager{ + q: q, + lo: opts.Lo, + i18n: opts.I18n, + db: opts.DB, + }, nil +} + +// GetAll retrieves all organizations with pagination and filtering. +func (o *Manager) GetAll(page, pageSize int, order, orderBy, filtersJSON string) ([]models.Organization, error) { + if pageSize > maxListPageSize { + return nil, envelope.NewError(envelope.InputError, o.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil) + } + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + + query, qArgs, err := o.makeOrganizationListQuery(page, pageSize, order, orderBy, filtersJSON) + if err != nil { + o.lo.Error("error creating organization list query", "error", err) + return nil, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "organization"), nil) + } + + // Start a read-only txn. + tx, err := o.db.BeginTxx(context.Background(), &sql.TxOptions{ + ReadOnly: true, + }) + if err != nil { + o.lo.Error("error starting read-only transaction", "error", err) + return nil, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "organization"), nil) + } + defer tx.Rollback() + + // Execute query + var organizations = make([]models.Organization, 0) + if err := tx.Select(&organizations, query, qArgs...); err != nil { + o.lo.Error("error fetching organizations", "error", err) + return nil, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "organization"), nil) + } + + return organizations, nil +} + +// Get retrieves an organization by ID. +func (o *Manager) Get(id string) (models.Organization, error) { + var org models.Organization + if err := o.q.GetOrganization.Get(&org, id); err != nil { + if err == sql.ErrNoRows { + return org, envelope.NewError(envelope.NotFoundError, o.i18n.Ts("globals.messages.notFound", "name", "Organization"), nil) + } + o.lo.Error("error fetching organization", "error", err, "id", id) + return org, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "organization"), nil) + } + return org, nil +} + +// Create creates a new organization. +func (o *Manager) Create(org models.Organization) (models.Organization, error) { + // Validate required fields + if strings.TrimSpace(org.Name) == "" { + return org, envelope.NewError(envelope.InputError, o.i18n.Ts("globals.messages.empty", "name", "Name"), nil) + } + + // Validate email domain if provided + if org.EmailDomain.Valid && org.EmailDomain.String != "" { + if !domainRegex.MatchString(org.EmailDomain.String) { + return org, envelope.NewError(envelope.InputError, "Invalid email domain format", nil) + } + } + + // Validate website URL if provided + if org.Website.Valid && org.Website.String != "" { + if _, err := url.ParseRequestURI(org.Website.String); err != nil { + return org, envelope.NewError(envelope.InputError, "Invalid website URL format", nil) + } + } + + var result models.Organization + if err := o.q.InsertOrganization.Get(&result, org.Name, org.Website, org.EmailDomain, org.Phone); err != nil { + o.lo.Error("error inserting organization", "error", err) + return result, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorCreating", "name", "organization"), nil) + } + + return result, nil +} + +// Update updates an organization by ID. +func (o *Manager) Update(id string, org models.Organization) (models.Organization, error) { + // Validate required fields + if strings.TrimSpace(org.Name) == "" { + return org, envelope.NewError(envelope.InputError, o.i18n.Ts("globals.messages.empty", "name", "Name"), nil) + } + + // Validate email domain if provided + if org.EmailDomain.Valid && org.EmailDomain.String != "" { + if !domainRegex.MatchString(org.EmailDomain.String) { + return org, envelope.NewError(envelope.InputError, "Invalid email domain format", nil) + } + } + + // Validate website URL if provided + if org.Website.Valid && org.Website.String != "" { + if _, err := url.ParseRequestURI(org.Website.String); err != nil { + return org, envelope.NewError(envelope.InputError, "Invalid website URL format", nil) + } + } + + var result models.Organization + if err := o.q.UpdateOrganization.Get(&result, id, org.Name, org.Website, org.EmailDomain, org.Phone); err != nil { + if err == sql.ErrNoRows { + return result, envelope.NewError(envelope.NotFoundError, o.i18n.Ts("globals.messages.notFound", "name", "Organization"), nil) + } + o.lo.Error("error updating organization", "error", err, "id", id) + return result, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorUpdating", "name", "organization"), nil) + } + + return result, nil +} + +// Delete deletes an organization by ID. +// Returns an error if there are contacts assigned to this organization. +func (o *Manager) Delete(id string) error { + // Check if organization has any contacts + var count int + if err := o.q.GetOrganizationContactsCount.Get(&count, id); err != nil { + o.lo.Error("error checking organization contacts count", "error", err, "id", id) + return envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorDeleting", "name", "organization"), nil) + } + + if count > 0 { + return envelope.NewError(envelope.ConflictError, fmt.Sprintf("Cannot delete organization with %d assigned contact(s). Please reassign or remove contacts first.", count), nil) + } + + if _, err := o.q.DeleteOrganization.Exec(id); err != nil { + if err == sql.ErrNoRows { + return envelope.NewError(envelope.NotFoundError, o.i18n.Ts("globals.messages.notFound", "name", "Organization"), nil) + } + o.lo.Error("error deleting organization", "error", err, "id", id) + return envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorDeleting", "name", "organization"), nil) + } + + return nil +} + +// makeOrganizationListQuery builds a paginated query for fetching organizations with filters. +func (o *Manager) makeOrganizationListQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []interface{}, error) { + // Base query with pagination support + baseQuery := ` + SELECT COUNT(*) OVER() as total, id, created_at, updated_at, name, website, email_domain, phone + FROM organizations + ` + + var qArgs []any + return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{ + Order: order, + OrderBy: orderBy, + Page: page, + PageSize: pageSize, + }, filtersJSON, dbutil.AllowedFields{ + "organizations": {"name", "email_domain", "created_at", "updated_at"}, + }) +} + +// GetCompact retrieves all organizations in a compact form (for dropdowns). +func (o *Manager) GetCompact() ([]models.OrganizationCompact, error) { + query := `SELECT id, name FROM organizations ORDER BY name ASC` + var organizations = make([]models.OrganizationCompact, 0) + if err := o.db.Select(&organizations, query); err != nil { + o.lo.Error("error fetching compact organizations", "error", err) + return nil, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "organization"), nil) + } + return organizations, nil +} diff --git a/internal/organization/organization_test.go b/internal/organization/organization_test.go new file mode 100644 index 00000000..55657fa4 --- /dev/null +++ b/internal/organization/organization_test.go @@ -0,0 +1,68 @@ +package organization + +import ( + "testing" +) + +func TestDomainValidation(t *testing.T) { + tests := []struct { + name string + domain string + expected bool + }{ + { + name: "valid domain", + domain: "example.com", + expected: true, + }, + { + name: "valid subdomain", + domain: "mail.example.com", + expected: true, + }, + { + name: "valid multi-level subdomain", + domain: "api.v2.example.com", + expected: true, + }, + { + name: "invalid - no TLD", + domain: "example", + expected: false, + }, + { + name: "invalid - starts with dot", + domain: ".example.com", + expected: false, + }, + { + name: "invalid - ends with dot", + domain: "example.com.", + expected: false, + }, + { + name: "invalid - contains space", + domain: "exam ple.com", + expected: false, + }, + { + name: "invalid - empty", + domain: "", + expected: false, + }, + { + name: "valid - with hyphen", + domain: "my-example.com", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := domainRegex.MatchString(tt.domain) + if result != tt.expected { + t.Errorf("domain %s: got %v, want %v", tt.domain, result, tt.expected) + } + }) + } +} diff --git a/internal/organization/queries.sql b/internal/organization/queries.sql new file mode 100644 index 00000000..e7cbdcad --- /dev/null +++ b/internal/organization/queries.sql @@ -0,0 +1,47 @@ +-- name: get-organization +SELECT + id, + created_at, + updated_at, + name, + website, + email_domain, + phone +FROM + organizations +WHERE + id = $1; + +-- name: insert-organization +INSERT INTO + organizations (name, website, email_domain, phone) +VALUES + ($1, $2, $3, $4) +RETURNING *; + +-- name: update-organization +UPDATE + organizations +SET + name = $2, + website = $3, + email_domain = $4, + phone = $5, + updated_at = now() +WHERE + id = $1 +RETURNING *; + +-- name: delete-organization +DELETE FROM + organizations +WHERE + id = $1; + +-- name: get-organization-contacts-count +SELECT + COUNT(*) +FROM + users +WHERE + organization_id = $1 AND type = 'contact' AND deleted_at IS NULL; diff --git a/schema.sql b/schema.sql index 6bdf520c..81bfb725 100644 --- a/schema.sql +++ b/schema.sql @@ -106,6 +106,23 @@ CREATE TABLE teams ( CONSTRAINT constraint_teams_on_name_unique UNIQUE ("name") ); +DROP TABLE IF EXISTS organizations CASCADE; +CREATE TABLE organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + "name" TEXT NOT NULL, + website TEXT NULL, + email_domain TEXT NULL, + phone TEXT NULL, + CONSTRAINT constraint_organizations_on_name CHECK (length("name") <= 255), + CONSTRAINT constraint_organizations_on_website CHECK (length(website) <= 255), + CONSTRAINT constraint_organizations_on_email_domain CHECK (length(email_domain) <= 255), + CONSTRAINT constraint_organizations_on_phone CHECK (length(phone) <= 50) +); +CREATE INDEX index_organizations_on_name ON organizations USING btree ("name"); +CREATE INDEX index_organizations_on_email_domain ON organizations USING btree (email_domain); + DROP TABLE IF EXISTS roles CASCADE; CREATE TABLE roles ( id SERIAL PRIMARY KEY, @@ -144,6 +161,8 @@ CREATE TABLE users ( api_key TEXT NULL, api_secret TEXT NULL, api_key_last_used_at TIMESTAMPTZ NULL, + -- Organization relationship (for contacts) + organization_id UUID REFERENCES organizations(id) ON DELETE SET NULL ON UPDATE CASCADE NULL, CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140), CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20), CONSTRAINT constraint_users_on_phone_number_country_code CHECK (LENGTH(phone_number_country_code) <= 10), @@ -155,6 +174,7 @@ CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null WHERE deleted_at IS NULL; CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops); CREATE INDEX index_users_on_api_key ON users(api_key); +CREATE INDEX index_users_on_organization_id ON users(organization_id); DROP TABLE IF EXISTS user_roles CASCADE; CREATE TABLE user_roles ( @@ -669,7 +689,7 @@ VALUES ( 'Admin', 'Role for users who have complete access to everything.', - '{webhooks:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}' + '{webhooks:manage,activity_logs:manage,custom_attributes:manage,contacts:read_all,contacts:read,contacts:write,contacts:block,contact_notes:read,contact_notes:write,contact_notes:delete,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage,organizations:manage}' );