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
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@lawdesk/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/server.ts",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json --noEmit",
"start": "tsx src/server.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.1",
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^12.0.1",
"@fastify/multipart": "^9.0.1",
"@fastify/rate-limit": "^10.2.1",
"@fastify/static": "^8.0.3",
"@lawdesk/db": "workspace:*",
"@sentry/node": "^8.45.0",
"argon2": "^0.41.1",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.36.4",
"fastify": "^5.1.0",
"fastify-plugin": "^5.0.1",
"fastify-type-provider-zod": "^4.0.2",
"pg": "^8.13.1",
"pdfkit": "^0.15.0",
"pino": "^9.5.0",
"resend": "^4.0.1",
"stripe": "^17.4.0",
"tsx": "^4.19.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.1",
"@types/pdfkit": "^0.13.5",
"@types/pg": "^8.11.10",
"pino-pretty": "^11.3.0",
"typescript": "^5.6.3"
}
}
+80
View File
@@ -0,0 +1,80 @@
import crypto from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import { isProd, env } from '../env';
import { SESSION_COOKIE } from './sessions';
export const CSRF_COOKIE = 'csrf';
export const CSRF_HEADER = 'x-csrf-token';
const TOKEN_BYTES = 32;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Routes that legitimately bypass CSRF — they receive their own auth (signature check)
// or have no session yet, so a CSRF attack against them is meaningless.
const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage'];
export function generateCsrfToken(): string {
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
}
function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
declare module 'fastify' {
interface FastifyInstance {
setCsrfCookie: (reply: FastifyReply, token: string) => void;
clearCsrfCookie: (reply: FastifyReply) => void;
}
}
async function plugin(app: FastifyInstance) {
app.decorate('setCsrfCookie', (reply: FastifyReply, token: string) => {
reply.setCookie(CSRF_COOKIE, token, {
path: '/',
httpOnly: false, // intentional — JS reads this and echoes it as a header
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
});
});
app.decorate('clearCsrfCookie', (reply: FastifyReply) => {
reply.clearCookie(CSRF_COOKIE, {
path: '/',
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
});
});
// Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set.
// This makes the protection self-bootstrapping after sessions created before CSRF was enabled.
app.addHook('onRequest', async (req, reply) => {
if (!req.cookies?.[SESSION_COOKIE]) return;
if (req.cookies?.[CSRF_COOKIE]) return;
const token = generateCsrfToken();
app.setCsrfCookie(reply, token);
req.cookies = { ...req.cookies, [CSRF_COOKIE]: token };
});
// Verify CSRF on every state-changing request that has a session cookie.
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
if (SAFE_METHODS.has(req.method)) return;
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
const url = req.routeOptions.url || req.url;
if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return;
const cookie = req.cookies?.[CSRF_COOKIE];
const header = (req.headers[CSRF_HEADER] as string | undefined) ?? '';
if (!cookie || !header || !constantTimeEqual(cookie, header)) {
return reply.code(403).send({ error: 'csrf_failed' });
}
});
}
export const csrfPlugin = fp(plugin, { name: 'csrf', dependencies: ['auth'] });
+16
View File
@@ -0,0 +1,16 @@
import argon2 from 'argon2';
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
};
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
+91
View File
@@ -0,0 +1,91 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import { SESSION_COOKIE, loadSession } from './sessions';
import { isProd, env } from '../env';
import { ensureSuperadminFlag } from './superadmin';
declare module 'fastify' {
interface FastifyRequest {
user?: {
id: string;
email: string;
firmId: string | null;
role: string;
isSuperadmin: boolean;
isSuspended: boolean;
};
}
interface FastifyInstance {
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireFirm: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireSuperadmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
setSessionCookie: (reply: FastifyReply, token: string, expiresAt: Date) => void;
clearSessionCookie: (reply: FastifyReply) => void;
}
}
async function plugin(app: FastifyInstance) {
app.addHook('onRequest', async (req) => {
const token = req.cookies?.[SESSION_COOKIE];
if (!token) return;
const session = await loadSession(token);
if (!session) return;
// Auto-promote/demote based on SUPERADMIN_EMAILS env var, every request — cheap and self-healing.
const isSuperadmin = await ensureSuperadminFlag(
session.user.id,
session.user.email,
session.user.isSuperadmin,
);
req.user = {
id: session.user.id,
email: session.user.email,
firmId: session.user.firmId,
role: session.user.role,
isSuperadmin,
isSuspended: session.user.isSuspended,
};
});
app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
});
app.decorate('requireFirm', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
if (!req.user.firmId) return reply.code(403).send({ error: 'no_firm' });
});
app.decorate('requireSuperadmin', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (!req.user.isSuperadmin) return reply.code(403).send({ error: 'forbidden' });
});
app.decorate('setSessionCookie', (reply: FastifyReply, token: string, expiresAt: Date) => {
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
expires: expiresAt,
signed: false,
});
});
app.decorate('clearSessionCookie', (reply: FastifyReply) => {
reply.clearCookie(SESSION_COOKIE, {
path: '/',
httpOnly: true,
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
});
});
}
export const authPlugin = fp(plugin, { name: 'auth' });
+77
View File
@@ -0,0 +1,77 @@
import crypto from 'node:crypto';
import { eq, lt } from 'drizzle-orm';
import { getDb, sessions, users } from '@lawdesk/db';
const SESSION_BYTES = 32;
const SESSION_TTL_DAYS = 30;
export const SESSION_COOKIE = 'sid';
export function generateSessionToken(): string {
return crypto.randomBytes(SESSION_BYTES).toString('base64url');
}
export function hashSessionToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export interface CreateSessionOpts {
userId: string;
ip?: string | null;
userAgent?: string | null;
}
export async function createSession(opts: CreateSessionOpts): Promise<{ token: string; expiresAt: Date }> {
const token = generateSessionToken();
const id = hashSessionToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
await getDb().insert(sessions).values({
id,
userId: opts.userId,
expiresAt,
ip: opts.ip ?? null,
userAgent: opts.userAgent ?? null,
});
return { token, expiresAt };
}
export async function loadSession(token: string) {
const id = hashSessionToken(token);
const db = getDb();
const rows = await db
.select({
session: sessions,
user: users,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, id))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() <= Date.now()) {
await db.delete(sessions).where(eq(sessions.id, id));
return null;
}
// Touch last_seen_at (best-effort, fire and forget)
db.update(sessions)
.set({ lastSeenAt: new Date() })
.where(eq(sessions.id, id))
.catch(() => {});
return row;
}
export async function destroySession(token: string): Promise<void> {
const id = hashSessionToken(token);
await getDb().delete(sessions).where(eq(sessions.id, id));
}
export async function purgeExpiredSessions(): Promise<void> {
await getDb().delete(sessions).where(lt(sessions.expiresAt, new Date()));
}
+16
View File
@@ -0,0 +1,16 @@
import { eq } from 'drizzle-orm';
import { getDb, users } from '@lawdesk/db';
import { env } from '../env';
export function isSuperadminEmail(email: string): boolean {
return env.superadminEmails.includes(email.toLowerCase());
}
// Promote any user whose email is on the SUPERADMIN_EMAILS list. Idempotent.
// Called on signup/login so the assignment happens automatically as soon as the user shows up.
export async function ensureSuperadminFlag(userId: string, email: string, currentFlag: boolean) {
const shouldBe = isSuperadminEmail(email);
if (shouldBe === currentFlag) return shouldBe;
await getDb().update(users).set({ isSuperadmin: shouldBe, updatedAt: new Date() }).where(eq(users.id, userId));
return shouldBe;
}
+39
View File
@@ -0,0 +1,39 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { z } from 'zod';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env from the monorepo root regardless of cwd
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_URL: z.string().url().default('http://localhost:8080'),
COOKIE_DOMAIN: z.string().optional(),
SESSION_SECRET: z.string().min(32),
CSRF_SECRET: z.string().min(32),
DATABASE_URL: z.string().min(1),
DATABASE_CA_CERT_PATH: z.string().optional(),
WEB_DIST_PATH: z.string().optional(),
SUPERADMIN_EMAILS: z.string().optional().default(''),
SENTRY_DSN_API: z.string().optional().default(''),
RESEND_API_KEY: z.string().optional().default(''),
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
STRIPE_SECRET_KEY: z.string().optional().default(''),
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
STRIPE_PRICE_PRO: z.string().optional().default(''),
STRIPE_PRICE_LIFETIME: z.string().optional().default(''),
});
const parsed = envSchema.parse(process.env);
export const env = {
...parsed,
superadminEmails: parsed.SUPERADMIN_EMAILS.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
};
export const isProd = env.NODE_ENV === 'production';
+19
View File
@@ -0,0 +1,19 @@
import { getDb, auditLog } from '@lawdesk/db';
export interface AuditEntry {
userId?: string | null;
firmId?: string | null;
action: string;
meta?: unknown;
ip?: string | null;
}
export async function logAudit(entry: AuditEntry): Promise<void> {
await getDb().insert(auditLog).values({
userId: entry.userId ?? null,
firmId: entry.firmId ?? null,
action: entry.action,
meta: entry.meta == null ? null : JSON.stringify(entry.meta),
ip: entry.ip ?? null,
});
}
+159
View File
@@ -0,0 +1,159 @@
import { Resend } from 'resend';
import { env } from '../env';
let _resend: Resend | null = null;
function getResend(): Resend | null {
if (!env.RESEND_API_KEY) return null;
if (!_resend) _resend = new Resend(env.RESEND_API_KEY);
return _resend;
}
export interface EmailOptions {
to: string;
subject: string;
html: string;
text: string;
attachments?: Array<{ filename: string; content: Buffer | string }>;
replyTo?: string;
}
export interface SendResult {
ok: boolean;
skipped?: boolean;
id?: string;
error?: string;
}
export async function sendEmail(opts: EmailOptions): Promise<SendResult> {
const resend = getResend();
if (!resend) {
// Logged but not sent — useful in dev when RESEND_API_KEY isn't set.
console.log(`[email skipped] to=${opts.to} subject="${opts.subject}"`);
return { ok: true, skipped: true };
}
try {
const res = await resend.emails.send({
from: env.EMAIL_FROM,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
replyTo: opts.replyTo,
attachments: opts.attachments?.map((a) => ({
filename: a.filename,
content: typeof a.content === 'string' ? a.content : a.content.toString('base64'),
})),
});
if (res.error) return { ok: false, error: res.error.message };
return { ok: true, id: res.data?.id };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
// ─────────────────────────── Templates ───────────────────────────
// Kept simple. Brand-blue header bar + readable body. Plain-text version always provided
// since some clients (and good practice) require it.
const BRAND = '#0052FF';
function shell(bodyHtml: string): string {
return `<!doctype html>
<html><head><meta charset="utf-8"><title>eLegal Software</title></head>
<body style="margin:0;padding:0;background:#f6f7f9;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#23272e;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #eceef2;">
<div style="background:${BRAND};padding:18px 24px;color:#fff;font-weight:700;letter-spacing:-0.01em;font-size:18px;">eLegal Software</div>
<div style="padding:28px 24px;line-height:1.55;font-size:15px;">${bodyHtml}</div>
<div style="border-top:1px solid #eceef2;padding:14px 24px;color:#7c8595;font-size:12px;">© ${new Date().getFullYear()} eLegal Software. You're receiving this because of activity on your account.</div>
</div>
</body></html>`;
}
export function welcomeEmail(toName: string | null, verifyUrl: string | null) {
const name = toName?.split(' ')[0] ?? 'there';
const verifyBlock = verifyUrl
? `<p>Please confirm your email address so we can send you important updates:</p>
<p><a href="${verifyUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Verify my email</a></p>
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${verifyUrl}</p>`
: '';
return {
subject: 'Welcome to eLegal Software',
html: shell(
`<p>Hi ${name},</p>
<p>Welcome to eLegal Software. Your account is set up and you're ready to add your first client and case.</p>
${verifyBlock}
<p>If you have questions, just reply to this email — a real person will see it.</p>
<p>— The eLegal Software team</p>`,
),
text: `Hi ${name},\n\nWelcome to eLegal Software. Your account is set up and you're ready to add your first client and case.\n\n${verifyUrl ? `Please confirm your email: ${verifyUrl}\n\n` : ''}If you have questions, just reply to this email.\n\n— The eLegal Software team`,
};
}
export function passwordResetEmail(toName: string | null, resetUrl: string) {
const name = toName?.split(' ')[0] ?? 'there';
return {
subject: 'Reset your eLegal Software password',
html: shell(
`<p>Hi ${name},</p>
<p>We got a request to reset the password on your eLegal Software account. Click below to choose a new one:</p>
<p><a href="${resetUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Reset password</a></p>
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${resetUrl}</p>
<p style="color:#5b6473;font-size:13px;">This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.</p>`,
),
text: `Hi ${name},\n\nWe got a request to reset your eLegal Software password.\n\nReset it here: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request a reset, ignore this email.`,
};
}
export function planUpgradedEmail(toName: string | null, plan: string) {
const name = toName?.split(' ')[0] ?? 'there';
return {
subject: `You're on eLegal Software ${plan}`,
html: shell(
`<p>Hi ${name},</p>
<p>Thanks for upgrading. Your firm is now on the <strong>${plan}</strong> plan and the limits and watermarks have been lifted.</p>
<p><a href="${process.env.PUBLIC_URL ?? 'https://app.elegalsoftware.com'}/app" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Open eLegal Software</a></p>
<p>Manage your subscription anytime from Settings → Billing.</p>`,
),
text: `Hi ${name},\n\nThanks for upgrading. Your firm is now on the ${plan} plan and the limits and watermarks have been lifted.\n\nManage your subscription from Settings → Billing.`,
};
}
export function invoiceEmail(opts: {
clientName: string;
firmName: string;
invoiceNumber: string;
total: string;
dueDate?: string | null;
notes?: string | null;
}) {
const dueLine = opts.dueDate ? `<p>Due on <strong>${opts.dueDate}</strong>.</p>` : '';
const notesLine = opts.notes
? `<p style="background:#f6f7f9;border-radius:10px;padding:12px;color:#5b6473;font-size:13px;">${opts.notes}</p>`
: '';
return {
subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`,
html: shell(
`<p>Hi ${opts.clientName.split(' ')[0]},</p>
<p>${opts.firmName} sent you a new invoice.</p>
<p style="font-size:18px;"><strong>${opts.invoiceNumber}</strong> — <strong>${opts.total}</strong></p>
${dueLine}
${notesLine}
<p>The PDF is attached. Reply to this email if you have any questions.</p>`,
),
text: `Hi ${opts.clientName},\n\n${opts.firmName} sent you a new invoice: ${opts.invoiceNumber}${opts.total}.${opts.dueDate ? ` Due on ${opts.dueDate}.` : ''}\n\nThe PDF is attached.${opts.notes ? `\n\nNotes: ${opts.notes}` : ''}`,
};
}
export function contactAckEmail(toName: string) {
const name = toName.split(' ')[0];
return {
subject: "Got your message — we'll be in touch",
html: shell(
`<p>Hi ${name},</p>
<p>Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.</p>
<p>— The eLegal Software team</p>`,
),
text: `Hi ${name},\n\nThanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.\n\n— The eLegal Software team`,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm';
import { getDb, firms } from '@lawdesk/db';
import type { PlanName } from './plan-limits';
export interface FirmContext {
id: string;
plan: PlanName;
name: string;
watermarkEnabled: boolean;
}
export async function loadFirm(firmId: string): Promise<FirmContext | null> {
const [row] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!row) return null;
return {
id: row.id,
plan: row.plan as PlanName,
name: row.name,
watermarkEnabled: row.watermarkEnabled,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { sql } from 'drizzle-orm';
import { eq, and, like } from 'drizzle-orm';
import { getDb, invoices } from '@lawdesk/db';
// Format: INV-YYYY-NNNN, scoped per firm.
// Uses a count-based sequence — the unique-on-(firm_id, number) constraint isn't enforced
// at the DB level yet, so two near-simultaneous creates could collide. For a v1 single-user
// firm this is fine; if it becomes a problem, add a per-firm Postgres sequence.
export async function nextInvoiceNumber(firmId: string): Promise<string> {
const year = new Date().getUTCFullYear();
const prefix = `INV-${year}-`;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(invoices)
.where(and(eq(invoices.firmId, firmId), like(invoices.number, `${prefix}%`)));
const next = (row?.count ?? 0) + 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
+148
View File
@@ -0,0 +1,148 @@
import PDFDocument from 'pdfkit';
import { PassThrough } from 'node:stream';
export interface InvoicePdfData {
number: string;
status: string;
issuedAt: Date | null;
dueAt: Date | null;
notes: string | null;
subtotal: string;
taxRate: string;
total: string;
firm: { name: string };
client: { name: string; email: string | null; address: string | null };
items: Array<{ description: string; quantity: string; rate: string; amount: string }>;
watermark?: boolean;
}
const FONT = 'Helvetica';
const FONT_BOLD = 'Helvetica-Bold';
function formatMoney(value: string | number | null | undefined): string {
if (value == null) return '$0.00';
const n = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(n)) return '$0.00';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
function formatDate(d: Date | null): string {
if (!d) return '—';
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
export function renderInvoicePdf(data: InvoicePdfData): NodeJS.ReadableStream {
const doc = new PDFDocument({ size: 'LETTER', margin: 50 });
const stream = new PassThrough();
doc.pipe(stream);
// Header bar
doc.rect(0, 0, doc.page.width, 6).fill('#0052FF');
doc.fillColor('#13161B');
// Firm + invoice meta
doc.font(FONT_BOLD).fontSize(20).text(data.firm.name, 50, 36);
doc.font(FONT).fontSize(10).fillColor('#5B6473').text('Invoice', 50, 62);
doc.fontSize(28).fillColor('#0052FF').font(FONT_BOLD).text(data.number, 0, 36, { align: 'right' });
doc.font(FONT).fontSize(10).fillColor('#5B6473');
doc.text(`Status: ${data.status.toUpperCase()}`, 0, 70, { align: 'right' });
// Bill-to + dates block
const blockY = 120;
doc.font(FONT_BOLD).fontSize(10).fillColor('#13161B').text('BILL TO', 50, blockY);
doc.font(FONT).fontSize(11).fillColor('#23272E');
doc.text(data.client.name, 50, blockY + 16);
if (data.client.email) doc.text(data.client.email, 50, blockY + 32);
if (data.client.address) doc.text(data.client.address, 50, blockY + 48, { width: 240 });
doc.font(FONT_BOLD).fontSize(10).fillColor('#13161B').text('ISSUED', 350, blockY);
doc.font(FONT).fontSize(11).fillColor('#23272E').text(formatDate(data.issuedAt), 350, blockY + 16);
doc.font(FONT_BOLD).fontSize(10).fillColor('#13161B').text('DUE', 470, blockY);
doc.font(FONT).fontSize(11).fillColor('#23272E').text(formatDate(data.dueAt), 470, blockY + 16);
// Items table
const tableY = 220;
const col = { desc: 50, qty: 340, rate: 410, amount: 480 };
const tableWidth = doc.page.width - 100;
doc.rect(50, tableY, tableWidth, 24).fill('#F6F7F9');
doc.fillColor('#5B6473').font(FONT_BOLD).fontSize(9);
doc.text('DESCRIPTION', col.desc + 8, tableY + 8);
doc.text('QTY', col.qty, tableY + 8, { width: 50, align: 'right' });
doc.text('RATE', col.rate, tableY + 8, { width: 50, align: 'right' });
doc.text('AMOUNT', col.amount, tableY + 8, { width: 65, align: 'right' });
doc.font(FONT).fontSize(10).fillColor('#23272E');
let y = tableY + 32;
for (const item of data.items) {
const descHeight = doc.heightOfString(item.description, { width: col.qty - col.desc - 16 });
const rowH = Math.max(20, descHeight + 6);
doc.text(item.description, col.desc + 8, y, { width: col.qty - col.desc - 16 });
doc.text(item.quantity, col.qty, y, { width: 50, align: 'right' });
doc.text(formatMoney(item.rate), col.rate, y, { width: 50, align: 'right' });
doc.text(formatMoney(item.amount), col.amount, y, { width: 65, align: 'right' });
y += rowH;
doc.moveTo(50, y).lineTo(50 + tableWidth, y).strokeColor('#ECEEF2').lineWidth(0.5).stroke();
y += 4;
if (y > doc.page.height - 200) {
doc.addPage();
y = 50;
}
}
// Totals
const totalsY = y + 20;
const labelX = 380;
const valueX = 480;
doc.font(FONT).fontSize(10).fillColor('#5B6473');
doc.text('Subtotal', labelX, totalsY, { width: 90, align: 'right' });
doc.fillColor('#23272E').text(formatMoney(data.subtotal), valueX, totalsY, { width: 65, align: 'right' });
if (Number(data.taxRate) > 0) {
doc.fillColor('#5B6473').text(`Tax (${data.taxRate}%)`, labelX, totalsY + 18, { width: 90, align: 'right' });
const taxAmount = (Number(data.subtotal) * Number(data.taxRate)) / 100;
doc.fillColor('#23272E').text(formatMoney(taxAmount), valueX, totalsY + 18, { width: 65, align: 'right' });
}
const totalY = totalsY + (Number(data.taxRate) > 0 ? 44 : 26);
doc.rect(labelX - 10, totalY - 6, 175, 28).fill('#0052FF');
doc.fillColor('#FFFFFF').font(FONT_BOLD).fontSize(12);
doc.text('Total', labelX, totalY + 2, { width: 90, align: 'right' });
doc.text(formatMoney(data.total), valueX, totalY + 2, { width: 65, align: 'right' });
// Notes
if (data.notes) {
const notesY = totalY + 60;
doc.fillColor('#13161B').font(FONT_BOLD).fontSize(10).text('NOTES', 50, notesY);
doc.fillColor('#23272E').font(FONT).fontSize(10).text(data.notes, 50, notesY + 16, {
width: tableWidth,
});
}
// Footer
const footerY = doc.page.height - 50;
doc.fillColor('#7C8595').font(FONT).fontSize(9).text(
`Generated by eLegal Software · ${data.firm.name}`,
50,
footerY,
{ width: tableWidth, align: 'center' },
);
// Watermark for Starter plan
if (data.watermark) {
doc.save();
doc.fillColor('#0052FF').fillOpacity(0.08).font(FONT_BOLD).fontSize(90);
doc.rotate(-30, { origin: [doc.page.width / 2, doc.page.height / 2] });
doc.text('LAWDESK', 0, doc.page.height / 2 - 60, {
width: doc.page.width,
align: 'center',
});
doc.restore();
}
doc.end();
return stream;
}
+72
View File
@@ -0,0 +1,72 @@
import { sql } from 'drizzle-orm';
import { getDb, clients, cases, invoices } from '@lawdesk/db';
import { and, eq, gte } from 'drizzle-orm';
export type PlanName = 'starter' | 'pro' | 'lifetime';
export interface PlanLimits {
clients: number | null;
activeCases: number | null;
invoicesPerMonth: number | null;
storageBytes: number | null;
}
export const PLAN_LIMITS: Record<PlanName, PlanLimits> = {
starter: {
clients: 2,
activeCases: 1,
invoicesPerMonth: 2,
storageBytes: 500 * 1024 * 1024, // 500 MB
},
pro: {
clients: null,
activeCases: 6,
invoicesPerMonth: null,
storageBytes: 8 * 1024 * 1024 * 1024, // 8 GB
},
lifetime: {
clients: null,
activeCases: null,
invoicesPerMonth: null,
storageBytes: 50 * 1024 * 1024 * 1024, // 50 GB
},
};
export class PlanLimitError extends Error {
constructor(public limit: keyof PlanLimits, public planName: PlanName) {
super(`plan_limit_${limit}`);
}
}
export async function assertCanCreateClient(firmId: string, plan: PlanName) {
const limit = PLAN_LIMITS[plan].clients;
if (limit === null) return;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(eq(clients.firmId, firmId));
if ((row?.count ?? 0) >= limit) throw new PlanLimitError('clients', plan);
}
export async function assertCanCreateCase(firmId: string, plan: PlanName) {
const limit = PLAN_LIMITS[plan].activeCases;
if (limit === null) return;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(cases)
.where(and(eq(cases.firmId, firmId), eq(cases.status, 'open')));
if ((row?.count ?? 0) >= limit) throw new PlanLimitError('activeCases', plan);
}
export async function assertCanCreateInvoice(firmId: string, plan: PlanName) {
const limit = PLAN_LIMITS[plan].invoicesPerMonth;
if (limit === null) return;
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(invoices)
.where(and(eq(invoices.firmId, firmId), gte(invoices.createdAt, monthStart)));
if ((row?.count ?? 0) >= limit) throw new PlanLimitError('invoicesPerMonth', plan);
}
+26
View File
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/node';
import { env, isProd } from '../env';
let initialized = false;
export function initSentry(): void {
if (initialized) return;
if (!env.SENTRY_DSN_API) return;
Sentry.init({
dsn: env.SENTRY_DSN_API,
environment: env.NODE_ENV,
tracesSampleRate: isProd ? 0.1 : 0,
sendDefaultPii: false,
});
initialized = true;
}
export function captureError(err: unknown, ctx?: Record<string, unknown>): void {
if (!initialized) return;
Sentry.withScope((scope) => {
if (ctx) for (const [k, v] of Object.entries(ctx)) scope.setExtra(k, v);
Sentry.captureException(err);
});
}
export { Sentry };
+37
View File
@@ -0,0 +1,37 @@
import Stripe from 'stripe';
import { env } from '../env';
let _stripe: Stripe | null = null;
export function getStripe(): Stripe {
if (!env.STRIPE_SECRET_KEY) {
throw new Error('stripe_not_configured');
}
if (!_stripe) {
_stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2024-11-20.acacia' as Stripe.LatestApiVersion });
}
return _stripe;
}
export function stripeIsConfigured(): boolean {
return !!env.STRIPE_SECRET_KEY;
}
export interface PlanConfig {
priceId: string;
mode: 'subscription' | 'payment';
planName: 'pro' | 'lifetime';
label: string;
}
export function getPlanConfig(plan: 'pro' | 'lifetime'): PlanConfig | null {
if (plan === 'pro') {
if (!env.STRIPE_PRICE_PRO) return null;
return { priceId: env.STRIPE_PRICE_PRO, mode: 'subscription', planName: 'pro', label: 'Professional' };
}
if (plan === 'lifetime') {
if (!env.STRIPE_PRICE_LIFETIME) return null;
return { priceId: env.STRIPE_PRICE_LIFETIME, mode: 'payment', planName: 'lifetime', label: 'Lifetime' };
}
return null;
}
+137
View File
@@ -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 };
});
}
+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 };
});
}
+244
View File
@@ -0,0 +1,244 @@
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, eq, gte, isNull, sql } from 'drizzle-orm';
import { getDb, users, firms, loginAttempts, passwordResets, sessions as sessionsTable } from '@lawdesk/db';
import { hashPassword, verifyPassword } from '../auth/password';
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
import { ensureSuperadminFlag } from '../auth/superadmin';
import { generateCsrfToken } from '../auth/csrf';
import { sendEmail, passwordResetEmail, welcomeEmail } from '../lib/email';
import { env } from '../env';
const signupBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(10).max(200),
fullName: z.string().min(1).max(120).trim(),
firmName: z.string().min(1).max(160).trim(),
});
const loginBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(1).max(200),
});
const MAX_FAILS_PER_15_MIN = 5;
async function recentFailedAttempts(email: string, ip: string | null): Promise<number> {
const since = new Date(Date.now() - 15 * 60 * 1000);
const db = getDb();
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.email, email),
eq(loginAttempts.success, false),
gte(loginAttempts.attemptedAt, since),
),
);
return rows[0]?.count ?? 0;
}
export async function authRoutes(app: FastifyInstance) {
app.post(
'/api/auth/signup',
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
async (req, reply) => {
const body = signupBody.parse(req.body);
const db = getDb();
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
if (existing.length > 0) {
return reply.code(409).send({ error: 'email_taken' });
}
const passwordHash = await hashPassword(body.password);
const [firm] = await db.insert(firms).values({ name: body.firmName }).returning();
if (!firm) return reply.code(500).send({ error: 'firm_create_failed' });
const [user] = await db
.insert(users)
.values({
email: body.email,
passwordHash,
fullName: body.fullName,
firmId: firm.id,
role: 'owner',
})
.returning();
if (!user) return reply.code(500).send({ error: 'user_create_failed' });
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
const { token, expiresAt } = await createSession({
userId: user.id,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? null,
});
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
// Fire-and-forget welcome email (no blocking)
const welcome = welcomeEmail(user.fullName, null);
sendEmail({ to: user.email, ...welcome }).catch((err) => app.log.warn({ err }, 'welcome email failed'));
return reply.code(201).send({
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
firmId: firm.id,
role: user.role,
isSuperadmin,
isSuspended: user.isSuspended,
},
});
});
app.post(
'/api/auth/login',
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => {
const body = loginBody.parse(req.body);
const db = getDb();
const ip = req.ip ?? null;
const fails = await recentFailedAttempts(body.email, ip);
if (fails >= MAX_FAILS_PER_15_MIN) {
return reply.code(429).send({ error: 'too_many_attempts' });
}
const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1);
const ok = user ? await verifyPassword(user.passwordHash, body.password) : false;
await db.insert(loginAttempts).values({ email: body.email, ip, success: ok });
if (!ok || !user) {
return reply.code(401).send({ error: 'invalid_credentials' });
}
if (user.isSuspended) {
return reply.code(403).send({ error: 'account_suspended' });
}
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, user.id));
const { token, expiresAt } = await createSession({
userId: user.id,
ip,
userAgent: req.headers['user-agent'] ?? null,
});
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
return {
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
firmId: user.firmId,
role: user.role,
isSuperadmin,
isSuspended: user.isSuspended,
},
};
});
app.post('/api/auth/logout', async (req, reply) => {
const token = req.cookies?.[SESSION_COOKIE];
if (token) await destroySession(token);
app.clearSessionCookie(reply);
app.clearCsrfCookie(reply);
return { ok: true };
});
app.get('/api/auth/me', async (req, reply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
return { user: req.user };
});
// ─────────────────────────── Password reset ───────────────────────────
// Request a reset link. Always returns ok=true so an attacker can't enumerate emails.
app.post(
'/api/auth/request-password-reset',
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
async (req) => {
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body);
if (!parsed.success) return { ok: true };
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
if (!user || user.isSuspended) return { ok: true };
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await db.insert(passwordResets).values({ tokenHash, userId: user.id, expiresAt });
const resetUrl = `${env.PUBLIC_URL}/reset-password?token=${rawToken}`;
const tpl = passwordResetEmail(user.fullName, resetUrl);
sendEmail({ to: user.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'password reset email failed'),
);
return { ok: true };
},
);
// Apply a new password using the token from the email.
app.post(
'/api/auth/reset-password',
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => {
const parsed = z
.object({
token: z.string().min(20).max(200),
password: z.string().min(10).max(200),
})
.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex');
const db = getDb();
const [reset] = await db
.select()
.from(passwordResets)
.where(and(eq(passwordResets.tokenHash, tokenHash), isNull(passwordResets.consumedAt)))
.limit(1);
if (!reset) return reply.code(400).send({ error: 'invalid_or_used_token' });
if (reset.expiresAt.getTime() < Date.now()) {
return reply.code(400).send({ error: 'token_expired' });
}
const [user] = await db.select().from(users).where(eq(users.id, reset.userId)).limit(1);
if (!user) return reply.code(400).send({ error: 'user_not_found' });
if (user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
const passwordHash = await hashPassword(parsed.data.password);
await db.transaction(async (tx) => {
await tx
.update(users)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, user.id));
await tx
.update(passwordResets)
.set({ consumedAt: new Date() })
.where(eq(passwordResets.tokenHash, tokenHash));
// Revoke all existing sessions for this user — they should re-login with the new password
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
});
return { ok: true };
},
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { getDb, firms } from '@lawdesk/db';
import { env } from '../env';
import { getStripe, getPlanConfig, stripeIsConfigured } from '../lib/stripe';
export async function billingRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// Status — what does the UI need to show? Configured at all? Current plan? Has subscription?
app.get('/api/billing/status', async (req) => {
const firmId = req.user!.firmId!;
const [firm] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1);
return {
configured: stripeIsConfigured(),
plan: firm?.plan ?? 'starter',
hasSubscription: !!firm?.stripeSubscriptionId,
hasCustomer: !!firm?.stripeCustomerId,
};
});
// Create a Checkout Session — returns the URL to redirect the user to.
app.post('/api/billing/checkout', async (req, reply) => {
const parsed = z
.object({ plan: z.enum(['pro', 'lifetime']) })
.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_plan' });
if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' });
const firmId = req.user!.firmId!;
const userEmail = req.user!.email;
const planCfg = getPlanConfig(parsed.data.plan);
if (!planCfg) return reply.code(503).send({ error: 'plan_not_configured' });
const db = getDb();
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!firm) return reply.code(404).send({ error: 'firm_not_found' });
const stripe = getStripe();
// Reuse the customer if we've made one before; otherwise let Checkout create one and we'll
// capture it on the webhook.
const session = await stripe.checkout.sessions.create({
mode: planCfg.mode,
line_items: [{ price: planCfg.priceId, quantity: 1 }],
customer: firm.stripeCustomerId ?? undefined,
customer_email: firm.stripeCustomerId ? undefined : userEmail,
client_reference_id: firmId,
metadata: { firmId, plan: planCfg.planName },
subscription_data:
planCfg.mode === 'subscription' ? { metadata: { firmId, plan: planCfg.planName } } : undefined,
success_url: `${env.PUBLIC_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.PUBLIC_URL}/billing/cancel`,
allow_promotion_codes: true,
});
return { url: session.url };
});
// Customer Portal — for managing the subscription, updating payment method, viewing invoices.
app.post('/api/billing/portal', async (req, reply) => {
if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' });
const firmId = req.user!.firmId!;
const [firm] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!firm?.stripeCustomerId) return reply.code(404).send({ error: 'no_customer' });
const stripe = getStripe();
const session = await stripe.billingPortal.sessions.create({
customer: firm.stripeCustomerId,
return_url: `${env.PUBLIC_URL}/app/settings`,
});
return { url: session.url };
});
}
+177
View File
@@ -0,0 +1,177 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { getDb, cases, clients, timeEntries } from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateCase, PlanLimitError } from '../lib/plan-limits';
const STATUSES = ['open', 'pending', 'closed', 'archived'] as const;
const createBody = z.object({
clientId: z.string().uuid(),
title: z.string().min(1).max(200).trim(),
caseNumber: z.string().max(80).optional().nullable(),
status: z.enum(STATUSES).default('open'),
practiceArea: z.string().max(120).optional().nullable(),
description: z.string().max(5000).optional().nullable(),
hourlyRate: z.coerce.number().nonnegative().optional().nullable(),
});
const updateBody = createBody.partial();
const listQuery = z.object({
q: z.string().max(160).optional(),
status: z.enum(STATUSES).optional(),
clientId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
async function assertClientBelongsToFirm(firmId: string, clientId: string): Promise<boolean> {
const [row] = await getDb()
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.firmId, firmId)))
.limit(1);
return !!row;
}
export async function casesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
app.get('/api/cases', async (req) => {
const firmId = req.user!.firmId!;
const { q, status, clientId, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const filters = [eq(cases.firmId, firmId)];
if (status) filters.push(eq(cases.status, status));
if (clientId) filters.push(eq(cases.clientId, clientId));
if (q) filters.push(or(ilike(cases.title, `%${q}%`), ilike(cases.caseNumber, `%${q}%`))!);
const where = and(...filters);
const rows = await db
.select({
id: cases.id,
title: cases.title,
caseNumber: cases.caseNumber,
status: cases.status,
practiceArea: cases.practiceArea,
hourlyRate: cases.hourlyRate,
openedAt: cases.openedAt,
clientId: cases.clientId,
clientName: clients.name,
billedMinutes: sql<number>`coalesce((select sum(${timeEntries.minutes})::int from ${timeEntries} where ${timeEntries.caseId} = ${cases.id}), 0)`,
})
.from(cases)
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(where)
.orderBy(desc(cases.openedAt))
.limit(limit)
.offset(offset);
const [count] = await db.select({ total: sql<number>`count(*)::int` }).from(cases).where(where);
return { items: rows, total: count?.total ?? 0 };
});
app.get('/api/cases/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.select({
id: cases.id,
title: cases.title,
caseNumber: cases.caseNumber,
status: cases.status,
practiceArea: cases.practiceArea,
description: cases.description,
hourlyRate: cases.hourlyRate,
openedAt: cases.openedAt,
closedAt: cases.closedAt,
clientId: cases.clientId,
clientName: clients.name,
clientEmail: clients.email,
})
.from(cases)
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.post('/api/cases', async (req, reply) => {
const firmId = req.user!.firmId!;
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
const body = createBody.parse(req.body);
if (!(await assertClientBelongsToFirm(firmId, body.clientId))) {
return reply.code(400).send({ error: 'invalid_client' });
}
if (body.status === 'open') {
try {
await assertCanCreateCase(firmId, firm.plan);
} catch (e) {
if (e instanceof PlanLimitError) return reply.code(402).send({ error: e.message, plan: firm.plan });
throw e;
}
}
const [row] = await getDb()
.insert(cases)
.values({
firmId,
clientId: body.clientId,
title: body.title,
caseNumber: body.caseNumber ?? null,
status: body.status,
practiceArea: body.practiceArea ?? null,
description: body.description ?? null,
hourlyRate: body.hourlyRate != null ? String(body.hourlyRate) : null,
})
.returning();
return reply.code(201).send(row);
});
app.patch('/api/cases/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
if (Object.keys(body).length === 0) return reply.code(400).send({ error: 'empty_body' });
if (body.clientId && !(await assertClientBelongsToFirm(firmId, body.clientId))) {
return reply.code(400).send({ error: 'invalid_client' });
}
const patch: Record<string, unknown> = { updatedAt: new Date() };
for (const [k, v] of Object.entries(body)) {
if (v === undefined) continue;
patch[k] = k === 'hourlyRate' && v != null ? String(v) : v;
}
if (body.status === 'closed') patch.closedAt = new Date();
if (body.status && body.status !== 'closed') patch.closedAt = null;
const [row] = await getDb()
.update(cases)
.set(patch)
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.returning();
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.delete('/api/cases/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.delete(cases)
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.returning({ id: cases.id });
if (!row) return reply.code(404).send({ error: 'not_found' });
return { ok: true };
});
}
+122
View File
@@ -0,0 +1,122 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { getDb, clients, cases } from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateClient, PlanLimitError } from '../lib/plan-limits';
const createBody = z.object({
name: z.string().min(1).max(160).trim(),
email: z.string().email().max(254).optional().nullable(),
phone: z.string().max(40).optional().nullable(),
address: z.string().max(500).optional().nullable(),
notes: z.string().max(5000).optional().nullable(),
});
const updateBody = createBody.partial();
const listQuery = z.object({
q: z.string().max(160).optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export async function clientsRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
app.get('/api/clients', async (req) => {
const firmId = req.user!.firmId!;
const { q, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const where = q
? and(
eq(clients.firmId, firmId),
or(ilike(clients.name, `%${q}%`), ilike(clients.email, `%${q}%`)),
)
: eq(clients.firmId, firmId);
const rows = await db
.select({
id: clients.id,
name: clients.name,
email: clients.email,
phone: clients.phone,
createdAt: clients.createdAt,
caseCount: sql<number>`(select count(*)::int from ${cases} where ${cases.clientId} = ${clients.id})`,
})
.from(clients)
.where(where)
.orderBy(desc(clients.createdAt))
.limit(limit)
.offset(offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(clients)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
app.get('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.select()
.from(clients)
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.post('/api/clients', async (req, reply) => {
const firmId = req.user!.firmId!;
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try {
await assertCanCreateClient(firmId, firm.plan);
} catch (e) {
if (e instanceof PlanLimitError) {
return reply.code(402).send({ error: e.message, plan: firm.plan });
}
throw e;
}
const body = createBody.parse(req.body);
const [row] = await getDb()
.insert(clients)
.values({ firmId, ...body })
.returning();
return reply.code(201).send(row);
});
app.patch('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
if (Object.keys(body).length === 0) return reply.code(400).send({ error: 'empty_body' });
const [row] = await getDb()
.update(clients)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.returning();
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.delete('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.delete(clients)
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.returning({ id: clients.id });
if (!row) return reply.code(404).send({ error: 'not_found' });
return { ok: true };
});
}
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getDb, contactMessages } from '@lawdesk/db';
import { sendEmail, contactAckEmail } from '../lib/email';
const contactBody = z.object({
fullName: z.string().min(1).max(120).trim(),
email: z.string().email().max(254).toLowerCase().trim(),
message: z.string().min(1).max(5000).trim(),
});
export async function contactRoutes(app: FastifyInstance) {
app.post(
'/api/contact',
{ config: { rateLimit: { max: 5, timeWindow: '10 minutes' } } },
async (req, reply) => {
const parsed = contactBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const body = parsed.data;
await getDb().insert(contactMessages).values({
fullName: body.fullName,
email: body.email,
message: body.message,
ip: req.ip ?? null,
});
const tpl = contactAckEmail(body.fullName);
sendEmail({ to: body.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'contact ack email failed'),
);
return reply.code(201).send({ ok: true });
},
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { sql } from 'drizzle-orm';
import { getDb } from '@lawdesk/db';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health', async () => ({ ok: true, ts: Date.now() }));
app.get('/api/health/db', async (_req, reply) => {
try {
await getDb().execute(sql`select 1`);
return { ok: true };
} catch (err) {
app.log.error({ err }, 'db health check failed');
return reply.code(503).send({ ok: false });
}
});
}
+562
View File
@@ -0,0 +1,562 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import {
getDb,
invoices,
invoiceItems,
clients,
cases,
timeEntries,
firms,
} from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits';
import { nextInvoiceNumber } from '../lib/invoice-numbering';
import { renderInvoicePdf } from '../lib/invoice-pdf';
import { sendEmail, invoiceEmail } from '../lib/email';
const STATUSES = ['draft', 'sent', 'paid', 'overdue', 'void'] as const;
const itemBody = z.object({
description: z.string().min(1).max(500),
quantity: z.coerce.number().positive().default(1),
rate: z.coerce.number().nonnegative(),
});
const createBody = z.object({
clientId: z.string().uuid(),
caseId: z.string().uuid().nullable().optional(),
notes: z.string().max(5000).nullable().optional(),
taxRate: z.coerce.number().min(0).max(100).default(0),
dueAt: z.string().datetime().nullable().optional(),
// Either provide explicit items or supply timeEntryIds to generate items from time entries.
items: z.array(itemBody).optional(),
timeEntryIds: z.array(z.string().uuid()).optional(),
});
const updateBody = z.object({
notes: z.string().max(5000).nullable().optional(),
taxRate: z.coerce.number().min(0).max(100).optional(),
dueAt: z.string().datetime().nullable().optional(),
});
const listQuery = z.object({
status: z.enum(STATUSES).optional(),
clientId: z.string().uuid().optional(),
caseId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
interface ItemAccumulator {
description: string;
quantity: string;
rate: string;
amount: string;
sortOrder: number;
timeEntryId?: string;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function computeTotals(items: { quantity: string; rate: string; amount: string }[], taxRate: number) {
const subtotal = items.reduce((acc, it) => acc + Number(it.amount), 0);
const total = round2(subtotal * (1 + taxRate / 100));
return { subtotal: round2(subtotal), total };
}
export async function invoicesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// List
app.get('/api/invoices', async (req) => {
const firmId = req.user!.firmId!;
const { status, clientId, caseId, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const filters = [eq(invoices.firmId, firmId)];
if (status) filters.push(eq(invoices.status, status));
if (clientId) filters.push(eq(invoices.clientId, clientId));
if (caseId) filters.push(eq(invoices.caseId, caseId));
const where = and(...filters);
const rows = await db
.select({
id: invoices.id,
number: invoices.number,
status: invoices.status,
total: invoices.total,
subtotal: invoices.subtotal,
issuedAt: invoices.issuedAt,
dueAt: invoices.dueAt,
paidAt: invoices.paidAt,
createdAt: invoices.createdAt,
clientId: invoices.clientId,
clientName: clients.name,
caseId: invoices.caseId,
caseTitle: cases.title,
})
.from(invoices)
.innerJoin(clients, eq(clients.id, invoices.clientId))
.leftJoin(cases, eq(cases.id, invoices.caseId))
.where(where)
.orderBy(desc(invoices.createdAt))
.limit(limit)
.offset(offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(invoices)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
// Get with items
app.get('/api/invoices/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [inv] = await db
.select({
id: invoices.id,
number: invoices.number,
status: invoices.status,
subtotal: invoices.subtotal,
taxRate: invoices.taxRate,
total: invoices.total,
notes: invoices.notes,
issuedAt: invoices.issuedAt,
dueAt: invoices.dueAt,
paidAt: invoices.paidAt,
createdAt: invoices.createdAt,
clientId: invoices.clientId,
clientName: clients.name,
clientEmail: clients.email,
caseId: invoices.caseId,
caseTitle: cases.title,
})
.from(invoices)
.innerJoin(clients, eq(clients.id, invoices.clientId))
.leftJoin(cases, eq(cases.id, invoices.caseId))
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!inv) return reply.code(404).send({ error: 'not_found' });
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, inv.id))
.orderBy(asc(invoiceItems.sortOrder));
return { ...inv, items };
});
// Create
app.post('/api/invoices', async (req, reply) => {
const firmId = req.user!.firmId!;
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try {
await assertCanCreateInvoice(firmId, firm.plan);
} catch (e) {
if (e instanceof PlanLimitError) return reply.code(402).send({ error: e.message, plan: firm.plan });
throw e;
}
const body = createBody.parse(req.body);
const db = getDb();
// Validate client belongs to firm
const [client] = await db
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, body.clientId), eq(clients.firmId, firmId)))
.limit(1);
if (!client) return reply.code(400).send({ error: 'invalid_client' });
// Validate case belongs to firm (and to client) if provided
if (body.caseId) {
const [c] = await db
.select({ id: cases.id })
.from(cases)
.where(and(eq(cases.id, body.caseId), eq(cases.firmId, firmId), eq(cases.clientId, body.clientId)))
.limit(1);
if (!c) return reply.code(400).send({ error: 'invalid_case' });
}
// Build line items
const accumulated: ItemAccumulator[] = [];
if (body.items && body.items.length) {
body.items.forEach((it, i) => {
accumulated.push({
description: it.description,
quantity: String(it.quantity),
rate: String(it.rate),
amount: String(round2(it.quantity * it.rate)),
sortOrder: i,
});
});
}
if (body.timeEntryIds && body.timeEntryIds.length) {
const entries = await db
.select({
id: timeEntries.id,
description: timeEntries.description,
minutes: timeEntries.minutes,
rate: timeEntries.rate,
billable: timeEntries.billable,
invoiceItemId: timeEntries.invoiceItemId,
caseId: timeEntries.caseId,
})
.from(timeEntries)
.where(and(eq(timeEntries.firmId, firmId), inArray(timeEntries.id, body.timeEntryIds)));
if (entries.length !== body.timeEntryIds.length) {
return reply.code(400).send({ error: 'invalid_time_entries' });
}
for (const e of entries) {
if (e.invoiceItemId) return reply.code(409).send({ error: 'time_entry_already_invoiced' });
if (!e.billable) return reply.code(400).send({ error: 'time_entry_not_billable' });
if (body.caseId && e.caseId !== body.caseId) {
return reply.code(400).send({ error: 'time_entry_case_mismatch' });
}
}
const startSort = accumulated.length;
entries.forEach((e, i) => {
const hours = round2(e.minutes / 60);
const rate = Number(e.rate);
accumulated.push({
description: e.description,
quantity: String(hours),
rate: String(rate),
amount: String(round2(hours * rate)),
sortOrder: startSort + i,
timeEntryId: e.id,
});
});
}
if (!accumulated.length) {
return reply.code(400).send({ error: 'no_items' });
}
const taxRate = body.taxRate;
const totals = computeTotals(accumulated, taxRate);
const number = await nextInvoiceNumber(firmId);
const created = await db.transaction(async (tx) => {
const [inv] = await tx
.insert(invoices)
.values({
firmId,
clientId: body.clientId,
caseId: body.caseId ?? null,
number,
status: 'draft',
subtotal: String(totals.subtotal),
taxRate: String(taxRate),
total: String(totals.total),
notes: body.notes ?? null,
dueAt: body.dueAt ? new Date(body.dueAt) : null,
})
.returning();
if (!inv) throw new Error('invoice_insert_failed');
const insertedItems = await tx
.insert(invoiceItems)
.values(
accumulated.map((a) => ({
invoiceId: inv.id,
description: a.description,
quantity: a.quantity,
rate: a.rate,
amount: a.amount,
sortOrder: a.sortOrder,
})),
)
.returning();
// Link the time entries (when generated from time) to their new invoice items
const updates: Array<Promise<unknown>> = [];
accumulated.forEach((a, i) => {
if (!a.timeEntryId) return;
const item = insertedItems[i];
if (!item) return;
updates.push(
tx
.update(timeEntries)
.set({ invoiceItemId: item.id, updatedAt: new Date() })
.where(eq(timeEntries.id, a.timeEntryId)),
);
});
await Promise.all(updates);
return inv;
});
return reply.code(201).send(created);
});
// Update (notes, dueAt, taxRate; only on drafts)
app.patch('/api/invoices/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' });
const patch: Record<string, unknown> = { updatedAt: new Date() };
if (body.notes !== undefined) patch.notes = body.notes;
if (body.dueAt !== undefined) patch.dueAt = body.dueAt ? new Date(body.dueAt) : null;
if (body.taxRate !== undefined) {
patch.taxRate = String(body.taxRate);
const items = await db.select().from(invoiceItems).where(eq(invoiceItems.invoiceId, id));
const totals = computeTotals(items, body.taxRate);
patch.subtotal = String(totals.subtotal);
patch.total = String(totals.total);
}
const [row] = await db.update(invoices).set(patch).where(eq(invoices.id, id)).returning();
return row;
});
// Send (draft → sent, set issuedAt). Emails the client with the PDF attached if we have
// their email on file. Email failure does not block the status change.
app.post('/api/invoices/:id/send', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' });
const now = new Date();
const [row] = await db
.update(invoices)
.set({ status: 'sent', issuedAt: now, updatedAt: now })
.where(eq(invoices.id, id))
.returning();
if (!row) return reply.code(500).send({ error: 'update_failed' });
// Render PDF + email the client (best-effort).
try {
const [client] = await db.select().from(clients).where(eq(clients.id, row.clientId)).limit(1);
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!client?.email || !firm) {
app.log.info({ invoiceId: id }, 'invoice sent, skipped email (no client email or firm missing)');
return row;
}
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, id))
.orderBy(asc(invoiceItems.sortOrder));
const pdfStream = renderInvoicePdf({
number: row.number,
status: row.status,
issuedAt: row.issuedAt,
dueAt: row.dueAt,
notes: row.notes,
subtotal: row.subtotal,
taxRate: row.taxRate,
total: row.total,
firm: { name: firm.name },
client: { name: client.name, email: client.email, address: client.address },
items: items.map((it) => ({
description: it.description,
quantity: it.quantity,
rate: it.rate,
amount: it.amount,
})),
watermark: firm.watermarkEnabled,
});
// Collect the PDF stream into a buffer.
const chunks: Buffer[] = [];
for await (const chunk of pdfStream as AsyncIterable<Buffer | string>) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const pdfBuffer = Buffer.concat(chunks);
const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
Number(row.total),
);
const dueDate = row.dueAt ? row.dueAt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : null;
const tpl = invoiceEmail({
clientName: client.name,
firmName: firm.name,
invoiceNumber: row.number,
total: totalFmt,
dueDate,
notes: row.notes,
});
sendEmail({
to: client.email,
...tpl,
attachments: [{ filename: `${row.number}.pdf`, content: pdfBuffer }],
}).catch((err) => app.log.warn({ err, invoiceId: id }, 'invoice email failed'));
} catch (err) {
app.log.warn({ err, invoiceId: id }, 'failed to render/send invoice email');
}
return row;
});
// Mark paid
app.post('/api/invoices/:id/mark-paid', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (!['sent', 'overdue'].includes(existing.status)) {
return reply.code(409).send({ error: 'invoice_not_sent' });
}
const now = new Date();
const [row] = await db
.update(invoices)
.set({ status: 'paid', paidAt: now, updatedAt: now })
.where(eq(invoices.id, id))
.returning();
return row;
});
// Void
app.post('/api/invoices/:id/void', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status === 'paid') return reply.code(409).send({ error: 'invoice_already_paid' });
const [row] = await db
.update(invoices)
.set({ status: 'void', updatedAt: new Date() })
.where(eq(invoices.id, id))
.returning();
return row;
});
// Delete (drafts only) — also unlinks time entries
app.delete('/api/invoices/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' });
await db.transaction(async (tx) => {
const items = await tx
.select({ id: invoiceItems.id })
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, id));
const itemIds = items.map((i) => i.id);
if (itemIds.length) {
await tx
.update(timeEntries)
.set({ invoiceItemId: null, updatedAt: new Date() })
.where(inArray(timeEntries.invoiceItemId, itemIds));
}
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
await tx.delete(invoices).where(eq(invoices.id, id));
});
return { ok: true };
});
// PDF download
app.get('/api/invoices/:id/pdf', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [inv] = await db
.select({
invoice: invoices,
client: clients,
firm: firms,
})
.from(invoices)
.innerJoin(clients, eq(clients.id, invoices.clientId))
.innerJoin(firms, eq(firms.id, invoices.firmId))
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!inv) return reply.code(404).send({ error: 'not_found' });
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, id))
.orderBy(asc(invoiceItems.sortOrder));
const stream = renderInvoicePdf({
number: inv.invoice.number,
status: inv.invoice.status,
issuedAt: inv.invoice.issuedAt,
dueAt: inv.invoice.dueAt,
notes: inv.invoice.notes,
subtotal: inv.invoice.subtotal,
taxRate: inv.invoice.taxRate,
total: inv.invoice.total,
firm: { name: inv.firm.name },
client: { name: inv.client.name, email: inv.client.email, address: inv.client.address },
items: items.map((it) => ({
description: it.description,
quantity: it.quantity,
rate: it.rate,
amount: it.amount,
})),
watermark: inv.firm.watermarkEnabled,
});
reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', `inline; filename="${inv.invoice.number}.pdf"`);
return reply.send(stream);
});
}
+307
View File
@@ -0,0 +1,307 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
import { getDb, timeEntries, cases, clients } from '@lawdesk/db';
const STATUSES = ['open', 'pending', 'closed', 'archived'] as const;
type CaseStatus = (typeof STATUSES)[number];
const startBody = z.object({
caseId: z.string().uuid(),
description: z.string().max(500).optional().default(''),
});
const createBody = z.object({
caseId: z.string().uuid(),
description: z.string().min(1).max(500),
startedAt: z.string().datetime(),
endedAt: z.string().datetime().optional().nullable(),
minutes: z.coerce.number().int().nonnegative().optional(),
rate: z.coerce.number().nonnegative().optional(),
billable: z.boolean().optional().default(true),
});
const updateBody = z.object({
description: z.string().min(1).max(500).optional(),
startedAt: z.string().datetime().optional(),
endedAt: z.string().datetime().nullable().optional(),
minutes: z.coerce.number().int().nonnegative().optional(),
rate: z.coerce.number().nonnegative().optional(),
billable: z.boolean().optional(),
});
const listQuery = z.object({
caseId: z.string().uuid().optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
invoiced: z.enum(['true', 'false']).optional(),
limit: z.coerce.number().int().positive().max(500).default(200),
offset: z.coerce.number().int().min(0).default(0),
});
async function loadCaseForFirm(firmId: string, caseId: string) {
const [row] = await getDb()
.select({
id: cases.id,
hourlyRate: cases.hourlyRate,
status: cases.status,
})
.from(cases)
.where(and(eq(cases.id, caseId), eq(cases.firmId, firmId)))
.limit(1);
return row ?? null;
}
function diffMinutes(startedAt: Date, endedAt: Date): number {
return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 60000));
}
export async function timeEntriesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// List
app.get('/api/time-entries', async (req) => {
const firmId = req.user!.firmId!;
const { caseId, from, to, invoiced, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const filters = [eq(timeEntries.firmId, firmId)];
if (caseId) filters.push(eq(timeEntries.caseId, caseId));
if (from) filters.push(gte(timeEntries.startedAt, new Date(from)));
if (to) filters.push(lte(timeEntries.startedAt, new Date(to)));
if (invoiced === 'true') filters.push(sql`${timeEntries.invoiceItemId} is not null`);
if (invoiced === 'false') filters.push(isNull(timeEntries.invoiceItemId));
const where = and(...filters);
const rows = await db
.select({
id: timeEntries.id,
caseId: timeEntries.caseId,
caseTitle: cases.title,
clientId: cases.clientId,
clientName: clients.name,
userId: timeEntries.userId,
description: timeEntries.description,
startedAt: timeEntries.startedAt,
endedAt: timeEntries.endedAt,
minutes: timeEntries.minutes,
rate: timeEntries.rate,
billable: timeEntries.billable,
invoiceItemId: timeEntries.invoiceItemId,
})
.from(timeEntries)
.innerJoin(cases, eq(cases.id, timeEntries.caseId))
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(where)
.orderBy(desc(timeEntries.startedAt))
.limit(limit)
.offset(offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(timeEntries)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
// Active (running) timer for the current user
app.get('/api/time-entries/active', async (req) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const [row] = await getDb()
.select({
id: timeEntries.id,
caseId: timeEntries.caseId,
caseTitle: cases.title,
clientName: clients.name,
description: timeEntries.description,
startedAt: timeEntries.startedAt,
rate: timeEntries.rate,
})
.from(timeEntries)
.innerJoin(cases, eq(cases.id, timeEntries.caseId))
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(
and(
eq(timeEntries.firmId, firmId),
eq(timeEntries.userId, userId),
isNull(timeEntries.endedAt),
),
)
.limit(1);
return { active: row ?? null };
});
// Start a timer
app.post('/api/time-entries/start', async (req, reply) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const body = startBody.parse(req.body);
// Refuse if there's already a running timer for this user
const [running] = await getDb()
.select({ id: timeEntries.id })
.from(timeEntries)
.where(
and(
eq(timeEntries.firmId, firmId),
eq(timeEntries.userId, userId),
isNull(timeEntries.endedAt),
),
)
.limit(1);
if (running) return reply.code(409).send({ error: 'timer_already_running' });
const c = await loadCaseForFirm(firmId, body.caseId);
if (!c) return reply.code(400).send({ error: 'invalid_case' });
const [row] = await getDb()
.insert(timeEntries)
.values({
firmId,
caseId: body.caseId,
userId,
description: body.description || 'Untitled work',
startedAt: new Date(),
endedAt: null,
minutes: 0,
rate: c.hourlyRate ?? '0',
billable: true,
})
.returning();
return reply.code(201).send(row);
});
// Stop a running timer
app.post('/api/time-entries/:id/stop', async (req, reply) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [entry] = await db
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.id, id),
eq(timeEntries.firmId, firmId),
eq(timeEntries.userId, userId),
),
)
.limit(1);
if (!entry) return reply.code(404).send({ error: 'not_found' });
if (entry.endedAt) return reply.code(409).send({ error: 'timer_not_running' });
const endedAt = new Date();
const minutes = diffMinutes(entry.startedAt, endedAt);
const [row] = await db
.update(timeEntries)
.set({ endedAt, minutes, updatedAt: endedAt })
.where(eq(timeEntries.id, id))
.returning();
return row;
});
// Manual entry create
app.post('/api/time-entries', async (req, reply) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const body = createBody.parse(req.body);
const c = await loadCaseForFirm(firmId, body.caseId);
if (!c) return reply.code(400).send({ error: 'invalid_case' });
const startedAt = new Date(body.startedAt);
let endedAt = body.endedAt ? new Date(body.endedAt) : null;
let minutes: number;
if (body.minutes != null) {
minutes = body.minutes;
// Manual entry with explicit duration: compute endedAt so the row isn't treated as "running"
if (!endedAt) endedAt = new Date(startedAt.getTime() + minutes * 60_000);
} else if (endedAt) {
minutes = diffMinutes(startedAt, endedAt);
} else {
minutes = 0;
}
const rate = body.rate != null ? String(body.rate) : (c.hourlyRate ?? '0');
const [row] = await getDb()
.insert(timeEntries)
.values({
firmId,
caseId: body.caseId,
userId,
description: body.description,
startedAt,
endedAt,
minutes,
rate,
billable: body.billable ?? true,
})
.returning();
return reply.code(201).send(row);
});
// Update
app.patch('/api/time-entries/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
const db = getDb();
const [existing] = await db
.select()
.from(timeEntries)
.where(and(eq(timeEntries.id, id), eq(timeEntries.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.invoiceItemId) return reply.code(409).send({ error: 'already_invoiced' });
const patch: Record<string, unknown> = { updatedAt: new Date() };
if (body.description !== undefined) patch.description = body.description;
if (body.billable !== undefined) patch.billable = body.billable;
if (body.rate !== undefined) patch.rate = String(body.rate);
const startedAt = body.startedAt ? new Date(body.startedAt) : existing.startedAt;
const endedAt =
body.endedAt === null ? null : body.endedAt ? new Date(body.endedAt) : existing.endedAt;
if (body.startedAt !== undefined) patch.startedAt = startedAt;
if (body.endedAt !== undefined) patch.endedAt = endedAt;
if (body.minutes !== undefined) {
patch.minutes = body.minutes;
} else if (body.startedAt !== undefined || body.endedAt !== undefined) {
patch.minutes = endedAt ? diffMinutes(startedAt, endedAt) : 0;
}
const [row] = await db.update(timeEntries).set(patch).where(eq(timeEntries.id, id)).returning();
return row;
});
// Delete
app.delete('/api/time-entries/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [existing] = await getDb()
.select({ id: timeEntries.id, invoiceItemId: timeEntries.invoiceItemId })
.from(timeEntries)
.where(and(eq(timeEntries.id, id), eq(timeEntries.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.invoiceItemId) return reply.code(409).send({ error: 'already_invoiced' });
await getDb().delete(timeEntries).where(eq(timeEntries.id, id));
return { ok: true };
});
}
// Re-export the type for shared usage if needed
export type { CaseStatus };
+53
View File
@@ -0,0 +1,53 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, eq, gte, sql } from 'drizzle-orm';
import { getDb, toolUsage } from '@lawdesk/db';
const TOOL_NAMES = [
'hourly-rate-calculator',
'case-profitability',
'billable-hours-tracker',
'document-templates',
] as const;
const logBody = z.object({
tool: z.enum(TOOL_NAMES),
sessionId: z.string().max(64).optional(),
});
export async function toolUsageRoutes(app: FastifyInstance) {
// Log a usage event. Rate-limited per IP so a malicious caller can't pump up "online now" counts.
app.post(
'/api/tool-usage',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (req, reply) => {
const parsed = logBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_tool' });
await getDb().insert(toolUsage).values({
tool: parsed.data.tool,
sessionId: parsed.data.sessionId ?? null,
ip: req.ip ?? null,
});
return { ok: true };
},
);
// Per-tool count of unique sessions in the last 5 minutes — what the public pages display
// as "X online". Public route, very cheap query.
app.get('/api/tool-usage/online', async () => {
const since = new Date(Date.now() - 5 * 60 * 1000);
const rows = await getDb()
.select({
tool: toolUsage.tool,
// Distinct (session_id, ip) so multiple page hits from the same browser don't multi-count
count: sql<number>`count(distinct coalesce(${toolUsage.sessionId}, host(${toolUsage.ip}::inet)))::int`,
})
.from(toolUsage)
.where(gte(toolUsage.createdAt, since))
.groupBy(toolUsage.tool);
const map: Record<string, number> = {};
for (const r of rows) map[r.tool] = r.count;
return { online: map, since: since.toISOString() };
});
}
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { eq } from 'drizzle-orm';
import { getDb, firms, users } from '@lawdesk/db';
import { env } from '../env';
import { getStripe } from '../lib/stripe';
import { sendEmail, planUpgradedEmail } from '../lib/email';
import { logAudit } from '../lib/audit';
// Registered as a sub-app so its own buffer-only content-type parser doesn't affect the rest of
// the API. Stripe webhooks need the raw request body to verify the signature.
export async function stripeWebhookRoute(app: FastifyInstance) {
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
app.post('/api/webhooks/stripe', async (req, reply) => {
if (!env.STRIPE_WEBHOOK_SECRET) {
return reply.code(503).send({ error: 'webhook_not_configured' });
}
const sig = req.headers['stripe-signature'];
if (!sig || typeof sig !== 'string') {
return reply.code(400).send({ error: 'missing_signature' });
}
const stripe = getStripe();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body as Buffer, sig, env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
app.log.warn({ err }, 'stripe webhook signature verification failed');
return reply.code(400).send({ error: 'invalid_signature' });
}
try {
await handleEvent(event, app);
} catch (err) {
app.log.error({ err, type: event.type }, 'stripe webhook handler failed');
// Return 200 anyway for some failures? No — let Stripe retry on transient failures.
return reply.code(500).send({ error: 'handler_failed' });
}
return { received: true };
});
}
async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const firmId = session.client_reference_id ?? (session.metadata?.firmId as string | undefined);
const planFromMeta = (session.metadata?.plan ?? '') as 'pro' | 'lifetime' | '';
if (!firmId) return app.log.warn({ session: session.id }, 'checkout.session.completed without firmId');
// Determine plan from session.mode if metadata didn't pin it.
const plan: 'pro' | 'lifetime' = planFromMeta || (session.mode === 'subscription' ? 'pro' : 'lifetime');
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null;
const subscriptionId =
typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null;
await applyPlan(firmId, plan, { customerId, subscriptionId });
await sendPlanUpgradedNotice(firmId, plan);
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const sub = event.data.object as Stripe.Subscription;
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
if (!firmId) return;
// Only flip to 'pro' while the subscription is paying.
const active = ['active', 'trialing', 'past_due'].includes(sub.status);
if (active) await applyPlan(firmId, 'pro', { subscriptionId: sub.id });
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
if (!firmId) return;
await applyPlan(firmId, 'starter', { subscriptionId: null });
break;
}
case 'invoice.payment_failed': {
// Optional: surface to the user via email later. For now, just log.
const invoice = event.data.object as Stripe.Invoice;
app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed');
break;
}
default:
// Ignore — Stripe sends many event types we don't care about.
break;
}
}
async function applyPlan(
firmId: string,
plan: 'starter' | 'pro' | 'lifetime',
ids: { customerId?: string | null; subscriptionId?: string | null } = {},
) {
const patch: Record<string, unknown> = {
plan,
watermarkEnabled: plan === 'starter',
updatedAt: new Date(),
};
if (ids.customerId !== undefined) patch.stripeCustomerId = ids.customerId;
if (ids.subscriptionId !== undefined) patch.stripeSubscriptionId = ids.subscriptionId;
await getDb().update(firms).set(patch).where(eq(firms.id, firmId));
await logAudit({
firmId,
action: `billing.plan.${plan}`,
meta: { stripeCustomerId: ids.customerId, stripeSubscriptionId: ids.subscriptionId },
});
}
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
const owners = await getDb()
.select({ email: users.email, fullName: users.fullName })
.from(users)
.where(eq(users.firmId, firmId));
const label = plan === 'pro' ? 'Professional' : 'Lifetime';
for (const u of owners) {
const tpl = planUpgradedEmail(u.fullName, label);
await sendEmail({ to: u.email, ...tpl });
}
}
+150
View File
@@ -0,0 +1,150 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import staticPlugin from '@fastify/static';
import { env, isProd } from './env';
import { initSentry, captureError } from './lib/sentry';
import { authPlugin } from './auth/plugin';
import { csrfPlugin } from './auth/csrf';
import { authRoutes } from './routes/auth';
import { healthRoutes } from './routes/health';
import { contactRoutes } from './routes/contact';
import { clientsRoutes } from './routes/clients';
import { casesRoutes } from './routes/cases';
import { timeEntriesRoutes } from './routes/time-entries';
import { invoicesRoutes } from './routes/invoices';
import { adminRoutes } from './routes/admin';
import { accountRoutes } from './routes/account';
import { toolUsageRoutes } from './routes/tool-usage';
import { billingRoutes } from './routes/billing';
import { stripeWebhookRoute } from './routes/webhooks-stripe';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
initSentry();
export async function buildServer() {
const app = Fastify({
logger: isProd
? { level: 'info' }
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
trustProxy: true,
bodyLimit: 5 * 1024 * 1024,
});
// Register the global error handler EARLY so it wins over plugin-default handlers and
// catches ZodErrors thrown by .parse() inside route handlers.
app.setErrorHandler((err, req, reply) => {
if (err instanceof ZodError || (err as { validation?: unknown }).validation || err.name === 'ZodError') {
req.log.info({ err }, 'validation error');
return reply
.code(400)
.send({ error: 'validation', details: err instanceof ZodError ? err.errors : (err as Error).message });
}
if ((err as { statusCode?: number }).statusCode === 429) {
// Let @fastify/rate-limit handle its own response shape.
return reply.send(err);
}
req.log.error({ err }, 'unhandled error');
captureError(err, { url: req.url, method: req.method, userId: req.user?.id });
return reply.code(500).send({ error: 'internal_error' });
});
// CSP: tight in production, off in dev (Vite HMR injects inline scripts/styles + uses eval)
await app.register(helmet, {
contentSecurityPolicy: isProd
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}
: false,
crossOriginEmbedderPolicy: false,
});
await app.register(cookie, {
secret: env.SESSION_SECRET,
});
// Global rate limit floor — per-route limits override below.
await app.register(rateLimit, {
global: true,
max: 600,
timeWindow: '1 minute',
keyGenerator: (req) => `${req.ip}`,
});
// Stripe webhook BEFORE auth/CSRF — registered as its own subapp with a buffer-only parser
// so signature verification works against the raw body.
await app.register(stripeWebhookRoute);
await app.register(authPlugin);
await app.register(csrfPlugin);
await app.register(authRoutes);
await app.register(healthRoutes);
await app.register(contactRoutes);
await app.register(clientsRoutes);
await app.register(casesRoutes);
await app.register(timeEntriesRoutes);
await app.register(invoicesRoutes);
await app.register(adminRoutes);
await app.register(accountRoutes);
await app.register(toolUsageRoutes);
await app.register(billingRoutes);
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDist)) {
await app.register(staticPlugin, {
root: webDist,
prefix: '/',
cacheControl: true,
maxAge: '1y',
immutable: true,
decorateReply: false,
});
// SPA fallback: any non-/api path returns index.html
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/')) {
return reply.code(404).send({ error: 'not_found' });
}
return reply.type('text/html').sendFile('index.html', webDist);
});
} else {
app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served');
}
return app;
}
const isEntrypoint = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isEntrypoint) {
const app = await buildServer();
try {
await app.listen({ host: '0.0.0.0', port: env.PORT });
app.log.info(`eLegal Software API listening on :${env.PORT}`);
} catch (err) {
app.log.error(err);
captureError(err);
process.exit(1);
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": false,
"declaration": false,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"]
}