task-l.3.1

This commit is contained in:
phaichayon
2026-06-22 14:18:26 +07:00
parent 42f403a7ed
commit 1b901efc51
21 changed files with 1320 additions and 102 deletions

View File

@@ -0,0 +1,33 @@
# Task L.3.1: Customer, Contact & Approval Visibility Hardening
## Summary
Task L.3.1 closes the next set of CRM visibility gaps after Task L.3 by moving customer, contact, and approval visibility closer to the resolved CRM access model already used by quotations and dashboard security.
## Implemented
- customer list/detail/update/delete routes now pass resolved CRM scope context
- customer service now filters visibility by:
- branch scope
- ownership scope
- related enquiry/quotation visibility
- marketing monitor visibility through enquiry relationships
- contact routes now inherit resolved customer visibility instead of organization-only reads
- contact access additionally preserves creator visibility
- approval list/detail now require either:
- related quotation visibility
- current approval actor visibility
- admin / organization-wide visibility
- dashboard approval analytics now reuse access-scoped approval listing
- added `customer_scope_violation`, `contact_scope_violation`, and `approval_scope_violation` security audit actions
- expanded static security verification coverage
- documented team-scope and contact-sharing limitations
## Important limitation
The production schema still has no persisted contact-sharing model. That means L.3.1 hardens creator/customer/admin visibility, but it cannot preserve a live shared-contact workflow that does not yet exist in persistence.
## Verification
- `npx tsc --noEmit`
- `node --experimental-strip-types scripts/security/verify-crm-access.ts`

View File

@@ -430,3 +430,13 @@ Future:
- define the canonical team relationship source
- replace current coarse team handling with explicit team membership or manager hierarchy logic
### Contact-sharing persistence gap
Current:
Task L.3.1 hardens customer/contact visibility, but the production schema still has no persisted contact-sharing relation. Legacy shared-contact behavior exists only in demo/mock material.
Future:
- add a first-class contact-sharing table and audit trail if the business still needs shared-contact workflows
- extend security verification from creator/customer/admin visibility to explicit share-based visibility once persistence exists

View File

@@ -11,13 +11,13 @@
| Area | Route / Module | Status | Notes |
| --- | --- | --- | --- |
| Customers | `/api/crm/customers` | `Partially Protected` | Permission-gated, but service is still mostly organization-wide and not yet ownership-scoped |
| Contacts | `/api/crm/customers/[id]/contacts` | `Partially Protected` | Bound to customer org, but no dedicated resolved ownership/contact-sharing policy yet |
| Customers | `/api/crm/customers` | `Protected` | List/detail/update/delete now pass resolved CRM scope context and customer visibility is filtered by ownership plus related enquiry/quotation signals |
| Contacts | `/api/crm/customers/[id]/contacts` | `Protected` | Contact APIs now inherit customer visibility and creator/admin/team visibility rules from resolved CRM access |
| Leads / Enquiries | `/api/crm/enquiries` and child routes | `Partially Protected` | Uses branch/product/own-scope logic already, but still passes legacy `businessRole` fields through route/service seams |
| Quotations | `/api/crm/quotations` and `[id]` | `Protected` | List/detail/create/update/delete now pass resolved CRM scope context and enforce branch/product/own visibility |
| Quotation pricing surfaces | document data, preview, PDF preview/download, approved PDF | `Protected` | Pricing-bearing outputs now require pricing visibility and emit `crm_security_access` audit events when denied |
| Document artifacts | `/api/crm/document-artifacts/[id]/download` | `Protected` | Approved quotation artifacts now require artifact permission plus pricing visibility guard |
| Approvals | `/api/crm/approvals`, approve/reject/return | `Legacy Protected` | Permission-gated; step actor resolution now uses resolved CRM access union, but request listing is still organization-wide |
| Approvals | `/api/crm/approvals`, approve/reject/return | `Protected` | Approval list/detail now require related quotation visibility or current-actor/admin visibility; step actor resolution uses resolved CRM access union |
| Dashboard | `/api/crm/dashboard` | `Protected` | Route uses resolved context; revenue widgets are hidden unless quotation pricing is visible |
| Dashboard export | `/api/crm/dashboard/export` | `Protected` | Revenue export explicitly blocked without pricing visibility |
| CRM settings: roles | `/api/crm/settings/roles*` | `Partially Protected` | Permission-gated, but inventory still needs full pass for every mutation route |
@@ -28,14 +28,12 @@
## High-Risk Findings
1. Customer/contact services still need full ownership-scope enforcement rather than organization-only reads.
2. Approval request listing and detail visibility are still broader than the final resolved-ownership model.
1. Team-scope semantics are still coarse because the current model does not yet carry a first-class subordinate/team graph.
2. Production contact sharing is not yet backed by a persisted sharing table; current enforcement covers creator/customer/admin visibility only.
3. Enquiry services already enforce meaningful scope, but several route files still pass legacy role-shaped context instead of a dedicated security context object.
4. Team-scope semantics are still coarse because the current model does not yet carry a first-class subordinate/team graph.
## Follow-up Focus
- Finish customer/contact ownership enforcement.
- Convert approval list/detail visibility from organization scope to resolved quotation visibility.
- Normalize remaining enquiry routes onto the same security-context builder used by quotations and dashboard.
- Add runtime scenario tests once seeded multi-role fixtures are available.
- Add runtime seeded scenario tests once dedicated security fixtures are available.
- Introduce a first-class team hierarchy and production contact-sharing model if the business requires them.

