diff --git a/docs/implementation/task-f3-approval-workflow-builder-foundation.md b/docs/implementation/task-f3-approval-workflow-builder-foundation.md new file mode 100644 index 0000000..f321df5 --- /dev/null +++ b/docs/implementation/task-f3-approval-workflow-builder-foundation.md @@ -0,0 +1,112 @@ +# Task F.3: Approval Workflow Builder Foundation + +## Summary + +This task upgrades the existing approval workflow settings surface into a safer workflow builder foundation. Admins can now manage workflow metadata and steps through dedicated APIs and a richer settings UI without editing seed data directly. + +## Delivered + +- Extended approval workflow schema in [src/db/schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts): + - workflow: `description`, `isSystem`, `createdBy`, `updatedBy` + - step: `approvalMode` +- Added migration [drizzle/0003_workflow_builder_foundation.sql](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0003_workflow_builder_foundation.sql) +- Added builder validation schemas: + - [src/features/crm/approval/schemas/approval-workflow.schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/schemas/approval-workflow.schema.ts) + - [src/features/crm/approval/schemas/approval-step.schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/schemas/approval-step.schema.ts) +- Added builder server rules in [src/features/crm/approval/server/workflow-builder.service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/server/workflow-builder.service.ts) +- Replaced the settings APIs with builder-aware behavior under: + - [src/app/api/crm/settings/approval-workflows/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts) + - [src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts) +- Rebuilt workflow builder client contracts and UI in: + - [src/features/foundation/approval/types.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/types.ts) + - [src/features/foundation/approval/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/service.ts) + - [src/features/foundation/approval/queries.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/queries.ts) + - [src/features/foundation/approval/mutations.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/mutations.ts) + - [src/features/foundation/approval/components/approval-workflow-settings.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/components/approval-workflow-settings.tsx) + +## Workflow Safety Strategy + +F.3 uses the recommended lock strategy from the task: + +- if a workflow has ever been used by any approval request, the workflow becomes locked for metadata edits +- locked workflows also block step create, update, delete, and reorder +- admins must clone a used workflow before changing its behavior + +This prevents historical approval requests from silently changing meaning after configuration edits. + +## Clone and Activation Rules + +- clone copies workflow metadata and all active steps +- cloned workflows start inactive +- deactivation is blocked when: + - active approval requests still use the workflow + - active approval matrices still point to the workflow +- deletion is blocked when: + - any approval request has ever used the workflow + - active approval matrices still point to the workflow + +## Step Builder Rules + +- steps are managed individually instead of replacing the whole step list +- create supports inserting at a target `stepNumber` +- delete reorders remaining steps to continuous numbering +- reorder uses explicit `orderedStepIds` +- active workflows must retain at least one required step + +## Approval Mode Support + +Stored step modes: + +- `sequential` +- `any_one` +- `all_required` + +Current runtime still executes sequentially only. The builder UI marks non-sequential modes as stored for future use instead of pretending they already affect runtime behavior. + +## Permissions Added + +Extended in [src/lib/auth/rbac.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts): + +- `crm.approval.workflow.clone` +- `crm.approval.workflow.activate` +- `crm.approval.workflow.deactivate` +- `crm.approval.workflow.step.manage` + +Existing workflow read/create/update/delete permissions remain in use. + +## Audit Actions + +Builder routes now emit: + +- `create_approval_workflow` +- `update_approval_workflow` +- `delete_approval_workflow` +- `clone_approval_workflow` +- `activate_approval_workflow` +- `deactivate_approval_workflow` +- `create_approval_step` +- `update_approval_step` +- `delete_approval_step` +- `reorder_approval_steps` + +## Matrix Integration Hook + +Workflow list API now accepts optional filters: + +- `entityType` +- `activeOnly` + +This keeps Approval Matrix integration future-ready without requiring F.3 to build the matrix UI itself. + +## Known Limitations + +- approval runtime still executes sequentially even if a step stores `any_one` or `all_required` +- no drag-and-drop builder is included; reorder uses simple up/down actions +- no workflow version table exists yet; lock-and-clone is the current compatibility strategy +- matrix admin UI is still outside this task scope diff --git a/drizzle/0003_workflow_builder_foundation.sql b/drizzle/0003_workflow_builder_foundation.sql new file mode 100644 index 0000000..a6b4efa --- /dev/null +++ b/drizzle/0003_workflow_builder_foundation.sql @@ -0,0 +1,14 @@ +ALTER TABLE "crm_approval_workflows" +ADD COLUMN "description" text; +--> statement-breakpoint +ALTER TABLE "crm_approval_workflows" +ADD COLUMN "is_system" boolean DEFAULT false NOT NULL; +--> statement-breakpoint +ALTER TABLE "crm_approval_workflows" +ADD COLUMN "created_by" text NOT NULL DEFAULT ''; +--> statement-breakpoint +ALTER TABLE "crm_approval_workflows" +ADD COLUMN "updated_by" text NOT NULL DEFAULT ''; +--> statement-breakpoint +ALTER TABLE "crm_approval_steps" +ADD COLUMN "approval_mode" text DEFAULT 'sequential' NOT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0104412..3c37a29 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1782604800000, "tag": "0002_calm_approval_matrix", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1782691200000, + "tag": "0003_workflow_builder_foundation", + "breakpoints": true } ] } diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/plans/task-f.3.md b/plans/task-f.3.md new file mode 100644 index 0000000..ae1dea0 --- /dev/null +++ b/plans/task-f.3.md @@ -0,0 +1,504 @@ +# Task F.3: Approval Workflow Builder Foundation + +## Status + +Planned + +## Objective + +Build the Approval Workflow Builder foundation so admins can manage approval workflows and workflow steps without changing code or seed data. + +This task extends the F.2 Approval Matrix Foundation by making workflow definitions editable and maintainable through service/API/UI foundations. + +The goal is to support: + +* workflow list +* workflow create/edit +* workflow clone +* workflow activate/deactivate +* step add/update/delete +* step reorder +* matrix-ready workflow selection +* safe compatibility with existing approval requests + +This task does not implement Notification, Reminder, Escalation, or full parallel approval behavior yet. + +--- + +## Mandatory Review + +Review before implementation: + +* `AGENTS.md` +* `docs/standards/project-foundations.md` +* `docs/standards/task-catalog.md` +* `docs/implementation/task-f2-approval-matrix-foundation.md` +* existing Approval implementation +* existing Approval Matrix implementation +* `src/db/schema.ts` +* `src/features/crm/approval/**` +* `src/features/foundation/approval/**` +* `src/features/foundation/audit-log/**` +* `src/lib/auth/rbac.ts` +* `docs/security/crm-authorization-boundaries.md` + +--- + +## Current Foundation + +Already available: + +```txt +crm_approval_workflows +crm_approval_steps +crm_approval_requests +crm_approval_actions +crm_approval_matrices + +Approval matrix resolver +Quotation submit approval flow +Default quotation matrix +Approval audit foundation +``` + +Current limitation: + +```txt +Approval workflows and steps are mostly seed/config driven. +``` + +This task makes them manageable through Builder APIs and UI foundation. + +--- + +## Business Requirement + +Admin must be able to configure approval workflows such as: + +```txt +Quotation Standard Approval + 1. Sales Manager + 2. Department Manager + 3. CEO + +Quotation Crane Executive Approval + 1. Sales Manager + 2. Product Head + 3. Finance + 4. CEO +``` + +Approval Matrix from F.2 can then point to any active workflow. + +--- + +## Scope F.3.1 Workflow Builder Domain Rules + +Workflow has: + +```txt +code +name +entityType +description +isActive +steps +``` + +Step has: + +```txt +stepNumber +roleCode +roleName +approvalMode +isRequired +``` + +Recommended `approvalMode` values: + +```txt +sequential +any_one +all_required +``` + +For F.3 behavior: + +* `sequential` remains the only fully executed runtime mode if current approval engine supports only sequential +* `any_one` and `all_required` can be stored for future use if schema supports it +* if runtime does not support non-sequential modes, UI/API must clearly mark them as future/disabled + +--- + +## Scope F.3.2 Schema Review / Extension + +Review existing tables: + +```txt +crm_approval_workflows +crm_approval_steps +``` + +Add missing fields only if needed: + +Recommended workflow fields: + +```txt +description text null +is_system boolean default false +created_by text +updated_by text +``` + +Recommended step fields: + +```txt +approval_mode text default 'sequential' +approver_type text default 'role' +approver_ref text null +sort_order integer +``` + +Definitions: + +```txt +approver_type = role | user | permission | manager +approver_ref = role code, user id, permission code, or manager rule key +``` + +If current phase should stay simple, use only: + +```txt +roleCode +roleName +stepNumber +isRequired +``` + +and defer advanced approver resolution to later tasks. + +--- + +## Scope F.3.3 Workflow CRUD API + +Create or extend settings APIs: + +```txt +GET /api/crm/settings/approval-workflows +POST /api/crm/settings/approval-workflows +GET /api/crm/settings/approval-workflows/[id] +PATCH /api/crm/settings/approval-workflows/[id] +DELETE /api/crm/settings/approval-workflows/[id] +POST /api/crm/settings/approval-workflows/[id]/clone +POST /api/crm/settings/approval-workflows/[id]/activate +POST /api/crm/settings/approval-workflows/[id]/deactivate +``` + +Rules: + +* delete should be soft delete +* do not allow hard delete +* do not allow deleting workflow used by an active approval request +* deactivation allowed only if not used by active matrix or active approval request unless forced by admin policy +* clone creates a new workflow and copies steps +* code must be unique within organization + +--- + +## Scope F.3.4 Step Management API + +Create: + +```txt +GET /api/crm/settings/approval-workflows/[id]/steps +POST /api/crm/settings/approval-workflows/[id]/steps +PATCH /api/crm/settings/approval-workflows/[id]/steps/[stepId] +DELETE /api/crm/settings/approval-workflows/[id]/steps/[stepId] +POST /api/crm/settings/approval-workflows/[id]/steps/reorder +``` + +Rules: + +* step numbers must be continuous: 1, 2, 3... +* deleting a step reorders remaining steps +* reorder must be transactional +* at least one required step must remain for active workflow +* cannot modify steps of workflow with active approval requests unless versioning is implemented +* F.3 may choose strict lock behavior: + + * if workflow is already used by any approval request, require clone before edit + +Recommended: + +```txt +Existing used workflow = locked +Edit by clone only +``` + +This prevents historical approval requests from changing meaning retroactively. + +--- + +## Scope F.3.5 Workflow Version Safety + +Implement one of these strategies: + +### Option A: Lock Used Workflows + +If workflow has ever been used in approval request: + +```txt +workflow cannot be edited +steps cannot be edited +admin must clone workflow +``` + +This is recommended for F.3. + +### Option B: Versioned Workflows + +Create workflow versions. + +This is more powerful but larger scope. + +Recommendation: + +```txt +Use Option A in F.3. +Defer versioned workflows to future task if needed. +``` + +--- + +## Scope F.3.6 Workflow Builder UI + +Create settings page: + +```txt +/dashboard/crm/settings/approval-workflows +``` + +Required UI: + +* workflow list +* create workflow +* edit workflow metadata +* clone workflow +* activate/deactivate workflow +* view steps +* add step +* edit step +* delete step +* reorder steps +* usage warning if workflow is locked + +Use existing UI conventions: + +```txt +shadcn/ui +card layout +dialog/sheet forms +table +bento/card section where useful +design tokens only +``` + +No drag-and-drop is required in F.3. Simple up/down reorder is enough. + +--- + +## Scope F.3.7 Matrix Integration UI Hook + +Approval Matrix settings must be able to select an active workflow. + +If F.2 has no matrix UI yet, expose workflow list API so future matrix UI can consume it. + +Workflow list API should support: + +```txt +entityType filter +active only filter +``` + +--- + +## Scope F.3.8 Validation Rules + +Create schemas: + +```txt +src/features/crm/approval/schemas/approval-workflow.schema.ts +src/features/crm/approval/schemas/approval-step.schema.ts +``` + +Workflow validation: + +* code required +* code normalized lowercase / snake_case +* name required +* entityType required +* code unique per organization +* cannot update code if workflow is used + +Step validation: + +* roleCode required +* roleName required +* stepNumber positive integer +* approvalMode required +* isRequired boolean +* cannot create duplicate stepNumber +* cannot leave active workflow with no required step + +--- + +## Scope F.3.9 Audit Logging + +Audit actions: + +```txt +create_approval_workflow +update_approval_workflow +delete_approval_workflow +clone_approval_workflow +activate_approval_workflow +deactivate_approval_workflow + +create_approval_step +update_approval_step +delete_approval_step +reorder_approval_steps +``` + +Audit must capture: + +```txt +workflowId +stepId +before +after +actor +timestamp +``` + +--- + +## Scope F.3.10 Permissions + +Add or verify permissions: + +```txt +crm.approval.workflow.read +crm.approval.workflow.create +crm.approval.workflow.update +crm.approval.workflow.delete +crm.approval.workflow.clone +crm.approval.workflow.activate +crm.approval.workflow.deactivate +crm.approval.workflow.step.manage +``` + +Rules: + +* Admin/settings-only feature +* route handlers must not check role strings directly +* use existing CRM authorization foundation +* do not expose builder to normal sales users + +--- + +## Scope F.3.11 Documentation + +Create: + +```txt +docs/implementation/task-f3-approval-workflow-builder-foundation.md +``` + +Document: + +```txt +workflow schema +step schema +lock strategy +clone behavior +API routes +permission map +known limitations +``` + +--- + +## Explicit Non-Scope + +Do not implement: + +* Notification Center +* Email sending +* LINE sending +* approval reminder +* approval escalation +* delegation +* vacation substitute approval +* manager hierarchy approver lookup +* full workflow versioning +* drag-and-drop builder +* advanced parallel approval runtime +* approval dashboard + +These belong to later F tasks. + +--- + +## Verification + +Run: + +```txt +npm exec tsc --noEmit +npm run build +``` + +Manual verification: + +```txt +Create workflow +Edit unused workflow +Add steps +Edit steps +Delete step and verify reorder +Reorder steps +Clone workflow +Deactivate workflow +Activate workflow +Cannot edit used workflow if lock strategy is applied +Cannot delete workflow used by active approval request +Approval Matrix can resolve to newly created active workflow +Submit quotation approval still works +Approve/reject existing flow still works +Audit logs are created +``` + +--- + +## Definition of Done + +Task is complete when: + +* Approval Workflow Builder APIs exist +* Approval Workflow settings UI exists +* Admin can create/clone/activate/deactivate workflow +* Admin can add/edit/delete/reorder steps +* Used workflow edit safety is enforced +* Approval Matrix can select active workflows +* Existing quotation approval submit/approve/reject flow remains operational +* Audit logs capture workflow and step maintenance + +Result: + +```txt +Approval Workflow Builder Foundation = Established +Approval Matrix Configuration = Maintainable +Ready for F.4 Notification Framework +``` diff --git a/src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts new file mode 100644 index 0000000..2e681fb --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { + activateApprovalWorkflow, + getApprovalWorkflow +} from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowActivate + }); + const before = await getApprovalWorkflow(id, organization.id); + const updated = await activateApprovalWorkflow(id, organization.id, session.user.id); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_workflow', + entityId: id, + action: 'activate_approval_workflow', + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Approval workflow activated successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable activate approval workflow' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts new file mode 100644 index 0000000..f6745fe --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { cloneApprovalWorkflow, getApprovalWorkflow } from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowClone + }); + const before = await getApprovalWorkflow(id, organization.id); + const cloned = await cloneApprovalWorkflow(id, organization.id, session.user.id); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_workflow', + entityId: cloned.id, + action: 'clone_approval_workflow', + beforeData: before, + afterData: cloned + }); + + return NextResponse.json({ + success: true, + message: 'Approval workflow cloned successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable clone approval workflow' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts new file mode 100644 index 0000000..4f0aa0f --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { + deactivateApprovalWorkflow, + getApprovalWorkflow +} from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowDeactivate + }); + const before = await getApprovalWorkflow(id, organization.id); + const updated = await deactivateApprovalWorkflow(id, organization.id, session.user.id); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_workflow', + entityId: id, + action: 'deactivate_approval_workflow', + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Approval workflow deactivated successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable deactivate approval workflow' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/route.ts index 1093103..7e730a8 100644 --- a/src/app/api/crm/settings/approval-workflows/[id]/route.ts +++ b/src/app/api/crm/settings/approval-workflows/[id]/route.ts @@ -1,11 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { updateApprovalWorkflowSchema } from '@/features/crm/approval/schemas/approval-workflow.schema'; import { getApprovalWorkflow, softDeleteApprovalWorkflow, updateApprovalWorkflow -} from '@/features/foundation/approval/server/service'; +} from '@/features/crm/approval/server/workflow-builder.service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -13,13 +14,6 @@ type Params = { params: Promise<{ id: string }>; }; -const workflowSchema = z.object({ - code: z.string().min(1).optional(), - name: z.string().min(1).optional(), - entityType: z.string().min(1).optional(), - isActive: z.boolean().optional() -}); - export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; @@ -41,7 +35,7 @@ export async function GET(_request: NextRequest, { params }: Params) { if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load approval workflow' }, { status: 500 }); + return NextResponse.json({ message: 'Unable load approval workflow' }, { status: 500 }); } } @@ -51,15 +45,16 @@ export async function PATCH(request: NextRequest, { params }: Params) { const { organization, session } = await requireOrganizationAccess({ permission: PERMISSIONS.crmApprovalWorkflowUpdate }); - const payload = workflowSchema.parse(await request.json()); + const payload = updateApprovalWorkflowSchema.parse(await request.json()); const before = await getApprovalWorkflow(id, organization.id); - const updated = await updateApprovalWorkflow(id, organization.id, payload); + const updated = await updateApprovalWorkflow(id, organization.id, session.user.id, payload); - await auditUpdate({ + await auditAction({ organizationId: organization.id, userId: session.user.id, entityType: 'crm_approval_workflow', entityId: id, + action: 'update_approval_workflow', beforeData: before, afterData: updated }); @@ -81,7 +76,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to update approval workflow' }, { status: 500 }); + return NextResponse.json({ message: 'Unable update approval workflow' }, { status: 500 }); } } @@ -91,13 +86,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) { const { organization, session } = await requireOrganizationAccess({ permission: PERMISSIONS.crmApprovalWorkflowDelete }); - const result = await softDeleteApprovalWorkflow(id, organization.id); + const result = await softDeleteApprovalWorkflow(id, organization.id, session.user.id); - await auditDelete({ + await auditAction({ organizationId: organization.id, userId: session.user.id, entityType: 'crm_approval_workflow', entityId: id, + action: 'delete_approval_workflow', beforeData: result.before, afterData: result.after }); @@ -113,6 +109,6 @@ export async function DELETE(_request: NextRequest, { params }: Params) { if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to delete approval workflow' }, { status: 500 }); + return NextResponse.json({ message: 'Unable delete approval workflow' }, { status: 500 }); } } diff --git a/src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts new file mode 100644 index 0000000..50d11a3 --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { updateApprovalStepSchema } from '@/features/crm/approval/schemas/approval-step.schema'; +import { + deleteApprovalWorkflowStep, + getApprovalWorkflow, + updateApprovalWorkflowStep +} from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string; stepId: string }>; +}; + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id, stepId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowStepManage + }); + const payload = updateApprovalStepSchema.parse(await request.json()); + const before = await getApprovalWorkflow(id, organization.id); + const updated = await updateApprovalWorkflowStep(stepId, organization.id, payload); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_step', + entityId: stepId, + action: 'update_approval_step', + beforeData: before.steps, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Approval workflow step updated successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable update approval workflow step' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id, stepId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowStepManage + }); + const before = await getApprovalWorkflow(id, organization.id); + const result = await deleteApprovalWorkflowStep(stepId, organization.id); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_step', + entityId: stepId, + action: 'delete_approval_step', + beforeData: before.steps, + afterData: result.steps + }); + + return NextResponse.json({ + success: true, + message: 'Approval workflow step deleted successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable delete approval workflow step' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts new file mode 100644 index 0000000..a938b0a --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { reorderApprovalStepsSchema } from '@/features/crm/approval/schemas/approval-step.schema'; +import { + getApprovalWorkflow, + reorderApprovalWorkflowSteps +} from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowStepManage + }); + const payload = reorderApprovalStepsSchema.parse(await request.json()); + const before = await getApprovalWorkflow(id, organization.id); + const steps = await reorderApprovalWorkflowSteps(id, organization.id, payload); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_step', + entityId: id, + action: 'reorder_approval_steps', + beforeData: before.steps, + afterData: steps + }); + + return NextResponse.json({ + success: true, + message: 'Approval workflow steps reordered successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable reorder approval workflow steps' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts index 7ff88bf..115c651 100644 --- a/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts +++ b/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts @@ -1,50 +1,67 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { auditUpdate } from '@/features/foundation/audit-log/service'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { createApprovalStepSchema } from '@/features/crm/approval/schemas/approval-step.schema'; import { + createApprovalWorkflowStep, getApprovalWorkflow, - replaceApprovalWorkflowSteps -} from '@/features/foundation/approval/server/service'; -import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac'; + listApprovalWorkflowSteps +} from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; type Params = { params: Promise<{ id: string }>; }; -const stepSchema = z.object({ - stepNumber: z.number().int().min(1), - roleCode: z.enum(BUSINESS_ROLES), - roleName: z.string().min(1), - isRequired: z.boolean().optional() -}); +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalWorkflowRead + }); + const items = await listApprovalWorkflowSteps(id, organization.id); -const payloadSchema = z.object({ - steps: z.array(stepSchema) -}); + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approval workflow steps loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable load approval workflow steps' }, { status: 500 }); + } +} -export async function PUT(request: NextRequest, { params }: Params) { +export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmApprovalWorkflowUpdate + permission: PERMISSIONS.crmApprovalWorkflowStepManage }); - const payload = payloadSchema.parse(await request.json()); + const payload = createApprovalStepSchema.parse(await request.json()); const before = await getApprovalWorkflow(id, organization.id); - const steps = await replaceApprovalWorkflowSteps(id, organization.id, payload.steps); + const steps = await createApprovalWorkflowStep(id, organization.id, payload); - await auditUpdate({ + await auditAction({ organizationId: organization.id, userId: session.user.id, entityType: 'crm_approval_step', entityId: id, + action: 'create_approval_step', beforeData: before.steps, afterData: steps }); return NextResponse.json({ success: true, - message: 'Approval workflow steps updated successfully' + message: 'Approval workflow step created successfully' }); } catch (error) { if (error instanceof AuthError) { @@ -59,9 +76,6 @@ export async function PUT(request: NextRequest, { params }: Params) { if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json( - { message: 'Unable to update approval workflow steps' }, - { status: 500 } - ); + return NextResponse.json({ message: 'Unable create approval workflow step' }, { status: 500 }); } } diff --git a/src/app/api/crm/settings/approval-workflows/route.ts b/src/app/api/crm/settings/approval-workflows/route.ts index 3702036..b5077c3 100644 --- a/src/app/api/crm/settings/approval-workflows/route.ts +++ b/src/app/api/crm/settings/approval-workflows/route.ts @@ -1,26 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { auditCreate } from '@/features/foundation/audit-log/service'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { createApprovalWorkflowSchema, approvalWorkflowFilterSchema } from '@/features/crm/approval/schemas/approval-workflow.schema'; import { createApprovalWorkflow, listApprovalWorkflows -} from '@/features/foundation/approval/server/service'; -import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac'; +} from '@/features/crm/approval/server/workflow-builder.service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; -const workflowSchema = z.object({ - code: z.string().min(1), - name: z.string().min(1), - entityType: z.string().min(1), - isActive: z.boolean().optional() -}); - -export async function GET(_request: NextRequest) { +export async function GET(request: NextRequest) { try { const { organization } = await requireOrganizationAccess({ permission: PERMISSIONS.crmApprovalWorkflowRead }); - const items = await listApprovalWorkflows(organization.id); + const filters = approvalWorkflowFilterSchema.parse({ + entityType: request.nextUrl.searchParams.get('entityType') ?? undefined, + activeOnly: request.nextUrl.searchParams.get('activeOnly') ?? undefined + }); + const items = await listApprovalWorkflows(organization.id, filters); return NextResponse.json({ success: true, @@ -32,10 +30,16 @@ export async function GET(_request: NextRequest) { if (error instanceof AuthError) { return NextResponse.json({ message: error.message }, { status: error.status }); } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid filters' }, + { status: 400 } + ); + } if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load approval workflows' }, { status: 500 }); + return NextResponse.json({ message: 'Unable load approval workflows' }, { status: 500 }); } } @@ -44,21 +48,21 @@ export async function POST(request: NextRequest) { const { organization, session } = await requireOrganizationAccess({ permission: PERMISSIONS.crmApprovalWorkflowCreate }); - const payload = workflowSchema.parse(await request.json()); - const created = await createApprovalWorkflow(organization.id, payload); + const payload = createApprovalWorkflowSchema.parse(await request.json()); + const created = await createApprovalWorkflow(organization.id, session.user.id, payload); - await auditCreate({ + await auditAction({ organizationId: organization.id, userId: session.user.id, entityType: 'crm_approval_workflow', entityId: created.id, + action: 'create_approval_workflow', afterData: created }); return NextResponse.json({ success: true, - message: 'Approval workflow created successfully', - allowedRoles: BUSINESS_ROLES + message: 'Approval workflow created successfully' }); } catch (error) { if (error instanceof AuthError) { @@ -73,6 +77,6 @@ export async function POST(request: NextRequest) { if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to create approval workflow' }, { status: 500 }); + return NextResponse.json({ message: 'Unable create approval workflow' }, { status: 500 }); } } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 92cddb5..79795d3 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,24 +1,33 @@ -'use client'; +"use client"; -import { useRouter } from 'next/navigation'; +import { useRouter } from "next/navigation"; -import { Button } from '@/components/ui/button'; +import { Button } from "@/components/ui/button"; export default function NotFound() { const router = useRouter(); return ( -
- +
+ 404 -

