This commit is contained in:
phaichayon
2026-06-22 10:22:45 +07:00
parent 51d67ef7c2
commit b154a8de33
37 changed files with 5952 additions and 332 deletions

View File

@@ -0,0 +1,60 @@
# Task L: CRM Permission & Role Management
## Summary
Task L starts the production CRM authorization foundation by replacing template-level behavior with organization-owned role profiles, effective permission resolution, and server-side scope enforcement.
## Implemented
- Added CRM role profile defaults for:
- `marketing`
- `sales`
- `sales_support`
- `sales_manager`
- `department_manager`
- `top_manager`
- `crm_admin`
- Added dedicated CRM permissions for:
- quotation pricing visibility
- role management
- master option management
- Added `crmRoleProfiles` persistence model with:
- permissions
- ownership scope
- branch scope mode
- product scope mode
- approval authority
- active/system flags
- Added membership scope fields:
- `branchScopeIds`
- `productTypeScopeIds`
- Added resolved CRM access helper to combine:
- system role
- membership role
- role profile permissions
- direct membership permissions
- Added CRM Settings > Roles page and role-management APIs for:
- role listing
- role editing
- permission matrix
- role cloning
- role activation/deactivation
- Added audit logging for CRM role create/update events.
- Moved CRM dashboard access evaluation to resolved branch/product/ownership scope.
- Started server-side enquiry enforcement for:
- branch scope
- product-type scope
- ownership visibility
- assign/reassign/follow-up access consistency
## Notes
- This task lays the authorization foundation first; dynamic user assignment UI for cloned CRM roles can build on top of these role profiles next.
- Quotation and approval flows should continue migrating onto the same resolved access model so ownership and scope rules stay consistent across CRM.
## Verification
- Run: `npx tsc --noEmit`
- Confirm CRM Settings sidebar shows `Roles & Permissions` only for users with `crm.role.read`
- Confirm sales users cannot access enquiries outside assigned branch/product scope
- Confirm dashboard export and dashboard summary honor resolved scope and permissions

View File

@@ -0,0 +1,23 @@
CREATE TABLE "crm_role_profiles" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"description" text,
"permissions" text[] DEFAULT '{}' NOT NULL,
"branch_scope_mode" text DEFAULT 'assigned' NOT NULL,
"product_scope_mode" text DEFAULT 'assigned' NOT NULL,
"ownership_scope" text DEFAULT 'own' NOT NULL,
"approval_authority" text DEFAULT 'none' NOT NULL,
"is_system" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"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
);
--> statement-breakpoint
ALTER TABLE "memberships" ADD COLUMN "branch_scope_ids" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "memberships" ADD COLUMN "product_type_scope_ids" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "crm_role_profiles_org_code_idx" ON "crm_role_profiles" USING btree ("organization_id","code");

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,13 @@
"when": 1781836261952, "when": 1781836261952,
"tag": "0012_simple_bucky", "tag": "0012_simple_bucky",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1782097963376,
"tag": "0013_great_nicolaos",
"breakpoints": true
} }
] ]
} }

507
plans/task-l.md Normal file
View File

@@ -0,0 +1,507 @@
# Task L: CRM Permission & Role Management Center
## Objective
Replace remaining template-level role behavior with a production CRM authorization model.
Task L establishes:
* CRM business roles
* role-based permissions
* branch scope
* product-type scope
* ownership visibility
* approval authority
* menu visibility
* configuration security
This task becomes the authorization foundation for all future CRM modules.
---
# Current Problems
The system currently has:
```txt
Users
Memberships
Roles
Permissions
```
but still relies partly on template behavior.
Examples:
* users cannot manage permissions directly
* role assignment is incomplete
* branch visibility is not enforced everywhere
* product-type visibility is not enforced everywhere
* dashboard visibility still depends on broad organization access
* approval authority is role-driven but not centrally configurable
---
# Business Roles (Frozen)
## Marketing
Responsibilities:
```txt
Lead creation
Lead follow-up
Lead assignment
Lead monitoring
```
Can:
```txt
View Leads
Create Leads
Assign Leads
View Enquiries
View Follow-Ups
```
Cannot:
```txt
View quotation pricing
View revenue dashboard
Approve quotations
Edit quotations
```
---
## Sales
Responsibilities:
```txt
Manage enquiries
Manage quotations
Manage follow-ups
```
Can:
```txt
View assigned enquiries
Create quotations
Edit own quotations
Manage own follow-ups
```
Cannot:
```txt
View other sales data
Approve quotations
Manage CRM settings
```
---
## Sales Support
Can:
```txt
Create quotations
Edit quotations
Support sales operations
```
Cannot:
```txt
Approve quotations
View management analytics
```
---
## Sales Manager
Can:
```txt
View team enquiries
Assign sales
Approve quotations
View team dashboard
```
Visibility:
```txt
Branch scope
Product-type scope
```
---
## Department Manager
Can:
```txt
Approve quotations
View department analytics
View department pipeline
```
---
## Top Manager
Can:
```txt
Approve final quotations
View all CRM analytics
```
---
## CRM Admin
Can:
```txt
Manage templates
Manage workflows
Manage document sequences
Manage master options
Manage CRM configuration
```
Cannot:
```txt
Override system security
```
---
## System Admin
Can:
```txt
Everything
```
---
# Scope L.1 Role Management UI
Add:
```txt
CRM Settings
└─ Roles & Permissions
```
Features:
```txt
Role list
Role detail
Permission matrix
Role cloning
Role activation
Role deactivation
```
---
# Scope L.2 Permission Assignment
Allow administrators to assign permissions to roles.
Support:
```txt
read
create
update
delete
approve
assign
export
manage
```
Examples:
```txt
crm.lead.read
crm.lead.create
crm.enquiry.read
crm.enquiry.assign
crm.quotation.approve
crm.dashboard.export
```
---
# Scope L.3 User Permission Resolution
Effective permission:
```txt
System Role
+
Membership Role
+
Direct Permissions
```
Evaluation order:
```txt
System Admin
Role Permissions
User Overrides
```
---
# Scope L.4 Branch Scope
User can be assigned:
```txt
Bangkok
Rayong
Chiang Mai
```
Visibility enforced server-side.
Examples:
```txt
Sales Bangkok
cannot view
Rayong enquiries
```
---
# Scope L.5 Product Type Scope
Supported:
```txt
Crane
Dock Door
Solar Cell
Service
Spare Part
```
Example:
```txt
Sales Crane
cannot see
Solar quotations
```
---
# Scope L.6 Ownership Enforcement
Server-side rules.
Sales:
```txt
Own records only
```
Manager:
```txt
Team records
```
Admin:
```txt
Organization records
```
Marketing:
```txt
Lead monitoring only
```
---
# Scope L.7 Menu Security
Navigation generated from permissions.
Examples:
```txt
Lead
Enquiry
Quotation
Approval
Dashboard
Settings
```
Menus disappear automatically when permission is absent.
---
# Scope L.8 Dashboard Security
Enforce:
```txt
Lead KPI
Enquiry KPI
Revenue KPI
Approval KPI
```
according to permission.
No UI-only hiding.
Server-side enforcement required.
---
# Scope L.9 CRM Settings Security
Protect:
```txt
Document Templates
Approval Workflows
Document Sequences
Master Options
```
using dedicated CRM permissions.
---
# Scope L.10 Permission Audit
Audit:
```txt
Role Created
Role Updated
Role Deleted
Permission Added
Permission Removed
Scope Changed
```
Entity Types:
```txt
crm_role
crm_permission
crm_scope
```
---
# Scope L.11 Migration Strategy
Convert legacy roles:
```txt
admin
user
```
into CRM-aligned business roles.
Backfill:
```txt
sales
marketing
sales_manager
crm_admin
```
where possible.
Retain historical records.
---
# Scope L.12 Verification
Verify:
```txt
Marketing cannot see quotation price
Sales cannot see other sales enquiries
Managers can see team data
Dashboard respects permissions
Menus respect permissions
CRM settings respect permissions
```
Run:
```txt
npx tsc --noEmit
```
---
# Deliverables
1. Role Management Center
2. Permission Matrix
3. Branch Scope Enforcement
4. Product Type Scope Enforcement
5. Ownership Visibility Enforcement
6. Dashboard Security Enforcement
7. CRM Settings Security
8. Audit Logging
9. Legacy Role Migration
---
# Definition of Done
Task L is complete when:
* permissions are assignable
* role matrix exists
* branch scope enforced
* product scope enforced
* ownership enforced
* dashboard secured
* settings secured
* menus secured
* audits recorded
This becomes the final authorization foundation before Report Center and Notification Center work begins.

View File

