Initial commit — eLegal Software monorepo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-04-26 02:42:42 -04:00
co-authored by Claude Sonnet 4.6
commit 0700d54225
160 changed files with 22771 additions and 0 deletions
+398
View File
@@ -0,0 +1,398 @@
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<number>`(select count(*)::int from ${firms})`,
users: sql<number>`(select count(*)::int from ${users})`,
cases: sql<number>`(select count(*)::int from ${cases})`,
clients: sql<number>`(select count(*)::int from ${clients})`,
invoices: sql<number>`(select count(*)::int from ${invoices})`,
unresolvedContact: sql<number>`(select count(*)::int from ${contactMessages} where ${contactMessages.resolvedAt} is null)`,
})
.from(sql`(select 1) as one`);
const [paidTotalsRow] = await db
.select({
paidTotal: sql<string>`coalesce(sum(${invoices.total})::text, '0')`,
})
.from(invoices)
.where(eq(invoices.status, 'paid'));
const planRows = await db
.select({ plan: firms.plan, count: sql<number>`count(*)::int` })
.from(firms)
.groupBy(firms.plan);
const signups = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${users.createdAt}), 'YYYY-MM-DD')`,
count: sql<number>`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<typeof sql>[] = [];
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<T>` 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<number>`(select count(*)::int from ${clients} where ${clients.firmId} = ${id})`,
cases: sql<number>`(select count(*)::int from ${cases} where ${cases.firmId} = ${id})`,
invoices: sql<number>`(select count(*)::int from ${invoices} where ${invoices.firmId} = ${id})`,
paidTotal: sql<string>`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<typeof and> = [];
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<number>`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 new one for the target user.
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' });
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,
});
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
await logAudit({
userId: req.user!.id,
firmId: target.firmId,
action: 'admin.impersonate',
meta: { targetUserId: target.id, targetEmail: target.email },
ip: req.ip,
});
return { ok: true, impersonating: { id: target.id, email: target.email, firmId: target.firmId } };
});
// ─────────────────────────── 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<typeof and> = [];
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<number>`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<typeof and> = [];
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<number>`count(*)::int`,
})
.from(toolUsage)
.where(gte(toolUsage.createdAt, since30))
.groupBy(toolUsage.tool)
.orderBy(sql`count(*) desc`);
return { items: rows };
});
}