This commit is contained in:
phaichayon
2026-06-22 10:59:31 +07:00
parent b154a8de33
commit 771ebfc308
23 changed files with 6113 additions and 39 deletions

View 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

View File

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

View File

@@ -365,6 +365,19 @@ Future:
- define whether mixed-role users see own, team, or organization scope by default
- 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
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.

View 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");

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,13 @@
"when": 1782097963376,
"tag": "0013_great_nicolaos",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1782099743528,
"tag": "0014_bored_valkyrie",
"breakpoints": true
}
]
}

575
plans/task-l.1.md Normal file
View 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

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

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

View File

@@ -0,0 +1,39 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { 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>
);
}

View File

@@ -27,6 +27,7 @@ type SessionOrganization = {
slug: string;
role: string;
businessRole: string;
businessRoles: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
@@ -119,6 +120,7 @@ async function buildUserAccessContext(userId: string) {
slug: row.slug,
role: row.role,
businessRole: row.businessRole,
businessRoles: resolvedAccess.businessRoles,
branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope,
@@ -228,7 +230,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.activeOrganizationName = activeMembership?.name ?? null;
token.activeOrganizationPlan = activeMembership?.plan ?? null;
token.activeMembershipRole = activeMembership?.role ?? null;
token.activeBusinessRole = activeMembership?.businessRole ?? null;
token.activeBusinessRole = activeMembershipAccess?.businessRole ?? activeMembership?.businessRole ?? null;
token.activePermissions = activeMembershipAccess?.permissions ?? [];
token.activeBranchScopeIds = activeMembershipAccess?.branchScopeIds ?? [];
token.activeProductTypeScopeIds = activeMembershipAccess?.productTypeScopeIds ?? [];
@@ -278,6 +280,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
typeof value.slug === 'string' &&
typeof value.role === 'string' &&
typeof value.businessRole === 'string' &&
Array.isArray((value as { businessRoles?: unknown }).businessRoles) &&
Array.isArray(value.branchScopeIds) &&
Array.isArray(value.productTypeScopeIds) &&
typeof value.ownershipScope === 'string' &&

View File

@@ -136,13 +136,21 @@ export const navGroups: NavGroup[] = [
url: "/dashboard/crm/settings/master-options",
icon: "settings",
isActive: false,
access: { requireOrg: true, permission: "crm.role.read" },
access: { requireOrg: true, permission: "crm.role.assignment.read" },
items: [
{
title: "Roles & Permissions",
url: "/dashboard/crm/settings/roles",
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",
url: "/dashboard/crm/settings/master-options",

View File

@@ -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', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
organizationId: text('organization_id').notNull(),

View File

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

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

View 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'
});
}

View 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;
}

View File

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

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

View File

@@ -1,18 +1,23 @@
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 {
ALL_PERMISSIONS,
type CrmApprovalAuthority,
type CrmEffectiveScopeMode,
type CrmOwnershipScope,
type CrmRoleAssignmentScopeMode,
type CrmScopeMode,
getDefaultBusinessRole,
getDefaultPermissions,
getMembershipBasePermissions,
getRoleProfileDefinition,
isApprovalAuthority,
isMembershipRole,
isOwnershipScope,
isRoleAssignmentScopeMode,
isScopeMode,
resolveEffectivePermissions,
type MembershipRole,
type Permission,
type SystemRole
@@ -21,12 +26,13 @@ import {
export interface ResolvedCrmAccess {
membershipRole: MembershipRole;
businessRole: string;
businessRoles: string[];
permissions: Permission[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: CrmOwnershipScope;
branchScopeMode: CrmScopeMode;
productScopeMode: CrmScopeMode;
branchScopeMode: CrmEffectiveScopeMode;
productScopeMode: CrmEffectiveScopeMode;
approvalAuthority: CrmApprovalAuthority;
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: {
systemRole: SystemRole;
organizationId: string;
@@ -62,6 +215,7 @@ export async function resolveCrmMembershipAccess(input: {
return {
membershipRole: 'admin',
businessRole: input.membership.businessRole,
businessRoles: [input.membership.businessRole],
permissions: ALL_PERMISSIONS,
branchScopeIds: [],
productTypeScopeIds: [],
@@ -77,6 +231,12 @@ export async function resolveCrmMembershipAccess(input: {
? input.membership.role
: 'user';
const businessRole = input.membership.businessRole || getDefaultBusinessRole(membershipRole);
await ensureCrmRoleAssignmentsBackfillForMembership(
input.organizationId,
input.membership,
input.membership.userId
);
const [roleProfile] = await db
.select()
.from(crmRoleProfiles)
@@ -89,40 +249,103 @@ export async function resolveCrmMembershipAccess(input: {
)
)
.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 ?? '')
? (roleProfile.ownershipScope as CrmOwnershipScope)
: (defaultDefinition?.ownershipScope ?? 'own');
const branchScopeMode: CrmScopeMode = isScopeMode(roleProfile?.branchScopeMode ?? '')
? (roleProfile.branchScopeMode as CrmScopeMode)
: (defaultDefinition?.branchScopeMode ?? 'assigned');
const productScopeMode: CrmScopeMode = isScopeMode(roleProfile?.productScopeMode ?? '')
? (roleProfile.productScopeMode as CrmScopeMode)
: (defaultDefinition?.productScopeMode ?? 'assigned');
const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority(
roleProfile?.approvalAuthority ?? ''
)
? (roleProfile.approvalAuthority as CrmApprovalAuthority)
: (defaultDefinition?.approvalAuthority ?? 'none');
if (!assignmentRows.length) {
return buildLegacyFallbackAccess({
systemRole: input.systemRole,
membership: input.membership,
membershipRole,
businessRole,
roleProfile
});
}
const primaryAssignment =
assignmentRows.find((row) => row.assignment.isPrimary) ?? assignmentRows[0];
const permissions = [
...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 {
membershipRole,
businessRole,
permissions: resolveEffectivePermissions({
systemRole: input.systemRole,
membershipRole,
businessRole,
rolePermissions: roleProfile?.permissions ?? defaultDefinition?.permissions ?? [],
directPermissions: input.membership.permissions
}),
branchScopeIds: normalizeStringArray(input.membership.branchScopeIds),
productTypeScopeIds: normalizeStringArray(input.membership.productTypeScopeIds),
ownershipScope,
branchScopeMode,
productScopeMode,
approvalAuthority,
roleProfileName: roleProfile?.name ?? defaultDefinition?.name ?? null
businessRole: primaryAssignment?.profileCode ?? businessRole,
businessRoles: assignmentRows.map((row) => row.profileCode),
permissions,
branchScopeIds: branchScope.ids,
productTypeScopeIds: productScope.ids,
ownershipScope: getMostPermissiveOwnershipScope(ownershipScopes),
branchScopeMode: branchScope.mode,
productScopeMode: productScope.mode,
approvalAuthority: getMostPermissiveApprovalAuthority(approvalAuthorities),
roleProfileName: primaryAssignment?.profileName ?? roleProfile?.name ?? null
};
}
@@ -131,10 +354,14 @@ export function hasBranchScopeAccess(access: BranchScopedAccess, branchId?: stri
return true;
}
if (access.branchScopeMode === 'all' || access.branchScopeIds.length === 0) {
if (access.branchScopeMode === 'all') {
return true;
}
if (access.branchScopeMode === 'none' || access.branchScopeIds.length === 0) {
return false;
}
return access.branchScopeIds.includes(branchId);
}
@@ -143,9 +370,13 @@ export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId
return true;
}
if (access.productScopeMode === 'all' || access.productTypeScopeIds.length === 0) {
if (access.productScopeMode === 'all') {
return true;
}
if (access.productScopeMode === 'none' || access.productTypeScopeIds.length === 0) {
return false;
}
return access.productTypeScopeIds.includes(productTypeId);
}

View File

@@ -11,6 +11,7 @@ export const BUSINESS_ROLES = [
] as const;
export const OWNERSHIP_SCOPES = ['own', 'team', 'organization', 'monitor'] 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 type SystemRole = (typeof SYSTEM_ROLES)[number];
@@ -18,6 +19,8 @@ export type MembershipRole = (typeof MEMBERSHIP_ROLES)[number];
export type BusinessRole = string;
export type CrmOwnershipScope = (typeof OWNERSHIP_SCOPES)[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 const PERMISSIONS = {
@@ -91,6 +94,11 @@ export const PERMISSIONS = {
crmRoleUpdate: 'crm.role.update',
crmRoleDelete: 'crm.role.delete',
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',
crmMasterOptionCreate: 'crm.master_option.create',
crmMasterOptionUpdate: 'crm.master_option.update',
@@ -249,6 +257,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -280,6 +289,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -311,6 +321,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview,
@@ -333,6 +344,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmRoleUpdate,
PERMISSIONS.crmRoleDelete,
PERMISSIONS.crmRoleAssign,
PERMISSIONS.crmRoleAssignmentRead,
PERMISSIONS.crmRoleAssignmentCreate,
PERMISSIONS.crmRoleAssignmentUpdate,
PERMISSIONS.crmRoleAssignmentDelete,
PERMISSIONS.crmRoleAssignmentManage,
PERMISSIONS.crmMasterOptionRead,
PERMISSIONS.crmMasterOptionCreate,
PERMISSIONS.crmMasterOptionUpdate,
@@ -438,6 +454,11 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmRoleUpdate, label: 'Update roles' },
{ key: PERMISSIONS.crmRoleDelete, label: 'Delete roles' },
{ 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.crmMasterOptionCreate, label: 'Create 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[];
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
export function getMembershipBasePermissions(role: MembershipRole): Permission[] {
if (role === 'admin') {
return [
PERMISSIONS.productsRead,
@@ -569,6 +590,10 @@ export function isScopeMode(value: string): value is 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 {
return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority);
}

View File

@@ -20,6 +20,10 @@ declare module 'next-auth' {
slug: string;
role: string;
businessRole: string;
businessRoles: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
plan: string;
imageUrl: string | null;
}>;
@@ -52,6 +56,10 @@ declare module 'next-auth/jwt' {
slug: string;
role: string;
businessRole: string;
businessRoles: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
plan: string;
imageUrl: string | null;
}>;