Something's missing

-

Sorry, the page you are looking for doesn't exist or has been moved.

-
- -
diff --git a/src/db/schema.ts b/src/db/schema.ts index e7a7ad9..87baa16 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -645,7 +645,11 @@ export const crmApprovalWorkflows = pgTable( code: text('code').notNull(), name: text('name').notNull(), entityType: text('entity_type').notNull(), + description: text('description'), + isSystem: boolean('is_system').default(false).notNull(), isActive: boolean('is_active').default(true).notNull(), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp('deleted_at', { withTimezone: true }) @@ -667,6 +671,7 @@ export const crmApprovalSteps = pgTable( stepNumber: integer('step_number').notNull(), roleCode: text('role_code').notNull(), roleName: text('role_name').notNull(), + approvalMode: text('approval_mode').default('sequential').notNull(), isRequired: boolean('is_required').default(true).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 50d98c9..25d93c3 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -658,10 +658,27 @@ const APPROVAL_WORKFLOWS = [ code: 'quotation_standard_approval', name: 'Quotation Standard Approval', entityType: 'quotation', + description: 'Default sequential quotation approval workflow', + isSystem: true, steps: [ - { stepNumber: 1, roleCode: 'sales_manager', roleName: 'Sales Manager' }, - { stepNumber: 2, roleCode: 'department_manager', roleName: 'Department Manager' }, - { stepNumber: 3, roleCode: 'top_manager', roleName: 'Top Manager' } + { + stepNumber: 1, + roleCode: 'sales_manager', + roleName: 'Sales Manager', + approvalMode: 'sequential' + }, + { + stepNumber: 2, + roleCode: 'department_manager', + roleName: 'Department Manager', + approvalMode: 'sequential' + }, + { + stepNumber: 3, + roleCode: 'top_manager', + roleName: 'Top Manager', + approvalMode: 'sequential' + } ] } ]; diff --git a/src/features/crm/approval/schemas/approval-step.schema.ts b/src/features/crm/approval/schemas/approval-step.schema.ts new file mode 100644 index 0000000..6a074e1 --- /dev/null +++ b/src/features/crm/approval/schemas/approval-step.schema.ts @@ -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; +export type UpdateApprovalStepInput = z.infer; +export type ReorderApprovalStepsInput = z.infer; diff --git a/src/features/crm/approval/schemas/approval-workflow.schema.ts b/src/features/crm/approval/schemas/approval-workflow.schema.ts new file mode 100644 index 0000000..f7164e7 --- /dev/null +++ b/src/features/crm/approval/schemas/approval-workflow.schema.ts @@ -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; +export type CreateApprovalWorkflowInput = z.infer; +export type UpdateApprovalWorkflowInput = z.infer; diff --git a/src/features/crm/approval/server/workflow-builder.service.ts b/src/features/crm/approval/server/workflow-builder.service.ts new file mode 100644 index 0000000..6046d83 --- /dev/null +++ b/src/features/crm/approval/server/workflow-builder.service.ts @@ -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(); + } + + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/features/foundation/approval/components/approval-workflow-settings.tsx b/src/features/foundation/approval/components/approval-workflow-settings.tsx index 7330a61..3ac5036 100644 --- a/src/features/foundation/approval/components/approval-workflow-settings.tsx +++ b/src/features/foundation/approval/components/approval-workflow-settings.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { BUSINESS_ROLES } from '@/lib/auth/rbac'; import { Icons } from '@/components/icons'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -16,54 +17,75 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; import { + activateApprovalWorkflowMutation, + cloneApprovalWorkflowMutation, createApprovalWorkflowMutation, + createApprovalWorkflowStepMutation, + deactivateApprovalWorkflowMutation, deleteApprovalWorkflowMutation, - replaceApprovalWorkflowStepsMutation, - updateApprovalWorkflowMutation + deleteApprovalWorkflowStepMutation, + reorderApprovalWorkflowStepsMutation, + updateApprovalWorkflowMutation, + updateApprovalWorkflowStepMutation } from '../mutations'; -import { - approvalWorkflowByIdOptions, - approvalWorkflowsQueryOptions -} from '../queries'; +import { approvalWorkflowByIdOptions, approvalWorkflowsQueryOptions } from '../queries'; import type { ApprovalStepMutationPayload, + ApprovalStepRecord, + ApprovalStepUpdatePayload, ApprovalWorkflowDetail, ApprovalWorkflowMutationPayload } from '../types'; -type WorkflowState = { +const APPROVAL_MODES = [ + { value: 'sequential', label: 'Sequential', description: 'Runs step-by-step in current runtime.' }, + { value: 'any_one', label: 'Any One', description: 'Stored for future runtime support.' }, + { value: 'all_required', label: 'All Required', description: 'Stored for future runtime support.' } +] as const; + +type WorkflowFormState = { code: string; name: string; entityType: string; + description: string; isActive: boolean; }; -type StepState = { +type StepFormState = { stepNumber: string; roleCode: string; roleName: string; + approvalMode: ApprovalStepMutationPayload['approvalMode']; isRequired: boolean; }; -function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState { +function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowFormState { return { code: workflow?.code ?? '', name: workflow?.name ?? '', entityType: workflow?.entityType ?? 'quotation', + description: workflow?.description ?? '', isActive: workflow?.isActive ?? true }; } -function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] { - return ( - workflow?.steps.map((step) => ({ - stepNumber: String(step.stepNumber), - roleCode: step.roleCode, - roleName: step.roleName, - isRequired: step.isRequired - })) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }] - ); +function toStepState(step?: ApprovalStepRecord, stepNumber = 1): StepFormState { + return { + stepNumber: String(step?.stepNumber ?? stepNumber), + roleCode: step?.roleCode ?? 'sales_manager', + roleName: step?.roleName ?? 'Sales Manager', + approvalMode: step?.approvalMode ?? 'sequential', + isRequired: step?.isRequired ?? true + }; +} + +function friendlyRoleName(roleCode: string) { + return roleCode + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); } function WorkflowDialog({ @@ -75,7 +97,7 @@ function WorkflowDialog({ onOpenChange: (open: boolean) => void; workflow?: ApprovalWorkflowDetail; }) { - const [state, setState] = React.useState(toWorkflowState(workflow)); + const [state, setState] = React.useState(toWorkflowState(workflow)); const createMutation = useMutation({ ...createApprovalWorkflowMutation, onSuccess: () => { @@ -99,12 +121,16 @@ function WorkflowDialog({ } }, [open, workflow]); + const isPending = createMutation.isPending || updateMutation.isPending; + async function onSubmit(event: React.FormEvent) { event.preventDefault(); + const payload: ApprovalWorkflowMutationPayload = { code: state.code, name: state.name, entityType: state.entityType, + description: state.description.trim() || null, isActive: state.isActive }; @@ -116,26 +142,73 @@ function WorkflowDialog({ await createMutation.mutateAsync(payload); } - const isPending = createMutation.isPending || updateMutation.isPending; - return ( - + {workflow ? 'Edit Workflow' : 'Create Workflow'} - Sequential workflow only. No parallel or conditional logic is introduced here. + + Configure workflow metadata. Runtime still executes steps sequentially for now. + -
- setState((current) => ({ ...current, code: e.target.value }))} /> - setState((current) => ({ ...current, name: e.target.value }))} /> - setState((current) => ({ ...current, entityType: e.target.value }))} /> -
-
-
Active
-
Inactive workflows stay available in audit history only.
+ + +
+
+
Code
+ setState((current) => ({ ...current, code: event.target.value }))} + /> +
+
+
Entity Type
+ + setState((current) => ({ ...current, entityType: event.target.value })) + } + />
- setState((current) => ({ ...current, isActive: checked }))} />
+ +
+
Name
+ setState((current) => ({ ...current, name: event.target.value }))} + /> +
+ +
+
Description
+