task-l
This commit is contained in:
60
docs/implementation/task-l-crm-permission-role-management.md
Normal file
60
docs/implementation/task-l-crm-permission-role-management.md
Normal 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
|
||||||
23
drizzle/0013_great_nicolaos.sql
Normal file
23
drizzle/0013_great_nicolaos.sql
Normal 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");
|
||||||
3440
drizzle/meta/0013_snapshot.json
Normal file
3440
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
507
plans/task-l.md
Normal 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.
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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 before = (
|
const before = (
|
||||||
await listEnquiryFollowups(id, organization.id, {
|
await listEnquiryFollowups(id, organization.id, accessContext)
|
||||||
organizationId: organization.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
membershipRole: membership.role,
|
|
||||||
businessRole: membership.businessRole
|
|
||||||
})
|
|
||||||
).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 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 before = (
|
const before = (
|
||||||
await listEnquiryFollowups(id, organization.id, {
|
await listEnquiryFollowups(id, organization.id, accessContext)
|
||||||
organizationId: organization.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
membershipRole: membership.role,
|
|
||||||
businessRole: membership.businessRole
|
|
||||||
})
|
|
||||||
).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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 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 result = await listEnquiries(
|
const result = await listEnquiries(
|
||||||
{
|
accessContext,
|
||||||
organizationId: organization.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
membershipRole: membership.role,
|
|
||||||
businessRole: membership.businessRole
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
45
src/app/api/crm/settings/roles/[code]/clone/route.ts
Normal file
45
src/app/api/crm/settings/roles/[code]/clone/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/api/crm/settings/roles/[code]/route.ts
Normal file
60
src/app/api/crm/settings/roles/[code]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/crm/settings/roles/route.ts
Normal file
31
src/app/api/crm/settings/roles/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
39
src/app/dashboard/crm/settings/roles/page.tsx
Normal file
39
src/app/dashboard/crm/settings/roles/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/auth.ts
106
src/auth.ts
@@ -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[] = [];
|
||||||
id: row.organizationId,
|
|
||||||
name: row.name,
|
for (const row of rows) {
|
||||||
slug: row.slug,
|
const resolvedAccess = await resolveCrmMembershipAccess({
|
||||||
role: row.role,
|
systemRole,
|
||||||
businessRole: row.businessRole,
|
organizationId: row.organizationId,
|
||||||
plan: row.plan,
|
membership: {
|
||||||
imageUrl: row.imageUrl
|
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,
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
role: row.role,
|
||||||
|
businessRole: row.businessRole,
|
||||||
|
branchScopeIds: resolvedAccess.branchScopeIds,
|
||||||
|
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
||||||
|
ownershipScope: resolvedAccess.ownershipScope,
|
||||||
|
plan: row.plan,
|
||||||
|
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'
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
30
src/features/foundation/role-management/api/mutations.ts
Normal file
30
src/features/foundation/role-management/api/mutations.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
13
src/features/foundation/role-management/api/queries.ts
Normal file
13
src/features/foundation/role-management/api/queries.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
28
src/features/foundation/role-management/api/service.ts
Normal file
28
src/features/foundation/role-management/api/service.ts
Normal 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)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/features/foundation/role-management/api/types.ts
Normal file
57
src/features/foundation/role-management/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
src/features/foundation/role-management/server/service.ts
Normal file
252
src/features/foundation/role-management/server/service.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
151
src/lib/auth/crm-access.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,106 +85,262 @@ 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 {
|
||||||
PERMISSIONS.crmLeadRead,
|
code: string;
|
||||||
PERMISSIONS.crmLeadCreate,
|
name: string;
|
||||||
PERMISSIONS.crmLeadUpdate,
|
description: string;
|
||||||
PERMISSIONS.crmLeadAssign,
|
permissions: Permission[];
|
||||||
PERMISSIONS.crmLeadDelete,
|
ownershipScope: CrmOwnershipScope;
|
||||||
PERMISSIONS.crmCustomerRead,
|
branchScopeMode: CrmScopeMode;
|
||||||
PERMISSIONS.crmCustomerCreate,
|
productScopeMode: CrmScopeMode;
|
||||||
PERMISSIONS.crmCustomerUpdate,
|
approvalAuthority: CrmApprovalAuthority;
|
||||||
PERMISSIONS.crmContactRead,
|
isSystem: boolean;
|
||||||
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.crmApprovalRead,
|
|
||||||
PERMISSIONS.crmApprovalSubmit,
|
|
||||||
PERMISSIONS.crmApprovalWorkflowRead,
|
|
||||||
PERMISSIONS.crmDashboardRead,
|
|
||||||
PERMISSIONS.crmDashboardExport,
|
|
||||||
PERMISSIONS.crmDocumentTemplateRead,
|
|
||||||
PERMISSIONS.crmDocumentSequenceRead,
|
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
|
||||||
PERMISSIONS.crmDocumentArtifactVoid,
|
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
|
||||||
PERMISSIONS.crmQuotationPdfPreview,
|
|
||||||
PERMISSIONS.crmQuotationPdfDownload
|
|
||||||
];
|
|
||||||
|
|
||||||
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
export interface PermissionGroup {
|
||||||
if (role === 'admin') {
|
key: string;
|
||||||
return [
|
label: string;
|
||||||
PERMISSIONS.productsRead,
|
permissions: Array<{ key: Permission; label: string }>;
|
||||||
PERMISSIONS.productsWrite,
|
}
|
||||||
PERMISSIONS.organizationManage,
|
|
||||||
PERMISSIONS.usersManage,
|
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.crmCustomerDelete,
|
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmContactCreate,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmContactUpdate,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmContactDelete,
|
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.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.crmApprovalSubmit,
|
||||||
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
|
PERMISSIONS.crmQuotationPdfPreview,
|
||||||
|
PERMISSIONS.crmQuotationPdfDownload
|
||||||
|
],
|
||||||
|
ownershipScope: 'own',
|
||||||
|
branchScopeMode: 'assigned',
|
||||||
|
productScopeMode: 'assigned',
|
||||||
|
approvalAuthority: 'none',
|
||||||
|
isSystem: true
|
||||||
|
},
|
||||||
|
sales_support: {
|
||||||
|
name: 'Sales Support',
|
||||||
|
description: 'Quotation drafting and operational support without approval authority.',
|
||||||
|
permissions: [
|
||||||
|
PERMISSIONS.crmCustomerRead,
|
||||||
|
PERMISSIONS.crmContactRead,
|
||||||
|
PERMISSIONS.crmEnquiryRead,
|
||||||
|
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.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -51,15 +53,22 @@ export async function requireOrganizationAccess(options?: {
|
|||||||
throw new AuthError('Organization membership required', 403);
|
throw new AuthError('Organization membership required', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
membership: {
|
membership: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
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,
|
||||||
name: activeOrganization.name,
|
name: activeOrganization.name,
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/types/next-auth.d.ts
vendored
6
src/types/next-auth.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user