task-l.1
This commit is contained in:
72
docs/adr/0014-crm-multi-role-user-assignment.md
Normal file
72
docs/adr/0014-crm-multi-role-user-assignment.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# ADR 0014: CRM Multi-Role User Assignment
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Task L introduced CRM role profiles and resolved CRM access, but authorization still depended on a single `memberships.businessRole` value per organization membership.
|
||||||
|
|
||||||
|
That model is too restrictive for real CRM operations because one user may need multiple CRM responsibilities inside the same organization, such as:
|
||||||
|
|
||||||
|
- `sales` + `sales_manager`
|
||||||
|
- `crm_admin` + `department_manager`
|
||||||
|
- `marketing` + `sales_support`
|
||||||
|
|
||||||
|
We need to keep `memberships` as organization access while moving CRM authorization into its own persistent model.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We adopt:
|
||||||
|
|
||||||
|
- `memberships` as organization/workspace access
|
||||||
|
- `crm_user_role_assignments` as CRM authorization
|
||||||
|
|
||||||
|
One user can have many CRM role assignments per organization.
|
||||||
|
|
||||||
|
Each assignment stores:
|
||||||
|
|
||||||
|
- `roleProfileId`
|
||||||
|
- branch scope mode and branch IDs
|
||||||
|
- product-type scope mode and product-type IDs
|
||||||
|
- primary/display flag
|
||||||
|
- active/inactive lifecycle
|
||||||
|
|
||||||
|
Effective CRM access is resolved from:
|
||||||
|
|
||||||
|
1. membership role
|
||||||
|
2. all active CRM role assignments
|
||||||
|
3. all assigned CRM role profile permissions
|
||||||
|
4. direct membership permissions
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- permissions are the union of all active role-profile permissions plus membership permissions
|
||||||
|
- branch scope is the union of active assignment scopes unless any active assignment grants `all`
|
||||||
|
- product-type scope is the union of active assignment scopes unless any active assignment grants `all`
|
||||||
|
- approval authority uses the highest active authority among assigned roles
|
||||||
|
- primary role is display-only and does not limit the permission union
|
||||||
|
- `memberships.businessRole` remains temporarily as a compatibility fallback only when no active CRM role assignment exists
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- supports realistic multi-role CRM operation
|
||||||
|
- separates organization access from CRM-specific authorization
|
||||||
|
- allows per-role scope assignment per user
|
||||||
|
- keeps rollout compatible with existing membership data through lazy backfill
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- resolver complexity increases
|
||||||
|
- old `memberships.businessRole` semantics must be maintained during transition
|
||||||
|
- UI and audit coverage must handle assignment lifecycle, not just role-profile maintenance
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
- create `crm_user_role_assignments`
|
||||||
|
- backfill one primary assignment from `memberships.businessRole` when possible
|
||||||
|
- treat `memberships.businessRole` as deprecated for CRM authorization after Task L.1
|
||||||
|
- keep the column until user-management and downstream integrations fully move to assignment-based CRM access
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Task L.1: CRM Multi-Role User Assignment
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Task L.1 extends the CRM authorization foundation so one user can hold multiple CRM role assignments inside the same organization.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- Added `crm_user_role_assignments` schema for CRM-specific authorization rows
|
||||||
|
- Added assignment permissions:
|
||||||
|
- `crm.role.assignment.read`
|
||||||
|
- `crm.role.assignment.create`
|
||||||
|
- `crm.role.assignment.update`
|
||||||
|
- `crm.role.assignment.delete`
|
||||||
|
- `crm.role.assignment.manage`
|
||||||
|
- Added lazy compatibility backfill from `memberships.businessRole` into CRM role assignments
|
||||||
|
- Updated CRM access resolution to:
|
||||||
|
- union permissions across all active assignments
|
||||||
|
- union branch/product scope across assignments
|
||||||
|
- choose the most permissive ownership scope
|
||||||
|
- choose the highest approval authority
|
||||||
|
- use primary role for display only
|
||||||
|
- Added `CRM Settings > User Role Assignments`
|
||||||
|
- Added assignment API routes:
|
||||||
|
- `GET /api/crm/settings/user-role-assignments`
|
||||||
|
- `POST /api/crm/settings/user-role-assignments`
|
||||||
|
- `PATCH /api/crm/settings/user-role-assignments/[id]`
|
||||||
|
- `DELETE /api/crm/settings/user-role-assignments/[id]`
|
||||||
|
- Added audit logging for `crm_user_role_assignment`
|
||||||
|
- Added ADR `0014-crm-multi-role-user-assignment`
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- `memberships.businessRole` remains in place
|
||||||
|
- when no active CRM role assignment exists, resolver still falls back safely
|
||||||
|
- when role assignments exist, CRM authorization no longer treats `memberships.businessRole` as the primary source
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run: `npx tsc --noEmit`
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
|
||||||
|
- user-creation and user-edit flows still center around legacy `memberships.businessRole`
|
||||||
|
- some CRM modules beyond the current resolver consumers may still need deeper scenario verification with mixed-role fixtures
|
||||||
|
- a dedicated one-shot operational migration script can still be useful even though lazy backfill is active
|
||||||
@@ -365,6 +365,19 @@ Future:
|
|||||||
- define whether mixed-role users see own, team, or organization scope by default
|
- define whether mixed-role users see own, team, or organization scope by default
|
||||||
- keep exports aligned with the same scope policy once enforced
|
- keep exports aligned with the same scope policy once enforced
|
||||||
|
|
||||||
|
## After Task L.1
|
||||||
|
|
||||||
|
### Deprecated membership.businessRole for CRM authorization
|
||||||
|
|
||||||
|
Current:
|
||||||
|
`memberships.businessRole` is now a compatibility fallback only. Primary CRM authorization moves to `crm_user_role_assignments`.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
|
||||||
|
- remove CRM authorization dependence on `memberships.businessRole` entirely
|
||||||
|
- update user-management flows to assign CRM roles through assignment rows instead of membership payloads
|
||||||
|
- remove the deprecated column only after all CRM modules and admin flows have migrated
|
||||||
|
|
||||||
### Global project-party filter coverage
|
### Global project-party filter coverage
|
||||||
|
|
||||||
Task J adds a global `Project Party Role` filter in the dashboard UI, but the strongest enforcement today is in revenue analytics and export sections.
|
Task J adds a global `Project Party Role` filter in the dashboard UI, but the strongest enforcement today is in revenue analytics and export sections.
|
||||||
|
|||||||
19
drizzle/0014_bored_valkyrie.sql
Normal file
19
drizzle/0014_bored_valkyrie.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "crm_user_role_assignments" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role_profile_id" text NOT NULL,
|
||||||
|
"branch_scope_mode" text DEFAULT 'inherit' NOT NULL,
|
||||||
|
"branch_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"product_type_scope_mode" text DEFAULT 'inherit' NOT NULL,
|
||||||
|
"product_type_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"is_primary" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"assigned_by" text NOT NULL,
|
||||||
|
"assigned_at" timestamp with time zone DEFAULT now() 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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_user_role_assignments_org_user_role_idx" ON "crm_user_role_assignments" USING btree ("organization_id","user_id","role_profile_id");
|
||||||
20
drizzle/0014_crm_user_role_assignments.sql
Normal file
20
drizzle/0014_crm_user_role_assignments.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "crm_user_role_assignments" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role_profile_id" text NOT NULL,
|
||||||
|
"branch_scope_mode" text DEFAULT 'inherit' NOT NULL,
|
||||||
|
"branch_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"product_type_scope_mode" text DEFAULT 'inherit' NOT NULL,
|
||||||
|
"product_type_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"is_primary" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"assigned_by" text NOT NULL,
|
||||||
|
"assigned_at" timestamp with time zone DEFAULT now() 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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "crm_user_role_assignments_org_user_role_idx"
|
||||||
|
ON "crm_user_role_assignments" ("organization_id", "user_id", "role_profile_id");
|
||||||
3580
drizzle/meta/0014_snapshot.json
Normal file
3580
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
|||||||
"when": 1782097963376,
|
"when": 1782097963376,
|
||||||
"tag": "0013_great_nicolaos",
|
"tag": "0013_great_nicolaos",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782099743528,
|
||||||
|
"tag": "0014_bored_valkyrie",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
575
plans/task-l.1.md
Normal file
575
plans/task-l.1.md
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
# Task L.1: CRM Multi-Role User Assignment & Scope Management
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Extend Task L authorization foundation so one user can hold multiple CRM role profiles within the same organization.
|
||||||
|
|
||||||
|
Business decision:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1 User = Many CRM Roles
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Sales Manager + Sales
|
||||||
|
CRM Admin + Department Manager
|
||||||
|
Sales Support + Marketing
|
||||||
|
```
|
||||||
|
|
||||||
|
This task adds role assignment persistence, assignment UI, and updates effective CRM access resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Task L introduced:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crmRoleProfiles
|
||||||
|
memberships.branchScopeIds
|
||||||
|
memberships.productTypeScopeIds
|
||||||
|
resolved CRM access helper
|
||||||
|
CRM Settings > Roles
|
||||||
|
```
|
||||||
|
|
||||||
|
Current limitation:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
membership.businessRole = single role
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not enough for real business operation.
|
||||||
|
|
||||||
|
Task L.1 moves CRM role assignment into a dedicated model while keeping membership as organization access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Target Model
|
||||||
|
|
||||||
|
Membership remains organization-level access:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
memberships
|
||||||
|
- userId
|
||||||
|
- organizationId
|
||||||
|
- role
|
||||||
|
- permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
CRM role assignments become CRM-specific authorization rows:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_user_role_assignments
|
||||||
|
- id
|
||||||
|
- organizationId
|
||||||
|
- userId
|
||||||
|
- roleProfileId
|
||||||
|
- branchScopeMode
|
||||||
|
- branchScopeIds
|
||||||
|
- productTypeScopeMode
|
||||||
|
- productTypeScopeIds
|
||||||
|
- isPrimary
|
||||||
|
- isActive
|
||||||
|
- assignedBy
|
||||||
|
- assignedAt
|
||||||
|
- createdAt
|
||||||
|
- updatedAt
|
||||||
|
- deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.1 Schema
|
||||||
|
|
||||||
|
Add table:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_user_role_assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
userId
|
||||||
|
roleProfileId
|
||||||
|
|
||||||
|
branchScopeMode
|
||||||
|
branchScopeIds
|
||||||
|
|
||||||
|
productTypeScopeMode
|
||||||
|
productTypeScopeIds
|
||||||
|
|
||||||
|
isPrimary
|
||||||
|
isActive
|
||||||
|
|
||||||
|
assignedBy
|
||||||
|
assignedAt
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended scope modes:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
all
|
||||||
|
selected
|
||||||
|
none
|
||||||
|
inherit
|
||||||
|
```
|
||||||
|
|
||||||
|
Unique constraint:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
organizationId + userId + roleProfileId
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* one user can have many role profiles
|
||||||
|
* same role profile cannot be duplicated for the same user/org
|
||||||
|
* only one primary CRM role per user/org
|
||||||
|
* soft delete supported
|
||||||
|
* old `membership.businessRole` remains temporarily for compatibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.2 Migration / Backfill
|
||||||
|
|
||||||
|
Backfill existing users.
|
||||||
|
|
||||||
|
For each membership:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
membership.businessRole
|
||||||
|
```
|
||||||
|
|
||||||
|
create one CRM role assignment matching the role profile.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* if businessRole has a matching `crmRoleProfiles.code`, create assignment
|
||||||
|
* if no matching role profile exists, skip and log warning
|
||||||
|
* set `isPrimary = true`
|
||||||
|
* branch/product scope from membership fields if available
|
||||||
|
* idempotent migration
|
||||||
|
* no duplicate assignments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.3 Effective CRM Access Resolver
|
||||||
|
|
||||||
|
Update resolved access helper.
|
||||||
|
|
||||||
|
Old:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
membership.businessRole
|
||||||
|
+
|
||||||
|
membership.permissions
|
||||||
|
+
|
||||||
|
roleProfile.permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
membership
|
||||||
|
+
|
||||||
|
all active crm_user_role_assignments
|
||||||
|
+
|
||||||
|
all assigned crmRoleProfiles
|
||||||
|
+
|
||||||
|
direct membership.permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
Permission behavior:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
effectivePermissions = union(all roleProfile.permissions, membership.permissions)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ownership behavior:
|
||||||
|
|
||||||
|
Use most permissive access among active role profiles:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
own
|
||||||
|
team
|
||||||
|
branch
|
||||||
|
organization
|
||||||
|
all
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch scope behavior:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
if any role has all -> all
|
||||||
|
else union selected branchScopeIds
|
||||||
|
else none
|
||||||
|
```
|
||||||
|
|
||||||
|
Product type scope behavior:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
if any role has all -> all
|
||||||
|
else union selected productTypeScopeIds
|
||||||
|
else none
|
||||||
|
```
|
||||||
|
|
||||||
|
Approval authority:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
effectiveApprovalAuthority = union(all roleProfile.approvalAuthority)
|
||||||
|
```
|
||||||
|
|
||||||
|
Primary role:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
used for display only
|
||||||
|
not permission limit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.4 User Assignment UI
|
||||||
|
|
||||||
|
Add page or tab:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CRM Settings
|
||||||
|
└─ User Role Assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
or add tab inside existing user detail:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Users
|
||||||
|
└─ CRM Roles
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended UI:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User
|
||||||
|
Assigned CRM Roles
|
||||||
|
Branch Scope
|
||||||
|
Product Type Scope
|
||||||
|
Primary Role
|
||||||
|
Status
|
||||||
|
Actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Assign Role
|
||||||
|
Edit Scope
|
||||||
|
Set Primary
|
||||||
|
Deactivate Assignment
|
||||||
|
Reactivate Assignment
|
||||||
|
Remove Assignment
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.5 Assignment Form
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User
|
||||||
|
Role Profile
|
||||||
|
Branch Scope Mode
|
||||||
|
Branches
|
||||||
|
Product Type Scope Mode
|
||||||
|
Product Types
|
||||||
|
Primary
|
||||||
|
Active
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* role profile dropdown from active `crmRoleProfiles`
|
||||||
|
* branch options from `crm_branch`
|
||||||
|
* product type options from `crm_product_type`
|
||||||
|
* if scope mode = all, disable selected list
|
||||||
|
* if scope mode = selected, require at least one selected option
|
||||||
|
* if primary = true, unset other primary role assignments for same user/org
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.6 API Routes
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/settings/user-role-assignments
|
||||||
|
POST /api/crm/settings/user-role-assignments
|
||||||
|
PATCH /api/crm/settings/user-role-assignments/[id]
|
||||||
|
DELETE /api/crm/settings/user-role-assignments/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/settings/users/[userId]/role-assignments
|
||||||
|
```
|
||||||
|
|
||||||
|
All APIs must:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
requireOrganizationAccess
|
||||||
|
requirePermission("crm.role.assignment.manage")
|
||||||
|
filter by organizationId
|
||||||
|
audit mutations
|
||||||
|
return user-safe errors
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.7 Permissions
|
||||||
|
|
||||||
|
Add permissions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.role.assignment.read
|
||||||
|
crm.role.assignment.create
|
||||||
|
crm.role.assignment.update
|
||||||
|
crm.role.assignment.delete
|
||||||
|
crm.role.assignment.manage
|
||||||
|
```
|
||||||
|
|
||||||
|
Grant default to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
system_admin
|
||||||
|
crm_admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional read to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
sales_manager
|
||||||
|
department_manager
|
||||||
|
top_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.8 Audit
|
||||||
|
|
||||||
|
Audit entity type:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_user_role_assignment
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
create
|
||||||
|
update
|
||||||
|
delete
|
||||||
|
activate
|
||||||
|
deactivate
|
||||||
|
set_primary
|
||||||
|
scope_update
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit payload should include:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
userId
|
||||||
|
roleProfileId
|
||||||
|
branchScopeMode
|
||||||
|
branchScopeIds
|
||||||
|
productTypeScopeMode
|
||||||
|
productTypeScopeIds
|
||||||
|
isPrimary
|
||||||
|
isActive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.9 Compatibility Layer
|
||||||
|
|
||||||
|
Keep `membership.businessRole` temporarily.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* do not remove column yet
|
||||||
|
* do not rely on it when role assignments exist
|
||||||
|
* fallback to `membership.businessRole` only when user has no active CRM role assignment
|
||||||
|
* document deprecation
|
||||||
|
|
||||||
|
Add technical debt:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
membership.businessRole is deprecated for CRM authorization after Task L.1.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.10 Server-Side Enforcement Migration
|
||||||
|
|
||||||
|
Update all CRM modules to use resolved multi-role access:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Leads
|
||||||
|
Enquiries
|
||||||
|
Customers
|
||||||
|
Contacts
|
||||||
|
Quotations
|
||||||
|
Approvals
|
||||||
|
Dashboard
|
||||||
|
Settings
|
||||||
|
Reports if present
|
||||||
|
```
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
|
||||||
|
1. Leads / Enquiries
|
||||||
|
2. Quotations
|
||||||
|
3. Dashboard
|
||||||
|
4. Approval
|
||||||
|
5. Customers / Contacts
|
||||||
|
6. Settings
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* no UI-only enforcement
|
||||||
|
* all list/detail/action routes must check resolved CRM access
|
||||||
|
* pricing visibility must remain permission-based
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.11 Verification Matrix
|
||||||
|
|
||||||
|
Create verification cases:
|
||||||
|
|
||||||
|
## Marketing + Sales Support
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
can view leads
|
||||||
|
can create support quotation if permission exists
|
||||||
|
cannot approve
|
||||||
|
cannot view revenue unless granted
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sales + Sales Manager
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
can see own sales records
|
||||||
|
can see team records if manager role scope allows
|
||||||
|
can create quotation
|
||||||
|
can approve only if role profile has authority
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRM Admin + Department Manager
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
can manage CRM settings
|
||||||
|
can approve department-level workflow
|
||||||
|
can view configured analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sales with Crane + Bangkok Scope
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
can see Crane Bangkok records
|
||||||
|
cannot see Solar Rayong records
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope L1.12 Documentation / ADR
|
||||||
|
|
||||||
|
Create ADR:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/adr/0014-crm-multi-role-user-assignment.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1 user = many CRM roles
|
||||||
|
membership is organization access
|
||||||
|
crm_user_role_assignments is CRM authorization
|
||||||
|
permission union rule
|
||||||
|
scope union rule
|
||||||
|
primary role display-only rule
|
||||||
|
businessRole deprecation
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Keycloak group sync
|
||||||
|
External identity role sync
|
||||||
|
Team hierarchy module
|
||||||
|
Advanced ABAC rules
|
||||||
|
Approval delegation
|
||||||
|
Temporary role expiration
|
||||||
|
Role request workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
After completion, summarize:
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Schema Added
|
||||||
|
4. Migration / Backfill
|
||||||
|
5. Resolver Changes
|
||||||
|
6. UI Added
|
||||||
|
7. API Added
|
||||||
|
8. Permissions Added
|
||||||
|
9. Audit Added
|
||||||
|
10. Verification Result
|
||||||
|
11. Remaining Risks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task L.1 is complete when:
|
||||||
|
|
||||||
|
* user can have multiple CRM role assignments
|
||||||
|
* each assignment can have branch/product scope
|
||||||
|
* one primary role can be selected
|
||||||
|
* effective permissions are unioned from all active roles
|
||||||
|
* effective branch/product scope is resolved correctly
|
||||||
|
* membership.businessRole is no longer primary CRM authorization source
|
||||||
|
* role assignment UI works
|
||||||
|
* role assignment API works
|
||||||
|
* all CRM critical routes use multi-role resolver
|
||||||
|
* audit logs are created for assignment changes
|
||||||
|
* ADR 0014 exists
|
||||||
91
src/app/api/crm/settings/user-role-assignments/[id]/route.ts
Normal file
91
src/app/api/crm/settings/user-role-assignments/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
deleteCrmUserRoleAssignment,
|
||||||
|
updateCrmUserRoleAssignment
|
||||||
|
} from '@/features/foundation/crm-role-assignments/server/service';
|
||||||
|
import { PERMISSIONS, ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
const updateAssignmentSchema = z.object({
|
||||||
|
branchScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES),
|
||||||
|
branchScopeIds: z.array(z.string()).default([]),
|
||||||
|
productTypeScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES),
|
||||||
|
productTypeScopeIds: z.array(z.string()).default([]),
|
||||||
|
isPrimary: z.boolean(),
|
||||||
|
isActive: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, props: RouteProps) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmRoleAssignmentManage
|
||||||
|
});
|
||||||
|
const payload = updateAssignmentSchema.parse(await request.json());
|
||||||
|
const { id } = await props.params;
|
||||||
|
const assignment = await updateCrmUserRoleAssignment(
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
id,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'CRM user role assignment updated successfully',
|
||||||
|
assignment
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to update CRM user role assignment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, props: RouteProps) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmRoleAssignmentManage
|
||||||
|
});
|
||||||
|
const { id } = await props.params;
|
||||||
|
await deleteCrmUserRoleAssignment(organization.id, session.user.id, id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'CRM user role assignment deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to delete CRM user role assignment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/app/api/crm/settings/user-role-assignments/route.ts
Normal file
87
src/app/api/crm/settings/user-role-assignments/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
createCrmUserRoleAssignment,
|
||||||
|
listResolvedCrmRoleAssignments
|
||||||
|
} from '@/features/foundation/crm-role-assignments/server/service';
|
||||||
|
import { PERMISSIONS, ROLE_ASSIGNMENT_SCOPE_MODES } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
const assignmentSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
roleProfileId: z.string().min(1),
|
||||||
|
branchScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES),
|
||||||
|
branchScopeIds: z.array(z.string()).default([]),
|
||||||
|
productTypeScopeMode: z.enum(ROLE_ASSIGNMENT_SCOPE_MODES),
|
||||||
|
productTypeScopeIds: z.array(z.string()).default([]),
|
||||||
|
isPrimary: z.boolean(),
|
||||||
|
isActive: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmRoleAssignmentRead
|
||||||
|
});
|
||||||
|
const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'CRM user role assignments loaded successfully',
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to load CRM user role assignments' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmRoleAssignmentManage
|
||||||
|
});
|
||||||
|
const payload = assignmentSchema.parse(await request.json());
|
||||||
|
const assignment = await createCrmUserRoleAssignment(
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'CRM user role assignment created successfully',
|
||||||
|
assignment
|
||||||
|
},
|
||||||
|
{ 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Unable to create CRM user role assignment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import { UserRoleAssignmentsPage } from '@/features/foundation/crm-role-assignments/components/user-role-assignments-page';
|
||||||
|
import { crmUserRoleAssignmentsQueryOptions } from '@/features/foundation/crm-role-assignments/api/queries';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
export default async function CrmUserRoleAssignmentsRoute() {
|
||||||
|
const session = await auth();
|
||||||
|
const canRead =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmRoleAssignmentRead));
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
if (canRead) {
|
||||||
|
void queryClient.prefetchQuery(crmUserRoleAssignmentsQueryOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='User Role Assignments'
|
||||||
|
pageDescription='Assign multiple CRM role profiles to users with branch and product-type scope per assignment.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to CRM user role assignments.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canRead ? (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<UserRoleAssignmentsPage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ type SessionOrganization = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
role: string;
|
role: string;
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
|
businessRoles: string[];
|
||||||
branchScopeIds: string[];
|
branchScopeIds: string[];
|
||||||
productTypeScopeIds: string[];
|
productTypeScopeIds: string[];
|
||||||
ownershipScope: string;
|
ownershipScope: string;
|
||||||
@@ -119,6 +120,7 @@ async function buildUserAccessContext(userId: string) {
|
|||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
businessRole: row.businessRole,
|
businessRole: row.businessRole,
|
||||||
|
businessRoles: resolvedAccess.businessRoles,
|
||||||
branchScopeIds: resolvedAccess.branchScopeIds,
|
branchScopeIds: resolvedAccess.branchScopeIds,
|
||||||
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
||||||
ownershipScope: resolvedAccess.ownershipScope,
|
ownershipScope: resolvedAccess.ownershipScope,
|
||||||
@@ -228,7 +230,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
token.activeOrganizationName = activeMembership?.name ?? null;
|
token.activeOrganizationName = activeMembership?.name ?? null;
|
||||||
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 = activeMembershipAccess?.businessRole ?? activeMembership?.businessRole ?? null;
|
||||||
token.activePermissions = activeMembershipAccess?.permissions ?? [];
|
token.activePermissions = activeMembershipAccess?.permissions ?? [];
|
||||||
token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? [];
|
token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? [];
|
||||||
token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? [];
|
token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? [];
|
||||||
@@ -278,6 +280,7 @@ 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 as { businessRoles?: unknown }).businessRoles) &&
|
||||||
Array.isArray(value.branchScopeIds) &&
|
Array.isArray(value.branchScopeIds) &&
|
||||||
Array.isArray(value.productTypeScopeIds) &&
|
Array.isArray(value.productTypeScopeIds) &&
|
||||||
typeof value.ownershipScope === 'string' &&
|
typeof value.ownershipScope === 'string' &&
|
||||||
|
|||||||
@@ -136,13 +136,21 @@ 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.role.read" },
|
access: { requireOrg: true, permission: "crm.role.assignment.read" },
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Roles & Permissions",
|
title: "Roles & Permissions",
|
||||||
url: "/dashboard/crm/settings/roles",
|
url: "/dashboard/crm/settings/roles",
|
||||||
access: { requireOrg: true, permission: "crm.role.read" },
|
access: { requireOrg: true, permission: "crm.role.read" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "User Role Assignments",
|
||||||
|
url: "/dashboard/crm/settings/user-role-assignments",
|
||||||
|
access: {
|
||||||
|
requireOrg: true,
|
||||||
|
permission: "crm.role.assignment.read",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Master Options",
|
title: "Master Options",
|
||||||
url: "/dashboard/crm/settings/master-options",
|
url: "/dashboard/crm/settings/master-options",
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ export const crmRoleProfiles = pgTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const crmUserRoleAssignments = pgTable(
|
||||||
|
'crm_user_role_assignments',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
roleProfileId: text('role_profile_id').notNull(),
|
||||||
|
branchScopeMode: text('branch_scope_mode').default('inherit').notNull(),
|
||||||
|
branchScopeIds: text('branch_scope_ids').array().default([]).notNull(),
|
||||||
|
productTypeScopeMode: text('product_type_scope_mode').default('inherit').notNull(),
|
||||||
|
productTypeScopeIds: text('product_type_scope_ids').array().default([]).notNull(),
|
||||||
|
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
assignedBy: text('assigned_by').notNull(),
|
||||||
|
assignedAt: timestamp('assigned_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
organizationUserRoleProfileIdx: uniqueIndex('crm_user_role_assignments_org_user_role_idx').on(
|
||||||
|
table.organizationId,
|
||||||
|
table.userId,
|
||||||
|
table.roleProfileId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
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(),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import {
|
||||||
|
createCrmUserRoleAssignment,
|
||||||
|
deleteCrmUserRoleAssignment,
|
||||||
|
updateCrmUserRoleAssignment
|
||||||
|
} from './service';
|
||||||
|
import { crmUserRoleAssignmentQueryKeys } from './queries';
|
||||||
|
|
||||||
|
export const createCrmUserRoleAssignmentMutation = mutationOptions({
|
||||||
|
mutationFn: createCrmUserRoleAssignment,
|
||||||
|
onSettled: async () => {
|
||||||
|
await getQueryClient().invalidateQueries({
|
||||||
|
queryKey: crmUserRoleAssignmentQueryKeys.all
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateCrmUserRoleAssignmentMutation = mutationOptions({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
payload
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: Parameters<typeof updateCrmUserRoleAssignment>[1];
|
||||||
|
}) => updateCrmUserRoleAssignment(id, payload),
|
||||||
|
onSettled: async () => {
|
||||||
|
await getQueryClient().invalidateQueries({
|
||||||
|
queryKey: crmUserRoleAssignmentQueryKeys.all
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteCrmUserRoleAssignmentMutation = mutationOptions({
|
||||||
|
mutationFn: deleteCrmUserRoleAssignment,
|
||||||
|
onSettled: async () => {
|
||||||
|
await getQueryClient().invalidateQueries({
|
||||||
|
queryKey: crmUserRoleAssignmentQueryKeys.all
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
13
src/features/foundation/crm-role-assignments/api/queries.ts
Normal file
13
src/features/foundation/crm-role-assignments/api/queries.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { getCrmUserRoleAssignments } from './service';
|
||||||
|
|
||||||
|
export const crmUserRoleAssignmentQueryKeys = {
|
||||||
|
all: ['crm-user-role-assignments'] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export function crmUserRoleAssignmentsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: crmUserRoleAssignmentQueryKeys.all,
|
||||||
|
queryFn: getCrmUserRoleAssignments
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/features/foundation/crm-role-assignments/api/service.ts
Normal file
42
src/features/foundation/crm-role-assignments/api/service.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
CrmUserRoleAssignmentMutationPayload,
|
||||||
|
CrmUserRoleAssignmentsResponse,
|
||||||
|
CrmUserRoleAssignmentRecord,
|
||||||
|
UpdateCrmUserRoleAssignmentPayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export async function getCrmUserRoleAssignments() {
|
||||||
|
return apiClient<CrmUserRoleAssignmentsResponse>('/crm/settings/user-role-assignments');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCrmUserRoleAssignment(
|
||||||
|
payload: CrmUserRoleAssignmentMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<{ success: boolean; assignment: CrmUserRoleAssignmentRecord }>(
|
||||||
|
'/crm/settings/user-role-assignments',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCrmUserRoleAssignment(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateCrmUserRoleAssignmentPayload
|
||||||
|
) {
|
||||||
|
return apiClient<{ success: boolean; assignment: CrmUserRoleAssignmentRecord }>(
|
||||||
|
`/crm/settings/user-role-assignments/${id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCrmUserRoleAssignment(id: string) {
|
||||||
|
return apiClient<{ success: boolean }>(`/crm/settings/user-role-assignments/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
63
src/features/foundation/crm-role-assignments/api/types.ts
Normal file
63
src/features/foundation/crm-role-assignments/api/types.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CrmRoleAssignmentScopeMode } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
|
export interface CrmUserRoleAssignmentRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
roleProfileId: string;
|
||||||
|
roleProfileCode: string;
|
||||||
|
roleProfileName: string;
|
||||||
|
branchScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
isPrimary: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
assignedBy: string;
|
||||||
|
assignedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmRoleAssignmentReferenceOption {
|
||||||
|
id: string;
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmUserRoleAssignmentsResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
assignments: CrmUserRoleAssignmentRecord[];
|
||||||
|
scopeModes: CrmRoleAssignmentScopeMode[];
|
||||||
|
roleProfiles: Array<{ id: string; code: string; name: string }>;
|
||||||
|
users: Array<{ id: string; name: string; email: string }>;
|
||||||
|
branches: Array<{ id: string; code: string; label: string }>;
|
||||||
|
productTypes: Array<{ id: string; code: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrmUserRoleAssignmentMutationPayload {
|
||||||
|
userId: string;
|
||||||
|
roleProfileId: string;
|
||||||
|
branchScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
isPrimary: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCrmUserRoleAssignmentPayload {
|
||||||
|
branchScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
isPrimary: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
'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 { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
createCrmUserRoleAssignmentMutation,
|
||||||
|
deleteCrmUserRoleAssignmentMutation,
|
||||||
|
updateCrmUserRoleAssignmentMutation
|
||||||
|
} from '../api/mutations';
|
||||||
|
import { crmUserRoleAssignmentsQueryOptions } from '../api/queries';
|
||||||
|
import type {
|
||||||
|
CrmUserRoleAssignmentMutationPayload,
|
||||||
|
CrmUserRoleAssignmentRecord,
|
||||||
|
UpdateCrmUserRoleAssignmentPayload
|
||||||
|
} from '../api/types';
|
||||||
|
|
||||||
|
type AssignmentEditorState = {
|
||||||
|
branchScopeMode: CrmUserRoleAssignmentMutationPayload['branchScopeMode'];
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeMode: CrmUserRoleAssignmentMutationPayload['productTypeScopeMode'];
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
isPrimary: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyCreateState: CrmUserRoleAssignmentMutationPayload = {
|
||||||
|
userId: '',
|
||||||
|
roleProfileId: '',
|
||||||
|
branchScopeMode: 'inherit',
|
||||||
|
branchScopeIds: [],
|
||||||
|
productTypeScopeMode: 'inherit',
|
||||||
|
productTypeScopeIds: [],
|
||||||
|
isPrimary: false,
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function ScopeSelector(props: {
|
||||||
|
label: string;
|
||||||
|
mode: CrmUserRoleAssignmentMutationPayload['branchScopeMode'];
|
||||||
|
ids: string[];
|
||||||
|
options: Array<{ id: string; label: string; code: string }>;
|
||||||
|
onModeChange: (value: CrmUserRoleAssignmentMutationPayload['branchScopeMode']) => void;
|
||||||
|
onIdsChange: (value: string[]) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>{props.label}</Label>
|
||||||
|
<Select value={props.mode} onValueChange={props.onModeChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='inherit'>Inherit role profile</SelectItem>
|
||||||
|
<SelectItem value='all'>All</SelectItem>
|
||||||
|
<SelectItem value='selected'>Selected</SelectItem>
|
||||||
|
<SelectItem value='none'>None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.mode === 'selected' ? (
|
||||||
|
<div className='grid gap-2 rounded-md border p-3 md:grid-cols-2'>
|
||||||
|
{props.options.map((option) => {
|
||||||
|
const checked = props.ids.includes(option.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={option.id} className='flex items-start gap-3 rounded-md border p-3'>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
props.onIdsChange(
|
||||||
|
value === true
|
||||||
|
? [...new Set([...props.ids, option.id])]
|
||||||
|
: props.ids.filter((item) => item !== option.id)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>{option.label}</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>{option.code}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRoleAssignmentsPage() {
|
||||||
|
const { data } = useSuspenseQuery(crmUserRoleAssignmentsQueryOptions());
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(data.assignments[0]?.id ?? null);
|
||||||
|
const [createState, setCreateState] =
|
||||||
|
useState<CrmUserRoleAssignmentMutationPayload>(emptyCreateState);
|
||||||
|
const [editorState, setEditorState] = useState<AssignmentEditorState | null>(null);
|
||||||
|
const selectedAssignment = useMemo(
|
||||||
|
() => data.assignments.find((assignment) => assignment.id === selectedId) ?? null,
|
||||||
|
[data.assignments, selectedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedAssignment) {
|
||||||
|
setEditorState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorState({
|
||||||
|
branchScopeMode: selectedAssignment.branchScopeMode,
|
||||||
|
branchScopeIds: selectedAssignment.branchScopeIds,
|
||||||
|
productTypeScopeMode: selectedAssignment.productTypeScopeMode,
|
||||||
|
productTypeScopeIds: selectedAssignment.productTypeScopeIds,
|
||||||
|
isPrimary: selectedAssignment.isPrimary,
|
||||||
|
isActive: selectedAssignment.isActive
|
||||||
|
});
|
||||||
|
}, [selectedAssignment]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
...createCrmUserRoleAssignmentMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('CRM role assignment created successfully');
|
||||||
|
setCreateState(emptyCreateState);
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Unable to create CRM role assignment'
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
...updateCrmUserRoleAssignmentMutation,
|
||||||
|
onSuccess: () => toast.success('CRM role assignment updated successfully'),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Unable to update CRM role assignment'
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
...deleteCrmUserRoleAssignmentMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('CRM role assignment deleted successfully');
|
||||||
|
setSelectedId(null);
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Unable to delete CRM role assignment'
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createState.userId || !createState.roleProfileId) {
|
||||||
|
toast.error('User and role profile are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMutation.mutateAsync(createState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdate() {
|
||||||
|
if (!selectedAssignment || !editorState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: selectedAssignment.id,
|
||||||
|
payload: editorState satisfies UpdateCrmUserRoleAssignmentPayload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Assign CRM Role</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
One user can hold multiple CRM roles in the same organization. Primary role is for
|
||||||
|
display; effective access unions across active assignments.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-6'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>User</Label>
|
||||||
|
<Select
|
||||||
|
value={createState.userId}
|
||||||
|
onValueChange={(value) => setCreateState({ ...createState, userId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select user' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{data.users.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name} ({user.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Role Profile</Label>
|
||||||
|
<Select
|
||||||
|
value={createState.roleProfileId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setCreateState({ ...createState, roleProfileId: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select role profile' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{data.roleProfiles.map((role) => (
|
||||||
|
<SelectItem key={role.id} value={role.id}>
|
||||||
|
{role.name} ({role.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-2'>
|
||||||
|
<ScopeSelector
|
||||||
|
label='Branch Scope'
|
||||||
|
mode={createState.branchScopeMode}
|
||||||
|
ids={createState.branchScopeIds}
|
||||||
|
options={data.branches}
|
||||||
|
onModeChange={(value) =>
|
||||||
|
setCreateState({
|
||||||
|
...createState,
|
||||||
|
branchScopeMode: value,
|
||||||
|
branchScopeIds: value === 'selected' ? createState.branchScopeIds : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onIdsChange={(value) => setCreateState({ ...createState, branchScopeIds: value })}
|
||||||
|
/>
|
||||||
|
<ScopeSelector
|
||||||
|
label='Product Type Scope'
|
||||||
|
mode={createState.productTypeScopeMode}
|
||||||
|
ids={createState.productTypeScopeIds}
|
||||||
|
options={data.productTypes}
|
||||||
|
onModeChange={(value) =>
|
||||||
|
setCreateState({
|
||||||
|
...createState,
|
||||||
|
productTypeScopeMode: value,
|
||||||
|
productTypeScopeIds: value === 'selected' ? createState.productTypeScopeIds : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onIdsChange={(value) =>
|
||||||
|
setCreateState({ ...createState, productTypeScopeIds: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-6'>
|
||||||
|
<label className='flex items-center gap-3'>
|
||||||
|
<Checkbox
|
||||||
|
checked={createState.isPrimary}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setCreateState({ ...createState, isPrimary: value === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className='text-sm font-medium'>Primary role</span>
|
||||||
|
</label>
|
||||||
|
<label className='flex items-center gap-3'>
|
||||||
|
<Checkbox
|
||||||
|
checked={createState.isActive}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setCreateState({ ...createState, isActive: value === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className='text-sm font-medium'>Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => void handleCreate()} isLoading={createMutation.isPending}>
|
||||||
|
Assign Role
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Existing Assignments</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Active and historical CRM role assignments in this organization.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
{data.assignments.map((assignment) => (
|
||||||
|
<button
|
||||||
|
key={assignment.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedId(assignment.id)}
|
||||||
|
className={`w-full rounded-lg border p-3 text-left transition ${
|
||||||
|
assignment.id === selectedId
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'hover:bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<div className='font-medium'>{assignment.userName}</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
{assignment.isPrimary ? <Badge>Primary</Badge> : null}
|
||||||
|
{!assignment.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground mt-1 text-xs'>{assignment.userEmail}</div>
|
||||||
|
<div className='mt-2 text-sm'>
|
||||||
|
{assignment.roleProfileName} ({assignment.roleProfileCode})
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Assignment Detail</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update scope, activation, and primary-role display for the selected CRM assignment.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-6'>
|
||||||
|
{!selectedAssignment || !editorState ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
Select an assignment to edit.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>User</div>
|
||||||
|
<div className='font-medium'>{selectedAssignment.userName}</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>{selectedAssignment.userEmail}</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Role Profile</div>
|
||||||
|
<div className='font-medium'>{selectedAssignment.roleProfileName}</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{selectedAssignment.roleProfileCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-2'>
|
||||||
|
<ScopeSelector
|
||||||
|
label='Branch Scope'
|
||||||
|
mode={editorState.branchScopeMode}
|
||||||
|
ids={editorState.branchScopeIds}
|
||||||
|
options={data.branches}
|
||||||
|
onModeChange={(value) =>
|
||||||
|
setEditorState({
|
||||||
|
...editorState,
|
||||||
|
branchScopeMode: value,
|
||||||
|
branchScopeIds: value === 'selected' ? editorState.branchScopeIds : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onIdsChange={(value) =>
|
||||||
|
setEditorState({ ...editorState, branchScopeIds: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ScopeSelector
|
||||||
|
label='Product Type Scope'
|
||||||
|
mode={editorState.productTypeScopeMode}
|
||||||
|
ids={editorState.productTypeScopeIds}
|
||||||
|
options={data.productTypes}
|
||||||
|
onModeChange={(value) =>
|
||||||
|
setEditorState({
|
||||||
|
...editorState,
|
||||||
|
productTypeScopeMode: value,
|
||||||
|
productTypeScopeIds:
|
||||||
|
value === 'selected' ? editorState.productTypeScopeIds : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onIdsChange={(value) =>
|
||||||
|
setEditorState({ ...editorState, productTypeScopeIds: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-6'>
|
||||||
|
<label className='flex items-center gap-3'>
|
||||||
|
<Checkbox
|
||||||
|
checked={editorState.isPrimary}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setEditorState({ ...editorState, isPrimary: value === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className='text-sm font-medium'>Primary role</span>
|
||||||
|
</label>
|
||||||
|
<label className='flex items-center gap-3'>
|
||||||
|
<Checkbox
|
||||||
|
checked={editorState.isActive}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setEditorState({ ...editorState, isActive: value === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className='text-sm font-medium'>Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
|
<Button onClick={() => void handleUpdate()} isLoading={updateMutation.isPending}>
|
||||||
|
Save Assignment
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => void deleteMutation.mutateAsync(selectedAssignment.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Remove Assignment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
625
src/features/foundation/crm-role-assignments/server/service.ts
Normal file
625
src/features/foundation/crm-role-assignments/server/service.ts
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmRoleProfiles,
|
||||||
|
crmUserRoleAssignments,
|
||||||
|
memberships,
|
||||||
|
msOptions,
|
||||||
|
users
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import { ensureCrmRoleProfiles } from '@/features/foundation/role-management/server/service';
|
||||||
|
import {
|
||||||
|
ROLE_ASSIGNMENT_SCOPE_MODES,
|
||||||
|
isRoleAssignmentScopeMode,
|
||||||
|
type CrmRoleAssignmentScopeMode
|
||||||
|
} from '@/lib/auth/rbac';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown) {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? [
|
||||||
|
...new Set(
|
||||||
|
value.filter(
|
||||||
|
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectedScopeInvalid(mode: CrmRoleAssignmentScopeMode, values: string[]) {
|
||||||
|
return mode === 'selected' && values.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAssignmentRow(
|
||||||
|
row: typeof crmUserRoleAssignments.$inferSelect,
|
||||||
|
options: {
|
||||||
|
roleProfileCode: string;
|
||||||
|
roleProfileName: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
userId: row.userId,
|
||||||
|
userName: options.userName,
|
||||||
|
userEmail: options.userEmail,
|
||||||
|
roleProfileId: row.roleProfileId,
|
||||||
|
roleProfileCode: options.roleProfileCode,
|
||||||
|
roleProfileName: options.roleProfileName,
|
||||||
|
branchScopeMode: isRoleAssignmentScopeMode(row.branchScopeMode)
|
||||||
|
? row.branchScopeMode
|
||||||
|
: 'inherit',
|
||||||
|
branchScopeIds: normalizeStringArray(row.branchScopeIds),
|
||||||
|
productTypeScopeMode: isRoleAssignmentScopeMode(row.productTypeScopeMode)
|
||||||
|
? row.productTypeScopeMode
|
||||||
|
: 'inherit',
|
||||||
|
productTypeScopeIds: normalizeStringArray(row.productTypeScopeIds),
|
||||||
|
isPrimary: row.isPrimary,
|
||||||
|
isActive: row.isActive,
|
||||||
|
assignedBy: row.assignedBy,
|
||||||
|
assignedAt: row.assignedAt.toISOString(),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePrimaryAssignmentForUser(organizationId: string, userId: string) {
|
||||||
|
const activeAssignments = await db
|
||||||
|
.select({
|
||||||
|
id: crmUserRoleAssignments.id,
|
||||||
|
isPrimary: crmUserRoleAssignments.isPrimary
|
||||||
|
})
|
||||||
|
.from(crmUserRoleAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId),
|
||||||
|
eq(crmUserRoleAssignments.userId, userId),
|
||||||
|
eq(crmUserRoleAssignments.isActive, true),
|
||||||
|
isNull(crmUserRoleAssignments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmUserRoleAssignments.assignedAt));
|
||||||
|
|
||||||
|
if (!activeAssignments.length || activeAssignments.some((item) => item.isPrimary)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
isPrimary: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmUserRoleAssignments.id, activeAssignments[0]!.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPrimaryAssignment(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
isPrimary: boolean
|
||||||
|
) {
|
||||||
|
if (!isPrimary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
isPrimary: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId),
|
||||||
|
eq(crmUserRoleAssignments.userId, userId),
|
||||||
|
isNull(crmUserRoleAssignments.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
isPrimary: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmUserRoleAssignments.id, assignmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCrmRoleAssignmentsBackfillForMembership(
|
||||||
|
organizationId: string,
|
||||||
|
membership: typeof memberships.$inferSelect,
|
||||||
|
actorUserId: string
|
||||||
|
) {
|
||||||
|
await ensureCrmRoleProfiles(organizationId, actorUserId);
|
||||||
|
|
||||||
|
const [roleProfile] = await db
|
||||||
|
.select({
|
||||||
|
id: crmRoleProfiles.id
|
||||||
|
})
|
||||||
|
.from(crmRoleProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmRoleProfiles.organizationId, organizationId),
|
||||||
|
eq(crmRoleProfiles.code, membership.businessRole),
|
||||||
|
isNull(crmRoleProfiles.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!roleProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.crmUserRoleAssignments.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId),
|
||||||
|
eq(crmUserRoleAssignments.userId, membership.userId),
|
||||||
|
eq(crmUserRoleAssignments.roleProfileId, roleProfile.id)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchScopeIds = normalizeStringArray(membership.branchScopeIds);
|
||||||
|
const productTypeScopeIds = normalizeStringArray(membership.productTypeScopeIds);
|
||||||
|
const branchScopeMode: CrmRoleAssignmentScopeMode = branchScopeIds.length ? 'selected' : 'all';
|
||||||
|
const productTypeScopeMode: CrmRoleAssignmentScopeMode = productTypeScopeIds.length
|
||||||
|
? 'selected'
|
||||||
|
: 'all';
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.deletedAt || !existing.isActive) {
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
branchScopeMode,
|
||||||
|
branchScopeIds,
|
||||||
|
productTypeScopeMode,
|
||||||
|
productTypeScopeIds,
|
||||||
|
isPrimary: true,
|
||||||
|
isActive: true,
|
||||||
|
assignedBy: actorUserId,
|
||||||
|
assignedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null
|
||||||
|
})
|
||||||
|
.where(eq(crmUserRoleAssignments.id, existing.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPrimaryAssignment(organizationId, membership.userId, existing.id, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmUserRoleAssignments)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
userId: membership.userId,
|
||||||
|
roleProfileId: roleProfile.id,
|
||||||
|
branchScopeMode,
|
||||||
|
branchScopeIds,
|
||||||
|
productTypeScopeMode,
|
||||||
|
productTypeScopeIds,
|
||||||
|
isPrimary: true,
|
||||||
|
isActive: true,
|
||||||
|
assignedBy: actorUserId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await setPrimaryAssignment(organizationId, membership.userId, created.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCrmRoleAssignmentsBackfillForOrganization(
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string
|
||||||
|
) {
|
||||||
|
const membershipRows = await db.query.memberships.findMany({
|
||||||
|
where: eq(memberships.organizationId, organizationId)
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const membership of membershipRows) {
|
||||||
|
await ensureCrmRoleAssignmentsBackfillForMembership(organizationId, membership, actorUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listResolvedCrmRoleAssignments(
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string
|
||||||
|
) {
|
||||||
|
await ensureCrmRoleAssignmentsBackfillForOrganization(organizationId, actorUserId);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
assignment: crmUserRoleAssignments,
|
||||||
|
roleProfileCode: crmRoleProfiles.code,
|
||||||
|
roleProfileName: crmRoleProfiles.name,
|
||||||
|
userName: users.name,
|
||||||
|
userEmail: users.email
|
||||||
|
})
|
||||||
|
.from(crmUserRoleAssignments)
|
||||||
|
.innerJoin(crmRoleProfiles, eq(crmRoleProfiles.id, crmUserRoleAssignments.roleProfileId))
|
||||||
|
.innerJoin(users, eq(users.id, crmUserRoleAssignments.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId),
|
||||||
|
isNull(crmUserRoleAssignments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(users.name), asc(crmUserRoleAssignments.assignedAt));
|
||||||
|
|
||||||
|
const [branchOptions, productTypeOptions, roleProfiles, orgUsers] = await Promise.all([
|
||||||
|
getUserBranches(),
|
||||||
|
getActiveOptionsByCategory('crm_product_type', { organizationId }),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: crmRoleProfiles.id,
|
||||||
|
code: crmRoleProfiles.code,
|
||||||
|
name: crmRoleProfiles.name
|
||||||
|
})
|
||||||
|
.from(crmRoleProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmRoleProfiles.organizationId, organizationId),
|
||||||
|
eq(crmRoleProfiles.isActive, true),
|
||||||
|
isNull(crmRoleProfiles.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmRoleProfiles.name)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
userId: users.id,
|
||||||
|
name: users.name,
|
||||||
|
email: users.email
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.innerJoin(users, eq(users.id, memberships.userId))
|
||||||
|
.where(eq(memberships.organizationId, organizationId))
|
||||||
|
.orderBy(asc(users.name))
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignments: rows.map((row) =>
|
||||||
|
mapAssignmentRow(row.assignment, {
|
||||||
|
roleProfileCode: row.roleProfileCode,
|
||||||
|
roleProfileName: row.roleProfileName,
|
||||||
|
userName: row.userName,
|
||||||
|
userEmail: row.userEmail
|
||||||
|
})
|
||||||
|
),
|
||||||
|
scopeModes: [...ROLE_ASSIGNMENT_SCOPE_MODES],
|
||||||
|
roleProfiles,
|
||||||
|
users: [...new Map(orgUsers.map((row) => [row.userId, row])).values()].map((row) => ({
|
||||||
|
id: row.userId,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email
|
||||||
|
})),
|
||||||
|
branches: branchOptions.map((branch) => ({
|
||||||
|
id: branch.id,
|
||||||
|
code: branch.code,
|
||||||
|
label: branch.name
|
||||||
|
})),
|
||||||
|
productTypes: productTypeOptions.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
code: option.code,
|
||||||
|
label: option.label
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertCrmUserRoleAssignmentPayload {
|
||||||
|
userId: string;
|
||||||
|
roleProfileId: string;
|
||||||
|
branchScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeMode: CrmRoleAssignmentScopeMode;
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
isPrimary: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAssignmentPayload(
|
||||||
|
organizationId: string,
|
||||||
|
payload: UpsertCrmUserRoleAssignmentPayload
|
||||||
|
) {
|
||||||
|
const [userMembership, roleProfile] = await Promise.all([
|
||||||
|
db.query.memberships.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(memberships.organizationId, organizationId),
|
||||||
|
eq(memberships.userId, payload.userId)
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
db.query.crmRoleProfiles.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(crmRoleProfiles.organizationId, organizationId),
|
||||||
|
eq(crmRoleProfiles.id, payload.roleProfileId),
|
||||||
|
isNull(crmRoleProfiles.deletedAt)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!userMembership) {
|
||||||
|
throw new AuthError('User does not belong to this organization', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roleProfile) {
|
||||||
|
throw new AuthError('CRM role profile not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelectedScopeInvalid(payload.branchScopeMode, payload.branchScopeIds)) {
|
||||||
|
throw new AuthError('Select at least one branch when branch scope mode is selected', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelectedScopeInvalid(payload.productTypeScopeMode, payload.productTypeScopeIds)) {
|
||||||
|
throw new AuthError(
|
||||||
|
'Select at least one product type when product scope mode is selected',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const branches = await getUserBranches();
|
||||||
|
const validBranchIds = new Set(branches.map((branch) => branch.id));
|
||||||
|
const productTypes = await getActiveOptionsByCategory('crm_product_type', { organizationId });
|
||||||
|
const validProductTypeIds = new Set(productTypes.map((option) => option.id));
|
||||||
|
|
||||||
|
if (payload.branchScopeIds.some((id) => !validBranchIds.has(id))) {
|
||||||
|
throw new AuthError('Invalid branch scope selection', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.productTypeScopeIds.some((id) => !validProductTypeIds.has(id))) {
|
||||||
|
throw new AuthError('Invalid product type scope selection', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userMembership,
|
||||||
|
roleProfile
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCrmUserRoleAssignment(
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
payload: UpsertCrmUserRoleAssignmentPayload
|
||||||
|
) {
|
||||||
|
await validateAssignmentPayload(organizationId, payload);
|
||||||
|
|
||||||
|
const duplicate = await db.query.crmUserRoleAssignments.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId),
|
||||||
|
eq(crmUserRoleAssignments.userId, payload.userId),
|
||||||
|
eq(crmUserRoleAssignments.roleProfileId, payload.roleProfileId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let assignmentId = duplicate?.id ?? null;
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
branchScopeMode: payload.branchScopeMode,
|
||||||
|
branchScopeIds: payload.branchScopeIds,
|
||||||
|
productTypeScopeMode: payload.productTypeScopeMode,
|
||||||
|
productTypeScopeIds: payload.productTypeScopeIds,
|
||||||
|
isPrimary: payload.isPrimary,
|
||||||
|
isActive: payload.isActive,
|
||||||
|
assignedBy: actorUserId,
|
||||||
|
assignedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null
|
||||||
|
})
|
||||||
|
.where(eq(crmUserRoleAssignments.id, duplicate.id));
|
||||||
|
} else {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmUserRoleAssignments)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
userId: payload.userId,
|
||||||
|
roleProfileId: payload.roleProfileId,
|
||||||
|
branchScopeMode: payload.branchScopeMode,
|
||||||
|
branchScopeIds: payload.branchScopeIds,
|
||||||
|
productTypeScopeMode: payload.productTypeScopeMode,
|
||||||
|
productTypeScopeIds: payload.productTypeScopeIds,
|
||||||
|
isPrimary: payload.isPrimary,
|
||||||
|
isActive: payload.isActive,
|
||||||
|
assignedBy: actorUserId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
assignmentId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignmentId) {
|
||||||
|
throw new AuthError('Unable to create CRM role assignment', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setPrimaryAssignment(organizationId, payload.userId, assignmentId, payload.isPrimary);
|
||||||
|
await ensurePrimaryAssignmentForUser(organizationId, payload.userId);
|
||||||
|
|
||||||
|
const refreshed = await listResolvedCrmRoleAssignments(organizationId, actorUserId);
|
||||||
|
const assignment = refreshed.assignments.find((item) => item.id === assignmentId);
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
throw new AuthError('CRM role assignment not found after create', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId: actorUserId,
|
||||||
|
entityType: 'crm_user_role_assignment',
|
||||||
|
entityId: assignment.id,
|
||||||
|
action: duplicate ? 'reactivate' : 'create',
|
||||||
|
afterData: assignment
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCrmUserRoleAssignment(
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
payload: Omit<UpsertCrmUserRoleAssignmentPayload, 'userId' | 'roleProfileId'>
|
||||||
|
) {
|
||||||
|
const current = await db.query.crmUserRoleAssignments.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(crmUserRoleAssignments.id, assignmentId),
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current || current.deletedAt) {
|
||||||
|
throw new AuthError('CRM role assignment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateAssignmentPayload(organizationId, {
|
||||||
|
userId: current.userId,
|
||||||
|
roleProfileId: current.roleProfileId,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
branchScopeMode: payload.branchScopeMode,
|
||||||
|
branchScopeIds: payload.branchScopeIds,
|
||||||
|
productTypeScopeMode: payload.productTypeScopeMode,
|
||||||
|
productTypeScopeIds: payload.productTypeScopeIds,
|
||||||
|
isPrimary: payload.isPrimary,
|
||||||
|
isActive: payload.isActive,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmUserRoleAssignments.id, assignmentId));
|
||||||
|
|
||||||
|
await setPrimaryAssignment(organizationId, current.userId, assignmentId, payload.isPrimary);
|
||||||
|
await ensurePrimaryAssignmentForUser(organizationId, current.userId);
|
||||||
|
|
||||||
|
const refreshed = await listResolvedCrmRoleAssignments(organizationId, actorUserId);
|
||||||
|
const assignment = refreshed.assignments.find((item) => item.id === assignmentId);
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
throw new AuthError('CRM role assignment not found after update', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId: actorUserId,
|
||||||
|
entityType: 'crm_user_role_assignment',
|
||||||
|
entityId: assignment.id,
|
||||||
|
action: payload.isActive ? 'update' : 'deactivate',
|
||||||
|
beforeData: current,
|
||||||
|
afterData: assignment
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCrmUserRoleAssignment(
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
assignmentId: string
|
||||||
|
) {
|
||||||
|
const current = await db.query.crmUserRoleAssignments.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(crmUserRoleAssignments.id, assignmentId),
|
||||||
|
eq(crmUserRoleAssignments.organizationId, organizationId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current || current.deletedAt) {
|
||||||
|
throw new AuthError('CRM role assignment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmUserRoleAssignments)
|
||||||
|
.set({
|
||||||
|
isActive: false,
|
||||||
|
isPrimary: false,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmUserRoleAssignments.id, assignmentId));
|
||||||
|
|
||||||
|
await ensurePrimaryAssignmentForUser(organizationId, current.userId);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId: actorUserId,
|
||||||
|
entityType: 'crm_user_role_assignment',
|
||||||
|
entityId: assignmentId,
|
||||||
|
action: 'delete',
|
||||||
|
beforeData: current
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCrmRoleAssignmentsForUser(
|
||||||
|
organizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const data = await listResolvedCrmRoleAssignments(organizationId, actorUserId);
|
||||||
|
|
||||||
|
return data.assignments.filter((assignment) => assignment.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoleAssignmentScopeMode(value: string): CrmRoleAssignmentScopeMode {
|
||||||
|
if (!isRoleAssignmentScopeMode(value)) {
|
||||||
|
throw new AuthError('Invalid role assignment scope mode', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoleAssignmentReferenceSummaries(organizationId: string) {
|
||||||
|
const [roleProfiles, usersInOrganization, branches, productTypes] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: crmRoleProfiles.id,
|
||||||
|
code: crmRoleProfiles.code,
|
||||||
|
name: crmRoleProfiles.name
|
||||||
|
})
|
||||||
|
.from(crmRoleProfiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmRoleProfiles.organizationId, organizationId),
|
||||||
|
eq(crmRoleProfiles.isActive, true),
|
||||||
|
isNull(crmRoleProfiles.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmRoleProfiles.name)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
email: users.email
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.innerJoin(users, eq(users.id, memberships.userId))
|
||||||
|
.where(eq(memberships.organizationId, organizationId))
|
||||||
|
.orderBy(asc(users.name)),
|
||||||
|
getUserBranches(),
|
||||||
|
getActiveOptionsByCategory('crm_product_type', { organizationId })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleProfiles,
|
||||||
|
users: [...new Map(usersInOrganization.map((row) => [row.id, row])).values()],
|
||||||
|
branches: branches.map((branch) => ({
|
||||||
|
id: branch.id,
|
||||||
|
code: branch.code,
|
||||||
|
label: branch.name
|
||||||
|
})),
|
||||||
|
productTypes: productTypes.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
code: option.code,
|
||||||
|
label: option.label
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import { and, eq, isNull } from 'drizzle-orm';
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
import { crmRoleProfiles, memberships } from '@/db/schema';
|
import { crmRoleProfiles, crmUserRoleAssignments, memberships } from '@/db/schema';
|
||||||
|
import { ensureCrmRoleAssignmentsBackfillForMembership } from '@/features/foundation/crm-role-assignments/server/service';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
ALL_PERMISSIONS,
|
ALL_PERMISSIONS,
|
||||||
type CrmApprovalAuthority,
|
type CrmApprovalAuthority,
|
||||||
|
type CrmEffectiveScopeMode,
|
||||||
type CrmOwnershipScope,
|
type CrmOwnershipScope,
|
||||||
|
type CrmRoleAssignmentScopeMode,
|
||||||
type CrmScopeMode,
|
type CrmScopeMode,
|
||||||
getDefaultBusinessRole,
|
getDefaultBusinessRole,
|
||||||
|
getDefaultPermissions,
|
||||||
|
getMembershipBasePermissions,
|
||||||
getRoleProfileDefinition,
|
getRoleProfileDefinition,
|
||||||
isApprovalAuthority,
|
isApprovalAuthority,
|
||||||
isMembershipRole,
|
isMembershipRole,
|
||||||
isOwnershipScope,
|
isOwnershipScope,
|
||||||
|
isRoleAssignmentScopeMode,
|
||||||
isScopeMode,
|
isScopeMode,
|
||||||
resolveEffectivePermissions,
|
|
||||||
type MembershipRole,
|
type MembershipRole,
|
||||||
type Permission,
|
type Permission,
|
||||||
type SystemRole
|
type SystemRole
|
||||||
@@ -21,12 +26,13 @@ import {
|
|||||||
export interface ResolvedCrmAccess {
|
export interface ResolvedCrmAccess {
|
||||||
membershipRole: MembershipRole;
|
membershipRole: MembershipRole;
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
|
businessRoles: string[];
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
branchScopeIds: string[];
|
branchScopeIds: string[];
|
||||||
productTypeScopeIds: string[];
|
productTypeScopeIds: string[];
|
||||||
ownershipScope: CrmOwnershipScope;
|
ownershipScope: CrmOwnershipScope;
|
||||||
branchScopeMode: CrmScopeMode;
|
branchScopeMode: CrmEffectiveScopeMode;
|
||||||
productScopeMode: CrmScopeMode;
|
productScopeMode: CrmEffectiveScopeMode;
|
||||||
approvalAuthority: CrmApprovalAuthority;
|
approvalAuthority: CrmApprovalAuthority;
|
||||||
roleProfileName: string | null;
|
roleProfileName: string | null;
|
||||||
}
|
}
|
||||||
@@ -53,6 +59,153 @@ function normalizeStringArray(value: unknown) {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OWNERSHIP_SCOPE_RANK: Record<CrmOwnershipScope, number> = {
|
||||||
|
monitor: 0,
|
||||||
|
own: 1,
|
||||||
|
team: 2,
|
||||||
|
organization: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPROVAL_AUTHORITY_RANK: Record<CrmApprovalAuthority, number> = {
|
||||||
|
none: 0,
|
||||||
|
manager: 1,
|
||||||
|
department: 2,
|
||||||
|
final: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMostPermissiveOwnershipScope(scopes: CrmOwnershipScope[]) {
|
||||||
|
return scopes.reduce<CrmOwnershipScope>(
|
||||||
|
(current, scope) =>
|
||||||
|
OWNERSHIP_SCOPE_RANK[scope] > OWNERSHIP_SCOPE_RANK[current] ? scope : current,
|
||||||
|
'own'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMostPermissiveApprovalAuthority(authorities: CrmApprovalAuthority[]) {
|
||||||
|
return authorities.reduce<CrmApprovalAuthority>(
|
||||||
|
(current, authority) =>
|
||||||
|
APPROVAL_AUTHORITY_RANK[authority] > APPROVAL_AUTHORITY_RANK[current]
|
||||||
|
? authority
|
||||||
|
: current,
|
||||||
|
'none'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveScopeFromAssignments(input: {
|
||||||
|
assignmentMode: CrmRoleAssignmentScopeMode;
|
||||||
|
assignmentIds: string[];
|
||||||
|
profileMode: CrmScopeMode;
|
||||||
|
}) {
|
||||||
|
if (input.assignmentMode === 'all') {
|
||||||
|
return { mode: 'all' as const, ids: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.assignmentMode === 'none') {
|
||||||
|
return { mode: 'none' as const, ids: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.assignmentMode === 'selected') {
|
||||||
|
return input.assignmentIds.length
|
||||||
|
? { mode: 'assigned' as const, ids: input.assignmentIds }
|
||||||
|
: { mode: 'none' as const, ids: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.profileMode === 'all') {
|
||||||
|
return { mode: 'all' as const, ids: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.assignmentIds.length
|
||||||
|
? { mode: 'assigned' as const, ids: input.assignmentIds }
|
||||||
|
: { mode: 'none' as const, ids: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineEffectiveScopes(
|
||||||
|
scopes: Array<{ mode: CrmEffectiveScopeMode; ids: string[] }>
|
||||||
|
): { mode: CrmEffectiveScopeMode; ids: string[] } {
|
||||||
|
if (scopes.some((scope) => scope.mode === 'all')) {
|
||||||
|
return { mode: 'all', ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedIds = [...new Set(scopes.flatMap((scope) => scope.ids))];
|
||||||
|
|
||||||
|
if (mergedIds.length > 0) {
|
||||||
|
return {
|
||||||
|
mode: 'assigned',
|
||||||
|
ids: mergedIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: 'none', ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegacyFallbackAccess(input: {
|
||||||
|
systemRole: SystemRole;
|
||||||
|
membership: typeof memberships.$inferSelect;
|
||||||
|
membershipRole: MembershipRole;
|
||||||
|
businessRole: string;
|
||||||
|
roleProfile: typeof crmRoleProfiles.$inferSelect | undefined;
|
||||||
|
}) {
|
||||||
|
const defaultDefinition = getRoleProfileDefinition(input.businessRole);
|
||||||
|
const ownershipScope: CrmOwnershipScope = isOwnershipScope(input.roleProfile?.ownershipScope ?? '')
|
||||||
|
? (input.roleProfile!.ownershipScope as CrmOwnershipScope)
|
||||||
|
: (defaultDefinition?.ownershipScope ?? 'own');
|
||||||
|
const branchScopeMode =
|
||||||
|
normalizeStringArray(input.membership.branchScopeIds).length > 0
|
||||||
|
? 'assigned'
|
||||||
|
: (isScopeMode(input.roleProfile?.branchScopeMode ?? '')
|
||||||
|
? (input.roleProfile!.branchScopeMode as CrmScopeMode)
|
||||||
|
: (defaultDefinition?.branchScopeMode ?? 'assigned'));
|
||||||
|
const productScopeMode =
|
||||||
|
normalizeStringArray(input.membership.productTypeScopeIds).length > 0
|
||||||
|
? 'assigned'
|
||||||
|
: (isScopeMode(input.roleProfile?.productScopeMode ?? '')
|
||||||
|
? (input.roleProfile!.productScopeMode as CrmScopeMode)
|
||||||
|
: (defaultDefinition?.productScopeMode ?? 'assigned'));
|
||||||
|
const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority(
|
||||||
|
input.roleProfile?.approvalAuthority ?? ''
|
||||||
|
)
|
||||||
|
? (input.roleProfile!.approvalAuthority as CrmApprovalAuthority)
|
||||||
|
: (defaultDefinition?.approvalAuthority ?? 'none');
|
||||||
|
const permissions =
|
||||||
|
input.systemRole === 'super_admin'
|
||||||
|
? ALL_PERMISSIONS
|
||||||
|
: [
|
||||||
|
...new Set([
|
||||||
|
...getDefaultPermissions(input.membershipRole, input.businessRole),
|
||||||
|
...(input.roleProfile?.permissions?.filter(
|
||||||
|
(value): value is Permission => typeof value === 'string'
|
||||||
|
) ?? []),
|
||||||
|
...(input.membership.permissions?.filter(
|
||||||
|
(value): value is Permission => typeof value === 'string'
|
||||||
|
) ?? [])
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
membershipRole: input.membershipRole,
|
||||||
|
businessRole: input.businessRole,
|
||||||
|
businessRoles: [input.businessRole],
|
||||||
|
permissions,
|
||||||
|
branchScopeIds: normalizeStringArray(input.membership.branchScopeIds),
|
||||||
|
productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds),
|
||||||
|
ownershipScope,
|
||||||
|
branchScopeMode:
|
||||||
|
branchScopeMode === 'all'
|
||||||
|
? 'all'
|
||||||
|
: normalizeStringArray(input.membership.branchScopeIds).length > 0
|
||||||
|
? 'assigned'
|
||||||
|
: 'none',
|
||||||
|
productScopeMode:
|
||||||
|
productScopeMode === 'all'
|
||||||
|
? 'all'
|
||||||
|
: normalizeStringArray(input.membership.productTypeScopeIds).length > 0
|
||||||
|
? 'assigned'
|
||||||
|
: 'none',
|
||||||
|
approvalAuthority,
|
||||||
|
roleProfileName: input.roleProfile?.name ?? defaultDefinition?.name ?? null
|
||||||
|
} satisfies ResolvedCrmAccess;
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveCrmMembershipAccess(input: {
|
export async function resolveCrmMembershipAccess(input: {
|
||||||
systemRole: SystemRole;
|
systemRole: SystemRole;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -62,6 +215,7 @@ export async function resolveCrmMembershipAccess(input: {
|
|||||||
return {
|
return {
|
||||||
membershipRole: 'admin',
|
membershipRole: 'admin',
|
||||||
businessRole: input.membership.businessRole,
|
businessRole: input.membership.businessRole,
|
||||||
|
businessRoles: [input.membership.businessRole],
|
||||||
permissions: ALL_PERMISSIONS,
|
permissions: ALL_PERMISSIONS,
|
||||||
branchScopeIds: [],
|
branchScopeIds: [],
|
||||||
productTypeScopeIds: [],
|
productTypeScopeIds: [],
|
||||||
@@ -77,6 +231,12 @@ export async function resolveCrmMembershipAccess(input: {
|
|||||||
? input.membership.role
|
? input.membership.role
|
||||||
: 'user';
|
: 'user';
|
||||||
const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole);
|
const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole);
|
||||||
|
await ensureCrmRoleAssignmentsBackfillForMembership(
|
||||||
|
input.organizationId,
|
||||||
|
input.membership,
|
||||||
|
input.membership.userId
|
||||||
|
);
|
||||||
|
|
||||||
const [roleProfile] = await db
|
const [roleProfile] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmRoleProfiles)
|
.from(crmRoleProfiles)
|
||||||
@@ -89,40 +249,103 @@ export async function resolveCrmMembershipAccess(input: {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const defaultDefinition = getRoleProfileDefinition(businessRole);
|
const assignmentRows = await db
|
||||||
|
.select({
|
||||||
|
assignment: crmUserRoleAssignments,
|
||||||
|
profileCode: crmRoleProfiles.code,
|
||||||
|
profileName: crmRoleProfiles.name,
|
||||||
|
profilePermissions: crmRoleProfiles.permissions,
|
||||||
|
profileOwnershipScope: crmRoleProfiles.ownershipScope,
|
||||||
|
profileBranchScopeMode: crmRoleProfiles.branchScopeMode,
|
||||||
|
profileProductScopeMode: crmRoleProfiles.productScopeMode,
|
||||||
|
profileApprovalAuthority: crmRoleProfiles.approvalAuthority
|
||||||
|
})
|
||||||
|
.from(crmUserRoleAssignments)
|
||||||
|
.innerJoin(crmRoleProfiles, eq(crmRoleProfiles.id, crmUserRoleAssignments.roleProfileId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmUserRoleAssignments.organizationId, input.organizationId),
|
||||||
|
eq(crmUserRoleAssignments.userId, input.membership.userId),
|
||||||
|
eq(crmUserRoleAssignments.isActive, true),
|
||||||
|
isNull(crmUserRoleAssignments.deletedAt),
|
||||||
|
isNull(crmRoleProfiles.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const ownershipScope: CrmOwnershipScope = isOwnershipScope(roleProfile?.ownershipScope ?? '')
|
if (!assignmentRows.length) {
|
||||||
? (roleProfile.ownershipScope as CrmOwnershipScope)
|
return buildLegacyFallbackAccess({
|
||||||
: (defaultDefinition?.ownershipScope ?? 'own');
|
systemRole: input.systemRole,
|
||||||
const branchScopeMode: CrmScopeMode = isScopeMode(roleProfile?.branchScopeMode ?? '')
|
membership: input.membership,
|
||||||
? (roleProfile.branchScopeMode as CrmScopeMode)
|
membershipRole,
|
||||||
: (defaultDefinition?.branchScopeMode ?? 'assigned');
|
businessRole,
|
||||||
const productScopeMode: CrmScopeMode = isScopeMode(roleProfile?.productScopeMode ?? '')
|
roleProfile
|
||||||
? (roleProfile.productScopeMode as CrmScopeMode)
|
});
|
||||||
: (defaultDefinition?.productScopeMode ?? 'assigned');
|
}
|
||||||
const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority(
|
|
||||||
roleProfile?.approvalAuthority ?? ''
|
const primaryAssignment =
|
||||||
)
|
assignmentRows.find((row) => row.assignment.isPrimary) ?? assignmentRows[0];
|
||||||
? (roleProfile.approvalAuthority as CrmApprovalAuthority)
|
const permissions = [
|
||||||
: (defaultDefinition?.approvalAuthority ?? 'none');
|
...new Set([
|
||||||
|
...getMembershipBasePermissions(membershipRole),
|
||||||
|
...assignmentRows.flatMap((row) =>
|
||||||
|
(row.profilePermissions ?? []).filter(
|
||||||
|
(value): value is Permission => typeof value === 'string'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
...(input.membership.permissions?.filter(
|
||||||
|
(value): value is Permission => typeof value === 'string'
|
||||||
|
) ?? [])
|
||||||
|
])
|
||||||
|
];
|
||||||
|
const ownershipScopes = assignmentRows.map((row) =>
|
||||||
|
isOwnershipScope(row.profileOwnershipScope)
|
||||||
|
? (row.profileOwnershipScope as CrmOwnershipScope)
|
||||||
|
: 'own'
|
||||||
|
);
|
||||||
|
const approvalAuthorities = assignmentRows.map((row) =>
|
||||||
|
isApprovalAuthority(row.profileApprovalAuthority)
|
||||||
|
? (row.profileApprovalAuthority as CrmApprovalAuthority)
|
||||||
|
: 'none'
|
||||||
|
);
|
||||||
|
const branchScope = combineEffectiveScopes(
|
||||||
|
assignmentRows.map((row) =>
|
||||||
|
resolveEffectiveScopeFromAssignments({
|
||||||
|
assignmentMode: isRoleAssignmentScopeMode(row.assignment.branchScopeMode)
|
||||||
|
? row.assignment.branchScopeMode
|
||||||
|
: 'inherit',
|
||||||
|
assignmentIds: normalizeStringArray(row.assignment.branchScopeIds),
|
||||||
|
profileMode: isScopeMode(row.profileBranchScopeMode)
|
||||||
|
? (row.profileBranchScopeMode as CrmScopeMode)
|
||||||
|
: 'assigned'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const productScope = combineEffectiveScopes(
|
||||||
|
assignmentRows.map((row) =>
|
||||||
|
resolveEffectiveScopeFromAssignments({
|
||||||
|
assignmentMode: isRoleAssignmentScopeMode(row.assignment.productTypeScopeMode)
|
||||||
|
? row.assignment.productTypeScopeMode
|
||||||
|
: 'inherit',
|
||||||
|
assignmentIds: normalizeStringArray(row.assignment.productTypeScopeIds),
|
||||||
|
profileMode: isScopeMode(row.profileProductScopeMode)
|
||||||
|
? (row.profileProductScopeMode as CrmScopeMode)
|
||||||
|
: 'assigned'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
membershipRole,
|
membershipRole,
|
||||||
businessRole,
|
businessRole: primaryAssignment?.profileCode ?? businessRole,
|
||||||
permissions: resolveEffectivePermissions({
|
businessRoles: assignmentRows.map((row) => row.profileCode),
|
||||||
systemRole: input.systemRole,
|
permissions,
|
||||||
membershipRole,
|
branchScopeIds: branchScope.ids,
|
||||||
businessRole,
|
productTypeScopeIds: productScope.ids,
|
||||||
rolePermissions: roleProfile?.permissions ?? defaultDefinition?.permissions ?? [],
|
ownershipScope: getMostPermissiveOwnershipScope(ownershipScopes),
|
||||||
directPermissions: input.membership.permissions
|
branchScopeMode: branchScope.mode,
|
||||||
}),
|
productScopeMode: productScope.mode,
|
||||||
branchScopeIds: normalizeStringArray(input.membership.branchScopeIds),
|
approvalAuthority: getMostPermissiveApprovalAuthority(approvalAuthorities),
|
||||||
productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds),
|
roleProfileName: primaryAssignment?.profileName ?? roleProfile?.name ?? null
|
||||||
ownershipScope,
|
|
||||||
branchScopeMode,
|
|
||||||
productScopeMode,
|
|
||||||
approvalAuthority,
|
|
||||||
roleProfileName: roleProfile?.name ?? defaultDefinition?.name ?? null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +354,14 @@ export function hasBranchScopeAccess(access: BranchScopedAccess, branchId?: stri
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (access.branchScopeMode === 'all' || access.branchScopeIds.length === 0) {
|
if (access.branchScopeMode === 'all') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (access.branchScopeMode === 'none' || access.branchScopeIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return access.branchScopeIds.includes(branchId);
|
return access.branchScopeIds.includes(branchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +370,13 @@ export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (access.productScopeMode === 'all' || access.productTypeScopeIds.length === 0) {
|
if (access.productScopeMode === 'all') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (access.productScopeMode === 'none' || access.productTypeScopeIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return access.productTypeScopeIds.includes(productTypeId);
|
return access.productTypeScopeIds.includes(productTypeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const BUSINESS_ROLES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] as const;
|
export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] as const;
|
||||||
export const SCOPE_MODES = ['all', 'assigned'] as const;
|
export const SCOPE_MODES = ['all', 'assigned'] as const;
|
||||||
|
export const ROLE_ASSIGNMENT_SCOPE_MODES = ['all', 'selected', 'none', 'inherit'] as const;
|
||||||
export const APPROVAL_AUTHORITIES = ['none', 'manager', 'department', 'final'] 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];
|
||||||
@@ -18,6 +19,8 @@ export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number];
|
|||||||
export type BusinessRole = string;
|
export type BusinessRole = string;
|
||||||
export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[number];
|
export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[number];
|
||||||
export type CrmScopeMode = (typeof SCOPE_MODES)[number];
|
export type CrmScopeMode = (typeof SCOPE_MODES)[number];
|
||||||
|
export type CrmRoleAssignmentScopeMode = (typeof ROLE_ASSIGNMENT_SCOPE_MODES)[number];
|
||||||
|
export type CrmEffectiveScopeMode = 'all' | 'assigned' | 'none';
|
||||||
export type CrmApprovalAuthority = (typeof APPROVAL_AUTHORITIES)[number];
|
export type CrmApprovalAuthority = (typeof APPROVAL_AUTHORITIES)[number];
|
||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
@@ -91,6 +94,11 @@ export const PERMISSIONS = {
|
|||||||
crmRoleUpdate: 'crm.role.update',
|
crmRoleUpdate: 'crm.role.update',
|
||||||
crmRoleDelete: 'crm.role.delete',
|
crmRoleDelete: 'crm.role.delete',
|
||||||
crmRoleAssign: 'crm.role.assign',
|
crmRoleAssign: 'crm.role.assign',
|
||||||
|
crmRoleAssignmentRead: 'crm.role.assignment.read',
|
||||||
|
crmRoleAssignmentCreate: 'crm.role.assignment.create',
|
||||||
|
crmRoleAssignmentUpdate: 'crm.role.assignment.update',
|
||||||
|
crmRoleAssignmentDelete: 'crm.role.assignment.delete',
|
||||||
|
crmRoleAssignmentManage: 'crm.role.assignment.manage',
|
||||||
crmMasterOptionRead: 'crm.master_option.read',
|
crmMasterOptionRead: 'crm.master_option.read',
|
||||||
crmMasterOptionCreate: 'crm.master_option.create',
|
crmMasterOptionCreate: 'crm.master_option.create',
|
||||||
crmMasterOptionUpdate: 'crm.master_option.update',
|
crmMasterOptionUpdate: 'crm.master_option.update',
|
||||||
@@ -249,6 +257,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalReturn,
|
PERMISSIONS.crmApprovalReturn,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -280,6 +289,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalReturn,
|
PERMISSIONS.crmApprovalReturn,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -311,6 +321,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalReturn,
|
PERMISSIONS.crmApprovalReturn,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -333,6 +344,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmRoleUpdate,
|
PERMISSIONS.crmRoleUpdate,
|
||||||
PERMISSIONS.crmRoleDelete,
|
PERMISSIONS.crmRoleDelete,
|
||||||
PERMISSIONS.crmRoleAssign,
|
PERMISSIONS.crmRoleAssign,
|
||||||
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
|
PERMISSIONS.crmRoleAssignmentCreate,
|
||||||
|
PERMISSIONS.crmRoleAssignmentUpdate,
|
||||||
|
PERMISSIONS.crmRoleAssignmentDelete,
|
||||||
|
PERMISSIONS.crmRoleAssignmentManage,
|
||||||
PERMISSIONS.crmMasterOptionRead,
|
PERMISSIONS.crmMasterOptionRead,
|
||||||
PERMISSIONS.crmMasterOptionCreate,
|
PERMISSIONS.crmMasterOptionCreate,
|
||||||
PERMISSIONS.crmMasterOptionUpdate,
|
PERMISSIONS.crmMasterOptionUpdate,
|
||||||
@@ -438,6 +454,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmRoleUpdate, label: 'Update roles' },
|
{ key: PERMISSIONS.crmRoleUpdate, label: 'Update roles' },
|
||||||
{ key: PERMISSIONS.crmRoleDelete, label: 'Delete roles' },
|
{ key: PERMISSIONS.crmRoleDelete, label: 'Delete roles' },
|
||||||
{ key: PERMISSIONS.crmRoleAssign, label: 'Assign role scopes' },
|
{ key: PERMISSIONS.crmRoleAssign, label: 'Assign role scopes' },
|
||||||
|
{ key: PERMISSIONS.crmRoleAssignmentRead, label: 'Read user role assignments' },
|
||||||
|
{ key: PERMISSIONS.crmRoleAssignmentCreate, label: 'Create user role assignments' },
|
||||||
|
{ key: PERMISSIONS.crmRoleAssignmentUpdate, label: 'Update user role assignments' },
|
||||||
|
{ key: PERMISSIONS.crmRoleAssignmentDelete, label: 'Delete user role assignments' },
|
||||||
|
{ key: PERMISSIONS.crmRoleAssignmentManage, label: 'Manage user role assignments' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' },
|
{ key: PERMISSIONS.crmMasterOptionRead, label: 'Read master options' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
|
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
|
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
|
||||||
@@ -473,7 +494,7 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
|
|
||||||
export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as Permission[];
|
export const ALL_PERMISSIONS = Object.values(PERMISSIONS) as Permission[];
|
||||||
|
|
||||||
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
export function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||||
if (role === 'admin') {
|
if (role === 'admin') {
|
||||||
return [
|
return [
|
||||||
PERMISSIONS.productsRead,
|
PERMISSIONS.productsRead,
|
||||||
@@ -569,6 +590,10 @@ export function isScopeMode(value: string): value is CrmScopeMode {
|
|||||||
return SCOPE_MODES.includes(value as CrmScopeMode);
|
return SCOPE_MODES.includes(value as CrmScopeMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRoleAssignmentScopeMode(value: string): value is CrmRoleAssignmentScopeMode {
|
||||||
|
return ROLE_ASSIGNMENT_SCOPE_MODES.includes(value as CrmRoleAssignmentScopeMode);
|
||||||
|
}
|
||||||
|
|
||||||
export function isApprovalAuthority(value: string): value is CrmApprovalAuthority {
|
export function isApprovalAuthority(value: string): value is CrmApprovalAuthority {
|
||||||
return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority);
|
return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority);
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/types/next-auth.d.ts
vendored
8
src/types/next-auth.d.ts
vendored
@@ -20,6 +20,10 @@ declare module 'next-auth' {
|
|||||||
slug: string;
|
slug: string;
|
||||||
role: string;
|
role: string;
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
|
businessRoles: string[];
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
ownershipScope: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -52,6 +56,10 @@ declare module 'next-auth/jwt' {
|
|||||||
slug: string;
|
slug: string;
|
||||||
role: string;
|
role: string;
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
|
businessRoles: string[];
|
||||||
|
branchScopeIds: string[];
|
||||||
|
productTypeScopeIds: string[];
|
||||||
|
ownershipScope: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
Reference in New Issue
Block a user