This commit is contained in:
phaichayon
2026-06-26 12:04:17 +07:00
parent 052677fa2c
commit 2ad57f0ad9
26 changed files with 2653 additions and 250 deletions

View File

@@ -0,0 +1,29 @@
import { z } from 'zod';
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
export const APPROVAL_MODES = ['sequential', 'any_one', 'all_required'] as const;
export const approvalStepSchema = z.object({
stepNumber: z.number().int().min(1),
roleCode: z.enum(BUSINESS_ROLES),
roleName: z.string().trim().min(1),
approvalMode: z.enum(APPROVAL_MODES).default('sequential'),
isRequired: z.boolean().default(true)
});
export const createApprovalStepSchema = approvalStepSchema;
export const updateApprovalStepSchema = z.object({
roleCode: z.enum(BUSINESS_ROLES).optional(),
roleName: z.string().trim().min(1).optional(),
approvalMode: z.enum(APPROVAL_MODES).optional(),
isRequired: z.boolean().optional()
});
export const reorderApprovalStepsSchema = z.object({
orderedStepIds: z.array(z.string().min(1)).min(1)
});
export type CreateApprovalStepInput = z.infer<typeof createApprovalStepSchema>;
export type UpdateApprovalStepInput = z.infer<typeof updateApprovalStepSchema>;
export type ReorderApprovalStepsInput = z.infer<typeof reorderApprovalStepsSchema>;

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
function normalizeWorkflowCode(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
const nullableTrimmedString = z
.string()
.trim()
.optional()
.nullable()
.transform((value) => {
if (!value) {
return null;
}
return value;
});
export const approvalWorkflowFilterSchema = z.object({
entityType: z.string().trim().optional(),
activeOnly: z
.union([z.literal('true'), z.literal('false')])
.optional()
.transform((value) => value === 'true')
});
export const createApprovalWorkflowSchema = z.object({
code: z
.string()
.min(1)
.transform((value) => normalizeWorkflowCode(value))
.refine((value) => value.length > 0, {
message: 'code is required'
}),
name: z.string().trim().min(1),
entityType: z.string().trim().min(1),
description: nullableTrimmedString,
isActive: z.boolean().optional()
});
export const updateApprovalWorkflowSchema = z.object({
code: z
.string()
.min(1)
.optional()
.transform((value) => (value ? normalizeWorkflowCode(value) : value)),
name: z.string().trim().min(1).optional(),
entityType: z.string().trim().min(1).optional(),
description: nullableTrimmedString.optional()
});
export type ApprovalWorkflowFiltersInput = z.infer<typeof approvalWorkflowFilterSchema>;
export type CreateApprovalWorkflowInput = z.infer<typeof createApprovalWorkflowSchema>;
export type UpdateApprovalWorkflowInput = z.infer<typeof updateApprovalWorkflowSchema>;

View File

@@ -0,0 +1,767 @@
import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import {
crmApprovalMatrices,
crmApprovalRequests,
crmApprovalSteps,
crmApprovalWorkflows
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import type {
CreateApprovalStepInput,
ReorderApprovalStepsInput,
UpdateApprovalStepInput
} from '../schemas/approval-step.schema';
import type {
ApprovalWorkflowFiltersInput,
CreateApprovalWorkflowInput,
UpdateApprovalWorkflowInput
} from '../schemas/approval-workflow.schema';
export interface ApprovalWorkflowUsage {
totalRequestCount: number;
activeRequestCount: number;
activeMatrixCount: number;
isLocked: boolean;
lockedReason: string | null;
canEdit: boolean;
canDelete: boolean;
canDeactivate: boolean;
}
export interface ApprovalWorkflowRecord {
id: string;
organizationId: string;
code: string;
name: string;
entityType: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface ApprovalStepRecord {
id: string;
organizationId: string;
workflowId: string;
stepNumber: number;
roleCode: string;
roleName: string;
approvalMode: 'sequential' | 'any_one' | 'all_required';
isRequired: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
stepCount: number;
usage: ApprovalWorkflowUsage;
}
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
steps: ApprovalStepRecord[];
usage: ApprovalWorkflowUsage;
}
export interface ApprovalWorkflowListFilters {
entityType?: string;
activeOnly?: boolean;
}
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
return {
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name,
entityType: row.entityType,
description: row.description ?? null,
isSystem: row.isSystem,
isActive: row.isActive,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepRecord {
return {
id: row.id,
organizationId: row.organizationId,
workflowId: row.workflowId,
stepNumber: row.stepNumber,
roleCode: row.roleCode,
roleName: row.roleName,
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
isRequired: row.isRequired,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function assertWorkflow(id: string, organizationId: string) {
const [workflow] = await db
.select()
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.id, id),
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt)
)
)
.limit(1);
if (!workflow) {
throw new AuthError('Approval workflow not found', 404);
}
return workflow;
}
async function assertStep(stepId: string, organizationId: string) {
const [step] = await db
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.id, stepId),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.limit(1);
if (!step) {
throw new AuthError('Approval step not found', 404);
}
return step;
}
async function listWorkflowStepsInternal(workflowId: string, organizationId: string) {
return db
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.workflowId, workflowId),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.orderBy(asc(crmApprovalSteps.stepNumber), asc(crmApprovalSteps.createdAt));
}
async function ensureWorkflowCodeUnique(
organizationId: string,
code: string,
excludedWorkflowId?: string
) {
const [existing] = await db
.select({ id: crmApprovalWorkflows.id })
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
eq(crmApprovalWorkflows.code, code),
isNull(crmApprovalWorkflows.deletedAt),
...(excludedWorkflowId ? [sql`${crmApprovalWorkflows.id} <> ${excludedWorkflowId}`] : [])
)
)
.limit(1);
if (existing) {
throw new AuthError('Approval workflow code already exists', 409);
}
}
async function getWorkflowUsageMap(workflowIds: string[], organizationId: string) {
if (workflowIds.length === 0) {
return new Map<string, ApprovalWorkflowUsage>();
}
const [allRequests, activeRequests, activeMatrices] = await Promise.all([
db
.select({
workflowId: crmApprovalRequests.workflowId,
count: count()
})
.from(crmApprovalRequests)
.where(
and(
eq(crmApprovalRequests.organizationId, organizationId),
inArray(crmApprovalRequests.workflowId, workflowIds),
isNull(crmApprovalRequests.deletedAt)
)
)
.groupBy(crmApprovalRequests.workflowId),
db
.select({
workflowId: crmApprovalRequests.workflowId,
count: count()
})
.from(crmApprovalRequests)
.where(
and(
eq(crmApprovalRequests.organizationId, organizationId),
inArray(crmApprovalRequests.workflowId, workflowIds),
eq(crmApprovalRequests.status, 'pending'),
isNull(crmApprovalRequests.deletedAt)
)
)
.groupBy(crmApprovalRequests.workflowId),
db
.select({
workflowId: crmApprovalMatrices.workflowId,
count: count()
})
.from(crmApprovalMatrices)
.where(
and(
eq(crmApprovalMatrices.organizationId, organizationId),
inArray(crmApprovalMatrices.workflowId, workflowIds),
eq(crmApprovalMatrices.isActive, true),
isNull(crmApprovalMatrices.deletedAt)
)
)
.groupBy(crmApprovalMatrices.workflowId)
]);
const requestMap = new Map(allRequests.map((row) => [row.workflowId, Number(row.count)]));
const activeRequestMap = new Map(activeRequests.map((row) => [row.workflowId, Number(row.count)]));
const activeMatrixMap = new Map(activeMatrices.map((row) => [row.workflowId, Number(row.count)]));
return new Map(
workflowIds.map((workflowId) => {
const totalRequestCount = requestMap.get(workflowId) ?? 0;
const activeRequestCount = activeRequestMap.get(workflowId) ?? 0;
const activeMatrixCount = activeMatrixMap.get(workflowId) ?? 0;
const isLocked = totalRequestCount > 0;
const lockedReason = isLocked
? 'This workflow has already been used by approval requests. Clone it before editing.'
: null;
return [
workflowId,
{
totalRequestCount,
activeRequestCount,
activeMatrixCount,
isLocked,
lockedReason,
canEdit: !isLocked,
canDelete: totalRequestCount === 0 && activeMatrixCount === 0,
canDeactivate: activeRequestCount === 0 && activeMatrixCount === 0
} satisfies ApprovalWorkflowUsage
];
})
);
}
async function assertWorkflowEditable(workflowId: string, organizationId: string) {
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
if (usage?.isLocked) {
throw new AuthError(usage.lockedReason ?? 'Workflow is locked', 400);
}
return usage;
}
async function assertWorkflowCanDeactivate(workflowId: string, organizationId: string) {
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
if (!usage) {
throw new AuthError('Approval workflow not found', 404);
}
if (!usage.canDeactivate) {
throw new AuthError(
'Workflow cannot be deactivated while active matrices or pending approval requests still reference it',
400
);
}
return usage;
}
async function assertWorkflowCanDelete(workflowId: string, organizationId: string) {
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
if (!usage) {
throw new AuthError('Approval workflow not found', 404);
}
if (!usage.canDelete) {
throw new AuthError(
'Workflow cannot be deleted because it is used by approval requests or active approval matrices',
400
);
}
return usage;
}
async function assertWorkflowHasRequiredSteps(workflowId: string, organizationId: string) {
const steps = await listWorkflowStepsInternal(workflowId, organizationId);
if (steps.length === 0) {
throw new AuthError('Active workflow must have at least one step', 400);
}
if (!steps.some((step) => step.isRequired)) {
throw new AuthError('Active workflow must have at least one required step', 400);
}
}
async function nextCloneCode(organizationId: string, baseCode: string) {
const normalizedBase = `${baseCode}_copy`;
const existingCodes = await db
.select({ code: crmApprovalWorkflows.code })
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt)
)
);
const existing = new Set(existingCodes.map((row) => row.code));
if (!existing.has(normalizedBase)) {
return normalizedBase;
}
let index = 2;
while (existing.has(`${normalizedBase}_${index}`)) {
index += 1;
}
return `${normalizedBase}_${index}`;
}
export async function listApprovalWorkflows(
organizationId: string,
filters: ApprovalWorkflowListFilters = {}
): Promise<ApprovalWorkflowListItem[]> {
const rows = await db
.select()
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt),
...(filters.entityType ? [eq(crmApprovalWorkflows.entityType, filters.entityType)] : []),
...(filters.activeOnly ? [eq(crmApprovalWorkflows.isActive, true)] : [])
)
)
.orderBy(
asc(crmApprovalWorkflows.entityType),
desc(crmApprovalWorkflows.isActive),
asc(crmApprovalWorkflows.name)
);
const workflowIds = rows.map((row) => row.id);
const [steps, usageMap] = await Promise.all([
workflowIds.length
? db
.select({
workflowId: crmApprovalSteps.workflowId
})
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.organizationId, organizationId),
inArray(crmApprovalSteps.workflowId, workflowIds),
isNull(crmApprovalSteps.deletedAt)
)
)
: Promise.resolve([]),
getWorkflowUsageMap(workflowIds, organizationId)
]);
const stepCountMap = new Map<string, number>();
for (const step of steps) {
stepCountMap.set(step.workflowId, (stepCountMap.get(step.workflowId) ?? 0) + 1);
}
return rows.map((row) => ({
...mapWorkflowRecord(row),
stepCount: stepCountMap.get(row.id) ?? 0,
usage: usageMap.get(row.id) ?? {
totalRequestCount: 0,
activeRequestCount: 0,
activeMatrixCount: 0,
isLocked: false,
lockedReason: null,
canEdit: true,
canDelete: true,
canDeactivate: true
}
}));
}
export async function getApprovalWorkflow(
id: string,
organizationId: string
): Promise<ApprovalWorkflowDetail> {
const workflow = await assertWorkflow(id, organizationId);
const [steps, usageMap] = await Promise.all([
listWorkflowStepsInternal(id, organizationId),
getWorkflowUsageMap([id], organizationId)
]);
return {
...mapWorkflowRecord(workflow),
steps: steps.map(mapStepRecord),
usage: usageMap.get(id) ?? {
totalRequestCount: 0,
activeRequestCount: 0,
activeMatrixCount: 0,
isLocked: false,
lockedReason: null,
canEdit: true,
canDelete: true,
canDeactivate: true
}
};
}
export async function createApprovalWorkflow(
organizationId: string,
userId: string,
payload: CreateApprovalWorkflowInput
): Promise<ApprovalWorkflowRecord> {
await ensureWorkflowCodeUnique(organizationId, payload.code);
const [created] = await db
.insert(crmApprovalWorkflows)
.values({
id: crypto.randomUUID(),
organizationId,
code: payload.code,
name: payload.name.trim(),
entityType: payload.entityType.trim(),
description: payload.description ?? null,
isSystem: false,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
return mapWorkflowRecord(created);
}
export async function updateApprovalWorkflow(
id: string,
organizationId: string,
userId: string,
payload: UpdateApprovalWorkflowInput
): Promise<ApprovalWorkflowRecord> {
await assertWorkflowEditable(id, organizationId);
const current = await assertWorkflow(id, organizationId);
const nextCode = payload.code ?? current.code;
await ensureWorkflowCodeUnique(organizationId, nextCode, id);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
code: nextCode,
name: payload.name?.trim() ?? current.name,
entityType: payload.entityType?.trim() ?? current.entityType,
description: payload.description !== undefined ? payload.description : current.description,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function cloneApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<ApprovalWorkflowDetail> {
const current = await assertWorkflow(id, organizationId);
const currentSteps = await listWorkflowStepsInternal(id, organizationId);
const cloneCode = await nextCloneCode(organizationId, current.code);
const clonedWorkflow = await db.transaction(async (tx) => {
const [workflow] = await tx
.insert(crmApprovalWorkflows)
.values({
id: crypto.randomUUID(),
organizationId,
code: cloneCode,
name: `${current.name} Copy`,
entityType: current.entityType,
description: current.description,
isSystem: false,
isActive: false,
createdBy: userId,
updatedBy: userId
})
.returning();
if (currentSteps.length > 0) {
await tx.insert(crmApprovalSteps).values(
currentSteps.map((step) => ({
id: crypto.randomUUID(),
organizationId,
workflowId: workflow.id,
stepNumber: step.stepNumber,
roleCode: step.roleCode,
roleName: step.roleName,
approvalMode: step.approvalMode,
isRequired: step.isRequired
}))
);
}
return workflow;
});
return getApprovalWorkflow(clonedWorkflow.id, organizationId);
}
export async function activateApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<ApprovalWorkflowRecord> {
await assertWorkflowHasRequiredSteps(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
isActive: true,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function deactivateApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<ApprovalWorkflowRecord> {
await assertWorkflowCanDeactivate(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
isActive: false,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function softDeleteApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<{ before: ApprovalWorkflowDetail; after: ApprovalWorkflowRecord }> {
const before = await getApprovalWorkflow(id, organizationId);
await assertWorkflowCanDelete(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return {
before,
after: mapWorkflowRecord(updated)
};
}
export async function listApprovalWorkflowSteps(
workflowId: string,
organizationId: string
): Promise<ApprovalStepRecord[]> {
await assertWorkflow(workflowId, organizationId);
const steps = await listWorkflowStepsInternal(workflowId, organizationId);
return steps.map(mapStepRecord);
}
export async function createApprovalWorkflowStep(
workflowId: string,
organizationId: string,
payload: CreateApprovalStepInput
): Promise<ApprovalStepRecord[]> {
await assertWorkflowEditable(workflowId, organizationId);
const workflow = await assertWorkflow(workflowId, organizationId);
await db.transaction(async (tx) => {
const existingSteps = await tx
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.workflowId, workflowId),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.orderBy(asc(crmApprovalSteps.stepNumber));
const maxStepNumber = existingSteps.length + 1;
if (payload.stepNumber > maxStepNumber) {
throw new AuthError(`stepNumber must be between 1 and ${maxStepNumber}`, 400);
}
await Promise.all(
existingSteps
.filter((step) => step.stepNumber >= payload.stepNumber)
.sort((left, right) => right.stepNumber - left.stepNumber)
.map((step) =>
tx
.update(crmApprovalSteps)
.set({
stepNumber: step.stepNumber + 1,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, step.id))
)
);
await tx.insert(crmApprovalSteps).values({
id: crypto.randomUUID(),
organizationId,
workflowId,
stepNumber: payload.stepNumber,
roleCode: payload.roleCode,
roleName: payload.roleName.trim(),
approvalMode: payload.approvalMode,
isRequired: payload.isRequired
});
});
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(workflowId, organizationId);
}
return listApprovalWorkflowSteps(workflowId, organizationId);
}
export async function updateApprovalWorkflowStep(
stepId: string,
organizationId: string,
payload: UpdateApprovalStepInput
): Promise<ApprovalStepRecord> {
const current = await assertStep(stepId, organizationId);
await assertWorkflowEditable(current.workflowId, organizationId);
const [updated] = await db
.update(crmApprovalSteps)
.set({
roleCode: payload.roleCode ?? current.roleCode,
roleName: payload.roleName?.trim() ?? current.roleName,
approvalMode: payload.approvalMode ?? current.approvalMode,
isRequired: payload.isRequired ?? current.isRequired,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, stepId))
.returning();
const workflow = await assertWorkflow(current.workflowId, organizationId);
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(current.workflowId, organizationId);
}
return mapStepRecord(updated);
}
export async function deleteApprovalWorkflowStep(
stepId: string,
organizationId: string
): Promise<{ before: ApprovalStepRecord; after: ApprovalStepRecord; steps: ApprovalStepRecord[] }> {
const current = await assertStep(stepId, organizationId);
await assertWorkflowEditable(current.workflowId, organizationId);
const workflow = await assertWorkflow(current.workflowId, organizationId);
const [updated] = await db
.update(crmApprovalSteps)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, stepId))
.returning();
const remainingSteps = await listWorkflowStepsInternal(current.workflowId, organizationId);
await Promise.all(
remainingSteps.map((step, index) =>
db
.update(crmApprovalSteps)
.set({
stepNumber: index + 1,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, step.id))
)
);
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(current.workflowId, organizationId);
}
return {
before: mapStepRecord(current),
after: mapStepRecord(updated),
steps: await listApprovalWorkflowSteps(current.workflowId, organizationId)
};
}
export async function reorderApprovalWorkflowSteps(
workflowId: string,
organizationId: string,
payload: ReorderApprovalStepsInput
): Promise<ApprovalStepRecord[]> {
await assertWorkflowEditable(workflowId, organizationId);
const existingSteps = await listWorkflowStepsInternal(workflowId, organizationId);
if (existingSteps.length !== payload.orderedStepIds.length) {
throw new AuthError('orderedStepIds must include every active step exactly once', 400);
}
const existingIds = new Set(existingSteps.map((step) => step.id));
for (const stepId of payload.orderedStepIds) {
if (!existingIds.has(stepId)) {
throw new AuthError('orderedStepIds contains an unknown step', 400);
}
}
await db.transaction(async (tx) => {
await Promise.all(
payload.orderedStepIds.map((stepId, index) =>
tx
.update(crmApprovalSteps)
.set({
stepNumber: index + 1,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, stepId))
)
);
});
const workflow = await assertWorkflow(workflowId, organizationId);
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(workflowId, organizationId);
}
return listApprovalWorkflowSteps(workflowId, organizationId);
}