This commit is contained in:
phaichayon
2026-06-26 11:03:25 +07:00
parent 42c8c59947
commit 052677fa2c
16 changed files with 1604 additions and 68 deletions

View File

@@ -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.

View File

@@ -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
);

View File

@@ -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
}
]
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

501
plans/task-f.2.md Normal file
View File

@@ -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
```

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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: [],
// },
],
},
];

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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<typeof createApprovalMatrixSchema>;
export type UpdateApprovalMatrixInput = z.infer<typeof updateApprovalMatrixSchema>;

View File

@@ -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<ApprovalMatrixRecord[]> {
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<ApprovalMatrixRecord> {
const matrix = await assertApprovalMatrix(id, organizationId);
return mapApprovalMatrixRecord(matrix);
}
export async function createApprovalMatrix(
organizationId: string,
userId: string,
payload: CreateApprovalMatrixInput
): Promise<ApprovalMatrixRecord> {
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<ApprovalMatrixRecord> {
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<ApprovalWorkflowResolution> {
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 };
}

View File

@@ -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) {

View File

@@ -96,6 +96,7 @@ export interface ApprovalFilters {
}
export interface SubmitApprovalPayload {
workflowId?: string;
workflowCode?: string;
entityType: string;
entityId: string;

View File

@@ -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<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
PERMISSIONS.crmMasterOptionDelete,
PERMISSIONS.crmApprovalMatrixRead,
PERMISSIONS.crmApprovalMatrixCreate,
PERMISSIONS.crmApprovalMatrixUpdate,
PERMISSIONS.crmApprovalMatrixDelete,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate,
@@ -597,6 +605,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
{ key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' },
{ key: PERMISSIONS.crmApprovalMatrixRead, label: 'Read approval matrices' },
{ key: PERMISSIONS.crmApprovalMatrixCreate, label: 'Create approval matrices' },
{ key: PERMISSIONS.crmApprovalMatrixUpdate, label: 'Update approval matrices' },
{ key: PERMISSIONS.crmApprovalMatrixDelete, label: 'Delete approval matrices' },
{ key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },