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