import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { isStageCategory, type StageCategory } from "../db/pipelineStage"; import type { Transaction } from "../generated/kysely-tailordb"; import { DuplicatePipelineNameError, InvalidCategoryError, InvalidNameError, InvalidPipelineTypeError, UserNotFoundError, } from "../lib/errors.generated"; export interface CreatePipelineDeps { getUser?: (id: string) => Promise<{ id: string } | null>; /** * Allowed pipelineType values, injected via defineModule({ pipelineTypes }). * The module itself is type-agnostic — consumers declare their own vocabulary * (e.g. "TASK_BOARD", "SUPPORT_QUEUE"). When omitted, any non-empty string * is accepted. */ allowedPipelineTypes?: readonly string[]; } export interface CreatePipelineInitialStage { name: string; category: StageCategory; color?: string; } export interface CreatePipelineInput { name: string; description?: string; pipelineType: string; /** User who creates the pipeline. Stored as immutable audit metadata. */ createdByUserId: string; /** * Managed-board flag. Locked pipelines keep the lane template seeded via * `stages` — stage-mutation commands reject them — and skip the * name uniqueness check (identity is owned by the managing module, so * cross-owner name collisions must be allowed). */ locked?: boolean; /** Initial lanes to seed at creation time, ordered left to right. */ stages?: ReadonlyArray; } const PIPELINE_OWN_KEYS = new Set([ "name", "description", "pipelineType", "createdByUserId", "locked", "stages", ]); export async function run>( db: Transaction, input: CreatePipelineInput & CF, ctx: CommandContext, deps: CreatePipelineDeps = {}, ) { void ctx; // Consumer extension fields (declared via defineModule's pipeline.fields) // pass through to the insert untouched. const customFields: Record = {}; for (const [key, value] of Object.entries(input as Record)) { if (!PIPELINE_OWN_KEYS.has(key)) { customFields[key] = value; } } if (!input.name.trim()) { return err(new InvalidNameError(input.name)); } if (!input.pipelineType.trim()) { return err(new InvalidPipelineTypeError(input.pipelineType)); } if (deps.allowedPipelineTypes && !deps.allowedPipelineTypes.includes(input.pipelineType)) { return err(new InvalidPipelineTypeError(input.pipelineType)); } for (const stage of input.stages ?? []) { if (!isStageCategory(stage.category)) { return err(new InvalidCategoryError(stage.category)); } } if (deps.getUser) { const creator = await deps.getUser(input.createdByUserId); if (!creator) { return err(new UserNotFoundError(input.createdByUserId)); } } // Locked (managed) pipelines are an implementation detail of their owning // module — that module owns the identity, so cross-owner name collisions // like "Maintenance" must be allowed. User-configured boards remain unique // per pipelineType across the app: every user sees every board (the module // imposes no visibility partitioning), so the name's namespace is global. const locked = input.locked ?? false; if (!locked) { const existingPipeline = await db .selectFrom("Pipeline") .selectAll() .where("pipelineType", "=", input.pipelineType) .where("name", "=", input.name) .executeTakeFirst(); if (existingPipeline) { return err(new DuplicatePipelineNameError(input.name)); } } const now = new Date(); const pipeline = await db .insertInto("Pipeline") .values({ ...customFields, name: input.name, description: input.description ?? null, pipelineType: input.pipelineType, createdByUserId: input.createdByUserId, status: "ACTIVE", locked, createdAt: now, }) .returningAll() .executeTakeFirstOrThrow(); // Seed the initial lane template (when supplied) so the board is renderable // the moment the pipeline exists. Consumers that configure stages later // (e.g. via pipeline settings) simply pass no stages. if (input.stages && input.stages.length > 0) { await db .insertInto("PipelineStage") .values( input.stages.map((s, index) => ({ pipelineId: pipeline.id, name: s.name, position: index + 1, category: s.category, color: s.color ?? null, createdAt: now, })), ) .execute(); } return ok({ pipeline }); }