From fcef5008c4e520243bbddca548ce980fda042676 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 7 Jun 2026 19:32:37 +0200 Subject: [PATCH] feat: add CRM task team assignment --- .../000030_task_team_assignment.down.sql | 4 ++ .../000030_task_team_assignment.up.sql | 10 ++++ internal/models/crm.go | 58 ++++++++++--------- internal/repository/pg_crm.go | 44 +++++++++----- 4 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000030_task_team_assignment.down.sql create mode 100644 internal/infrastructure/db/migrations/000030_task_team_assignment.up.sql diff --git a/internal/infrastructure/db/migrations/000030_task_team_assignment.down.sql b/internal/infrastructure/db/migrations/000030_task_team_assignment.down.sql new file mode 100644 index 00000000..bbcc0337 --- /dev/null +++ b/internal/infrastructure/db/migrations/000030_task_team_assignment.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_crm_tasks_assigned_team; + +ALTER TABLE crm_tasks + DROP COLUMN IF EXISTS assigned_team_id; diff --git a/internal/infrastructure/db/migrations/000030_task_team_assignment.up.sql b/internal/infrastructure/db/migrations/000030_task_team_assignment.up.sql new file mode 100644 index 00000000..5bc7eba9 --- /dev/null +++ b/internal/infrastructure/db/migrations/000030_task_team_assignment.up.sql @@ -0,0 +1,10 @@ +-- A CRM task can be assigned to an individual user (assigned_to) AND/OR a team. +-- The two are independent: a task may have a person, a team, both, or neither. +-- ON DELETE SET NULL mirrors the soft-clearing behaviour of the user assignment: +-- deleting the team unassigns the task rather than deleting it. +ALTER TABLE crm_tasks + ADD COLUMN assigned_team_id uuid REFERENCES teams (id) ON DELETE SET NULL; + +-- Filtering and counting by team + status is the hot path for the tasks views +-- (team filter + status facets share the search/summary predicate). +CREATE INDEX idx_crm_tasks_assigned_team ON crm_tasks (assigned_team_id, status); diff --git a/internal/models/crm.go b/internal/models/crm.go index ba573c19..30a78663 100644 --- a/internal/models/crm.go +++ b/internal/models/crm.go @@ -319,6 +319,7 @@ type CRMTask struct { ContactID *uuid.UUID `json:"contact_id,omitempty"` DealID *uuid.UUID `json:"deal_id,omitempty"` AssignedTo *uuid.UUID `json:"assigned_to,omitempty"` + AssignedTeamID *uuid.UUID `json:"assigned_team_id,omitempty"` CreatedBy uuid.UUID `json:"created_by"` Title string `json:"title"` Description *string `json:"description,omitempty"` @@ -337,24 +338,26 @@ type CRMTasksResult struct { } type CreateCRMTask struct { - ContactID *uuid.UUID `json:"contact_id,omitempty"` - DealID *uuid.UUID `json:"deal_id,omitempty"` - AssignedTo *uuid.UUID `json:"assigned_to,omitempty"` - Title string `json:"title" binding:"required,min=1,max=255"` - Description *string `json:"description,omitempty"` - DueDate *time.Time `json:"due_date,omitempty"` - Priority string `json:"priority,omitempty"` - Type string `json:"type,omitempty"` + ContactID *uuid.UUID `json:"contact_id,omitempty"` + DealID *uuid.UUID `json:"deal_id,omitempty"` + AssignedTo *uuid.UUID `json:"assigned_to,omitempty"` + AssignedTeamID *uuid.UUID `json:"assigned_team_id,omitempty"` + Title string `json:"title" binding:"required,min=1,max=255"` + Description *string `json:"description,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + Priority string `json:"priority,omitempty"` + Type string `json:"type,omitempty"` } type UpdateCRMTask struct { - AssignedTo *uuid.UUID `json:"assigned_to,omitempty"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - DueDate *time.Time `json:"due_date,omitempty"` - Priority *string `json:"priority,omitempty"` - Type *string `json:"type,omitempty"` - Status *string `json:"status,omitempty"` + AssignedTo *uuid.UUID `json:"assigned_to,omitempty"` + AssignedTeamID *uuid.UUID `json:"assigned_team_id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + Priority *string `json:"priority,omitempty"` + Type *string `json:"type,omitempty"` + Status *string `json:"status,omitempty"` } // ===================== @@ -367,18 +370,19 @@ type UpdateCRMTask struct { // that makes the tasks view correct at scale instead of paging a cursor and // reducing client-side. type SearchTasks struct { - Query string `json:"query"` // title ILIKE - Statuses []string `json:"statuses"` // pending | in_progress | completed | cancelled (any of) - Priorities []string `json:"priorities"` // low | medium | high | urgent (any of) - Types []string `json:"types"` // task type NAME is any of - AssignedTo []string `json:"assigned_to"` // assignee user id is any of - ContactID *string `json:"contact_id"` // linked contact - DealID *string `json:"deal_id"` // linked deal - DueAfter *time.Time `json:"due_after"` // due_date >= - DueBefore *time.Time `json:"due_before"` // due_date <= - Overdue bool `json:"overdue"` // due_date < now() AND not completed/cancelled - SortBy string `json:"sort_by"` // created_at|due_date|priority|title|updated_at - Reverse bool `json:"reverse"` // true = ASC, false = DESC (default) + Query string `json:"query"` // title ILIKE + Statuses []string `json:"statuses"` // pending | in_progress | completed | cancelled (any of) + Priorities []string `json:"priorities"` // low | medium | high | urgent (any of) + Types []string `json:"types"` // task type NAME is any of + AssignedTo []string `json:"assigned_to"` // assignee user id is any of + TeamIDs []uuid.UUID `json:"team_ids"` // task team is any of, OR assignee is a member of any of + ContactID *string `json:"contact_id"` // linked contact + DealID *string `json:"deal_id"` // linked deal + DueAfter *time.Time `json:"due_after"` // due_date >= + DueBefore *time.Time `json:"due_before"` // due_date <= + Overdue bool `json:"overdue"` // due_date < now() AND not completed/cancelled + SortBy string `json:"sort_by"` // created_at|due_date|priority|title|updated_at + Reverse bool `json:"reverse"` // true = ASC, false = DESC (default) } // TasksSearchResult is the offset-paginated result of POST /crm/tasks/search. diff --git a/internal/repository/pg_crm.go b/internal/repository/pg_crm.go index 981642a9..56116789 100644 --- a/internal/repository/pg_crm.go +++ b/internal/repository/pg_crm.go @@ -1071,18 +1071,18 @@ func (r *crmRepository) CreateCRMTask(ctx context.Context, orgID, userID uuid.UU taskType := data.Type query := ` - INSERT INTO crm_tasks (organization_id, contact_id, deal_id, assigned_to, created_by, title, description, due_date, priority, type) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id, organization_id, contact_id, deal_id, assigned_to, created_by, title, description, + INSERT INTO crm_tasks (organization_id, contact_id, deal_id, assigned_to, assigned_team_id, created_by, title, description, due_date, priority, type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, organization_id, contact_id, deal_id, assigned_to, assigned_team_id, created_by, title, description, due_date, priority, type, status, completed_at, created_at, updated_at ` var task models.CRMTask err := r.db.QueryRow(ctx, query, - orgID, data.ContactID, data.DealID, data.AssignedTo, userID, + orgID, data.ContactID, data.DealID, data.AssignedTo, data.AssignedTeamID, userID, data.Title, data.Description, data.DueDate, priority, taskType, ).Scan( &task.ID, &task.OrganizationID, &task.ContactID, &task.DealID, - &task.AssignedTo, &task.CreatedBy, &task.Title, &task.Description, + &task.AssignedTo, &task.AssignedTeamID, &task.CreatedBy, &task.Title, &task.Description, &task.DueDate, &task.Priority, &task.Type, &task.Status, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ) @@ -1094,7 +1094,7 @@ func (r *crmRepository) CreateCRMTask(ctx context.Context, orgID, userID uuid.UU func (r *crmRepository) GetCRMTask(ctx context.Context, orgID, taskID uuid.UUID) (*models.CRMTask, error) { query := ` - SELECT id, organization_id, contact_id, deal_id, assigned_to, created_by, title, description, + SELECT id, organization_id, contact_id, deal_id, assigned_to, assigned_team_id, created_by, title, description, due_date, priority, type, status, completed_at, created_at, updated_at FROM crm_tasks WHERE organization_id = $1 AND id = $2 @@ -1102,7 +1102,7 @@ func (r *crmRepository) GetCRMTask(ctx context.Context, orgID, taskID uuid.UUID) var task models.CRMTask err := r.db.QueryRow(ctx, query, orgID, taskID).Scan( &task.ID, &task.OrganizationID, &task.ContactID, &task.DealID, - &task.AssignedTo, &task.CreatedBy, &task.Title, &task.Description, + &task.AssignedTo, &task.AssignedTeamID, &task.CreatedBy, &task.Title, &task.Description, &task.DueDate, &task.Priority, &task.Type, &task.Status, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ) @@ -1148,7 +1148,7 @@ func (r *crmRepository) ListCRMTasks(ctx context.Context, orgID uuid.UUID, conta } query := fmt.Sprintf(` - SELECT id, organization_id, contact_id, deal_id, assigned_to, created_by, title, description, + SELECT id, organization_id, contact_id, deal_id, assigned_to, assigned_team_id, created_by, title, description, due_date, priority, type, status, completed_at, created_at, updated_at FROM crm_tasks WHERE %s @@ -1168,7 +1168,7 @@ func (r *crmRepository) ListCRMTasks(ctx context.Context, orgID uuid.UUID, conta var task models.CRMTask if err := rows.Scan( &task.ID, &task.OrganizationID, &task.ContactID, &task.DealID, - &task.AssignedTo, &task.CreatedBy, &task.Title, &task.Description, + &task.AssignedTo, &task.AssignedTeamID, &task.CreatedBy, &task.Title, &task.Description, &task.DueDate, &task.Priority, &task.Type, &task.Status, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ); err != nil { @@ -1226,6 +1226,19 @@ func taskSearchWhere(orgID uuid.UUID, f models.SearchTasks) ([]string, []any) { appendIn("type", f.Types) appendIn("assigned_to", f.AssignedTo) + // Team filter: a task matches when it is directly assigned to one of the + // given teams, OR its individual assignee is a member of one of them. Bound + // once as a uuid[] so the same predicate (and the same $N) is reused + // identically by the search rows query and the summary aggregate. + if len(f.TeamIDs) > 0 { + clauses = append(clauses, fmt.Sprintf( + "(t.assigned_team_id = ANY($%d) OR t.assigned_to IN (SELECT user_id FROM team_members WHERE team_id = ANY($%d)))", + pos, pos, + )) + args = append(args, f.TeamIDs) + pos++ + } + if f.ContactID != nil { clauses = append(clauses, fmt.Sprintf("t.contact_id = $%d", pos)) args = append(args, *f.ContactID) @@ -1299,7 +1312,7 @@ func (r *crmRepository) SearchCRMTasks(ctx context.Context, orgID uuid.UUID, fil limitPos := len(args) + 1 offsetPos := len(args) + 2 query := fmt.Sprintf(` - SELECT t.id, t.organization_id, t.contact_id, t.deal_id, t.assigned_to, t.created_by, t.title, t.description, + SELECT t.id, t.organization_id, t.contact_id, t.deal_id, t.assigned_to, t.assigned_team_id, t.created_by, t.title, t.description, t.due_date, t.priority, t.type, t.status, t.completed_at, t.created_at, t.updated_at FROM crm_tasks t WHERE %s @@ -1319,7 +1332,7 @@ func (r *crmRepository) SearchCRMTasks(ctx context.Context, orgID uuid.UUID, fil var task models.CRMTask if err := rows.Scan( &task.ID, &task.OrganizationID, &task.ContactID, &task.DealID, - &task.AssignedTo, &task.CreatedBy, &task.Title, &task.Description, + &task.AssignedTo, &task.AssignedTeamID, &task.CreatedBy, &task.Title, &task.Description, &task.DueDate, &task.Priority, &task.Type, &task.Status, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ); err != nil { @@ -1387,6 +1400,11 @@ func (r *crmRepository) UpdateCRMTask(ctx context.Context, orgID, taskID uuid.UU args = append(args, *data.AssignedTo) argPos++ } + if data.AssignedTeamID != nil { + setClauses = append(setClauses, fmt.Sprintf("assigned_team_id = $%d", argPos)) + args = append(args, *data.AssignedTeamID) + argPos++ + } if data.Title != nil { setClauses = append(setClauses, fmt.Sprintf("title = $%d", argPos)) args = append(args, *data.Title) @@ -1431,14 +1449,14 @@ func (r *crmRepository) UpdateCRMTask(ctx context.Context, orgID, taskID uuid.UU query := fmt.Sprintf(` UPDATE crm_tasks SET %s WHERE organization_id = $1 AND id = $2 - RETURNING id, organization_id, contact_id, deal_id, assigned_to, created_by, title, description, + RETURNING id, organization_id, contact_id, deal_id, assigned_to, assigned_team_id, created_by, title, description, due_date, priority, type, status, completed_at, created_at, updated_at `, strings.Join(setClauses, ", ")) var task models.CRMTask err := r.db.QueryRow(ctx, query, args...).Scan( &task.ID, &task.OrganizationID, &task.ContactID, &task.DealID, - &task.AssignedTo, &task.CreatedBy, &task.Title, &task.Description, + &task.AssignedTo, &task.AssignedTeamID, &task.CreatedBy, &task.Title, &task.Description, &task.DueDate, &task.Priority, &task.Type, &task.Status, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, )