feat: add CRM task team assignment

This commit is contained in:
Matthew Meszaros
2026-06-07 19:32:37 +02:00
parent 81c4539b20
commit fcef5008c4
4 changed files with 76 additions and 40 deletions
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_crm_tasks_assigned_team;
ALTER TABLE crm_tasks
DROP COLUMN IF EXISTS assigned_team_id;
@@ -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);
+31 -27
View File
@@ -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.
+31 -13
View File
@@ -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,
)