commit 0700d54225ccc252db11cb5222160db3559e6f67 Author: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sun Apr 26 02:42:42 2026 -0400 Initial commit — eLegal Software monorepo Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4ae7b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# ───────────────────────────────────────────── +# Server +# ───────────────────────────────────────────── +NODE_ENV=development +PORT=8080 +PUBLIC_URL=http://localhost:8080 +COOKIE_DOMAIN= + +# 32+ random bytes, hex. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET=replace-me-with-32-byte-hex +CSRF_SECRET=replace-me-with-32-byte-hex + +# Comma-separated emails. Any user with one of these emails is auto-promoted to superadmin +# on login/signup and gains access to /admin. +SUPERADMIN_EMAILS= + +# ───────────────────────────────────────────── +# DigitalOcean Managed Postgres +# ───────────────────────────────────────────── +# Format: postgresql://user:pass@host:25060/dbname?sslmode=require +DATABASE_URL=postgresql://doadmin:password@db-postgresql-nyc1-xxxxx.b.db.ondigitalocean.com:25060/defaultdb?sslmode=require +DATABASE_CA_CERT_PATH=./certs/do-ca.crt + +# ───────────────────────────────────────────── +# DigitalOcean Spaces (S3-compatible) +# ───────────────────────────────────────────── +SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com +SPACES_REGION=nyc3 +SPACES_BUCKET=lawdesk-uploads +SPACES_ACCESS_KEY= +SPACES_SECRET_KEY= + +# ───────────────────────────────────────────── +# Email (Resend) +# ───────────────────────────────────────────── +RESEND_API_KEY= +EMAIL_FROM="eLegal Software " + +# ───────────────────────────────────────────── +# Stripe +# ───────────────────────────────────────────── +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_PRO= +STRIPE_PRICE_LIFETIME= + +# ───────────────────────────────────────────── +# Sentry (optional — leave blank to disable error reporting) +# ───────────────────────────────────────────── +SENTRY_DSN_API= +# Web DSN must be exposed to the browser bundle, so prefix with VITE_ +VITE_SENTRY_DSN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5bc5c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +node_modules +dist +build +.next +.turbo +.cache +coverage + +.env +.env.local +.env.*.local +!.env.example + +*.log +npm-debug.log* +pnpm-debug.log* + +.DS_Store +Thumbs.db + +.vscode +.idea + +tmp/restart.txt +logs/ +uploads/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +20 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7c890e --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# eLegal Software + +All-in-one practice management for law firms. Single Node app that serves both the React SPA and the API on one port — designed to run behind Plesk's Node.js extension on a single domain. + +## Stack + +- **Frontend** — Vite + React 18 + TypeScript + Tailwind + Framer Motion + Recharts + lucide-react +- **API** — Fastify 5 + TypeScript + Zod +- **DB** — Drizzle ORM → DigitalOcean Managed Postgres (TLS) +- **Auth** — local: argon2id passwords + Postgres-backed sessions in httpOnly cookies (no third-party auth provider) +- **Storage** — DigitalOcean Spaces (S3-compatible, presigned uploads) +- **Email** — Resend +- **Payments** — Stripe +- **Hosting** — Plesk + Phusion Passenger (Node 20 LTS) + +## Repository layout + +``` +. +├── apps/ +│ ├── api/ # Fastify server (also serves built web/dist in prod) +│ └── web/ # Vite + React SPA +├── packages/ +│ └── db/ # Drizzle schema + migrations (shared) +├── certs/ # DO Postgres CA cert (do-ca.crt) — not in git +├── scripts/ +│ └── plesk-deploy.sh +├── tmp/restart.txt # touched by deploy script to bounce Passenger +└── app.js # Plesk entrypoint (loads apps/api/dist/server.js) +``` + +## Local development + +Prerequisites: Node 20+, pnpm 9+, a Postgres database (managed DO instance, or local). + +```bash +cp .env.example .env +# fill in DATABASE_URL, SESSION_SECRET, CSRF_SECRET (generate with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`) + +pnpm install +pnpm db:generate # generate SQL migrations from schema +pnpm db:migrate # apply to the DB + +pnpm dev # starts api on :8080 and web on :5173 (proxies /api → :8080) +``` + +Visit http://localhost:5173. + +## Production build + +```bash +pnpm build # builds packages/db → apps/web → apps/api +pnpm start # runs node app.js → apps/api/dist/server.js +``` + +The API serves `apps/web/dist` at `/` with SPA fallback and routes `/api/*` to Fastify handlers. + +## Plesk deployment (single domain) + +1. **Create the domain** in Plesk and enable **Let's Encrypt** TLS. +2. **Install Node.js extension** (Plesk → Extensions → "Node.js"). Set Node version to **20.x** in the domain's Node.js settings. +3. **Pull the repo** into the domain's document root via Plesk → Git, or `git clone` over SSH into `/var/www/vhosts/yourdomain.com/httpdocs`. +4. **Node.js settings** in the Plesk panel for that domain: + - **Application root** → the repo root + - **Document root** → leave as default; nginx will proxy to Passenger + - **Application startup file** → `app.js` + - **Custom environment variables** → set every entry from `.env.example` (Passenger does **not** read `.env` files) +5. **Add DigitalOcean's Postgres CA** to `certs/do-ca.crt` (download from the DO Postgres dashboard) and set `DATABASE_CA_CERT_PATH=./certs/do-ca.crt`. +6. **Run the deploy script** over SSH: + ```bash + bash scripts/plesk-deploy.sh + ``` + This installs deps, builds, runs migrations, then `touch tmp/restart.txt` to bounce Passenger. +7. **Stripe webhook** — add `https://yourdomain.com/api/stripe/webhook` in the Stripe dashboard. In Plesk → Apache & nginx → "Additional nginx directives" add: + ```nginx + location /api/stripe/webhook { + proxy_request_buffering off; + } + ``` +8. **Auto-deploy on push** (optional) — in Plesk → Git, enable "Enable additional deploy actions" and set the script to `bash scripts/plesk-deploy.sh`. + +## Environment variables + +See `.env.example` for the full list. Highlights: + +| Var | Purpose | +|---|---| +| `DATABASE_URL` | DO Managed Postgres connection string (`?sslmode=require`) | +| `DATABASE_CA_CERT_PATH` | Path to DO CA cert (recommended for `rejectUnauthorized: true`) | +| `SESSION_SECRET` | 32+ byte hex used to sign cookies and as Fastify cookie secret | +| `CSRF_SECRET` | 32+ byte hex for CSRF token derivation | +| `SPACES_*` | DigitalOcean Spaces credentials + bucket | +| `RESEND_API_KEY` | Transactional email | +| `STRIPE_*` | Billing | +| `PORT` | Port for Fastify (Plesk usually injects this; falls back to 8080) | +| `COOKIE_DOMAIN` | Set to your apex domain in production (e.g. `lawdesk.com`); leave blank in dev | + +## Database commands + +```bash +pnpm db:generate # create a new migration from schema changes +pnpm db:migrate # apply pending migrations +pnpm --filter @lawdesk/db studio # open Drizzle Studio +``` + +## Auth model + +- Passwords hashed with **argon2id** (64MB memory cost). +- Cookie holds a 32-byte random token; the DB stores its **SHA-256 hash** (so a DB read can't impersonate users). +- Sessions are 30-day sliding (touched on every request). +- Login rate limited: 5 failed attempts per email per 15 minutes. +- All `/api/*` requests automatically attach `req.user` if a valid session cookie is present. Use `app.requireAuth` / `app.requireFirm` as preHandler guards on protected routes. + +## What's next + +- Wire DO Spaces upload routes for documents +- Build the `/app` dashboard (cases, time tracking, invoices) +- Free public tools (`/tools/*`) +- Stripe checkout + webhook +- Email templates via Resend +- pg-boss background jobs diff --git a/app.js b/app.js new file mode 100644 index 0000000..8e4c4f8 --- /dev/null +++ b/app.js @@ -0,0 +1,3 @@ +// app.js — kept for Plesk configs that still point to app.js. +// The canonical entrypoint is server.js — this just forwards to it. +await import('./server.js'); diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..1bb2736 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,43 @@ +{ + "name": "@lawdesk/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/server.ts", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json --noEmit", + "start": "tsx src/server.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@fastify/cookie": "^11.0.1", + "@fastify/cors": "^10.0.1", + "@fastify/helmet": "^12.0.1", + "@fastify/multipart": "^9.0.1", + "@fastify/rate-limit": "^10.2.1", + "@fastify/static": "^8.0.3", + "@lawdesk/db": "workspace:*", + "@sentry/node": "^8.45.0", + "argon2": "^0.41.1", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.36.4", + "fastify": "^5.1.0", + "fastify-plugin": "^5.0.1", + "fastify-type-provider-zod": "^4.0.2", + "pg": "^8.13.1", + "pdfkit": "^0.15.0", + "pino": "^9.5.0", + "resend": "^4.0.1", + "stripe": "^17.4.0", + "tsx": "^4.19.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "@types/pdfkit": "^0.13.5", + "@types/pg": "^8.11.10", + "pino-pretty": "^11.3.0", + "typescript": "^5.6.3" + } +} diff --git a/apps/api/src/auth/csrf.ts b/apps/api/src/auth/csrf.ts new file mode 100644 index 0000000..dffe921 --- /dev/null +++ b/apps/api/src/auth/csrf.ts @@ -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'] }); diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts new file mode 100644 index 0000000..ba3d3f7 --- /dev/null +++ b/apps/api/src/auth/password.ts @@ -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 { + return argon2.hash(password, ARGON2_OPTIONS); +} + +export function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password); +} diff --git a/apps/api/src/auth/plugin.ts b/apps/api/src/auth/plugin.ts new file mode 100644 index 0000000..2c7c1c3 --- /dev/null +++ b/apps/api/src/auth/plugin.ts @@ -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; + requireFirm: (req: FastifyRequest, reply: FastifyReply) => Promise; + requireSuperadmin: (req: FastifyRequest, reply: FastifyReply) => Promise; + 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' }); diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts new file mode 100644 index 0000000..287e260 --- /dev/null +++ b/apps/api/src/auth/sessions.ts @@ -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 { + const id = hashSessionToken(token); + await getDb().delete(sessions).where(eq(sessions.id, id)); +} + +export async function purgeExpiredSessions(): Promise { + await getDb().delete(sessions).where(lt(sessions.expiresAt, new Date())); +} diff --git a/apps/api/src/auth/superadmin.ts b/apps/api/src/auth/superadmin.ts new file mode 100644 index 0000000..f6ae4c7 --- /dev/null +++ b/apps/api/src/auth/superadmin.ts @@ -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; +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..cdcc618 --- /dev/null +++ b/apps/api/src/env.ts @@ -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 '), + 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'; diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..652119e --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -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 { + 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, + }); +} diff --git a/apps/api/src/lib/email.ts b/apps/api/src/lib/email.ts new file mode 100644 index 0000000..9a9d638 --- /dev/null +++ b/apps/api/src/lib/email.ts @@ -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 { + 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 ` +eLegal Software + +
+
eLegal Software
+
${bodyHtml}
+
© ${new Date().getFullYear()} eLegal Software. You're receiving this because of activity on your account.
+
+`; +} + +export function welcomeEmail(toName: string | null, verifyUrl: string | null) { + const name = toName?.split(' ')[0] ?? 'there'; + const verifyBlock = verifyUrl + ? `