View File

@@ -56,6 +56,34 @@ Approval step handling must use resolved CRM role unions instead of a single leg
- `organization`: organization-wide inside active tenant
- `monitor`: read-oriented role with limited commercial visibility
## Customer Boundary
Customer visibility is now enforced from resolved CRM access.
- `organization` and admin scopes can see organization customers within branch scope
- `team` currently behaves as branch/product-scoped broad visibility until a first-class team graph exists
- `own` can see customers they created or customers linked to enquiries/quotations they can already access
- `monitor` can see customers only when those customers are related to visible lead/enquiry records
## Contact Boundary
Contact visibility is enforced through customer visibility plus contact ownership rules.
- if a user can access the parent customer, they can access its contact list within their role boundary
- contact creators retain access to their own contacts
- admin/organization scope retains access
- production shared-contact persistence does not exist yet, so legacy demo sharing is not part of the live authorization boundary
## Approval Boundary
Approval visibility is now narrower than generic approval-read permission.
A user can see approval records only when at least one of these is true:
1. they can access the related quotation through resolved CRM scope
2. they are the current approval actor by resolved CRM role
3. they are admin / organization-wide CRM authority
## Artifact Boundary
Artifact permission alone is not enough for approved quotation PDFs.
@@ -74,6 +102,8 @@ Dashboard access is split:
- approval widgets: approval read permission
- export visibility: same scope as dashboard, with extra revenue export restriction
Approval widgets additionally inherit approval-request visibility rules, not just a raw approval permission.
## Audit Events
Security-sensitive denials are recorded under entity type `crm_security_access` with actions:

View File

@@ -0,0 +1,41 @@
# Team Scope Limitations
## Current Team Scope Logic
Current CRM authorization resolves the most permissive ownership scope from active CRM role assignments.
For `team` scope today:
- quotation visibility is effectively broader than `own`, but still constrained by branch scope and product scope
- customer visibility is approximated from branch scope plus related enquiry/quotation relationships
- approval visibility inherits quotation visibility or current approval-actor visibility
## Known Limitations
There is not yet a first-class team hierarchy model in the domain.
That means the system does **not** currently know:
- who reports to whom
- which sales users belong to the same explicit team
- whether a manager should see one subset of sales users but not another inside the same branch
Because of that, `team` currently behaves as an approximate operational scope rather than a strict org-chart scope.
## Contact Sharing Limitation
The production schema currently has no persisted contact-sharing table.
Result:
- contact visibility is enforced through customer ownership plus contact creator visibility
- mock/demo shared-contact behavior from legacy demo data is not part of the production boundary yet
## Future Team Hierarchy Model
Recommended future direction:
1. add a first-class manager/team relationship model
2. resolve `team` scope from that relationship instead of permissive approximation
3. add explicit contact-sharing persistence if the business still requires cross-owner contact access
4. expand runtime security fixtures around manager-team boundaries after real hierarchy data exists

561
plans/task-l.3.1.md Normal file
View File

