From 0700d54225ccc252db11cb5222160db3559e6f67 Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sun, 26 Apr 2026 02:42:42 -0400 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20eLegal=20Softwar?= =?UTF-8?q?e=20monorepo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 52 + .gitignore | 26 + .node-version | 1 + README.md | 121 + app.js | 3 + apps/api/package.json | 43 + apps/api/src/auth/csrf.ts | 80 + apps/api/src/auth/password.ts | 16 + apps/api/src/auth/plugin.ts | 91 + apps/api/src/auth/sessions.ts | 77 + apps/api/src/auth/superadmin.ts | 16 + apps/api/src/env.ts | 39 + apps/api/src/lib/audit.ts | 19 + apps/api/src/lib/email.ts | 159 + apps/api/src/lib/firm.ts | 21 + apps/api/src/lib/invoice-numbering.ts | 20 + apps/api/src/lib/invoice-pdf.ts | 148 + apps/api/src/lib/plan-limits.ts | 72 + apps/api/src/lib/sentry.ts | 26 + apps/api/src/lib/stripe.ts | 37 + apps/api/src/routes/account.ts | 137 + apps/api/src/routes/admin.ts | 398 ++ apps/api/src/routes/auth.ts | 244 + apps/api/src/routes/billing.ts | 77 + apps/api/src/routes/cases.ts | 177 + apps/api/src/routes/clients.ts | 122 + apps/api/src/routes/contact.ts | 33 + apps/api/src/routes/health.ts | 17 + apps/api/src/routes/invoices.ts | 562 ++ apps/api/src/routes/time-entries.ts | 307 + apps/api/src/routes/tool-usage.ts | 53 + apps/api/src/routes/webhooks-stripe.ts | 130 + apps/api/src/server.ts | 150 + apps/api/tsconfig.json | 14 + apps/web/index.html | 25 + apps/web/package.json | 39 + apps/web/postcss.config.js | 6 + apps/web/public/favicon.png | Bin 0 -> 10140 bytes apps/web/public/favicon.svg | 5 + apps/web/public/logo-dark.png | Bin 0 -> 7311 bytes apps/web/public/logo-light.png | Bin 0 -> 7480 bytes apps/web/src/App.tsx | 90 + apps/web/src/components/CookieBanner.tsx | 82 + apps/web/src/components/admin/AdminLayout.tsx | 57 + .../web/src/components/admin/AdminSidebar.tsx | 81 + apps/web/src/components/app/AppLayout.tsx | 48 + apps/web/src/components/app/BillingCard.tsx | 204 + apps/web/src/components/app/CaseTimeList.tsx | 84 + .../components/app/CreateInvoiceDrawer.tsx | 360 ++ .../src/components/app/ManualEntryDrawer.tsx | 155 + apps/web/src/components/app/Sidebar.tsx | 76 + apps/web/src/components/app/TimerWidget.tsx | 169 + apps/web/src/components/app/Topbar.tsx | 81 + apps/web/src/components/auth/AuthLayout.tsx | 58 + apps/web/src/components/auth/Field.tsx | 41 + .../src/components/marketing/BlogTeaser.tsx | 58 + apps/web/src/components/marketing/Contact.tsx | 153 + apps/web/src/components/marketing/Faq.tsx | 82 + .../web/src/components/marketing/Features.tsx | 72 + .../web/src/components/marketing/FinalCta.tsx | 23 + apps/web/src/components/marketing/Footer.tsx | 68 + .../components/marketing/FreeToolsTeaser.tsx | 89 + apps/web/src/components/marketing/Hero.tsx | 152 + .../src/components/marketing/HowItWorks.tsx | 156 + apps/web/src/components/marketing/Logo.tsx | 15 + apps/web/src/components/marketing/Navbar.tsx | 100 + apps/web/src/components/marketing/Pricing.tsx | 122 + .../components/marketing/ProblemSolution.tsx | 102 + apps/web/src/components/marketing/Stats.tsx | 39 + .../src/components/marketing/Testimonials.tsx | 99 + .../src/components/public/PublicLayout.tsx | 36 + apps/web/src/components/ui/Badge.tsx | 21 + apps/web/src/components/ui/Button.tsx | 43 + apps/web/src/components/ui/Card.tsx | 51 + apps/web/src/components/ui/Drawer.tsx | 64 + apps/web/src/components/ui/Input.tsx | 77 + apps/web/src/content/posts.ts | 212 + apps/web/src/hooks/useAccount.ts | 39 + apps/web/src/hooks/useAdmin.ts | 201 + apps/web/src/hooks/useAuth.ts | 66 + apps/web/src/hooks/useBilling.ts | 28 + apps/web/src/hooks/useCases.ts | 108 + apps/web/src/hooks/useClients.ts | 82 + apps/web/src/hooks/useInvoices.ts | 160 + apps/web/src/hooks/useResetPassword.ts | 14 + apps/web/src/hooks/useTime.ts | 134 + apps/web/src/hooks/useToolUsage.ts | 42 + apps/web/src/lib/api.ts | 51 + apps/web/src/lib/cn.ts | 6 + apps/web/src/lib/format.ts | 32 + apps/web/src/lib/sentry.ts | 16 + apps/web/src/main.tsx | 28 + apps/web/src/pages/ForgotPasswordPage.tsx | 80 + apps/web/src/pages/LandingPage.tsx | 37 + apps/web/src/pages/LoginPage.tsx | 102 + apps/web/src/pages/ResetPasswordPage.tsx | 105 + apps/web/src/pages/SignupPage.tsx | 114 + apps/web/src/pages/admin/AdminAuditPage.tsx | 64 + apps/web/src/pages/admin/AdminContactPage.tsx | 115 + .../src/pages/admin/AdminDashboardPage.tsx | 152 + .../src/pages/admin/AdminFirmDetailPage.tsx | 145 + apps/web/src/pages/admin/AdminFirmsPage.tsx | 94 + apps/web/src/pages/admin/AdminUsersPage.tsx | 167 + .../web/src/pages/app/AccountSettingsPage.tsx | 196 + apps/web/src/pages/app/CaseDetailPage.tsx | 297 + apps/web/src/pages/app/CasesPage.tsx | 253 + apps/web/src/pages/app/ClientDetailPage.tsx | 199 + apps/web/src/pages/app/ClientsPage.tsx | 162 + apps/web/src/pages/app/DashboardPage.tsx | 157 + apps/web/src/pages/app/InvoiceDetailPage.tsx | 219 + apps/web/src/pages/app/InvoicesPage.tsx | 146 + apps/web/src/pages/app/TimePage.tsx | 196 + .../src/pages/billing/BillingCancelPage.tsx | 33 + .../src/pages/billing/BillingSuccessPage.tsx | 51 + apps/web/src/pages/blog/BlogIndexPage.tsx | 48 + apps/web/src/pages/blog/BlogPostPage.tsx | 119 + apps/web/src/pages/legal/CookiesPage.tsx | 86 + apps/web/src/pages/legal/LegalLayout.tsx | 78 + apps/web/src/pages/legal/PrivacyPage.tsx | 139 + apps/web/src/pages/legal/TermsPage.tsx | 134 + .../pages/tools/BillableHoursTrackerPage.tsx | 288 + .../src/pages/tools/CaseProfitabilityPage.tsx | 100 + .../src/pages/tools/DocumentTemplatesPage.tsx | 366 ++ .../pages/tools/HourlyRateCalculatorPage.tsx | 152 + apps/web/src/pages/tools/ToolsIndexPage.tsx | 84 + apps/web/src/styles/globals.css | 51 + apps/web/tailwind.config.ts | 68 + apps/web/tsconfig.app.json | 16 + apps/web/tsconfig.json | 7 + apps/web/tsconfig.node.json | 11 + apps/web/vite.config.ts | 34 + package.json | 25 + packages/db/drizzle.config.ts | 18 + packages/db/migrations/0000_clear_wallop.sql | 309 + .../migrations/0001_orange_jamie_braddock.sql | 4 + .../db/migrations/meta/0000_snapshot.json | 1663 +++++ .../db/migrations/meta/0001_snapshot.json | 1689 +++++ packages/db/migrations/meta/_journal.json | 20 + packages/db/package.json | 30 + packages/db/src/index.ts | 56 + packages/db/src/migrate.ts | 21 + packages/db/src/schema/auth.ts | 103 + packages/db/src/schema/cases.ts | 33 + packages/db/src/schema/clients.ts | 22 + packages/db/src/schema/documents.ts | 27 + packages/db/src/schema/firms.ts | 16 + packages/db/src/schema/index.ts | 8 + packages/db/src/schema/invoices.ts | 53 + packages/db/src/schema/misc.ts | 25 + packages/db/src/schema/time.ts | 35 + packages/db/tsconfig.json | 10 + pinterestmarketingsystem (1).png | Bin 0 -> 7480 bytes pinterestmarketingsystem.png | Bin 0 -> 7311 bytes pnpm-lock.yaml | 5664 +++++++++++++++++ pnpm-workspace.yaml | 3 + ramework Monster (512 x 512 px).png | Bin 0 -> 10140 bytes scripts/plesk-deploy.sh | 20 + server.js | 45 + tmp/.gitkeep | 0 tsconfig.base.json | 17 + 160 files changed, 22771 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 README.md create mode 100644 app.js create mode 100644 apps/api/package.json create mode 100644 apps/api/src/auth/csrf.ts create mode 100644 apps/api/src/auth/password.ts create mode 100644 apps/api/src/auth/plugin.ts create mode 100644 apps/api/src/auth/sessions.ts create mode 100644 apps/api/src/auth/superadmin.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/lib/audit.ts create mode 100644 apps/api/src/lib/email.ts create mode 100644 apps/api/src/lib/firm.ts create mode 100644 apps/api/src/lib/invoice-numbering.ts create mode 100644 apps/api/src/lib/invoice-pdf.ts create mode 100644 apps/api/src/lib/plan-limits.ts create mode 100644 apps/api/src/lib/sentry.ts create mode 100644 apps/api/src/lib/stripe.ts create mode 100644 apps/api/src/routes/account.ts create mode 100644 apps/api/src/routes/admin.ts create mode 100644 apps/api/src/routes/auth.ts create mode 100644 apps/api/src/routes/billing.ts create mode 100644 apps/api/src/routes/cases.ts create mode 100644 apps/api/src/routes/clients.ts create mode 100644 apps/api/src/routes/contact.ts create mode 100644 apps/api/src/routes/health.ts create mode 100644 apps/api/src/routes/invoices.ts create mode 100644 apps/api/src/routes/time-entries.ts create mode 100644 apps/api/src/routes/tool-usage.ts create mode 100644 apps/api/src/routes/webhooks-stripe.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/public/favicon.png create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/public/logo-dark.png create mode 100644 apps/web/public/logo-light.png create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/CookieBanner.tsx create mode 100644 apps/web/src/components/admin/AdminLayout.tsx create mode 100644 apps/web/src/components/admin/AdminSidebar.tsx create mode 100644 apps/web/src/components/app/AppLayout.tsx create mode 100644 apps/web/src/components/app/BillingCard.tsx create mode 100644 apps/web/src/components/app/CaseTimeList.tsx create mode 100644 apps/web/src/components/app/CreateInvoiceDrawer.tsx create mode 100644 apps/web/src/components/app/ManualEntryDrawer.tsx create mode 100644 apps/web/src/components/app/Sidebar.tsx create mode 100644 apps/web/src/components/app/TimerWidget.tsx create mode 100644 apps/web/src/components/app/Topbar.tsx create mode 100644 apps/web/src/components/auth/AuthLayout.tsx create mode 100644 apps/web/src/components/auth/Field.tsx create mode 100644 apps/web/src/components/marketing/BlogTeaser.tsx create mode 100644 apps/web/src/components/marketing/Contact.tsx create mode 100644 apps/web/src/components/marketing/Faq.tsx create mode 100644 apps/web/src/components/marketing/Features.tsx create mode 100644 apps/web/src/components/marketing/FinalCta.tsx create mode 100644 apps/web/src/components/marketing/Footer.tsx create mode 100644 apps/web/src/components/marketing/FreeToolsTeaser.tsx create mode 100644 apps/web/src/components/marketing/Hero.tsx create mode 100644 apps/web/src/components/marketing/HowItWorks.tsx create mode 100644 apps/web/src/components/marketing/Logo.tsx create mode 100644 apps/web/src/components/marketing/Navbar.tsx create mode 100644 apps/web/src/components/marketing/Pricing.tsx create mode 100644 apps/web/src/components/marketing/ProblemSolution.tsx create mode 100644 apps/web/src/components/marketing/Stats.tsx create mode 100644 apps/web/src/components/marketing/Testimonials.tsx create mode 100644 apps/web/src/components/public/PublicLayout.tsx create mode 100644 apps/web/src/components/ui/Badge.tsx create mode 100644 apps/web/src/components/ui/Button.tsx create mode 100644 apps/web/src/components/ui/Card.tsx create mode 100644 apps/web/src/components/ui/Drawer.tsx create mode 100644 apps/web/src/components/ui/Input.tsx create mode 100644 apps/web/src/content/posts.ts create mode 100644 apps/web/src/hooks/useAccount.ts create mode 100644 apps/web/src/hooks/useAdmin.ts create mode 100644 apps/web/src/hooks/useAuth.ts create mode 100644 apps/web/src/hooks/useBilling.ts create mode 100644 apps/web/src/hooks/useCases.ts create mode 100644 apps/web/src/hooks/useClients.ts create mode 100644 apps/web/src/hooks/useInvoices.ts create mode 100644 apps/web/src/hooks/useResetPassword.ts create mode 100644 apps/web/src/hooks/useTime.ts create mode 100644 apps/web/src/hooks/useToolUsage.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/cn.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/sentry.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/ForgotPasswordPage.tsx create mode 100644 apps/web/src/pages/LandingPage.tsx create mode 100644 apps/web/src/pages/LoginPage.tsx create mode 100644 apps/web/src/pages/ResetPasswordPage.tsx create mode 100644 apps/web/src/pages/SignupPage.tsx create mode 100644 apps/web/src/pages/admin/AdminAuditPage.tsx create mode 100644 apps/web/src/pages/admin/AdminContactPage.tsx create mode 100644 apps/web/src/pages/admin/AdminDashboardPage.tsx create mode 100644 apps/web/src/pages/admin/AdminFirmDetailPage.tsx create mode 100644 apps/web/src/pages/admin/AdminFirmsPage.tsx create mode 100644 apps/web/src/pages/admin/AdminUsersPage.tsx create mode 100644 apps/web/src/pages/app/AccountSettingsPage.tsx create mode 100644 apps/web/src/pages/app/CaseDetailPage.tsx create mode 100644 apps/web/src/pages/app/CasesPage.tsx create mode 100644 apps/web/src/pages/app/ClientDetailPage.tsx create mode 100644 apps/web/src/pages/app/ClientsPage.tsx create mode 100644 apps/web/src/pages/app/DashboardPage.tsx create mode 100644 apps/web/src/pages/app/InvoiceDetailPage.tsx create mode 100644 apps/web/src/pages/app/InvoicesPage.tsx create mode 100644 apps/web/src/pages/app/TimePage.tsx create mode 100644 apps/web/src/pages/billing/BillingCancelPage.tsx create mode 100644 apps/web/src/pages/billing/BillingSuccessPage.tsx create mode 100644 apps/web/src/pages/blog/BlogIndexPage.tsx create mode 100644 apps/web/src/pages/blog/BlogPostPage.tsx create mode 100644 apps/web/src/pages/legal/CookiesPage.tsx create mode 100644 apps/web/src/pages/legal/LegalLayout.tsx create mode 100644 apps/web/src/pages/legal/PrivacyPage.tsx create mode 100644 apps/web/src/pages/legal/TermsPage.tsx create mode 100644 apps/web/src/pages/tools/BillableHoursTrackerPage.tsx create mode 100644 apps/web/src/pages/tools/CaseProfitabilityPage.tsx create mode 100644 apps/web/src/pages/tools/DocumentTemplatesPage.tsx create mode 100644 apps/web/src/pages/tools/HourlyRateCalculatorPage.tsx create mode 100644 apps/web/src/pages/tools/ToolsIndexPage.tsx create mode 100644 apps/web/src/styles/globals.css create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.app.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts create mode 100644 package.json create mode 100644 packages/db/drizzle.config.ts create mode 100644 packages/db/migrations/0000_clear_wallop.sql create mode 100644 packages/db/migrations/0001_orange_jamie_braddock.sql create mode 100644 packages/db/migrations/meta/0000_snapshot.json create mode 100644 packages/db/migrations/meta/0001_snapshot.json create mode 100644 packages/db/migrations/meta/_journal.json create mode 100644 packages/db/package.json create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/migrate.ts create mode 100644 packages/db/src/schema/auth.ts create mode 100644 packages/db/src/schema/cases.ts create mode 100644 packages/db/src/schema/clients.ts create mode 100644 packages/db/src/schema/documents.ts create mode 100644 packages/db/src/schema/firms.ts create mode 100644 packages/db/src/schema/index.ts create mode 100644 packages/db/src/schema/invoices.ts create mode 100644 packages/db/src/schema/misc.ts create mode 100644 packages/db/src/schema/time.ts create mode 100644 packages/db/tsconfig.json create mode 100644 pinterestmarketingsystem (1).png create mode 100644 pinterestmarketingsystem.png create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 ramework Monster (512 x 512 px).png create mode 100644 scripts/plesk-deploy.sh create mode 100644 server.js create mode 100644 tmp/.gitkeep create mode 100644 tsconfig.base.json 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 0000000000000000000000000000000000000000..1e4a0e660ad610c1dd457351023b84b90d02ada7 GIT binary patch literal 10140 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 Y^tGF(n-2<9zgckE;kmtPThI^x2mW?Su>b%7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce63191592f3c5600485f383d6eac011a4159f83 GIT binary patch literal 7311 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 KDO9p~@P7c`K3@s| literal 0 HcmV?d00001 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 ( + + ); +} 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)} + /> +
+ +