Please confirm your email address so we can send you important updates:

+

Verify my email

+

Or paste this link into your browser: ${verifyUrl}

` + : ''; + return { + subject: 'Welcome to eLegal Software', + html: shell( + `

Hi ${name},

+

Welcome to eLegal Software. Your account is set up and you're ready to add your first client and case.

+ ${verifyBlock} +

If you have questions, just reply to this email — a real person will see it.

+

— The eLegal Software team

`, + ), + 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( + `

Hi ${name},

+

We got a request to reset the password on your eLegal Software account. Click below to choose a new one:

+

Reset password

+

Or paste this link into your browser: ${resetUrl}

+

This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.

`, + ), + 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( + `

Hi ${name},

+

Thanks for upgrading. Your firm is now on the ${plan} plan and the limits and watermarks have been lifted.

+

Open eLegal Software

+

Manage your subscription anytime from Settings → Billing.

`, + ), + 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 ? `

Due on ${opts.dueDate}.

` : ''; + const notesLine = opts.notes + ? `

${opts.notes}

` + : ''; + return { + subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`, + html: shell( + `

Hi ${opts.clientName.split(' ')[0]},

+

${opts.firmName} sent you a new invoice.

+

${opts.invoiceNumber}${opts.total}

+ ${dueLine} + ${notesLine} +

The PDF is attached. Reply to this email if you have any questions.

`, + ), + 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( + `

Hi ${name},

+

Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.

+

— The eLegal Software team

`, + ), + 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`, + }; +} diff --git a/apps/api/src/lib/firm.ts b/apps/api/src/lib/firm.ts new file mode 100644 index 0000000..dadaf19 --- /dev/null +++ b/apps/api/src/lib/firm.ts @@ -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 { + 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, + }; +} diff --git a/apps/api/src/lib/invoice-numbering.ts b/apps/api/src/lib/invoice-numbering.ts new file mode 100644 index 0000000..de142ef --- /dev/null +++ b/apps/api/src/lib/invoice-numbering.ts @@ -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 { + const year = new Date().getUTCFullYear(); + const prefix = `INV-${year}-`; + + const [row] = await getDb() + .select({ count: sql`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')}`; +} diff --git a/apps/api/src/lib/invoice-pdf.ts b/apps/api/src/lib/invoice-pdf.ts new file mode 100644 index 0000000..7070c43 --- /dev/null +++ b/apps/api/src/lib/invoice-pdf.ts @@ -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; +} diff --git a/apps/api/src/lib/plan-limits.ts b/apps/api/src/lib/plan-limits.ts new file mode 100644 index 0000000..d0e7e4f --- /dev/null +++ b/apps/api/src/lib/plan-limits.ts @@ -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 = { + 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`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`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`count(*)::int` }) + .from(invoices) + .where(and(eq(invoices.firmId, firmId), gte(invoices.createdAt, monthStart))); + if ((row?.count ?? 0) >= limit) throw new PlanLimitError('invoicesPerMonth', plan); +} diff --git a/apps/api/src/lib/sentry.ts b/apps/api/src/lib/sentry.ts new file mode 100644 index 0000000..98d2adf --- /dev/null +++ b/apps/api/src/lib/sentry.ts @@ -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): 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 }; diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts new file mode 100644 index 0000000..bbdb8fc --- /dev/null +++ b/apps/api/src/lib/stripe.ts @@ -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; +} diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts new file mode 100644 index 0000000..251a09c --- /dev/null +++ b/apps/api/src/routes/account.ts @@ -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 = { + 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`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 }; + }); +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..02fa194 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -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`(select count(*)::int from ${firms})`, + users: sql`(select count(*)::int from ${users})`, + cases: sql`(select count(*)::int from ${cases})`, + clients: sql`(select count(*)::int from ${clients})`, + invoices: sql`(select count(*)::int from ${invoices})`, + unresolvedContact: sql`(select count(*)::int from ${contactMessages} where ${contactMessages.resolvedAt} is null)`, + }) + .from(sql`(select 1) as one`); + + const [paidTotalsRow] = await db + .select({ + paidTotal: sql`coalesce(sum(${invoices.total})::text, '0')`, + }) + .from(invoices) + .where(eq(invoices.status, 'paid')); + + const planRows = await db + .select({ plan: firms.plan, count: sql`count(*)::int` }) + .from(firms) + .groupBy(firms.plan); + + const signups = await db + .select({ + day: sql`to_char(date_trunc('day', ${users.createdAt}), 'YYYY-MM-DD')`, + count: sql`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[] = []; + 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` 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`(select count(*)::int from ${clients} where ${clients.firmId} = ${id})`, + cases: sql`(select count(*)::int from ${cases} where ${cases.firmId} = ${id})`, + invoices: sql`(select count(*)::int from ${invoices} where ${invoices.firmId} = ${id})`, + paidTotal: sql`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 = []; + 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`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 = []; + 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`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 = []; + 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`count(*)::int`, + }) + .from(toolUsage) + .where(gte(toolUsage.createdAt, since30)) + .groupBy(toolUsage.tool) + .orderBy(sql`count(*) desc`); + return { items: rows }; + }); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..26e7c02 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -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 { + const since = new Date(Date.now() - 15 * 60 * 1000); + const db = getDb(); + const rows = await db + .select({ count: sql`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 }; + }, + ); +} diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts new file mode 100644 index 0000000..ac2cf81 --- /dev/null +++ b/apps/api/src/routes/billing.ts @@ -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 }; + }); +} diff --git a/apps/api/src/routes/cases.ts b/apps/api/src/routes/cases.ts new file mode 100644 index 0000000..7d258e2 --- /dev/null +++ b/apps/api/src/routes/cases.ts @@ -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 { + 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`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`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 = { 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 }; + }); +} diff --git a/apps/api/src/routes/clients.ts b/apps/api/src/routes/clients.ts new file mode 100644 index 0000000..710d895 --- /dev/null +++ b/apps/api/src/routes/clients.ts @@ -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`(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`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 }; + }); +} diff --git a/apps/api/src/routes/contact.ts b/apps/api/src/routes/contact.ts new file mode 100644 index 0000000..5e321f4 --- /dev/null +++ b/apps/api/src/routes/contact.ts @@ -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 }); + }, + ); +} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..702035e --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -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 }); + } + }); +} diff --git a/apps/api/src/routes/invoices.ts b/apps/api/src/routes/invoices.ts new file mode 100644 index 0000000..6bb89c0 --- /dev/null +++ b/apps/api/src/routes/invoices.ts @@ -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`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> = []; + 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 = { 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) { + 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); + }); +} diff --git a/apps/api/src/routes/time-entries.ts b/apps/api/src/routes/time-entries.ts new file mode 100644 index 0000000..6e2a2e2 --- /dev/null +++ b/apps/api/src/routes/time-entries.ts @@ -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`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 = { 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 }; diff --git a/apps/api/src/routes/tool-usage.ts b/apps/api/src/routes/tool-usage.ts new file mode 100644 index 0000000..cadf8c8 --- /dev/null +++ b/apps/api/src/routes/tool-usage.ts @@ -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`count(distinct coalesce(${toolUsage.sessionId}, host(${toolUsage.ip}::inet)))::int`, + }) + .from(toolUsage) + .where(gte(toolUsage.createdAt, since)) + .groupBy(toolUsage.tool); + + const map: Record = {}; + for (const r of rows) map[r.tool] = r.count; + return { online: map, since: since.toISOString() }; + }); +} diff --git a/apps/api/src/routes/webhooks-stripe.ts b/apps/api/src/routes/webhooks-stripe.ts new file mode 100644 index 0000000..7b3d9e3 --- /dev/null +++ b/apps/api/src/routes/webhooks-stripe.ts @@ -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 = { + 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 }); + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..d9267f8 --- /dev/null +++ b/apps/api/src/server.ts @@ -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); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..b204817 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": false, + "declaration": false, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..90a6140 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,25 @@ + + + + + + + + + eLegal Software - All-in-One Practice Management for Law Firms + + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..6ffe1b8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@lawdesk/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@sentry/react": "^8.45.0", + "@tanstack/react-query": "^5.62.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^11.13.1", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", + "react-router-dom": "^6.28.0", + "recharts": "^2.13.3", + "tailwind-merge": "^2.5.5", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.9.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/favicon.png b/apps/web/public/favicon.png new file mode 100644 index 0000000..1e4a0e6 Binary files /dev/null and b/apps/web/public/favicon.png differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..7406a1c --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/web/public/logo-dark.png b/apps/web/public/logo-dark.png new file mode 100644 index 0000000..ce63191 Binary files /dev/null and b/apps/web/public/logo-dark.png differ diff --git a/apps/web/public/logo-light.png b/apps/web/public/logo-light.png new file mode 100644 index 0000000..8d5e41b Binary files /dev/null and b/apps/web/public/logo-light.png differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..8aad71c --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,90 @@ +import { Route, Routes } from 'react-router-dom'; +import LandingPage from './pages/LandingPage'; +import LoginPage from './pages/LoginPage'; +import SignupPage from './pages/SignupPage'; +import ForgotPasswordPage from './pages/ForgotPasswordPage'; +import ResetPasswordPage from './pages/ResetPasswordPage'; +import BillingSuccessPage from './pages/billing/BillingSuccessPage'; +import BillingCancelPage from './pages/billing/BillingCancelPage'; +import { AppLayout } from './components/app/AppLayout'; +import DashboardPage from './pages/app/DashboardPage'; +import ClientsPage from './pages/app/ClientsPage'; +import ClientDetailPage from './pages/app/ClientDetailPage'; +import CasesPage from './pages/app/CasesPage'; +import CaseDetailPage from './pages/app/CaseDetailPage'; +import TimePage from './pages/app/TimePage'; +import InvoicesPage from './pages/app/InvoicesPage'; +import InvoiceDetailPage from './pages/app/InvoiceDetailPage'; +import AccountSettingsPage from './pages/app/AccountSettingsPage'; +import { CookieBanner } from './components/CookieBanner'; +import { AdminLayout } from './components/admin/AdminLayout'; +import AdminDashboardPage from './pages/admin/AdminDashboardPage'; +import AdminFirmsPage from './pages/admin/AdminFirmsPage'; +import AdminFirmDetailPage from './pages/admin/AdminFirmDetailPage'; +import AdminUsersPage from './pages/admin/AdminUsersPage'; +import AdminContactPage from './pages/admin/AdminContactPage'; +import AdminAuditPage from './pages/admin/AdminAuditPage'; +import ToolsIndexPage from './pages/tools/ToolsIndexPage'; +import HourlyRateCalculatorPage from './pages/tools/HourlyRateCalculatorPage'; +import CaseProfitabilityPage from './pages/tools/CaseProfitabilityPage'; +import BillableHoursTrackerPage from './pages/tools/BillableHoursTrackerPage'; +import DocumentTemplatesPage from './pages/tools/DocumentTemplatesPage'; +import BlogIndexPage from './pages/blog/BlogIndexPage'; +import BlogPostPage from './pages/blog/BlogPostPage'; +import PrivacyPage from './pages/legal/PrivacyPage'; +import TermsPage from './pages/legal/TermsPage'; +import CookiesPage from './pages/legal/CookiesPage'; + +export default function App() { + return ( + <> + + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + + ); +} diff --git a/apps/web/src/components/CookieBanner.tsx b/apps/web/src/components/CookieBanner.tsx new file mode 100644 index 0000000..e8f4c43 --- /dev/null +++ b/apps/web/src/components/CookieBanner.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { Cookie, X } from 'lucide-react'; + +const STORAGE_KEY = 'lawdesk:cookie-consent'; + +type Consent = 'all' | 'essentials'; + +export function getConsent(): Consent | null { + if (typeof localStorage === 'undefined') return null; + const v = localStorage.getItem(STORAGE_KEY); + return v === 'all' || v === 'essentials' ? v : null; +} + +function setConsent(v: Consent) { + localStorage.setItem(STORAGE_KEY, v); + window.dispatchEvent(new CustomEvent('lawdesk:consent-changed', { detail: v })); +} + +export function CookieBanner() { + const [open, setOpen] = useState(false); + + useEffect(() => { + setOpen(getConsent() === null); + }, []); + + if (!open) return null; + + function choose(v: Consent) { + setConsent(v); + setOpen(false); + } + + return ( +
+
+
+
+ +
+
+

