import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { isItemPriority } from "../db/pipelineItem"; import type { Transaction } from "../generated/kysely-tailordb"; import { PipelineArchivedError, PipelineNotFoundError, InvalidPriorityError, InvalidTitleError, StageNotFoundError, UserNotFoundError, } from "../lib/errors.generated"; export interface CreatePipelineItemDeps { /** * 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 CreatePipelineItemInput { pipelineId: string; title: string; description?: string; assigneeId?: string; priority?: string; dueDate?: Date | string; asDraft?: boolean; } const ITEM_OWN_KEYS = new Set([ "pipelineId", "title", "description", "assigneeId", "priority", "dueDate", "asDraft", ]); export async function run>( db: Transaction, input: CreatePipelineItemInput & CF, ctx: CommandContext, deps: CreatePipelineItemDeps = {}, ) { void ctx; const customFields: Record = {}; for (const [key, value] of Object.entries(input as Record)) { if (!ITEM_OWN_KEYS.has(key)) { customFields[key] = value; } } const pipeline = await db .selectFrom("Pipeline") .selectAll() .where("id", "=", input.pipelineId) .forUpdate() .executeTakeFirst(); if (!pipeline) { return err(new PipelineNotFoundError(input.pipelineId)); } if (pipeline.status !== "ACTIVE") { return err(new PipelineArchivedError(input.pipelineId)); } if (!input.title.trim()) { return err(new InvalidTitleError(input.title)); } if (input.priority !== undefined && !isItemPriority(input.priority)) { return err(new InvalidPriorityError(input.priority)); } if (deps.getUser && input.assigneeId !== undefined) { const assignee = await deps.getUser(input.assigneeId); if (!assignee) { return err(new UserNotFoundError(input.assigneeId)); } } const firstStage = await db .selectFrom("PipelineStage") .selectAll() .where("pipelineId", "=", input.pipelineId) .orderBy("position", "asc") .limit(1) .executeTakeFirst(); if (!firstStage) { return err(new StageNotFoundError(input.pipelineId)); } const itemsInStage = await db .selectFrom("PipelineItem") .selectAll() .where("stageId", "=", firstStage.id) .orderBy("position", "asc") .execute(); const nextPosition = itemsInStage.length > 0 ? Math.max(...itemsInStage.map((item) => item.position)) + 1 : 1; const priority = input.priority !== undefined && isItemPriority(input.priority) ? input.priority : "MEDIUM"; // Use the pipeline's monotonic counter to prevent number reuse after deletion. // Fall back to MAX(itemNumber) only on the first call after the field was added. let nextItemNumber: number; if (pipeline.itemNumberSeq !== null) { nextItemNumber = pipeline.itemNumberSeq + 1; } else { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Kysely aggregate returns unknown const maxRow = await db .selectFrom("PipelineItem") .select(db.fn.max("itemNumber").as("maxNum")) .where("pipelineId", "=", input.pipelineId) .executeTakeFirst(); nextItemNumber = ((maxRow?.maxNum as number | null) ?? 0) + 1; } await db .updateTable("Pipeline") .set({ itemNumberSeq: nextItemNumber }) .where("id", "=", input.pipelineId) .execute(); const item = await db .insertInto("PipelineItem") .values({ ...customFields, pipelineId: input.pipelineId, stageId: firstStage.id, title: input.title, description: input.description ?? null, assigneeId: input.assigneeId ?? null, priority, dueDate: input.dueDate ?? null, position: nextPosition, itemNumber: nextItemNumber, lifecycle: input.asDraft ? "DRAFT" : "OPEN", createdAt: new Date(), }) .returningAll() .executeTakeFirstOrThrow(); return ok({ item }); }