Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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<string, unknown> = {
|
||||
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<number>`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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user