@@ -40,7 +40,12 @@ export async function GET(request: NextRequest) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
}, },
{ {
dateFrom: searchParams.get('dateFrom'), dateFrom: searchParams.get('dateFrom'),

View File

@@ -15,7 +15,12 @@ export async function GET(request: NextRequest) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
}, },
{ {
dateFrom: searchParams.get('dateFrom'), dateFrom: searchParams.get('dateFrom'),

View File

@@ -17,13 +17,25 @@ export async function POST(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryAssign permission: PERMISSIONS.crmEnquiryAssign
}); });
const payload = enquiryAssignmentRequestSchema.parse(await request.json()); const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id, { const accessContext = {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}); branchScopeIds: membership.branchScopeIds ?? [],
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload); productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await assignEnquiryToUser(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({ await auditAction({
organizationId: organization.id, organizationId: organization.id,

View File

@@ -13,12 +13,18 @@ export async function GET(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({ const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead permission: PERMISSIONS.crmEnquiryRead
}); });
const items = await listEnquiryProjectParties(id, organization.id, { const accessContext = {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}); branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listEnquiryProjectParties(id, organization.id, accessContext);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -26,13 +26,19 @@ export async function PATCH(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryFollowupUpdate permission: PERMISSIONS.crmEnquiryFollowupUpdate
}); });
const payload = followupRequestSchema.parse(await request.json()); const payload = followupRequestSchema.parse(await request.json());
const before = ( const accessContext = {
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}) branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listEnquiryFollowups(id, organization.id, accessContext)
).find( ).find(
(item) => item.id === followupId (item) => item.id === followupId
); );
@@ -47,12 +53,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
organization.id, organization.id,
session.user.id, session.user.id,
payload, payload,
{ accessContext
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
); );
await auditUpdate({ await auditUpdate({
@@ -95,13 +96,19 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({ const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupDelete permission: PERMISSIONS.crmEnquiryFollowupDelete
}); });
const before = ( const accessContext = {
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}) branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listEnquiryFollowups(id, organization.id, accessContext)
).find( ).find(
(item) => item.id === followupId (item) => item.id === followupId
); );
@@ -115,12 +122,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
followupId, followupId,
organization.id, organization.id,
session.user.id, session.user.id,
{ accessContext
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
); );
await auditDelete({ await auditDelete({

View File

@@ -24,12 +24,18 @@ export async function GET(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({ const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupRead permission: PERMISSIONS.crmEnquiryFollowupRead
}); });
const items = await listEnquiryFollowups(id, organization.id, { const accessContext = {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}); branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listEnquiryFollowups(id, organization.id, accessContext);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -57,17 +63,23 @@ export async function POST(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryFollowupCreate permission: PERMISSIONS.crmEnquiryFollowupCreate
}); });
const payload = followupRequestSchema.parse(await request.json()); const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createEnquiryFollowup( const created = await createEnquiryFollowup(
id, id,
organization.id, organization.id,
session.user.id, session.user.id,
payload, payload,
{ accessContext
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
); );
await auditCreate({ await auditCreate({

View File

@@ -17,13 +17,25 @@ export async function POST(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryReassign permission: PERMISSIONS.crmEnquiryReassign
}); });
const payload = enquiryAssignmentRequestSchema.parse(await request.json()); const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id, { const accessContext = {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}); branchScopeIds: membership.branchScopeIds ?? [],
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload); productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await reassignEnquiryToUser(
id,
organization.id,
session.user.id,
payload,
accessContext
);
await auditAction({ await auditAction({
organizationId: organization.id, organizationId: organization.id,

View File

@@ -32,7 +32,12 @@ export async function GET(_request: NextRequest, { params }: Params) {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
}; };
const [enquiry, activity] = await Promise.all([ const [enquiry, activity] = await Promise.all([
getEnquiryDetail(id, organization.id, accessContext), getEnquiryDetail(id, organization.id, accessContext),
@@ -66,18 +71,19 @@ export async function PATCH(request: NextRequest, { params }: Params) {
permission: PERMISSIONS.crmEnquiryUpdate permission: PERMISSIONS.crmEnquiryUpdate
}); });
const payload = enquiryRequestSchema.parse(await request.json()); const payload = enquiryRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id, { const accessContext = {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}); branchScopeIds: membership.branchScopeIds ?? [],
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, { productTypeScopeIds: membership.productTypeScopeIds ?? [],
organizationId: organization.id, ownershipScope: membership.ownershipScope ?? 'organization',
userId: session.user.id, branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
membershipRole: membership.role, productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
businessRole: membership.businessRole };
}); const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, accessContext);
await auditUpdate({ await auditUpdate({
organizationId: organization.id, organizationId: organization.id,
@@ -120,18 +126,19 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
const { organization, session, membership } = await requireOrganizationAccess({ const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryDelete permission: PERMISSIONS.crmEnquiryDelete
}); });
const before = await getEnquiryDetail(id, organization.id, { const accessContext = {
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}); branchScopeIds: membership.branchScopeIds ?? [],
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, { productTypeScopeIds: membership.productTypeScopeIds ?? [],
organizationId: organization.id, ownershipScope: membership.ownershipScope ?? 'organization',
userId: session.user.id, branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
membershipRole: membership.role, productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
businessRole: membership.businessRole };
}); const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, accessContext);
await auditDelete({ await auditDelete({
organizationId: organization.id, organizationId: organization.id,

View File

@@ -29,13 +29,19 @@ export async function GET(request: NextRequest) {
const branch = searchParams.get('branch') ?? undefined; const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined; const customer = searchParams.get('customer') ?? undefined;
const sort = searchParams.get('sort') ?? undefined; const sort = searchParams.get('sort') ?? undefined;
const result = await listEnquiries( const accessContext = {
{
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole businessRole: membership.businessRole,
}, branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const result = await listEnquiries(
accessContext,
{ {
page, page,
limit, limit,
@@ -79,10 +85,21 @@ export async function POST(request: NextRequest) {
permission: PERMISSIONS.crmEnquiryCreate permission: PERMISSIONS.crmEnquiryCreate
}); });
const payload = enquiryRequestSchema.parse(await request.json()); const payload = enquiryRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createEnquiry( const created = await createEnquiry(
organization.id, organization.id,
session.user.id, session.user.id,
membership.businessRole, accessContext,
payload payload
); );

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { cloneCrmRoleProfile } from '@/features/foundation/role-management/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const cloneRoleSchema = z.object({
code: z.string().min(2),
name: z.string().min(2),
description: z.string().nullable().optional()
});
type RouteProps = {
params: Promise<{ code: string }>;
};
export async function POST(request: NextRequest, props: RouteProps) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleCreate
});
const payload = cloneRoleSchema.parse(await request.json());
const { code } = await props.params;
const role = await cloneCrmRoleProfile(organization.id, session.user.id, code, payload);
return NextResponse.json(
{
success: true,
message: 'CRM role cloned successfully',
role
},
{ status: 201 }
);
} 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 });
}
return NextResponse.json({ message: 'Unable to clone CRM role' }, { status: 500 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { updateCrmRoleProfile } from '@/features/foundation/role-management/server/service';
import {
APPROVAL_AUTHORITIES,
ALL_PERMISSIONS,
OWNERSHIP_SCOPES,
PERMISSIONS,
SCOPE_MODES,
type Permission
} from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const updateRoleSchema = z.object({
name: z.string().min(2),
description: z.string().nullable().optional(),
permissions: z
.array(z.string())
.refine(
(values) => values.every((value) => ALL_PERMISSIONS.includes(value as Permission)),
'Invalid permission key'
)
.transform((values) => values as Permission[]),
branchScopeMode: z.enum(SCOPE_MODES),
productScopeMode: z.enum(SCOPE_MODES),
ownershipScope: z.enum(OWNERSHIP_SCOPES),
approvalAuthority: z.enum(APPROVAL_AUTHORITIES),
isActive: z.boolean()
});
type RouteProps = {
params: Promise<{ code: string }>;
};
export async function PATCH(request: NextRequest, props: RouteProps) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleUpdate
});
const payload = updateRoleSchema.parse(await request.json());
const { code } = await props.params;
const role = await updateCrmRoleProfile(organization.id, session.user.id, code, payload);
return NextResponse.json({
success: true,
message: 'CRM role updated successfully',
role
});
} 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 });
}
return NextResponse.json({ message: 'Unable to update CRM role' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { listCrmRoleProfiles } from '@/features/foundation/role-management/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET() {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmRoleRead
});
const result = await listCrmRoleProfiles(organization.id, session.user.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'CRM roles loaded successfully',
...result
});
} 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 request' }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load CRM roles' }, { status: 500 });
}
}

View File

@@ -45,7 +45,12 @@ export default async function LeadDetailRoute({ params }: PageProps) {
organizationId: session.user.activeOrganizationId, organizationId: session.user.activeOrganizationId,
userId: session.user.id, userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user', membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support' businessRole: session.user.activeBusinessRole ?? 'sales_support',
branchScopeIds: session.user.activeBranchScopeIds ?? [],
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
branchScopeMode: 'all',
productScopeMode: 'all'
}) })
: null; : null;

View File

