import { and, asc, eq, ne } from 'drizzle-orm' import { Hono } from 'hono' import { z } from 'zod' import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts' import { requireRole } from '../auth/middleware.ts' import { createAuthToken } from '../auth/tokens.ts' import { hashPassword } from '../auth/password.ts' import { deleteUserSessions } from '../auth/sessions.ts' import { isUniqueViolation } from '../db/client.ts' import { organizations, users } from '../db/schema.ts' import { audit, diff } from '../lib/audit.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' /** Columns safe to return to clients (never the password hash). */ export const publicUser = { id: users.id, email: users.email, name: users.name, role: users.role, active: users.active, lastLoginAt: users.lastLoginAt, createdAt: users.createdAt, } const idParam = z.object({ id: z.uuid() }) const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000 export const userRoutes = new Hono() .get('/', requireRole('safety_manager'), async (c) => { const me = currentUser(c) const rows = await c.get('db').select(publicUser).from(users).where(eq(users.organizationId, me.organizationId)).orderBy(asc(users.name)) return c.json({ users: rows }) }) /** Active people who can lead investigations or be assigned actions. */ .get('/assignable', requireRole('supervisor'), async (c) => { const me = currentUser(c) const rows = await c .get('db') .select({ id: users.id, name: users.name, role: users.role }) .from(users) .where(and(eq(users.organizationId, me.organizationId), eq(users.active, true))) .orderBy(asc(users.name)) return c.json({ users: rows }) }) .post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => { const me = currentUser(c) const { password, ...input } = c.req.valid('json') const db = c.get('db') try { const [user] = await db .insert(users) .values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null }) .returning(publicUser) await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: user.id, action: 'create', changes: { email: user.email, role: user.role } }) if (!password) { const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId)) const token = await createAuthToken(db, user.id, 'password_reset', INVITE_TTL_MS) c.get('notifier').passwordReset(user.email, token, { organization: organization.name, invitedBy: me.name }) } return c.json({ user, invited: !password }, 201) } catch (error) { if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists') throw error } }) .patch('/:id', requireRole('admin'), validate('param', idParam), validate('json', updateUserSchema), async (c) => { const me = currentUser(c) const { id } = c.req.valid('param') const input = c.req.valid('json') const db = c.get('db') const [target] = await db.select().from(users).where(and(eq(users.id, id), eq(users.organizationId, me.organizationId))) if (!target) throw fail(404, 'User not found') if (target.id === me.id && input.active === false) throw fail(400, 'You cannot deactivate your own account') const losesAdmin = target.role === 'admin' && target.active && ((input.role && input.role !== 'admin') || input.active === false) if (losesAdmin) { const otherAdmins = await db .select({ id: users.id }) .from(users) .where(and(eq(users.organizationId, me.organizationId), eq(users.role, 'admin'), eq(users.active, true), ne(users.id, target.id))) if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required') } const { password, ...fields } = input const changes: Record = diff(target, fields) if (password) changes.password = 'reset by admin' if (Object.keys(changes).length === 0) { const [user] = await db.select(publicUser).from(users).where(eq(users.id, id)) return c.json({ user }) } const [user] = await db .update(users) .set({ ...fields, ...(password ? { passwordHash: await hashPassword(password) } : {}) }) .where(eq(users.id, id)) .returning(publicUser) // Roles are re-read on every request, so only deactivation and password resets need to end sessions. if (input.active === false || (password && id !== me.id)) await deleteUserSessions(db, id) await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes }) return c.json({ user }) })