Cookies on this site

+

+ We use a few essential cookies to keep you signed in and secure. With your permission + we'd also like to use optional cookies to understand how the product is used so we can + improve it. You can change this choice anytime from your account settings. +

+
+ + + + Learn more + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx new file mode 100644 index 0000000..ffd666b --- /dev/null +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -0,0 +1,57 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { LogOut } from 'lucide-react'; +import { useLogout, useMe } from '@/hooks/useAuth'; +import { AdminSidebar } from './AdminSidebar'; + +export function AdminLayout() { + const me = useMe(); + const logout = useLogout(); + + if (me.isLoading) { + return
Loading…
; + } + if (!me.data) return ; + if (!me.data.isSuperadmin) return ; + + return ( +
+ +
+
+

Signed in as {me.data.email}

+ +
+
+ +
+
+
+ ); +} + +export function AdminPageHeader({ + title, + description, + action, +}: { + title: string; + description?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +} diff --git a/apps/web/src/components/admin/AdminSidebar.tsx b/apps/web/src/components/admin/AdminSidebar.tsx new file mode 100644 index 0000000..9efec2c --- /dev/null +++ b/apps/web/src/components/admin/AdminSidebar.tsx @@ -0,0 +1,81 @@ +import { NavLink } from 'react-router-dom'; +import { + LayoutDashboard, + Building2, + Users, + MessageSquare, + ShieldAlert, + ArrowLeft, + ScrollText, +} from 'lucide-react'; +import type { ComponentType } from 'react'; +import { cn } from '@/lib/cn'; + +interface Item { + to: string; + label: string; + icon: ComponentType<{ className?: string }>; +} + +const NAV: Item[] = [ + { to: '/admin', label: 'Overview', icon: LayoutDashboard }, + { to: '/admin/firms', label: 'Firms', icon: Building2 }, + { to: '/admin/users', label: 'Users', icon: Users }, + { to: '/admin/contact', label: 'Contact inbox', icon: MessageSquare }, + { to: '/admin/audit', label: 'Audit log', icon: ScrollText }, +]; + +export function AdminSidebar() { + return ( + + ); +} + +function NavItem({ item }: { item: Item }) { + return ( + + cn( + 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition', + isActive + ? 'bg-white text-ink-950 font-medium shadow' + : 'text-ink-300 hover:bg-ink-900/60 hover:text-white', + ) + } + > + + {item.label} + + ); +} diff --git a/apps/web/src/components/app/AppLayout.tsx b/apps/web/src/components/app/AppLayout.tsx new file mode 100644 index 0000000..83f3095 --- /dev/null +++ b/apps/web/src/components/app/AppLayout.tsx @@ -0,0 +1,48 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useMe } from '@/hooks/useAuth'; +import { Sidebar } from './Sidebar'; +import { Topbar } from './Topbar'; + +export function AppLayout() { + const me = useMe(); + + if (me.isLoading) { + return
Loading…
; + } + + if (!me.data) { + return ; + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +} + +export function PageHeader({ + title, + description, + action, +}: { + title: string; + description?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +} diff --git a/apps/web/src/components/app/BillingCard.tsx b/apps/web/src/components/app/BillingCard.tsx new file mode 100644 index 0000000..dd4c10d --- /dev/null +++ b/apps/web/src/components/app/BillingCard.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { CreditCard, Sparkles, Crown } from 'lucide-react'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { useBillingStatus, useStartCheckout, useOpenPortal } from '@/hooks/useBilling'; + +const PLAN_TONES: Record<'starter' | 'pro' | 'lifetime', 'neutral' | 'brand' | 'emerald'> = { + starter: 'neutral', + pro: 'brand', + lifetime: 'emerald', +}; + +const PLAN_LABEL: Record<'starter' | 'pro' | 'lifetime', string> = { + starter: 'Starter', + pro: 'Professional', + lifetime: 'Lifetime', +}; + +export function BillingCard() { + const status = useBillingStatus(); + const checkout = useStartCheckout(); + const portal = useOpenPortal(); + const [error, setError] = useState(null); + + async function startCheckout(plan: 'pro' | 'lifetime') { + setError(null); + try { + const { url } = await checkout.mutateAsync({ plan }); + if (url) window.location.href = url; + } catch (e) { + const code = (e as { code?: string }).code; + setError( + code === 'stripe_not_configured' + ? 'Billing is not configured yet. Contact support.' + : code === 'plan_not_configured' + ? 'This plan is not available yet.' + : 'Could not start checkout.', + ); + } + } + + async function openPortal() { + setError(null); + try { + const { url } = await portal.mutateAsync(); + if (url) window.location.href = url; + } catch { + setError('Could not open billing portal.'); + } + } + + if (status.isLoading) { + return ( + + + +

Loading…

+
+
+ ); + } + + const plan = status.data?.plan ?? 'starter'; + const isPaid = plan !== 'starter'; + const configured = !!status.data?.configured; + + return ( + + + +
+
+
+ +
+
+

Current plan

+

+ {PLAN_LABEL[plan]} {plan} +

+
+
+ {status.data?.hasCustomer && ( + + )} +
+ + {!configured && ( +

+ Stripe isn't configured on this server yet. Set STRIPE_SECRET_KEY and the price IDs in your environment to enable checkout. +

+ )} + + {plan === 'starter' && ( +
+ } + name="Professional" + price="$25/mo" + points={['Unlimited clients & invoices', '6 active cases', '8GB storage', 'No watermark']} + cta="Upgrade to Pro" + onClick={() => startCheckout('pro')} + loading={checkout.isPending} + disabled={!configured} + /> + } + name="Lifetime" + price="$129 once" + points={['Everything in Pro', 'Unlimited cases', '50GB storage', 'Future updates']} + cta="Get Lifetime" + onClick={() => startCheckout('lifetime')} + loading={checkout.isPending} + disabled={!configured} + highlight + /> +
+ )} + + {plan === 'pro' && ( +

+ You're on the Professional plan ($25/mo). Want a lifetime license instead?{' '} + + . +

+ )} + + {plan === 'lifetime' && ( +

+ You're on the Lifetime plan. No renewal needed — you have full access forever. +

+ )} + + {error &&

{error}

} +
+
+ ); +} + +function PlanOption({ + icon, + name, + price, + points, + cta, + onClick, + loading, + disabled, + highlight, +}: { + icon: React.ReactNode; + name: string; + price: string; + points: string[]; + cta: string; + onClick: () => void; + loading: boolean; + disabled: boolean; + highlight?: boolean; +}) { + return ( +
+
+ + {icon} + +

{name}

+ {price} +
+
    + {points.map((p) => ( +
  • · {p}
  • + ))} +
+ +
+ ); +} diff --git a/apps/web/src/components/app/CaseTimeList.tsx b/apps/web/src/components/app/CaseTimeList.tsx new file mode 100644 index 0000000..5acb4ad --- /dev/null +++ b/apps/web/src/components/app/CaseTimeList.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { Plus, Trash2, Clock } from 'lucide-react'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { ManualEntryDrawer } from './ManualEntryDrawer'; +import { useTimeEntries, useDeleteTimeEntry, type TimeEntry } from '@/hooks/useTime'; +import { formatDate, formatHours, formatMoney } from '@/lib/format'; + +function entryAmount(e: TimeEntry): number { + if (!e.billable) return 0; + return (Number(e.rate) || 0) * (e.minutes / 60); +} + +export function CaseTimeList({ caseId }: { caseId: string }) { + const list = useTimeEntries({ caseId }); + const del = useDeleteTimeEntry(); + const [drawerOpen, setDrawerOpen] = useState(false); + + const totalMinutes = (list.data?.items ?? []).reduce((acc, e) => acc + e.minutes, 0); + const totalAmount = (list.data?.items ?? []).reduce((acc, e) => acc + entryAmount(e), 0); + + return ( + + 0 ? `${formatHours(totalMinutes)} · ${formatMoney(totalAmount)} billable` : 'No time logged yet.' + } + action={ + + } + /> + + {list.isLoading ? ( +
Loading…
+ ) : !list.data?.items.length ? ( + } + title="No time logged" + description="Start the timer in the topbar or log time manually." + /> + ) : ( +
    + {list.data.items.map((e) => ( +
  • +
    +

    {e.description}

    +

    {formatDate(e.startedAt)}

    +
    +
    +

    {formatHours(e.minutes)}

    +

    + {e.billable ? formatMoney(entryAmount(e)) : 'Non-billable'} +

    +
    + {e.invoiceItemId ? ( + Invoiced + ) : !e.endedAt ? ( + Running + ) : ( + + )} +
  • + ))} +
+ )} + + setDrawerOpen(false)} initialCaseId={caseId} /> +
+ ); +} diff --git a/apps/web/src/components/app/CreateInvoiceDrawer.tsx b/apps/web/src/components/app/CreateInvoiceDrawer.tsx new file mode 100644 index 0000000..220eea3 --- /dev/null +++ b/apps/web/src/components/app/CreateInvoiceDrawer.tsx @@ -0,0 +1,360 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Plus, Trash2, FileText } from 'lucide-react'; +import { Drawer } from '@/components/ui/Drawer'; +import { Button } from '@/components/ui/Button'; +import { Input, Select, Textarea } from '@/components/ui/Input'; +import { useClients } from '@/hooks/useClients'; +import { useCases } from '@/hooks/useCases'; +import { useTimeEntries } from '@/hooks/useTime'; +import { useCreateInvoice, type CreateInvoiceInput } from '@/hooks/useInvoices'; +import { formatDate, formatHours, formatMoney, planLimitMessage } from '@/lib/format'; +import { cn } from '@/lib/cn'; + +interface ManualItem { + description: string; + quantity: string; + rate: string; +} + +type Mode = 'manual' | 'time'; + +interface Props { + open: boolean; + onClose: () => void; + initialClientId?: string; + initialCaseId?: string; + onCreated?: (invoiceId: string) => void; +} + +function defaultDueDate(): string { + const d = new Date(); + d.setDate(d.getDate() + 30); + return d.toISOString().slice(0, 10); +} + +export function CreateInvoiceDrawer({ open, onClose, initialClientId, initialCaseId, onCreated }: Props) { + const clients = useClients(); + const cases = useCases(); + const create = useCreateInvoice(); + + const [mode, setMode] = useState(initialCaseId ? 'time' : 'manual'); + const [clientId, setClientId] = useState(initialClientId ?? ''); + const [caseId, setCaseId] = useState(initialCaseId ?? ''); + const [taxRate, setTaxRate] = useState('0'); + const [dueDate, setDueDate] = useState(defaultDueDate()); + const [notes, setNotes] = useState(''); + const [items, setItems] = useState([{ description: '', quantity: '1', rate: '' }]); + const [selectedTimeIds, setSelectedTimeIds] = useState>(new Set()); + + // Reset on open + useEffect(() => { + if (!open) return; + setMode(initialCaseId ? 'time' : 'manual'); + setClientId(initialClientId ?? ''); + setCaseId(initialCaseId ?? ''); + setTaxRate('0'); + setDueDate(defaultDueDate()); + setNotes(''); + setItems([{ description: '', quantity: '1', rate: '' }]); + setSelectedTimeIds(new Set()); + create.reset(); + }, [open, initialClientId, initialCaseId, create]); + + // When client changes, clear case selection if the case doesn't belong to that client + useEffect(() => { + if (!caseId) return; + const c = cases.data?.items.find((x) => x.id === caseId); + if (c && c.clientId !== clientId) setCaseId(''); + }, [clientId, caseId, cases.data]); + + // Pull unbilled time entries for the chosen case (or for any case of the client if no case) + const unbilledTime = useTimeEntries( + mode === 'time' + ? caseId + ? { caseId, invoiced: 'false' } + : { invoiced: 'false' } + : { invoiced: 'false' }, + ); + + const filteredEntries = useMemo(() => { + const all = unbilledTime.data?.items ?? []; + return all.filter((e) => { + if (!e.billable) return false; + if (e.endedAt === null) return false; // skip running timer + if (clientId && e.clientId !== clientId) return false; + if (caseId && e.caseId !== caseId) return false; + return true; + }); + }, [unbilledTime.data, clientId, caseId]); + + function toggleTime(id: string) { + setSelectedTimeIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + // Live totals preview + const previewSubtotal = useMemo(() => { + if (mode === 'manual') { + return items.reduce((acc, it) => acc + (Number(it.quantity) || 0) * (Number(it.rate) || 0), 0); + } + return filteredEntries + .filter((e) => selectedTimeIds.has(e.id)) + .reduce((acc, e) => acc + (Number(e.rate) || 0) * (e.minutes / 60), 0); + }, [mode, items, filteredEntries, selectedTimeIds]); + + const previewTotal = previewSubtotal * (1 + (Number(taxRate) || 0) / 100); + + const clientCases = useMemo( + () => (cases.data?.items ?? []).filter((c) => !clientId || c.clientId === clientId), + [cases.data, clientId], + ); + + async function onSubmit() { + if (!clientId) return; + const payload: CreateInvoiceInput = { + clientId, + caseId: caseId || null, + notes: notes.trim() || null, + taxRate: Number(taxRate) || 0, + dueAt: dueDate ? new Date(`${dueDate}T00:00:00`).toISOString() : null, + }; + if (mode === 'manual') { + payload.items = items + .filter((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0) + .map((it) => ({ + description: it.description.trim(), + quantity: Number(it.quantity), + rate: Number(it.rate), + })); + } else { + payload.timeEntryIds = Array.from(selectedTimeIds); + } + if (!payload.items?.length && !payload.timeEntryIds?.length) return; + + const created = await create.mutateAsync(payload); + onCreated?.(created.id); + onClose(); + } + + const apiErr = create.error ? planLimitMessage(create.error.code, 'Could not create the invoice.') : null; + + const canSubmit = + !!clientId && + ((mode === 'manual' && + items.some((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)) || + (mode === 'time' && selectedTimeIds.size > 0)); + + return ( + +

+ Total{' '} + {formatMoney(previewTotal)} +

+
+ + +
+ + } + > +
+
+ + +
+ +
+ setMode('time')}>From time entries + setMode('manual')}>Manual +
+ + {mode === 'time' ? ( +
+
+

Unbilled time entries

+

{selectedTimeIds.size} selected

+
+ {!clientId ? ( +
Select a client first.
+ ) : !filteredEntries.length ? ( +
+ No unbilled, billable time entries{caseId ? ' for this case' : ' for this client'}. +
+ ) : ( +
    + {filteredEntries.map((e) => { + const checked = selectedTimeIds.has(e.id); + const amount = (Number(e.rate) || 0) * (e.minutes / 60); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ) : ( +
+
+

Line items

+ +
+
    + {items.map((it, i) => ( +
  • + setItems((s) => s.map((x, j) => (j === i ? { ...x, description: e.target.value } : x)))} + className="col-span-6 rounded-lg border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" + /> + setItems((s) => s.map((x, j) => (j === i ? { ...x, quantity: e.target.value } : x)))} + className="col-span-2 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" + /> + setItems((s) => s.map((x, j) => (j === i ? { ...x, rate: e.target.value } : x)))} + className="col-span-3 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" + /> + +
  • + ))} +
+
+ )} + +
+ setTaxRate(e.target.value)} + /> + setDueDate(e.target.value)} + /> +
+ +