@@ -0,0 +1,561 @@
# Task L.3.1: Customer, Contact & Approval Visibility Hardening
## Objective
Close the remaining authorization gaps from Task L.3 and make CRM visibility enforcement production-ready.
After Task L.3.1:
```txt
Customer Visibility
Contact Visibility
Approval Visibility
```
must all use the same resolved CRM access model as:
```txt
Leads
Enquiries
Quotations
Dashboard
PDF
Artifacts
```
This task is expected to close the Authorization Epic.
---
# Background
Task L.3 completed:
```txt
Quotation Scope Enforcement
Pricing Visibility
PDF Security
Artifact Security
Dashboard Revenue Security
Approval Actor Validation
```
Remaining gaps:
```txt
Customer Ownership Scope
Contact Ownership Scope
Approval List Visibility
Approval Detail Visibility
Runtime Security Verification
```
---
# Scope L3.1.1 Customer Ownership Enforcement
Apply resolved CRM access to:
```txt
Customers List
Customer Detail
Customer Search
Customer Lookup
Customer API
```
Rules:
## Sales
```txt
Own customers only
```
Customer ownership derived from:
```txt
createdBy
salesmanId
assigned ownership relationship
```
based on current CRM model.
---
## Sales Manager
```txt
Team customers
```
limited by:
```txt
Branch Scope
Product Scope
Ownership Scope
```
---
## Marketing
```txt
Monitoring visibility only
```
May view customer profile if related to:
```txt
Lead
Enquiry
```
but cannot access quotation pricing.
---
## CRM Admin
```txt
Organization-wide visibility
```
---
# Scope L3.1.2 Contact Ownership Enforcement
Apply same resolved access model.
Protect:
```txt
Contacts List
Contact Detail
Contact Search
Contact API
```
Rules:
```txt
Owner
Shared User
Manager Scope
Admin Scope
```
must all work correctly.
---
# Scope L3.1.3 Contact Sharing Validation
Audit existing sharing model.
Verify:
```txt
Shared Contact
```
still visible after ownership enforcement.
Rules:
```txt
Creator
Shared User
CRM Admin
```
retain access.
Users outside sharing relationship:
```txt
Access Denied
```
---
# Scope L3.1.4 Approval Visibility Enforcement
Approval actor validation already exists.
Now enforce visibility for:
```txt
Approval List
Approval Detail
Approval History
Approval Dashboard Widgets
```
Rules:
User can only see approval records when:
```txt
Can Access Related Quotation
```
OR
```txt
Current Approval Actor
```
OR
```txt
CRM Admin
```
---
# Scope L3.1.5 Approval Detail Security
Protect:
```txt
Approval Route
Approval APIs
Approval Timeline
Approval History
```
Users without quotation visibility:
```txt
cannot access approval details
```
even if they know the URL.
Server-side enforcement required.
---
# Scope L3.1.6 Approval Dashboard Hardening
Verify:
```txt
Pending Approvals
My Approvals
Approval KPIs
Approval Analytics
```
all respect:
```txt
Branch Scope
Product Scope
Ownership Scope
```
and resolved CRM access.
---
# Scope L3.1.7 Team Scope Clarification
Current implementation uses:
```txt
Most Permissive Ownership Scope
```
but team membership is approximate.
Document current behavior.
Create:
```txt
docs/security/team-scope-limitations.md
```
Document:
```txt
Current Team Scope Logic
Known Limitations
Future Team Hierarchy Model
```
No hierarchy implementation in this task.
---
# Scope L3.1.8 Security Fixture Expansion
Expand:
```txt
scripts/security/verify-crm-access.ts
```
Add scenarios:
### Customer Visibility
```txt
Sales A
cannot see
Sales B customer
```
---
### Contact Sharing
```txt
Shared Contact
visible
Unshared Contact
hidden
```
---
### Approval Visibility
```txt
Approver
can access
Non-Approver
cannot access
```
---
### Marketing
```txt
can monitor lead
cannot view quotation pricing
```
---
### CRM Admin
```txt
full access
```
---
# Scope L3.1.9 Security Audit Logging
Extend:
```txt
crm_security_access
```
Events:
```txt
customer_scope_violation
contact_scope_violation
approval_scope_violation
```
Payload:
```txt
userId
entityId
entityType
reason
resolvedScope
```
---
# Scope L3.1.10 Security Inventory Update
Update:
```txt
docs/security/crm-access-enforcement-inventory.md
```
Status should become:
```txt
Protected
```
for:
```txt
Customers
Contacts
Approvals
```
Remove:
```txt
Partially Protected
```
classification where resolved.
---
# Scope L3.1.11 Authorization Boundary Review
Update:
```txt
docs/security/crm-authorization-boundaries.md
```
Document final behavior for:
```txt
Customer
Contact
Lead
Enquiry
Quotation
Approval
Dashboard
Artifacts
Settings
```
This becomes the official CRM authorization reference.
---
# Verification Matrix
## Sales A
Expected:
```txt
Own Customers
Own Contacts
Own Quotations
```
Cannot:
```txt
Access Sales B records
```
---
## Sales Manager
Expected:
```txt
Team Customers
Team Contacts
Team Quotations
```
within scope.
---
## Marketing
Expected:
```txt
Lead Monitoring
Follow-Ups
Assignments
```
Cannot:
```txt
View Pricing
View Revenue
View Approval Detail
```
---
## Approver
Expected:
```txt
Can Access Pending Approval
Can Access Approval Detail
```
---
## Non Approver
Expected:
```txt
Cannot Access Approval Detail
```
---
## CRM Admin
Expected:
```txt
Full CRM Access
```
---
# Deliverables
1. Customer Ownership Enforcement
2. Contact Ownership Enforcement
3. Contact Sharing Validation
4. Approval Visibility Enforcement
5. Approval Dashboard Hardening
6. Security Fixture Expansion
7. Security Audit Events
8. Updated Security Inventory
9. Authorization Boundary Review
---
# Definition of Done
Task L.3.1 is complete when:
* customer visibility uses resolved CRM access
* contact visibility uses resolved CRM access
* contact sharing still works
* approval visibility is secured
* approval detail is secured
* approval dashboard respects scope
* security regression scenarios pass
* security inventory updated
* authorization boundary documentation updated
Result:
```txt
Authorization Foundation = CLOSED
CRM Security Boundary = PRODUCTION READY
CRM UAT Security Baseline = READY
```

View File

@@ -57,6 +57,41 @@ const checks: Check[] = [
name: 'quotation service applies scoped record access',
file: 'src/features/crm/quotations/server/service.ts',
patterns: ['canAccessScopedCrmRecord', 'buildQuotationAccessScopedFilters', 'Forbidden branch scope']
},
{
name: 'customer route passes resolved scope context',
file: 'src/app/api/crm/customers/route.ts',
patterns: ['buildCrmSecurityContext', 'listCustomers(organization.id', 'customer_scope_violation']
},
{
name: 'customer detail route passes resolved scope context',
file: 'src/app/api/crm/customers/[id]/route.ts',
patterns: ['buildCrmSecurityContext', 'getCustomerDetail(id, organization.id, accessContext)']
},
{
name: 'customer contact routes pass resolved scope context',
file: 'src/app/api/crm/customers/[id]/contacts/route.ts',
patterns: ['buildCrmSecurityContext', 'listCustomerContacts(id, organization.id, accessContext)']
},
{
name: 'customer service applies access-scoped ownership filtering',
file: 'src/features/crm/customers/server/service.ts',
patterns: ['resolveAccessibleCustomerRelationSets', 'canAccessCustomerRow', 'ownershipScope === \'monitor\'']
},
{
name: 'approval route passes resolved scope context',
file: 'src/app/api/crm/approvals/route.ts',
patterns: ['buildCrmSecurityContext', 'listApprovalRequests(organization.id', 'approval_scope_violation']
},
{
name: 'approval detail route passes resolved scope context',
file: 'src/app/api/crm/approvals/[id]/route.ts',
patterns: ['buildCrmSecurityContext', 'getApprovalRequest(id, organization.id, accessContext)']
},
{
name: 'approval service enforces current-actor or quotation visibility',
file: 'src/features/foundation/approval/server/service.ts',
patterns: ['canCurrentActorAccessApproval', 'canAccessQuotationForApproval', 'visibleItems']
}
];

