diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8b5b7f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Keep the build context small and NEVER bake secrets or host-built artifacts into the image. + +# Secrets — must never enter the image (env is injected by Dokploy at runtime). +.env +.env.* +!.env.example + +# Dependencies + build outputs (reinstalled / rebuilt inside the image). +node_modules +**/node_modules +apps/web/dist +apps/api/dist +dist +build +.turbo +.cache +coverage + +# VCS / CI / editor / OS noise. +.git +.gitignore +.github +.vscode +.idea +.DS_Store +Thumbs.db + +# Logs and legacy Plesk/Passenger runtime scratch. +*.log +logs +tmp + +# Local-only storage dirs (documents live in Spaces). +storage +uploads + +# Tests aren't needed in the runtime image. +apps/api/test +**/*.test.ts + +# Note: certs/ is intentionally NOT ignored — the Postgres CA cert (if committed) is baked in +# so production TLS verification works. See DEPLOY-DOKPLOY.md. diff --git a/.env.example b/.env.example index d2c0903..deb713e 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,20 @@ STORAGE_PATH=./storage SMTP2GO_API_KEY= EMAIL_FROM="eLegal Software " +# ───────────────────────────────────────────── +# AI (Anthropic) — powers case summaries, document summaries, and text polish. +# Leave blank to disable AI features (endpoints return 503 ai_not_configured). +# ───────────────────────────────────────────── +ANTHROPIC_API_KEY= + +# ───────────────────────────────────────────── +# Cloudflare Turnstile — bot protection on signup, login, password reset, and the +# contact form. Leave both blank to disable (dev). The site key is public (bundled +# into the web app); the secret key is server-only. +# ───────────────────────────────────────────── +TURNSTILE_SECRET_KEY= +VITE_TURNSTILE_SITE_KEY= + # ───────────────────────────────────────────── # Stripe # ───────────────────────────────────────────── diff --git a/DEPLOY-DOKPLOY.md b/DEPLOY-DOKPLOY.md new file mode 100644 index 0000000..d5bdf3f --- /dev/null +++ b/DEPLOY-DOKPLOY.md @@ -0,0 +1,132 @@ +# Deploying eLegal Software on Dokploy + +This app was previously deployed on Plesk + Passenger. It now ships as a single Docker container +(built from the repo `Dockerfile`) that serves the built React SPA **and** the Fastify API on one +port. Documents live in DigitalOcean Spaces and logs go to stdout, so the container is **stateless** +— no volumes needed. + +- **Runtime:** Node 20, one process, listens on `0.0.0.0:$PORT` (default `8080`). +- **Health check:** `GET /api/health` → `200 {"ok":true}` (no DB dependency). A deeper + `GET /api/health/db` verifies the database. +- **Database:** external DigitalOcean Managed Postgres (unchanged). +- **Storage:** external DigitalOcean Spaces (unchanged). + +--- + +## 1. Create the application in Dokploy + +1. **Project → Create Application.** +2. **Source → Git.** Point it at this repo (`https://tea.serfaty.co/admin/elegalsoftware`), branch + `master`. Add a deploy key / token in Dokploy if the repo is private. +3. **Build Type → `Dockerfile`.** Path: `./Dockerfile` (repo root). *(Alternatively use the + "Compose" type with the bundled `docker-compose.yml`, but Application + Dockerfile is simpler — + Dokploy wires Traefik and env for you.)* +4. **Port → `8080`.** This is the container port Dokploy/Traefik routes to. + +## 2. Domain + TLS + +- **Domains → Add** your hostname (e.g. `app.elegalsoftware.com`), container port `8080`, and + enable **HTTPS / Let's Encrypt**. Traefik terminates TLS and proxies to the container. +- Point the hostname's DNS at the Dokploy server first so the ACME challenge can succeed. + +## 3. Runtime environment variables + +Set these in **Environment** (Dokploy injects them at container start). Use `.env.example` as the +authoritative list. The essentials: + +| Variable | Notes | +|---|---| +| `NODE_ENV` | `production` | +| `PORT` | `8080` (matches the exposed port) | +| `PUBLIC_URL` | Your public HTTPS URL, e.g. `https://app.elegalsoftware.com`. Used in emails and absolute links — **must** be the real domain in prod. | +| `COOKIE_DOMAIN` | Your apex/app domain (e.g. `elegalsoftware.com`). Leave blank only in local dev. | +| `SESSION_SECRET` / `CSRF_SECRET` | 32+ byte hex each. Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. | +| `SUPERADMIN_EMAILS` | Comma-separated. Only **verified** accounts on this list become superadmin. | +| `DATABASE_URL` | DO Postgres URL (`...?sslmode=require`). | +| `DATABASE_CA_CERT_PATH` | Path to the DO CA cert **inside the container** — required in prod (see §4). | +| `SPACES_ENDPOINT` / `SPACES_REGION` / `SPACES_BUCKET` / `SPACES_KEY` / `SPACES_SECRET` | Object storage (required). | +| `SMTP2GO_API_KEY` / `EMAIL_FROM` | Transactional email. | +| `ANTHROPIC_API_KEY` | AI features (optional; blank disables them). | +| `TURNSTILE_SECRET_KEY` | Bot-protection server key (see §5 for the site key). | +| `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET` / `STRIPE_PRICE_PRO` / `STRIPE_PRICE_LIFETIME` | Billing. | +| `SENTRY_DSN_API` | Optional error reporting. | + +> The API **fails fast on boot** if a required variable is missing (secrets, `SPACES_*`, database). +> That's intentional — a misconfigured deploy stops loudly instead of running half-broken. + +## 4. Database TLS — the one required extra step + +In production the app **refuses to connect over unverified TLS** (no silent MITM exposure). You must +give it the DigitalOcean CA certificate: + +1. In the DO control panel → your Postgres cluster → **Download CA certificate**. +2. Make it available in the container, either: + - **Commit it** as `certs/ca-certificate.crt` (a CA cert is public, safe to commit). The + Dockerfile bakes `certs/` into the image. Set `DATABASE_CA_CERT_PATH=./certs/ca-certificate.crt`. + - **Or** mount it via a Dokploy **Volume/Mount** (e.g. at `/app/certs/ca-certificate.crt`) and + set `DATABASE_CA_CERT_PATH` to that path. + +Without this, the container boots and `/api/health` still passes, but any request that touches the +database will error. (`/api/health/db` will return `503` until the cert is in place.) + +## 5. Build-time variables (Vite) — easy to miss + +`VITE_*` values are **compiled into the browser bundle during `vite build`**, so they must be set as +**Build-time variables**, not runtime env: + +- `VITE_TURNSTILE_SITE_KEY` — the public Turnstile site key. +- `VITE_SENTRY_DSN` — browser Sentry DSN (optional). + +In Dokploy: **Build → Build-time variables** (passed as Docker build args). If you only set them as +runtime env, the browser bundle won't pick them up and Turnstile won't render. + +## 6. Database migrations + +Run migrations as a **deploy step**, not automatically on every container start. In Dokploy, use a +**Run Command** (or the app's terminal) after a deploy: + +```bash +npm run db:migrate +``` + +Migrations are tracked (drizzle only applies pending ones) and safe to re-run. Keep the app at **1 +replica** while migrating; the invoice-numbering advisory locks handle write concurrency, but schema +migrations should not run from multiple instances at once. + +## 7. Stripe webhook + +Add the endpoint in the Stripe dashboard: + +``` +https:///api/webhooks/stripe +``` + +The route reads the raw request body for signature verification. Unlike Plesk (which needed +`proxy_request_buffering off`), Traefik forwards the body fine — no extra proxy config required. +Set `STRIPE_WEBHOOK_SECRET` to the signing secret Stripe shows for that endpoint. + +## 8. Deploy + +Trigger a deploy in Dokploy (or enable auto-deploy on push). First deploy checklist: + +- [ ] Runtime env vars set (§3), including `PUBLIC_URL` and `COOKIE_DOMAIN` on the real domain. +- [ ] DB CA cert in place and `DATABASE_CA_CERT_PATH` set (§4). +- [ ] `VITE_*` set as build-time variables (§5). +- [ ] Domain + Let's Encrypt configured (§2). +- [ ] Migrations run (§6). +- [ ] Stripe webhook pointed at the new URL (§7). + +Verify: `https:///api/health` → `{"ok":true}`, then load the app, sign up, and open a +deep-link/refresh (e.g. `/app`) to confirm SPA routing. + +--- + +## Notes + +- **Local Docker test:** `docker build -t elegal . && docker run --rm -p 8080:8080 --env-file .env elegal` + then hit `http://localhost:8080/api/health`. (Uses your local `.env`; the image itself never + contains it — `.env` is in `.dockerignore`.) +- **Scaling:** the app is stateless (Spaces storage, stdout logs, Postgres sessions), so it scales + horizontally. Just run migrations as a single controlled step, not per-instance. +- **Legacy Plesk files** — `app.js`, `server.cjs`, `scripts/plesk-deploy.sh`, and `tmp/restart.txt` + are no longer used and can be deleted once the Dokploy cutover is confirmed. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a6470c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +# +# Container image for Dokploy (Docker + Traefik). Single Node process that serves the built +# React SPA and the Fastify API on one port. Documents live in DigitalOcean Spaces and logs go +# to stdout, so the container is stateless — no volumes required. + +# ─── Builder ──────────────────────────────────────────────────────────────────── +FROM node:20-bookworm-slim AS builder +WORKDIR /app + +# Toolchain for native modules (argon2) in case no prebuilt binary is available for this platform. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +# Install dependencies first for better layer caching — copy every workspace manifest, then npm ci. +COPY package.json package-lock.json ./ +COPY apps/api/package.json ./apps/api/ +COPY apps/web/package.json ./apps/web/ +COPY packages/db/package.json ./packages/db/ +RUN npm ci + +# Copy the rest of the source. +COPY . . + +# Public config baked into the Vite bundle. Vite inlines VITE_* AT BUILD TIME, so these must be +# provided as build args (Dokploy → Build → Build-time variables), NOT as runtime env vars. +# Both are optional: an empty value simply disables that feature in the browser bundle. +ARG VITE_TURNSTILE_SITE_KEY="" +ARG VITE_SENTRY_DSN="" +ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} \ + VITE_SENTRY_DSN=${VITE_SENTRY_DSN} + +# Build the SPA → apps/web/dist. The API runs from TypeScript source via the tsx loader, so there +# is no separate API build step. +RUN npm run build + +# ─── Runtime ──────────────────────────────────────────────────────────────────── +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + PORT=8080 + +# ca-certificates for outbound TLS (Postgres, Spaces, Stripe, SMTP2GO, Anthropic). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 1001 app + +# Bring over the fully-installed, already-built app (node_modules incl. the compiled argon2 binary +# and workspace symlinks, apps/web/dist, TS source run by tsx, and certs/ if the CA cert is present). +COPY --from=builder --chown=app:app /app /app + +USER app +EXPOSE 8080 + +# Container liveness. Dokploy/Traefik can additionally health-check the /api/health HTTP path. +HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +# server.js registers the tsx ESM loader, builds the Fastify app, and listens on 0.0.0.0:$PORT. +CMD ["node", "server.js"] diff --git a/apps/api/package.json b/apps/api/package.json index cb0858d..0822cfb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@anthropic-ai/sdk": "^0.111.0", "@aws-sdk/client-s3": "^3.1088.0", "@aws-sdk/s3-request-presigner": "^3.1088.0", "@fastify/cookie": "^11.0.1", diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 87ea604..475e540 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -30,6 +30,8 @@ const envSchema = z.object({ // Legacy local path — read only by the one-time migration script, not the running app. STORAGE_PATH: z.string().optional().default('./storage'), SMTP2GO_API_KEY: z.string().optional().default(''), + ANTHROPIC_API_KEY: z.string().optional().default(''), + TURNSTILE_SECRET_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(''), diff --git a/apps/api/src/lib/ai.ts b/apps/api/src/lib/ai.ts new file mode 100644 index 0000000..00d3b71 --- /dev/null +++ b/apps/api/src/lib/ai.ts @@ -0,0 +1,103 @@ +// AI layer — Anthropic Claude. All platform AI features go through this module so the +// model choice, error mapping, and enablement check live in one place. +// +// Model: claude-haiku-4-5 — the cheapest current Claude model ($1/M input, $5/M output), +// chosen deliberately for cost; swap AI_MODEL to a bigger model if quality needs grow. +import Anthropic from '@anthropic-ai/sdk'; +import { env } from '../env'; + +export const AI_MODEL = 'claude-haiku-4-5'; + +let _client: Anthropic | null = null; + +function getClient(): Anthropic | null { + if (!env.ANTHROPIC_API_KEY) return null; + if (!_client) _client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); + return _client; +} + +export function isAiEnabled(): boolean { + return Boolean(env.ANTHROPIC_API_KEY); +} + +export class AiDisabledError extends Error { + constructor() { + super('ai_not_configured'); + this.name = 'AiDisabledError'; + } +} + +export class AiUnavailableError extends Error { + constructor( + message: string, + public retryable: boolean, + ) { + super(message); + this.name = 'AiUnavailableError'; + } +} + +export interface AiUsage { + inputTokens: number; + outputTokens: number; +} + +export interface AiResult { + text: string; + usage: AiUsage; +} + +/** + * Single-turn completion. `content` is either plain text or prebuilt content blocks + * (e.g. a document block for PDF summarization). + */ +export async function aiComplete(opts: { + system: string; + content: string | Anthropic.ContentBlockParam[]; + maxTokens?: number; +}): Promise { + const client = getClient(); + if (!client) throw new AiDisabledError(); + + let response: Anthropic.Message; + try { + response = await client.messages.create({ + model: AI_MODEL, + max_tokens: opts.maxTokens ?? 1500, + system: opts.system, + messages: [{ role: 'user', content: opts.content }], + }); + } catch (err) { + if (err instanceof Anthropic.RateLimitError) { + throw new AiUnavailableError('ai_rate_limited', true); + } + if (err instanceof Anthropic.APIConnectionError) { + throw new AiUnavailableError('ai_connection_failed', true); + } + if (err instanceof Anthropic.APIError) { + // 4xx: our request is malformed (e.g. unsupported document) — not retryable. + // 5xx/overloaded: transient. + const retryable = typeof err.status === 'number' ? err.status >= 500 : true; + throw new AiUnavailableError(`ai_error_${err.status ?? 'unknown'}`, retryable); + } + throw err; + } + + if (response.stop_reason === 'refusal') { + throw new AiUnavailableError('ai_refused', false); + } + + const text = response.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map((b) => b.text) + .join('\n') + .trim(); + + return { + text, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; +} diff --git a/apps/api/src/lib/turnstile.ts b/apps/api/src/lib/turnstile.ts new file mode 100644 index 0000000..f33a2f9 --- /dev/null +++ b/apps/api/src/lib/turnstile.ts @@ -0,0 +1,47 @@ +// Cloudflare Turnstile server-side verification. Protects the public, abuse-prone +// endpoints (signup, login, password reset, contact form) from bots. +// With TURNSTILE_SECRET_KEY unset (local dev), verification is skipped entirely. +import { env } from '../env'; + +const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +export function isTurnstileEnabled(): boolean { + return Boolean(env.TURNSTILE_SECRET_KEY); +} + +interface SiteverifyResponse { + success: boolean; + 'error-codes'?: string[]; +} + +/** + * Verifies a Turnstile token. Returns true when the check passes or Turnstile is + * disabled. Tokens are single-use — the widget must be reset after a failed submit. + */ +export async function verifyTurnstile( + token: string | undefined | null, + ip?: string | null, +): Promise { + if (!isTurnstileEnabled()) return true; + if (!token) return false; + + const body = new URLSearchParams({ + secret: env.TURNSTILE_SECRET_KEY, + response: token, + }); + if (ip) body.set('remoteip', ip); + + try { + const res = await fetch(VERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + const json = (await res.json()) as SiteverifyResponse; + return json.success === true; + } catch { + // Cloudflare unreachable — fail closed. The client shows "try again" and the + // widget issues a fresh token on retry. + return false; + } +} diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts new file mode 100644 index 0000000..3bdaa47 --- /dev/null +++ b/apps/api/src/routes/ai.ts @@ -0,0 +1,251 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import { and, desc, eq } from 'drizzle-orm'; +import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db'; +import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai'; +import { getObjectStream, FileNotFoundError } from '../lib/storage'; + +// The one non-negotiable framing for a legal-tech product: the model assists with +// organization and drafting; it must never present itself as giving legal advice. +const BASE_SYSTEM = `You are an assistant inside eLegal Software, a practice-management tool used by attorneys. +You help organize and summarize the firm's own records. You do not give legal advice, cite law, or predict case outcomes. +Be factual and concise. Only state what is supported by the provided material; if something is unclear or missing, say so plainly.`; + +const AI_DISCLAIMER = + 'AI-generated from your case records — review for accuracy. Not legal advice.'; + +// Document types we can hand to the model directly. +const AI_MIME = { + pdf: 'application/pdf', + text: 'text/plain', + images: new Set(['image/jpeg', 'image/png', 'image/webp']), +} as const; +const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap + +function sendAiError(reply: FastifyReply, err: unknown): FastifyReply { + if (err instanceof AiDisabledError) { + return reply.code(503).send({ error: 'ai_not_configured' }); + } + if (err instanceof AiUnavailableError) { + return reply.code(err.retryable ? 503 : 422).send({ error: err.message }); + } + throw err; +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream as AsyncIterable) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks); +} + +export async function aiRoutes(app: FastifyInstance) { + app.addHook('preHandler', app.requireFirm); + + // Lets the UI decide whether to render AI affordances at all. + app.get('/api/ai/status', async () => ({ enabled: isAiEnabled(), model: AI_MODEL })); + + // ── Case summary ───────────────────────────────────────────────────────── + // Builds a brief from everything the firm has recorded on a case. + app.post( + '/api/cases/:id/ai/summary', + { config: { rateLimit: { max: 30, timeWindow: '1 hour' } } }, + async (req, reply) => { + const firmId = req.user!.firmId!; + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const db = getDb(); + + const [c] = await db + .select() + .from(cases) + .where(and(eq(cases.id, id), eq(cases.firmId, firmId))) + .limit(1); + if (!c) return reply.code(404).send({ error: 'not_found' }); + + const [client] = await db + .select({ name: clients.name }) + .from(clients) + .where(eq(clients.id, c.clientId)) + .limit(1); + + const entries = await db + .select({ + description: timeEntries.description, + minutes: timeEntries.minutes, + billable: timeEntries.billable, + startedAt: timeEntries.startedAt, + }) + .from(timeEntries) + .where(and(eq(timeEntries.caseId, id), eq(timeEntries.firmId, firmId))) + .orderBy(desc(timeEntries.startedAt)) + .limit(50); + + const docs = await db + .select({ name: documents.name, createdAt: documents.createdAt }) + .from(documents) + .where(and(eq(documents.caseId, id), eq(documents.firmId, firmId))) + .orderBy(desc(documents.createdAt)) + .limit(50); + + const caseInvoices = await db + .select({ number: invoices.number, status: invoices.status, total: invoices.total }) + .from(invoices) + .where(and(eq(invoices.caseId, id), eq(invoices.firmId, firmId))); + + const fmtDate = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : 'n/a'); + const context = [ + `CASE: ${c.title}${c.caseNumber ? ` (No. ${c.caseNumber})` : ''}`, + `Client: ${client?.name ?? 'unknown'} · Status: ${c.status} · Practice area: ${c.practiceArea ?? 'n/a'}`, + `Opened: ${fmtDate(c.openedAt)}${c.closedAt ? ` · Closed: ${fmtDate(c.closedAt)}` : ''}`, + c.description ? `Description: ${c.description}` : '', + '', + `TIME ENTRIES (most recent ${entries.length}):`, + ...entries.map( + (e) => + `- ${fmtDate(e.startedAt)} · ${e.minutes} min · ${e.billable ? 'billable' : 'non-billable'} · ${e.description}`, + ), + '', + `DOCUMENTS (${docs.length}):`, + ...docs.map((d) => `- ${d.name} (uploaded ${fmtDate(d.createdAt)})`), + '', + `INVOICES (${caseInvoices.length}):`, + ...caseInvoices.map((i) => `- ${i.number} · ${i.status} · $${i.total}`), + ] + .filter((line) => line !== '') + .join('\n'); + + try { + const result = await aiComplete({ + system: `${BASE_SYSTEM} +Write a case brief for the attorney working this case, as plain text (no markdown syntax) with these section headings on their own lines: +STATUS SNAPSHOT — 2-3 sentences on where the case stands based on the records. +RECENT ACTIVITY — the notable recent work, grouped, not a raw list. +BILLING PICTURE — hours logged, what's invoiced/outstanding. +GAPS & FOLLOW-UPS — anything the records suggest needs attention (stale activity, unbilled time, missing documents). +Keep it under 300 words.`, + content: context, + }); + return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage }; + } catch (err) { + return sendAiError(reply, err); + } + }, + ); + + // ── Document summary ───────────────────────────────────────────────────── + app.post( + '/api/documents/:docId/ai/summary', + { config: { rateLimit: { max: 30, timeWindow: '1 hour' } } }, + async (req, reply) => { + const firmId = req.user!.firmId!; + const { docId } = z.object({ docId: z.string().uuid() }).parse(req.params); + const db = getDb(); + + const [doc] = await db + .select() + .from(documents) + .where(and(eq(documents.id, docId), eq(documents.firmId, firmId))) + .limit(1); + if (!doc) return reply.code(404).send({ error: 'not_found' }); + + const supported = + doc.mimeType === AI_MIME.pdf || + doc.mimeType === AI_MIME.text || + AI_MIME.images.has(doc.mimeType); + if (!supported) { + return reply.code(400).send({ + error: 'unsupported_for_ai', + hint: 'AI summaries support PDF, plain text, and image documents.', + }); + } + if (doc.sizeBytes > MAX_AI_DOC_BYTES) { + return reply.code(400).send({ error: 'document_too_large_for_ai' }); + } + + let buf: Buffer; + try { + buf = await streamToBuffer(await getObjectStream(doc.storageKey)); + } catch (err) { + if (err instanceof FileNotFoundError) return reply.code(404).send({ error: 'file_missing' }); + throw err; + } + + const instruction = `Summarize the attached document ("${doc.name}") for the attorney's case file, as plain text (no markdown syntax) with these section headings on their own lines: +WHAT IT IS — document type and apparent purpose. +KEY POINTS — parties, dates, amounts, deadlines, obligations that appear in it. +ANYTHING UNUSUAL — inconsistencies, missing signatures/pages, ambiguities worth a closer look. +Keep it under 300 words.`; + + const content = + doc.mimeType === AI_MIME.text + ? `${instruction}\n\n--- DOCUMENT CONTENT ---\n${buf.toString('utf8')}` + : ([ + doc.mimeType === AI_MIME.pdf + ? { + type: 'document' as const, + source: { + type: 'base64' as const, + media_type: 'application/pdf' as const, + data: buf.toString('base64'), + }, + } + : { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: doc.mimeType as 'image/jpeg' | 'image/png' | 'image/webp', + data: buf.toString('base64'), + }, + }, + { type: 'text' as const, text: instruction }, + ]); + + try { + const result = await aiComplete({ system: BASE_SYSTEM, content }); + return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage }; + } catch (err) { + return sendAiError(reply, err); + } + }, + ); + + // ── Text polish ────────────────────────────────────────────────────────── + // Generic "make this professional" helper for notes, descriptions, and messages. + app.post( + '/api/ai/polish', + { config: { rateLimit: { max: 60, timeWindow: '1 hour' } } }, + async (req, reply) => { + const body = z + .object({ + text: z.string().min(1).max(10_000), + kind: z.enum(['time_entry', 'invoice_note', 'case_description', 'client_message']), + }) + .parse(req.body); + + const KIND_GUIDANCE: Record = { + time_entry: + 'Rewrite as a professional billing narrative: past tense, specific, defensible to a client reviewing the invoice. One or two sentences.', + invoice_note: + 'Rewrite as a courteous, professional note to appear on a client invoice. Brief.', + case_description: + 'Rewrite as a clear internal case description: what the matter is, who is involved, current posture.', + client_message: + 'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.', + }; + + try { + const result = await aiComplete({ + system: `${BASE_SYSTEM} +${KIND_GUIDANCE[body.kind]} +Return ONLY the rewritten text — no preamble, no quotes, no commentary. Preserve all facts; never invent names, dates, or amounts.`, + content: body.text, + maxTokens: 800, + }); + return { text: result.text, usage: result.usage }; + } catch (err) { + return sendAiError(reply, err); + } + }, + ); +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 3902b1f..fe1a775 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -23,17 +23,20 @@ import { verifyEmailEmail, } from '../lib/email'; import { env } from '../env'; +import { verifyTurnstile } from '../lib/turnstile'; 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(), + turnstileToken: z.string().max(3000).optional(), }); const loginBody = z.object({ email: z.string().email().max(254).toLowerCase().trim(), password: z.string().min(1).max(200), + turnstileToken: z.string().max(3000).optional(), }); const MAX_FAILS_PER_15_MIN = 5; @@ -74,6 +77,11 @@ export async function authRoutes(app: FastifyInstance) { { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, async (req, reply) => { const body = signupBody.parse(req.body); + + if (!(await verifyTurnstile(body.turnstileToken, req.ip))) { + return reply.code(400).send({ error: 'captcha_failed' }); + } + const db = getDb(); const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1); @@ -144,6 +152,11 @@ export async function authRoutes(app: FastifyInstance) { { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } }, async (req, reply) => { const body = loginBody.parse(req.body); + + if (!(await verifyTurnstile(body.turnstileToken, req.ip))) { + return reply.code(400).send({ error: 'captcha_failed' }); + } + const db = getDb(); const ip = req.ip ?? null; @@ -216,10 +229,21 @@ export async function authRoutes(app: FastifyInstance) { 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); + async (req, reply) => { + const parsed = z + .object({ + email: z.string().email().max(254).toLowerCase().trim(), + turnstileToken: z.string().max(3000).optional(), + }) + .safeParse(req.body); if (!parsed.success) return { ok: true }; + // Bot check is orthogonal to email enumeration — a captcha failure is reported + // honestly; only account existence is concealed by the ok-always contract. + if (!(await verifyTurnstile(parsed.data.turnstileToken, req.ip))) { + return reply.code(400).send({ error: 'captcha_failed' }); + } + 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 }; diff --git a/apps/api/src/routes/contact.ts b/apps/api/src/routes/contact.ts index 0bac1a9..6b1ca7c 100644 --- a/apps/api/src/routes/contact.ts +++ b/apps/api/src/routes/contact.ts @@ -3,11 +3,13 @@ import { z } from 'zod'; import { getDb, contactMessages } from '@lawdesk/db'; import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email'; import { env } from '../env'; +import { verifyTurnstile } from '../lib/turnstile'; 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(), + turnstileToken: z.string().max(3000).optional(), }); export async function contactRoutes(app: FastifyInstance) { @@ -18,6 +20,11 @@ export async function contactRoutes(app: FastifyInstance) { const parsed = contactBody.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); const body = parsed.data; + + if (!(await verifyTurnstile(body.turnstileToken, req.ip))) { + return reply.code(400).send({ error: 'captcha_failed' }); + } + await getDb().insert(contactMessages).values({ fullName: body.fullName, email: body.email, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 01efdb5..852c6d3 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -24,6 +24,7 @@ import { accountRoutes } from './routes/account'; import { toolUsageRoutes } from './routes/tool-usage'; import { billingRoutes } from './routes/billing'; import { documentsRoutes } from './routes/documents'; +import { aiRoutes } from './routes/ai'; import { stripeWebhookRoute } from './routes/webhooks-stripe'; const __filename = fileURLToPath(import.meta.url); @@ -70,11 +71,12 @@ export async function buildServer() { ? { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'"], + scriptSrc: ["'self'", 'https://challenges.cloudflare.com'], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], - imgSrc: ["'self'", 'data:', 'blob:'], + imgSrc: ["'self'", 'data:', 'blob:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'], connectSrc: ["'self'"], + frameSrc: ['https://challenges.cloudflare.com'], frameAncestors: ["'none'"], formAction: ["'self'"], baseUri: ["'self'"], @@ -119,6 +121,7 @@ export async function buildServer() { await app.register(toolUsageRoutes); await app.register(billingRoutes); await app.register(documentsRoutes); + await app.register(aiRoutes); // 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'); diff --git a/apps/web/src/components/Turnstile.tsx b/apps/web/src/components/Turnstile.tsx new file mode 100644 index 0000000..aaec62b --- /dev/null +++ b/apps/web/src/components/Turnstile.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; + +// Cloudflare Turnstile widget (explicit render). Renders nothing when +// VITE_TURNSTILE_SITE_KEY is unset, so dev without keys just works. + +declare global { + interface Window { + turnstile?: { + render: ( + el: HTMLElement, + opts: { + sitekey: string; + callback: (token: string) => void; + 'expired-callback'?: () => void; + 'error-callback'?: () => void; + theme?: 'light' | 'dark' | 'auto'; + }, + ) => string; + reset: (widgetId: string) => void; + remove: (widgetId: string) => void; + }; + } +} + +export const TURNSTILE_SITE_KEY: string = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? ''; + +let scriptPromise: Promise | null = null; + +function loadScript(): Promise { + if (window.turnstile) return Promise.resolve(); + if (!scriptPromise) { + scriptPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + s.async = true; + s.onload = () => resolve(); + s.onerror = () => { + scriptPromise = null; + reject(new Error('turnstile_script_failed')); + }; + document.head.appendChild(s); + }); + } + return scriptPromise; +} + +export function Turnstile({ + onToken, + resetSignal = 0, + className, +}: { + /** Called with a fresh token, or null when the token expires/errors. */ + onToken: (token: string | null) => void; + /** Bump this number to force a widget reset (tokens are single-use). */ + resetSignal?: number; + className?: string; +}) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const onTokenRef = useRef(onToken); + onTokenRef.current = onToken; + + useEffect(() => { + if (!TURNSTILE_SITE_KEY || !containerRef.current) return; + let cancelled = false; + + loadScript() + .then(() => { + if (cancelled || !containerRef.current || widgetIdRef.current || !window.turnstile) return; + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: TURNSTILE_SITE_KEY, + theme: 'light', + callback: (token) => onTokenRef.current(token), + 'expired-callback': () => onTokenRef.current(null), + 'error-callback': () => onTokenRef.current(null), + }); + }) + .catch(() => onTokenRef.current(null)); + + return () => { + cancelled = true; + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current); + widgetIdRef.current = null; + } + }; + }, []); + + useEffect(() => { + if (resetSignal > 0 && widgetIdRef.current && window.turnstile) { + window.turnstile.reset(widgetIdRef.current); + onTokenRef.current(null); + } + }, [resetSignal]); + + if (!TURNSTILE_SITE_KEY) return null; + return
; +} diff --git a/apps/web/src/components/app/AiCaseSummary.tsx b/apps/web/src/components/app/AiCaseSummary.tsx new file mode 100644 index 0000000..7309aff --- /dev/null +++ b/apps/web/src/components/app/AiCaseSummary.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { Loader2, Sparkles } from 'lucide-react'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { api, type ApiError } from '@/lib/api'; + +interface AiSummaryResponse { + summary: string; + disclaimer: string; +} + +const ERROR_COPY: Record = { + ai_not_configured: 'AI features are not configured on this server.', + ai_rate_limited: 'AI is busy right now — try again in a minute.', +}; + +export function AiCaseSummary({ caseId }: { caseId: string }) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function generate() { + setLoading(true); + setError(null); + try { + const data = await api.post(`/api/cases/${caseId}/ai/summary`); + setResult(data); + } catch (err) { + const code = (err as ApiError).code ?? ''; + setError(ERROR_COPY[code] ?? 'Could not generate a summary — try again shortly.'); + } finally { + setLoading(false); + } + } + + return ( + + + {loading ? ( + <> + + Summarizing… + + ) : ( + <> + + {result ? 'Regenerate' : 'Generate summary'} + + )} + + } + /> + + {error &&

{error}

} + {!error && !result && !loading && ( +

+ Generate an AI brief of this case from its records — status, recent activity, billing, + and suggested follow-ups. +

+ )} + {result && ( +
+

+ {result.summary} +

+

{result.disclaimer}

+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/marketing/BlogTeaser.tsx b/apps/web/src/components/marketing/BlogTeaser.tsx index 699c9a2..3e60154 100644 --- a/apps/web/src/components/marketing/BlogTeaser.tsx +++ b/apps/web/src/components/marketing/BlogTeaser.tsx @@ -31,12 +31,21 @@ export function BlogTeaser() { to={`/blog/${p.slug}`} className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col" > -
-
- - {p.title.split(' ').slice(0, 3).join(' ')} - -
+
+ {p.coverImage ? ( + + ) : ( +
+ + {p.title.split(' ').slice(0, 3).join(' ')} + +
+ )}

{formatDate(p.publishedAt)}

diff --git a/apps/web/src/components/marketing/Contact.tsx b/apps/web/src/components/marketing/Contact.tsx index e211120..5352571 100644 --- a/apps/web/src/components/marketing/Contact.tsx +++ b/apps/web/src/components/marketing/Contact.tsx @@ -1,12 +1,15 @@ import { useState } from 'react'; import { Mail, Send, Clock } from 'lucide-react'; import { api, type ApiError } from '@/lib/api'; +import { Turnstile, TURNSTILE_SITE_KEY } from '@/components/Turnstile'; type State = 'idle' | 'submitting' | 'success' | 'error'; export function Contact() { const [state, setState] = useState('idle'); const [error, setError] = useState(null); + const [captchaToken, setCaptchaToken] = useState(null); + const [captchaReset, setCaptchaReset] = useState(0); async function onSubmit(e: React.FormEvent) { e.preventDefault(); @@ -18,6 +21,7 @@ export function Contact() { fullName: String(fd.get('fullName') ?? '').trim(), email: String(fd.get('email') ?? '').trim(), message: String(fd.get('message') ?? '').trim(), + turnstileToken: captchaToken ?? undefined, }; try { @@ -28,6 +32,9 @@ export function Contact() { const apiErr = err as ApiError; setError(apiErr.code ?? apiErr.message); setState('error'); + } finally { + // Tokens are single-use — reset the widget whether the send worked or not. + setCaptchaReset((n) => n + 1); } } @@ -70,7 +77,13 @@ export function Contact() {
-