import "server-only" import { cache } from "react" import { and, count, desc, eq, ilike, inArray, or } from "drizzle-orm" import { db } from "@/shared/db" import { roles, users, usersToRoles } from "@/shared/db/schema" import type { PaginatedResult, UserRoleAssignment } from "./types" const DEFAULT_PAGE_SIZE = 20 const MAX_PAGE_SIZE = 100 function clampPageSize(size?: number): number { if (!size || size <= 0) return DEFAULT_PAGE_SIZE return Math.min(size, MAX_PAGE_SIZE) } function clampPage(page?: number): number { if (!page || page <= 0) return 1 return page } /** * Get the list of role names assigned to a user. */ export const getUserRoleNames = cache(async (userId: string): Promise => { const rows = await db .select({ name: roles.name }) .from(usersToRoles) .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .where(eq(usersToRoles.userId, userId)) return rows.map((r) => r.name) }) /** * Replace the full set of roles assigned to a user. * Resolves role names to IDs and inserts them in a transaction. * * Only enabled roles (or system roles, which are always considered enabled) * can be assigned. Disabled roles are silently filtered out to keep * `users_to_roles` consistent with `resolvePermissions` (which also filters * disabled roles at login time). * * Pass an empty array to remove all roles from the user. */ export async function assignRolesToUser( userId: string, roleNames: string[] ): Promise { // Verify the user exists const user = await db.query.users.findFirst({ where: eq(users.id, userId) }) if (!user) throw new Error("User not found") // Resolve role names to role records. Only enabled roles are assignable; // disabled roles are filtered out to avoid data inconsistency. const roleRows = roleNames.length > 0 ? await db .select({ id: roles.id, name: roles.name, isEnabled: roles.isEnabled }) .from(roles) .where(inArray(roles.name, roleNames)) : [] // Validate all requested role names exist const foundNames = new Set(roleRows.map((r) => r.name)) const missing = roleNames.filter((n) => !foundNames.has(n)) if (missing.length > 0) { throw new Error(`Unknown roles: ${missing.join(", ")}`) } // P1-9 security fix: filter out disabled non-system roles const assignableRoles = roleRows.filter((r) => r.isEnabled || r.name === "admin") const roleIds = assignableRoles.map((r) => r.id) await db.transaction(async (tx) => { await tx.delete(usersToRoles).where(eq(usersToRoles.userId, userId)) if (roleIds.length > 0) { await tx.insert(usersToRoles).values( roleIds.map((roleId) => ({ userId, roleId })) ) } }) } /** * List user-role assignments with optional filtering and pagination. * Each row includes the user's id, name, email, and the list of role names assigned. */ export async function getUserRoleAssignments(params?: { page?: number pageSize?: number search?: string role?: string }): Promise> { const page = clampPage(params?.page) const pageSize = clampPageSize(params?.pageSize) const offset = (page - 1) * pageSize const conditions = [] if (params?.search) { const term = `%${params.search}%` conditions.push(or(ilike(users.name, term), ilike(users.email, term))) } // If filtering by role, join through users_to_roles const where = conditions.length ? and(...conditions) : undefined const [userRows, totalRows] = await Promise.all([ db .select({ id: users.id, name: users.name, email: users.email, createdAt: users.createdAt, }) .from(users) .where(where) .orderBy(desc(users.createdAt)) .limit(pageSize) .offset(offset), db.select({ value: count() }).from(users).where(where), ]) const userIds = userRows.map((u) => u.id) if (userIds.length === 0) { return { items: [], page, pageSize, total: 0, totalPages: 0, } } // Fetch role assignments for these users in a single query const assignmentRows = await db .select({ userId: usersToRoles.userId, roleName: roles.name }) .from(usersToRoles) .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .where(inArray(usersToRoles.userId, userIds)) const roleMap = new Map() for (const row of assignmentRows) { const list = roleMap.get(row.userId) ?? [] list.push(row.roleName) roleMap.set(row.userId, list) } const items: UserRoleAssignment[] = userRows.map((u) => ({ userId: u.id, userName: u.name, userEmail: u.email, roleNames: roleMap.get(u.id) ?? [], createdAt: u.createdAt, })) // Apply role filter post-fetch if specified (simpler than a subquery here) const filtered = params?.role ? items.filter((i) => i.roleNames.includes(params.role ?? "")) : items const total = Number(totalRows[0]?.value ?? 0) return { items: filtered, page, pageSize, total, totalPages: Math.ceil(total / pageSize), } }