import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema'; import { auth } from '@/lib/auth'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { sendEmail } from '@/lib/email'; import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change'; import { getBrandingShell } from '@/lib/email/branding-resolver'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users'; export async function listUsers(portId: string) { // Two passes: // 1. Users with an explicit user_port_roles row for this port // 2. All super-admins (they have global access via the // userProfiles.isSuperAdmin flag, no per-port row required — // previous query missed them and the admin list looked empty // to the only super-admin viewing it) const portRoleRows = await db .select({ userId: userPortRoles.userId, displayName: userProfiles.displayName, firstName: userProfiles.firstName, lastName: userProfiles.lastName, fullName: user.name, email: user.email, phone: userProfiles.phone, isActive: userProfiles.isActive, isSuperAdmin: userProfiles.isSuperAdmin, lastLoginAt: userProfiles.lastLoginAt, roleId: roles.id, roleName: roles.name, assignedAt: userPortRoles.createdAt, }) .from(userPortRoles) .innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId)) .innerJoin(user, eq(userPortRoles.userId, user.id)) .innerJoin(roles, eq(userPortRoles.roleId, roles.id)) .where(eq(userPortRoles.portId, portId)); const superAdminRows = await db .select({ userId: userProfiles.userId, displayName: userProfiles.displayName, firstName: userProfiles.firstName, lastName: userProfiles.lastName, fullName: user.name, email: user.email, phone: userProfiles.phone, isActive: userProfiles.isActive, isSuperAdmin: userProfiles.isSuperAdmin, lastLoginAt: userProfiles.lastLoginAt, assignedAt: userProfiles.createdAt, }) .from(userProfiles) .innerJoin(user, eq(userProfiles.userId, user.id)) .where(eq(userProfiles.isSuperAdmin, true)); // Dedup: a super-admin who ALSO has an explicit per-port role // appears once with their port-role displayed (more specific). const seen = new Set(portRoleRows.map((r) => r.userId)); const merged = [ ...portRoleRows.map((row) => ({ userId: row.userId, displayName: row.displayName, firstName: row.firstName, lastName: row.lastName, fullName: row.fullName, email: row.email, phone: row.phone, isActive: row.isActive, isSuperAdmin: row.isSuperAdmin, lastLoginAt: row.lastLoginAt, role: { id: row.roleId, name: row.roleName }, assignedAt: row.assignedAt, })), ...superAdminRows .filter((row) => !seen.has(row.userId)) .map((row) => ({ userId: row.userId, displayName: row.displayName, firstName: row.firstName, lastName: row.lastName, fullName: row.fullName, email: row.email, phone: row.phone, isActive: row.isActive, isSuperAdmin: row.isSuperAdmin, lastLoginAt: row.lastLoginAt, // Synthetic role label — super admins don't have a per-port // role row, but the UI expects a `role` object. The list // already shows the "Super Admin" badge separately. role: { id: 'super_admin', name: 'super_admin' }, assignedAt: row.assignedAt, })), ]; merged.sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? '')); return merged; } export async function getUser(userId: string, portId: string) { const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, userId), }); if (!profile) throw new NotFoundError('User'); const authUser = await db.query.user.findFirst({ where: eq(user.id, userId), }); const portRole = await db.query.userPortRoles.findFirst({ where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)), with: { role: true }, }); if (!portRole) throw new NotFoundError('User not assigned to this port'); return { userId: profile.userId, displayName: profile.displayName, firstName: profile.firstName, lastName: profile.lastName, fullName: authUser?.name ?? null, email: authUser?.email ?? '', phone: profile.phone, isActive: profile.isActive, isSuperAdmin: profile.isSuperAdmin, lastLoginAt: profile.lastLoginAt, avatarUrl: profile.avatarUrl, preferences: profile.preferences, role: { id: portRole.role.id, name: portRole.role.name }, residentialAccess: portRole.residentialAccess, createdAt: profile.createdAt, }; } export async function createUser(portId: string, data: CreateUserInput, meta: AuditMeta) { // Check email uniqueness const existingUser = await db.query.user.findFirst({ where: eq(user.email, data.email.toLowerCase()), }); if (existingUser) { throw new ConflictError('A user with this email already exists'); } // Validate role exists const role = await db.query.roles.findFirst({ where: eq(roles.id, data.roleId), }); if (!role) throw new ValidationError('Invalid role ID'); // Create Better Auth user const authResult = await auth.api.signUpEmail({ body: { email: data.email.toLowerCase(), password: data.password, name: data.name, }, }); const newUserId = authResult.user.id; // Create CRM profile await db.insert(userProfiles).values({ userId: newUserId, displayName: data.displayName, firstName: data.firstName ?? null, lastName: data.lastName ?? null, phone: data.phone ?? null, }); // Assign to port with role await db.insert(userPortRoles).values({ userId: newUserId, portId, roleId: data.roleId, residentialAccess: data.residentialAccess ?? false, assignedBy: meta.userId, }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'user', entityId: newUserId, newValue: { email: data.email, displayName: data.displayName, role: role.name }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'system:alert', { alertType: 'user:created', message: `User "${data.displayName}" added`, severity: 'info', }); return getUser(newUserId, portId); } export async function updateUser( userId: string, portId: string, data: UpdateUserInput, meta: AuditMeta, /** * Caller's effective permission map at call time. Used to enforce the * caller-superset rule on role reassignment so a port admin holding * only `admin.manage_users` can't promote a peer to a role that grants * permissions the caller doesn't themselves hold (authz-auditor C-1 * parallel path). Super admins bypass via `callerIsSuperAdmin`. */ callerPermissions?: Record> | null, callerIsSuperAdmin?: boolean, ) { const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, userId), }); if (!profile) throw new NotFoundError('User'); const portRole = await db.query.userPortRoles.findFirst({ where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)), }); if (!portRole) throw new NotFoundError('User not assigned to this port'); // Update profile fields const profileUpdates: Record = { updatedAt: new Date() }; if (data.displayName !== undefined) profileUpdates.displayName = data.displayName; if (data.firstName !== undefined) profileUpdates.firstName = data.firstName; if (data.lastName !== undefined) profileUpdates.lastName = data.lastName; if (data.phone !== undefined) profileUpdates.phone = data.phone; if (data.isActive !== undefined) profileUpdates.isActive = data.isActive; if (Object.keys(profileUpdates).length > 1) { await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId)); } // Auth-table updates: full name + email. Both go through Better Auth's // `user` table; the email change is admin-initiated and forces the // user to sign in with the new address (we notify the prior one). const authUserRow = await db.query.user.findFirst({ where: eq(user.id, userId) }); const previousEmail = authUserRow?.email ?? null; const wantsEmailChange = typeof data.email === 'string' && previousEmail !== null && data.email.toLowerCase() !== previousEmail.toLowerCase(); if (data.fullName !== undefined || wantsEmailChange) { const userUpdates: Record = { updatedAt: new Date() }; if (data.fullName !== undefined) userUpdates.name = data.fullName; if (wantsEmailChange) userUpdates.email = data.email!.toLowerCase(); await db.update(user).set(userUpdates).where(eq(user.id, userId)); } if (wantsEmailChange) { const newEmailLower = data.email!.toLowerCase(); // Better Auth's credential provider authenticates by // `account.accountId` (the email captured at sign-up), NOT by // `user.email`. Without this update the user can't sign in with // either address — old fails because user.email no longer matches, // new fails because there's no account.accountId row for it. await db .update(account) .set({ accountId: newEmailLower, updatedAt: new Date() }) .where(and(eq(account.userId, userId), eq(account.providerId, 'credential'))); // Revoke every active session — the admin just changed the identity // the user authenticates with, so existing sessions are effectively // orphaned and a security risk if the account is being rotated due // to compromise. The user re-authenticates with the new address. await db.delete(session).where(eq(session.userId, userId)); } if (wantsEmailChange && previousEmail) { // Best-effort notification — failure to send doesn't roll back the // change because Better Auth's primary identity has already moved. // The user still gets in with the new address; this is just an // outbound courtesy. void notifyAdminEmailChange({ previousEmail, newEmail: data.email!.toLowerCase(), displayName: data.displayName ?? profile.displayName, changedByUserId: meta.userId, portId, }); } // Update role assignment + per-user toggles const portRoleUpdates: Record = {}; if (data.roleId && data.roleId !== portRole.roleId) { const newRole = await db.query.roles.findFirst({ where: eq(roles.id, data.roleId), }); if (!newRole) throw new ValidationError('Invalid role ID'); // Caller-superset check (authz-auditor C-1 parallel path): refuse to // assign a role whose effective permission set contains any leaf // the caller doesn't hold. Super admins bypass. When // `callerPermissions` isn't passed (legacy callers / system jobs) // we skip the check — but every interactive API route should pass // ctx.permissions + ctx.isSuperAdmin through. if (callerPermissions && !callerIsSuperAdmin) { const newRolePerms = newRole.permissions as Record>; for (const [resource, actions] of Object.entries(newRolePerms ?? {})) { for (const [action, value] of Object.entries(actions)) { if (value !== true) continue; if (callerPermissions[resource]?.[action] !== true) { throw new ForbiddenError( `You don't hold ${resource}.${action} yourself, so you can't assign a role that grants it.`, ); } } } } portRoleUpdates.roleId = data.roleId; portRoleUpdates.assignedBy = meta.userId; } if ( data.residentialAccess !== undefined && data.residentialAccess !== portRole.residentialAccess ) { portRoleUpdates.residentialAccess = data.residentialAccess; } if (Object.keys(portRoleUpdates).length > 0) { await db .update(userPortRoles) .set(portRoleUpdates) .where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId))); } void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'user', entityId: userId, oldValue: { displayName: profile.displayName, isActive: profile.isActive, roleId: portRole.roleId, }, newValue: data, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'system:alert', { alertType: 'user:updated', message: `User "${data.displayName ?? profile.displayName}" updated`, severity: 'info', }); return getUser(userId, portId); } export async function removeUserFromPort(userId: string, portId: string, meta: AuditMeta) { const portRole = await db.query.userPortRoles.findFirst({ where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)), }); if (!portRole) throw new NotFoundError('User not assigned to this port'); // Prevent removing yourself if (userId === meta.userId) { throw new ValidationError('Cannot remove yourself from the port'); } await db .delete(userPortRoles) .where(and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId))); const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, userId), }); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'user', entityId: userId, oldValue: { displayName: profile?.displayName, roleId: portRole.roleId }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'system:alert', { alertType: 'user:removed', message: `User "${profile?.displayName}" removed from port`, severity: 'info', }); } /** * Sends the "your admin changed your sign-in email" courtesy notice to * the prior address. Best-effort — failures are logged but don't roll * back the change; Better Auth has already pointed the account at the * new address by the time this fires. */ async function notifyAdminEmailChange(args: { previousEmail: string; newEmail: string; displayName: string; changedByUserId: string; portId: string; }): Promise { try { const [admin, port, branding] = await Promise.all([ db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, args.changedByUserId) }), db.query.ports.findFirst({ where: eq(ports.id, args.portId) }), getBrandingShell(args.portId).catch(() => null), ]); const { subject, html, text } = await adminEmailChangeEmail( { recipientName: args.displayName, newEmail: args.newEmail, changedByDisplayName: admin?.displayName, portName: port?.name, loginUrl: env.APP_URL ? `${env.APP_URL}/login` : undefined, }, { branding }, ); await sendEmail(args.previousEmail, subject, html, undefined, text, args.portId); } catch (err) { logger.warn( { err, previousEmail: args.previousEmail, newEmail: args.newEmail }, 'admin email-change notification failed (non-fatal)', ); } }