diff --git a/docs/implementation/task-f2-approval-matrix-foundation.md b/docs/implementation/task-f2-approval-matrix-foundation.md new file mode 100644 index 0000000..20a772d --- /dev/null +++ b/docs/implementation/task-f2-approval-matrix-foundation.md @@ -0,0 +1,81 @@ +# Task F.2: Approval Matrix Foundation + +## Summary + +This task adds approval-matrix based workflow resolution for quotation submission so the system no longer relies on a single static workflow code. + +## Delivered + +- Added `crm_approval_matrices` persistence in [src/db/schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts). +- Added SQL migration in [drizzle/0002_calm_approval_matrix.sql](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0002_calm_approval_matrix.sql). +- Seeded a default quotation matrix in [src/db/seeds/foundation.seed.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts) to preserve current behavior. +- Added matrix validation in [src/features/crm/approval/schemas/approval-matrix.schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/schemas/approval-matrix.schema.ts). +- Added matrix CRUD and resolver services in [src/features/crm/approval/server/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/server/service.ts). +- Added settings APIs: + - [src/app/api/crm/settings/approval-matrices/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-matrices/route.ts) + - [src/app/api/crm/settings/approval-matrices/[id]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-matrices/[id]/route.ts) +- Updated quotation submit approval flow in [src/app/api/crm/quotations/[id]/submit-approval/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/submit-approval/route.ts) to resolve workflow by matrix before creating the approval request. +- Extended foundation approval submit input 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/server/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/server/service.ts) +- Added permissions in [src/lib/auth/rbac.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts): + - `crm.approval.matrix.read` + - `crm.approval.matrix.create` + - `crm.approval.matrix.update` + - `crm.approval.matrix.delete` + +## Matching Rules + +- Scope filters consider organization, entity type, active state, and soft delete. +- `productType`, `branchId`, and `currency` match exact values or `null` wildcard. +- `amount` respects `minAmount` and `maxAmount` when those bounds exist. +- Specific rules outrank default rules. +- Specificity scoring prefers: + - product type match + - branch match + - currency match + - amount-range match +- Lower `priority` value wins after specificity. +- If specificity and priority tie, the newest active rule wins. +- If candidates still tie on the same creation timestamp, the resolver throws a deterministic configuration conflict. +- If no specific rule matches, the resolver falls back to an active default rule. + +## Submit Approval Integration + +- Quotation submit approval now: + 1. loads the quotation + 2. enforces CRM scoped access + 3. resolves the approval matrix + 4. submits approval using the resolved `workflowId` + 5. writes audit data for the resolution +- Existing approve, reject, return, and cancel flows remain unchanged. + +## Audit + +- Matrix CRUD uses `entityType = crm_approval_matrix`. +- Matrix maintenance actions: + - `create_approval_matrix` + - `update_approval_matrix` + - `delete_approval_matrix` +- Quotation submission writes `resolve_approval_matrix` with: + - `quotationId` + - `approvalRequestId` + - `matrixId` + - `workflowId` + - `matchReason` + - `productType` + - `branchId` + - `amount` + - `currency` + +## Seed Behavior + +- Foundation seed creates or refreshes one active default quotation matrix pointing to `quotation_standard_approval`. +- This preserves legacy quotation approval behavior until organization-specific rules are added. +- Sample non-default matrices were intentionally not seeded because additional workflows such as executive approval are not yet guaranteed in every environment. + +## Known Limitations + +- F.2 adds API and service foundations only. No matrix admin UI is included. +- Overlapping active rules are allowed. Resolver handles them deterministically, but configuration hygiene still matters. +- Matrix branch, product, and currency values currently assume the same ids already stored on quotation records. diff --git a/drizzle/0002_calm_approval_matrix.sql b/drizzle/0002_calm_approval_matrix.sql new file mode 100644 index 0000000..d6551a7 --- /dev/null +++ b/drizzle/0002_calm_approval_matrix.sql @@ -0,0 +1,20 @@ +CREATE TABLE "crm_approval_matrices" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "entity_type" text NOT NULL, + "workflow_id" text NOT NULL, + "branch_id" text, + "product_type" text, + "min_amount" double precision, + "max_amount" double precision, + "currency" text, + "priority" integer DEFAULT 100 NOT NULL, + "is_default" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "description" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e66170e..0104412 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1782381033930, "tag": "0001_lazy_otto_octavius", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1782604800000, + "tag": "0002_calm_approval_matrix", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/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.2.md b/plans/task-f.2.md new file mode 100644 index 0000000..59053f0 --- /dev/null +++ b/plans/task-f.2.md @@ -0,0 +1,501 @@ +# Task F.2: Approval Matrix Foundation + +## Status + +Planned + +## Objective + +Build the Approval Matrix foundation so the system can select the correct approval workflow automatically when a quotation is submitted for approval. + +This task upgrades approval from a single static workflow to rule-based workflow resolution. + +The goal is to support approval flow selection by: + +* entity type +* product type +* branch +* quotation amount +* default fallback +* priority when multiple rules match + +This task does not build the full Workflow Builder UI or Notification system yet. + +--- + +## Mandatory Review + +Review before implementation: + +* `AGENTS.md` +* `docs/standards/project-foundations.md` +* `docs/standards/task-catalog.md` +* existing Approval implementation +* existing Quotation submit approval flow +* `src/db/schema.ts` +* `src/features/crm/approval/**` +* `src/features/crm/quotations/**` +* `src/features/crm/opportunities/**` +* `src/features/foundation/audit-log/**` +* `src/features/foundation/master-options/**` +* `docs/security/crm-authorization-boundaries.md` + +--- + +## Current Foundation + +Already available: + +```txt +crm_approval_workflows +crm_approval_steps +crm_approval_requests +crm_approval_actions + +quotation_standard_approval +quotation submit approval flow +quotation approval actions +audit foundation +``` + +Current limitation: + +```txt +Quotation approval uses a fixed or first active workflow. +``` + +This task introduces matrix-based workflow resolution. + +--- + +## Business Requirement + +When a quotation is submitted for approval, the system must determine: + +```txt +Which approval workflow should this quotation use? +``` + +The workflow should be selected based on business conditions. + +Example: + +```txt +Crane quotation under 500,000 +→ Crane Standard Approval + +Crane quotation over 5,000,000 +→ Crane Executive Approval + +Dock Door quotation +→ Dock Door Approval + +No specific match +→ Default Quotation Approval +``` + +--- + +## Scope F.2.1 Approval Matrix Schema + +Add table: + +```txt +crm_approval_matrices +``` + +Recommended columns: + +```txt +id +organization_id +entity_type +workflow_id +branch_id +product_type +min_amount +max_amount +currency +priority +is_default +is_active +description +created_at +updated_at +deleted_at +created_by +updated_by +``` + +Recommended types: + +```txt +entity_type text not null +workflow_id text not null +branch_id text null +product_type text null +min_amount numeric(14,2) null +max_amount numeric(14,2) null +currency text null +priority integer not null default 100 +is_default boolean not null default false +is_active boolean not null default true +``` + +Rules: + +* `entity_type = quotation` for quotation approval +* `product_type = null` means all product types +* `branch_id = null` means all branches +* `min_amount = null` means no lower bound +* `max_amount = null` means no upper bound +* `currency = null` means all currencies +* lower priority number wins, or document project convention if already defined +* only active rules are considered +* soft-deleted rules are ignored + +--- + +## Scope F.2.2 Matrix Matching Rules + +Implement resolver: + +```txt +src/features/crm/approval/server/matrix-resolver.ts +``` + +Function: + +```ts +resolveApprovalWorkflowForEntity(input) +``` + +Input: + +```ts +{ + organizationId: string; + entityType: 'quotation'; + branchId?: string | null; + productType?: string | null; + amount?: number | null; + currency?: string | null; +} +``` + +Output: + +```ts +{ + matrixId: string; + workflowId: string; + matchReason: string; +} +``` + +Matching rules: + +1. Match same organization +2. Match same entity type +3. Ignore inactive or deleted matrix rows +4. Product type must match exact or be null +5. Branch must match exact or be null +6. Currency must match exact or be null +7. Amount must be within range: + + * `minAmount <= amount` if min exists + * `amount <= maxAmount` if max exists +8. Choose most specific rule +9. If specificity ties, choose highest priority +10. If still tied, choose newest active rule or throw deterministic conflict error +11. If no specific rule matches, use default rule +12. If no default rule exists, throw clear configuration error + +Specificity scoring suggestion: + +```txt +productType match = +10 +branch match = +10 +currency match = +5 +amount range match = +5 +default rule = -100 +``` + +--- + +## Scope F.2.3 Submit Approval Integration + +Update quotation submit approval flow. + +Current behavior: + +```txt +use static quotation_standard_approval +``` + +New behavior: + +```txt +quotation submit + ↓ +build approval matrix input from quotation + ↓ +resolve workflow + ↓ +create approval request with resolved workflow +``` + +Quotation fields used: + +```txt +organizationId +branchId +productType / quotationType +totalAmount +currency +``` + +Rules: + +* do not select workflow in route handler +* service must call matrix resolver +* approval request must store selected workflowId +* audit must include selected matrixId and workflowId +* existing approval action flow should continue unchanged after request is created + +--- + +## Scope F.2.4 Default Matrix Seed + +Update seed to create at least one default quotation approval matrix. + +Example: + +```txt +entity_type = quotation +workflow = quotation_standard_approval +product_type = null +branch_id = null +min_amount = null +max_amount = null +currency = null +priority = 999 +is_default = true +is_active = true +``` + +This preserves current behavior while enabling future product-specific rules. + +--- + +## Scope F.2.5 Product Type Matrix Examples + +Seed optional sample rules only if project seed policy allows. + +Examples: + +```txt +quotation_crane_standard +product_type = crane +amount <= 500000 +workflow = quotation_standard_approval + +quotation_crane_executive +product_type = crane +amount > 5000000 +workflow = quotation_executive_approval +``` + +If required workflows do not exist, do not seed these sample rules. + +--- + +## Scope F.2.6 Matrix Admin API Foundation + +Create API foundation for managing matrix rows. + +Routes: + +```txt +GET /api/crm/approval/matrices +POST /api/crm/approval/matrices +GET /api/crm/approval/matrices/[id] +PATCH /api/crm/approval/matrices/[id] +DELETE /api/crm/approval/matrices/[id] +``` + +Rules: + +* no UI required in F.2 +* enforce approval admin permission +* validate workflow exists +* validate no invalid range +* soft delete only +* prevent deleting default matrix if it is the only active default for entity type + +--- + +## Scope F.2.7 Validation Rules + +Create schemas: + +```txt +src/features/crm/approval/schemas/approval-matrix.schema.ts +``` + +Validation: + +* entityType required +* workflowId required +* minAmount must be <= maxAmount when both exist +* priority must be integer +* productType optional +* branchId optional +* currency optional +* isDefault boolean +* isActive boolean + +Conflict warning: + +* multiple active matrix rows can overlap +* resolver must handle deterministic selection +* API may warn but does not need to fully block overlap in F.2 + +--- + +## Scope F.2.8 Audit Logging + +Audit actions: + +```txt +create_approval_matrix +update_approval_matrix +delete_approval_matrix +resolve_approval_matrix +``` + +For submit approval, audit must capture: + +```txt +quotationId +matrixId +workflowId +matchReason +productType +branchId +amount +currency +``` + +--- + +## Scope F.2.9 Permission Rules + +Add or verify permissions: + +```txt +crm.approval.matrix.read +crm.approval.matrix.create +crm.approval.matrix.update +crm.approval.matrix.delete +``` + +Submit approval should still use existing quotation approval submit permission. + +Rules: + +* route handlers must not check role strings directly +* use existing CRM authorization foundation +* admin-only API for matrix maintenance + +--- + +## Scope F.2.10 Documentation + +Create: + +```txt +docs/implementation/task-f2-approval-matrix-foundation.md +``` + +Document: + +```txt +schema +matching rules +priority rules +default fallback +submit approval integration +seed behavior +known limitations +``` + +--- + +## Explicit Non-Scope + +Do not implement: + +* full Workflow Builder UI +* drag-and-drop step editor +* Notification Center +* Email sending +* LINE / webhook sending +* reminder / escalation +* approval dashboard +* approval delegation +* parallel approval +* any-one approval +* dynamic approver lookup by manager hierarchy + +These belong to later F tasks. + +--- + +## Verification + +Run: + +```txt +npm exec tsc --noEmit +npm run build +``` + +Manual verification: + +```txt +Default matrix exists after seed +Submit quotation approval still works +Approval request stores resolved workflowId +Matrix resolver chooses product-specific rule over default +Matrix resolver chooses branch-specific rule over all-branch rule +Matrix resolver chooses amount-range rule correctly +Matrix resolver handles no-match with clear error +Matrix CRUD APIs work +Soft delete matrix works +Audit logs include matrix resolution +Existing approval approve/reject actions still work +Existing quotation flow still works +``` + +--- + +## Definition of Done + +Task is complete when: + +* `crm_approval_matrices` exists +* default quotation approval matrix is seeded +* quotation submit approval uses matrix resolver +* workflow selection supports product type, branch, amount, currency, default fallback, and priority +* matrix CRUD APIs exist +* audit logs capture matrix changes and resolution +* existing approval actions continue working + +Result: + +```txt +Approval Matrix Foundation = Established +Workflow Resolution = Dynamic +Ready for F.3 Approval Workflow Builder +``` diff --git a/src/app/api/crm/quotations/[id]/submit-approval/route.ts b/src/app/api/crm/quotations/[id]/submit-approval/route.ts index aecfe91..2fa07be 100644 --- a/src/app/api/crm/quotations/[id]/submit-approval/route.ts +++ b/src/app/api/crm/quotations/[id]/submit-approval/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { submitForApproval } from '@/features/foundation/approval/server/service'; +import { submitQuotationForApproval } from '@/features/crm/approval/server/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -15,20 +16,26 @@ const submitSchema = z.object({ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmApprovalSubmit }); const payload = submitSchema.parse(await request.json()); - await submitForApproval(organization.id, session.user.id, { - workflowCode: 'quotation_standard_approval', - entityType: 'quotation', - entityId: id, - remark: payload.remark + + await submitQuotationForApproval({ + organizationId: organization.id, + userId: session.user.id, + quotationId: id, + remark: payload.remark, + accessContext: buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }) }); return NextResponse.json({ success: true, - message: 'Quotation submitted for approval successfully' + message: 'Quotation submitted approval successfully' }); } catch (error) { if (error instanceof AuthError) { @@ -43,6 +50,6 @@ export async function POST(request: NextRequest, { params }: Params) { if (error instanceof Error) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to submit quotation for approval' }, { status: 500 }); + return NextResponse.json({ message: 'Unable submit quotation for approval' }, { status: 500 }); } } diff --git a/src/app/api/crm/settings/approval-matrices/[id]/route.ts b/src/app/api/crm/settings/approval-matrices/[id]/route.ts new file mode 100644 index 0000000..c1ce618 --- /dev/null +++ b/src/app/api/crm/settings/approval-matrices/[id]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + updateApprovalMatrixSchema, + type UpdateApprovalMatrixInput +} from '@/features/crm/approval/schemas/approval-matrix.schema'; +import { + getApprovalMatrix, + softDeleteApprovalMatrix, + updateApprovalMatrix +} from '@/features/crm/approval/server/service'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalMatrixRead + }); + const matrix = await getApprovalMatrix(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approval matrix loaded successfully', + matrix + }); + } 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 to load approval matrix' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalMatrixUpdate + }); + const payload = updateApprovalMatrixSchema.parse( + await request.json() + ) as UpdateApprovalMatrixInput; + const before = await getApprovalMatrix(id, organization.id); + const updated = await updateApprovalMatrix(id, organization.id, session.user.id, payload); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_matrix', + entityId: id, + action: 'update_approval_matrix', + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Approval matrix 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 to update approval matrix' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalMatrixDelete + }); + const result = await softDeleteApprovalMatrix(id, organization.id, session.user.id); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_matrix', + entityId: id, + action: 'delete_approval_matrix', + beforeData: result.before, + afterData: result.after + }); + + return NextResponse.json({ + success: true, + message: 'Approval matrix 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 to delete approval matrix' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/settings/approval-matrices/route.ts b/src/app/api/crm/settings/approval-matrices/route.ts new file mode 100644 index 0000000..80e2338 --- /dev/null +++ b/src/app/api/crm/settings/approval-matrices/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + createApprovalMatrixSchema, + type CreateApprovalMatrixInput +} from '@/features/crm/approval/schemas/approval-matrix.schema'; +import { + createApprovalMatrix, + listApprovalMatrices +} from '@/features/crm/approval/server/service'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET(_request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalMatrixRead + }); + const items = await listApprovalMatrices(organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approval matrices 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 to load approval matrices' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalMatrixCreate + }); + const payload = createApprovalMatrixSchema.parse( + await request.json() + ) as CreateApprovalMatrixInput; + const created = await createApprovalMatrix(organization.id, session.user.id, payload); + + await auditAction({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_approval_matrix', + entityId: created.id, + action: 'create_approval_matrix', + afterData: created + }); + + return NextResponse.json({ + success: true, + message: 'Approval matrix created 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 to create approval matrix' }, { status: 500 }); + } +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 5f07ffb..bade8a6 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -1,52 +1,6 @@ import { NavGroup } from "@/types"; /** * Navigation configuration with RBAC support * * This configuration is used for both the sidebar navigation and Cmd+K bar. * Items are organized into groups, each rendered with a SidebarGroupLabel. * * RBAC Access Control: * Each navigation item can have an `access` property that controls visibility * based on permissions, plans, features, roles, and organization context. * * Examples: * * 1. Require organization: * access: { requireOrg: true } * * 2. Require specific permission: * access: { requireOrg: true, permission: 'org:teams:manage' } * * 3. Require specific plan: * access: { plan: 'pro' } * * 4. Require specific feature: * access: { feature: 'premium_access' } * * 5. Require specific role: * access: { role: 'admin' } * * 6. Multiple conditions (all must be true): * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } * * Note: The `visible` function is deprecated but still supported for backward compatibility. * Use the `access` property for new items. */ export const navGroups: NavGroup[] = [ - // { - // label: "Overview", - // items: [ - // { - // title: "Dashboard Overview", - // url: "/dashboard", - // icon: "dashboard", - // isActive: false, - // shortcut: ["d", "d"], - // items: [], - // }, - // { - // title: "Workspaces", - // url: "/dashboard/workspaces", - // icon: "workspace", - // isActive: false, - // items: [], - // access: { systemRole: "super_admin" }, - // }, - // { - // title: "Teams", - // url: "/dashboard/workspaces/team", - // icon: "teams", - // isActive: false, - // items: [], - // access: { requireOrg: true, permission: "crm.quotation.read" }, - // }, - // { - // title: "Users", - // url: "/dashboard/users", - // icon: "teams", - // shortcut: ["u", "u"], - // isActive: false, - // items: [], - // access: { requireOrg: true, permission: "users:manage" }, - // }, - // { - // title: "Kanban", - // url: "/dashboard/kanban", - // icon: "kanban", - // shortcut: ["k", "k"], - // isActive: false, - // items: [], - // }, - // ], - // }, { label: "CRM", items: [ @@ -181,4 +135,50 @@ import { NavGroup } from "@/types"; }, ], }, + { + label: "Admin", + items: [ + // { + // title: "Dashboard Overview", + // url: "/dashboard", + // icon: "dashboard", + // isActive: false, + // shortcut: ["d", "d"], + // items: [], + // }, + { + title: "Workspaces", + url: "/dashboard/workspaces", + icon: "workspace", + isActive: false, + items: [], + access: { systemRole: "super_admin" }, + }, + { + title: "Teams", + url: "/dashboard/workspaces/team", + icon: "teams", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.quotation.read" }, + }, + { + title: "Users", + url: "/dashboard/users", + icon: "teams", + shortcut: ["u", "u"], + isActive: false, + items: [], + access: { requireOrg: true, permission: "users:manage" }, + }, + // { + // title: "Kanban", + // url: "/dashboard/kanban", + // icon: "kanban", + // shortcut: ["k", "k"], + // isActive: false, + // items: [], + // }, + ], + }, ]; diff --git a/src/db/schema.ts b/src/db/schema.ts index c5b42dd..e7a7ad9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -680,6 +680,27 @@ export const crmApprovalSteps = pgTable( }) ); +export const crmApprovalMatrices = pgTable('crm_approval_matrices', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + entityType: text('entity_type').notNull(), + workflowId: text('workflow_id').notNull(), + branchId: text('branch_id'), + productType: text('product_type'), + minAmount: doublePrecision('min_amount'), + maxAmount: doublePrecision('max_amount'), + currency: text('currency'), + priority: integer('priority').default(100).notNull(), + isDefault: boolean('is_default').default(false).notNull(), + isActive: boolean('is_active').default(true).notNull(), + description: text('description'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() +}); + export const crmApprovalRequests = pgTable('crm_approval_requests', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 173807c..50d98c9 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -666,6 +666,22 @@ const APPROVAL_WORKFLOWS = [ } ]; +const APPROVAL_MATRIX_DEFAULTS = [ + { + entityType: 'quotation', + workflowCode: 'quotation_standard_approval', + branchId: null, + productType: null, + minAmount: null, + maxAmount: null, + currency: null, + priority: 999, + isDefault: true, + isActive: true, + description: 'Default quotation approval matrix' + } +]; + const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [ { placeholderKey: 'customer_name', @@ -1031,6 +1047,95 @@ async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizati } } +async function upsertApprovalMatricesForOrganization( + sql: SqlClient, + organization: { id: string; createdBy: string } +) { + const workflows = await sql` + select id, code + from crm_approval_workflows + where organization_id = ${organization.id} + and deleted_at is null + `; + + const workflowIdByCode = new Map( + workflows.map((workflow: { code: string; id: string }) => [workflow.code, workflow.id]) + ); + + for (const matrix of APPROVAL_MATRIX_DEFAULTS) { + const workflowId = workflowIdByCode.get(matrix.workflowCode); + if (!workflowId) { + continue; + } + + const [existing] = await sql` + select id + from crm_approval_matrices + where organization_id = ${organization.id} + and entity_type = ${matrix.entityType} + and is_default = ${matrix.isDefault} + and deleted_at is null + order by created_at asc + limit 1 + `; + + if (existing?.id) { + await sql` + update crm_approval_matrices + set workflow_id = ${workflowId}, + branch_id = ${matrix.branchId}, + product_type = ${matrix.productType}, + min_amount = ${matrix.minAmount}, + max_amount = ${matrix.maxAmount}, + currency = ${matrix.currency}, + priority = ${matrix.priority}, + is_active = ${matrix.isActive}, + description = ${matrix.description}, + updated_at = now(), + updated_by = ${organization.createdBy} + where id = ${existing.id} + `; + continue; + } + + await sql` + insert into crm_approval_matrices ( + id, + organization_id, + entity_type, + workflow_id, + branch_id, + product_type, + min_amount, + max_amount, + currency, + priority, + is_default, + is_active, + description, + created_by, + updated_by + ) values ( + ${crypto.randomUUID()}, + ${organization.id}, + ${matrix.entityType}, + ${workflowId}, + ${matrix.branchId}, + ${matrix.productType}, + ${matrix.minAmount}, + ${matrix.maxAmount}, + ${matrix.currency}, + ${matrix.priority}, + ${matrix.isDefault}, + ${matrix.isActive}, + ${matrix.description}, + ${organization.createdBy}, + ${organization.createdBy} + ) + `; + } +} + async function upsertDocumentTemplatesForOrganization( sql: SqlClient, organization: { id: string; name: string; createdBy: string } @@ -1336,12 +1441,13 @@ async function main() { ); } - for (const organization of organizations) { - const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); - await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); - await upsertApprovalWorkflowsForOrganization(sql, organization.id); - await upsertDocumentTemplatesForOrganization(sql, organization); - await upsertReportDefinitionsForOrganization(sql, organization); + for (const organization of organizations) { + const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); + await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); + await upsertApprovalWorkflowsForOrganization(sql, organization.id); + await upsertApprovalMatricesForOrganization(sql, organization); + await upsertDocumentTemplatesForOrganization(sql, organization); + await upsertReportDefinitionsForOrganization(sql, organization); console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); } } finally { diff --git a/src/features/crm/approval/schemas/approval-matrix.schema.ts b/src/features/crm/approval/schemas/approval-matrix.schema.ts new file mode 100644 index 0000000..63aa136 --- /dev/null +++ b/src/features/crm/approval/schemas/approval-matrix.schema.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; + +const nullableTrimmedString = z + .string() + .trim() + .min(1) + .optional() + .nullable() + .transform((value) => value ?? null); + +const nullableNumber = z + .coerce.number() + .finite() + .optional() + .nullable() + .transform((value) => value ?? null); + +const nullableBoolean = z + .boolean() + .optional() + .nullable() + .transform((value) => value ?? null); + +export const approvalMatrixBaseSchema = z + .object({ + entityType: z.string().trim().min(1), + workflowId: z.string().trim().min(1), + branchId: nullableTrimmedString, + productType: nullableTrimmedString, + minAmount: nullableNumber, + maxAmount: nullableNumber, + currency: nullableTrimmedString, + priority: z.number().int(), + isDefault: z.boolean(), + isActive: z.boolean(), + description: nullableTrimmedString + }) + .superRefine((value, ctx) => { + if ( + value.minAmount !== null && + value.maxAmount !== null && + value.minAmount > value.maxAmount + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minAmount'], + message: 'minAmount must be less than or equal to maxAmount' + }); + } + }); + +export const createApprovalMatrixSchema = approvalMatrixBaseSchema; + +export const updateApprovalMatrixSchema = z + .object({ + entityType: z.string().trim().min(1).optional(), + workflowId: z.string().trim().min(1).optional(), + branchId: nullableTrimmedString.optional(), + productType: nullableTrimmedString.optional(), + minAmount: nullableNumber.optional(), + maxAmount: nullableNumber.optional(), + currency: nullableTrimmedString.optional(), + priority: z.number().int().optional(), + isDefault: nullableBoolean.optional(), + isActive: nullableBoolean.optional(), + description: nullableTrimmedString.optional() + }) + .superRefine((value, ctx) => { + if ( + value.minAmount !== undefined && + value.maxAmount !== undefined && + value.minAmount !== null && + value.maxAmount !== null && + value.minAmount > value.maxAmount + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minAmount'], + message: 'minAmount must be less than or equal to maxAmount' + }); + } + }); + +export type CreateApprovalMatrixInput = z.infer; +export type UpdateApprovalMatrixInput = z.infer; diff --git a/src/features/crm/approval/server/service.ts b/src/features/crm/approval/server/service.ts new file mode 100644 index 0000000..35f6b25 --- /dev/null +++ b/src/features/crm/approval/server/service.ts @@ -0,0 +1,497 @@ +import { and, asc, count, desc, eq, isNull, ne, or } from 'drizzle-orm'; +import { crmApprovalMatrices, crmApprovalWorkflows, crmQuotations } from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { submitForApproval } from '@/features/foundation/approval/server/service'; +import { + type CrmSecurityContext, + auditCrmSecurityEvent, + canAccessScopedCrmRecord +} from '@/features/crm/security/server/service'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import type { + CreateApprovalMatrixInput, + UpdateApprovalMatrixInput +} from '../schemas/approval-matrix.schema'; + +export interface ApprovalMatrixRecord { + id: string; + organizationId: string; + entityType: string; + workflowId: string; + branchId: string | null; + productType: string | null; + minAmount: number | null; + maxAmount: number | null; + currency: string | null; + priority: number; + isDefault: boolean; + isActive: boolean; + description: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface ResolveApprovalWorkflowInput { + organizationId: string; + entityType: 'quotation'; + branchId?: string | null; + productType?: string | null; + amount?: number | null; + currency?: string | null; +} + +export interface ApprovalWorkflowResolution { + matrixId: string; + workflowId: string; + matchReason: string; +} + +interface MatrixCandidate { + row: typeof crmApprovalMatrices.$inferSelect; + specificity: number; + matchReason: string; +} + +function mapApprovalMatrixRecord(row: typeof crmApprovalMatrices.$inferSelect): ApprovalMatrixRecord { + return { + id: row.id, + organizationId: row.organizationId, + entityType: row.entityType, + workflowId: row.workflowId, + branchId: row.branchId ?? null, + productType: row.productType ?? null, + minAmount: row.minAmount ?? null, + maxAmount: row.maxAmount ?? null, + currency: row.currency ?? null, + priority: row.priority, + isDefault: row.isDefault, + isActive: row.isActive, + description: row.description ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +async function assertWorkflowExists(workflowId: string, organizationId: string, entityType?: string) { + const [workflow] = await db + .select() + .from(crmApprovalWorkflows) + .where( + and( + eq(crmApprovalWorkflows.id, workflowId), + eq(crmApprovalWorkflows.organizationId, organizationId), + eq(crmApprovalWorkflows.isActive, true), + isNull(crmApprovalWorkflows.deletedAt), + ...(entityType ? [eq(crmApprovalWorkflows.entityType, entityType)] : []) + ) + ) + .limit(1); + + if (!workflow) { + throw new AuthError('Approval workflow not found', 404); + } + + return workflow; +} + +async function assertApprovalMatrix(id: string, organizationId: string) { + const [matrix] = await db + .select() + .from(crmApprovalMatrices) + .where( + and( + eq(crmApprovalMatrices.id, id), + eq(crmApprovalMatrices.organizationId, organizationId), + isNull(crmApprovalMatrices.deletedAt) + ) + ) + .limit(1); + + if (!matrix) { + throw new AuthError('Approval matrix not found', 404); + } + + return matrix; +} + +function buildMatchReason(parts: string[], row: typeof crmApprovalMatrices.$inferSelect) { + const reasons = [...parts]; + if (row.isDefault) { + reasons.push('default fallback'); + } + reasons.push(`priority ${row.priority}`); + return reasons.join(', '); +} + +function buildCandidate( + row: typeof crmApprovalMatrices.$inferSelect, + input: ResolveApprovalWorkflowInput +): MatrixCandidate | null { + if (!row.isActive || row.deletedAt) { + return null; + } + + if (row.productType && row.productType !== input.productType) { + return null; + } + + if (row.branchId && row.branchId !== input.branchId) { + return null; + } + + if (row.currency && row.currency !== input.currency) { + return null; + } + + if (row.minAmount !== null && row.minAmount !== undefined) { + if (input.amount === null || input.amount === undefined || input.amount < row.minAmount) { + return null; + } + } + + if (row.maxAmount !== null && row.maxAmount !== undefined) { + if (input.amount === null || input.amount === undefined || input.amount > row.maxAmount) { + return null; + } + } + + let specificity = row.isDefault ? -100 : 0; + const reasons: string[] = []; + + if (row.productType) { + specificity += 10; + reasons.push('product type'); + } + + if (row.branchId) { + specificity += 10; + reasons.push('branch'); + } + + if (row.currency) { + specificity += 5; + reasons.push('currency'); + } + + if (row.minAmount !== null || row.maxAmount !== null) { + specificity += 5; + reasons.push('amount range'); + } + + return { + row, + specificity, + matchReason: buildMatchReason(reasons, row) + }; +} + +export async function listApprovalMatrices(organizationId: string): Promise { + const rows = await db + .select() + .from(crmApprovalMatrices) + .where( + and( + eq(crmApprovalMatrices.organizationId, organizationId), + isNull(crmApprovalMatrices.deletedAt) + ) + ) + .orderBy( + asc(crmApprovalMatrices.entityType), + asc(crmApprovalMatrices.priority), + desc(crmApprovalMatrices.updatedAt) + ); + + return rows.map(mapApprovalMatrixRecord); +} + +export async function getApprovalMatrix(id: string, organizationId: string): Promise { + const matrix = await assertApprovalMatrix(id, organizationId); + return mapApprovalMatrixRecord(matrix); +} + +export async function createApprovalMatrix( + organizationId: string, + userId: string, + payload: CreateApprovalMatrixInput +): Promise { + await assertWorkflowExists(payload.workflowId, organizationId, payload.entityType); + + const [created] = await db + .insert(crmApprovalMatrices) + .values({ + id: crypto.randomUUID(), + organizationId, + entityType: payload.entityType.trim(), + workflowId: payload.workflowId.trim(), + branchId: payload.branchId, + productType: payload.productType, + minAmount: payload.minAmount, + maxAmount: payload.maxAmount, + currency: payload.currency, + priority: payload.priority, + isDefault: payload.isDefault, + isActive: payload.isActive, + description: payload.description, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + return mapApprovalMatrixRecord(created); +} + +export async function updateApprovalMatrix( + id: string, + organizationId: string, + userId: string, + payload: UpdateApprovalMatrixInput +): Promise { + const current = await assertApprovalMatrix(id, organizationId); + + const nextEntityType = payload.entityType?.trim() ?? current.entityType; + const nextWorkflowId = payload.workflowId?.trim() ?? current.workflowId; + const nextMinAmount = payload.minAmount !== undefined ? payload.minAmount : current.minAmount; + const nextMaxAmount = payload.maxAmount !== undefined ? payload.maxAmount : current.maxAmount; + + if (nextMinAmount !== null && nextMaxAmount !== null && nextMinAmount > nextMaxAmount) { + throw new AuthError('minAmount must be less than or equal to maxAmount', 400); + } + + await assertWorkflowExists(nextWorkflowId, organizationId, nextEntityType); + + const [updated] = await db + .update(crmApprovalMatrices) + .set({ + entityType: nextEntityType, + workflowId: nextWorkflowId, + branchId: payload.branchId !== undefined ? payload.branchId : current.branchId, + productType: payload.productType !== undefined ? payload.productType : current.productType, + minAmount: nextMinAmount, + maxAmount: nextMaxAmount, + currency: payload.currency !== undefined ? payload.currency : current.currency, + priority: payload.priority ?? current.priority, + isDefault: payload.isDefault ?? current.isDefault, + isActive: payload.isActive ?? current.isActive, + description: payload.description !== undefined ? payload.description : current.description, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmApprovalMatrices.id, id)) + .returning(); + + return mapApprovalMatrixRecord(updated); +} + +export async function softDeleteApprovalMatrix( + id: string, + organizationId: string, + userId: string +): Promise<{ before: ApprovalMatrixRecord; after: ApprovalMatrixRecord }> { + const current = await assertApprovalMatrix(id, organizationId); + + if (current.isDefault && current.isActive) { + const [defaultCount] = await db + .select({ value: count() }) + .from(crmApprovalMatrices) + .where( + and( + eq(crmApprovalMatrices.organizationId, organizationId), + eq(crmApprovalMatrices.entityType, current.entityType), + eq(crmApprovalMatrices.isDefault, true), + eq(crmApprovalMatrices.isActive, true), + isNull(crmApprovalMatrices.deletedAt), + ne(crmApprovalMatrices.id, id) + ) + ); + + if (!defaultCount || defaultCount.value === 0) { + throw new AuthError('At least one active default matrix is required for this entity type', 400); + } + } + + const [updated] = await db + .update(crmApprovalMatrices) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmApprovalMatrices.id, id)) + .returning(); + + return { + before: mapApprovalMatrixRecord(current), + after: mapApprovalMatrixRecord(updated) + }; +} + +export async function resolveApprovalWorkflowForEntity( + input: ResolveApprovalWorkflowInput +): Promise { + const rows = await db + .select() + .from(crmApprovalMatrices) + .where( + and( + eq(crmApprovalMatrices.organizationId, input.organizationId), + eq(crmApprovalMatrices.entityType, input.entityType), + eq(crmApprovalMatrices.isActive, true), + isNull(crmApprovalMatrices.deletedAt), + or(eq(crmApprovalMatrices.branchId, input.branchId ?? ''), isNull(crmApprovalMatrices.branchId)), + or( + eq(crmApprovalMatrices.productType, input.productType ?? ''), + isNull(crmApprovalMatrices.productType) + ), + or(eq(crmApprovalMatrices.currency, input.currency ?? ''), isNull(crmApprovalMatrices.currency)) + ) + ) + .orderBy(desc(crmApprovalMatrices.createdAt), desc(crmApprovalMatrices.id)); + + const candidates = rows + .map((row) => buildCandidate(row, input)) + .filter((candidate): candidate is MatrixCandidate => candidate !== null); + + if (!candidates.length) { + throw new AuthError('No approval matrix matched and no default matrix is configured', 400); + } + + const specificCandidates = candidates.filter((candidate) => !candidate.row.isDefault); + const defaultCandidates = candidates.filter((candidate) => candidate.row.isDefault); + const workingSet = specificCandidates.length > 0 ? specificCandidates : defaultCandidates; + + if (!workingSet.length) { + throw new AuthError('No default approval matrix is configured for this entity type', 400); + } + + const ranked = [...workingSet].sort((left, right) => { + if (left.specificity !== right.specificity) { + return right.specificity - left.specificity; + } + + if (left.row.priority !== right.row.priority) { + return left.row.priority - right.row.priority; + } + + const createdDiff = right.row.createdAt.getTime() - left.row.createdAt.getTime(); + if (createdDiff !== 0) { + return createdDiff; + } + + return right.row.id.localeCompare(left.row.id); + }); + + const [winner, runnerUp] = ranked; + + if ( + runnerUp && + winner.specificity === runnerUp.specificity && + winner.row.priority === runnerUp.row.priority && + winner.row.createdAt.getTime() === runnerUp.row.createdAt.getTime() + ) { + throw new AuthError( + `Approval matrix conflict between ${winner.row.id} and ${runnerUp.row.id}`, + 409 + ); + } + + await assertWorkflowExists(winner.row.workflowId, input.organizationId, input.entityType); + + return { + matrixId: winner.row.id, + workflowId: winner.row.workflowId, + matchReason: winner.matchReason + }; +} + +export async function submitQuotationForApproval( + input: { + organizationId: string; + userId: string; + quotationId: string; + remark?: string; + accessContext: CrmSecurityContext; + } +) { + const [quotation] = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.id, input.quotationId), + eq(crmQuotations.organizationId, input.organizationId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1); + + if (!quotation) { + throw new AuthError('Quotation not found', 404); + } + + const canAccess = canAccessScopedCrmRecord(input.accessContext, { + branchId: quotation.branchId, + productType: quotation.quotationType, + createdBy: quotation.createdBy, + ownerUserId: quotation.salesmanId + }); + + if (!canAccess) { + await auditCrmSecurityEvent({ + organizationId: input.organizationId, + userId: input.userId, + action: 'approval_scope_violation', + entityType: 'crm_quotation', + entityId: input.quotationId, + reason: 'User attempted to submit approval outside resolved CRM scope', + metadata: { + branchId: quotation.branchId, + productType: quotation.quotationType + } + }); + throw new AuthError('Forbidden', 403); + } + + const resolution = await resolveApprovalWorkflowForEntity({ + organizationId: input.organizationId, + entityType: 'quotation', + branchId: quotation.branchId, + productType: quotation.quotationType, + amount: quotation.totalAmount, + currency: quotation.currency + }); + + const request = await submitForApproval(input.organizationId, input.userId, { + workflowId: resolution.workflowId, + entityType: 'quotation', + entityId: input.quotationId, + remark: input.remark + }); + + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'crm_approval_matrix', + entityId: resolution.matrixId, + action: 'resolve_approval_matrix', + afterData: { + quotationId: input.quotationId, + approvalRequestId: request.id, + matrixId: resolution.matrixId, + workflowId: resolution.workflowId, + matchReason: resolution.matchReason, + productType: quotation.quotationType, + branchId: quotation.branchId, + amount: quotation.totalAmount, + currency: quotation.currency + } + }); + + return { request, resolution }; +} diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index bcd42f2..204ad2f 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -987,11 +987,13 @@ export async function submitForApproval( userId: string, payload: SubmitApprovalPayload ) { - const workflow = await assertWorkflowByCode( - organizationId, - payload.workflowCode ?? `${payload.entityType}_standard_approval`, - payload.entityType - ); + const workflow = payload.workflowId + ? await assertWorkflowById(payload.workflowId, organizationId) + : await assertWorkflowByCode( + organizationId, + payload.workflowCode ?? `${payload.entityType}_standard_approval`, + payload.entityType + ); const steps = await listWorkflowSteps(workflow.id, organizationId); if (!steps.length) { diff --git a/src/features/foundation/approval/types.ts b/src/features/foundation/approval/types.ts index fb91b1a..661788e 100644 --- a/src/features/foundation/approval/types.ts +++ b/src/features/foundation/approval/types.ts @@ -96,6 +96,7 @@ export interface ApprovalFilters { } export interface SubmitApprovalPayload { + workflowId?: string; workflowCode?: string; entityType: string; entityId: string; diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 9ab79e0..6b8e7ed 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -81,6 +81,10 @@ export const PERMISSIONS = { crmApprovalApprove: 'crm.approval.approve', crmApprovalReject: 'crm.approval.reject', crmApprovalReturn: 'crm.approval.return', + crmApprovalMatrixRead: 'crm.approval.matrix.read', + crmApprovalMatrixCreate: 'crm.approval.matrix.create', + crmApprovalMatrixUpdate: 'crm.approval.matrix.update', + crmApprovalMatrixDelete: 'crm.approval.matrix.delete', crmApprovalWorkflowRead: 'crm.approval.workflow.read', crmApprovalWorkflowCreate: 'crm.approval.workflow.create', crmApprovalWorkflowUpdate: 'crm.approval.workflow.update', @@ -441,6 +445,10 @@ const DEFAULT_ROLE_DEFINITIONS: Record