import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { isItemPriority } from "../db/pipelineItem"; import type { Transaction } from "../generated/kysely-tailordb"; import { ItemClosedError, ItemNotFoundError, InvalidPriorityError, InvalidTitleError, UserNotFoundError, } from "../lib/errors.generated"; export interface UpdatePipelineItemDeps { /** * Optional assignee-existence check, injected via * defineModule({ userManagement: { getUser } }). When omitted, assigneeId * is stored as-is. */ getUser?: (id: string) => Promise<{ id: string } | null>; } export interface UpdatePipelineItemInput { itemId: string; title?: string; description?: string | null; assigneeId?: string | null; priority?: string; dueDate?: Date | string | null; position?: number; } // dueDate is exposed in multiple shapes (Date object, ISO string, null). The // audit log stores a single canonical string form so consumers can interpret // it uniformly, and so a no-op write (same string) doesn't generate a row. const dueDateToAuditString = (value: Date | string | null | undefined): string | null => { if (value === null || value === undefined) return null; if (value instanceof Date) return value.toISOString(); const d = new Date(value); if (Number.isNaN(d.getTime())) return value; return d.toISOString(); }; const ITEM_OWN_KEYS = new Set([ "itemId", "title", "description", "assigneeId", "priority", "dueDate", "position", ]); // Module-managed PipelineItem columns that must never be writable through the // extension-field pass-through — lifecycle/stage changes go through their // dedicated commands (close/open/reopen/move), and itemNumber is immutable. const ITEM_RESERVED_KEYS = new Set([ "id", "pipelineId", "stageId", "itemNumber", "lifecycle", "createdAt", "updatedAt", ]); export async function run>( db: Transaction, input: UpdatePipelineItemInput & Partial, ctx: CommandContext, deps: UpdatePipelineItemDeps = {}, ) { // Consumer extension fields (declared via defineModule's pipelineItem.fields) // pass through to the update untouched — except reserved model columns. const customFields: Record = {}; for (const [key, value] of Object.entries(input as Record)) { if (!ITEM_OWN_KEYS.has(key) && !ITEM_RESERVED_KEYS.has(key)) { customFields[key] = value; } } const item = await db .selectFrom("PipelineItem") .selectAll() .where("id", "=", input.itemId) .forUpdate() .executeTakeFirst(); if (!item) { return err(new ItemNotFoundError(input.itemId)); } if (item.lifecycle === "CLOSED") { return err(new ItemClosedError(input.itemId)); } if (input.title !== undefined && !input.title.trim()) { return err(new InvalidTitleError(input.title)); } if (input.priority !== undefined && !isItemPriority(input.priority)) { return err(new InvalidPriorityError(input.priority)); } // null clears the assignee and needs no existence check. if (deps.getUser && input.assigneeId !== undefined && input.assigneeId !== null) { const assignee = await deps.getUser(input.assigneeId); if (!assignee) { return err(new UserNotFoundError(input.assigneeId)); } } const update: Record = { ...customFields, updatedAt: new Date(), }; if (input.title !== undefined) update.title = input.title; if (input.description !== undefined) update.description = input.description; if (input.assigneeId !== undefined) update.assigneeId = input.assigneeId; if (input.priority !== undefined) update.priority = input.priority; if (input.dueDate !== undefined) update.dueDate = input.dueDate; if (input.position !== undefined) update.position = input.position; const titleChanged = input.title !== undefined && input.title !== item.title; const descriptionChanged = input.description !== undefined && (input.description ?? null) !== (item.description ?? null); const assigneeChanged = input.assigneeId !== undefined && (input.assigneeId ?? null) !== (item.assigneeId ?? null); const priorityChanged = input.priority !== undefined && input.priority !== item.priority; const prevDueDate = dueDateToAuditString(item.dueDate); const newDueDate = input.dueDate !== undefined ? dueDateToAuditString(input.dueDate) : prevDueDate; const dueDateChanged = input.dueDate !== undefined && prevDueDate !== newDueDate; const updatedItem = await db .updateTable("PipelineItem") .set(update) .where("id", "=", input.itemId) .returningAll() .executeTakeFirst(); const now = new Date(); const changes: Array<{ changeType: "CONTENT" | "FIELD"; fieldName: "TITLE" | "DESCRIPTION" | "ASSIGNEE" | "PRIORITY" | "DUE_DATE"; prevValue: string | null; newValue: string | null; }> = []; if (titleChanged && input.title !== undefined) { changes.push({ changeType: "CONTENT", fieldName: "TITLE", prevValue: item.title, newValue: input.title, }); } if (descriptionChanged) { changes.push({ changeType: "CONTENT", fieldName: "DESCRIPTION", prevValue: item.description ?? null, newValue: input.description ?? null, }); } if (assigneeChanged) { changes.push({ changeType: "FIELD", fieldName: "ASSIGNEE", prevValue: item.assigneeId ?? null, newValue: input.assigneeId ?? null, }); } if (priorityChanged && input.priority !== undefined) { changes.push({ changeType: "FIELD", fieldName: "PRIORITY", prevValue: item.priority, newValue: input.priority, }); } if (dueDateChanged) { changes.push({ changeType: "FIELD", fieldName: "DUE_DATE", prevValue: prevDueDate, newValue: newDueDate, }); } if (changes.length > 0) { await db .insertInto("PipelineItemChange") .values( changes.map((c) => ({ itemId: item.id, changedByUserId: ctx.actorId, changeType: c.changeType, fieldName: c.fieldName, prevValue: c.prevValue, newValue: c.newValue, createdAt: now, })), ) .execute(); } return ok({ item: updatedItem ?? { ...item, ...update } }); }