import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { approvalRequestLifecycle } from "../db/approvalRequest.lifecycle.generated"; import { approvalStepLifecycle } from "../db/approvalStep.lifecycle.generated"; import { approvalStepAssigneeLifecycle } from "../db/approvalStepAssignee.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { InvalidSendBackTargetError, MissingRequiredFieldError, NotActiveAssigneeError, ParentRequestNotPendingError, SelfDecisionNotAllowedError, } from "../lib/errors.generated"; export interface SendBackApprovalStepInput { approvalStepAssigneeId: string; reason: string; /** * Optional rewind target. When omitted the request is returned to the * requester as REVISION_REQUESTED (requester mode). When set to an earlier * step in the same request, the request stays PENDING and rewinds to that * step (step mode). */ targetApprovalStepId?: string; } /** * Function: sendBackApprovalStep * * The non-terminal counterpart to rejectApprovalStep, with two destinations * selected by the optional targetApprovalStepId: * * - Requester mode (no target): records a SEND_BACK decision and moves the * parent request to REVISION_REQUESTED, returning it to the requester. Step * and assignee rows are left untouched and are reset only when the requester * calls resubmitApprovalRequest. * - Step mode (target supplied): records a SEND_BACK decision carrying * sentBackToStepId and rewinds the request to a chosen earlier step. The * request stays PENDING; every step from the target through the current step * is reset to PENDING (their APPROVED assignees reset to PENDING, DELEGATED * rows left as-is) and the target step is re-activated to IN_PROGRESS, * reusing the frozen assignee set without re-expanding roles. */ export async function run(db: Transaction, input: SendBackApprovalStepInput, ctx: CommandContext) { const assignee = await db .selectFrom("ApprovalStepAssignee") .selectAll() .where("id", "=", input.approvalStepAssigneeId) .forUpdate() .executeTakeFirst(); if (!assignee) return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); if (assignee.status !== "PENDING" || assignee.userId !== ctx.actorId) { return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); } const step = await db .selectFrom("ApprovalStep") .selectAll() .where("id", "=", assignee.approvalStepId) .forUpdate() .executeTakeFirst(); if (!step || step.status !== "IN_PROGRESS") { return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); } const request = await db .selectFrom("ApprovalRequest") .selectAll() .where("id", "=", step.approvalRequestId) .forUpdate() .executeTakeFirst(); if (!request) return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); // Liveness guard: only a PENDING request can be sent back (both modes). const revisionStatus = approvalRequestLifecycle.tryTransition(request.status, "sendBack"); if (!revisionStatus) { return err(new ParentRequestNotPendingError(request.id)); } if (ctx.actorId === request.requesterId) { return err(new SelfDecisionNotAllowedError(request.id)); } if (input.reason.trim() === "") { return err(new MissingRequiredFieldError("reason")); } const now = new Date(); // Step mode: rewind to a chosen earlier step, leaving the request PENDING. if (input.targetApprovalStepId !== undefined) { const targetStep = await db .selectFrom("ApprovalStep") .selectAll() .where("id", "=", input.targetApprovalStepId) .where("approvalRequestId", "=", request.id) .forUpdate() .executeTakeFirst(); if (!targetStep || targetStep.stepOrder >= step.stepOrder) { return err(new InvalidSendBackTargetError(input.targetApprovalStepId)); } await db .insertInto("ApprovalDecision") .values({ approvalRequestId: request.id, approvalStepId: step.id, sentBackToStepId: targetStep.id, decision: "SEND_BACK", decidedByUserId: ctx.actorId, comment: input.reason, decidedAt: now, }) .execute(); // Steps from the target through the current step: reset to PENDING. const rangeSteps = await db .selectFrom("ApprovalStep") .select("id") .where("approvalRequestId", "=", request.id) .where("stepOrder", ">=", targetStep.stepOrder) .where("stepOrder", "<=", step.stepOrder) .execute(); const rangeStepIds = rangeSteps.map((s) => s.id); await db .updateTable("ApprovalStep") .set({ status: approvalStepLifecycle.transitions.reset.to, resolvedAt: null }) .where("approvalRequestId", "=", request.id) .where("stepOrder", ">=", targetStep.stepOrder) .where("stepOrder", "<=", step.stepOrder) .execute(); // DELEGATED rows stay: reviving the delegator would seat them alongside the delegatee's row. if (rangeStepIds.length > 0) { await db .updateTable("ApprovalStepAssignee") .set({ status: approvalStepAssigneeLifecycle.transitions.reset.to, resolvedAt: null }) .where("approvalStepId", "in", rangeStepIds) .where("status", "in", approvalStepAssigneeLifecycle.transitions.reset.from) .execute(); } // Re-activate the target step; the frozen assignee set is reused as-is. await db .updateTable("ApprovalStep") .set({ status: approvalStepLifecycle.transitions.activate.to }) .where("id", "=", targetStep.id) .execute(); return ok({ approvalRequest: request }); } // Requester mode: hand the request back to the requester as REVISION_REQUESTED. await db .insertInto("ApprovalDecision") .values({ approvalRequestId: request.id, approvalStepId: step.id, sentBackToStepId: null, decision: "SEND_BACK", decidedByUserId: ctx.actorId, comment: input.reason, decidedAt: now, }) .execute(); const updated = await db .updateTable("ApprovalRequest") .set({ status: revisionStatus }) .where("id", "=", request.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ approvalRequest: updated }); }