import { Router, type IRouter } from "express"; import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { db, usersTable } from "@workspace/db"; import { requireAdmin } from "../middleware/auth"; import { writeAuditLog } from "../lib/audit"; import { z } from "zod/v4"; const router: IRouter = Router(); const UserCreateSchema = z.object({ username: z.string().min(2), password: z.string().min(6), email: z.string().optional(), role: z.enum(["admin", "user"]).optional().default("user"), }); const UserRoleUpdateSchema = z.object({ role: z.enum(["admin", "user"]), }); router.get("/users", requireAdmin, async (req, res): Promise => { const users = await db .select({ id: usersTable.id, username: usersTable.username, email: usersTable.email, role: usersTable.role, createdAt: usersTable.createdAt, }) .from(usersTable) .orderBy(usersTable.createdAt); res.json(users); }); router.post("/users", requireAdmin, async (req, res): Promise => { const parsed = UserCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const existing = await db .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.username, parsed.data.username)) .limit(1); if (existing.length > 0) { res.status(409).json({ error: "Username already exists" }); return; } const passwordHash = await bcrypt.hash(parsed.data.password, 10); const [user] = await db .insert(usersTable) .values({ username: parsed.data.username, passwordHash, email: parsed.data.email ?? null, role: parsed.data.role ?? "user", }) .returning({ id: usersTable.id, username: usersTable.username, email: usersTable.email, role: usersTable.role, createdAt: usersTable.createdAt, }); await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role }); res.status(201).json(user); }); router.patch("/users/:id", requireAdmin, async (req, res): Promise => { const id = parseInt(req.params.id, 10); if (isNaN(id)) { res.status(400).json({ error: "Invalid user id" }); return; } const parsed = UserRoleUpdateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.message }); return; } const [user] = await db .update(usersTable) .set({ role: parsed.data.role }) .where(eq(usersTable.id, id)) .returning({ id: usersTable.id, username: usersTable.username, email: usersTable.email, role: usersTable.role, createdAt: usersTable.createdAt, }); if (!user) { res.status(404).json({ error: "User not found" }); return; } await writeAuditLog(req, "user", id, "update", { role: parsed.data.role }); res.json(user); }); router.delete("/users/:id", requireAdmin, async (req, res): Promise => { const id = parseInt(req.params.id, 10); if (isNaN(id)) { res.status(400).json({ error: "Invalid user id" }); return; } if (req.session.user?.sub === String(id)) { res.status(400).json({ error: "Cannot delete your own account" }); return; } const [user] = await db.delete(usersTable).where(eq(usersTable.id, id)).returning(); if (!user) { res.status(404).json({ error: "User not found" }); return; } await writeAuditLog(req, "user", id, "delete", { username: user.username }); res.sendStatus(204); }); export default router;