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

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, "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
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; 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' &&

View File

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

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', { export const products = pgTable('products', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(), id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
organizationId: text('organization_id').notNull(), organizationId: text('organization_id').notNull(),

View File

@@ -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 { 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({
const ownershipScope: CrmOwnershipScope = isOwnershipScope(roleProfile?.ownershipScope ?? '') assignment: crmUserRoleAssignments,
? (roleProfile.ownershipScope as CrmOwnershipScope) profileCode: crmRoleProfiles.code,
: (defaultDefinition?.ownershipScope ?? 'own'); profileName: crmRoleProfiles.name,
const branchScopeMode: CrmScopeMode = isScopeMode(roleProfile?.branchScopeMode ?? '') profilePermissions: crmRoleProfiles.permissions,
? (roleProfile.branchScopeMode as CrmScopeMode) profileOwnershipScope: crmRoleProfiles.ownershipScope,
: (defaultDefinition?.branchScopeMode ?? 'assigned'); profileBranchScopeMode: crmRoleProfiles.branchScopeMode,
const productScopeMode: CrmScopeMode = isScopeMode(roleProfile?.productScopeMode ?? '') profileProductScopeMode: crmRoleProfiles.productScopeMode,
? (roleProfile.productScopeMode as CrmScopeMode) profileApprovalAuthority: crmRoleProfiles.approvalAuthority
: (defaultDefinition?.productScopeMode ?? 'assigned'); })
const approvalAuthority: CrmApprovalAuthority = isApprovalAuthority( .from(crmUserRoleAssignments)
roleProfile?.approvalAuthority ?? '' .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)
) )
? (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 { 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);
} }

View File

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

View File

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