View File

@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { cancelApproval, getApprovalRequest } from '@/features/foundation/approval/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -10,10 +14,15 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalRead
});
const approval = await getApprovalRequest(id, organization.id);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const approval = await getApprovalRequest(id, organization.id, accessContext);
return NextResponse.json({
success: true,
@@ -23,6 +32,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const session = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalRead
}).catch(() => null);
if (session) {
await auditCrmSecurityEvent({
organizationId: session.organization.id,
userId: session.session.user.id,
action: 'approval_scope_violation',
entityType: 'crm_approval_request',
entityId: (await params).id,
reason: 'Approval detail visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listApprovalRequests, submitForApproval } from '@/features/foundation/approval/server/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext
} from '@/features/crm/security/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -13,7 +17,7 @@ const submitApprovalSchema = z.object({
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalRead
});
const { searchParams } = request.nextUrl;
@@ -24,6 +28,11 @@ export async function GET(request: NextRequest) {
const entityType = searchParams.get('entityType') ?? undefined;
const entityId = searchParams.get('entityId') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const result = await listApprovalRequests(organization.id, {
page,
limit,
@@ -32,7 +41,7 @@ export async function GET(request: NextRequest) {
entityType,
entityId,
sort
});
}, accessContext);
return NextResponse.json({
success: true,
@@ -45,6 +54,22 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const session = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalRead
}).catch(() => null);
if (session) {
await auditCrmSecurityEvent({
organizationId: session.organization.id,
userId: session.session.user.id,
action: 'approval_scope_violation',
entityType: 'crm_approval_request',
entityId: 'list',
reason: 'Approval list visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext
} from '@/features/crm/security/server/service';
import {
listCustomerContacts,
softDeleteCustomerContact,
@@ -17,11 +21,16 @@ type Params = {
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, contactId } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactUpdate
});
const payload = customerContactSchema.parse(await request.json());
const before = (await listCustomerContacts(id, organization.id)).find(
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = (await listCustomerContacts(id, organization.id, accessContext)).find(
(contact) => contact.id === contactId
);
@@ -34,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: Params) {
contactId,
organization.id,
session.user.id,
payload
payload,
accessContext
);
await auditUpdate({
@@ -53,6 +63,22 @@ export async function PATCH(request: NextRequest, { params }: Params) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const access = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactUpdate
}).catch(() => null);
if (access) {
await auditCrmSecurityEvent({
organizationId: access.organization.id,
userId: access.session.user.id,
action: 'contact_scope_violation',
entityType: 'crm_customer_contact',
entityId: (await params).contactId,
reason: 'Customer contact update visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}
@@ -74,10 +100,15 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, contactId } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactDelete
});
const before = (await listCustomerContacts(id, organization.id)).find(
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = (await listCustomerContacts(id, organization.id, accessContext)).find(
(contact) => contact.id === contactId
);
@@ -89,7 +120,8 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
id,
contactId,
organization.id,
session.user.id
session.user.id,
accessContext
);
await auditDelete({
@@ -107,6 +139,22 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const access = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactDelete
}).catch(() => null);
if (access) {
await auditCrmSecurityEvent({
organizationId: access.organization.id,
userId: access.session.user.id,
action: 'contact_scope_violation',
entityType: 'crm_customer_contact',
entityId: (await params).contactId,
reason: 'Customer contact delete visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext
} from '@/features/crm/security/server/service';
import {
createCustomerContact,
listCustomerContacts
@@ -16,10 +20,15 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactRead
});
const items = await listCustomerContacts(id, organization.id);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const items = await listCustomerContacts(id, organization.id, accessContext);
return NextResponse.json({
success: true,
@@ -29,6 +38,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const access = await requireOrganizationAccess({
permission: PERMISSIONS.crmContactRead
}).catch(() => null);
if (access) {
await auditCrmSecurityEvent({
organizationId: access.organization.id,
userId: access.session.user.id,
action: 'contact_scope_violation',
entityType: 'crm_customer_contact',
entityId: (await params).id,
reason: 'Customer contact list visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}
@@ -43,11 +68,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
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.crmContactCreate
});
const payload = customerContactSchema.parse(await request.json());
const created = await createCustomerContact(id, organization.id, session.user.id, payload);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const created = await createCustomerContact(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditCreate({
organizationId: organization.id,

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext
} from '@/features/crm/security/server/service';
import {
getCustomerActivity,
getCustomerDetail,
@@ -25,11 +29,16 @@ const customerRequestSchema = customerSchema.extend({
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerRead
});
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const [customer, activity] = await Promise.all([
getCustomerDetail(id, organization.id),
getCustomerDetail(id, organization.id, accessContext),
getCustomerActivity(id, organization.id)
]);
@@ -42,6 +51,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const access = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerRead
}).catch(() => null);
if (access) {
await auditCrmSecurityEvent({
organizationId: access.organization.id,
userId: access.session.user.id,
action: 'customer_scope_violation',
entityType: 'crm_customer',
entityId: (await params).id,
reason: 'Customer detail visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}
@@ -54,12 +79,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerUpdate
});
const payload = customerRequestSchema.parse(await request.json());
const before = await getCustomerDetail(id, organization.id);
const updated = await updateCustomer(id, organization.id, session.user.id, payload);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = await getCustomerDetail(id, organization.id, accessContext);
const updated = await updateCustomer(id, organization.id, session.user.id, payload, accessContext);
await auditUpdate({
organizationId: organization.id,
@@ -97,11 +127,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerDelete
});
const before = await getCustomerDetail(id, organization.id);
const updated = await softDeleteCustomer(id, organization.id, session.user.id);
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const before = await getCustomerDetail(id, organization.id, accessContext);
const updated = await softDeleteCustomer(id, organization.id, session.user.id, accessContext);
await auditDelete({
organizationId: organization.id,

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
auditCrmSecurityEvent,
buildCrmSecurityContext
} from '@/features/crm/security/server/service';
import { createCustomer, listCustomers } from '@/features/crm/customers/server/service';
import { customerSchema } from '@/features/crm/customers/schemas/customer.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
@@ -15,7 +19,7 @@ const customerRequestSchema = customerSchema.extend({
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerRead
});
const { searchParams } = request.nextUrl;
@@ -26,6 +30,11 @@ export async function GET(request: NextRequest) {
const customerType = searchParams.get('customerType') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const accessContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const result = await listCustomers(organization.id, {
page,
limit,
@@ -34,7 +43,7 @@ export async function GET(request: NextRequest) {
customerType,
branch,
sort
});
}, accessContext);
const offset = (page - 1) * limit;
return NextResponse.json({
@@ -48,6 +57,22 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
if (error instanceof AuthError) {
if (error.status === 403) {
const access = await requireOrganizationAccess({
permission: PERMISSIONS.crmCustomerRead
}).catch(() => null);
if (access) {
await auditCrmSecurityEvent({
organizationId: access.organization.id,
userId: access.session.user.id,
action: 'customer_scope_violation',
entityType: 'crm_customer',
entityId: 'list',
reason: 'Customer list visibility denied'
});
}
}
return NextResponse.json({ message: error.message }, { status: error.status });
}

View File

@@ -2,6 +2,7 @@ import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { ApprovalDetail } from '@/features/foundation/approval/components/approval-detail';
import { getApprovalRequest } from '@/features/foundation/approval/server/service';
import { approvalByIdOptions } from '@/features/foundation/approval/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -40,9 +41,30 @@ export default async function ApprovalDetailRoute({ params }: PageProps) {
session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit)));
const isOrgAdmin =
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
const accessContext =
session?.user?.activeOrganizationId && session?.user?.id
? {
organizationId: session.user.activeOrganizationId,
userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support',
permissions: session.user.activePermissions ?? [],
branchScopeIds: session.user.activeBranchScopeIds ?? [],
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
branchScopeMode: 'all',
productScopeMode: 'all'
}
: null;
const resolvedCanRead =
canRead && accessContext
? await getApprovalRequest(id, session.user.activeOrganizationId!, accessContext)
.then(() => true)
.catch(() => false)
: false;
const queryClient = getQueryClient();
if (canRead) {
if (resolvedCanRead) {
void queryClient.prefetchQuery(approvalByIdOptions(id));
}
@@ -50,14 +72,14 @@ export default async function ApprovalDetailRoute({ params }: PageProps) {
<PageContainer
pageTitle='รายละเอียดการอนุมัติเอกสาร'
pageDescription='ลำดับขั้น ผู้อนุมัติปัจจุบัน และไทม์ไลน์ของคำขออนุมัติเอกสาร'
access={canRead}
access={resolvedCanRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to this approval request.
</div>
}
>
{canRead && session?.user ? (
{resolvedCanRead && session?.user ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<ApprovalDetail
approvalId={id}

View File

@@ -3,7 +3,10 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { CustomerDetail } from '@/features/crm/customers/components/customer-detail';
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
import {
getCustomerDetail,
getCustomerReferenceData
} from '@/features/crm/customers/server/service';
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
@@ -46,34 +49,55 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmContactDelete)));
const accessContext =
session?.user?.activeOrganizationId && session?.user?.id
? {
organizationId: session.user.activeOrganizationId,
userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support',
permissions: session.user.activePermissions ?? [],
branchScopeIds: session.user.activeBranchScopeIds ?? [],
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
branchScopeMode: 'all',
productScopeMode: 'all'
}
: null;
const resolvedCanRead =
canRead && accessContext
? await getCustomerDetail(id, session.user.activeOrganizationId!, accessContext)
.then(() => true)
.catch(() => false)
: false;
const queryClient = getQueryClient();
if (canRead) {
if (resolvedCanRead) {
void queryClient.prefetchQuery(customerByIdOptions(id));
}
if (canContactRead) {
if (resolvedCanRead && canContactRead) {
void queryClient.prefetchQuery(customerContactsOptions(id));
}
const referenceData =
canRead && session?.user?.activeOrganizationId
resolvedCanRead && session?.user?.activeOrganizationId
? await getCustomerReferenceData(session.user.activeOrganizationId)
: null;
const relatedEnquiries =
canRead && session?.user?.activeOrganizationId
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext)
: [];
const relatedQuotations =
canRead && session?.user?.activeOrganizationId
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId)
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId, accessContext)
: [];
return (
<PageContainer
pageTitle='Customer Detail'
pageDescription='Customer profile, contacts, audit timeline, and future CRM document handoff.'
access={canRead}
access={resolvedCanRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to this customer record.

View File

@@ -1,11 +1,13 @@
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
import { crmCustomerContacts, crmCustomers, crmEnquiries, crmQuotations, msOptions, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { type CrmSecurityContext } from '@/features/crm/security/server/service';
import type {
BranchOption,
CustomerActivityRecord,
@@ -25,6 +27,8 @@ type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubG
let customerSubGroupColumnAvailable: boolean | undefined;
export type CustomerAccessContext = CrmSecurityContext;
const CUSTOMER_OPTION_CATEGORIES = {
customerType: 'crm_customer_type',
customerStatus: 'crm_customer_status',
@@ -269,6 +273,130 @@ async function assertCustomerBelongsToOrganization(id: string, organizationId: s
return customer;
}
async function resolveAccessibleCustomerRelationSets(
organizationId: string,
accessContext: CustomerAccessContext
) {
const [enquiries, quotations] = await Promise.all([
db
.select({
customerId: crmEnquiries.customerId,
createdBy: crmEnquiries.createdBy,
assignedToUserId: crmEnquiries.assignedToUserId,
branchId: crmEnquiries.branchId,
productType: crmEnquiries.productType
})
.from(crmEnquiries)
.where(
and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))
),
db
.select({
customerId: crmQuotations.customerId,
createdBy: crmQuotations.createdBy,
salesmanId: crmQuotations.salesmanId,
branchId: crmQuotations.branchId,
quotationType: crmQuotations.quotationType
})
.from(crmQuotations)
.where(
and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt))
)
]);
const enquiryCustomerIds = new Set<string>();
const quotationCustomerIds = new Set<string>();
for (const row of enquiries) {
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
continue;
}
if (!hasProductScopeAccess(accessContext, row.productType)) {
continue;
}
if (
accessContext.membershipRole === 'admin' ||
accessContext.ownershipScope === 'organization' ||
accessContext.ownershipScope === 'team' ||
accessContext.ownershipScope === 'monitor' ||
row.createdBy === accessContext.userId ||
row.assignedToUserId === accessContext.userId
) {
enquiryCustomerIds.add(row.customerId);
}
}
for (const row of quotations) {
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
continue;
}
if (!hasProductScopeAccess(accessContext, row.quotationType)) {
continue;
}
if (
accessContext.membershipRole === 'admin' ||
accessContext.ownershipScope === 'organization' ||
accessContext.ownershipScope === 'team' ||
row.createdBy === accessContext.userId ||
row.salesmanId === accessContext.userId
) {
quotationCustomerIds.add(row.customerId);
}
}
return { enquiryCustomerIds, quotationCustomerIds };
}
async function canAccessCustomerRow(
row: CustomerRecordSource,
accessContext: CustomerAccessContext
) {
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
return false;
}
if (
accessContext.membershipRole === 'admin' ||
accessContext.ownershipScope === 'organization' ||
accessContext.ownershipScope === 'team'
) {
return true;
}
const { enquiryCustomerIds, quotationCustomerIds } = await resolveAccessibleCustomerRelationSets(
row.organizationId,
accessContext
);
if (accessContext.ownershipScope === 'monitor') {
return enquiryCustomerIds.has(row.id);
}
return (
row.createdBy === accessContext.userId ||
enquiryCustomerIds.has(row.id) ||
quotationCustomerIds.has(row.id)
);
}
async function assertCustomerAccess(
id: string,
organizationId: string,
accessContext?: CustomerAccessContext
) {
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
if (accessContext && !(await canAccessCustomerRow(customer, accessContext))) {
throw new AuthError('Forbidden', 403);
}
return customer;
}
async function assertContactBelongsToCustomer(
contactId: string,
customerId: string,
@@ -406,7 +534,8 @@ export async function getCustomerReferenceData(
export async function listCustomers(
organizationId: string,
filters: CustomerFilters
filters: CustomerFilters,
accessContext?: CustomerAccessContext
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
const page = filters.page ?? 1;
@@ -415,16 +544,22 @@ export async function listCustomers(
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
const rows = await db
.select(getCustomerRecordSelection(includeCustomerSubGroup))
.from(crmCustomers)
.where(where)
.orderBy(parseSort(filters.sort))
.limit(limit)
.offset(offset);
.orderBy(parseSort(filters.sort));
const customerIds = rows.map((row) => row.id);
const scopedRows = accessContext
? (
await Promise.all(
rows.map(async (row) => ((await canAccessCustomerRow(row, accessContext)) ? row : null))
)
).filter((row): row is CustomerRecordSource => row !== null)
: rows;
const pagedRows = scopedRows.slice(offset, offset + limit);
const customerIds = pagedRows.map((row) => row.id);
const contactCounts = customerIds.length
? await db
.select({
@@ -444,19 +579,20 @@ export async function listCustomers(
const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value]));
return {
items: rows.map((row) => ({
items: pagedRows.map((row) => ({
...mapCustomerRecord(row),
contactCount: countMap.get(row.id) ?? 0
})),
totalItems: totalResult?.value ?? 0
totalItems: scopedRows.length
};
}
export async function getCustomerDetail(
id: string,
organizationId: string
organizationId: string,
accessContext?: CustomerAccessContext
): Promise<CustomerRecord> {
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
const customer = await assertCustomerAccess(id, organizationId, accessContext);
return mapCustomerRecord(customer);
}
@@ -542,10 +678,11 @@ export async function updateCustomer(
id: string,
organizationId: string,
userId: string,
payload: CustomerMutationPayload
payload: CustomerMutationPayload,
accessContext?: CustomerAccessContext
) {
await validateCustomerPayload(organizationId, payload);
await assertCustomerBelongsToOrganization(id, organizationId);
await assertCustomerAccess(id, organizationId, accessContext);
const [updated] = await db
.update(crmCustomers)
@@ -580,8 +717,13 @@ export async function updateCustomer(
return updated;
}
export async function softDeleteCustomer(id: string, organizationId: string, userId: string) {
await assertCustomerBelongsToOrganization(id, organizationId);
export async function softDeleteCustomer(
id: string,
organizationId: string,
userId: string,
accessContext?: CustomerAccessContext
) {
await assertCustomerAccess(id, organizationId, accessContext);
const now = new Date();
@@ -614,8 +756,12 @@ export async function softDeleteCustomer(id: string, organizationId: string, use
return updated;
}
export async function listCustomerContacts(id: string, organizationId: string) {
await assertCustomerBelongsToOrganization(id, organizationId);
export async function listCustomerContacts(
id: string,
organizationId: string,
accessContext?: CustomerAccessContext
) {
await assertCustomerAccess(id, organizationId, accessContext);
const rows = await db.query.crmCustomerContacts.findMany({
where: and(
eq(crmCustomerContacts.customerId, id),
@@ -625,16 +771,26 @@ export async function listCustomerContacts(id: string, organizationId: string) {
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
});
return rows.map(mapCustomerContactRecord);
return rows
.filter(
(row) =>
!accessContext ||
row.createdBy === accessContext.userId ||
accessContext.membershipRole === 'admin' ||
accessContext.ownershipScope === 'organization' ||
accessContext.ownershipScope === 'team'
)
.map(mapCustomerContactRecord);
}
export async function createCustomerContact(
customerId: string,
organizationId: string,
userId: string,
payload: CustomerContactMutationPayload
payload: CustomerContactMutationPayload,
accessContext?: CustomerAccessContext
) {
await assertCustomerBelongsToOrganization(customerId, organizationId);
await assertCustomerAccess(customerId, organizationId, accessContext);
return db.transaction(async (tx) => {
if (payload.isPrimary) {
@@ -683,9 +839,10 @@ export async function updateCustomerContact(
contactId: string,
organizationId: string,
userId: string,
payload: CustomerContactMutationPayload
payload: CustomerContactMutationPayload,
accessContext?: CustomerAccessContext
) {
await assertCustomerBelongsToOrganization(customerId, organizationId);
await assertCustomerAccess(customerId, organizationId, accessContext);
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
return db.transaction(async (tx) => {
@@ -732,9 +889,10 @@ export async function softDeleteCustomerContact(
customerId: string,
contactId: string,
organizationId: string,
userId: string
userId: string,
accessContext?: CustomerAccessContext
) {
await assertCustomerBelongsToOrganization(customerId, organizationId);
await assertCustomerAccess(customerId, organizationId, accessContext);
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
const [updated] = await db

View File

@@ -686,7 +686,7 @@ async function buildApprovalAnalytics(
page: 1,
limit: 50,
status: 'pending'
});
}, context);
const todayStart = startOfDay(new Date());
const todayEnd = endOfDay(new Date());
const rows = await db

View File

@@ -1348,7 +1348,8 @@ export async function softDeleteEnquiryFollowup(
export async function listCustomerEnquiryRelations(
customerId: string,
organizationId: string
organizationId: string,
accessContext?: EnquiryAccessContext
): Promise<EnquiryCustomerRelationItem[]> {
const rows = await db
.select()
@@ -1363,7 +1364,9 @@ export async function listCustomerEnquiryRelations(
.orderBy(desc(crmEnquiries.updatedAt))
.limit(10);
return rows.map((row) => ({
return rows
.filter((row) => !accessContext || canAccessEnquiryRow(row, accessContext))
.map((row) => ({
id: row.id,
code: row.code,
title: row.title,

View File

@@ -2319,7 +2319,8 @@ export async function listEnquiryQuotationRelations(
export async function listCustomerQuotationRelations(
customerId: string,
organizationId: string
organizationId: string,
accessContext?: QuotationAccessContext
): Promise<QuotationRelationItem[]> {
const rows = await db
.select()
@@ -2333,7 +2334,9 @@ export async function listCustomerQuotationRelations(
)
.orderBy(desc(crmQuotations.updatedAt));
return rows.map((row) => ({
return rows
.filter((row) => !accessContext || canAccessQuotationRow(row, accessContext))
.map((row) => ({
id: row.id,
code: row.code,
status: row.status,

View File

@@ -89,7 +89,14 @@ export function canAccessScopedCrmRecord(
export async function auditCrmSecurityEvent(input: {
organizationId: string;
userId: string;
action: 'access_denied' | 'scope_violation' | 'permission_violation' | 'pricing_hidden';
action:
| 'access_denied'
| 'scope_violation'
| 'permission_violation'
| 'pricing_hidden'
| 'customer_scope_violation'
| 'contact_scope_violation'
| 'approval_scope_violation';
entityType: string;
entityId: string;
reason: string;

View File

@@ -15,6 +15,10 @@ import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { type SystemRole } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
type CrmSecurityContext,
canAccessScopedCrmRecord
} from '@/features/crm/security/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import type {
ApprovalActionRecord,
@@ -49,6 +53,7 @@ const APPROVAL_ACTIONS = {
} as const;
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
export type ApprovalAccessContext = CrmSecurityContext;
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
return {
@@ -683,24 +688,61 @@ function buildApprovalFilters(organizationId: string, filters: ApprovalFilters):
];
}
function canCurrentActorAccessApproval(
currentStep: ApprovalStepRecord | null,
accessContext?: ApprovalAccessContext
) {
if (!accessContext) {
return false;
}
if (
accessContext.membershipRole === 'admin' ||
accessContext.ownershipScope === 'organization'
) {
return true;
}
if (!currentStep) {
return false;
}
return (
accessContext.businessRole === currentStep.roleCode ||
(accessContext.businessRoles ?? []).includes(currentStep.roleCode)
);
}
function canAccessQuotationForApproval(
quotation: typeof crmQuotations.$inferSelect | null | undefined,
accessContext?: ApprovalAccessContext
) {
if (!quotation || !accessContext) {
return false;
}
return canAccessScopedCrmRecord(accessContext, {
branchId: quotation.branchId,
productType: quotation.quotationType,
createdBy: quotation.createdBy,
ownerUserId: quotation.salesmanId
});
}
export async function listApprovalRequests(
organizationId: string,
filters: ApprovalFilters
filters: ApprovalFilters,
accessContext?: ApprovalAccessContext
): Promise<{ items: ApprovalListItem[]; totalItems: number }> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildApprovalFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where);
const rows = await db
.select()
.from(crmApprovalRequests)
.where(where)
.orderBy(parseSort(filters.sort))
.limit(limit)
.offset(offset);
.orderBy(parseSort(filters.sort));
const workflowIds = [...new Set(rows.map((row) => row.workflowId))];
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
@@ -740,11 +782,37 @@ export async function listApprovalRequests(
}
}
return {
items: rows.map((row) => {
const quotationIds = rows
.filter((row) => row.entityType === 'quotation')
.map((row) => row.entityId);
const quotations = quotationIds.length
? await db
.select()
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, organizationId),
inArray(crmQuotations.id, quotationIds),
isNull(crmQuotations.deletedAt)
)
)
: [];
const quotationMap = new Map(quotations.map((row) => [row.id, row]));
const visibleItems = rows
.map((row) => {
const workflow = workflowMap.get(row.workflowId);
const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null;
const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null;
const canAccess =
!accessContext ||
canCurrentActorAccessApproval(currentStep, accessContext) ||
(row.entityType === 'quotation' &&
canAccessQuotationForApproval(quotationMap.get(row.entityId), accessContext));
if (!canAccess) {
return null;
}
return {
...mapRequestRecord(row),
@@ -755,9 +823,14 @@ export async function listApprovalRequests(
requestedByName: requesterMap.get(row.requestedBy) ?? null,
entityCode: entitySummary?.entityCode ?? null,
entityTitle: entitySummary?.entityTitle ?? null
};
}),
totalItems: totalResult?.value ?? 0
} satisfies ApprovalListItem;
})
.filter((item): item is ApprovalListItem => item !== null);
const offset = (page - 1) * limit;
return {
items: visibleItems.slice(offset, offset + limit),
totalItems: visibleItems.length
};
}
@@ -815,7 +888,8 @@ export async function getCurrentApprovalStep(
export async function getApprovalRequest(
id: string,
organizationId: string
organizationId: string,
accessContext?: ApprovalAccessContext
): Promise<ApprovalDetailRecord> {
const requestRow = await assertApprovalRequest(id, organizationId);
const [workflowRow] = await db
@@ -838,6 +912,31 @@ export async function getApprovalRequest(
const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null;
const entitySummary =
entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null;
const quotation =
requestRow.entityType === 'quotation'
? (
await db
.select()
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, organizationId),
eq(crmQuotations.id, requestRow.entityId),
isNull(crmQuotations.deletedAt)
)
)
.limit(1)
)[0]
: null;
const canAccess =
!accessContext ||
canCurrentActorAccessApproval(currentStep, accessContext) ||
(requestRow.entityType === 'quotation' && canAccessQuotationForApproval(quotation, accessContext));
if (!canAccess) {
throw new AuthError('Forbidden', 403);
}
return {
request: mapRequestRecord(requestRow),