task-l.2
This commit is contained in:
@@ -23,7 +23,7 @@ type RouteProps = {
|
||||
export async function PATCH(request: NextRequest, props: RouteProps) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmRoleAssignmentManage
|
||||
permission: PERMISSIONS.crmUserRoleAssignmentManage
|
||||
});
|
||||
const payload = updateAssignmentSchema.parse(await request.json());
|
||||
const { id } = await props.params;
|
||||
@@ -65,7 +65,7 @@ export async function PATCH(request: NextRequest, props: RouteProps) {
|
||||
export async function DELETE(_request: NextRequest, props: RouteProps) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmRoleAssignmentManage
|
||||
permission: PERMISSIONS.crmUserRoleAssignmentManage
|
||||
});
|
||||
const { id } = await props.params;
|
||||
await deleteCrmUserRoleAssignment(organization.id, session.user.id, id);
|
||||
|
||||
@@ -21,7 +21,7 @@ const assignmentSchema = z.object({
|
||||
export async function GET() {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmRoleAssignmentRead
|
||||
permission: PERMISSIONS.crmUserRoleAssignmentRead
|
||||
});
|
||||
const data = await listResolvedCrmRoleAssignments(organization.id, session.user.id);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function GET() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmRoleAssignmentManage
|
||||
permission: PERMISSIONS.crmUserRoleAssignmentManage
|
||||
});
|
||||
const payload = assignmentSchema.parse(await request.json());
|
||||
const assignment = await createCrmUserRoleAssignment(
|
||||
|
||||
53
src/app/api/users/[id]/effective-access-preview/route.ts
Normal file
53
src/app/api/users/[id]/effective-access-preview/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import {
|
||||
assertManageableTargetUser,
|
||||
buildUserResponseModels,
|
||||
requireUserManagementAccess
|
||||
} from '@/app/api/users/_lib';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
||||
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
|
||||
const [user] = await buildUserResponseModels({
|
||||
scopedUserIds: [id],
|
||||
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await auditAction({
|
||||
organizationId: session.user.activeOrganizationId ?? user.organizations[0]?.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'effective_access_preview',
|
||||
entityId: id,
|
||||
action: 'preview',
|
||||
afterData: {
|
||||
userId: user.id,
|
||||
crmAccessSummaries: user.crmAccessSummaries
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Effective access preview loaded successfully',
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to preview effective access' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { hash } from 'bcryptjs';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { memberships, users } from '@/db/schema';
|
||||
import { crmUserRoleAssignments, memberships, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import {
|
||||
assertManageableTargetUser,
|
||||
buildMembershipValues,
|
||||
buildUserResponseModels,
|
||||
parseUserMutationPayload,
|
||||
requireUserManagementAccess,
|
||||
syncCrmRoleAssignmentsForUser,
|
||||
validateManagedOrganizations
|
||||
} from '../_lib';
|
||||
|
||||
@@ -21,7 +23,12 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
||||
const payload = parseUserMutationPayload(await request.json());
|
||||
const targetUser = await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
|
||||
|
||||
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
|
||||
await validateManagedOrganizations(
|
||||
payload.memberships,
|
||||
payload.crmRoleAssignments,
|
||||
manageableOrganizationIds,
|
||||
isSuperAdmin
|
||||
);
|
||||
|
||||
if (!isSuperAdmin && payload.systemRole !== 'user') {
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
@@ -66,7 +73,18 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
||||
await db.update(users).set(updateValues).where(eq(users.id, id));
|
||||
|
||||
await db.delete(memberships).where(eq(memberships.userId, id));
|
||||
await db.insert(memberships).values(buildMembershipValues(id, payload.memberships));
|
||||
const membershipValues = await buildMembershipValues(
|
||||
id,
|
||||
payload.memberships,
|
||||
payload.crmRoleAssignments
|
||||
);
|
||||
await db.insert(memberships).values(membershipValues);
|
||||
await syncCrmRoleAssignmentsForUser(
|
||||
id,
|
||||
session.user.id,
|
||||
payload.memberships,
|
||||
payload.crmRoleAssignments
|
||||
);
|
||||
|
||||
if (
|
||||
session.user.id === id &&
|
||||
@@ -97,6 +115,35 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
||||
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
|
||||
const [user] = await buildUserResponseModels({
|
||||
scopedUserIds: [id],
|
||||
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'User loaded successfully',
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load user' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
@@ -108,6 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
|
||||
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
|
||||
|
||||
await db.delete(crmUserRoleAssignments).where(eq(crmUserRoleAssignments.userId, id));
|
||||
await db.delete(memberships).where(eq(memberships.userId, id));
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { memberships, organizations, users } from '@/db/schema';
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import {
|
||||
crmRoleProfiles,
|
||||
crmUserRoleAssignments,
|
||||
memberships,
|
||||
organizations,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import {
|
||||
createCrmUserRoleAssignment,
|
||||
deleteCrmUserRoleAssignment,
|
||||
updateCrmUserRoleAssignment
|
||||
} from '@/features/foundation/crm-role-assignments/server/service';
|
||||
import {
|
||||
type BusinessRole,
|
||||
getDefaultBusinessRole,
|
||||
getDefaultPermissions,
|
||||
isBusinessRole,
|
||||
isMembershipRole,
|
||||
isRoleAssignmentScopeMode,
|
||||
isSystemRole
|
||||
} from '@/lib/auth/rbac';
|
||||
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
|
||||
import { AuthError, requireSession } from '@/lib/auth/session';
|
||||
import { db } from '@/lib/db';
|
||||
import type {
|
||||
User,
|
||||
UserCrmRoleAssignment,
|
||||
UserCrmAccessSummary
|
||||
} from '@/features/users/api/types';
|
||||
|
||||
export type UserMembershipInput = {
|
||||
organizationId: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole: BusinessRole;
|
||||
};
|
||||
|
||||
export type UserCrmRoleAssignmentInput = {
|
||||
id?: string;
|
||||
organizationId: string;
|
||||
roleProfileId: string;
|
||||
branchScopeMode: 'all' | 'selected' | 'none' | 'inherit';
|
||||
branchScopeIds: string[];
|
||||
productTypeScopeMode: 'all' | 'selected' | 'none' | 'inherit';
|
||||
productTypeScopeIds: string[];
|
||||
isPrimary: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type UserMutationInput = {
|
||||
@@ -23,6 +50,7 @@ export type UserMutationInput = {
|
||||
password?: string;
|
||||
systemRole: 'super_admin' | 'user';
|
||||
memberships: UserMembershipInput[];
|
||||
crmRoleAssignments: UserCrmRoleAssignmentInput[];
|
||||
};
|
||||
|
||||
export async function requireUserManagementAccess() {
|
||||
@@ -64,6 +92,9 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
const membershipsPayload = Array.isArray(payload.memberships) ? payload.memberships : [];
|
||||
const crmRoleAssignmentsPayload = Array.isArray(payload.crmRoleAssignments)
|
||||
? payload.crmRoleAssignments
|
||||
: [];
|
||||
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
|
||||
const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
|
||||
const password = typeof payload.password === 'string' ? payload.password.trim() : undefined;
|
||||
@@ -83,25 +114,65 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
||||
const organizationId =
|
||||
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
||||
const membershipRole = typeof value.membershipRole === 'string' ? value.membershipRole : '';
|
||||
const businessRole =
|
||||
typeof value.businessRole === 'string'
|
||||
? value.businessRole
|
||||
: getDefaultBusinessRole(
|
||||
membershipRole === 'admin' || membershipRole === 'user' ? membershipRole : 'user'
|
||||
);
|
||||
|
||||
if (!organizationId || !isMembershipRole(membershipRole) || !isBusinessRole(businessRole)) {
|
||||
if (!organizationId || !isMembershipRole(membershipRole)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
membershipRole,
|
||||
businessRole
|
||||
membershipRole
|
||||
};
|
||||
})
|
||||
.filter((membership): membership is UserMembershipInput => membership !== null);
|
||||
|
||||
const crmRoleAssignments = crmRoleAssignmentsPayload
|
||||
.map((assignment) => {
|
||||
if (!assignment || typeof assignment !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = assignment as Record<string, unknown>;
|
||||
const organizationId =
|
||||
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
||||
const roleProfileId =
|
||||
typeof value.roleProfileId === 'string' ? value.roleProfileId.trim() : '';
|
||||
const id = typeof value.id === 'string' ? value.id.trim() : undefined;
|
||||
const branchScopeMode = typeof value.branchScopeMode === 'string' ? value.branchScopeMode : '';
|
||||
const productTypeScopeMode =
|
||||
typeof value.productTypeScopeMode === 'string' ? value.productTypeScopeMode : '';
|
||||
const branchScopeIds = Array.isArray(value.branchScopeIds)
|
||||
? value.branchScopeIds.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
const productTypeScopeIds = Array.isArray(value.productTypeScopeIds)
|
||||
? value.productTypeScopeIds.filter((item): item is string => typeof item === 'string')
|
||||
: [];
|
||||
const isPrimary = value.isPrimary === true;
|
||||
const isActive = value.isActive !== false;
|
||||
|
||||
if (
|
||||
!organizationId ||
|
||||
!roleProfileId ||
|
||||
!isRoleAssignmentScopeMode(branchScopeMode) ||
|
||||
!isRoleAssignmentScopeMode(productTypeScopeMode)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(id ? { id } : {}),
|
||||
organizationId,
|
||||
roleProfileId,
|
||||
branchScopeMode,
|
||||
branchScopeIds,
|
||||
productTypeScopeMode,
|
||||
productTypeScopeIds,
|
||||
isPrimary,
|
||||
isActive
|
||||
} satisfies UserCrmRoleAssignmentInput;
|
||||
})
|
||||
.filter((assignment): assignment is UserCrmRoleAssignmentInput => assignment !== null);
|
||||
|
||||
const uniqueMemberships = [
|
||||
...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()
|
||||
];
|
||||
@@ -115,16 +186,23 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
||||
email,
|
||||
password,
|
||||
systemRole: systemRoleValue,
|
||||
memberships: uniqueMemberships
|
||||
memberships: uniqueMemberships,
|
||||
crmRoleAssignments
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateManagedOrganizations(
|
||||
targetMemberships: UserMembershipInput[],
|
||||
crmRoleAssignments: UserCrmRoleAssignmentInput[],
|
||||
manageableOrganizationIds: string[],
|
||||
isSuperAdmin: boolean
|
||||
) {
|
||||
const organizationIds = targetMemberships.map((membership) => membership.organizationId);
|
||||
const organizationIds = [
|
||||
...new Set([
|
||||
...targetMemberships.map((membership) => membership.organizationId),
|
||||
...crmRoleAssignments.map((assignment) => assignment.organizationId)
|
||||
])
|
||||
];
|
||||
|
||||
const organizationRows = await db
|
||||
.select({
|
||||
@@ -143,6 +221,16 @@ export async function validateManagedOrganizations(
|
||||
) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
const membershipOrganizationIds = new Set(
|
||||
targetMemberships.map((membership) => membership.organizationId)
|
||||
);
|
||||
|
||||
if (
|
||||
crmRoleAssignments.some((assignment) => !membershipOrganizationIds.has(assignment.organizationId))
|
||||
) {
|
||||
throw new Error('CRM role assignments must belong to a selected organization membership');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserMembershipRows(userId: string) {
|
||||
@@ -184,13 +272,379 @@ export async function assertManageableTargetUser(
|
||||
return user;
|
||||
}
|
||||
|
||||
export function buildMembershipValues(userId: string, targetMemberships: UserMembershipInput[]) {
|
||||
function getLegacyBusinessRoleForMembership(input: {
|
||||
membership: UserMembershipInput;
|
||||
crmRoleAssignments: UserCrmRoleAssignmentInput[];
|
||||
roleProfilesById: Map<string, { code: string }>;
|
||||
}) {
|
||||
const orgAssignments = input.crmRoleAssignments.filter(
|
||||
(assignment) => assignment.organizationId === input.membership.organizationId && assignment.isActive
|
||||
);
|
||||
const primaryAssignment =
|
||||
orgAssignments.find((assignment) => assignment.isPrimary) ?? orgAssignments[0] ?? null;
|
||||
|
||||
if (primaryAssignment) {
|
||||
return (
|
||||
input.roleProfilesById.get(primaryAssignment.roleProfileId)?.code ??
|
||||
getDefaultBusinessRole(input.membership.membershipRole)
|
||||
);
|
||||
}
|
||||
|
||||
return getDefaultBusinessRole(input.membership.membershipRole);
|
||||
}
|
||||
|
||||
export async function buildMembershipValues(
|
||||
userId: string,
|
||||
targetMemberships: UserMembershipInput[],
|
||||
crmRoleAssignments: UserCrmRoleAssignmentInput[]
|
||||
) {
|
||||
const roleProfileIds = [...new Set(crmRoleAssignments.map((assignment) => assignment.roleProfileId))];
|
||||
const roleProfiles = roleProfileIds.length
|
||||
? await db
|
||||
.select({
|
||||
id: crmRoleProfiles.id,
|
||||
code: crmRoleProfiles.code
|
||||
})
|
||||
.from(crmRoleProfiles)
|
||||
.where(and(inArray(crmRoleProfiles.id, roleProfileIds), isNull(crmRoleProfiles.deletedAt)))
|
||||
: [];
|
||||
const roleProfilesById = new Map(roleProfiles.map((profile) => [profile.id, profile]));
|
||||
|
||||
return targetMemberships.map((membership) => ({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
role: membership.membershipRole,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: getDefaultPermissions(membership.membershipRole, membership.businessRole)
|
||||
businessRole: getLegacyBusinessRoleForMembership({
|
||||
membership,
|
||||
crmRoleAssignments,
|
||||
roleProfilesById
|
||||
}),
|
||||
permissions: getDefaultPermissions(
|
||||
membership.membershipRole,
|
||||
getLegacyBusinessRoleForMembership({
|
||||
membership,
|
||||
crmRoleAssignments,
|
||||
roleProfilesById
|
||||
})
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncCrmRoleAssignmentsForUser(
|
||||
userId: string,
|
||||
actorUserId: string,
|
||||
membershipsForUser: UserMembershipInput[],
|
||||
crmRoleAssignments: UserCrmRoleAssignmentInput[]
|
||||
) {
|
||||
const membershipOrganizationIds = membershipsForUser.map((membership) => membership.organizationId);
|
||||
const existingAssignments = await db
|
||||
.select()
|
||||
.from(crmUserRoleAssignments)
|
||||
.where(eq(crmUserRoleAssignments.userId, userId));
|
||||
const assignmentsForManagedMemberships = existingAssignments.filter((assignment) =>
|
||||
membershipOrganizationIds.includes(assignment.organizationId)
|
||||
);
|
||||
const payloadIds = new Set(crmRoleAssignments.map((assignment) => assignment.id).filter(Boolean));
|
||||
|
||||
for (const assignment of assignmentsForManagedMemberships) {
|
||||
if (payloadIds.has(assignment.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stillReferenced = crmRoleAssignments.some(
|
||||
(item) =>
|
||||
item.organizationId === assignment.organizationId &&
|
||||
item.roleProfileId === assignment.roleProfileId &&
|
||||
!item.id
|
||||
);
|
||||
|
||||
if (!stillReferenced) {
|
||||
await deleteCrmUserRoleAssignment(assignment.organizationId, actorUserId, assignment.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const assignment of crmRoleAssignments) {
|
||||
if (assignment.id) {
|
||||
await updateCrmUserRoleAssignment(assignment.organizationId, actorUserId, assignment.id, {
|
||||
branchScopeMode: assignment.branchScopeMode,
|
||||
branchScopeIds: assignment.branchScopeIds,
|
||||
productTypeScopeMode: assignment.productTypeScopeMode,
|
||||
productTypeScopeIds: assignment.productTypeScopeIds,
|
||||
isPrimary: assignment.isPrimary,
|
||||
isActive: assignment.isActive
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await createCrmUserRoleAssignment(assignment.organizationId, actorUserId, {
|
||||
userId,
|
||||
roleProfileId: assignment.roleProfileId,
|
||||
branchScopeMode: assignment.branchScopeMode,
|
||||
branchScopeIds: assignment.branchScopeIds,
|
||||
productTypeScopeMode: assignment.productTypeScopeMode,
|
||||
productTypeScopeIds: assignment.productTypeScopeIds,
|
||||
isPrimary: assignment.isPrimary,
|
||||
isActive: assignment.isActive
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeScope(mode: UserCrmAccessSummary['branchScopeMode'], ids: string[]) {
|
||||
if (mode === 'all') {
|
||||
return 'All';
|
||||
}
|
||||
|
||||
if (mode === 'none' || ids.length === 0) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
return ids.length <= 2 ? ids.join(' + ') : `${ids.slice(0, 2).join(' + ')} +${ids.length - 2}`;
|
||||
}
|
||||
|
||||
export async function buildUserResponseModels(params: {
|
||||
scopedOrganizationIds?: string[];
|
||||
scopedUserIds?: string[];
|
||||
}) {
|
||||
const membershipRows = await db
|
||||
.select({
|
||||
membershipId: memberships.id,
|
||||
userId: memberships.userId,
|
||||
organizationId: memberships.organizationId,
|
||||
role: memberships.role,
|
||||
businessRole: memberships.businessRole,
|
||||
permissions: memberships.permissions,
|
||||
branchScopeIds: memberships.branchScopeIds,
|
||||
productTypeScopeIds: memberships.productTypeScopeIds,
|
||||
createdAt: memberships.createdAt,
|
||||
updatedAt: memberships.updatedAt,
|
||||
organizationName: organizations.name
|
||||
})
|
||||
.from(memberships)
|
||||
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
|
||||
.where(
|
||||
and(
|
||||
...(params.scopedOrganizationIds?.length
|
||||
? [inArray(memberships.organizationId, params.scopedOrganizationIds)]
|
||||
: []),
|
||||
...(params.scopedUserIds?.length ? [inArray(memberships.userId, params.scopedUserIds)] : [])
|
||||
)
|
||||
);
|
||||
|
||||
if (!membershipRows.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const userIds = [...new Set(membershipRows.map((row) => row.userId))];
|
||||
const organizationIds = [...new Set(membershipRows.map((row) => row.organizationId))];
|
||||
const [userRows, assignmentRows, roleProfileRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
systemRole: users.systemRole,
|
||||
activeOrganizationId: users.activeOrganizationId
|
||||
})
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds)),
|
||||
db
|
||||
.select()
|
||||
.from(crmUserRoleAssignments)
|
||||
.where(
|
||||
and(
|
||||
inArray(crmUserRoleAssignments.userId, userIds),
|
||||
inArray(crmUserRoleAssignments.organizationId, organizationIds),
|
||||
isNull(crmUserRoleAssignments.deletedAt)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
id: crmRoleProfiles.id,
|
||||
code: crmRoleProfiles.code,
|
||||
name: crmRoleProfiles.name
|
||||
})
|
||||
.from(crmRoleProfiles)
|
||||
.where(
|
||||
and(
|
||||
inArray(crmRoleProfiles.organizationId, organizationIds),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
)
|
||||
]);
|
||||
const userMap = new Map(userRows.map((row) => [row.id, row]));
|
||||
const roleProfileMap = new Map(roleProfileRows.map((row) => [row.id, row]));
|
||||
const assignmentsByUserOrg = new Map<string, UserCrmRoleAssignment[]>();
|
||||
|
||||
for (const assignment of assignmentRows) {
|
||||
const roleProfile = roleProfileMap.get(assignment.roleProfileId);
|
||||
if (!roleProfile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${assignment.userId}:${assignment.organizationId}`;
|
||||
const current = assignmentsByUserOrg.get(key) ?? [];
|
||||
current.push({
|
||||
id: assignment.id,
|
||||
organizationId: assignment.organizationId,
|
||||
roleProfileId: assignment.roleProfileId,
|
||||
roleProfileCode: roleProfile.code,
|
||||
roleProfileName: roleProfile.name,
|
||||
branchScopeMode: isRoleAssignmentScopeMode(assignment.branchScopeMode)
|
||||
? assignment.branchScopeMode
|
||||
: 'inherit',
|
||||
branchScopeIds: assignment.branchScopeIds ?? [],
|
||||
productTypeScopeMode: isRoleAssignmentScopeMode(assignment.productTypeScopeMode)
|
||||
? assignment.productTypeScopeMode
|
||||
: 'inherit',
|
||||
productTypeScopeIds: assignment.productTypeScopeIds ?? [],
|
||||
isPrimary: assignment.isPrimary,
|
||||
isActive: assignment.isActive
|
||||
});
|
||||
assignmentsByUserOrg.set(key, current);
|
||||
}
|
||||
|
||||
const usersMap = new Map<string, User>();
|
||||
|
||||
for (const membershipRow of membershipRows) {
|
||||
const dbUser = userMap.get(membershipRow.userId);
|
||||
|
||||
if (!dbUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedAccess = await resolveCrmMembershipAccess({
|
||||
systemRole: dbUser.systemRole === 'super_admin' ? 'super_admin' : 'user',
|
||||
organizationId: membershipRow.organizationId,
|
||||
membership: {
|
||||
id: membershipRow.membershipId,
|
||||
userId: membershipRow.userId,
|
||||
organizationId: membershipRow.organizationId,
|
||||
role: membershipRow.role,
|
||||
businessRole: membershipRow.businessRole,
|
||||
permissions: membershipRow.permissions,
|
||||
branchScopeIds: membershipRow.branchScopeIds,
|
||||
productTypeScopeIds: membershipRow.productTypeScopeIds,
|
||||
createdAt: membershipRow.createdAt,
|
||||
updatedAt: membershipRow.updatedAt
|
||||
}
|
||||
});
|
||||
const userKey = membershipRow.userId;
|
||||
const existing = usersMap.get(userKey);
|
||||
const assignments =
|
||||
assignmentsByUserOrg.get(`${membershipRow.userId}:${membershipRow.organizationId}`) ?? [];
|
||||
const accessSummary: UserCrmAccessSummary = {
|
||||
organizationId: membershipRow.organizationId,
|
||||
organizationName: membershipRow.organizationName,
|
||||
primaryCrmRole: resolvedAccess.businessRole,
|
||||
primaryCrmRoleLabel:
|
||||
assignments.find((assignment) => assignment.isPrimary)?.roleProfileName ??
|
||||
assignments[0]?.roleProfileName ??
|
||||
resolvedAccess.roleProfileName,
|
||||
roleCount: assignments.filter((assignment) => assignment.isActive).length,
|
||||
permissions: resolvedAccess.permissions,
|
||||
ownershipScope: resolvedAccess.ownershipScope,
|
||||
branchScopeMode: resolvedAccess.branchScopeMode,
|
||||
branchScopeIds: resolvedAccess.branchScopeIds,
|
||||
productScopeMode: resolvedAccess.productScopeMode,
|
||||
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
||||
approvalAuthority: resolvedAccess.approvalAuthority,
|
||||
usedLegacyFallback: assignments.length === 0
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.crmRoleAssignments ??= [];
|
||||
existing.crmAccessSummaries ??= [];
|
||||
existing.organizations.push({
|
||||
id: membershipRow.organizationId,
|
||||
name: membershipRow.organizationName,
|
||||
role: membershipRow.role === 'admin' ? 'admin' : 'user'
|
||||
});
|
||||
existing.memberships.push({
|
||||
organizationId: membershipRow.organizationId,
|
||||
organizationName: membershipRow.organizationName,
|
||||
membershipRole: membershipRow.role === 'admin' ? 'admin' : 'user',
|
||||
businessRole: membershipRow.businessRole,
|
||||
isLegacyBusinessRoleDeprecated: assignments.length > 0
|
||||
});
|
||||
existing.crmRoleAssignments.push(...assignments);
|
||||
existing.crmAccessSummaries.push(accessSummary);
|
||||
continue;
|
||||
}
|
||||
|
||||
usersMap.set(userKey, {
|
||||
id: dbUser.id,
|
||||
name: dbUser.name,
|
||||
email: dbUser.email,
|
||||
systemRole: dbUser.systemRole === 'super_admin' ? 'super_admin' : 'user',
|
||||
activeMembershipRole:
|
||||
dbUser.activeOrganizationId === membershipRow.organizationId && membershipRow.role === 'admin'
|
||||
? 'admin'
|
||||
: dbUser.activeOrganizationId === membershipRow.organizationId
|
||||
? 'user'
|
||||
: null,
|
||||
activeOrganizationId: dbUser.activeOrganizationId,
|
||||
organizations: [
|
||||
{
|
||||
id: membershipRow.organizationId,
|
||||
name: membershipRow.organizationName,
|
||||
role: membershipRow.role === 'admin' ? 'admin' : 'user'
|
||||
}
|
||||
],
|
||||
memberships: [
|
||||
{
|
||||
organizationId: membershipRow.organizationId,
|
||||
organizationName: membershipRow.organizationName,
|
||||
membershipRole: membershipRow.role === 'admin' ? 'admin' : 'user',
|
||||
businessRole: membershipRow.businessRole,
|
||||
isLegacyBusinessRoleDeprecated: assignments.length > 0
|
||||
}
|
||||
],
|
||||
crmRoleAssignments: [...assignments],
|
||||
crmAccessSummaries: [accessSummary],
|
||||
primaryCrmRole: null,
|
||||
crmRoleCount: 0,
|
||||
branchScopeSummary: 'None',
|
||||
productScopeSummary: 'None',
|
||||
hasLegacyBusinessRoleFallback: assignments.length === 0
|
||||
});
|
||||
}
|
||||
|
||||
return [...usersMap.values()].map((user) => {
|
||||
const crmAccessSummaries = user.crmAccessSummaries ?? [];
|
||||
const crmRoleAssignments = user.crmRoleAssignments ?? [];
|
||||
const primaryAccess =
|
||||
crmAccessSummaries.find(
|
||||
(summary) => summary.organizationId === user.activeOrganizationId
|
||||
) ?? crmAccessSummaries[0];
|
||||
|
||||
return {
|
||||
...user,
|
||||
organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
memberships: user.memberships.toSorted((a, b) =>
|
||||
a.organizationName.localeCompare(b.organizationName)
|
||||
),
|
||||
crmRoleAssignments: crmRoleAssignments.toSorted((a, b) => {
|
||||
if (a.isPrimary !== b.isPrimary) {
|
||||
return a.isPrimary ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.roleProfileName.localeCompare(b.roleProfileName);
|
||||
}),
|
||||
crmAccessSummaries: crmAccessSummaries.toSorted((a, b) =>
|
||||
a.organizationName.localeCompare(b.organizationName)
|
||||
),
|
||||
primaryCrmRole: primaryAccess?.primaryCrmRoleLabel ?? primaryAccess?.primaryCrmRole ?? null,
|
||||
crmRoleCount: crmRoleAssignments.filter((assignment) => assignment.isActive).length,
|
||||
branchScopeSummary: primaryAccess
|
||||
? summarizeScope(primaryAccess.branchScopeMode, primaryAccess.branchScopeIds)
|
||||
: 'None',
|
||||
productScopeSummary: primaryAccess
|
||||
? summarizeScope(primaryAccess.productScopeMode, primaryAccess.productTypeScopeIds)
|
||||
: 'None',
|
||||
hasLegacyBusinessRoleFallback: crmAccessSummaries.some(
|
||||
(summary) => summary.usedLegacyFallback
|
||||
)
|
||||
} satisfies User;
|
||||
});
|
||||
}
|
||||
|
||||
105
src/app/api/users/crm-role-assignment-reference/route.ts
Normal file
105
src/app/api/users/crm-role-assignment-reference/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { crmRoleProfiles, memberships, msOptions } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { requireUserManagementAccess } from '../_lib';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
||||
const requestedOrganizationIds = request.nextUrl.searchParams
|
||||
.get('organizationIds')
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
const organizationIds = requestedOrganizationIds.length
|
||||
? requestedOrganizationIds
|
||||
: manageableOrganizationIds;
|
||||
|
||||
if (
|
||||
!isSuperAdmin &&
|
||||
organizationIds.some((organizationId) => !manageableOrganizationIds.includes(organizationId))
|
||||
) {
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const [roleProfiles, branches, productTypes] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
organizationId: crmRoleProfiles.organizationId,
|
||||
id: crmRoleProfiles.id,
|
||||
code: crmRoleProfiles.code,
|
||||
name: crmRoleProfiles.name
|
||||
})
|
||||
.from(crmRoleProfiles)
|
||||
.where(
|
||||
and(
|
||||
inArray(crmRoleProfiles.organizationId, organizationIds),
|
||||
eq(crmRoleProfiles.isActive, true),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmRoleProfiles.name)),
|
||||
db
|
||||
.select({
|
||||
organizationId: msOptions.organizationId,
|
||||
id: msOptions.id,
|
||||
code: msOptions.code,
|
||||
label: msOptions.label
|
||||
})
|
||||
.from(msOptions)
|
||||
.where(
|
||||
and(
|
||||
inArray(msOptions.organizationId, organizationIds),
|
||||
eq(msOptions.category, 'crm_branch'),
|
||||
eq(msOptions.isActive, true),
|
||||
isNull(msOptions.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(msOptions.label)),
|
||||
db
|
||||
.select({
|
||||
organizationId: msOptions.organizationId,
|
||||
id: msOptions.id,
|
||||
code: msOptions.code,
|
||||
label: msOptions.label
|
||||
})
|
||||
.from(msOptions)
|
||||
.where(
|
||||
and(
|
||||
inArray(msOptions.organizationId, organizationIds),
|
||||
eq(msOptions.category, 'crm_product_type'),
|
||||
eq(msOptions.isActive, true),
|
||||
isNull(msOptions.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(msOptions.label))
|
||||
]);
|
||||
|
||||
const membershipsByOrganization = await db
|
||||
.select({
|
||||
organizationId: memberships.organizationId,
|
||||
userId: memberships.userId
|
||||
})
|
||||
.from(memberships)
|
||||
.where(inArray(memberships.organizationId, organizationIds));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
roleProfiles,
|
||||
branches,
|
||||
productTypes,
|
||||
organizationUserIds: membershipsByOrganization
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Unable to load CRM role assignment reference data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,100 +3,17 @@ import { asc, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { memberships, organizations, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { isBusinessRole } from '@/lib/auth/rbac';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import {
|
||||
buildMembershipValues,
|
||||
buildUserResponseModels,
|
||||
parseUserMutationPayload,
|
||||
requireUserManagementAccess,
|
||||
syncCrmRoleAssignmentsForUser,
|
||||
validateManagedOrganizations
|
||||
} from './_lib';
|
||||
|
||||
type UserRow = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
systemRole: string;
|
||||
activeOrganizationId: string | null;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
};
|
||||
|
||||
function formatUsers(rows: UserRow[]) {
|
||||
const usersMap = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
systemRole: 'super_admin' | 'user';
|
||||
activeMembershipRole: 'admin' | 'user' | null;
|
||||
activeOrganizationId: string | null;
|
||||
organizations: Array<{ id: string; name: string; role: 'admin' | 'user' }>;
|
||||
memberships: Array<{
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole: string;
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = usersMap.get(row.userId);
|
||||
const organization = {
|
||||
id: row.organizationId,
|
||||
name: row.organizationName,
|
||||
role: row.membershipRole === 'admin' ? 'admin' : 'user'
|
||||
} as const;
|
||||
const membership = {
|
||||
organizationId: row.organizationId,
|
||||
organizationName: row.organizationName,
|
||||
membershipRole: organization.role,
|
||||
businessRole: isBusinessRole(row.businessRole) ? row.businessRole : 'sales_support'
|
||||
} as const;
|
||||
|
||||
if (existing) {
|
||||
if (!existing.organizations.some((item) => item.id === organization.id)) {
|
||||
existing.organizations.push(organization);
|
||||
}
|
||||
|
||||
if (!existing.memberships.some((item) => item.organizationId === membership.organizationId)) {
|
||||
existing.memberships.push(membership);
|
||||
}
|
||||
|
||||
if (row.organizationId === existing.activeOrganizationId) {
|
||||
existing.activeMembershipRole = organization.role;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
usersMap.set(row.userId, {
|
||||
id: row.userId,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
systemRole: row.systemRole === 'super_admin' ? 'super_admin' : 'user',
|
||||
activeMembershipRole:
|
||||
row.organizationId === row.activeOrganizationId ? organization.role : null,
|
||||
activeOrganizationId: row.activeOrganizationId,
|
||||
organizations: [organization],
|
||||
memberships: [membership]
|
||||
});
|
||||
}
|
||||
|
||||
return [...usersMap.values()].map((user) => ({
|
||||
...user,
|
||||
organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
memberships: user.memberships.toSorted((a, b) =>
|
||||
a.organizationName.localeCompare(b.organizationName)
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: string) {
|
||||
function applyRoleFilter(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, roles?: string) {
|
||||
if (!roles) {
|
||||
return usersList;
|
||||
}
|
||||
@@ -113,7 +30,7 @@ function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: stri
|
||||
);
|
||||
}
|
||||
|
||||
function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
|
||||
function sortUsers(usersList: Awaited<ReturnType<typeof buildUserResponseModels>>, sort?: string) {
|
||||
const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (!sort) {
|
||||
@@ -132,12 +49,28 @@ function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
|
||||
const left =
|
||||
primarySort.id === 'role'
|
||||
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
|
||||
: primarySort.id === 'primaryCrmRole'
|
||||
? a.primaryCrmRole ?? ''
|
||||
: primarySort.id === 'crmRoleCount'
|
||||
? a.crmRoleCount
|
||||
: primarySort.id === 'branchScopeSummary'
|
||||
? a.branchScopeSummary
|
||||
: primarySort.id === 'productScopeSummary'
|
||||
? a.productScopeSummary
|
||||
: primarySort.id === 'organizations'
|
||||
? a.memberships.map((membership) => membership.organizationName).join(', ')
|
||||
: (a[primarySort.id as 'name' | 'email'] ?? a.name);
|
||||
const right =
|
||||
primarySort.id === 'role'
|
||||
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
|
||||
: primarySort.id === 'primaryCrmRole'
|
||||
? b.primaryCrmRole ?? ''
|
||||
: primarySort.id === 'crmRoleCount'
|
||||
? b.crmRoleCount
|
||||
: primarySort.id === 'branchScopeSummary'
|
||||
? b.branchScopeSummary
|
||||
: primarySort.id === 'productScopeSummary'
|
||||
? b.productScopeSummary
|
||||
: primarySort.id === 'organizations'
|
||||
? b.memberships.map((membership) => membership.organizationName).join(', ')
|
||||
: (b[primarySort.id as 'name' | 'email'] ?? b.name);
|
||||
@@ -161,36 +94,18 @@ export async function GET(request: NextRequest) {
|
||||
const search = searchParams.get('search') ?? undefined;
|
||||
const sort = searchParams.get('sort') ?? undefined;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
systemRole: users.systemRole,
|
||||
activeOrganizationId: users.activeOrganizationId,
|
||||
organizationId: memberships.organizationId,
|
||||
organizationName: organizations.name,
|
||||
membershipRole: memberships.role,
|
||||
businessRole: memberships.businessRole
|
||||
})
|
||||
.from(users)
|
||||
.innerJoin(memberships, eq(memberships.userId, users.id))
|
||||
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
|
||||
.orderBy(asc(users.name));
|
||||
|
||||
const scopedRows = isSuperAdmin
|
||||
? rows
|
||||
: rows.filter((row) => manageableOrganizationIds.includes(row.organizationId));
|
||||
const usersList = await buildUserResponseModels({
|
||||
...(isSuperAdmin ? {} : { scopedOrganizationIds: manageableOrganizationIds })
|
||||
});
|
||||
const searchTerm = search?.trim().toLowerCase();
|
||||
const searchedRows = searchTerm
|
||||
? scopedRows.filter(
|
||||
const searchedUsers = searchTerm
|
||||
? usersList.filter(
|
||||
(row) =>
|
||||
row.name.toLowerCase().includes(searchTerm) ||
|
||||
row.email.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
: scopedRows;
|
||||
const formattedUsers = formatUsers(searchedRows);
|
||||
const filteredUsers = applyRoleFilter(formattedUsers, roles);
|
||||
: usersList;
|
||||
const filteredUsers = applyRoleFilter(searchedUsers, roles);
|
||||
const sortedUsers = sortUsers(filteredUsers, sort);
|
||||
const offset = (page - 1) * limit;
|
||||
const pagedUsers = sortedUsers.slice(offset, offset + limit);
|
||||
@@ -215,7 +130,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
||||
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
|
||||
const payload = parseUserMutationPayload(await request.json());
|
||||
|
||||
if (!payload.password || payload.password.length < 8) {
|
||||
@@ -234,6 +149,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
await validateManagedOrganizations(
|
||||
payload.memberships,
|
||||
payload.crmRoleAssignments,
|
||||
manageableOrganizationIds,
|
||||
isSuperAdmin
|
||||
);
|
||||
@@ -261,7 +177,19 @@ export async function POST(request: NextRequest) {
|
||||
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
|
||||
});
|
||||
|
||||
await db.insert(memberships).values(buildMembershipValues(userId, payload.memberships));
|
||||
const membershipValues = await buildMembershipValues(
|
||||
userId,
|
||||
payload.memberships,
|
||||
payload.crmRoleAssignments
|
||||
);
|
||||
|
||||
await db.insert(memberships).values(membershipValues);
|
||||
await syncCrmRoleAssignmentsForUser(
|
||||
userId,
|
||||
session.user.id,
|
||||
payload.memberships,
|
||||
payload.crmRoleAssignments
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user