import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { eq, inArray, sql } from 'drizzle-orm'; import { getDb, users, firms, clients, cases, timeEntries, invoices, invoiceItems, documents, sessions, } from '@lawdesk/db'; import { verifyPassword } from '../auth/password'; import { logAudit } from '../lib/audit'; export async function accountRoutes(app: FastifyInstance) { app.addHook('preHandler', app.requireAuth); // GDPR data export — full JSON dump of everything tied to the user's firm. app.get('/api/account/export', async (req, reply) => { const userId = req.user!.id; const firmId = req.user!.firmId; const db = getDb(); const [profile] = await db .select({ id: users.id, email: users.email, fullName: users.fullName, role: users.role, emailVerifiedAt: users.emailVerifiedAt, totpEnabled: users.totpEnabled, lastSeenAt: users.lastSeenAt, createdAt: users.createdAt, }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!profile) return reply.code(404).send({ error: 'profile_not_found' }); const dump: Record = { exportedAt: new Date().toISOString(), profile, }; if (firmId) { const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1); const firmClients = await db.select().from(clients).where(eq(clients.firmId, firmId)); const firmCases = await db.select().from(cases).where(eq(cases.firmId, firmId)); const firmTime = await db.select().from(timeEntries).where(eq(timeEntries.firmId, firmId)); const firmInvoices = await db.select().from(invoices).where(eq(invoices.firmId, firmId)); const invoiceIds = firmInvoices.map((i) => i.id); const items = invoiceIds.length ? await db.select().from(invoiceItems).where(inArray(invoiceItems.invoiceId, invoiceIds)) : []; const docs = await db.select().from(documents).where(eq(documents.firmId, firmId)); dump.firm = firm; dump.clients = firmClients; dump.cases = firmCases; dump.timeEntries = firmTime; dump.invoices = firmInvoices.map((i) => ({ ...i, items: items.filter((it) => it.invoiceId === i.id), })); dump.documents = docs; } await logAudit({ userId, firmId, action: 'account.export', ip: req.ip, }); reply .header('Content-Type', 'application/json; charset=utf-8') .header( 'Content-Disposition', `attachment; filename="lawdesk-export-${new Date().toISOString().slice(0, 10)}.json"`, ); return JSON.stringify(dump, null, 2); }); // GDPR delete — password-confirmed. Solo firms cascade everything; multi-user firms must // transfer ownership first (we'll add a transfer endpoint when we add team management). app.post('/api/account/delete', async (req, reply) => { const userId = req.user!.id; const firmId = req.user!.firmId; const body = z.object({ password: z.string().min(1) }).parse(req.body); const db = getDb(); const [me] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!me) return reply.code(404).send({ error: 'user_not_found' }); const ok = await verifyPassword(me.passwordHash, body.password); if (!ok) return reply.code(401).send({ error: 'invalid_password' }); if (firmId) { const [{ count }] = await db .select({ count: sql`count(*)::int` }) .from(users) .where(eq(users.firmId, firmId)); if (count > 1) { return reply.code(409).send({ error: 'firm_has_other_users', hint: 'Transfer firm ownership or remove other users before deleting this account.', }); } } await logAudit({ userId, firmId, action: 'account.delete', meta: { email: me.email }, ip: req.ip, }); await db.transaction(async (tx) => { await tx.delete(sessions).where(eq(sessions.userId, userId)); // Deleting the firm cascades: clients → cases → time_entries / documents / invoices → // invoice_items via the foreign-key onDelete:'cascade' chain. Audit log entries pointing // to this user keep their row but null out user_id (set null). if (firmId) await tx.delete(firms).where(eq(firms.id, firmId)); await tx.delete(users).where(eq(users.id, userId)); }); app.clearSessionCookie(reply); app.clearCsrfCookie(reply); return { ok: true }; }); }