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 { hashPassword } from '../auth/password.ts' import { deleteUserSessions } from '../auth/sessions.ts' import { isUniqueViolation } from '../db/client.ts' import { 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() }) 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 }) }) .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: await hashPassword(password) }) .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 } }) return c.json({ user }, 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 changes = diff(target, input) 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(input).where(eq(users.id, id)).returning(publicUser) // Roles are re-read on every request, so only deactivation needs to end sessions. if (input.active === false) await deleteUserSessions(db, id) await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes }) return c.json({ user }) })