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