import { type Kysely } from 'kysely'; import { type CombinedPg } from '../../services/combinedDbTypes.js'; import { getPermissionGroups, getPermissionsForRole, SystemRoleDefaults, UserPermission, UserRole, type PermissionGroup, } from '../../services/userManagementService/index.js'; import { makeKyselyTransactionWithRetry } from '../../utils/kyselyTransactionWithRetry.js'; type RolesKysely = Kysely; /** * GraphQL `Role` parent shape returned to the role-editor UI. `id` is the * `public.roles.id` UUID when the org has a persisted row, otherwise `null` * (the row is materialized lazily on first save). `key` matches a * {@link UserRole} value and is the stable identifier the client sends back * on mutations. */ export type RoleParent = { id: string | null; key: UserRole; displayName: string; description: string | null; isSystem: boolean; permissions: UserPermission[]; /** True when this role is materialized from {@link SystemRoleDefaults} */ /** plus {@link UserPermissionsForRole} rather than `public.roles`. */ isFallback: boolean; /** Approved (non-rejected) users in the org assigned to this role. */ userCount: number; }; type RoleRow = { id: string; key: string; display_name: string; description: string | null; is_system: boolean; }; /** * Lists every system role for an org, merging persisted rows with the * static defaults so freshly created orgs (which haven't been seeded by * the role migration) still surface a complete role list. Permissions for * persisted rows come from `public.role_permissions`; missing rows fall * back to {@link UserPermissionsForRole}. */ export async function kyselyListRolesForOrg( kysely: RolesKysely, orgId: string, ): Promise { const persistedRows: ReadonlyArray = await kysely .selectFrom('public.roles') .select(['id', 'key', 'display_name', 'description', 'is_system']) .where('org_id', '=', orgId) .where('is_system', '=', true) .execute(); const persistedIds = persistedRows .filter((r): r is RoleRow & { id: string } => Boolean(r.id)) .map((r) => r.id); const permissionRows: ReadonlyArray<{ role_id: string; permission: string; }> = persistedIds.length === 0 ? [] : await kysely .selectFrom('public.role_permissions') .select(['role_id', 'permission']) .where('role_id', 'in', persistedIds) .execute(); const permissionsByRoleId = new Map(); for (const row of permissionRows) { if (!isUserPermission(row.permission)) { continue; } const arr = permissionsByRoleId.get(row.role_id) ?? []; arr.push(row.permission); permissionsByRoleId.set(row.role_id, arr); } const persistedByKey = new Map(); for (const row of persistedRows) { persistedByKey.set(row.key, row); } // Count by legacy `role` string to cover users predating the role_id backfill. const userCountRows = await countApprovedUsersByRole(kysely, orgId); const userCountsByRole = new Map(); for (const r of userCountRows) { userCountsByRole.set(r.role, r.count); } return Object.values(UserRole).map((roleKey) => { const row = persistedByKey.get(roleKey); const defaults = SystemRoleDefaults[roleKey]; const userCount = userCountsByRole.get(roleKey) ?? 0; if (row !== undefined) { // Persisted rows are authoritative; an empty set is a valid saved state. return { id: row.id, key: roleKey, displayName: row.display_name, description: row.description, isSystem: row.is_system, permissions: permissionsByRoleId.get(row.id) ?? [], isFallback: false, userCount, }; } return { id: null, key: roleKey, displayName: defaults.displayName, description: defaults.description, isSystem: true, permissions: getPermissionsForRole(roleKey), isFallback: true, userCount, }; }); } async function countApprovedUsersByRole( kysely: RolesKysely, orgId: string, ): Promise> { const rows = await kysely .selectFrom('public.users') .select((eb) => ['role', eb.fn.countAll().as('count')]) .where('org_id', '=', orgId) .where('rejected_by_admin', '=', false) .where('role', 'is not', null) .groupBy('role') .execute(); return rows .filter((r): r is { role: string; count: string } => r.role != null) .map((r) => ({ role: r.role, count: Number(r.count) })); } /** * Atomically replaces the permission set for `(orgId, roleKey)`. Materializes * the `public.roles` row on first save and backfills `role_id` on existing * users/invites so the next load picks up the persisted permissions. */ export async function kyselyUpdateRolePermissions( kysely: RolesKysely, opts: { orgId: string; roleKey: UserRole; permissions: readonly UserPermission[]; }, ): Promise { const { orgId, roleKey, permissions } = opts; return makeKyselyTransactionWithRetry(kysely)(async (tx) => { const roleId = await ensureSystemRoleRow(tx, { orgId, roleKey }); await tx .deleteFrom('public.role_permissions') .where('role_id', '=', roleId) .execute(); if (permissions.length > 0) { const dedupedPermissions = Array.from(new Set(permissions)); await tx .insertInto('public.role_permissions') .values( dedupedPermissions.map((permission) => ({ role_id: roleId, permission, })), ) .execute(); } return readRoleAfterWrite(tx, { orgId, roleKey, roleId }); }); } /** * Renames a role's display name and optionally its description. * Materializes the `public.roles` row on first save. */ export async function kyselyRenameRole( kysely: RolesKysely, opts: { orgId: string; roleKey: UserRole; displayName: string; description?: string | null; }, ): Promise { const { orgId, roleKey, displayName, description } = opts; return makeKyselyTransactionWithRetry(kysely)(async (tx) => { const roleId = await ensureSystemRoleRow(tx, { orgId, roleKey }); await tx .updateTable('public.roles') .set({ display_name: displayName, ...(description !== undefined ? { description } : {}), updated_at: new Date(), }) .where('id', '=', roleId) .execute(); return readRoleAfterWrite(tx, { orgId, roleKey, roleId }); }); } export function kyselyGetPermissionGroups(): readonly PermissionGroup[] { return getPermissionGroups(); } async function ensureSystemRoleRow( tx: RolesKysely, opts: { orgId: string; roleKey: UserRole }, ): Promise { const existing = await tx .selectFrom('public.roles') .select('id') .where('org_id', '=', opts.orgId) .where('key', '=', opts.roleKey) .where('is_system', '=', true) .executeTakeFirst(); if (existing !== undefined) { return existing.id; } const defaults = SystemRoleDefaults[opts.roleKey]; const inserted = await tx .insertInto('public.roles') .values({ org_id: opts.orgId, key: opts.roleKey, display_name: defaults.displayName, description: defaults.description, is_system: true, }) .returning('id') .executeTakeFirstOrThrow(); // Seed permissions from the static defaults so a freshly materialized // row never looks like an "explicitly empty" set to readers. Callers that // overwrite permissions (e.g. kyselyUpdateRolePermissions) delete these // before inserting their own. const seededPermissions = Array.from( new Set(getPermissionsForRole(opts.roleKey)), ); if (seededPermissions.length > 0) { await tx .insertInto('public.role_permissions') .values( seededPermissions.map((permission) => ({ role_id: inserted.id, permission, })), ) .execute(); } // Backfill role_id on rows where it's NULL; don't stomp existing links. await tx .updateTable('public.users') .set({ role_id: inserted.id }) .where('org_id', '=', opts.orgId) .where('role', '=', opts.roleKey) .where('role_id', 'is', null) .execute(); await tx .updateTable('public.invite_user_tokens') .set({ role_id: inserted.id }) .where('org_id', '=', opts.orgId) .where('role', '=', opts.roleKey) .where('role_id', 'is', null) .execute(); return inserted.id; } async function readRoleAfterWrite( tx: RolesKysely, opts: { orgId: string; roleKey: UserRole; roleId: string }, ): Promise { const row = await tx .selectFrom('public.roles') .select(['id', 'key', 'display_name', 'description', 'is_system']) .where('id', '=', opts.roleId) .executeTakeFirstOrThrow(); const permissionRows = await tx .selectFrom('public.role_permissions') .select('permission') .where('role_id', '=', opts.roleId) .execute(); const permissions = permissionRows .map((p) => p.permission) .filter(isUserPermission); const counts = await countApprovedUsersByRole(tx, opts.orgId); const userCount = counts.find((c) => c.role === opts.roleKey)?.count ?? 0; return { id: row.id, key: opts.roleKey, displayName: row.display_name, description: row.description, isSystem: row.is_system, permissions, isFallback: false, userCount, }; } function isUserPermission(value: string): value is UserPermission { return (Object.values(UserPermission) as string[]).includes(value); }