import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { ApprovalStepAssigneeRoleQuorum } from "../generated/enums"; import type { Transaction } from "../generated/kysely-tailordb"; import { NotActiveAssigneeError, ParentRequestNotPendingError, RoleHasNoActiveMembersError, SelfDecisionNotAllowedError, StepQuorumUnreachableError, } from "../lib/errors.generated"; import type { UserManagementQueries } from "../module"; export interface ApproveApprovalStepInput { approvalStepAssigneeId: string; comment?: string; } export async function run( db: Transaction, input: ApproveApprovalStepInput, ctx: CommandContext, deps: Pick, ) { const now = new Date(); // 1. Load StepAssignee with row lock const assignee = await db .selectFrom("ApprovalStepAssignee") .selectAll() .where("id", "=", input.approvalStepAssigneeId) .forUpdate() .executeTakeFirst(); if (!assignee) { return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); } // 2. Load parent Step with row lock const step = await db .selectFrom("ApprovalStep") .selectAll() .where("id", "=", assignee.approvalStepId) .forUpdate() .executeTakeFirst(); if (!step) { return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); } // 3. Load parent Request with row lock const request = await db .selectFrom("ApprovalRequest") .selectAll() .where("id", "=", step.approvalRequestId) .forUpdate() .executeTakeFirst(); if (!request) { return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); } // 4. C1a guard: ownership + assignee PENDING + step IN_PROGRESS if ( assignee.userId !== ctx.actorId || assignee.status !== "PENDING" || step.status !== "IN_PROGRESS" ) { return err(new NotActiveAssigneeError(input.approvalStepAssigneeId)); } // 5. C1b guard: parent request liveness if (request.status !== "PENDING") { return err(new ParentRequestNotPendingError(request.id)); } // 6. C3 guard: segregation of duties if (ctx.actorId === request.requesterId) { return err(new SelfDecisionNotAllowedError(request.id)); } // 7. Insert immutable ApprovalDecision row await db .insertInto("ApprovalDecision") .values({ approvalRequestId: request.id, approvalStepId: step.id, decision: "APPROVE", decidedByUserId: ctx.actorId, comment: input.comment, decidedAt: now, }) .returningAll() .executeTakeFirstOrThrow(); // 8. Transition assignee to APPROVED await db .updateTable("ApprovalStepAssignee") .set({ status: "APPROVED", resolvedAt: now }) .where("id", "=", assignee.id) .execute(); // 9. Voting evaluation: re-load all assignees on this step const stepAssignees = await db .selectFrom("ApprovalStepAssignee") .selectAll() .where("approvalStepId", "=", step.id) .execute(); // DELEGATED rows are skipped from voting checks; the delegate row replaces them. const activeAssignees = stepAssignees.filter((row) => row.status !== "DELEGATED"); // Per-user required guard: every roleId-null required=true assignee must be APPROVED const userRequiredPending = activeAssignees.some( (row) => row.roleId === null && row.required === true && row.status !== "APPROVED", ); if (userRequiredPending) { return ok({ approvalRequest: request }); } // Per-role group quorum guard: each distinct roleId group must satisfy its roleQuorum const roleGroups = new Map< string, { quorum: ApprovalStepAssigneeRoleQuorum | null; rows: typeof activeAssignees } >(); for (const row of activeAssignees) { if (row.roleId !== null) { const existing = roleGroups.get(row.roleId); if (existing) { existing.rows.push(row); } else { roleGroups.set(row.roleId, { quorum: row.roleQuorum, rows: [row] }); } } } for (const { quorum, rows } of roleGroups.values()) { if (quorum === "ALL") { if (rows.some((r) => r.status !== "APPROVED")) { return ok({ approvalRequest: request }); } } else if (quorum === "ANY") { if (!rows.some((r) => r.status === "APPROVED")) { return ok({ approvalRequest: request }); } } } // Step threshold guard: total APPROVED count must meet step.minimumApprovals const approvedCount = stepAssignees.filter((row) => row.status === "APPROVED").length; if (approvedCount < step.minimumApprovals) { return ok({ approvalRequest: request }); } // Step completes await db .updateTable("ApprovalStep") .set({ status: "APPROVED", resolvedAt: now }) .where("id", "=", step.id) .execute(); // 10. Find next PENDING step by stepOrder const nextStep = await db .selectFrom("ApprovalStep") .selectAll() .where("approvalRequestId", "=", request.id) .where("stepOrder", ">", step.stepOrder) .where("status", "=", "PENDING") .orderBy("stepOrder", "asc") .limit(1) .executeTakeFirst(); if (nextStep) { // 11. Activate next step: expand role-based assignees and set IN_PROGRESS const nextAssignees = await db .selectFrom("ApprovalStepAssignee") .selectAll() .where("approvalStepId", "=", nextStep.id) .execute(); let nextRuntimeAssigneeCount = 0; for (const row of nextAssignees) { // Direct assignee, or a row already expanded in a prior round (resubmit restart). if (row.userId !== null) { nextRuntimeAssigneeCount += 1; continue; } if (row.roleId !== null) { const usersResult = await deps.listUsersByRole(db, { roleId: row.roleId }, ctx); if (!usersResult.ok) { return err(new RoleHasNoActiveMembersError(row.roleId)); } const users = usersResult.value.users; if (users.length === 0) { return err(new RoleHasNoActiveMembersError(row.roleId)); } // Insert one StepAssignee per ACTIVE member, preserving the originating role and roleQuorum await db .insertInto("ApprovalStepAssignee") .values( users.map((user) => ({ approvalStepId: nextStep.id, userId: user.id, roleId: row.roleId, required: row.required, roleQuorum: row.roleQuorum, status: "PENDING", })), ) .execute(); // Drop the role-only seed row await db.deleteFrom("ApprovalStepAssignee").where("id", "=", row.id).execute(); nextRuntimeAssigneeCount += users.length; } } // Runtime quorum guard: after role expansion, the assignee count must be // sufficient to reach minimumApprovals. if (nextRuntimeAssigneeCount < nextStep.minimumApprovals) { return err(new StepQuorumUnreachableError(nextStep.id)); } await db .updateTable("ApprovalStep") .set({ status: "IN_PROGRESS" }) .where("id", "=", nextStep.id) .execute(); return ok({ approvalRequest: request }); } // 12. No further step — request transitions to APPROVED const updatedRequest = await db .updateTable("ApprovalRequest") .set({ status: "APPROVED", resolvedAt: now }) .where("id", "=", request.id) .returningAll() .executeTakeFirstOrThrow(); return ok({ approvalRequest: updatedRequest }); }