import { Prisma } from "@capakraken/db"; import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, } from "@capakraken/shared"; import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { makeAuditLogger } from "../lib/audit-helpers.js"; import type { TRPCContext } from "../trpc.js"; import { invalidateRoleDefaultsCache } from "../trpc.js"; export const CreateUserInputSchema = z.object({ email: z.string().email(), name: z.string().min(1), systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH), }); export const SetUserPasswordInputSchema = z.object({ userId: z.string(), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH), }); export const UpdateUserRoleInputSchema = z.object({ id: z.string(), systemRole: z.nativeEnum(SystemRole), }); export const UpdateUserNameInputSchema = z.object({ id: z.string(), name: z.string().min(1, "Name is required").max(200), }); export const LinkUserResourceInputSchema = z.object({ userId: z.string(), resourceId: z.string().nullable(), }); export const SetUserPermissionsInputSchema = z.object({ userId: z.string(), overrides: z .object({ granted: z.array(z.string()).optional(), denied: z.array(z.string()).optional(), chapterIds: z.array(z.string()).optional(), }) .nullable(), }); export const UserIdInputSchema = z.object({ userId: z.string(), }); type UserReadContext = Pick; type UserMutationContext = UserReadContext; export async function listAssignableUsers(ctx: UserReadContext) { return ctx.db.user.findMany({ select: { id: true, name: true, email: true, }, orderBy: { name: "asc" }, }); } export async function listUsers(ctx: UserReadContext) { return ctx.db.user.findMany({ select: { id: true, name: true, email: true, systemRole: true, createdAt: true, lastLoginAt: true, lastActiveAt: true, permissionOverrides: true, totpEnabled: true, isActive: true, }, orderBy: { name: "asc" }, }); } export async function countActiveUsers(ctx: UserReadContext) { const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); const count = await ctx.db.user.count({ where: { lastActiveAt: { gte: fiveMinAgo } }, }); return { count }; } export async function getCurrentUserProfile(ctx: UserReadContext) { return findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: ctx.dbUser!.id }, select: { id: true, name: true, email: true, systemRole: true, permissionOverrides: true, createdAt: true, }, }), "User", ); } export async function createUser( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const existing = await ctx.db.user.findUnique({ where: { email: input.email } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" }); } const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); const user = await ctx.db.user.create({ data: { email: input.email, name: input.name, systemRole: input.systemRole, passwordHash, }, select: { id: true, name: true, email: true, systemRole: true }, }); const matchingResource = await ctx.db.resource.findFirst({ where: { email: input.email, userId: null }, select: { id: true }, }); if (matchingResource) { await ctx.db.resource.update({ where: { id: matchingResource.id }, data: { userId: user.id }, }); } audit({ entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "CREATE", after: user as unknown as Record, }); return user; } export async function setUserPassword( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true }, }), "User", ); const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); await ctx.db.user.update({ where: { id: input.userId }, data: { passwordHash }, }); // Invalidate all active sessions so any compromised session cannot be // reused after the password is changed (CWE-613). await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); audit({ entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", summary: "Password reset by admin", }); return { success: true }; } export async function updateUserRole( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true, systemRole: true }, }), "User", ); const updated = await ctx.db.user.update({ where: { id: input.id }, data: { systemRole: input.systemRole }, select: { id: true, name: true, email: true, systemRole: true }, }); // Force re-login: a role change (especially a demotion) must revoke // currently-issued JWTs. Our JWT middleware checks the jti against // ActiveSession on every tRPC call, so wiping these rows invalidates // every outstanding session for this user on the next request. if (before.systemRole !== updated.systemRole) { await ctx.db.activeSession.deleteMany({ where: { userId: updated.id } }); // Also nuke the per-instance role-defaults cache (cross-node via pub/sub). invalidateRoleDefaultsCache(); } audit({ entityType: "User", entityId: updated.id, entityName: `${updated.name} (${updated.email})`, action: "UPDATE", before: before as unknown as Record, after: updated as unknown as Record, summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`, }); return updated; } export async function updateUserName( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, email: true }, }), "User", ); const updated = await ctx.db.user.update({ where: { id: input.id }, data: { name: input.name }, select: { id: true, name: true, email: true }, }); audit({ entityType: "User", entityId: updated.id, entityName: `${updated.name} (${updated.email})`, action: "UPDATE", before: before as unknown as Record, after: updated as unknown as Record, summary: `Changed name from "${before.name}" to "${updated.name}"`, }); return updated; } export async function linkUserResource( ctx: UserMutationContext, input: z.infer, ) { await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true }, }), "User", ); if (input.resourceId) { const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, userId: true }, }), "Resource", ); if (resource.userId && resource.userId !== input.userId) { throw new TRPCError({ code: "CONFLICT", message: "Resource is already linked to another user", }); } await ctx.db.resource.updateMany({ where: { userId: input.userId, NOT: { id: input.resourceId }, }, data: { userId: null }, }); const linkResult = await ctx.db.resource.updateMany({ where: { id: input.resourceId, OR: [{ userId: null }, { userId: input.userId }], }, data: { userId: input.userId }, }); if (linkResult.count !== 1) { const [userStillExists, resourceStillExists] = await Promise.all([ ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true }, }), ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, userId: true }, }), ]); if (!userStillExists) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found", }); } if (!resourceStillExists) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found", }); } if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) { throw new TRPCError({ code: "CONFLICT", message: "Resource is already linked to another user", }); } throw new TRPCError({ code: "CONFLICT", message: "Resource link changed during update. Please retry.", }); } } else { await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null }, }); } return { success: true }; } export async function autoLinkUsersByEmail(ctx: UserMutationContext) { const unlinkedUsers = await ctx.db.user.findMany({ where: { resource: null }, select: { id: true, email: true }, }); let linked = 0; for (const user of unlinkedUsers) { const resource = await ctx.db.resource.findFirst({ where: { email: user.email, userId: null }, select: { id: true }, }); if (resource) { await ctx.db.resource.update({ where: { id: resource.id }, data: { userId: user.id }, }); linked++; } } return { linked, checked: unlinkedUsers.length }; } export async function setUserPermissions( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, permissionOverrides: true }, }), "User", ); const user = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: input.overrides ?? Prisma.DbNull }, select: { id: true, name: true, email: true, permissionOverrides: true }, }); // Permission overrides can remove access — force affected sessions to // re-authenticate so the new override set is applied immediately rather // than waiting for the TTL. Cross-node cache invalidation via pub/sub. await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); invalidateRoleDefaultsCache(); audit({ entityType: "User", entityId: input.userId, entityName: `${before.name} (${before.email})`, action: "UPDATE", before: { permissionOverrides: before.permissionOverrides } as unknown as Record< string, unknown >, after: { permissionOverrides: input.overrides } as unknown as Record, summary: input.overrides ? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})` : "Cleared permission overrides", }); return user; } export async function resetUserPermissions( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const before = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, permissionOverrides: true }, }), "User", ); const updated = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: Prisma.DbNull }, select: { id: true, name: true, email: true, permissionOverrides: true }, }); // Reset may remove privileges that were `granted` via override — force // re-login so the regression applies on the next request. await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); invalidateRoleDefaultsCache(); audit({ entityType: "User", entityId: input.userId, entityName: `${before.name} (${before.email})`, action: "UPDATE", before: { permissionOverrides: before.permissionOverrides } as unknown as Record< string, unknown >, after: { permissionOverrides: null } as unknown as Record, summary: "Reset permission overrides to role defaults", }); return updated; } export async function getEffectiveUserPermissions( ctx: UserMutationContext, input: z.infer, ) { const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { systemRole: true, permissionOverrides: true }, }), "User", ); const permissions = resolvePermissions( user.systemRole as SystemRole, user.permissionOverrides as PermissionOverrides | null, ); return { systemRole: user.systemRole, effectivePermissions: Array.from(permissions), overrides: user.permissionOverrides as PermissionOverrides | null, }; } export async function deactivateUser( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); if (ctx.dbUser!.id === input.userId) { throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot deactivate your own account.", }); } const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, isActive: true }, }), "User", ); if (!user.isActive) { throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." }); } await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false, deletedAt: new Date() }, }); // Invalidate all existing sessions so the user is logged out immediately await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); audit({ entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", summary: "User deactivated", }); return { success: true }; } export async function reactivateUser( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, isActive: true }, }), "User", ); if (user.isActive) { throw new TRPCError({ code: "BAD_REQUEST", message: "User is already active." }); } await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true, deletedAt: null }, }); audit({ entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", summary: "User reactivated", }); return { success: true }; } export async function deleteUser( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); if (ctx.dbUser!.id === input.userId) { throw new TRPCError({ code: "BAD_REQUEST", message: "You cannot delete your own account." }); } const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true }, }), "User", ); // These tables have required (non-nullable) FKs to User — must be removed first await ctx.db.vacation.deleteMany({ where: { requestedById: input.userId } }); await ctx.db.notificationBroadcast.deleteMany({ where: { senderId: input.userId } }); await ctx.db.inviteToken.deleteMany({ where: { createdById: input.userId } }); // Unlink resource (nullable FK — belt-and-suspenders) await ctx.db.resource.updateMany({ where: { userId: input.userId }, data: { userId: null } }); audit({ entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "DELETE", summary: "User account permanently deleted", }); // Delete user — Prisma cascade covers: Account, Session, ActiveSession, // Notification, ReportTemplate, AssistantApproval, Comment. // Nullable FKs (AuditLog.userId, etc.) become NULL via Prisma SET NULL default. await ctx.db.user.delete({ where: { id: input.userId } }); return { success: true }; } export async function disableTotp( ctx: UserMutationContext, input: z.infer, ) { const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { id: input.userId }, select: { id: true, name: true, email: true, totpEnabled: true }, }), "User", ); await ctx.db.user.update({ where: { id: input.userId }, data: { totpEnabled: false, totpSecret: null }, }); audit({ entityType: "User", entityId: user.id, entityName: `${user.name} (${user.email})`, action: "UPDATE", summary: "Disabled TOTP MFA (admin override)", }); return { disabled: true }; }