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