diff --git a/apps/mcp/src/__tests__/utils/formatters.test.ts b/apps/mcp/src/__tests__/utils/formatters.test.ts index 5e3e8026..a3b33040 100644 --- a/apps/mcp/src/__tests__/utils/formatters.test.ts +++ b/apps/mcp/src/__tests__/utils/formatters.test.ts @@ -91,7 +91,7 @@ describe("formatTask", () => { const result = formatTask(baseTask); expect(result).toContain("Status: None"); expect(result).toContain("Sprint: None"); - expect(result).toContain("Assignee: Unassigned"); + expect(result).toContain("Assignees: Unassigned"); expect(result).toContain("Parent Task: None"); expect(result).toContain("Story Points: None"); expect(result).toContain("Tags: None"); @@ -403,12 +403,12 @@ describe("formatTaskDetail", () => { role_name: "Developer", }; const result = formatTaskDetail( - { ...baseTask, assignee_id: "u1" }, + { ...baseTask, assignee_ids: ["u1"] }, undefined, undefined, undefined, undefined, - assignee, + [assignee], ); expect(result).toContain("Alice Smith"); expect(result).toContain("@alice"); diff --git a/apps/mcp/src/api/client.ts b/apps/mcp/src/api/client.ts index f8d81b19..638e7eac 100644 --- a/apps/mcp/src/api/client.ts +++ b/apps/mcp/src/api/client.ts @@ -207,7 +207,8 @@ export class PacaAPIClient { if (input.task_type_id !== undefined) body.task_type_id = input.task_type_id; if (input.sprint_id !== undefined) body.sprint_id = input.sprint_id; - if (input.assignee_id !== undefined) body.assignee_id = input.assignee_id; + if (input.assignee_id !== undefined) + body.assignee_ids = input.assignee_id ? [input.assignee_id] : []; if (input.parent_task_id !== undefined) body.parent_task_id = input.parent_task_id; if (input.importance !== undefined) body.importance = input.importance; @@ -237,7 +238,8 @@ export class PacaAPIClient { if (input.task_type_id !== undefined) body.task_type_id = input.task_type_id; if (input.sprint_id !== undefined) body.sprint_id = input.sprint_id; - if (input.assignee_id !== undefined) body.assignee_id = input.assignee_id; + if (input.assignee_id !== undefined) + body.assignee_ids = input.assignee_id ? [input.assignee_id] : []; if (input.reporter_id !== undefined) body.reporter_id = input.reporter_id; if (input.parent_task_id !== undefined) body.parent_task_id = input.parent_task_id; diff --git a/apps/mcp/src/tools/task-tools.ts b/apps/mcp/src/tools/task-tools.ts index 46e967ab..a9f6f0be 100644 --- a/apps/mcp/src/tools/task-tools.ts +++ b/apps/mcp/src/tools/task-tools.ts @@ -548,7 +548,9 @@ async function getTaskDetail( const status = statuses.find((s: any) => s.id === task.status_id); const taskType = taskTypes.find((t: any) => t.id === task.task_type_id); const sprint = sprints.find((s: any) => s.id === task.sprint_id); - const assignee = members.find((m: any) => m.id === task.assignee_id); + const assignees = members.filter((m: any) => + task.assignee_ids?.includes(m.id), + ); const reporter = members.find((m: any) => m.id === task.reporter_id); let parentTask: Task | undefined; if (task.parent_task_id) { @@ -565,7 +567,7 @@ async function getTaskDetail( status, taskType, sprint, - assignee, + assignees, reporter, parentTask, subtasks, diff --git a/apps/mcp/src/types/index.ts b/apps/mcp/src/types/index.ts index b0c3f7f7..6e916508 100644 --- a/apps/mcp/src/types/index.ts +++ b/apps/mcp/src/types/index.ts @@ -81,7 +81,7 @@ export interface Task { description?: unknown[] | null; importance: number; story_points?: number | null; - assignee_id?: string | null; + assignee_ids?: string[]; reporter_id?: string | null; custom_fields: Record; start_date?: string | null; diff --git a/apps/mcp/src/utils/formatters.ts b/apps/mcp/src/utils/formatters.ts index b2f8bcc4..4350f57b 100644 --- a/apps/mcp/src/utils/formatters.ts +++ b/apps/mcp/src/utils/formatters.ts @@ -56,7 +56,7 @@ ID: ${task.id} Status: ${task.status_id || "None"} Type: ${task.task_type_id || "None"} Sprint: ${task.sprint_id || "None"} -Assignee: ${task.assignee_id || "Unassigned"} +Assignees: ${task.assignee_ids && task.assignee_ids.length > 0 ? task.assignee_ids.join(", ") : "Unassigned"} Parent Task: ${task.parent_task_id || "None"} Importance: ${task.importance} Story Points: ${task.story_points ?? "None"} @@ -77,7 +77,7 @@ ${description}`; * @param status - The task status object (if available) * @param taskType - The task type object (if available) * @param sprint - The sprint object (if available) - * @param assignee - The assignee member object (if available) + * @param assignees - The assignee member objects (if available) * @param reporter - The reporter member object (if available) * @param parentTask - The parent task object (if available) * @param subtasks - Array of subtasks (if available) @@ -100,7 +100,7 @@ export function formatTaskDetail( status?: TaskStatus, taskType?: TaskType, sprint?: Sprint, - assignee?: ProjectMember, + assignees?: ProjectMember[], reporter?: ProjectMember, parentTask?: Task, subtasks?: Task[], @@ -141,10 +141,14 @@ export function formatTaskDetail( ); } - if (assignee || task.assignee_id) { - sections.push( - `**Assignee:** ${assignee ? `${assignee.full_name || assignee.username} (@${assignee.username})` : task.assignee_id || "Unassigned"}`, - ); + if ((assignees && assignees.length > 0) || (task.assignee_ids && task.assignee_ids.length > 0)) { + const label = + assignees && assignees.length > 0 + ? assignees + .map((a) => `${a.full_name || a.username} (@${a.username})`) + .join(", ") + : (task.assignee_ids ?? []).join(", ") || "Unassigned"; + sections.push(`**Assignees:** ${label}`); } if (reporter || task.reporter_id) { @@ -200,7 +204,7 @@ export function formatTaskDetail( sections.push("## Subtasks"); subtasks.forEach((subtask, index) => { sections.push( - `${index + 1}. **${subtask.title}** (#${subtask.task_number}) - Status ID: ${subtask.status_id || "None"}, Type ID: ${subtask.task_type_id || "None"}, Assignee ID: ${subtask.assignee_id || "Unassigned"}`, + `${index + 1}. **${subtask.title}** (#${subtask.task_number}) - Status ID: ${subtask.status_id || "None"}, Type ID: ${subtask.task_type_id || "None"}, Assignee IDs: ${subtask.assignee_ids && subtask.assignee_ids.length > 0 ? subtask.assignee_ids.join(", ") : "Unassigned"}`, ); }); } diff --git a/apps/web/src/components/projects/interactions/board-view.test.tsx b/apps/web/src/components/projects/interactions/board-view.test.tsx index 3b899454..abfc49da 100644 --- a/apps/web/src/components/projects/interactions/board-view.test.tsx +++ b/apps/web/src/components/projects/interactions/board-view.test.tsx @@ -61,7 +61,7 @@ const makeTask = (overrides: Partial = {}): Task => ({ parent_task_id: null, description: null, importance: 0, - assignee_id: null, + assignee_ids: [], reporter_id: null, custom_fields: {}, view_position: null, diff --git a/apps/web/src/components/projects/interactions/task-card.test.tsx b/apps/web/src/components/projects/interactions/task-card.test.tsx index 37dd2f92..7d2ce84a 100644 --- a/apps/web/src/components/projects/interactions/task-card.test.tsx +++ b/apps/web/src/components/projects/interactions/task-card.test.tsx @@ -17,7 +17,7 @@ const makeTask = (overrides: Partial = {}): Task => ({ parent_task_id: null, description: null, importance: 0, - assignee_id: null, + assignee_ids: [], reporter_id: null, custom_fields: {}, view_position: null, @@ -166,13 +166,13 @@ describe("TaskCard", () => { it("shows the assignee icon when task has an assignee", () => { const { container } = render( , ); - // assigned state: filled avatar circle with ring-primary/20 - const assigneeEl = container.querySelector(".ring-primary\\/20"); + // assigned state: filled avatar circle with the primary gradient + const assigneeEl = container.querySelector(".from-primary\\/20"); expect(assigneeEl).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/projects/interactions/task-card.tsx b/apps/web/src/components/projects/interactions/task-card.tsx index ca2984e3..2e82e9b1 100644 --- a/apps/web/src/components/projects/interactions/task-card.tsx +++ b/apps/web/src/components/projects/interactions/task-card.tsx @@ -74,33 +74,54 @@ export function TaskCard({ const { t } = useTranslation("projects"); const [typePopoverOpen, setTypePopoverOpen] = useState(false); const taskType = taskTypes.find((t) => t.id === task.task_type_id); - const assignee = task.assignee_id - ? members.find((m) => m.id === task.assignee_id) - : undefined; const status = statuses.find((s) => s.id === task.status_id); /** Renders the chip/indicator for a single field key. */ const renderField = (fieldKey: string) => { switch (fieldKey) { - case "assignee": + case "assignee": { + const assigneeIds = task.assignee_ids ?? []; + const visible = assigneeIds.slice(0, 3); + const overflow = assigneeIds.length - visible.length; + const avatarStack = ( +
+ {visible.length > 0 ? ( + visible.map((id) => { + const m = members.find((mm) => mm.id === id); + return ( +
+ {m ? ( + (m.full_name || m.username).slice(0, 1).toUpperCase() + ) : ( + + )} +
+ ); + }) + ) : ( +
+ +
+ )} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+ ); + return canEdit && members.length > 0 ? ( e.stopPropagation()} - className="nodrag flex size-5 items-center justify-center rounded-full transition-all duration-150 hover:ring-2 hover:ring-primary/30" + className="nodrag flex items-center rounded-full transition-all duration-150 hover:ring-2 hover:ring-primary/30" > - {assignee ? ( -
- {(assignee.full_name || assignee.username) - .slice(0, 1) - .toUpperCase()} -
- ) : ( -
- -
- )} + {avatarStack}
{ e.stopPropagation(); - onUpdate?.(task.id, { assignee_id: null }); + onUpdate?.(task.id, { assignee_ids: [] }); }} > {t("board.taskCard.unassigned")} - {!assignee && } + {assigneeIds.length === 0 && ( + + )} - {members.map((m) => ( - - ))} + {members.map((m) => { + const isSelected = task.assignee_ids?.includes(m.id) ?? false; + return ( + + ); + })}
) : ( -
- {assignee ? ( - (assignee.full_name || assignee.username) - .slice(0, 1) - .toUpperCase() - ) : ( - - )} -
+
{avatarStack}
); + } case "type": return canEdit && taskTypes.length > 0 ? ( diff --git a/apps/web/src/components/projects/interactions/task-detail-panel.tsx b/apps/web/src/components/projects/interactions/task-detail-panel.tsx index 4c1660c7..520e7080 100644 --- a/apps/web/src/components/projects/interactions/task-detail-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail-panel.tsx @@ -112,7 +112,7 @@ export function TaskDetailPanel({ {t("taskDetail.panel.assignee")} - {task.assignee_id ? ( + {task.assignee_ids && task.assignee_ids.length > 0 ? (
diff --git a/apps/web/src/components/projects/interactions/task-detail/activity-item.tsx b/apps/web/src/components/projects/interactions/task-detail/activity-item.tsx index 3172a126..800941c3 100644 --- a/apps/web/src/components/projects/interactions/task-detail/activity-item.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/activity-item.tsx @@ -66,14 +66,19 @@ export function describeTaskChange( return t("taskDetail.activity.changedPriority", { oldVal, newVal }); return t("taskDetail.activity.setPriority", { newVal }); case "assignee": { - const oldName = resolveMember(change.old); - const newName = resolveMember(change.new); - if (hasOld && hasNew) + const oldIds = Array.isArray(change.old) ? change.old : []; + const newIds = Array.isArray(change.new) ? change.new : []; + const resolveMembers = (ids: unknown[]) => + ids.map((id) => resolveMember(id)).join(", "); + const oldName = resolveMembers(oldIds); + const newName = resolveMembers(newIds); + if (oldIds.length > 0 && newIds.length > 0) return t("taskDetail.activity.changedAssignee", { oldName, newName, }); - if (hasNew) return t("taskDetail.activity.assignedTo", { newName }); + if (newIds.length > 0) + return t("taskDetail.activity.assignedTo", { newName }); return t("taskDetail.activity.removedAssignee", { oldName }); } case "reporter": { diff --git a/apps/web/src/components/projects/interactions/task-detail/activity-pane.tsx b/apps/web/src/components/projects/interactions/task-detail/activity-pane.tsx index cf1372e8..593d6e03 100644 --- a/apps/web/src/components/projects/interactions/task-detail/activity-pane.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/activity-pane.tsx @@ -193,8 +193,9 @@ export function TaskActivityPane({ if (typeof oldVal === "number") payload.importance = oldVal; break; case "assignee": - payload.assignee_id = - typeof oldVal === "string" && oldVal ? oldVal : null; + payload.assignee_ids = Array.isArray(oldVal) + ? oldVal.filter((v): v is string => typeof v === "string") + : []; break; case "reporter": payload.reporter_id = diff --git a/apps/web/src/components/projects/interactions/task-detail/index.tsx b/apps/web/src/components/projects/interactions/task-detail/index.tsx index a5fdc9dc..cec921e5 100644 --- a/apps/web/src/components/projects/interactions/task-detail/index.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/index.tsx @@ -103,7 +103,7 @@ export function TaskDetailModal({ const status = statuses.find((s) => s.id === task?.status_id); const taskType = taskTypes.find((t) => t.id === task?.task_type_id); const priority = getPriority(task?.importance ?? 0); - const assignee = members.find((m) => m.id === task?.assignee_id); + const assignees = members.filter((m) => task?.assignee_ids?.includes(m.id)); const reporter = members.find((m) => m.id === task?.reporter_id); // ── Task role detection ──────────────────────────────────────────────────── @@ -170,6 +170,14 @@ export function TaskDetailModal({ if (aStr !== bStr) { filtered.tags = a; } + } else if (key === "assignee_ids") { + const a = newValue as string[] | undefined; + const b = oldValue as string[] | undefined; + const aStr = a ? [...a].sort().join(",") : ""; + const bStr = b ? [...b].sort().join(",") : ""; + if (aStr !== bStr) { + filtered.assignee_ids = a; + } } else if (key === "custom_fields") { const a = newValue as Record | undefined; const b = oldValue as Record | undefined; @@ -359,7 +367,7 @@ export function TaskDetailModal({ status={status} taskType={taskType} priority={priority} - assignee={assignee} + assignees={assignees} reporter={reporter} statuses={statuses} taskTypes={taskTypes} diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx index 0f9c8b7c..b6888389 100644 --- a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx @@ -41,7 +41,7 @@ import type { CustomFieldDef } from "./types"; type UpdatePayload = Partial<{ status_id: string | null; task_type_id: string | null; - assignee_id: string | null; + assignee_ids: string[]; reporter_id: string | null; importance: number; story_points: number | null; @@ -58,7 +58,7 @@ interface PropertiesPanelProps { status: TaskStatus | undefined; taskType: TaskType | undefined; priority: PriorityMeta; - assignee: ProjectMember | undefined; + assignees: ProjectMember[]; reporter: ProjectMember | undefined; statuses?: TaskStatus[]; taskTypes?: TaskType[]; @@ -90,7 +90,7 @@ export function PropertiesPanel({ task, status, taskType, - assignee, + assignees, reporter, statuses = [], taskTypes = [], @@ -136,7 +136,7 @@ export function PropertiesPanel({ ]; const memberUserOptions: UserOption[] = members.map(toUserOption); - const assigneeUserOption = assignee ? toUserOption(assignee) : null; + const assigneeUserOptions: UserOption[] = assignees.map(toUserOption); const reporterUserOption = reporter ? toUserOption(reporter) : null; return ( @@ -196,12 +196,11 @@ export function PropertiesPanel({ onUpdate?.({ assignee_id: v })} + onUsersChange={(v) => onUpdate?.({ assignee_ids: v })} canEdit={canEdit && members.length > 0} - showUnassigned /> diff --git a/apps/web/src/components/projects/interactions/task-detail/property-field/index.tsx b/apps/web/src/components/projects/interactions/task-detail/property-field/index.tsx index 1853299e..ac5b4392 100644 --- a/apps/web/src/components/projects/interactions/task-detail/property-field/index.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/property-field/index.tsx @@ -4,6 +4,7 @@ import { FieldRow, FieldValue } from "../primitives"; import { CheckboxEditor } from "./checkbox-editor"; import { CustomFieldEditor } from "./custom-field-editor"; import { DateRangeEditor, SingleDateEditor } from "./date-editor"; +import { MultiUserEditor } from "./multi-user-editor"; import { NumberEditor } from "./number-editor"; import { SelectEditor } from "./select-editor"; import { TagsEditor } from "./tags-editor"; @@ -43,6 +44,8 @@ export function PropertyField({ users = [], onUserChange, showUnassigned = true, + userValues, + onUsersChange, tags = [], onTagsChange, displayValue, @@ -152,6 +155,16 @@ export function PropertyField({ /> ); + case "multi-user": + return ( + + ); + case "tags": return ( diff --git a/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx b/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx new file mode 100644 index 00000000..a3d3687b --- /dev/null +++ b/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx @@ -0,0 +1,101 @@ +import { Check } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { FieldValue } from "../primitives"; +import { ChipField } from "./chip-field"; +import type { UserOption } from "./types"; + +function UserAvatar({ initials }: { initials: string }) { + return ( +
+ {initials} +
+ ); +} + +function UserListButton({ + user, + isSelected, + onClick, +}: { + user: UserOption; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function MultiUserEditor({ + userValues = [], + users = [], + canEdit, + onChange, +}: { + userValues?: UserOption[]; + users?: UserOption[]; + canEdit: boolean; + onChange?: (values: string[]) => void; +}) { + const { t } = useTranslation("projects"); + const selectedIds = userValues.map((u) => u.value); + + function toggle(userId: string) { + onChange?.( + selectedIds.includes(userId) + ? selectedIds.filter((v) => v !== userId) + : [...selectedIds, userId], + ); + } + + if (!canEdit) { + if (userValues.length === 0) return ; + return ( +
+ {userValues.map((u) => ( + + + {u.label} + + ))} +
+ ); + } + + return ( + ({ + key: u.value, + label: ( + + + {u.label} + + ), + }))} + onRemoveChip={toggle} + canEdit={canEdit} + addLabel={t("taskDetail.propertyField.multiSelectEditor.addOption")} + > + {users.map((u) => ( + toggle(u.value)} + /> + ))} + + ); +} diff --git a/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts b/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts index 7b5d056a..3f694de5 100644 --- a/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts +++ b/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts @@ -9,6 +9,7 @@ export type PropertyFieldMode = | "text" | "checkbox" | "user" + | "multi-user" | "tags" | "readonly" | "link" @@ -58,6 +59,9 @@ export interface PropertyFieldProps { onUserChange?: (value: string | null) => void; showUnassigned?: boolean; + userValues?: UserOption[]; + onUsersChange?: (values: string[]) => void; + tags?: string[]; onTagsChange?: (tags: string[]) => void; diff --git a/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx b/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx index a25b83ef..ff5c7745 100644 --- a/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx @@ -24,7 +24,7 @@ import { TaskTypeSelector } from "../task-type-selector"; type SubtaskUpdatePayload = Partial<{ status_id: string | null; task_type_id: string | null; - assignee_id: string | null; + assignee_ids: string[]; importance: number; }>; @@ -54,7 +54,6 @@ export function SubtaskRow({ }: SubtaskRowProps) { const { t } = useTranslation("projects"); const status = statuses.find((s) => s.id === task.status_id); - const assignee = members.find((m) => m.id === task.assignee_id); const priority = getPriority(task.importance ?? 0); const canEditField = !!(canEdit && onUpdate); @@ -109,82 +108,100 @@ export function SubtaskRow({ onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > - {canEditField && members.length > 0 ? ( - - -
- {assignee ? ( - (assignee.full_name || assignee.username) - .slice(0, 1) - .toUpperCase() - ) : ( + {(() => { + const assigneeIds = task.assignee_ids ?? []; + const visible = assigneeIds.slice(0, 3); + const overflow = assigneeIds.length - visible.length; + const avatarStack = ( +
+ {visible.length > 0 ? ( + visible.map((id) => { + const m = members.find((mm) => mm.id === id); + return ( +
+ {m ? ( + (m.full_name || m.username).slice(0, 1).toUpperCase() + ) : ( + + )} +
+ ); + }) + ) : ( +
- )} -
- - -
+ ); + + return canEditField && members.length > 0 ? ( + + onUpdate?.(task.id, { assignee_id: null })} + className="flex items-center rounded-full hover:ring-2 hover:ring-primary/30 transition-all duration-150" + > + {avatarStack} + + - - - {t("taskDetail.common.unassigned")} - - {!assignee && } - - {members.map((m) => ( - ))} - - - ) : ( -
- {assignee ? ( - (assignee.full_name || assignee.username) - .slice(0, 1) - .toUpperCase() - ) : ( - - )} -
- )} + {members.map((m) => { + const isSelected = task.assignee_ids?.includes(m.id) ?? false; + return ( + + ); + })} + + + ) : ( + avatarStack + ); + })()}
{/* Priority */} diff --git a/apps/web/src/components/projects/interactions/task-detail/subtasks-section.tsx b/apps/web/src/components/projects/interactions/task-detail/subtasks-section.tsx index 886a53d6..002d3acb 100644 --- a/apps/web/src/components/projects/interactions/task-detail/subtasks-section.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/subtasks-section.tsx @@ -22,7 +22,7 @@ interface SubtasksSectionProps { payload: Partial<{ status_id: string | null; task_type_id: string | null; - assignee_id: string | null; + assignee_ids: string[]; importance: number; }>, ) => void; diff --git a/apps/web/src/components/projects/interactions/task-row.tsx b/apps/web/src/components/projects/interactions/task-row.tsx index 7fabad30..99b91f5b 100644 --- a/apps/web/src/components/projects/interactions/task-row.tsx +++ b/apps/web/src/components/projects/interactions/task-row.tsx @@ -349,9 +349,40 @@ export function TaskRow({ ); case "assignee": { - const assignee = task.assignee_id - ? members.find((m) => m.id === task.assignee_id) - : undefined; + const assigneeIds = task.assignee_ids ?? []; + const visible = assigneeIds.slice(0, 3); + const overflow = assigneeIds.length - visible.length; + const avatarStack = ( +
+ {visible.length > 0 ? ( + visible.map((id) => { + const m = members.find((mm) => mm.id === id); + return ( +
+ {m ? ( + (m.full_name || m.username).slice(0, 1).toUpperCase() + ) : ( + + )} +
+ ); + }) + ) : ( +
+ +
+ )} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +
+ ); + return canEditField && members.length > 0 ? ( // biome-ignore lint/a11y/noStaticElementInteractions: cell container stops propagation; inner controls are the interactive elements
-
- {assignee ? ( - (assignee.full_name || assignee.username) - .slice(0, 1) - .toUpperCase() - ) : ( - - )} -
+ {avatarStack}
- onUpdateTaskField(task.id, { assignee_id: null }) + onUpdateTaskField(task.id, { assignee_ids: [] }) } > {t("board.taskRow.unassigned")} - {!assignee && } + {assigneeIds.length === 0 && ( + + )} - {members.map((m) => ( - - ))} + {members.map((m) => { + const isSelected = task.assignee_ids?.includes(m.id) ?? false; + return ( + + ); + })}
@@ -427,22 +453,7 @@ export function TaskRow({ key="assignee" className={cn(col.className, "flex items-center justify-center")} > -
- {assignee ? ( - (assignee.full_name || assignee.username) - .slice(0, 1) - .toUpperCase() - ) : ( - - )} -
+ {avatarStack}
); } diff --git a/apps/web/src/components/projects/interactions/view-utils.ts b/apps/web/src/components/projects/interactions/view-utils.ts index ba95e0b1..f8badc9a 100644 --- a/apps/web/src/components/projects/interactions/view-utils.ts +++ b/apps/web/src/components/projects/interactions/view-utils.ts @@ -209,7 +209,7 @@ export function getTaskColumnKeys( return [task.sprint_id ?? "__backlog"]; } if (columnBy === "assignee") { - return [task.assignee_id ?? "__unassigned"]; + return [task.assignee_ids?.[0] ?? "__unassigned"]; } if (columnBy === "reporter") { return [task.reporter_id ?? "__none"]; @@ -442,7 +442,7 @@ export function buildAllFieldOptions( export type TaskFieldUpdate = Partial<{ status_id: string | null; - assignee_id: string | null; + assignee_ids: string[]; importance: number; story_points: number | null; task_type_id: string | null; @@ -467,7 +467,9 @@ export function buildColumnDropUpdate( return { sprint_id: (columnFieldValue as string | null) ?? null }; } if (columnBy === "assignee") { - return { assignee_id: (columnFieldValue as string | null) ?? null }; + return { + assignee_ids: columnFieldValue ? [columnFieldValue as string] : [], + }; } if (columnBy === "importance") { return { importance: Number(columnFieldValue) || 0 }; diff --git a/apps/web/src/lib/interaction-api.test.ts b/apps/web/src/lib/interaction-api.test.ts index c1d7eb8e..e56a852a 100644 --- a/apps/web/src/lib/interaction-api.test.ts +++ b/apps/web/src/lib/interaction-api.test.ts @@ -47,7 +47,7 @@ const makeTask = (overrides: Partial = {}): Task => ({ parent_task_id: null, description: null, importance: 0, - assignee_id: null, + assignee_ids: [], reporter_id: null, custom_fields: {}, view_position: null, diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index 16c86350..a67ab239 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -35,7 +35,7 @@ export interface Task { description?: unknown[] | null; importance: number; story_points?: number | null; - assignee_id?: string | null; + assignee_ids?: string[]; reporter_id?: string | null; custom_fields: Record; start_date?: string | null; @@ -497,7 +497,7 @@ export async function createTask( status_id?: string | null; sprint_id?: string | null; task_type_id?: string | null; - assignee_id?: string | null; + assignee_ids?: string[]; parent_task_id?: string | null; }, ): Promise { @@ -526,7 +526,7 @@ export async function updateTask( status_id: string | null; sprint_id: string | null; task_type_id: string | null; - assignee_id: string | null; + assignee_ids: string[]; reporter_id: string | null; parent_task_id: string | null; description: unknown[] | null; diff --git a/docs/ai-agent/overview.md b/docs/ai-agent/overview.md index 5bc3a1f6..075ca031 100644 --- a/docs/ai-agent/overview.md +++ b/docs/ai-agent/overview.md @@ -84,7 +84,7 @@ Paca AI Agents are first-class project members powered by the [OpenHands Softwar ### 1. Task Assignment -When a task's `assignee_id` points to a `project_members` row with `member_type = 'agent'`, the API publishes an `agent.task.assigned` event to the Valkey Stream containing: +A task can have multiple assignees (stored in the `task_assignees` join table). When one of a task's assignees points to a `project_members` row with `member_type = 'agent'`, the API publishes an `agent.task.assigned` event to the Valkey Stream — one event per newly-added agent assignee — containing: ```json { diff --git a/docs/api/http-design.md b/docs/api/http-design.md index dbeba503..3bdceae8 100644 --- a/docs/api/http-design.md +++ b/docs/api/http-design.md @@ -1052,7 +1052,7 @@ Creates a new task in a project. The `description` field is a **JSON array of Bl "sprint_id": "uuid-or-null", "status_id": "uuid-or-null", "task_type_id": "uuid-or-null", - "assignee_id": "uuid-or-null", + "assignee_ids": ["uuid"], "tags": ["backend", "auth"] } ``` @@ -1086,7 +1086,7 @@ Success response: `200 OK` "sprint_id": null, "status_id": null, "task_type_id": null, - "assignee_id": null, + "assignee_ids": [], "reporter_id": null, "tags": [], "custom_fields": {}, diff --git a/docs/architecture/database-schema.md b/docs/architecture/database-schema.md index c722a6da..5616c7b8 100644 --- a/docs/architecture/database-schema.md +++ b/docs/architecture/database-schema.md @@ -117,7 +117,6 @@ Table tasks { description jsonb [null, note: 'BlockNote rich-text document stored as a JSON array of block objects. null means no description. Each block object follows the BlockNote schema: { id, type, props, content, children }.'] importance integer [not null, default: 0, note: 'unsigned; higher = more important'] story_points integer [null, note: 'Story point estimate; must be >= 0 if set'] - assignee_id uuid [ref: > project_members.id] reporter_id uuid [ref: > project_members.id] custom_fields jsonb [not null, default: '{}'] start_date date [null] @@ -231,6 +230,19 @@ Table task_attachments { } } +// A task can have multiple assignees; task_assignees is the join table. +// member_id cascades on delete, so removing a member drops just their one +// join row rather than clearing the whole task. +Table task_assignees { + task_id uuid [not null, ref: > tasks.id] + member_id uuid [not null, ref: > project_members.id] + assigned_at timestamp [not null] + + indexes { + (task_id, member_id) [pk] + } +} + // --- DOCUMENTATION --- Table doc_folders { id uuid [primary key] diff --git a/docs/plugins/sdk-reference.md b/docs/plugins/sdk-reference.md index 62a4d08d..f5a70608 100644 --- a/docs/plugins/sdk-reference.md +++ b/docs/plugins/sdk-reference.md @@ -221,7 +221,7 @@ interface TaskSummary { title: string; task_number: number; // snake_case matching the API JSON status_id: string | null; - assignee_id: string | null; + assignee_ids: string[]; } interface Task extends TaskSummary { diff --git a/services/api/internal/domain/task/entity.go b/services/api/internal/domain/task/entity.go index 9e59ad5d..83994443 100644 --- a/services/api/internal/domain/task/entity.go +++ b/services/api/internal/domain/task/entity.go @@ -157,7 +157,7 @@ type Task struct { Description json.RawMessage Importance int StoryPoints *int - AssigneeID *uuid.UUID + AssigneeIDs []uuid.UUID ReporterID *uuid.UUID CustomFields map[string]any StartDate *time.Time diff --git a/services/api/internal/domain/task/repository.go b/services/api/internal/domain/task/repository.go index 93d5ec1e..d20ab0c6 100644 --- a/services/api/internal/domain/task/repository.go +++ b/services/api/internal/domain/task/repository.go @@ -103,7 +103,7 @@ type TaskFilter struct { StatusIDs []uuid.UUID // multi-value; takes priority over StatusID AssigneeID *uuid.UUID // single-value compat; ignored when AssigneeIDs is non-empty AssigneeIDs []uuid.UUID // multi-value; takes priority over AssigneeID - AssigneeNull bool // true → only tasks where assignee_id IS NULL + AssigneeNull bool // true → only tasks with no rows in task_assignees ParentTaskID *uuid.UUID // non-nil → only subtasks of this parent TaskTypeIDs []uuid.UUID // multi-value; when non-empty, only tasks of these types TaskTypeNull bool // true → only tasks where task_type_id IS NULL diff --git a/services/api/internal/domain/task/service.go b/services/api/internal/domain/task/service.go index 65bc1ccd..7fb39cbb 100644 --- a/services/api/internal/domain/task/service.go +++ b/services/api/internal/domain/task/service.go @@ -140,7 +140,7 @@ type CreateTaskInput struct { Description json.RawMessage Importance int StoryPoints *int - AssigneeID *uuid.UUID + AssigneeIDs []uuid.UUID ReporterID *uuid.UUID CustomFields map[string]any StartDate *time.Time @@ -155,9 +155,10 @@ type CreateTaskInput struct { // - non-nil outer pointer, inner pointer nil → explicitly set to null (clear) // - non-nil outer pointer, inner pointer non-nil → set to the given value // -// For slice/map fields (Tags, CustomFields), a nil pointer means the field was -// absent and should not be overwritten; a non-nil pointer (even to an empty -// slice/map) means the field was explicitly set and replaces the stored value. +// For slice/map fields (Tags, CustomFields, AssigneeIDs), a nil pointer means +// the field was absent and should not be overwritten; a non-nil pointer (even +// to an empty slice/map) means the field was explicitly set and replaces the +// stored value in full. type UpdateTaskInput struct { TaskTypeID **uuid.UUID StatusID **uuid.UUID @@ -167,7 +168,7 @@ type UpdateTaskInput struct { Description *json.RawMessage Importance *int StoryPoints **int - AssigneeID **uuid.UUID + AssigneeIDs *[]uuid.UUID ReporterID **uuid.UUID CustomFields *map[string]any StartDate **time.Time diff --git a/services/api/internal/platform/plugin/runtime.go b/services/api/internal/platform/plugin/runtime.go index 4e0bcb55..b2be1879 100644 --- a/services/api/internal/platform/plugin/runtime.go +++ b/services/api/internal/platform/plugin/runtime.go @@ -704,6 +704,36 @@ func (r *Runtime) execStatement(ctx context.Context, schema, sqlStr, paramsJSON // PLUG-BE-05: Core read-only functions // ------------------------------------------------------------------------- +// loadTaskAssigneeIDs batch-loads task_assignees for taskIDs, returning a map +// keyed by task ID (tasks with no assignees are simply absent from the map). +func (r *Runtime) loadTaskAssigneeIDs(ctx context.Context, taskIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) { + result := make(map[uuid.UUID][]uuid.UUID, len(taskIDs)) + if len(taskIDs) == 0 { + return result, nil + } + placeholders := make([]string, len(taskIDs)) + args := make([]any, len(taskIDs)) + for i, id := range taskIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + rows, err := r.services.DB.QueryContext(ctx, + `SELECT task_id, member_id FROM task_assignees WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY assigned_at ASC`, + args...) + if err != nil { + return nil, fmt.Errorf("load task assignees: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var taskID, memberID uuid.UUID + if err := rows.Scan(&taskID, &memberID); err != nil { + return nil, fmt.Errorf("load task assignees: scan: %w", err) + } + result[taskID] = append(result[taskID], memberID) + } + return result, rows.Err() +} + // registerCoreFunctions adds paca.tasks_list, paca.task_get, // paca.project_get, paca.members_list to the host module builder. // All results are scoped to the authorised project extracted from the @@ -720,28 +750,51 @@ func (r *Runtime) registerCoreFunctions(b wazero.HostModuleBuilder, _ plugindom. } rows, err := r.services.DB.QueryContext(ctx, - `SELECT id, title, status_id, assignee_id, task_number FROM tasks + `SELECT id, title, status_id, task_number FROM tasks WHERE project_id = $1 AND deleted_at IS NULL ORDER BY task_number DESC LIMIT 100`, projectID) if err != nil { copy(stack, writeErrorResult(m, err)) return } - defer func() { _ = rows.Close() }() - var tasks []map[string]any + type taskRow struct { + id uuid.UUID + title string + statusID *uuid.UUID + num int + } + var taskRows []taskRow for rows.Next() { - var id uuid.UUID - var title string - var statusID, assigneeID *uuid.UUID - var num int - if err := rows.Scan(&id, &title, &statusID, &assigneeID, &num); err != nil { + var tr taskRow + if err := rows.Scan(&tr.id, &tr.title, &tr.statusID, &tr.num); err != nil { + _ = rows.Close() copy(stack, writeErrorResult(m, err)) return } + taskRows = append(taskRows, tr) + } + _ = rows.Close() + if err := rows.Err(); err != nil { + copy(stack, writeErrorResult(m, err)) + return + } + + taskIDs := make([]uuid.UUID, len(taskRows)) + for i, tr := range taskRows { + taskIDs[i] = tr.id + } + assigneeIDs, err := r.loadTaskAssigneeIDs(ctx, taskIDs) + if err != nil { + copy(stack, writeErrorResult(m, err)) + return + } + + var tasks []map[string]any + for _, tr := range taskRows { tasks = append(tasks, map[string]any{ - "id": id, "title": title, "status_id": statusID, - "assignee_id": assigneeID, "task_number": num, + "id": tr.id, "title": tr.title, "status_id": tr.statusID, + "assignee_ids": assigneeIDs[tr.id], "task_number": tr.num, }) } copy(stack, writeJSONResult(m, tasks)) @@ -760,21 +813,26 @@ func (r *Runtime) registerCoreFunctions(b wazero.HostModuleBuilder, _ plugindom. } row := r.services.DB.QueryRowContext(ctx, - `SELECT id, project_id, title, status_id, assignee_id, task_number + `SELECT id, project_id, title, status_id, task_number FROM tasks WHERE id = $1 AND deleted_at IS NULL`, taskID) var ( - id, projectID uuid.UUID - title string - statusID, assigneeID *uuid.UUID - num int + id, projectID uuid.UUID + title string + statusID *uuid.UUID + num int ) - if err := row.Scan(&id, &projectID, &title, &statusID, &assigneeID, &num); err != nil { + if err := row.Scan(&id, &projectID, &title, &statusID, &num); err != nil { + copy(stack, writeErrorResult(m, err)) + return + } + assigneeIDs, err := r.loadTaskAssigneeIDs(ctx, []uuid.UUID{id}) + if err != nil { copy(stack, writeErrorResult(m, err)) return } copy(stack, writeJSONResult(m, map[string]any{ "id": id, "project_id": projectID, "title": title, - "status_id": statusID, "assignee_id": assigneeID, "task_number": num, + "status_id": statusID, "assignee_ids": assigneeIDs[id], "task_number": num, })) }), []api.ValueType{api.ValueTypeI64, api.ValueTypeI64}, []api.ValueType{api.ValueTypeI64, api.ValueTypeI64}). diff --git a/services/api/internal/repository/postgres/task_repository.go b/services/api/internal/repository/postgres/task_repository.go index bbf3218d..9d629b39 100644 --- a/services/api/internal/repository/postgres/task_repository.go +++ b/services/api/internal/repository/postgres/task_repository.go @@ -54,7 +54,6 @@ type taskRecord struct { Description *json.RawMessage `db:"description"` Importance int `db:"importance"` StoryPoints *int `db:"story_points"` - AssigneeID *string `db:"assignee_id"` ReporterID *string `db:"reporter_id"` CustomFields []byte `db:"custom_fields"` StartDate *time.Time `db:"start_date"` @@ -85,7 +84,6 @@ type taskWithPositionRow struct { Description *json.RawMessage `db:"description"` Importance int `db:"importance"` StoryPoints *int `db:"story_points"` - AssigneeID *string `db:"assignee_id"` ReporterID *string `db:"reporter_id"` CustomFields []byte `db:"custom_fields"` StartDate *time.Time `db:"start_date"` @@ -110,7 +108,6 @@ func (r *taskWithPositionRow) asTaskRecord() taskRecord { Description: r.Description, Importance: r.Importance, StoryPoints: r.StoryPoints, - AssigneeID: r.AssigneeID, ReporterID: r.ReporterID, CustomFields: r.CustomFields, StartDate: r.StartDate, @@ -426,7 +423,7 @@ func (r *TaskRepository) FindDefaultTaskStatus(ctx context.Context, projectID uu // --- Tasks ------------------------------------------------------------------ const taskCols = `id, project_id, task_number, task_type_id, status_id, sprint_id, parent_task_id, - title, description, importance, story_points, assignee_id, reporter_id, + title, description, importance, story_points, reporter_id, custom_fields, start_date, due_date, tags, created_at, updated_at, deleted_at` // applyTaskFilter adds WHERE predicates for all TaskFilter fields. @@ -457,21 +454,28 @@ func applyTaskFilter(b *queryBuilder, filter taskdom.TaskFilter) { switch { case filter.AssigneeNull && len(filter.AssigneeIDs) > 0: - // Build: assignee_id IS NULL OR assignee_id IN (...) + // Build: NOT EXISTS(any assignee) OR EXISTS(assignee IN (...)) placeholders := make([]string, len(filter.AssigneeIDs)) for i, id := range filter.AssigneeIDs { placeholders[i] = fmt.Sprintf("$%d", b.idx) b.args = append(b.args, id.String()) b.idx++ } - b.whereClauses = append(b.whereClauses, "(assignee_id IS NULL OR assignee_id IN ("+strings.Join(placeholders, ",")+"))") + b.whereClauses = append(b.whereClauses, "(NOT EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = tasks.id) "+ + "OR EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = tasks.id AND ta.member_id IN ("+strings.Join(placeholders, ",")+")))") case filter.AssigneeNull: - b.whereClauses = append(b.whereClauses, "assignee_id IS NULL") + b.whereClauses = append(b.whereClauses, "NOT EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = tasks.id)") case len(filter.AssigneeIDs) > 0: - b.addInClause("assignee_id", uuidSliceToStrSlice(filter.AssigneeIDs)) + placeholders := make([]string, len(filter.AssigneeIDs)) + for i, id := range filter.AssigneeIDs { + placeholders[i] = fmt.Sprintf("$%d", b.idx) + b.args = append(b.args, id.String()) + b.idx++ + } + b.whereClauses = append(b.whereClauses, "EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = tasks.id AND ta.member_id IN ("+strings.Join(placeholders, ",")+"))") case filter.AssigneeID != nil: p := b.placeholder() - b.whereClauses = append(b.whereClauses, "assignee_id = "+p) + b.whereClauses = append(b.whereClauses, "EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = tasks.id AND ta.member_id = "+p+")") b.args = append(b.args, filter.AssigneeID.String()) } @@ -840,6 +844,9 @@ func (r *TaskRepository) ListTasks(ctx context.Context, projectID uuid.UUID, fil t.ViewPosition = rows[i].VTPPosition tasks = append(tasks, t) } + if err := r.attachAssigneeIDs(ctx, tasks); err != nil { + return nil, false, err + } return tasks, hasMore, nil } @@ -861,6 +868,9 @@ func (r *TaskRepository) ListTasks(ctx context.Context, projectID uuid.UUID, fil } tasks = append(tasks, t) } + if err := r.attachAssigneeIDs(ctx, tasks); err != nil { + return nil, false, err + } return tasks, hasMore, nil } @@ -926,7 +936,14 @@ func (r *TaskRepository) FindTaskByID(ctx context.Context, id uuid.UUID) (*taskd if err != nil { return nil, fmt.Errorf("task repo: find by id: %w", err) } - return toTaskEntity(&rec) + t, err := toTaskEntity(&rec) + if err != nil { + return nil, err + } + if err := r.attachAssigneeIDs(ctx, []*taskdom.Task{t}); err != nil { + return nil, err + } + return t, nil } // FindTaskByNumber returns the task with the given project-scoped task number (non-deleted). @@ -939,7 +956,14 @@ func (r *TaskRepository) FindTaskByNumber(ctx context.Context, projectID uuid.UU if err != nil { return nil, fmt.Errorf("task repo: find by number: %w", err) } - return toTaskEntity(&rec) + t, err := toTaskEntity(&rec) + if err != nil { + return nil, err + } + if err := r.attachAssigneeIDs(ctx, []*taskdom.Task{t}); err != nil { + return nil, err + } + return t, nil } // CreateTask persists a new task, assigning the next per-project task_number atomically. @@ -974,19 +998,22 @@ func (r *TaskRepository) CreateTask(ctx context.Context, t *taskdom.Task) error _, err := tx.ExecContext(ctx, ` INSERT INTO tasks (id, project_id, task_number, task_type_id, status_id, sprint_id, parent_task_id, - title, description, importance, story_points, assignee_id, reporter_id, + title, description, importance, story_points, reporter_id, custom_fields, start_date, due_date, tags, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, t.ID.String(), t.ProjectID.String(), t.TaskNumber, uuidPtrToStrPtr(t.TaskTypeID), uuidPtrToStrPtr(t.StatusID), uuidPtrToStrPtr(t.SprintID), uuidPtrToStrPtr(t.ParentTaskID), t.Title, t.Description, t.Importance, t.StoryPoints, - uuidPtrToStrPtr(t.AssigneeID), uuidPtrToStrPtr(t.ReporterID), + uuidPtrToStrPtr(t.ReporterID), cf, t.StartDate, t.DueDate, tagsJSON, t.CreatedAt, t.UpdatedAt, ) if err != nil { return fmt.Errorf("task repo: create: %w", err) } + if err := insertTaskAssignees(ctx, tx, t.ID, t.AssigneeIDs); err != nil { + return err + } return nil }) } @@ -1005,21 +1032,140 @@ func (r *TaskRepository) UpdateTask(ctx context.Context, t *taskdom.Task) error if err != nil { return fmt.Errorf("task repo: marshal tags: %w", err) } - _, err = r.db.ExecContext(ctx, ` - UPDATE tasks SET - task_type_id=$1, status_id=$2, sprint_id=$3, parent_task_id=$4, - title=$5, description=$6, importance=$7, story_points=$8, - assignee_id=$9, reporter_id=$10, custom_fields=$11, - start_date=$12, due_date=$13, tags=$14, updated_at=$15 - WHERE id=$16`, - uuidPtrToStrPtr(t.TaskTypeID), uuidPtrToStrPtr(t.StatusID), - uuidPtrToStrPtr(t.SprintID), uuidPtrToStrPtr(t.ParentTaskID), - t.Title, t.Description, t.Importance, t.StoryPoints, - uuidPtrToStrPtr(t.AssigneeID), uuidPtrToStrPtr(t.ReporterID), - cf, t.StartDate, t.DueDate, tagsJSON, t.UpdatedAt, t.ID.String(), - ) - if err != nil { - return fmt.Errorf("task repo: update: %w", err) + return WithTx(ctx, r.db, func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, ` + UPDATE tasks SET + task_type_id=$1, status_id=$2, sprint_id=$3, parent_task_id=$4, + title=$5, description=$6, importance=$7, story_points=$8, + reporter_id=$9, custom_fields=$10, + start_date=$11, due_date=$12, tags=$13, updated_at=$14 + WHERE id=$15`, + uuidPtrToStrPtr(t.TaskTypeID), uuidPtrToStrPtr(t.StatusID), + uuidPtrToStrPtr(t.SprintID), uuidPtrToStrPtr(t.ParentTaskID), + t.Title, t.Description, t.Importance, t.StoryPoints, + uuidPtrToStrPtr(t.ReporterID), + cf, t.StartDate, t.DueDate, tagsJSON, t.UpdatedAt, t.ID.String(), + ) + if err != nil { + return fmt.Errorf("task repo: update: %w", err) + } + if err := syncTaskAssignees(ctx, tx, t.ID, t.AssigneeIDs); err != nil { + return err + } + return nil + }) +} + +// syncTaskAssignees reconciles task_assignees with wantIDs by removing only +// members no longer present and inserting only members newly present, +// leaving unchanged members' rows (and their assigned_at) untouched. A naive +// delete-all-then-reinsert would reset assigned_at for every assignee on +// every task update — including ones that don't touch assignees at all — +// which both loses assignment history and makes the assigned_at-ordered +// assignee list (used for e.g. "first assignee" swimlane grouping) shuffle +// nondeterministically after unrelated edits, since a bulk reinsert gives +// every row the same NOW() timestamp. +func syncTaskAssignees(ctx context.Context, tx *sqlx.Tx, taskID uuid.UUID, wantIDs []uuid.UUID) error { + var currentIDStrs []string + if err := tx.SelectContext(ctx, ¤tIDStrs, `SELECT member_id FROM task_assignees WHERE task_id=$1`, taskID.String()); err != nil { + return fmt.Errorf("task repo: update: read current assignees: %w", err) + } + current := make(map[string]struct{}, len(currentIDStrs)) + for _, id := range currentIDStrs { + current[id] = struct{}{} + } + want := make(map[string]struct{}, len(wantIDs)) + for _, id := range wantIDs { + want[id.String()] = struct{}{} + } + + var toRemove []string + for id := range current { + if _, ok := want[id]; !ok { + toRemove = append(toRemove, id) + } + } + if len(toRemove) > 0 { + placeholders := make([]string, len(toRemove)) + args := make([]interface{}, 0, len(toRemove)+1) + args = append(args, taskID.String()) + for i, id := range toRemove { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args = append(args, id) + } + query := `DELETE FROM task_assignees WHERE task_id=$1 AND member_id IN (` + strings.Join(placeholders, ",") + `)` + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("task repo: update: remove assignees: %w", err) + } + } + + var toAdd []uuid.UUID + for _, id := range wantIDs { + if _, ok := current[id.String()]; !ok { + toAdd = append(toAdd, id) + } + } + return insertTaskAssignees(ctx, tx, taskID, toAdd) +} + +// insertTaskAssignees bulk-inserts task_assignees rows for taskID. No-op when +// memberIDs is empty. ON CONFLICT DO NOTHING guards against duplicate member +// IDs in the input (the PK is (task_id, member_id)). +func insertTaskAssignees(ctx context.Context, tx *sqlx.Tx, taskID uuid.UUID, memberIDs []uuid.UUID) error { + if len(memberIDs) == 0 { + return nil + } + valuePlaceholders := make([]string, len(memberIDs)) + args := make([]interface{}, 0, len(memberIDs)*2) + for i, mid := range memberIDs { + valuePlaceholders[i] = fmt.Sprintf("($%d,$%d)", i*2+1, i*2+2) + args = append(args, taskID.String(), mid.String()) + } + query := `INSERT INTO task_assignees (task_id, member_id) VALUES ` + strings.Join(valuePlaceholders, ",") + ` ON CONFLICT DO NOTHING` + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("task repo: insert assignees: %w", err) + } + return nil +} + +// attachAssigneeIDs batch-loads task_assignees for tasks and sets each +// Task.AssigneeIDs in place, avoiding an N+1 query per task. tasks with no +// assignees get an empty (non-nil) slice. +func (r *TaskRepository) attachAssigneeIDs(ctx context.Context, tasks []*taskdom.Task) error { + if len(tasks) == 0 { + return nil + } + byID := make(map[string]*taskdom.Task, len(tasks)) + placeholders := make([]string, len(tasks)) + args := make([]interface{}, len(tasks)) + for i, t := range tasks { + t.AssigneeIDs = []uuid.UUID{} + id := t.ID.String() + byID[id] = t + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + type assigneeRow struct { + TaskID string `db:"task_id"` + MemberID string `db:"member_id"` + } + var rows []assigneeRow + query := `SELECT task_id, member_id FROM task_assignees WHERE task_id IN (` + + strings.Join(placeholders, ",") + `) ORDER BY assigned_at ASC` + if err := r.db.SelectContext(ctx, &rows, query, args...); err != nil { + return fmt.Errorf("task repo: attach assignees: %w", err) + } + for _, row := range rows { + t, ok := byID[row.TaskID] + if !ok { + continue + } + memberID, err := uuid.Parse(row.MemberID) + if err != nil { + continue + } + t.AssigneeIDs = append(t.AssigneeIDs, memberID) } return nil } @@ -1135,7 +1281,6 @@ func toTaskEntity(r *taskRecord) (*taskdom.Task, error) { Description: desc, Importance: r.Importance, StoryPoints: r.StoryPoints, - AssigneeID: strPtrToUUIDPtr(r.AssigneeID), ReporterID: strPtrToUUIDPtr(r.ReporterID), CustomFields: cf, StartDate: r.StartDate, diff --git a/services/api/internal/repository/postgres/task_repository_test.go b/services/api/internal/repository/postgres/task_repository_test.go index f2605367..37300106 100644 --- a/services/api/internal/repository/postgres/task_repository_test.go +++ b/services/api/internal/repository/postgres/task_repository_test.go @@ -24,8 +24,13 @@ func openTaskRepoTestDB(t *testing.T) *sqlx.DB { CREATE TABLE tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, - assignee_id TEXT, deleted_at DATETIME + ); + CREATE TABLE task_assignees ( + task_id TEXT NOT NULL, + member_id TEXT NOT NULL, + assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (task_id, member_id) );` if _, err := db.ExecContext(context.Background(), schema); err != nil { t.Fatalf("create schema: %v", err) @@ -47,9 +52,14 @@ func TestTaskRepository_CountTasks_AssigneeNullWithAssigneeIDs(t *testing.T) { assigneeIn := uuid.New() assigneeOut := uuid.New() - db.MustExec(`INSERT INTO tasks (id, project_id, assignee_id) VALUES ($1, $2, NULL)`, uuid.New().String(), projectID.String()) - db.MustExec(`INSERT INTO tasks (id, project_id, assignee_id) VALUES ($1, $2, $3)`, uuid.New().String(), projectID.String(), assigneeIn.String()) - db.MustExec(`INSERT INTO tasks (id, project_id, assignee_id) VALUES ($1, $2, $3)`, uuid.New().String(), projectID.String(), assigneeOut.String()) + taskUnassigned := uuid.New().String() + taskIn := uuid.New().String() + taskOut := uuid.New().String() + db.MustExec(`INSERT INTO tasks (id, project_id) VALUES ($1, $2)`, taskUnassigned, projectID.String()) + db.MustExec(`INSERT INTO tasks (id, project_id) VALUES ($1, $2)`, taskIn, projectID.String()) + db.MustExec(`INSERT INTO tasks (id, project_id) VALUES ($1, $2)`, taskOut, projectID.String()) + db.MustExec(`INSERT INTO task_assignees (task_id, member_id) VALUES ($1, $2)`, taskIn, assigneeIn.String()) + db.MustExec(`INSERT INTO task_assignees (task_id, member_id) VALUES ($1, $2)`, taskOut, assigneeOut.String()) filter := taskdom.TaskFilter{ AssigneeNull: true, @@ -64,3 +74,91 @@ func TestTaskRepository_CountTasks_AssigneeNullWithAssigneeIDs(t *testing.T) { t.Fatalf("expected 2 tasks (unassigned + matching assignee), got %d", count) } } + +// TestSyncTaskAssignees_PreservesUnchangedRows is a regression test: UpdateTask +// used to unconditionally DELETE-then-reinsert every task_assignees row on +// every call, which reset assigned_at for every assignee even when an update +// didn't touch assignees at all (e.g. renaming a task), corrupting +// assignment history and making the assigned_at-ordered assignee list +// (used for "first assignee" swimlane grouping and avatar-stack order) +// reorder nondeterministically after unrelated edits. syncTaskAssignees must +// leave rows for members present in both the old and new set untouched, and +// only remove/insert the actual diff. +func TestSyncTaskAssignees_PreservesUnchangedRows(t *testing.T) { + db := openTaskRepoTestDB(t) + ctx := context.Background() + + taskID := uuid.New() + memberA := uuid.New() + memberB := uuid.New() + memberC := uuid.New() + + db.MustExec(`INSERT INTO tasks (id, project_id) VALUES ($1, $2)`, taskID.String(), uuid.New().String()) + db.MustExec(`INSERT INTO task_assignees (task_id, member_id, assigned_at) VALUES ($1, $2, $3)`, + taskID.String(), memberA.String(), "2020-01-01T00:00:00Z") + db.MustExec(`INSERT INTO task_assignees (task_id, member_id, assigned_at) VALUES ($1, $2, $3)`, + taskID.String(), memberB.String(), "2020-01-02T00:00:00Z") + + type row struct { + MemberID string `db:"member_id"` + AssignedAt string `db:"assigned_at"` + } + readRows := func() map[string]string { + t.Helper() + var rows []row + if err := db.SelectContext(ctx, &rows, `SELECT member_id, assigned_at FROM task_assignees WHERE task_id=$1`, taskID.String()); err != nil { + t.Fatalf("query rows: %v", err) + } + out := make(map[string]string, len(rows)) + for _, r := range rows { + out[r.MemberID] = r.AssignedAt + } + return out + } + + // A no-op sync (wantIDs identical to the current set, simulating an + // update that doesn't touch assignee_ids at all) must not touch + // assigned_at for either existing member. + tx, err := db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + if err := syncTaskAssignees(ctx, tx, taskID, []uuid.UUID{memberA, memberB}); err != nil { + t.Fatalf("syncTaskAssignees (no-op): %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + got := readRows() + if len(got) != 2 { + t.Fatalf("expected 2 assignee rows to survive a no-op sync, got %+v", got) + } + if got[memberA.String()] != "2020-01-01T00:00:00Z" || got[memberB.String()] != "2020-01-02T00:00:00Z" { + t.Fatalf("expected assigned_at to be preserved by a no-op sync, got %+v", got) + } + + // A real change (drop B, add C) must preserve A's original assigned_at, + // remove B, and add C. + tx2, err := db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + if err := syncTaskAssignees(ctx, tx2, taskID, []uuid.UUID{memberA, memberC}); err != nil { + t.Fatalf("syncTaskAssignees (change): %v", err) + } + if err := tx2.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + got = readRows() + if _, stillPresent := got[memberB.String()]; stillPresent { + t.Fatalf("expected memberB to be removed after dropping it from wantIDs, got %+v", got) + } + if got[memberA.String()] != "2020-01-01T00:00:00Z" { + t.Fatalf("expected memberA's original assigned_at to survive an unrelated diff, got %q", got[memberA.String()]) + } + if _, added := got[memberC.String()]; !added { + t.Fatalf("expected memberC to be newly inserted, got %+v", got) + } +} diff --git a/services/api/internal/service/task/task_service.go b/services/api/internal/service/task/task_service.go index 3e5138d0..805d5110 100644 --- a/services/api/internal/service/task/task_service.go +++ b/services/api/internal/service/task/task_service.go @@ -361,6 +361,10 @@ func (s *Service) CreateTask(ctx context.Context, in taskdom.CreateTaskInput) (* if tags == nil { tags = []string{} } + assigneeIDs := in.AssigneeIDs + if assigneeIDs == nil { + assigneeIDs = []uuid.UUID{} + } now := time.Now() t := &taskdom.Task{ @@ -374,7 +378,7 @@ func (s *Service) CreateTask(ctx context.Context, in taskdom.CreateTaskInput) (* Description: in.Description, Importance: in.Importance, StoryPoints: in.StoryPoints, - AssigneeID: in.AssigneeID, + AssigneeIDs: assigneeIDs, ReporterID: in.ReporterID, CustomFields: cf, StartDate: in.StartDate, @@ -451,8 +455,8 @@ func (s *Service) UpdateTask(ctx context.Context, projectID, id uuid.UUID, in ta if in.StoryPoints != nil { t.StoryPoints = *in.StoryPoints } - if in.AssigneeID != nil { - t.AssigneeID = *in.AssigneeID + if in.AssigneeIDs != nil { + t.AssigneeIDs = *in.AssigneeIDs } if in.ReporterID != nil { t.ReporterID = *in.ReporterID diff --git a/services/api/internal/service/task/task_service_test.go b/services/api/internal/service/task/task_service_test.go index 895ed1b8..642eec81 100644 --- a/services/api/internal/service/task/task_service_test.go +++ b/services/api/internal/service/task/task_service_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strings" "sync" @@ -274,7 +275,7 @@ func (r *fakeTaskRepo) ListTasks(_ context.Context, projectID uuid.UUID, filter if filter.StatusID != nil && (t.StatusID == nil || *t.StatusID != *filter.StatusID) { continue } - if filter.AssigneeID != nil && (t.AssigneeID == nil || *t.AssigneeID != *filter.AssigneeID) { + if filter.AssigneeID != nil && !slices.Contains(t.AssigneeIDs, *filter.AssigneeID) { continue } if !taskMatchesSearch(t, filter) { @@ -385,7 +386,7 @@ func (r *fakeTaskRepo) CountTasks(_ context.Context, projectID uuid.UUID, filter if filter.StatusID != nil && (t.StatusID == nil || *t.StatusID != *filter.StatusID) { continue } - if filter.AssigneeID != nil && (t.AssigneeID == nil || *t.AssigneeID != *filter.AssigneeID) { + if filter.AssigneeID != nil && !slices.Contains(t.AssigneeIDs, *filter.AssigneeID) { continue } if filter.BacklogOnly && t.SprintID != nil { @@ -414,7 +415,7 @@ func (r *fakeTaskRepo) SumTaskField(_ context.Context, projectID uuid.UUID, filt if filter.StatusID != nil && (t.StatusID == nil || *t.StatusID != *filter.StatusID) { continue } - if filter.AssigneeID != nil && (t.AssigneeID == nil || *t.AssigneeID != *filter.AssigneeID) { + if filter.AssigneeID != nil && !slices.Contains(t.AssigneeIDs, *filter.AssigneeID) { continue } if filter.BacklogOnly && t.SprintID != nil { diff --git a/services/api/internal/transport/http/dto/task_dto.go b/services/api/internal/transport/http/dto/task_dto.go index d9449c38..7cc66acf 100644 --- a/services/api/internal/transport/http/dto/task_dto.go +++ b/services/api/internal/transport/http/dto/task_dto.go @@ -46,6 +46,44 @@ func (o OptionalUUID) Ptr() **uuid.UUID { return &o.Value } +// OptionalUUIDSlice is a JSON-decodable field for a settable list of UUIDs +// (e.g. assignee_ids). Unlike OptionalUUID it has no null/absent distinction +// beyond "was this field present in the body" — JSON null is treated the same +// as an empty array (both clear the stored set). +type OptionalUUIDSlice struct { + Set bool + Value []uuid.UUID +} + +// UnmarshalJSON implements json.Unmarshaler. It marks the field as Set and +// decodes the value, treating JSON null as an empty (non-nil) slice. +func (o *OptionalUUIDSlice) UnmarshalJSON(data []byte) error { + o.Set = true + if string(data) == "null" { + o.Value = []uuid.UUID{} + return nil + } + var ids []uuid.UUID + if err := json.Unmarshal(data, &ids); err != nil { + return fmt.Errorf("invalid uuid array value: %w", err) + } + if ids == nil { + ids = []uuid.UUID{} + } + o.Value = ids + return nil +} + +// Ptr returns a *[]uuid.UUID suitable for UpdateTaskInput.AssigneeIDs: nil +// when the field was absent (don't overwrite), non-nil when it was set +// (replace in full, possibly with an empty slice to clear). +func (o OptionalUUIDSlice) Ptr() *[]uuid.UUID { + if !o.Set { + return nil + } + return &o.Value +} + // OptionalString is a JSON-decodable field for nullable string columns. type OptionalString struct { Set bool @@ -281,7 +319,7 @@ type CreateTaskRequest struct { Description *json.RawMessage `json:"description"` Importance int `json:"importance"` StoryPoints *int `json:"story_points"` - AssigneeID *uuid.UUID `json:"assignee_id"` + AssigneeIDs []uuid.UUID `json:"assignee_ids"` ReporterID *uuid.UUID `json:"reporter_id"` CustomFields map[string]any `json:"custom_fields"` StartDate *time.Time `json:"start_date"` @@ -304,20 +342,20 @@ func (r CreateTaskRequest) NormalizedDescription() json.RawMessage { // For Tags and CustomFields, a nil pointer means absent (don't update); a non-nil // pointer (even to an empty slice/map) means the field was explicitly set. type UpdateTaskRequest struct { - Title string `json:"title"` - TaskTypeID OptionalUUID `json:"task_type_id"` - StatusID OptionalUUID `json:"status_id"` - SprintID OptionalUUID `json:"sprint_id"` - ParentTaskID OptionalUUID `json:"parent_task_id"` - Description OptionalJSON `json:"description"` - Importance *int `json:"importance"` - StoryPoints OptionalInt `json:"story_points"` - AssigneeID OptionalUUID `json:"assignee_id"` - ReporterID OptionalUUID `json:"reporter_id"` - CustomFields *map[string]any `json:"custom_fields"` - StartDate OptionalTime `json:"start_date"` - DueDate OptionalTime `json:"due_date"` - Tags *[]string `json:"tags"` + Title string `json:"title"` + TaskTypeID OptionalUUID `json:"task_type_id"` + StatusID OptionalUUID `json:"status_id"` + SprintID OptionalUUID `json:"sprint_id"` + ParentTaskID OptionalUUID `json:"parent_task_id"` + Description OptionalJSON `json:"description"` + Importance *int `json:"importance"` + StoryPoints OptionalInt `json:"story_points"` + AssigneeIDs OptionalUUIDSlice `json:"assignee_ids"` + ReporterID OptionalUUID `json:"reporter_id"` + CustomFields *map[string]any `json:"custom_fields"` + StartDate OptionalTime `json:"start_date"` + DueDate OptionalTime `json:"due_date"` + Tags *[]string `json:"tags"` } // TaskResponse is the public representation of a task. @@ -336,7 +374,7 @@ type TaskResponse struct { Description json.RawMessage `json:"description,omitempty"` Importance int `json:"importance"` StoryPoints *int `json:"story_points,omitempty"` - AssigneeID *uuid.UUID `json:"assignee_id,omitempty"` + AssigneeIDs []uuid.UUID `json:"assignee_ids"` ReporterID *uuid.UUID `json:"reporter_id,omitempty"` CustomFields map[string]any `json:"custom_fields"` StartDate *time.Time `json:"start_date,omitempty"` @@ -358,6 +396,10 @@ func TaskFromEntity(t *taskdom.Task) TaskResponse { if tags == nil { tags = []string{} } + assigneeIDs := t.AssigneeIDs + if assigneeIDs == nil { + assigneeIDs = []uuid.UUID{} + } return TaskResponse{ ID: t.ID, ProjectID: t.ProjectID, @@ -370,7 +412,7 @@ func TaskFromEntity(t *taskdom.Task) TaskResponse { Description: t.Description, Importance: t.Importance, StoryPoints: t.StoryPoints, - AssigneeID: t.AssigneeID, + AssigneeIDs: assigneeIDs, ReporterID: t.ReporterID, CustomFields: cf, StartDate: t.StartDate, diff --git a/services/api/internal/transport/http/handler/task_handler.go b/services/api/internal/transport/http/handler/task_handler.go index 361117ef..426cc2a9 100644 --- a/services/api/internal/transport/http/handler/task_handler.go +++ b/services/api/internal/transport/http/handler/task_handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strconv" "strings" "time" @@ -624,7 +625,7 @@ func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) { Description: req.NormalizedDescription(), Importance: req.Importance, StoryPoints: req.StoryPoints, - AssigneeID: req.AssigneeID, + AssigneeIDs: req.AssigneeIDs, ReporterID: req.ReporterID, CustomFields: req.CustomFields, StartDate: req.StartDate, @@ -653,10 +654,10 @@ func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) { Content: content, }) - // Enqueue an assignment event so the NotificationConsumer can create - // the in-app notification asynchronously (best-effort). - if req.AssigneeID != nil { - _ = events.PublishAssignmentChanged(r.Context(), h.publisher, t.ID, projectID, *req.AssigneeID, nil, actorID, nil) + // Enqueue one assignment event per assignee so the NotificationConsumer + // can create the in-app notification asynchronously (best-effort). + for _, memberID := range req.AssigneeIDs { + _ = events.PublishAssignmentChanged(r.Context(), h.publisher, t.ID, projectID, memberID, nil, actorID, nil) } } @@ -699,7 +700,7 @@ func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) { Description: req.Description.Ptr(), Importance: req.Importance, StoryPoints: req.StoryPoints.Ptr(), - AssigneeID: req.AssigneeID.Ptr(), + AssigneeIDs: req.AssigneeIDs.Ptr(), ReporterID: req.ReporterID.Ptr(), CustomFields: req.CustomFields, StartDate: req.StartDate.Ptr(), @@ -731,12 +732,20 @@ func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) { }) } - // Enqueue an assignment event when the assignee changed so the - // NotificationConsumer can create the in-app notification asynchronously. - if req.AssigneeID.Set && req.AssigneeID.Value != nil { - assigneeChanged := oldTask.AssigneeID == nil || *oldTask.AssigneeID != *req.AssigneeID.Value - if assigneeChanged { - _ = events.PublishAssignmentChanged(r.Context(), h.publisher, taskID, projectID, *req.AssigneeID.Value, oldTask.AssigneeID, actorID, nil) + // Enqueue an assignment event for each newly-added assignee so the + // NotificationConsumer can create the in-app notification + // asynchronously. Members already assigned before this update don't + // get a duplicate event — only genuinely new assignments notify. + if req.AssigneeIDs.Set { + oldSet := make(map[uuid.UUID]struct{}, len(oldTask.AssigneeIDs)) + for _, id := range oldTask.AssigneeIDs { + oldSet[id] = struct{}{} + } + for _, memberID := range req.AssigneeIDs.Value { + if _, alreadyAssigned := oldSet[memberID]; alreadyAssigned { + continue + } + _ = events.PublishAssignmentChanged(r.Context(), h.publisher, taskID, projectID, memberID, nil, actorID, nil) } } } @@ -784,10 +793,10 @@ func (h *TaskHandler) taskChangedFields(ctx context.Context, old *taskdom.Task, } } - if req.AssigneeID.Set { - oldVal := uuidPtrToStr(old.AssigneeID) - newVal := uuidPtrToStr(req.AssigneeID.Value) - if oldVal != newVal { + if req.AssigneeIDs.Set { + oldVal := uuidSliceToStrs(old.AssigneeIDs) + newVal := uuidSliceToStrs(req.AssigneeIDs.Value) + if !equalStrSets(oldVal, newVal) { changes = append(changes, taskdom.FieldChange{Field: "assignee", Old: oldVal, New: newVal}) } } @@ -883,6 +892,30 @@ func uuidPtrToStr(id *uuid.UUID) string { return id.String() } +// uuidSliceToStrs converts a UUID slice to a sorted string slice, for +// order-insensitive set comparison and stable activity-log rendering. +func uuidSliceToStrs(ids []uuid.UUID) []string { + out := make([]string, len(ids)) + for i, id := range ids { + out[i] = id.String() + } + sort.Strings(out) + return out +} + +// equalStrSets reports whether a and b (both already sorted) contain the same elements. +func equalStrSets(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // timePtrToStr formats a *time.Time as a date string (empty string for nil). func timePtrToStr(t *time.Time) string { if t == nil { diff --git a/services/api/internal/worker/workflow_consumer.go b/services/api/internal/worker/workflow_consumer.go index c8f99ffc..8ba1de8b 100644 --- a/services/api/internal/worker/workflow_consumer.go +++ b/services/api/internal/worker/workflow_consumer.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "slices" "strings" "time" @@ -397,7 +398,7 @@ func (c *WorkflowConsumer) processTaskStatusChange(ctx context.Context, projectI // applyNode runs event 1 (this node's own status rule) and, if the node just // became done, fans out event 2 to its outgoing edges. task is shared with // every other node evaluated for this same event (see cache), and -// applyStatusRule updates its AssigneeID in place after a successful +// applyStatusRule updates its AssigneeIDs in place after a successful // reassignment so a task wrapped by multiple active-workflow nodes doesn't // have a later node's idempotency check read a stale assignee. func (c *WorkflowConsumer) applyNode(ctx context.Context, projectID uuid.UUID, node *workflowdom.Node, task *taskdom.Task, cache *evalCache) error { @@ -527,8 +528,8 @@ func (c *WorkflowConsumer) applyStatusRule(ctx context.Context, projectID uuid.U if rule == nil { return nil } - if task.AssigneeID != nil && *task.AssigneeID == rule.AssigneeMemberID { - return nil // already assigned — idempotent no-op + if len(task.AssigneeIDs) == 1 && task.AssigneeIDs[0] == rule.AssigneeMemberID { + return nil // already assigned exactly to the rule's member — idempotent no-op } // Fetched fresh (not through evalCache) despite already being read @@ -548,20 +549,20 @@ func (c *WorkflowConsumer) applyStatusRule(ctx context.Context, projectID uuid.U return nil } - oldAssignee := task.AssigneeID + oldAssignees := task.AssigneeIDs newAssignee := rule.AssigneeMemberID - newAssigneePtr := &newAssignee - if _, err := c.taskSvc.UpdateTask(ctx, projectID, task.ID, taskdom.UpdateTaskInput{AssigneeID: &newAssigneePtr}); err != nil { + newAssigneeIDs := []uuid.UUID{newAssignee} + if _, err := c.taskSvc.UpdateTask(ctx, projectID, task.ID, taskdom.UpdateTaskInput{AssigneeIDs: &newAssigneeIDs}); err != nil { return fmt.Errorf("update task assignee: %w", err) } - task.AssigneeID = &newAssignee + task.AssigneeIDs = newAssigneeIDs if c.activityRec != nil { content, _ := json.Marshal(map[string]any{ "workflow_id": workflow.ID, "workflow_name": workflow.Name, "reason": reason, - "old_assignee": oldAssignee, + "old_assignees": oldAssignees, "new_assignee": newAssignee, }) _ = c.activityRec.RecordActivity(ctx, taskdom.RecordActivityInput{ @@ -586,7 +587,14 @@ func (c *WorkflowConsumer) applyStatusRule(ctx context.Context, projectID uuid.U } } } - _ = events.PublishAssignmentChanged(ctx, c.publisher, task.ID, projectID, newAssignee, oldAssignee, userdom.SystemActorUserID, extra) + // Only notify for a genuinely new assignment — if newAssignee was already + // one of the task's prior assignees (e.g. task had [A, B] and the rule + // targets A), the reassignment above still collapses the set down to + // [A], but A doesn't need a fresh "you've been assigned" notification. + // Mirrors the same dedup the HTTP UpdateTask handler does. + if !slices.Contains(oldAssignees, newAssignee) { + _ = events.PublishAssignmentChanged(ctx, c.publisher, task.ID, projectID, newAssignee, nil, userdom.SystemActorUserID, extra) + } return nil } diff --git a/services/api/internal/worker/workflow_consumer_test.go b/services/api/internal/worker/workflow_consumer_test.go index 9e08ed8e..b3531467 100644 --- a/services/api/internal/worker/workflow_consumer_test.go +++ b/services/api/internal/worker/workflow_consumer_test.go @@ -15,6 +15,12 @@ func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } +// isAssignedOnlyTo reports whether t's assignee set is exactly {member} — +// the shape a status rule's replace-all-assignees reassignment produces. +func isAssignedOnlyTo(t *taskdom.Task, member uuid.UUID) bool { + return len(t.AssigneeIDs) == 1 && t.AssigneeIDs[0] == member +} + // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- @@ -165,8 +171,8 @@ func (f *fakeTaskStore) UpdateTask(_ context.Context, _, id uuid.UUID, in taskdo if !ok { return nil, taskdom.ErrTaskNotFound } - if in.AssigneeID != nil { - t.AssigneeID = *in.AssigneeID + if in.AssigneeIDs != nil { + t.AssigneeIDs = *in.AssigneeIDs } cp := *t return &cp, nil @@ -275,8 +281,8 @@ func TestApplyNode_EventOne_DirectStatusRule(t *testing.T) { } got := f.tasks.tasks[task.ID] - if got.AssigneeID == nil || *got.AssigneeID != member { - t.Fatalf("expected task assigned to %v, got %+v", member, got.AssigneeID) + if !isAssignedOnlyTo(got, member) { + t.Fatalf("expected task assigned to %v, got %+v", member, got.AssigneeIDs) } if f.tasks.updateCalls != 1 { t.Fatalf("expected exactly 1 UpdateTask call, got %d", f.tasks.updateCalls) @@ -291,7 +297,7 @@ func TestApplyNode_EventOne_IdempotentWhenAlreadyAssigned(t *testing.T) { _, task := f.addNode(w, &f.doneStatus.ID) f.addRule(w, f.doneStatus.ID, member) - task.AssigneeID = &member // already assigned before the event fires + task.AssigneeIDs = []uuid.UUID{member} // already assigned before the event fires if err := f.consumer.processTaskStatusChange(ctx, f.projectID, task.ID); err != nil { t.Fatalf("processTaskStatusChange: %v", err) @@ -318,8 +324,8 @@ func TestChain_SinglePredecessor_AssignsDownstreamOnDone(t *testing.T) { } gotB := f.tasks.tasks[taskB.ID] - if gotB.AssigneeID == nil || *gotB.AssigneeID != downstreamMember { - t.Fatalf("expected downstream task assigned to %v, got %+v", downstreamMember, gotB.AssigneeID) + if !isAssignedOnlyTo(gotB, downstreamMember) { + t.Fatalf("expected downstream task assigned to %v, got %+v", downstreamMember, gotB.AssigneeIDs) } } @@ -342,8 +348,8 @@ func TestDiamond_ANDJoin_WaitsForAllPredecessors(t *testing.T) { if err := f.consumer.processTaskStatusChange(ctx, f.projectID, taskA.ID); err != nil { t.Fatalf("processTaskStatusChange(A): %v", err) } - if got := f.tasks.tasks[taskC.ID]; got.AssigneeID != nil { - t.Fatalf("expected C to remain unassigned while B is not done, got %+v", got.AssigneeID) + if got := f.tasks.tasks[taskC.ID]; len(got.AssigneeIDs) != 0 { + t.Fatalf("expected C to remain unassigned while B is not done, got %+v", got.AssigneeIDs) } // B finishes too — NOW C should be assigned. @@ -352,8 +358,8 @@ func TestDiamond_ANDJoin_WaitsForAllPredecessors(t *testing.T) { t.Fatalf("processTaskStatusChange(B): %v", err) } gotC := f.tasks.tasks[taskC.ID] - if gotC.AssigneeID == nil || *gotC.AssigneeID != downstreamMember { - t.Fatalf("expected C assigned to %v once both predecessors are done, got %+v", downstreamMember, gotC.AssigneeID) + if !isAssignedOnlyTo(gotC, downstreamMember) { + t.Fatalf("expected C assigned to %v once both predecessors are done, got %+v", downstreamMember, gotC.AssigneeIDs) } } @@ -478,12 +484,12 @@ func TestApplyStatusRule_ArchivedMidFanOut_StopsLaterNodes(t *testing.T) { } gotA := f.tasks.tasks[taskA.ID] - if gotA.AssigneeID == nil || *gotA.AssigneeID != memberA { - t.Fatalf("expected nodeA's own reassignment to complete before the archive landed, got %+v", gotA.AssigneeID) + if !isAssignedOnlyTo(gotA, memberA) { + t.Fatalf("expected nodeA's own reassignment to complete before the archive landed, got %+v", gotA.AssigneeIDs) } gotB := f.tasks.tasks[taskB.ID] - if gotB.AssigneeID != nil { - t.Fatalf("expected nodeB to NOT be reassigned once the workflow was archived mid-fan-out, got %v", *gotB.AssigneeID) + if len(gotB.AssigneeIDs) != 0 { + t.Fatalf("expected nodeB to NOT be reassigned once the workflow was archived mid-fan-out, got %v", gotB.AssigneeIDs) } if f.tasks.updateCalls != 1 { t.Fatalf("expected exactly 1 UpdateTask call (nodeA only, before the archive), got %d", f.tasks.updateCalls) @@ -529,8 +535,8 @@ func TestApplyStatusRule_ReflectsRuleEditedMidFanOut(t *testing.T) { } gotB := f.tasks.tasks[taskB.ID] - if gotB.AssigneeID == nil || *gotB.AssigneeID != newMemberB { - t.Fatalf("expected nodeB to be assigned the rule's up-to-date member %v (edited concurrently mid-fan-out), got %+v", newMemberB, gotB.AssigneeID) + if !isAssignedOnlyTo(gotB, newMemberB) { + t.Fatalf("expected nodeB to be assigned the rule's up-to-date member %v (edited concurrently mid-fan-out), got %+v", newMemberB, gotB.AssigneeIDs) } if f.graph.rulesCalls != 2 { t.Fatalf("expected rules to be re-read fresh for each node in the fan-out (not cached), got %d calls", f.graph.rulesCalls) diff --git a/services/api/migrations/000021_add_task_assignees.sql b/services/api/migrations/000021_add_task_assignees.sql new file mode 100644 index 00000000..c3efe6e8 --- /dev/null +++ b/services/api/migrations/000021_add_task_assignees.sql @@ -0,0 +1,49 @@ +-- 000021_add_task_assignees.sql +-- Replaces the single-valued tasks.assignee_id column with a task_assignees +-- join table so a task can have multiple assignees. +-- +-- This file is re-executed on every application startup (see +-- database.RunMigrationsFS), so every statement must be safe to run +-- repeatedly. The backfill + column drop can only run once (the source +-- column disappears after the first run), so they're wrapped in a DO block +-- that checks column existence first — a plain SELECT referencing +-- assignee_id would fail to even parse once the column is gone, so +-- ALTER TABLE ... DROP COLUMN IF EXISTS alone (the pattern used by prior +-- drop-column migrations in this directory) isn't enough here. +-- +-- The DO block takes an ACCESS EXCLUSIVE lock on tasks before checking +-- column existence, so the check-then-drop is atomic across concurrently +-- starting replicas: without it, two replicas racing this migration on the +-- same first deploy could both observe "column exists" before either +-- commits, and the second one's DROP COLUMN would then fail once it +-- acquires the lock behind the first (the column would already be gone), +-- failing that replica's startup. + +BEGIN; + +CREATE TABLE IF NOT EXISTS task_assignees ( + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES project_members(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (task_id, member_id) +); + +CREATE INDEX IF NOT EXISTS idx_task_assignees_member ON task_assignees (member_id); + +DO $$ +BEGIN + LOCK TABLE tasks IN ACCESS EXCLUSIVE MODE; + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tasks' AND column_name = 'assignee_id' + ) THEN + EXECUTE ' + INSERT INTO task_assignees (task_id, member_id) + SELECT id, assignee_id FROM tasks WHERE assignee_id IS NOT NULL + ON CONFLICT DO NOTHING + '; + EXECUTE 'ALTER TABLE tasks DROP COLUMN assignee_id'; + END IF; +END $$; + +COMMIT; diff --git a/services/api/test/e2e/task_management_test.go b/services/api/test/e2e/task_management_test.go index 77be6e9e..c59bbf00 100644 --- a/services/api/test/e2e/task_management_test.go +++ b/services/api/test/e2e/task_management_test.go @@ -1209,6 +1209,166 @@ func TestE2ETaskManagement_DatesAndTags(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// Multiple assignees +// --------------------------------------------------------------------------- + +func TestE2ETaskManagement_MultipleAssignees(t *testing.T) { + env := newE2EEnv(t) + ownerUsername := "multi-assignee-owner-" + uuid.NewString() + seedTaskMemberUser(t, env, ownerUsername, "multiassigneeowner1") + client, token := taskMemberLogin(t, env, ownerUsername, "multiassigneeowner1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + memberA := addProjectMemberWithWorkflowPerms(t, env, client, token, projID, "multi-assignee-a-"+uuid.NewString(), "multiassigneea1") + memberB := addProjectMemberWithWorkflowPerms(t, env, client, token, projID, "multi-assignee-b-"+uuid.NewString(), "multiassigneeb1") + memberC := addProjectMemberWithWorkflowPerms(t, env, client, token, projID, "multi-assignee-c-"+uuid.NewString(), "multiassigneec1") + + var taskID string + + assigneeIDs := func(data map[string]any) []string { + raw, _ := data["assignee_ids"].([]any) + out := make([]string, len(raw)) + for i, v := range raw { + out[i], _ = v.(string) + } + return out + } + containsAll := func(got []string, want ...string) bool { + set := make(map[string]bool, len(got)) + for _, g := range got { + set[g] = true + } + for _, w := range want { + if !set[w] { + return false + } + } + return true + } + + t.Run("create_task_with_multiple_assignees", func(t *testing.T) { + body := jsonBody(t, map[string]any{ + "title": "Task with multiple assignees", + "assignee_ids": []string{memberA, memberB}, + }) + req := mustRequest(env.ctx, t, http.MethodPost, + fmt.Sprintf("%s/api/v1/projects/%s/tasks", env.base, projID), body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusCreated) + var env2 envelope + decodeJSON(t, resp, &env2) + data := assertDataMap(t, env2) + taskID, _ = data["id"].(string) + if taskID == "" { + t.Fatal("expected non-empty task id") + } + got := assigneeIDs(data) + if len(got) != 2 || !containsAll(got, memberA, memberB) { + t.Fatalf("expected assignee_ids=[%s,%s], got %v", memberA, memberB, got) + } + }) + + t.Run("get_task_returns_both_assignees", func(t *testing.T) { + req := mustRequest(env.ctx, t, http.MethodGet, + fmt.Sprintf("%s/api/v1/projects/%s/tasks/%s", env.base, projID, taskID), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + var env2 envelope + decodeJSON(t, resp, &env2) + data := assertDataMap(t, env2) + got := assigneeIDs(data) + if len(got) != 2 || !containsAll(got, memberA, memberB) { + t.Fatalf("expected assignee_ids=[%s,%s], got %v", memberA, memberB, got) + } + }) + + t.Run("filter_by_assignee_ids_matches_any_assignee", func(t *testing.T) { + // Filtering by memberB alone should still surface the task since it + // matches ANY of its assignees, not all of them. + req := mustRequest(env.ctx, t, http.MethodGet, + fmt.Sprintf("%s/api/v1/projects/%s/tasks?assignee_ids=%s", env.base, projID, memberB), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + var env2 envelope + decodeJSON(t, resp, &env2) + data := assertDataMap(t, env2) + items, _ := data["items"].([]any) + found := false + for _, item := range items { + m, _ := item.(map[string]any) + if id, _ := m["id"].(string); id == taskID { + found = true + } + } + if !found { + t.Errorf("expected task %s to be included when filtering by assignee_ids=%s", taskID, memberB) + } + }) + + t.Run("filter_by_unrelated_assignee_excludes_task", func(t *testing.T) { + req := mustRequest(env.ctx, t, http.MethodGet, + fmt.Sprintf("%s/api/v1/projects/%s/tasks?assignee_ids=%s", env.base, projID, memberC), nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + var env2 envelope + decodeJSON(t, resp, &env2) + data := assertDataMap(t, env2) + items, _ := data["items"].([]any) + for _, item := range items { + m, _ := item.(map[string]any) + if id, _ := m["id"].(string); id == taskID { + t.Errorf("did not expect task %s when filtering by unrelated assignee_ids=%s", taskID, memberC) + } + } + }) + + t.Run("update_task_removes_one_and_adds_another", func(t *testing.T) { + body := jsonBody(t, map[string]any{"assignee_ids": []string{memberB, memberC}}) + req := mustRequest(env.ctx, t, http.MethodPatch, + fmt.Sprintf("%s/api/v1/projects/%s/tasks/%s", env.base, projID, taskID), body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + var env2 envelope + decodeJSON(t, resp, &env2) + data := assertDataMap(t, env2) + got := assigneeIDs(data) + if len(got) != 2 || !containsAll(got, memberB, memberC) { + t.Fatalf("expected assignee_ids=[%s,%s], got %v", memberB, memberC, got) + } + }) + + t.Run("update_task_clears_all_assignees", func(t *testing.T) { + body := jsonBody(t, map[string]any{"assignee_ids": []string{}}) + req := mustRequest(env.ctx, t, http.MethodPatch, + fmt.Sprintf("%s/api/v1/projects/%s/tasks/%s", env.base, projID, taskID), body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + var env2 envelope + decodeJSON(t, resp, &env2) + data := assertDataMap(t, env2) + got := assigneeIDs(data) + if len(got) != 0 { + t.Fatalf("expected no assignees after clearing, got %v", got) + } + }) +} + // --------------------------------------------------------------------------- // Custom field definition helpers // --------------------------------------------------------------------------- diff --git a/services/api/test/e2e/workflow_automation_test.go b/services/api/test/e2e/workflow_automation_test.go index 44a95e87..cd87d9fa 100644 --- a/services/api/test/e2e/workflow_automation_test.go +++ b/services/api/test/e2e/workflow_automation_test.go @@ -76,10 +76,13 @@ func setStatusRuleViaAPI(t *testing.T, env *e2eEnv, client *http.Client, token, assertStatus(t, resp, http.StatusCreated) } -// waitForTaskAssignee polls the task until its assignee_id matches -// wantAssigneeID or timeout elapses. Reassignment happens asynchronously (via -// a Valkey-stream event the WorkflowConsumer processes in the background), so -// tests must poll rather than assert immediately after the triggering PATCH. +// waitForTaskAssignee polls the task until its assignee_ids is exactly +// [wantAssigneeID] or timeout elapses. Reassignment happens asynchronously +// (via a Valkey-stream event the WorkflowConsumer processes in the +// background), so tests must poll rather than assert immediately after the +// triggering PATCH. A status rule firing replaces the task's entire assignee +// set with its single configured member, so "assigned to X" means the array +// is exactly [X], not merely contains it. // // The timeout is generous because the consumer group is shared across the // whole e2e suite: the first automation test to run drains any backlog of @@ -90,16 +93,16 @@ func waitForTaskAssignee(t *testing.T, env *e2eEnv, client *http.Client, token, t.Helper() deadline := time.Now().Add(timeout) var data map[string]any - var lastAssignee string + var lastAssignees []any for time.Now().Before(deadline) { data = getTaskViaAPI(t, env, client, token, projectID, taskID) - lastAssignee, _ = data["assignee_id"].(string) - if lastAssignee == wantAssigneeID { + lastAssignees, _ = data["assignee_ids"].([]any) + if len(lastAssignees) == 1 && lastAssignees[0] == wantAssigneeID { return data } time.Sleep(150 * time.Millisecond) } - t.Fatalf("timed out waiting for task %s to be assigned to %q; last observed assignee_id=%q", taskID, wantAssigneeID, lastAssignee) + t.Fatalf("timed out waiting for task %s to be assigned to %q; last observed assignee_ids=%v", taskID, wantAssigneeID, lastAssignees) return nil } @@ -274,8 +277,8 @@ func TestE2EWorkflowAutomation_AndJoinWaitsForAllPredecessors(t *testing.T) { waitForTaskAssignee(t, env, ownerClient, ownerToken, projID, taskA, ownerMemberID, 20*time.Second) dataB := getTaskViaAPI(t, env, ownerClient, ownerToken, projID, taskB) - if assignee, ok := dataB["assignee_id"].(string); ok && assignee != "" { - t.Errorf("expected the joint successor to remain unassigned until ALL predecessors are done, got assignee_id=%q", assignee) + if assignees, ok := dataB["assignee_ids"].([]any); ok && len(assignees) > 0 { + t.Errorf("expected the joint successor to remain unassigned until ALL predecessors are done, got assignee_ids=%v", assignees) } }) diff --git a/services/api/test/integration/task_test.go b/services/api/test/integration/task_test.go index a114252f..11371b14 100644 --- a/services/api/test/integration/task_test.go +++ b/services/api/test/integration/task_test.go @@ -284,7 +284,7 @@ func (r *fakeTaskRepo) ListTasks(_ context.Context, projectID uuid.UUID, filter if filter.StatusID != nil && (t.StatusID == nil || *t.StatusID != *filter.StatusID) { continue } - if filter.AssigneeID != nil && (t.AssigneeID == nil || *t.AssigneeID != *filter.AssigneeID) { + if filter.AssigneeID != nil && !slices.Contains(t.AssigneeIDs, *filter.AssigneeID) { continue } if !taskMatchesSearch(t, filter) { @@ -351,7 +351,7 @@ func (r *fakeTaskRepo) CountTasks(_ context.Context, projectID uuid.UUID, filter if filter.StatusID != nil && (t.StatusID == nil || *t.StatusID != *filter.StatusID) { continue } - if filter.AssigneeID != nil && (t.AssigneeID == nil || *t.AssigneeID != *filter.AssigneeID) { + if filter.AssigneeID != nil && !slices.Contains(t.AssigneeIDs, *filter.AssigneeID) { continue } if !taskMatchesSearch(t, filter) {