Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user