import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { ItemNotFoundError, ItemNotOpenError, SameStageError, StageNotFoundError, StageNotOnPipelineError, } from "../lib/errors.generated"; export interface MovePipelineItemInput { itemId: string; stageId: string; position?: number; } export async function run(db: Transaction, input: MovePipelineItemInput, ctx: CommandContext) { const item = await db .selectFrom("PipelineItem") .selectAll() .where("id", "=", input.itemId) .forUpdate() .executeTakeFirst(); if (!item) { return err(new ItemNotFoundError(input.itemId)); } // movePipelineItem only operates on OPEN items. DRAFT items must be published first // and CLOSED items must be reopened. if (item.lifecycle !== "OPEN") { return err(new ItemNotOpenError(input.itemId)); } const targetStage = await db .selectFrom("PipelineStage") .selectAll() .where("id", "=", input.stageId) .executeTakeFirst(); if (!targetStage) { return err(new StageNotFoundError(input.stageId)); } if (targetStage.pipelineId !== item.pipelineId) { return err(new StageNotOnPipelineError(input.stageId)); } if (targetStage.id === item.stageId) { return err(new SameStageError(input.stageId)); } const itemsInTargetStage = await db .selectFrom("PipelineItem") .selectAll() .where("stageId", "=", targetStage.id) .orderBy("position", "asc") .execute(); const now = new Date(); // `input.position` (when supplied, e.g. dropping a card between two cards in a // different lane) is a 1-based slot index among the target lane's items, the // same contract reorderPipelineItem uses — not a literal position value. We renormalize // the whole target lane to a contiguous 1..N sequence with the moved card spliced // in at that slot. Interpreting the slot as a literal position only shifted the // siblings at or after it, which assumed the stored positions were already a // clean 1..N run; whenever they had gaps or duplicates (createPipelineItem assigns // max+1, and lane moves leave gaps behind) the card snapped to the top or // bottom instead of the chosen slot. Without a position, append to the end. const insertIndex = typeof input.position === "number" ? Math.max(0, Math.min(input.position - 1, itemsInTargetStage.length)) : itemsInTargetStage.length; // Siblings before the slot keep their order (positions 1..insertIndex); the // moved card takes insertIndex + 1; siblings at or after the slot shift down by // one to make room. This rewrites the lane to a contiguous sequence regardless // of the pre-existing position values. const nextPosition = insertIndex + 1; for (const [index, sibling] of itemsInTargetStage.entries()) { const position = index < insertIndex ? index + 1 : index + 2; if (sibling.position !== position) { await db .updateTable("PipelineItem") .set({ position, updatedAt: now }) .where("id", "=", sibling.id) .execute(); } } // Lifecycle stays OPEN regardless of target stage. Reaching a DONE/CANCELED // stage is no longer sufficient to close the item — closing is always an // explicit operator action via ClosePipelineItem (or a consuming module's // own close command wrapping it). const movedItem = await db .updateTable("PipelineItem") .set({ stageId: targetStage.id, position: nextPosition, updatedAt: now, }) .where("id", "=", input.itemId) .returningAll() .executeTakeFirst(); await db .insertInto("PipelineStageTransition") .values({ itemId: item.id, fromStageId: item.stageId, toStageId: targetStage.id, movedByUserId: ctx.actorId, createdAt: now, }) .returningAll() .executeTakeFirst(); return ok({ item: movedItem ?? { ...item, stageId: targetStage.id, position: nextPosition, updatedAt: now, }, }); }