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 { AssigneeFlagsIncompatibleError, AssigneeUserRoleXorViolatedError, CreationModeAmbiguousError, DuplicateRoleAssigneeError, DuplicateStepOrderError, DuplicateUserAssigneeError, InvalidMinimumApprovalsError, MissingRequiredFieldError, PolicyHasNoStepsError, PolicyNotActiveError, PolicyNotFoundError, RoleHasNoActiveMembersError, StepHasNoAssigneesError, StepQuorumUnreachableError, } from "../lib/errors.generated"; import type { UserManagementQueries } from "../module"; export interface CreateApprovalRequestAssigneeInput { userId?: string; roleId?: string; required?: boolean; roleQuorum?: ApprovalStepAssigneeRoleQuorum; } export interface CreateApprovalRequestStepInput { stepOrder: number; name: string; minimumApprovals?: number; assignees: CreateApprovalRequestAssigneeInput[]; } export type CreateApprovalRequestInput = { name: string; purpose: string; targetEntityType: string; targetEntityId: string; } & ( | { policyId: string; steps?: undefined } | { policyId?: undefined; steps: CreateApprovalRequestStepInput[] } ); export async function run( db: Transaction, input: CreateApprovalRequestInput, ctx: CommandContext, deps: Pick, ) { if (input.name.trim() === "") { return err(new MissingRequiredFieldError("name")); } if (input.purpose.trim() === "") { return err(new MissingRequiredFieldError("purpose")); } if (!input.targetEntityType) { return err(new MissingRequiredFieldError("targetEntityType")); } if (!input.targetEntityId) { return err(new MissingRequiredFieldError("targetEntityId")); } if ((input.policyId == null) === (input.steps == null)) { return err(new CreationModeAmbiguousError(input.name)); } // Insert the request and its step + assignee rows; the lowest-stepOrder step // is activated inline (status = IN_PROGRESS, role assignees expanded). The // requestId, the activating step row, and its raw assignee rows are returned // so the activation logic below can run uniformly across both modes. let request: { id: string } & Record; let activatingStep: { id: string; minimumApprovals: number; }; let activatingStepAssignees: Array<{ id: string; userId: string | null; roleId: string | null; required: boolean; roleQuorum: ApprovalStepAssigneeRoleQuorum | null; }>; if (input.policyId != null) { const policyId = input.policyId; const policy = await db .selectFrom("ApprovalPolicy") .selectAll() .where("id", "=", policyId) .executeTakeFirst(); if (!policy) { return err(new PolicyNotFoundError(policyId)); } if (policy.status !== "ACTIVE") { return err(new PolicyNotActiveError(policyId)); } const policySteps = await db .selectFrom("ApprovalPolicyStep") .selectAll() .where("approvalPolicyId", "=", policy.id) .orderBy("stepOrder", "asc") .execute(); request = await db .insertInto("ApprovalRequest") .values({ name: input.name, purpose: input.purpose, targetEntityType: input.targetEntityType, targetEntityId: input.targetEntityId, requesterId: ctx.actorId, sourcePolicyId: policy.id, status: "PENDING", }) .returningAll() .executeTakeFirstOrThrow(); let firstStepId: string | null = null; let firstStepMinimumApprovals = 0; for (const [index, ps] of policySteps.entries()) { const isFirst = index === 0; const step = await db .insertInto("ApprovalStep") .values({ approvalRequestId: request.id, stepOrder: ps.stepOrder, name: ps.name, minimumApprovals: ps.minimumApprovals, status: isFirst ? "IN_PROGRESS" : "PENDING", }) .returningAll() .executeTakeFirstOrThrow(); if (isFirst) { firstStepId = step.id; firstStepMinimumApprovals = ps.minimumApprovals; } const policyAssignees = await db .selectFrom("ApprovalPolicyStepAssignee") .selectAll() .where("approvalPolicyStepId", "=", ps.id) .execute(); if (policyAssignees.length > 0) { await db .insertInto("ApprovalStepAssignee") .values( policyAssignees.map((a) => ({ approvalStepId: step.id, userId: a.userId, roleId: a.roleId, required: a.required, roleQuorum: a.roleQuorum, status: "PENDING", })), ) .execute(); } } if (firstStepId === null) { return err(new PolicyHasNoStepsError(input.name)); } activatingStep = { id: firstStepId, minimumApprovals: firstStepMinimumApprovals }; activatingStepAssignees = await db .selectFrom("ApprovalStepAssignee") .selectAll() .where("approvalStepId", "=", firstStepId) .execute(); } else { const steps = input.steps; if (steps.length === 0) { return err(new PolicyHasNoStepsError(input.name)); } const stepOrders = new Set(); for (const s of steps) { if (stepOrders.has(s.stepOrder)) { return err(new DuplicateStepOrderError(input.name)); } stepOrders.add(s.stepOrder); if (s.assignees.length === 0) { return err(new StepHasNoAssigneesError(s.name)); } const min = s.minimumApprovals ?? 1; if (!Number.isInteger(min) || min < 1) { return err(new InvalidMinimumApprovalsError(s.name)); } const seenUserIds = new Set(); const seenRoleIds = new Set(); for (const { userId, roleId, required, roleQuorum } of s.assignees) { const hasUser = userId !== undefined; const hasRole = roleId !== undefined; if (hasUser === hasRole) { return err(new AssigneeUserRoleXorViolatedError(s.name)); } if (hasUser && roleQuorum !== undefined) { return err(new AssigneeFlagsIncompatibleError(s.name)); } if (hasRole && required === true) { return err(new AssigneeFlagsIncompatibleError(s.name)); } if (userId !== undefined) { if (seenUserIds.has(userId)) { return err(new DuplicateUserAssigneeError(s.name)); } seenUserIds.add(userId); } if (roleId !== undefined) { if (seenRoleIds.has(roleId)) { return err(new DuplicateRoleAssigneeError(s.name)); } seenRoleIds.add(roleId); } } } request = await db .insertInto("ApprovalRequest") .values({ name: input.name, purpose: input.purpose, targetEntityType: input.targetEntityType, targetEntityId: input.targetEntityId, requesterId: ctx.actorId, status: "PENDING", }) .returningAll() .executeTakeFirstOrThrow(); const orderedSteps = [...steps].sort((a, b) => a.stepOrder - b.stepOrder); let firstStepId: string | null = null; let firstStepMinimumApprovals = 0; for (const [index, s] of orderedSteps.entries()) { const isFirst = index === 0; const minimumApprovals = s.minimumApprovals ?? 1; const step = await db .insertInto("ApprovalStep") .values({ approvalRequestId: request.id, stepOrder: s.stepOrder, name: s.name, minimumApprovals, status: isFirst ? "IN_PROGRESS" : "PENDING", }) .returningAll() .executeTakeFirstOrThrow(); if (isFirst) { firstStepId = step.id; firstStepMinimumApprovals = minimumApprovals; } await db .insertInto("ApprovalStepAssignee") .values( s.assignees.map((a) => ({ approvalStepId: step.id, userId: a.userId, roleId: a.roleId, required: a.required ?? false, roleQuorum: a.roleQuorum, status: "PENDING", })), ) .execute(); } if (firstStepId === null) { return err(new PolicyHasNoStepsError(input.name)); } activatingStep = { id: firstStepId, minimumApprovals: firstStepMinimumApprovals }; activatingStepAssignees = await db .selectFrom("ApprovalStepAssignee") .selectAll() .where("approvalStepId", "=", firstStepId) .execute(); } // Activate the lowest-stepOrder step inline: expand role-based assignees and // verify the runtime assignee count meets the step's minimumApprovals. let runtimeAssigneeCount = 0; for (const row of activatingStepAssignees) { if (row.userId !== null && row.roleId === null) { runtimeAssigneeCount += 1; continue; } if (row.userId === null && 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)); } await db .insertInto("ApprovalStepAssignee") .values( users.map((user) => ({ approvalStepId: activatingStep.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(); runtimeAssigneeCount += users.length; } } if (runtimeAssigneeCount < activatingStep.minimumApprovals) { return err(new StepQuorumUnreachableError(activatingStep.id)); } return ok({ approvalRequest: request }); }