import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, desc, eq, gte, ilike, isNull, or, sql } from 'drizzle-orm'; import { getDb, users, firms, clients, cases, invoices, contactMessages, auditLog, toolUsage, } from '@lawdesk/db'; import { createSession, destroySession, SESSION_COOKIE } from '../auth/sessions'; import { generateCsrfToken } from '../auth/csrf'; import { logAudit } from '../lib/audit'; const PLANS = ['starter', 'pro', 'lifetime'] as const; const idParam = z.object({ id: z.string().uuid() }); export async function adminRoutes(app: FastifyInstance) { app.addHook('preHandler', app.requireSuperadmin); // ─────────────────────────── Stats ─────────────────────────── app.get('/api/admin/stats', async () => { const db = getDb(); const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const [counts] = await db .select({ firms: sql`(select count(*)::int from ${firms})`, users: sql`(select count(*)::int from ${users})`, cases: sql`(select count(*)::int from ${cases})`, clients: sql`(select count(*)::int from ${clients})`, invoices: sql`(select count(*)::int from ${invoices})`, unresolvedContact: sql`(select count(*)::int from ${contactMessages} where ${contactMessages.resolvedAt} is null)`, }) .from(sql`(select 1) as one`); const [paidTotalsRow] = await db .select({ paidTotal: sql`coalesce(sum(${invoices.total})::text, '0')`, }) .from(invoices) .where(eq(invoices.status, 'paid')); const planRows = await db .select({ plan: firms.plan, count: sql`count(*)::int` }) .from(firms) .groupBy(firms.plan); const signups = await db .select({ day: sql`to_char(date_trunc('day', ${users.createdAt}), 'YYYY-MM-DD')`, count: sql`count(*)::int`, }) .from(users) .where(gte(users.createdAt, since30)) .groupBy(sql`date_trunc('day', ${users.createdAt})`) .orderBy(sql`date_trunc('day', ${users.createdAt})`); return { counters: { firms: counts?.firms ?? 0, users: counts?.users ?? 0, cases: counts?.cases ?? 0, clients: counts?.clients ?? 0, invoices: counts?.invoices ?? 0, unresolvedContact: counts?.unresolvedContact ?? 0, paidRevenueTotal: paidTotalsRow?.paidTotal ?? '0', }, planDistribution: planRows, signupsLast30Days: signups, }; }); // ─────────────────────────── Firms ─────────────────────────── app.get('/api/admin/firms', async (req) => { const q = z .object({ q: z.string().max(160).optional(), plan: z.enum(PLANS).optional(), limit: z.coerce.number().int().positive().max(200).default(50), offset: z.coerce.number().int().min(0).default(0), }) .parse(req.query); const db = getDb(); // Build where as raw SQL so we can use the aliased table name in the main query below. const whereClauses: ReturnType[] = []; if (q.plan) whereClauses.push(sql`f.plan = ${q.plan}`); if (q.q) whereClauses.push(sql`f.name ilike ${'%' + q.q + '%'}`); const whereSql = whereClauses.length ? sql.join([sql`where`, sql.join(whereClauses, sql` and `)], sql` `) : sql``; // Raw SQL — Drizzle's `${firms.id}` interpolation inside `sql` doesn't bind to the outer // query's table reference inside correlated subqueries. const result = await db.execute(sql` select f.id, f.name, f.plan, f.watermark_enabled as "watermarkEnabled", f.created_at as "createdAt", coalesce((select count(*)::int from users u where u.firm_id = f.id), 0) as "userCount", coalesce((select count(*)::int from cases c where c.firm_id = f.id), 0) as "caseCount", coalesce((select count(*)::int from clients cl where cl.firm_id = f.id), 0) as "clientCount", coalesce((select sum(total)::text from invoices i where i.firm_id = f.id and i.status = 'paid'), '0') as "paidTotal" from firms f ${whereSql} order by f.created_at desc limit ${q.limit} offset ${q.offset} `); const totalResult = await db.execute(sql`select count(*)::int as total from firms f ${whereSql}`); const total = (totalResult.rows[0]?.total as number) ?? 0; return { items: result.rows, total }; }); app.get('/api/admin/firms/:id', async (req, reply) => { const { id } = idParam.parse(req.params); const db = getDb(); const [firm] = await db.select().from(firms).where(eq(firms.id, id)).limit(1); if (!firm) return reply.code(404).send({ error: 'not_found' }); const firmUsers = await db .select({ id: users.id, email: users.email, fullName: users.fullName, role: users.role, isSuspended: users.isSuspended, isSuperadmin: users.isSuperadmin, createdAt: users.createdAt, lastSeenAt: users.lastSeenAt, }) .from(users) .where(eq(users.firmId, id)) .orderBy(desc(users.createdAt)); const [counts] = await db .select({ clients: sql`(select count(*)::int from ${clients} where ${clients.firmId} = ${id})`, cases: sql`(select count(*)::int from ${cases} where ${cases.firmId} = ${id})`, invoices: sql`(select count(*)::int from ${invoices} where ${invoices.firmId} = ${id})`, paidTotal: sql`coalesce((select sum(${invoices.total})::text from ${invoices} where ${invoices.firmId} = ${id} and ${invoices.status} = 'paid'), '0')`, }) .from(sql`(select 1) as one`); return { firm, users: firmUsers, counts }; }); app.patch('/api/admin/firms/:id', async (req, reply) => { const { id } = idParam.parse(req.params); const body = z .object({ plan: z.enum(PLANS).optional(), watermarkEnabled: z.boolean().optional(), name: z.string().min(1).max(160).optional(), }) .parse(req.body); if (!Object.keys(body).length) return reply.code(400).send({ error: 'empty_body' }); const [updated] = await getDb() .update(firms) .set({ ...body, updatedAt: new Date() }) .where(eq(firms.id, id)) .returning(); if (!updated) return reply.code(404).send({ error: 'not_found' }); await logAudit({ userId: req.user!.id, firmId: id, action: 'admin.firm.update', meta: body, ip: req.ip, }); return updated; }); // ─────────────────────────── Users ─────────────────────────── app.get('/api/admin/users', async (req) => { const q = z .object({ q: z.string().max(160).optional(), suspended: z.enum(['true', 'false']).optional(), limit: z.coerce.number().int().positive().max(200).default(50), offset: z.coerce.number().int().min(0).default(0), }) .parse(req.query); const db = getDb(); const filters: Parameters = []; if (q.q) filters.push(or(ilike(users.email, `%${q.q}%`), ilike(users.fullName, `%${q.q}%`))!); if (q.suspended === 'true') filters.push(eq(users.isSuspended, true)); if (q.suspended === 'false') filters.push(eq(users.isSuspended, false)); const where = filters.length ? and(...filters) : undefined; const rows = await db .select({ id: users.id, email: users.email, fullName: users.fullName, role: users.role, isSuperadmin: users.isSuperadmin, isSuspended: users.isSuspended, createdAt: users.createdAt, lastSeenAt: users.lastSeenAt, firmId: users.firmId, firmName: firms.name, }) .from(users) .leftJoin(firms, eq(firms.id, users.firmId)) .where(where) .orderBy(desc(users.createdAt)) .limit(q.limit) .offset(q.offset); const [count] = await db.select({ total: sql`count(*)::int` }).from(users).where(where); return { items: rows, total: count?.total ?? 0 }; }); app.patch('/api/admin/users/:id', async (req, reply) => { const { id } = idParam.parse(req.params); const body = z .object({ isSuspended: z.boolean().optional(), role: z.enum(['owner', 'attorney', 'paralegal', 'staff']).optional(), }) .parse(req.body); if (!Object.keys(body).length) return reply.code(400).send({ error: 'empty_body' }); if (req.user!.id === id && body.isSuspended === true) { return reply.code(409).send({ error: 'cannot_suspend_self' }); } const [updated] = await getDb() .update(users) .set({ ...body, updatedAt: new Date() }) .where(eq(users.id, id)) .returning({ id: users.id, email: users.email, role: users.role, isSuspended: users.isSuspended, }); if (!updated) return reply.code(404).send({ error: 'not_found' }); if (body.isSuspended) { // Revoke all active sessions for this user const { sessions } = await import('@lawdesk/db'); await getDb().delete(sessions).where(eq(sessions.userId, id)); } await logAudit({ userId: req.user!.id, action: 'admin.user.update', meta: { targetUserId: id, patch: body }, ip: req.ip, }); return updated; }); // Impersonate: end the current session, start a short-lived one for the target user that is // permanently stamped with the acting admin's id. Every audit row written from that session // carries `impersonatedBy`, so support activity can never be mistaken for the customer's own. app.post('/api/admin/users/:id/impersonate', async (req, reply) => { const { id } = idParam.parse(req.params); const db = getDb(); const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1); if (!target) return reply.code(404).send({ error: 'not_found' }); if (target.isSuspended) return reply.code(409).send({ error: 'target_suspended' }); if (target.id === req.user!.id) return reply.code(409).send({ error: 'cannot_impersonate_self' }); // Never let one platform admin borrow another's identity — that would launder an action // between two accounts that both hold full platform authority. if (target.isSuperadmin) return reply.code(409).send({ error: 'cannot_impersonate_superadmin' }); const adminId = req.user!.id; const oldToken = req.cookies?.[SESSION_COOKIE]; if (oldToken) await destroySession(oldToken); const { token, expiresAt } = await createSession({ userId: target.id, ip: req.ip, userAgent: req.headers['user-agent'] ?? null, impersonatedBy: adminId, }); app.setSessionCookie(reply, token, expiresAt); app.setCsrfCookie(reply, generateCsrfToken()); await logAudit({ userId: adminId, firmId: target.firmId, action: 'admin.impersonate.start', meta: { targetUserId: target.id, targetEmail: target.email, expiresAt: expiresAt.toISOString() }, ip: req.ip, }); return { ok: true, impersonating: { id: target.id, email: target.email, firmId: target.firmId }, expiresAt: expiresAt.toISOString(), }; }); // ─────────────────────────── Contact inbox ─────────────────────────── app.get('/api/admin/contact-messages', async (req) => { const q = z .object({ resolved: z.enum(['true', 'false']).optional(), limit: z.coerce.number().int().positive().max(200).default(100), offset: z.coerce.number().int().min(0).default(0), }) .parse(req.query); const filters: Parameters = []; if (q.resolved === 'true') filters.push(sql`${contactMessages.resolvedAt} is not null`); if (q.resolved === 'false') filters.push(isNull(contactMessages.resolvedAt)); const where = filters.length ? and(...filters) : undefined; const db = getDb(); const rows = await db .select() .from(contactMessages) .where(where) .orderBy(desc(contactMessages.createdAt)) .limit(q.limit) .offset(q.offset); const [count] = await db .select({ total: sql`count(*)::int` }) .from(contactMessages) .where(where); return { items: rows, total: count?.total ?? 0 }; }); app.patch('/api/admin/contact-messages/:id', async (req, reply) => { const { id } = idParam.parse(req.params); const body = z.object({ resolved: z.boolean() }).parse(req.body); const [updated] = await getDb() .update(contactMessages) .set({ resolvedAt: body.resolved ? new Date() : null }) .where(eq(contactMessages.id, id)) .returning(); if (!updated) return reply.code(404).send({ error: 'not_found' }); return updated; }); // ─────────────────────────── Audit log ─────────────────────────── app.get('/api/admin/audit-log', async (req) => { const q = z .object({ userId: z.string().uuid().optional(), firmId: z.string().uuid().optional(), action: z.string().max(120).optional(), limit: z.coerce.number().int().positive().max(500).default(100), offset: z.coerce.number().int().min(0).default(0), }) .parse(req.query); const filters: Parameters = []; if (q.userId) filters.push(eq(auditLog.userId, q.userId)); if (q.firmId) filters.push(eq(auditLog.firmId, q.firmId)); if (q.action) filters.push(ilike(auditLog.action, `%${q.action}%`)); const where = filters.length ? and(...filters) : undefined; const db = getDb(); const rows = await db .select({ id: auditLog.id, userId: auditLog.userId, firmId: auditLog.firmId, action: auditLog.action, meta: auditLog.meta, ip: auditLog.ip, createdAt: auditLog.createdAt, userEmail: users.email, }) .from(auditLog) .leftJoin(users, eq(users.id, auditLog.userId)) .where(where) .orderBy(desc(auditLog.createdAt)) .limit(q.limit) .offset(q.offset); return { items: rows }; }); // ─────────────────────────── Tool usage analytics ─────────────────────────── app.get('/api/admin/tool-usage', async () => { const db = getDb(); const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const rows = await db .select({ tool: toolUsage.tool, count: sql`count(*)::int`, }) .from(toolUsage) .where(gte(toolUsage.createdAt, since30)) .groupBy(toolUsage.tool) .orderBy(sql`count(*) desc`); return { items: rows }; }); }