@@ -16,7 +16,7 @@ export default async function MasterOptionsRoute(props: PageProps) {
session?.user?.systemRole === 'super_admin' || session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId && (!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' || (session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.organizationManage))); session.user.activePermissions.includes(PERMISSIONS.crmMasterOptionRead)));
searchParamsCache.parse(searchParams); searchParamsCache.parse(searchParams);

View File

@@ -0,0 +1,39 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { RoleManagementPage } from '@/features/foundation/role-management/components/role-management-page';
import { crmRoleProfilesQueryOptions } from '@/features/foundation/role-management/api/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
export default async function CrmRolesRoute() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
session.user.activePermissions.includes(PERMISSIONS.crmRoleRead));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(crmRoleProfilesQueryOptions());
}
return (
<PageContainer
pageTitle='Roles & Permissions'
pageDescription='Configure CRM role profiles, permission matrices, ownership visibility, and approval authority.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM role management.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<RoleManagementPage />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -6,10 +6,9 @@ import { z } from 'zod';
import { memberships, organizations, users } from '@/db/schema'; import { memberships, organizations, users } from '@/db/schema';
import { import {
getDefaultBusinessRole, getDefaultBusinessRole,
getDefaultPermissions,
isBusinessRole,
isSystemRole isSystemRole
} from '@/lib/auth/rbac'; } from '@/lib/auth/rbac';
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
const signInSchema = z.object({ const signInSchema = z.object({
@@ -28,6 +27,9 @@ type SessionOrganization = {
slug: string; slug: string;
role: string; role: string;
businessRole: string; businessRole: string;
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
plan: string; plan: string;
imageUrl: string | null; imageUrl: string | null;
}; };
@@ -47,6 +49,8 @@ async function buildUserAccessContext(userId: string) {
role: memberships.role, role: memberships.role,
businessRole: memberships.businessRole, businessRole: memberships.businessRole,
permissions: memberships.permissions, permissions: memberships.permissions,
branchScopeIds: memberships.branchScopeIds,
productTypeScopeIds: memberships.productTypeScopeIds,
name: organizations.name, name: organizations.name,
slug: organizations.slug, slug: organizations.slug,
plan: organizations.plan, plan: organizations.plan,
@@ -67,18 +71,10 @@ async function buildUserAccessContext(userId: string) {
return { return {
organizationId: organization.id, organizationId: organization.id,
role: membership?.role ?? 'admin', role: membership?.role ?? 'admin',
businessRole: businessRole: membership?.businessRole ?? getDefaultBusinessRole('admin'),
membership?.businessRole && isBusinessRole(membership.businessRole) permissions: membership?.permissions ?? [],
? membership.businessRole branchScopeIds: membership?.branchScopeIds ?? [],
: getDefaultBusinessRole('admin'), productTypeScopeIds: membership?.productTypeScopeIds ?? [],
permissions:
membership?.permissions ??
getDefaultPermissions(
membership?.role === 'admin' ? 'admin' : 'user',
membership?.businessRole && isBusinessRole(membership.businessRole)
? membership.businessRole
: getDefaultBusinessRole('admin')
),
name: organization.name, name: organization.name,
slug: organization.slug, slug: organization.slug,
plan: organization.plan, plan: organization.plan,
@@ -97,20 +93,64 @@ async function buildUserAccessContext(userId: string) {
.where(eq(users.id, dbUser.id)); .where(eq(users.id, dbUser.id));
} }
const sessionOrganizations: SessionOrganization[] = rows.map((row) => ({ const sessionOrganizations: SessionOrganization[] = [];
for (const row of rows) {
const resolvedAccess = await resolveCrmMembershipAccess({
systemRole,
organizationId: row.organizationId,
membership: {
id: '',
userId: dbUser.id,
organizationId: row.organizationId,
role: row.role,
businessRole: row.businessRole,
permissions: row.permissions,
branchScopeIds: row.branchScopeIds ?? [],
productTypeScopeIds: row.productTypeScopeIds ?? [],
createdAt: new Date(),
updatedAt: new Date()
}
});
sessionOrganizations.push({
id: row.organizationId, id: row.organizationId,
name: row.name, name: row.name,
slug: row.slug, slug: row.slug,
role: row.role, role: row.role,
businessRole: row.businessRole, businessRole: row.businessRole,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope,
plan: row.plan, plan: row.plan,
imageUrl: row.imageUrl imageUrl: row.imageUrl
})); });
}
const activeMembershipAccess = activeMembership
? await resolveCrmMembershipAccess({
systemRole,
organizationId: activeMembership.organizationId,
membership: {
id: '',
userId: dbUser.id,
organizationId: activeMembership.organizationId,
role: activeMembership.role,
businessRole: activeMembership.businessRole,
permissions: activeMembership.permissions,
branchScopeIds: activeMembership.branchScopeIds ?? [],
productTypeScopeIds: activeMembership.productTypeScopeIds ?? [],
createdAt: new Date(),
updatedAt: new Date()
}
})
: null;
return { return {
dbUser, dbUser,
sessionOrganizations, sessionOrganizations,
activeMembership activeMembership,
activeMembershipAccess
}; };
} }
@@ -177,7 +217,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return token; return token;
} }
const { dbUser, sessionOrganizations, activeMembership } = accessContext; const { dbUser, sessionOrganizations, activeMembership, activeMembershipAccess } = accessContext;
const systemRole = isSystemRole(dbUser.systemRole) ? dbUser.systemRole : 'user'; const systemRole = isSystemRole(dbUser.systemRole) ? dbUser.systemRole : 'user';
token.name = dbUser.name; token.name = dbUser.name;
@@ -189,7 +229,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.activeOrganizationPlan = activeMembership?.plan ?? null; token.activeOrganizationPlan = activeMembership?.plan ?? null;
token.activeMembershipRole = activeMembership?.role ?? null; token.activeMembershipRole = activeMembership?.role ?? null;
token.activeBusinessRole = activeMembership?.businessRole ?? null; token.activeBusinessRole = activeMembership?.businessRole ?? null;
token.activePermissions = activeMembership?.permissions ?? []; token.activePermissions = activeMembershipAccess?.permissions ?? [];
token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? [];
token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? [];
token.activeOwnershipScope = activeMembershipAccess?.ownershipScope ?? null;
token.organizations = sessionOrganizations; token.organizations = sessionOrganizations;
return token; return token;
@@ -217,6 +260,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.activePermissions = Array.isArray(token.activePermissions) session.user.activePermissions = Array.isArray(token.activePermissions)
? token.activePermissions.filter((value): value is string => typeof value === 'string') ? token.activePermissions.filter((value): value is string => typeof value === 'string')
: []; : [];
session.user.activeBranchScopeIds = Array.isArray(token.activeBranchScopeIds)
? token.activeBranchScopeIds.filter((value): value is string => typeof value === 'string')
: [];
session.user.activeProductTypeScopeIds = Array.isArray(token.activeProductTypeScopeIds)
? token.activeProductTypeScopeIds.filter((value): value is string => typeof value === 'string')
: [];
session.user.activeOwnershipScope =
typeof token.activeOwnershipScope === 'string' ? token.activeOwnershipScope : null;
session.user.organizations = Array.isArray(token.organizations) session.user.organizations = Array.isArray(token.organizations)
? token.organizations.filter( ? token.organizations.filter(
(value): value is SessionOrganization => (value): value is SessionOrganization =>
@@ -227,6 +278,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
typeof value.slug === 'string' && typeof value.slug === 'string' &&
typeof value.role === 'string' && typeof value.role === 'string' &&
typeof value.businessRole === 'string' && typeof value.businessRole === 'string' &&
Array.isArray(value.branchScopeIds) &&
Array.isArray(value.productTypeScopeIds) &&
typeof value.ownershipScope === 'string' &&
typeof value.plan === 'string' typeof value.plan === 'string'
) )
: []; : [];

View File

@@ -59,7 +59,7 @@ export const navGroups: NavGroup[] = [
icon: "teams", icon: "teams",
isActive: false, isActive: false,
items: [], items: [],
access: { requireOrg: true, role: "admin" }, access: { requireOrg: true, permission: "crm.quotation.read" },
}, },
{ {
title: "Users", title: "Users",
@@ -136,12 +136,17 @@ export const navGroups: NavGroup[] = [
url: "/dashboard/crm/settings/master-options", url: "/dashboard/crm/settings/master-options",
icon: "settings", icon: "settings",
isActive: false, isActive: false,
access: { requireOrg: true, permission: "crm.document_template.read" }, access: { requireOrg: true, permission: "crm.role.read" },
items: [ items: [
{
title: "Roles & Permissions",
url: "/dashboard/crm/settings/roles",
access: { requireOrg: true, permission: "crm.role.read" },
},
{ {
title: "Master Options", title: "Master Options",
url: "/dashboard/crm/settings/master-options", url: "/dashboard/crm/settings/master-options",
access: { requireOrg: true, permission: "organization:manage" }, access: { requireOrg: true, permission: "crm.master_option.read" },
}, },
{ {
title: "Document Sequences", title: "Document Sequences",

View File

@@ -51,10 +51,41 @@ export const memberships = pgTable('memberships', {
role: text('role').default('user').notNull(), role: text('role').default('user').notNull(),
businessRole: text('business_role').default('sales_support').notNull(), businessRole: text('business_role').default('sales_support').notNull(),
permissions: text('permissions').array().default([]).notNull(), permissions: text('permissions').array().default([]).notNull(),
branchScopeIds: text('branch_scope_ids').array().default([]).notNull(),
productTypeScopeIds: text('product_type_scope_ids').array().default([]).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
}); });
export const crmRoleProfiles = pgTable(
'crm_role_profiles',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
code: text('code').notNull(),
name: text('name').notNull(),
description: text('description'),
permissions: text('permissions').array().default([]).notNull(),
branchScopeMode: text('branch_scope_mode').default('assigned').notNull(),
productScopeMode: text('product_scope_mode').default('assigned').notNull(),
ownershipScope: text('ownership_scope').default('own').notNull(),
approvalAuthority: text('approval_authority').default('none').notNull(),
isSystem: boolean('is_system').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
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()
},
(table) => ({
organizationRoleCodeIdx: uniqueIndex('crm_role_profiles_org_code_idx').on(
table.organizationId,
table.code
)
})
);
export const products = pgTable('products', { export const products = pgTable('products', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(), id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
organizationId: text('organization_id').notNull(), organizationId: text('organization_id').notNull(),

View File

@@ -22,6 +22,7 @@ import {
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { PERMISSIONS } from '@/lib/auth/rbac'; import { PERMISSIONS } from '@/lib/auth/rbac';
import { getUserBranches } from '@/features/foundation/branch-scope/service'; import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listApprovalRequests } from '@/features/foundation/approval/server/service'; import { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { import {
@@ -57,6 +58,11 @@ type DashboardContext = {
membershipRole: string; membershipRole: string;
businessRole: string; businessRole: string;
permissions: string[]; permissions: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
}; };
type NormalizedFilters = { type NormalizedFilters = {
@@ -73,15 +79,6 @@ type StatusMaps = {
quotationStatusById: Map<string, string>; quotationStatusById: Map<string, string>;
}; };
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters { function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date(); const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1)); const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
@@ -128,20 +125,26 @@ function canViewApprovalData(context: DashboardContext) {
} }
function hasDashboardFullScope(context: DashboardContext) { function hasDashboardFullScope(context: DashboardContext) {
return ( return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
);
} }
function canAccessDashboardEnquiry( function canAccessDashboardEnquiry(
row: typeof crmEnquiries.$inferSelect, row: typeof crmEnquiries.$inferSelect,
context: DashboardContext context: DashboardContext
) { ) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (hasDashboardFullScope(context)) { if (hasDashboardFullScope(context)) {
return true; return true;
} }
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) { if (context.ownershipScope === 'own') {
return row.createdBy === context.userId || row.assignedToUserId === context.userId; return row.createdBy === context.userId || row.assignedToUserId === context.userId;
} }
@@ -152,11 +155,19 @@ function canAccessDashboardQuotation(
row: typeof crmQuotations.$inferSelect, row: typeof crmQuotations.$inferSelect,
context: DashboardContext context: DashboardContext
) { ) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.quotationType)) {
return false;
}
if (hasDashboardFullScope(context)) { if (hasDashboardFullScope(context)) {
return true; return true;
} }
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) { if (context.ownershipScope === 'own') {
return row.salesmanId === context.userId; return row.salesmanId === context.userId;
} }

View File

@@ -11,6 +11,7 @@ import {
} from '@/db/schema'; } from '@/db/schema';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session'; import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
@@ -50,13 +51,6 @@ const ENQUIRY_OPTION_CATEGORIES = {
} as const; } as const;
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']); const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const SALES_CREATED_ENQUIRY_ROLES = new Set([ const SALES_CREATED_ENQUIRY_ROLES = new Set([
'sales', 'sales',
'sales_support', 'sales_support',
@@ -73,6 +67,11 @@ export interface EnquiryAccessContext {
userId: string; userId: string;
membershipRole: string; membershipRole: string;
businessRole: string; businessRole: string;
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
} }
function mapOption( function mapOption(
@@ -141,20 +140,26 @@ function mapEnquiryRecord(
} }
function hasEnquiryFullAccess(context: EnquiryAccessContext) { function hasEnquiryFullAccess(context: EnquiryAccessContext) {
return ( return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
);
} }
function canAccessEnquiryRow( function canAccessEnquiryRow(
row: typeof crmEnquiries.$inferSelect, row: typeof crmEnquiries.$inferSelect,
context: EnquiryAccessContext context: EnquiryAccessContext
) { ) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
}
if (!hasProductScopeAccess(context, row.productType)) {
return false;
}
if (hasEnquiryFullAccess(context)) { if (hasEnquiryFullAccess(context)) {
return true; return true;
} }
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) { if (context.ownershipScope === 'own') {
return row.createdBy === context.userId || row.assignedToUserId === context.userId; return row.createdBy === context.userId || row.assignedToUserId === context.userId;
} }
@@ -162,17 +167,23 @@ function canAccessEnquiryRow(
} }
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] { function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
if (hasEnquiryFullAccess(context)) { const filters: SQL[] = [];
return [];
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds));
} }
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) { if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) {
return [ filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds));
}
if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') {
filters.push(
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))! or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
]; );
} }
return []; return filters;
} }
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord { function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
@@ -437,7 +448,11 @@ async function assertFollowupBelongsToEnquiry(
return followup; return followup;
} }
async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) { async function validateEnquiryPayload(
organizationId: string,
payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId); await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (payload.contactId) { if (payload.contactId) {
@@ -465,6 +480,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
await validateBranchAccess(payload.branchId); await validateBranchAccess(payload.branchId);
} }
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
throw new AuthError('Forbidden branch scope', 403);
}
if (accessContext && !hasProductScopeAccess(accessContext, payload.productType)) {
throw new AuthError('Forbidden product scope', 403);
}
for (const projectParty of payload.projectParties ?? []) { for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId); await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue( await assertMasterOptionValue(
@@ -996,13 +1019,13 @@ export async function getEnquiryActivity(
export async function createEnquiry( export async function createEnquiry(
organizationId: string, organizationId: string,
userId: string, userId: string,
businessRole: string, accessContext: EnquiryAccessContext,
payload: EnquiryMutationPayload payload: EnquiryMutationPayload
) { ) {
await validateEnquiryPayload(organizationId, payload); await validateEnquiryPayload(organizationId, payload, accessContext);
const pipelineStage = await resolvePipelineStageForCreate( const pipelineStage = await resolvePipelineStageForCreate(
organizationId, organizationId,
businessRole, accessContext.businessRole,
payload.status payload.status
); );
@@ -1064,7 +1087,7 @@ export async function updateEnquiry(
payload: EnquiryMutationPayload, payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext accessContext?: EnquiryAccessContext
) { ) {
await validateEnquiryPayload(organizationId, payload); await validateEnquiryPayload(organizationId, payload, accessContext);
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload); const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
@@ -1167,9 +1190,10 @@ async function updateEnquiryAssignment(
organizationId: string, organizationId: string,
userId: string, userId: string,
payload: EnquiryAssignmentMutationPayload, payload: EnquiryAssignmentMutationPayload,
mode: 'assign' | 'reassign' mode: 'assign' | 'reassign',
accessContext?: EnquiryAccessContext
) { ) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId); const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
if (mode === 'assign' && enquiry.assignedToUserId) { if (mode === 'assign' && enquiry.assignedToUserId) {
throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400); throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
@@ -1202,18 +1226,20 @@ export async function assignEnquiryToUser(
id: string, id: string,
organizationId: string, organizationId: string,
userId: string, userId: string,
payload: EnquiryAssignmentMutationPayload payload: EnquiryAssignmentMutationPayload,
accessContext?: EnquiryAccessContext
) { ) {
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign'); return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext);
} }
export async function reassignEnquiryToUser( export async function reassignEnquiryToUser(
id: string, id: string,
organizationId: string, organizationId: string,
userId: string, userId: string,
payload: EnquiryAssignmentMutationPayload payload: EnquiryAssignmentMutationPayload,
accessContext?: EnquiryAccessContext
) { ) {
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign'); return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
} }
export async function listEnquiryFollowups( export async function listEnquiryFollowups(

View File

@@ -0,0 +1,30 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { cloneCrmRoleProfile, updateCrmRoleProfile } from './service';
import { crmRoleQueryKeys } from './queries';
export const updateCrmRoleProfileMutation = mutationOptions({
mutationFn: ({
code,
payload
}: {
code: string;
payload: Parameters<typeof updateCrmRoleProfile>[1];
}) => updateCrmRoleProfile(code, payload),
onSettled: async () => {
await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all });
}
});
export const cloneCrmRoleProfileMutation = mutationOptions({
mutationFn: ({
sourceCode,
payload
}: {
sourceCode: string;
payload: Parameters<typeof cloneCrmRoleProfile>[1];
}) => cloneCrmRoleProfile(sourceCode, payload),
onSettled: async () => {
await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all });
}
});

View File

@@ -0,0 +1,13 @@
import { queryOptions } from '@tanstack/react-query';
import { getCrmRoleProfiles } from './service';
export const crmRoleQueryKeys = {
all: ['crm-role-profiles'] as const
};
export function crmRoleProfilesQueryOptions() {
return queryOptions({
queryKey: crmRoleQueryKeys.all,
queryFn: getCrmRoleProfiles
});
}

View File

@@ -0,0 +1,28 @@
import { apiClient } from '@/lib/api-client';
import type {
CloneCrmRoleProfilePayload,
CrmRoleProfileRecord,
CrmRoleProfilesResponse,
UpdateCrmRoleProfilePayload
} from './types';
export async function getCrmRoleProfiles() {
return apiClient<CrmRoleProfilesResponse>('/crm/settings/roles');
}
export async function updateCrmRoleProfile(code: string, payload: UpdateCrmRoleProfilePayload) {
return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(`/crm/settings/roles/${code}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
}
export async function cloneCrmRoleProfile(sourceCode: string, payload: CloneCrmRoleProfilePayload) {
return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(
`/crm/settings/roles/${sourceCode}/clone`,
{
method: 'POST',
body: JSON.stringify(payload)
}
);
}

View File

@@ -0,0 +1,57 @@
import type {
CrmApprovalAuthority,
CrmOwnershipScope,
CrmScopeMode,
Permission
} from '@/lib/auth/rbac';
export interface CrmRoleProfileRecord {
id: string;
organizationId: string;
code: string;
name: string;
description: string | null;
permissions: Permission[];
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
ownershipScope: CrmOwnershipScope;
approvalAuthority: CrmApprovalAuthority;
isSystem: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PermissionGroupDto {
key: string;
label: string;
permissions: Array<{
key: Permission;
label: string;
}>;
}
export interface CrmRoleProfilesResponse {
success: boolean;
time: string;
message: string;
roles: CrmRoleProfileRecord[];
permissionGroups: PermissionGroupDto[];
}
export interface UpdateCrmRoleProfilePayload {
name: string;
description?: string | null;
permissions: Permission[];
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
ownershipScope: CrmOwnershipScope;
approvalAuthority: CrmApprovalAuthority;
isActive: boolean;
}
export interface CloneCrmRoleProfilePayload {
code: string;
name: string;
description?: string | null;
}

View File

@@ -0,0 +1,391 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
APPROVAL_AUTHORITIES,
OWNERSHIP_SCOPES,
SCOPE_MODES,
type Permission
} from '@/lib/auth/rbac';
import { cloneCrmRoleProfileMutation, updateCrmRoleProfileMutation } from '../api/mutations';
import { crmRoleProfilesQueryOptions } from '../api/queries';
type RoleEditorState = {
name: string;
description: string;
permissions: Permission[];
branchScopeMode: (typeof SCOPE_MODES)[number];
productScopeMode: (typeof SCOPE_MODES)[number];
ownershipScope: (typeof OWNERSHIP_SCOPES)[number];
approvalAuthority: (typeof APPROVAL_AUTHORITIES)[number];
isActive: boolean;
};
export function RoleManagementPage() {
const { data } = useSuspenseQuery(crmRoleProfilesQueryOptions());
const [selectedCode, setSelectedCode] = useState<string | null>(data.roles[0]?.code ?? null);
const [cloneCode, setCloneCode] = useState('');
const [cloneName, setCloneName] = useState('');
const [state, setState] = useState<RoleEditorState | null>(null);
const selectedRole = useMemo(
() => data.roles.find((role) => role.code === selectedCode) ?? null,
[data.roles, selectedCode]
);
useEffect(() => {
if (!selectedRole) {
setState(null);
return;
}
setState({
name: selectedRole.name,
description: selectedRole.description ?? '',
permissions: selectedRole.permissions,
branchScopeMode: selectedRole.branchScopeMode,
productScopeMode: selectedRole.productScopeMode,
ownershipScope: selectedRole.ownershipScope,
approvalAuthority: selectedRole.approvalAuthority,
isActive: selectedRole.isActive
});
}, [selectedRole]);
const updateMutation = useMutation({
...updateCrmRoleProfileMutation,
onSuccess: () => toast.success('CRM role updated successfully'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to update CRM role')
});
const cloneMutation = useMutation({
...cloneCrmRoleProfileMutation,
onSuccess: (result) => {
toast.success('CRM role cloned successfully');
setSelectedCode(result.role.code);
setCloneCode('');
setCloneName('');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to clone CRM role')
});
if (!selectedRole || !state) {
return (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
No CRM roles available.
</div>
);
}
const currentRole = selectedRole;
const editorState = state;
function togglePermission(permission: Permission, checked: boolean) {
setState((current) =>
current
? {
...current,
permissions: checked
? [...new Set([...current.permissions, permission])]
: current.permissions.filter((item) => item !== permission)
}
: current
);
}
async function handleSave() {
await updateMutation.mutateAsync({
code: currentRole.code,
payload: {
...editorState,
description: editorState.description || null
}
});
}
async function handleClone() {
if (!cloneCode.trim() || !cloneName.trim()) {
toast.error('Clone code and clone name are required');
return;
}
await cloneMutation.mutateAsync({
sourceCode: currentRole.code,
payload: {
code: cloneCode,
name: cloneName,
description: editorState.description || null
}
});
}
return (
<div className='grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]'>
<Card>
<CardHeader>
<CardTitle>Roles</CardTitle>
<CardDescription>System and cloned CRM role profiles for this organization.</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{data.roles.map((role) => (
<button
key={role.code}
type='button'
onClick={() => setSelectedCode(role.code)}
className={`w-full rounded-lg border p-3 text-left transition ${
role.code === selectedCode ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
}`}
>
<div className='flex items-center justify-between gap-2'>
<div className='font-medium'>{role.name}</div>
<div className='flex gap-2'>
{role.isSystem ? <Badge variant='secondary'>System</Badge> : null}
{!role.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
</div>
</div>
<div className='text-muted-foreground mt-1 text-xs'>{role.code}</div>
<div className='text-muted-foreground mt-2 line-clamp-2 text-sm'>
{role.description || 'No description'}
</div>
</button>
))}
</CardContent>
</Card>
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>{currentRole.name}</CardTitle>
<CardDescription>
Configure permissions, ownership visibility, branch scope mode, and approval authority.
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='role-name'>Role Name</Label>
<Input
id='role-name'
value={editorState.name}
onChange={(event) => setState({ ...state, name: event.target.value })}
/>
</div>
<div className='space-y-2'>
<Label>Role Code</Label>
<Input value={currentRole.code} readOnly />
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='role-description'>Description</Label>
<Textarea
id='role-description'
value={editorState.description}
onChange={(event) => setState({ ...state, description: event.target.value })}
/>
</div>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<div className='space-y-2'>
<Label>Ownership Scope</Label>
<Select
value={editorState.ownershipScope}
onValueChange={(value) =>
setState({ ...state, ownershipScope: value as RoleEditorState['ownershipScope'] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OWNERSHIP_SCOPES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Branch Scope</Label>
<Select
value={editorState.branchScopeMode}
onValueChange={(value) =>
setState({ ...state, branchScopeMode: value as RoleEditorState['branchScopeMode'] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_MODES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Product Scope</Label>
<Select
value={editorState.productScopeMode}
onValueChange={(value) =>
setState({
...state,
productScopeMode: value as RoleEditorState['productScopeMode']
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_MODES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label>Approval Authority</Label>
<Select
value={editorState.approvalAuthority}
onValueChange={(value) =>
setState({
...state,
approvalAuthority: value as RoleEditorState['approvalAuthority']
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPROVAL_AUTHORITIES.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex items-center gap-3'>
<Checkbox
checked={editorState.isActive}
onCheckedChange={(checked) => setState({ ...state, isActive: checked === true })}
/>
<div>
<div className='font-medium'>Role active</div>
<div className='text-muted-foreground text-sm'>
Inactive roles remain historical but should not be used for new assignments.
</div>
</div>
</div>
<div className='space-y-4'>
<div>
<div className='font-medium'>Permission Matrix</div>
<div className='text-muted-foreground text-sm'>
Effective permissions are role permissions plus membership-level overrides.
</div>
</div>
<div className='grid gap-4 xl:grid-cols-2'>
{data.permissionGroups.map((group) => (
<Card key={group.key}>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>{group.label}</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{group.permissions.map((permission) => {
const checked = editorState.permissions.includes(permission.key);
return (
<label
key={permission.key}
className='flex cursor-pointer items-start gap-3 rounded-md border p-3'
>
<Checkbox
checked={checked}
onCheckedChange={(value) =>
togglePermission(permission.key, value === true)
}
/>
<div className='space-y-1'>
<div className='font-medium'>{permission.label}</div>
<div className='text-muted-foreground text-xs'>{permission.key}</div>
</div>
</label>
);
})}
</CardContent>
</Card>
))}
</div>
</div>
<div className='flex flex-wrap gap-3'>
<Button onClick={() => void handleSave()} isLoading={updateMutation.isPending}>
Save Role
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Clone Role</CardTitle>
<CardDescription>
Create a custom role profile by copying the current role and then tailoring its permissions.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-[1fr_1fr_auto]'>
<div className='space-y-2'>
<Label htmlFor='clone-code'>New Role Code</Label>
<Input
id='clone-code'
value={cloneCode}
onChange={(event) => setCloneCode(event.target.value)}
placeholder='sales_crane_manager'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='clone-name'>New Role Name</Label>
<Input
id='clone-name'
value={cloneName}
onChange={(event) => setCloneName(event.target.value)}
placeholder='Sales Crane Manager'
/>
</div>
<div className='flex items-end'>
<Button onClick={() => void handleClone()} isLoading={cloneMutation.isPending}>
Clone
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import { crmRoleProfiles } from '@/db/schema';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
CRM_PERMISSION_GROUPS,
getDefaultRoleProfiles,
isApprovalAuthority,
isOwnershipScope,
isScopeMode,
type Permission
} from '@/lib/auth/rbac';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import type {
CloneCrmRoleProfilePayload,
CrmRoleProfileRecord,
UpdateCrmRoleProfilePayload
} from '../api/types';
function mapRole(row: typeof crmRoleProfiles.$inferSelect): CrmRoleProfileRecord {
return {
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name,
description: row.description,
permissions: row.permissions as Permission[],
branchScopeMode: isScopeMode(row.branchScopeMode) ? row.branchScopeMode : 'assigned',
productScopeMode: isScopeMode(row.productScopeMode) ? row.productScopeMode : 'assigned',
ownershipScope: isOwnershipScope(row.ownershipScope) ? row.ownershipScope : 'own',
approvalAuthority: isApprovalAuthority(row.approvalAuthority)
? row.approvalAuthority
: 'none',
isSystem: row.isSystem,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
function sanitizeCode(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export async function ensureCrmRoleProfiles(
organizationId: string,
userId: string
) {
const existingRows = await db
.select({ code: crmRoleProfiles.code })
.from(crmRoleProfiles)
.where(
and(
eq(crmRoleProfiles.organizationId, organizationId),
isNull(crmRoleProfiles.deletedAt)
)
);
const existingCodes = new Set(existingRows.map((row) => row.code));
const missingProfiles = getDefaultRoleProfiles().filter(
(profile) => !existingCodes.has(profile.code)
);
if (!missingProfiles.length) {
return;
}
await db.insert(crmRoleProfiles).values(
missingProfiles.map((profile) => ({
id: crypto.randomUUID(),
organizationId,
code: profile.code,
name: profile.name,
description: profile.description,
permissions: profile.permissions,
branchScopeMode: profile.branchScopeMode,
productScopeMode: profile.productScopeMode,
ownershipScope: profile.ownershipScope,
approvalAuthority: profile.approvalAuthority,
isSystem: profile.isSystem,
isActive: true,
createdBy: userId,
updatedBy: userId
}))
);
}
export async function listCrmRoleProfiles(
organizationId: string,
userId: string
) {
await ensureCrmRoleProfiles(organizationId, userId);
const rows = await db
.select()
.from(crmRoleProfiles)
.where(
and(
eq(crmRoleProfiles.organizationId, organizationId),
isNull(crmRoleProfiles.deletedAt)
)
)
.orderBy(asc(crmRoleProfiles.isSystem), asc(crmRoleProfiles.name));
return {
roles: rows.map(mapRole),
permissionGroups: CRM_PERMISSION_GROUPS
};
}
export async function getCrmRoleProfileByCode(
organizationId: string,
userId: string,
code: string
) {
await ensureCrmRoleProfiles(organizationId, userId);
const row = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, code),
isNull(crmRoleProfiles.deletedAt)
)
});
if (!row) {
throw new AuthError('CRM role not found', 404);
}
return mapRole(row);
}
export async function updateCrmRoleProfile(
organizationId: string,
userId: string,
code: string,
payload: UpdateCrmRoleProfilePayload
) {
const current = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, code),
isNull(crmRoleProfiles.deletedAt)
)
});
if (!current) {
throw new AuthError('CRM role not found', 404);
}
const [updated] = await db
.update(crmRoleProfiles)
.set({
name: payload.name.trim(),
description: payload.description?.trim() || null,
permissions: payload.permissions,
branchScopeMode: payload.branchScopeMode,
productScopeMode: payload.productScopeMode,
ownershipScope: payload.ownershipScope,
approvalAuthority: payload.approvalAuthority,
isActive: payload.isActive,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmRoleProfiles.id, current.id))
.returning();
await auditAction({
organizationId,
userId,
entityType: 'crm_role',
entityId: updated.id,
action: 'update',
beforeData: mapRole(current),
afterData: mapRole(updated)
});
return mapRole(updated);
}
export async function cloneCrmRoleProfile(
organizationId: string,
userId: string,
sourceCode: string,
payload: CloneCrmRoleProfilePayload
) {
const source = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, sourceCode),
isNull(crmRoleProfiles.deletedAt)
)
});
if (!source) {
throw new AuthError('Source CRM role not found', 404);
}
const targetCode = sanitizeCode(payload.code);
if (!targetCode) {
throw new AuthError('Role code is required', 400);
}
const duplicate = await db.query.crmRoleProfiles.findFirst({
where: and(
eq(crmRoleProfiles.organizationId, organizationId),
eq(crmRoleProfiles.code, targetCode),
isNull(crmRoleProfiles.deletedAt)
)
});
if (duplicate) {
throw new AuthError('Role code already exists', 409);
}
const [created] = await db
.insert(crmRoleProfiles)
.values({
id: crypto.randomUUID(),
organizationId,
code: targetCode,
name: payload.name.trim(),
description: payload.description?.trim() || source.description,
permissions: source.permissions,
branchScopeMode: source.branchScopeMode,
productScopeMode: source.productScopeMode,
ownershipScope: source.ownershipScope,
approvalAuthority: source.approvalAuthority,
isSystem: false,
isActive: true,
createdBy: userId,
updatedBy: userId
})
.returning();
await auditAction({
organizationId,
userId,
entityType: 'crm_role',
entityId: created.id,
action: 'create',
afterData: {
sourceCode,
role: mapRole(created)
}
});
return mapRole(created);
}

View File

@@ -52,7 +52,8 @@ const businessRoleOptions = [
{ value: 'sales', label: 'Sales' }, { value: 'sales', label: 'Sales' },
{ value: 'sales_support', label: 'Sales Support' }, { value: 'sales_support', label: 'Sales Support' },
{ value: 'department_manager', label: 'Department Manager' }, { value: 'department_manager', label: 'Department Manager' },
{ value: 'top_manager', label: 'Top Manager' } { value: 'top_manager', label: 'Top Manager' },
{ value: 'crm_admin', label: 'CRM Admin' }
] as const; ] as const;
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) { export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {

View File

@@ -1,6 +1,4 @@
import * as z from 'zod'; import * as z from 'zod';
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
export const userSchema = z.object({ export const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'), name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'), email: z.string().email('Please enter a valid email'),
@@ -11,7 +9,7 @@ export const userSchema = z.object({
z.object({ z.object({
organizationId: z.string().min(1), organizationId: z.string().min(1),
membershipRole: z.enum(['admin', 'user']), membershipRole: z.enum(['admin', 'user']),
businessRole: z.enum(BUSINESS_ROLES) businessRole: z.string().min(1)
}) })
) )
.min(1, 'Select at least one organization') .min(1, 'Select at least one organization')

151
src/lib/auth/crm-access.ts Normal file
View File

@@ -0,0 +1,151 @@
import { and, eq, isNull } from 'drizzle-orm';
import { crmRoleProfiles, memberships } from '@/db/schema';
import { db } from '@/lib/db';
import {
ALL_PERMISSIONS,
type CrmApprovalAuthority,
type CrmOwnershipScope,
type CrmScopeMode,
getDefaultBusinessRole,
getRoleProfileDefinition,
isApprovalAuthority,
isMembershipRole,
isOwnershipScope,
isScopeMode,
resolveEffectivePermissions,
type MembershipRole,
type Permission,
type SystemRole
} from './rbac';
export interface ResolvedCrmAccess {
membershipRole: MembershipRole;
businessRole: string;
permissions: Permission[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: CrmOwnershipScope;
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
approvalAuthority: CrmApprovalAuthority;
roleProfileName: string | null;
}
type BranchScopedAccess = Pick<
ResolvedCrmAccess,
'branchScopeMode' | 'branchScopeIds'
> | {
branchScopeMode: string;
branchScopeIds: string[];
};
type ProductScopedAccess = Pick<
ResolvedCrmAccess,
'productScopeMode' | 'productTypeScopeIds'
> | {
productScopeMode: string;
productTypeScopeIds: string[];
};
function normalizeStringArray(value: unknown) {
return Array.isArray(value)
? [...new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0))]
: [];
}
export async function resolveCrmMembershipAccess(input: {
systemRole: SystemRole;
organizationId: string;
membership: typeof memberships.$inferSelect;
}): Promise<ResolvedCrmAccess> {
if (input.systemRole === 'super_admin') {
return {
membershipRole: 'admin',
businessRole: input.membership.businessRole,
permissions: ALL_PERMISSIONS,
branchScopeIds: [],
productTypeScopeIds: [],
ownershipScope: 'organization',
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'final',
roleProfileName: 'System Admin'
};
}
const membershipRole = isMembershipRole(input.membership.role)
? input.membership.role
: 'user';
const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole);
const [roleProfile] = await db
.select()
.from(crmRoleProfiles)
.where(
and(
eq(crmRoleProfiles.organizationId, input.organizationId),
eq(crmRoleProfiles.code, businessRole),
eq(crmRoleProfiles.isActive, true),
isNull(crmRoleProfiles.deletedAt)
)
)
.limit(1);
const defaultDefinition = getRoleProfileDefinition(businessRole);
const ownershipScope: CrmOwnershipScope = isOwnershipScope(roleProfile?.ownershipScope ?? '')
? (roleProfile.ownershipScope as CrmOwnershipScope)
: (defaultDefinition?.ownershipScope ?? 'own');
const branchScopeMode: CrmScopeMode = isScopeMode(roleProfile?.branchScopeMode ?? '')
? (roleProfile.branchScopeMode as CrmScopeMode)
: (defaultDefinition?.branchScopeMode ?? 'assigned');
const productScopeMode: CrmScopeMode = isScopeMode(roleProfile?.productScopeMode ?? '')
? (roleProfile.productScopeMode as CrmScopeMode)
: (defaultDefinition?.productScopeMode ?? 'assigned');
const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority(
roleProfile?.approvalAuthority ?? ''
)
? (roleProfile.approvalAuthority as CrmApprovalAuthority)
: (defaultDefinition?.approvalAuthority ?? 'none');
return {
membershipRole,
businessRole,
permissions: resolveEffectivePermissions({
systemRole: input.systemRole,
membershipRole,
businessRole,
rolePermissions: roleProfile?.permissions ?? defaultDefinition?.permissions ?? [],
directPermissions: input.membership.permissions
}),
branchScopeIds: normalizeStringArray(input.membership.branchScopeIds),
productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds),
ownershipScope,
branchScopeMode,
productScopeMode,
approvalAuthority,
roleProfileName: roleProfile?.name ?? defaultDefinition?.name ?? null
};
}
export function hasBranchScopeAccess(access: BranchScopedAccess, branchId?: string | null) {
if (!branchId) {
return true;
}
if (access.branchScopeMode === 'all' || access.branchScopeIds.length === 0) {
return true;
}
return access.branchScopeIds.includes(branchId);
}
export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId?: string | null) {
if (!productTypeId) {
return true;
}
if (access.productScopeMode === 'all' || access.productTypeScopeIds.length === 0) {
return true;
}
return access.productTypeScopeIds.includes(productTypeId);
}

View File

@@ -6,12 +6,19 @@ export const BUSINESS_ROLES = [
'sales_support', 'sales_support',
'sales_manager', 'sales_manager',
'department_manager', 'department_manager',
'top_manager' 'top_manager',
'crm_admin'
] as const; ] as const;
export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] as const;
export const SCOPE_MODES = ['all', 'assigned'] as const;
export const APPROVAL_AUTHORITIES = ['none', 'manager', 'department', 'final'] as const;
export type SystemRole = (typeof SYSTEM_ROLES)[number]; export type SystemRole = (typeof SYSTEM_ROLES)[number];
export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number]; export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number];
export type BusinessRole = (typeof BUSINESS_ROLES)[number]; export type BusinessRole = string;
export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[number];
export type CrmScopeMode = (typeof SCOPE_MODES)[number];
export type CrmApprovalAuthority = (typeof APPROVAL_AUTHORITIES)[number];
export const PERMISSIONS = { export const PERMISSIONS = {
productsRead: 'products:read', productsRead: 'products:read',
@@ -52,6 +59,7 @@ export const PERMISSIONS = {
crmQuotationFollowupManage: 'crm.quotation.followup.manage', crmQuotationFollowupManage: 'crm.quotation.followup.manage',
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage', crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
crmQuotationRevisionCreate: 'crm.quotation.revision.create', crmQuotationRevisionCreate: 'crm.quotation.revision.create',
crmQuotationPricingRead: 'crm.quotation.pricing.read',
crmApprovalRead: 'crm.approval.read', crmApprovalRead: 'crm.approval.read',
crmApprovalSubmit: 'crm.approval.submit', crmApprovalSubmit: 'crm.approval.submit',
crmApprovalApprove: 'crm.approval.approve', crmApprovalApprove: 'crm.approval.approve',
@@ -77,28 +85,68 @@ export const PERMISSIONS = {
crmQuotationDocumentPreview: 'crm.quotation.document.preview', crmQuotationDocumentPreview: 'crm.quotation.document.preview',
crmQuotationPdfPreview: 'crm.quotation.pdf.preview', crmQuotationPdfPreview: 'crm.quotation.pdf.preview',
crmQuotationPdfDownload: 'crm.quotation.pdf.download', crmQuotationPdfDownload: 'crm.quotation.pdf.download',
crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved' crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved',
crmRoleRead: 'crm.role.read',
crmRoleCreate: 'crm.role.create',
crmRoleUpdate: 'crm.role.update',
crmRoleDelete: 'crm.role.delete',
crmRoleAssign: 'crm.role.assign',
crmMasterOptionRead: 'crm.master_option.read',
crmMasterOptionCreate: 'crm.master_option.create',
crmMasterOptionUpdate: 'crm.master_option.update',
crmMasterOptionDelete: 'crm.master_option.delete'
} as const; } as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
const MANAGER_PERMISSIONS: Permission[] = [ export interface CrmRoleProfileDefinition {
code: string;
name: string;
description: string;
permissions: Permission[];
ownershipScope: CrmOwnershipScope;
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
approvalAuthority: CrmApprovalAuthority;
isSystem: boolean;
}
export interface PermissionGroup {
key: string;
label: string;
permissions: Array<{ key: Permission; label: string }>;
}
const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'code'>> = {
marketing: {
name: 'Marketing',
description: 'Lead creation, assignment, monitoring, and enquiry visibility without commercial authority.',
permissions: [
PERMISSIONS.crmLeadRead, PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate, PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate, PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign, PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead, PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead, PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmContactUpdate, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmDashboardRead
],
ownershipScope: 'monitor',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'none',
isSystem: true
},
sales: {
name: 'Sales',
description: 'Own enquiries, quotations, and follow-ups within assigned branch and product scope.',
permissions: [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate, PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate, PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate, PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -111,72 +159,188 @@ const MANAGER_PERMISSIONS: Permission[] = [
PERMISSIONS.crmQuotationFollowupManage, PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage, PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate, PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead, PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead, PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload, PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload PERMISSIONS.crmQuotationPdfDownload
]; ],
ownershipScope: 'own',
function getMembershipBasePermissions(role: MembershipRole): Permission[] { branchScopeMode: 'assigned',
if (role === 'admin') { productScopeMode: 'assigned',
return [ approvalAuthority: 'none',
PERMISSIONS.productsRead, isSystem: true
PERMISSIONS.productsWrite, },
PERMISSIONS.organizationManage, sales_support: {
PERMISSIONS.usersManage, name: 'Sales Support',
PERMISSIONS.crmLeadRead, description: 'Quotation drafting and operational support without approval authority.',
PERMISSIONS.crmLeadCreate, permissions: [
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead, PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmCustomerDelete,
PERMISSIONS.crmContactRead, PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmContactDelete,
PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate, PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryDelete,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate, PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmEnquiryFollowupDelete,
PERMISSIONS.crmQuotationRead, PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate, PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate, PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationDelete,
PERMISSIONS.crmQuotationItemManage, PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage, PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage, PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage, PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage, PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate, PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'own',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'none',
isSystem: true
},
sales_manager: {
name: 'Sales Manager',
description: 'Team visibility, sales assignment, and quotation approval authority.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalApprove, PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject, PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn, PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'team',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'manager',
isSystem: true
},
department_manager: {
name: 'Department Manager',
description: 'Department analytics and higher-level approval authority.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'organization',
branchScopeMode: 'assigned',
productScopeMode: 'assigned',
approvalAuthority: 'department',
isSystem: true
},
top_manager: {
name: 'Top Manager',
description: 'Final approval authority with full CRM analytics visibility.',
permissions: [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationPricingRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
],
ownershipScope: 'organization',
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'final',
isSystem: true
},
crm_admin: {
name: 'CRM Admin',
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
permissions: [
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmRoleRead,
PERMISSIONS.crmRoleCreate,
PERMISSIONS.crmRoleUpdate,
PERMISSIONS.crmRoleDelete,
PERMISSIONS.crmRoleAssign,
PERMISSIONS.crmMasterOptionRead,
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
PERMISSIONS.crmMasterOptionDelete,
PERMISSIONS.crmApprovalWorkflowRead, PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmApprovalWorkflowCreate, PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate, PERMISSIONS.crmApprovalWorkflowUpdate,
PERMISSIONS.crmApprovalWorkflowDelete, PERMISSIONS.crmApprovalWorkflowDelete,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentTemplateCreate, PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate, PERMISSIONS.crmDocumentTemplateUpdate,
@@ -192,116 +356,163 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmQuotationPdfPreview, PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload, PERMISSIONS.crmQuotationPdfDownload,
PERMISSIONS.crmQuotationPdfGenerateApproved PERMISSIONS.crmQuotationPdfGenerateApproved
],
ownershipScope: 'organization',
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'none',
isSystem: true
}
};
export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{
key: 'lead',
label: 'Leads',
permissions: [
{ key: PERMISSIONS.crmLeadRead, label: 'Read leads' },
{ key: PERMISSIONS.crmLeadCreate, label: 'Create leads' },
{ key: PERMISSIONS.crmLeadUpdate, label: 'Update leads' },
{ key: PERMISSIONS.crmLeadAssign, label: 'Assign leads' },
{ key: PERMISSIONS.crmLeadDelete, label: 'Delete leads' }
]
},
{
key: 'enquiry',
label: 'Enquiries',
permissions: [
{ key: PERMISSIONS.crmEnquiryRead, label: 'Read enquiries' },
{ key: PERMISSIONS.crmEnquiryCreate, label: 'Create enquiries' },
{ key: PERMISSIONS.crmEnquiryUpdate, label: 'Update enquiries' },
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' },
{ key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' },
{ key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupDelete, label: 'Delete enquiry follow-ups' }
]
},
{
key: 'quotation',
label: 'Quotations',
permissions: [
{ key: PERMISSIONS.crmQuotationRead, label: 'Read quotations' },
{ key: PERMISSIONS.crmQuotationCreate, label: 'Create quotations' },
{ key: PERMISSIONS.crmQuotationUpdate, label: 'Update quotations' },
{ key: PERMISSIONS.crmQuotationDelete, label: 'Delete quotations' },
{ key: PERMISSIONS.crmQuotationPricingRead, label: 'Read quotation pricing' },
{ key: PERMISSIONS.crmQuotationItemManage, label: 'Manage quotation items' },
{ key: PERMISSIONS.crmQuotationCustomerManage, label: 'Manage quotation customers' },
{ key: PERMISSIONS.crmQuotationTopicManage, label: 'Manage quotation topics' },
{ key: PERMISSIONS.crmQuotationFollowupManage, label: 'Manage quotation follow-ups' },
{ key: PERMISSIONS.crmQuotationAttachmentManage, label: 'Manage quotation attachments' },
{ key: PERMISSIONS.crmQuotationRevisionCreate, label: 'Create quotation revisions' }
]
},
{
key: 'approval',
label: 'Approvals',
permissions: [
{ key: PERMISSIONS.crmApprovalRead, label: 'Read approvals' },
{ key: PERMISSIONS.crmApprovalSubmit, label: 'Submit approvals' },
{ key: PERMISSIONS.crmApprovalApprove, label: 'Approve quotations' },
{ key: PERMISSIONS.crmApprovalReject, label: 'Reject quotations' },
{ key: PERMISSIONS.crmApprovalReturn, label: 'Return quotations' }
]
},
{
key: 'dashboard',
label: 'Dashboard',
permissions: [
{ key: PERMISSIONS.crmDashboardRead, label: 'Read dashboard' },
{ key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' }
]
},
{
key: 'settings',
label: 'CRM Settings',
permissions: [
{ key: PERMISSIONS.crmRoleRead, label: 'Read roles' },
{ key: PERMISSIONS.crmRoleCreate, label: 'Create roles' },
{ key: PERMISSIONS.crmRoleUpdate, label: 'Update roles' },
{ key: PERMISSIONS.crmRoleDelete, label: 'Delete roles' },
{ key: PERMISSIONS.crmRoleAssign, label: 'Assign role scopes' },
{ key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' },
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
{ key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' },
{ key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowDelete, label: 'Delete approval workflows' },
{ key: PERMISSIONS.crmDocumentTemplateRead, label: 'Read document templates' },
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
{ key: PERMISSIONS.crmDocumentTemplateDelete, label: 'Delete document templates' },
{ key: PERMISSIONS.crmDocumentSequenceRead, label: 'Read document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceCreate, label: 'Create document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceUpdate, label: 'Update document sequences' },
{ key: PERMISSIONS.crmDocumentSequenceReset, label: 'Reset document sequences' }
]
},
{
key: 'documents',
label: 'Documents',
permissions: [
{ key: PERMISSIONS.crmDocumentArtifactRead, label: 'Read approved artifacts' },
{ key: PERMISSIONS.crmDocumentArtifactDownload, label: 'Download approved artifacts' },
{ key: PERMISSIONS.crmDocumentArtifactVoid, label: 'Void approved artifacts' },
{ key: PERMISSIONS.crmQuotationDocumentPreview, label: 'Preview quotation document' },
{ key: PERMISSIONS.crmQuotationPdfPreview, label: 'Preview quotation PDF' },
{ key: PERMISSIONS.crmQuotationPdfDownload, label: 'Download quotation PDF' },
{ key: PERMISSIONS.crmQuotationPdfGenerateApproved, label: 'Generate approved quotation PDF' }
]
}
];
export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as Permission[];
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
if (role === 'admin') {
return [
PERMISSIONS.productsRead,
PERMISSIONS.productsWrite,
PERMISSIONS.organizationManage,
PERMISSIONS.usersManage
]; ];
} }
return [ return [PERMISSIONS.productsRead];
PERMISSIONS.productsRead, }
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead, export function getRoleProfileDefinition(roleCode: string): CrmRoleProfileDefinition | null {
PERMISSIONS.crmContactRead, const definition = DEFAULT_ROLE_DEFINITIONS[roleCode];
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead, if (!definition) {
PERMISSIONS.crmQuotationRead, return null;
PERMISSIONS.crmApprovalRead, }
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead, return {
PERMISSIONS.crmDocumentTemplateRead, code: roleCode,
PERMISSIONS.crmDocumentSequenceRead, ...definition
PERMISSIONS.crmDocumentArtifactRead, };
PERMISSIONS.crmDocumentArtifactDownload, }
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview, export function getDefaultRoleProfiles(): CrmRoleProfileDefinition[] {
PERMISSIONS.crmQuotationPdfDownload return BUSINESS_ROLES.map((roleCode) => {
]; const definition = getRoleProfileDefinition(roleCode);
if (!definition) {
throw new Error(`Missing default role definition for ${roleCode}`);
}
return definition;
});
} }
export function getBusinessRolePermissions(role: BusinessRole): Permission[] { export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
switch (role) { return getRoleProfileDefinition(role)?.permissions ?? [];
case 'marketing':
return [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmDashboardRead
];
case 'sales_manager':
return MANAGER_PERMISSIONS;
case 'sales':
return [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
case 'sales_support':
return [
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
case 'department_manager':
case 'top_manager':
return MANAGER_PERMISSIONS;
default:
return [];
}
} }
export function getDefaultBusinessRole(role: MembershipRole): BusinessRole { export function getDefaultBusinessRole(role: MembershipRole): BusinessRole {
@@ -317,6 +528,27 @@ export function getDefaultPermissions(
]; ];
} }
export function resolveEffectivePermissions(input: {
systemRole: SystemRole;
membershipRole: MembershipRole;
businessRole: string;
rolePermissions?: string[] | null;
directPermissions?: string[] | null;
}) {
if (input.systemRole === 'super_admin') {
return ALL_PERMISSIONS;
}
return [
...new Set([
...getMembershipBasePermissions(input.membershipRole),
...getBusinessRolePermissions(input.businessRole),
...(input.rolePermissions?.filter((value): value is Permission => typeof value === 'string') ?? []),
...(input.directPermissions?.filter((value): value is Permission => typeof value === 'string') ?? [])
])
];
}
export function isSystemRole(value: string): value is SystemRole { export function isSystemRole(value: string): value is SystemRole {
return SYSTEM_ROLES.includes(value as SystemRole); return SYSTEM_ROLES.includes(value as SystemRole);
} }
@@ -326,5 +558,17 @@ export function isMembershipRole(value: string): value is MembershipRole {
} }
export function isBusinessRole(value: string): value is BusinessRole { export function isBusinessRole(value: string): value is BusinessRole {
return BUSINESS_ROLES.includes(value as BusinessRole); return value.trim().length > 0;
}
export function isOwnershipScope(value: string): value is CrmOwnershipScope {
return OWNERSHIP_SCOPES.includes(value as CrmOwnershipScope);
}
export function isScopeMode(value: string): value is CrmScopeMode {
return SCOPE_MODES.includes(value as CrmScopeMode);
}
export function isApprovalAuthority(value: string): value is CrmApprovalAuthority {
return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority);
} }

View File

@@ -1,7 +1,9 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { memberships, organizations } from '@/db/schema'; import { memberships, organizations } from '@/db/schema';
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import type { Permission } from './rbac';
export class AuthError extends Error { export class AuthError extends Error {
status: number; status: number;
@@ -58,7 +60,14 @@ export async function requireOrganizationAccess(options?: {
organizationId: activeOrganization.id, organizationId: activeOrganization.id,
role: activeOrganization.role, role: activeOrganization.role,
businessRole: activeOrganization.businessRole, businessRole: activeOrganization.businessRole,
permissions: session.user.activePermissions permissions: session.user.activePermissions,
branchScopeIds: session.user.activeBranchScopeIds,
productTypeScopeIds: session.user.activeProductTypeScopeIds,
ownershipScope: session.user.activeOwnershipScope,
branchScopeMode: 'all',
productScopeMode: 'all',
approvalAuthority: 'final',
roleProfileName: 'System Admin'
}, },
organization: { organization: {
id: activeOrganization.id, id: activeOrganization.id,
@@ -85,6 +94,12 @@ export async function requireOrganizationAccess(options?: {
throw new AuthError('Forbidden', 403); throw new AuthError('Forbidden', 403);
} }
const resolvedAccess = await resolveCrmMembershipAccess({
systemRole: session.user.systemRole,
organizationId: membership.organizationId,
membership
});
const allowedRoles = options?.roles ?? (options?.role ? [options.role] : []); const allowedRoles = options?.roles ?? (options?.role ? [options.role] : []);
const allowedPermissions = options?.permissions ?? (options?.permission ? [options.permission] : []); const allowedPermissions = options?.permissions ?? (options?.permission ? [options.permission] : []);
@@ -94,7 +109,9 @@ export async function requireOrganizationAccess(options?: {
if ( if (
allowedPermissions.length > 0 && allowedPermissions.length > 0 &&
!allowedPermissions.every((permission) => membership.permissions.includes(permission)) !allowedPermissions.every((permission) =>
resolvedAccess.permissions.includes(permission as Permission)
)
) { ) {
throw new AuthError('Forbidden', 403); throw new AuthError('Forbidden', 403);
} }
@@ -109,7 +126,17 @@ export async function requireOrganizationAccess(options?: {
return { return {
session, session,
membership, membership: {
...membership,
permissions: resolvedAccess.permissions,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope,
branchScopeMode: resolvedAccess.branchScopeMode,
productScopeMode: resolvedAccess.productScopeMode,
approvalAuthority: resolvedAccess.approvalAuthority,
roleProfileName: resolvedAccess.roleProfileName
},
organization organization
}; };
} }

View File

@@ -11,6 +11,9 @@ declare module 'next-auth' {
activeMembershipRole: string | null; activeMembershipRole: string | null;
activeBusinessRole: string | null; activeBusinessRole: string | null;
activePermissions: string[]; activePermissions: string[];
activeBranchScopeIds: string[];
activeProductTypeScopeIds: string[];
activeOwnershipScope: string | null;
organizations: Array<{ organizations: Array<{
id: string; id: string;
name: string; name: string;
@@ -40,6 +43,9 @@ declare module 'next-auth/jwt' {
activeMembershipRole?: string | null; activeMembershipRole?: string | null;
activeBusinessRole?: string | null; activeBusinessRole?: string | null;
activePermissions?: string[]; activePermissions?: string[];
activeBranchScopeIds?: string[];
activeProductTypeScopeIds?: string[];
activeOwnershipScope?: string | null;
organizations?: Array<{ organizations?: Array<{
id: string; id: string;
name: string; name: string;