diff --git a/.env.example b/.env.example index 39a6c08..bbcc439 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,11 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM= + +# Automatic SQLite backups (optional - sensible defaults are used if unset) +# Directory where backup files are written (default: ./data/backups) +BACKUP_DIR=./data/backups +# How often to run a backup, in hours. Set to 0 to disable backups entirely (default: 24) +BACKUP_INTERVAL_HOURS=24 +# How many of the newest backups to keep; older ones are deleted (default: 7) +BACKUP_RETENTION=7 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6b623c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md — guidance for AI coding agents + +Client Flow is a business-management app: a React/Vite frontend with a local +Express/SQLite (better-sqlite3) backend. The frontend talks to the backend +through a **Supabase-compatible client shim** (`src/integrations/api/client.ts`), +so app code reads like Supabase but there is no Supabase at runtime. + +## Commands + +```bash +npm run dev # frontend (:8080) + backend (:3001) together +npm run dev:client # Vite only +npm run dev:server # API server only (tsx watch) +npm run build # production build +npm run lint # ESLint (note: repo has pre-existing lint debt) +npm run typecheck # tsc --noEmit for the server project +npm run test # Vitest (watch) +npm run test:run # Vitest (once) — server integration tests +npm run db:reset # delete data/app.db and restart the server +``` + +## Architecture + +``` +Frontend (React/Vite :8080) <--> Backend (Express/SQLite :3001) + src/integrations/api/client.ts server/ + (Supabase-compatible shim) ├── index.ts app wiring, CORS, rate limiting + ├── db/{schema.sql, seed.sql, database.ts, columns.ts} + ├── routes/{auth,crud,storage,functions, + │ external-api,bill-import,mail}.ts + ├── middleware/auth.ts JWT + permissions + └── utils/{numbering,activityLogger,email}.ts +mcp-server/ local MCP server exposing the external API to AI agents +openapi.yaml OpenAPI 3.1 contract for /api/external +``` + +The `crud.ts` router implements the Supabase-style query surface (select with +relations, filters like `col.eq.value`, order, limit) over SQLite. Table and +**column names are validated** against real columns (`db/columns.ts`) before +being interpolated — never interpolate a caller-supplied identifier without +validating it. + +## Security model (do not regress) + +- Auth is JWT (`middleware/auth.ts`); `authMiddleware` re-checks the user is + active on every request. Passwords are bcrypt. +- Onboarding is **invite-based**: `POST /auth/users` (requires `team:write`) + creates a user with an invite token; `POST /auth/accept-invite` sets the + password. Login never auto-creates passwords. +- The external API (`/api/external`, `routes/external-api.ts`) authenticates + with hashed API keys and is gated by the key's **scopes** AND the owning + user's role. Keys are created at `/api-keys`. +- In production the server refuses to boot with a placeholder `JWT_SECRET`. + +## Numbering + +Invoice/job numbers come from atomic counters in `company_settings`, allocated +via `server/utils/numbering.ts` (`allocateInvoiceNumber` / `allocateJobNumber`) +inside a transaction. The frontend reserves a number at save time via +`POST /api/functions/allocate-invoice-number`. Do NOT generate numbers with +`COUNT(*)` or a client-side read-modify-write — both reuse/collide. + +## Project structure (frontend) + +``` +src/ +├── components/ui/ shadcn/ui — DO NOT modify (add via `npx shadcn@latest add`) +├── components/ feature components (PascalCase) +├── pages/ route pages (PascalCase), lazy-loaded in App.tsx +├── hooks/ custom hooks (useXxx.ts) +├── contexts/ React contexts (Auth, Permission, Branding) +├── integrations/api/ the API client shim +└── lib/utils.ts cn(), uuid +``` + +## Conventions + +- Import order: React → external libs → `@/components/ui/*` → `@/components/*` + → `@/hooks/*` → `@/contexts/*` → `@/integrations/*` → `@/lib/*` → types. + Always use the `@/` alias. +- Naming: Pages/Components PascalCase; ui components lowercase-hyphen; hooks + `useX`; handlers `handleX`; types PascalCase. +- Styling: Tailwind only; use `cn()` for conditional classes. No inline styles. +- User-facing errors go through `useToast` → + `toast({ title, description, variant: 'destructive' })`. +- TypeScript strictness is relaxed on the frontend; the **server project + typechecks cleanly** (`npm run typecheck`) — keep it that way. + +## Testing + +Server integration tests live in `server/__tests__/` (Vitest + supertest over a +temp SQLite). They assert the security and numbering invariants — run +`npm run test:run` before committing server changes, and add a test when you fix +a backend bug. CI (`.github/workflows/ci.yml`) gates on typecheck + tests + +build + docker build. + +## Gotchas + +- better-sqlite3 is **synchronous** and built with `SQLITE_DQS=0`: use + single-quoted SQL string literals (`datetime('now')`, not `"now"`), or the + statement throws. +- The DB is a singleton keyed off `DATABASE_PATH`; tests set env vars before + dynamically importing the app. +- `server/uploads/` and `data/` are gitignored; storage paths are contained to + their bucket dir (no traversal). + +## Environment + +```bash +VITE_API_URL=http://localhost:3001/api +API_PORT=3001 +DATABASE_PATH=./data/app.db +JWT_SECRET=change-me # required; must be non-placeholder in production +CORS_ORIGIN=http://localhost:8080 +# SMTP_* optional, for invoice/invite email +``` diff --git a/README.md b/README.md index 918d469..eb27885 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,17 @@ Programmatic access for integrations and AI tools: - **Vendor Spend** - Spending by vendor - **GST Summary** - Tax summary +### Backups + +The server automatically backs up the SQLite database on startup and on a recurring interval, using better-sqlite3's online backup API (safe to run while the app is live): + +- Backups are written to `BACKUP_DIR` (default `data/backups`, already gitignored) as `app-YYYYMMDD-HHmmss.db` +- Runs every `BACKUP_INTERVAL_HOURS` (default `24`); set to `0` to disable +- Only the newest `BACKUP_RETENTION` backups are kept (default `7`); older ones are deleted automatically +- A backup failure is logged but never crashes the server + +Configure these in `.env.local` if you want different values - see `.env.example`. + ## Notes - The local database file lives at `data/app.db` and is gitignored. diff --git a/api-examples.md b/api-examples.md index c0a7694..71f803e 100644 --- a/api-examples.md +++ b/api-examples.md @@ -431,3 +431,12 @@ curl -s -X POST "$BASE_URL/clients" \ -H "Content-Type: application/json" \ -d '{"name": "Test Client", "is_active": 1}' | jq . ``` + +--- + +## API reference (OpenAPI) + +The full machine-readable contract for this API — every resource, path, request/response +schema, auth scheme, and error shape — is defined in [`openapi.yaml`](./openapi.yaml) at the +repo root (OpenAPI 3.1). Load it into any OpenAPI-aware tool (Redoc, Swagger UI, Postman, +an LLM/codegen client, etc.) to browse or generate clients from it. diff --git a/bun.lockb b/bun.lockb deleted file mode 100644 index 4952705..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..dd8fe26 --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.env diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..c77ec38 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,93 @@ +# clientflow-mcp + +A local [MCP](https://modelcontextprotocol.io) server that lets an AI agent (Claude Desktop, +etc.) drive the **Client Flow** app through its external REST API (`/api/external`) using a +scoped API key. It exposes generic CRUD tools over every resource the external API supports. + +> **Security:** this server adds no privileges. Every call is gated server-side by the API +> key's **scopes** and the owning user's **role**. Create narrowly-scoped keys at `/api-keys` +> and treat the key like a password. + +## Requirements + +- Node.js 18+ +- A running Client Flow server +- An API key created in the app at **/api-keys** + +## Build + +```sh +cd mcp-server +npm install +npm run build +``` + +## Configuration + +Two environment variables: + +| Variable | Example | Notes | +| -------------------- | ----------------------------- | -------------------------------------------------- | +| `CLIENTFLOW_API_URL` | `http://localhost:3001/api` | Base URL of the app (with or without trailing `/api`). | +| `CLIENTFLOW_API_KEY` | `sk_live_...` | An API key from `/api-keys`. | + +The server refuses to start if either is missing (and never prints the key). + +## Add to Claude Desktop + +Add this to your `claude_desktop_config.json` (adjust the path to `dist/index.js`): + +```json +{ + "mcpServers": { + "clientflow": { + "command": "node", + "args": ["/absolute/path/to/mcp-server/dist/index.js"], + "env": { + "CLIENTFLOW_API_URL": "http://localhost:3001/api", + "CLIENTFLOW_API_KEY": "sk_live_replace_me" + } + } + } +} +``` + +## Tools + +| Tool | Purpose | Hints | +| ---------------- | --------------------------------------------------- | ------------------------------ | +| `list_resources` | List available resources and their permission groups | read-only | +| `list_records` | List records of a resource (max 100, no server filtering; use `limit`) | read-only | +| `get_record` | Fetch one record by id | read-only | +| `create_record` | Create a record from column/value pairs | write | +| `update_record` | Update fields on a record by id | write, idempotent | +| `delete_record` | Delete a record by id | write, **destructive** | + +Resources (gated by the permission group in parentheses): clients, jobs, invoices, payments, +assets, issues, vendors, items (inventory), expenses, timesheets (jobs), bank-accounts (banking), +bank-transactions (banking), profiles (team), kb-articles/kb-attachments/kb-article-issues (kb), +locations, location-contacts (locations), bill-import-sessions (purchases). + +The full REST contract is documented in [`../openapi.yaml`](../openapi.yaml). + +## Example prompts + +- "List all active clients." +- "How many invoices are still in `draft` status?" (list invoices, filter client-side) +- "Create a new client named Acme Pty Ltd with email accounts@acme.example." +- "Create a draft invoice for client `` with a total of 1500." +- "Mark invoice `` as sent." + +## Test + +```sh +npm test # builds, then runs the api-client unit tests with node --test +``` + +The tests cover URL normalization and the actionable error-message mapping (no network calls). + +## Notes / limitations + +- The list endpoint returns at most 100 rows and supports no server-side filtering or + pagination — retrieve the set and filter client-side. +- `get_record` returns `null` (not an error) when no record matches the id. diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 0000000..96f177e --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1215 @@ +{ + "name": "clientflow-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clientflow-mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^3.25.76" + }, + "bin": { + "clientflow-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.16.5", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.28", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz", + "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 0000000..b06610b --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,29 @@ +{ + "name": "clientflow-mcp", + "version": "0.1.0", + "description": "Local MCP (Model Context Protocol) server that wraps the Client Flow external REST API so an AI agent can list, read, create, update, and delete records through a scoped API key.", + "type": "module", + "private": true, + "bin": { + "clientflow-mcp": "dist/index.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "test": "npm run build && node --test dist" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^22.16.5", + "typescript": "^5.8.3" + } +} diff --git a/mcp-server/src/api-client.test.ts b/mcp-server/src/api-client.test.ts new file mode 100644 index 0000000..5dd5a07 --- /dev/null +++ b/mcp-server/src/api-client.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + normalizeApiUrl, + buildExternalBaseUrl, + buildErrorMessage, +} from "./api-client.js"; + +test("normalizeApiUrl keeps a base that already ends in /api", () => { + assert.equal(normalizeApiUrl("http://localhost:3001/api"), "http://localhost:3001/api"); +}); + +test("normalizeApiUrl strips trailing slashes", () => { + assert.equal(normalizeApiUrl("http://localhost:3001/api/"), "http://localhost:3001/api"); + assert.equal(normalizeApiUrl("http://localhost:3001/"), "http://localhost:3001/api"); +}); + +test("normalizeApiUrl appends /api when missing", () => { + assert.equal(normalizeApiUrl("http://localhost:3001"), "http://localhost:3001/api"); +}); + +test("buildExternalBaseUrl appends /external", () => { + assert.equal( + buildExternalBaseUrl("http://localhost:3001/api"), + "http://localhost:3001/api/external" + ); + assert.equal( + buildExternalBaseUrl("http://localhost:3001"), + "http://localhost:3001/api/external" + ); +}); + +test("buildErrorMessage gives auth guidance on 401", () => { + const msg = buildErrorMessage(401, "clients", undefined, { error: "Invalid or revoked API key" }); + assert.match(msg, /HTTP 401/); + assert.match(msg, /Invalid or revoked API key/); + assert.match(msg, /CLIENTFLOW_API_KEY/); +}); + +test("buildErrorMessage gives permission guidance on 403", () => { + const msg = buildErrorMessage(403, "invoices", undefined, { error: "Access denied" }); + assert.match(msg, /Permission denied for 'invoices'/); + assert.match(msg, /api-keys/); +}); + +test("buildErrorMessage lists available resources on invalid-resource 400", () => { + const msg = buildErrorMessage(400, "bogus", undefined, { + error: "Invalid resource", + available_resources: ["clients", "jobs"], + }); + assert.match(msg, /not a valid resource/); + assert.match(msg, /clients, jobs/); +}); + +test("buildErrorMessage explains column errors on other 400s", () => { + const msg = buildErrorMessage(400, "clients", undefined, { error: "Invalid column name" }); + assert.match(msg, /field name/); +}); + +test("buildErrorMessage references the id on 404", () => { + const msg = buildErrorMessage(404, "clients", "abc-123", { error: "Record not found" }); + assert.match(msg, /No clients record found with id 'abc-123'/); +}); diff --git a/mcp-server/src/api-client.ts b/mcp-server/src/api-client.ts new file mode 100644 index 0000000..1e0efb8 --- /dev/null +++ b/mcp-server/src/api-client.ts @@ -0,0 +1,156 @@ +/** + * Thin HTTP client for the Client Flow external API + * (`/external/...`, see server/routes/external-api.ts). + * + * Responsibilities: + * - normalize the configured base URL, + * - attach the Bearer auth header, + * - perform the fetch, + * - turn non-2xx responses into ClientFlowApiError with an actionable, + * human-readable message (so the calling tool can report it back to + * the agent instead of crashing the process). + */ + +export interface ClientFlowConfig { + /** Base URL that already includes the trailing "/external" segment. */ + baseUrl: string; + apiKey: string; +} + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface ApiRequestOptions { + id?: string; + body?: unknown; +} + +/** + * Error thrown for any non-2xx response (or transport failure) from the + * Client Flow external API. `.message` is already actionable and safe to + * surface directly to an agent/user; `.status` is the HTTP status code + * (0 for network-level failures where no response was received). + */ +export class ClientFlowApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ClientFlowApiError"; + this.status = status; + } +} + +/** + * Normalizes a user-supplied CLIENTFLOW_API_URL into a base URL that ends + * in "/api" with no trailing slash. Accepts the value with or without a + * trailing slash, and with or without a trailing "/api": + * + * "http://localhost:3001/api" -> "http://localhost:3001/api" + * "http://localhost:3001/api/" -> "http://localhost:3001/api" + * "http://localhost:3001" -> "http://localhost:3001/api" + * "http://localhost:3001/" -> "http://localhost:3001/api" + */ +export function normalizeApiUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/+$/, ""); + return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`; +} + +/** Builds the full "/api/external" URL used as ClientFlowConfig.baseUrl. */ +export function buildExternalBaseUrl(rawApiUrl: string): string { + return `${normalizeApiUrl(rawApiUrl)}/external`; +} + +/** + * Builds an actionable error message for a failed external API call. + * Always includes the HTTP status and the server's raw `error` text, plus + * guidance specific to the status code. + */ +export function buildErrorMessage( + status: number, + resource: string, + id: string | undefined, + body: any +): string { + const serverError = + body && typeof body.error === "string" ? body.error : "(no error detail returned)"; + const prefix = `ClientFlow API error (HTTP ${status}) for resource '${resource}': ${serverError}`; + + switch (status) { + case 401: + return `${prefix}. Authentication failed: the API key is missing, invalid, revoked, or expired. Check CLIENTFLOW_API_KEY.`; + case 403: + return `${prefix}. Permission denied for '${resource}': the API key's scopes or the owning user's role does not allow this. Grant the resource scope/permission on the key at /api-keys.`; + case 400: + if (Array.isArray(body?.available_resources)) { + return `${prefix}. '${resource}' is not a valid resource. Available resources: ${body.available_resources.join(", ")}.`; + } + return `${prefix}. Check that every field name in your data matches an actual column on '${resource}'.`; + case 404: + return id + ? `${prefix}. No ${resource} record found with id '${id}'.` + : `${prefix}. The requested ${resource} resource was not found.`; + default: + return prefix; + } +} + +/** + * Performs one call against the Client Flow external API and returns the + * parsed `data` payload (or `null` for a 204 No Content response, e.g. + * DELETE). Throws ClientFlowApiError on any non-2xx response or network + * failure — callers should catch this and convert it into an MCP tool + * error response rather than letting it propagate and crash the server. + */ +export async function apiRequest( + config: ClientFlowConfig, + method: HttpMethod, + resource: string, + options: ApiRequestOptions = {} +): Promise { + const { id, body } = options; + const path = id + ? `${config.baseUrl}/${resource}/${encodeURIComponent(id)}` + : `${config.baseUrl}/${resource}`; + + const hasBody = body !== undefined; + const headers: Record = { + Authorization: `Bearer ${config.apiKey}`, + }; + if (hasBody) { + headers["Content-Type"] = "application/json"; + } + + let response: Response; + try { + response = await fetch(path, { + method, + headers, + body: hasBody ? JSON.stringify(body) : undefined, + }); + } catch (err: any) { + throw new ClientFlowApiError( + `Could not reach ClientFlow API at ${path}: ${err?.message ?? err}. Check that CLIENTFLOW_API_URL points to a running server.`, + 0 + ); + } + + if (response.status === 204) { + return null; + } + + const text = await response.text(); + let parsed: any = null; + if (text.length > 0) { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } + + if (!response.ok) { + throw new ClientFlowApiError(buildErrorMessage(response.status, resource, id, parsed), response.status); + } + + return (parsed?.data ?? null) as T | null; +} diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 0000000..7fd98ea --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,217 @@ +#!/usr/bin/env node +/** + * Client Flow MCP server. + * + * A local Model Context Protocol server (stdio transport) that lets an AI + * agent drive the Client Flow app through its external REST API using a + * scoped API key. All access is ultimately gated server-side by the key's + * scopes and the owning user's role — this server adds no privileges. + * + * Configuration (environment variables): + * CLIENTFLOW_API_URL base URL of the app, e.g. http://localhost:3001/api + * CLIENTFLOW_API_KEY an API key created at /api-keys (looks like sk_live_...) + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +import { + apiRequest, + buildExternalBaseUrl, + ClientFlowApiError, + type ClientFlowConfig, +} from "./api-client.js"; +import { RESOURCES, RESOURCE_PERMISSION_GROUPS, type Resource } from "./resources.js"; + +function loadConfig(): ClientFlowConfig { + const rawUrl = process.env.CLIENTFLOW_API_URL; + const apiKey = process.env.CLIENTFLOW_API_KEY; + + const missing: string[] = []; + if (!rawUrl) missing.push("CLIENTFLOW_API_URL"); + if (!apiKey) missing.push("CLIENTFLOW_API_KEY"); + if (missing.length > 0) { + // Never print the key itself. + console.error( + `clientflow-mcp: missing required environment variable(s): ${missing.join(", ")}.\n` + + "Set CLIENTFLOW_API_URL (e.g. http://localhost:3001/api) and CLIENTFLOW_API_KEY " + + "(an API key from /api-keys) before starting the server." + ); + process.exit(1); + } + + return { baseUrl: buildExternalBaseUrl(rawUrl!), apiKey: apiKey! }; +} + +const resourceEnum = z.enum(RESOURCES as unknown as [Resource, ...Resource[]]); + +/** Wrap a tool body so a ClientFlowApiError becomes an MCP tool error instead of crashing. */ +async function toToolResult(run: () => Promise) { + try { + const data = await run(); + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + }; + } catch (err) { + const message = + err instanceof ClientFlowApiError + ? err.message + : `Unexpected error: ${(err as Error)?.message ?? String(err)}`; + return { + content: [{ type: "text" as const, text: message }], + isError: true, + }; + } +} + +function createServer(config: ClientFlowConfig): McpServer { + const server = new McpServer({ name: "clientflow-mcp", version: "0.1.0" }); + + server.registerTool( + "list_resources", + { + title: "List available resources", + description: + "List every resource this server can access and the permission group " + + "that gates each one. Call this first to discover what you can read or write. " + + "Actual access still depends on the API key's scopes and the owning user's role.", + inputSchema: {}, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async () => + toToolResult(async () => + RESOURCES.map((resource) => ({ + resource, + permission_group: RESOURCE_PERMISSION_GROUPS[resource], + })) + ) + ); + + server.registerTool( + "list_records", + { + title: "List records", + description: + "List records of a resource. Returns at most 100 rows; the API does not " + + "support server-side filtering or pagination, so retrieve the set and filter " + + "client-side. Use `limit` to cap how many rows are returned.", + inputSchema: { + resource: resourceEnum.describe("Which resource to list."), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Maximum number of rows to return (1-100, default 100)."), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ resource, limit }) => + toToolResult(async () => { + const rows = (await apiRequest(config, "GET", resource)) ?? []; + return typeof limit === "number" ? rows.slice(0, limit) : rows; + }) + ); + + server.registerTool( + "get_record", + { + title: "Get a record by id", + description: "Fetch a single record of a resource by its id. Returns null if no such record exists.", + inputSchema: { + resource: resourceEnum.describe("Which resource to read."), + id: z.string().min(1).describe("The record's id (UUID)."), + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + async ({ resource, id }) => + toToolResult(async () => apiRequest(config, "GET", resource, { id })) + ); + + server.registerTool( + "create_record", + { + title: "Create a record", + description: + "Create a record. `data` is an object of column/value pairs; unknown column " + + "names are rejected. `id` is generated if omitted, and jobs/invoices get an " + + "auto-assigned number if you omit job_number/invoice_number. Returns the created record.", + inputSchema: { + resource: resourceEnum.describe("Which resource to create."), + data: z.record(z.any()).describe("Column/value pairs for the new record."), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ resource, data }) => + toToolResult(async () => apiRequest(config, "POST", resource, { body: data })) + ); + + server.registerTool( + "update_record", + { + title: "Update a record", + description: + "Update fields on an existing record by id. `data` is an object of the " + + "column/value pairs to change; unknown column names are rejected. Returns the updated record.", + inputSchema: { + resource: resourceEnum.describe("Which resource to update."), + id: z.string().min(1).describe("The id of the record to update."), + data: z.record(z.any()).describe("Column/value pairs to change."), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ resource, id, data }) => + toToolResult(async () => apiRequest(config, "PATCH", resource, { id, body: data })) + ); + + server.registerTool( + "delete_record", + { + title: "Delete a record", + description: "Permanently delete a record by id. This cannot be undone.", + inputSchema: { + resource: resourceEnum.describe("Which resource to delete from."), + id: z.string().min(1).describe("The id of the record to delete."), + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ resource, id }) => + toToolResult(async () => { + await apiRequest(config, "DELETE", resource, { id }); + return { deleted: true, resource, id }; + }) + ); + + return server; +} + +async function main() { + const config = loadConfig(); + const server = createServer(config); + const transport = new StdioServerTransport(); + await server.connect(transport); + // Log to stderr so it doesn't corrupt the stdio JSON-RPC stream on stdout. + console.error("clientflow-mcp: ready (stdio)"); +} + +main().catch((err) => { + console.error("clientflow-mcp: fatal error:", err); + process.exit(1); +}); diff --git a/mcp-server/src/resources.ts b/mcp-server/src/resources.ts new file mode 100644 index 0000000..1cf4f3a --- /dev/null +++ b/mcp-server/src/resources.ts @@ -0,0 +1,65 @@ +/** + * The fixed set of resources exposed by the Client Flow external API + * (mirrors RESOURCE_MAP in server/routes/external-api.ts of the main app). + * + * This list is intentionally hardcoded rather than discovered at runtime: + * the external API has no "list resources" endpoint of its own, and a + * static, typed union gives the agent (and Zod) a closed set to validate + * against instead of an arbitrary string. + */ +export const RESOURCES = [ + "clients", + "jobs", + "invoices", + "payments", + "assets", + "issues", + "vendors", + "items", + "expenses", + "timesheets", + "bank-accounts", + "bank-transactions", + "profiles", + "kb-articles", + "kb-attachments", + "kb-article-issues", + "locations", + "location-contacts", + "bill-import-sessions", +] as const; + +export type Resource = (typeof RESOURCES)[number]; + +/** + * Maps each resource to the permission group that gates it server-side. + * A request against a resource is only allowed when: + * 1. the API key's scopes include "*" or this permission group, AND + * 2. the key's owning user has read/write permission for this group. + * + * Mirrors the `resource` field of RESOURCE_MAP in + * server/routes/external-api.ts. Several resources intentionally share a + * group (e.g. timesheets is gated by the "jobs" permission, bank-accounts + * and bank-transactions both fall under "banking"). + */ +export const RESOURCE_PERMISSION_GROUPS: Record = { + clients: "clients", + jobs: "jobs", + invoices: "invoices", + payments: "payments", + assets: "assets", + issues: "issues", + vendors: "vendors", + items: "inventory", + expenses: "expenses", + timesheets: "jobs", + "bank-accounts": "banking", + "bank-transactions": "banking", + profiles: "team", + "kb-articles": "kb", + "kb-attachments": "kb", + "kb-article-issues": "kb", + locations: "locations", + "location-contacts": "locations", + "bill-import-sessions": "purchases", +}; diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..ce81558 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..637074f --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,549 @@ +openapi: 3.1.0 +info: + title: Client Flow External API + version: "1.0.0" + description: | + Generic, table-backed REST API for integrating external systems with Client Flow. + + All endpoints live under a single `/external/{resource}` family of routes. `{resource}` + selects one of a fixed set of tables (see the `resource` path parameter for the exact + list); there is no way to address a table that isn't in that list. + + ### Authentication + + Every request must include `Authorization: Bearer `, where `` is a key + issued from the app's API Keys settings page (keys look like `sk_live_...`). Requests with + a missing, malformed, unknown, expired, or revoked key receive `401` with a JSON body + `{ "error": "" }`. The exact `error` message varies by cause, for example: + - `Missing or invalid Authorization header` — no `Bearer ` prefix present + - `Invalid or revoked API key` — key not found, or found but inactive + - `API key has expired` — key's `expires_at` is in the past + + ### Authorization (scopes + user permissions) + + Access to a resource is gated two independent ways, both of which must pass: + 1. **API key scope** — the key's `scopes` list must contain `*` (all resources) or the + permission group for the requested resource (see mapping below). Failing this + returns `403 { "error": "API key does not have access to this resource" }`. + 2. **Owning user's permission** — the user who owns the API key must have `read` + permission (for `GET`) or `write` permission (for `POST`/`PUT`/`PATCH`/`DELETE`) on + that same permission group, via their assigned role(s). Failing this returns + `403 { "error": "Permission denied: no read access to this resource" }` (GET) or + `403 { "error": "Permission denied: no write access to this resource" }` (writes). + + Most resources use their own name as the permission group (e.g. `clients` is gated by + the `clients` group). The exceptions are: + - `items` → `inventory` + - `timesheets` → `jobs` + - `bank-accounts`, `bank-transactions` → `banking` + - `profiles` → `team` + - `kb-articles`, `kb-attachments`, `kb-article-issues` → `kb` + - `location-contacts` → `locations` + - `bill-import-sessions` → `purchases` + + ### Records are free-form + + Request and response bodies for individual records are plain JSON objects of + column/value pairs matching the underlying table's real columns — there is no fixed + schema beyond "must be real columns of that table". Sending an unknown column name in a + `POST`, `PUT`, or `PATCH` body returns `400 { "error": "Invalid column name" }`. + + ### Listing has no filtering or pagination + + `GET /external/{resource}` always runs `SELECT * FROM LIMIT 100` — it returns at + most 100 rows, in whatever order SQLite returns them, with no query-string filtering, + sorting, or pagination support of any kind. +servers: + - url: http://localhost:3001/api + description: >- + Local development server. The host and port are deployment-specific — replace this + with the actual origin (and, where applicable, HTTPS) for your environment. In all + cases the external API is mounted at `/api/external`, so combined with the paths + below the full URL is `{server}/external/{resource}[/{id}]`. +security: + - bearerAuth: [] +tags: + - name: Records + description: >- + Generic CRUD operations that behave uniformly across every supported resource/table. +paths: + /external/{resource}: + parameters: + - $ref: '#/components/parameters/resource' + get: + operationId: listRecords + summary: List records for a resource + description: >- + Returns up to 100 rows from the resource's underlying table + (`SELECT * FROM
LIMIT 100`). No filtering, sorting, or pagination + parameters are supported. + tags: [Records] + responses: + '200': + description: A page of up to 100 records. + content: + application/json: + schema: + $ref: '#/components/schemas/RecordListResponse' + examples: + clients: + $ref: '#/components/examples/ClientListResponse' + invoices: + $ref: '#/components/examples/InvoiceListResponse' + '400': + $ref: '#/components/responses/InvalidResource' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + operationId: createRecord + summary: Create a record + description: >- + Inserts a new row. The request body is a free-form object of column/value pairs + for the resource's table. + + - If `id` is omitted, a UUID is generated automatically. + - For `jobs`, `job_number` is auto-assigned (from the shared, transaction-guarded + company counter) if omitted. + - For `invoices`, `invoice_number` is auto-assigned the same way if omitted. + - Any key that is not a real column of the underlying table causes a + `400 { "error": "Invalid column name" }` response before any write happens. + - Other database-level failures (e.g. a `NOT NULL` or `UNIQUE`/foreign-key + constraint violation) also return `400` with the underlying error message in + `error`. + tags: [Records] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + examples: + createClient: + $ref: '#/components/examples/ClientCreateRequest' + createInvoice: + $ref: '#/components/examples/InvoiceCreateRequest' + responses: + '201': + description: The newly created record, as stored. + content: + application/json: + schema: + $ref: '#/components/schemas/RecordResponse' + examples: + createdClient: + $ref: '#/components/examples/ClientRecordResponse' + createdInvoice: + $ref: '#/components/examples/InvoiceRecordResponse' + '400': + description: >- + Invalid resource, an unknown column name in the request body, or another + database constraint error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidColumn: + summary: Unknown column in request body + value: + error: Invalid column name + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /external/{resource}/{id}: + parameters: + - $ref: '#/components/parameters/resource' + - $ref: '#/components/parameters/id' + get: + operationId: getRecord + summary: Get a single record by ID + description: >- + Looks up one row by primary key. If no row matches `id`, the response is still + `200`, with `data` set to `null` — this endpoint never returns `404`. + tags: [Records] + responses: + '200': + description: The record, or `null` if no record with that ID exists. + content: + application/json: + schema: + $ref: '#/components/schemas/NullableRecordResponse' + examples: + foundClient: + $ref: '#/components/examples/ClientRecordResponse' + notFound: + $ref: '#/components/examples/ClientNotFoundResponse' + '400': + $ref: '#/components/responses/InvalidResource' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + put: + operationId: updateRecord + summary: Replace/update a record by ID (full update) + description: >- + Applies the given column/value pairs with a `SET` update. Only the columns present + in the request body are written — this is the same handler as `PATCH` and does not + require every column to be supplied. + tags: [Records] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + examples: + updateClient: + $ref: '#/components/examples/ClientUpdateRequest' + responses: + '200': + description: The updated record, as stored after the update. + content: + application/json: + schema: + $ref: '#/components/schemas/RecordResponse' + examples: + updatedClient: + $ref: '#/components/examples/ClientRecordResponse' + '400': + description: An unknown column name in the request body, or a database constraint error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidColumn: + value: + error: Invalid column name + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + operationId: patchRecord + summary: Partially update a record by ID + description: >- + Identical behavior to `PUT` on this same path — both apply a `SET` update covering + only the columns present in the request body. Provided as an alias for clients that + prefer `PATCH` semantics for partial updates. + tags: [Records] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + examples: + patchInvoiceStatus: + $ref: '#/components/examples/InvoicePatchRequest' + responses: + '200': + description: The updated record, as stored after the update. + content: + application/json: + schema: + $ref: '#/components/schemas/RecordResponse' + examples: + updatedInvoice: + $ref: '#/components/examples/InvoiceRecordResponse' + '400': + description: An unknown column name in the request body, or a database constraint error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidColumn: + value: + error: Invalid column name + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + operationId: deleteRecord + summary: Delete a record by ID + description: Deletes the row by primary key. No response body is returned on success. + tags: [Records] + responses: + '204': + description: Record deleted successfully. No response body. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: sk_live_... + description: >- + API key issued from the app's API Keys settings page, sent as + `Authorization: Bearer `. Keys are prefixed `sk_live_`. + parameters: + resource: + name: resource + in: path + required: true + description: >- + The table/resource to operate on. This is a fixed enum — any value outside this + list returns `400 { "error": "Invalid resource", "available_resources": [...] }`. + schema: + type: string + enum: + - clients + - jobs + - invoices + - payments + - assets + - issues + - vendors + - items + - expenses + - timesheets + - bank-accounts + - bank-transactions + - profiles + - kb-articles + - kb-attachments + - kb-article-issues + - locations + - location-contacts + - bill-import-sessions + example: clients + id: + name: id + in: path + required: true + description: Primary key (`id` column) of the record. + schema: + type: string + example: 3fa2b41e-9c2a-4b3a-8a1a-2b6e9b6a1a11 + schemas: + Error: + type: object + required: [error] + properties: + error: + type: string + description: Human-readable error message. + additionalProperties: false + InvalidResourceError: + type: object + required: [error, available_resources] + properties: + error: + type: string + example: Invalid resource + available_resources: + type: array + description: The full list of valid values for the `resource` path parameter. + items: + type: string + additionalProperties: false + Record: + type: object + description: >- + Free-form column/value pairs matching the real columns of the underlying table. + Field names and types vary per resource; see `server/db/schema.sql` in the + Client Flow repository for each table's authoritative column list. + additionalProperties: true + RecordListResponse: + type: object + required: [data] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Record' + RecordResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/Record' + NullableRecordResponse: + type: object + required: [data] + properties: + data: + description: The matching record, or `null` if no record with the given ID exists. + oneOf: + - $ref: '#/components/schemas/Record' + - type: 'null' + responses: + Unauthorized: + description: >- + Missing, malformed, unknown, expired, or revoked API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidKey: + summary: Invalid or revoked key + value: + error: Invalid or revoked API key + expiredKey: + summary: Expired key + value: + error: API key has expired + missingHeader: + summary: Missing Authorization header + value: + error: Missing or invalid Authorization header + Forbidden: + description: >- + The API key's scopes do not include this resource, or the key's owning user lacks + read/write permission on the resource's permission group. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + scopeDenied: + summary: Key scope does not cover this resource + value: + error: API key does not have access to this resource + readDenied: + summary: User lacks read permission + value: + error: 'Permission denied: no read access to this resource' + writeDenied: + summary: User lacks write permission + value: + error: 'Permission denied: no write access to this resource' + NotFound: + description: No record exists with the given ID. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + notFound: + value: + error: Record not found + InvalidResource: + description: >- + The `resource` path segment is not one of the supported resources. + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidResourceError' + examples: + invalidResource: + value: + error: Invalid resource + available_resources: + - clients + - jobs + - invoices + - payments + - assets + - issues + - vendors + - items + - expenses + - timesheets + - bank-accounts + - bank-transactions + - profiles + - kb-articles + - kb-attachments + - kb-article-issues + - locations + - location-contacts + - bill-import-sessions + examples: + ClientCreateRequest: + summary: Create a client + value: + name: Acme Corp + contact_email: billing@acme.com + contact_phone: '+1-555-0100' + is_active: 1 + ClientUpdateRequest: + summary: Update a client + value: + contact_name: Jane Smith + payment_terms: 14 + ClientRecordResponse: + summary: A single client record + value: + data: &ClientRecordValue + id: 3fa2b41e-9c2a-4b3a-8a1a-2b6e9b6a1a11 + name: Acme Corp + trading_name: Acme + abn: null + acn: null + contact_name: Jane Smith + contact_email: billing@acme.com + contact_phone: '+1-555-0100' + billing_address: '123 Main St, Springfield' + payment_terms: 30 + notes: null + default_billable_time: 1 + default_billable_expenses: 1 + location_id: null + is_active: 1 + user_id: null + created_at: '2026-01-15T09:00:00.000Z' + updated_at: '2026-01-15T09:00:00.000Z' + ClientListResponse: + summary: A page of clients + value: + data: + - *ClientRecordValue + ClientNotFoundResponse: + summary: No client with that ID + value: + data: null + InvoiceCreateRequest: + summary: Create an invoice + value: + client_id: 3fa2b41e-9c2a-4b3a-8a1a-2b6e9b6a1a11 + due_date: '2026-02-08' + status: draft + subtotal: 1000 + tax_total: 100 + total: 1100 + InvoicePatchRequest: + summary: Mark an invoice as sent + value: + status: sent + InvoiceRecordResponse: + summary: A single invoice record + value: + data: &InvoiceRecordValue + id: b1f6a2b0-1e2d-4a3b-9c4d-5e6f7a8b9c0d + invoice_number: INV-00001 + client_id: 3fa2b41e-9c2a-4b3a-8a1a-2b6e9b6a1a11 + job_id: null + issue_date: '2026-01-25' + due_date: '2026-02-08' + status: draft + subtotal: 1000 + tax_total: 100 + total: 1100 + amount_paid: 0 + notes: null + terms: null + created_by: null + created_at: '2026-01-25T10:00:00.000Z' + updated_at: '2026-01-25T10:00:00.000Z' + InvoiceListResponse: + summary: A page of invoices + value: + data: + - *InvoiceRecordValue diff --git a/package-lock.json b/package-lock.json index b94647f..48e0ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "vite_react_shadcn_ts", - "version": "0.0.0", + "name": "client-flow", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vite_react_shadcn_ts", - "version": "0.0.0", + "name": "client-flow", + "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.11", @@ -36,7 +36,6 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", - "@supabase/supabase-js": "^2.89.0", "@tanstack/react-query": "^5.83.0", "@types/nodemailer": "^7.0.5", "bcryptjs": "^3.0.3", @@ -56,6 +55,7 @@ "multer": "^2.0.2", "next-themes": "^0.3.0", "nodemailer": "^7.0.12", + "pdfkit": "^0.19.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -80,6 +80,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.0.0", "@types/node": "^22.16.5", + "@types/pdfkit": "^0.17.6", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@types/supertest": "^6.0.3", @@ -1680,11 +1681,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -3922,86 +3934,6 @@ "node": ">=18.0.0" } }, - "node_modules/@supabase/auth-js": { - "version": "2.89.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.89.0.tgz", - "integrity": "sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.89.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.89.0.tgz", - "integrity": "sha512-XEueaC5gMe5NufNYfBh9kPwJlP5M2f+Ogr8rvhmRDAZNHgY6mI35RCkYDijd92pMcNM7g8pUUJov93UGUnqfyw==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.89.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.89.0.tgz", - "integrity": "sha512-/b0fKrxV9i7RNOEXMno/I1862RsYhuUo+Q6m6z3ar1f4ulTMXnDfv0y4YYxK2POcgrOXQOgKYQx1eArybyNvtg==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.89.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.89.0.tgz", - "integrity": "sha512-aMOvfDb2a52u6PX6jrrjvACHXGV3zsOlWRzZsTIOAJa0hOVvRp01AwC1+nLTGUzxzezejrYeCX+KnnM1xHdl+w==", - "license": "MIT", - "dependencies": { - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "tslib": "2.8.1", - "ws": "^8.18.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.89.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.89.0.tgz", - "integrity": "sha512-6zKcXofk/M/4Eato7iqpRh+B+vnxeiTumCIP+Tz26xEqIiywzD9JxHq+udRrDuv6hXE+pmetvJd8n5wcf4MFRQ==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.89.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.89.0.tgz", - "integrity": "sha512-KlaRwSfFA0fD73PYVMHj5/iXFtQGCcX7PSx0FdQwYEEw9b2wqM7GxadY+5YwcmuEhalmjFB/YvqaoNVF+sWUlg==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.89.0", - "@supabase/functions-js": "2.89.0", - "@supabase/postgrest-js": "2.89.0", - "@supabase/realtime-js": "2.89.0", - "@supabase/storage-js": "2.89.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@swc/core": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz", @@ -4218,6 +4150,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@swc/types": { "version": "0.1.23", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz", @@ -4501,11 +4442,15 @@ "@types/node": "*" } }, - "node_modules/@types/phoenix": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", - "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", - "license": "MIT" + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/prop-types": { "version": "15.7.13", @@ -4601,15 +4546,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", @@ -5321,6 +5257,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.25.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", @@ -5657,6 +5611,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6128,6 +6091,12 @@ "wrappy": "1" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -6648,7 +6617,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -6826,6 +6794,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -7192,15 +7177,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -7416,6 +7392,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7535,6 +7517,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8549,6 +8550,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8639,6 +8646,20 @@ "node": ">= 14.16" } }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8675,6 +8696,14 @@ "node": ">= 6" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -9303,6 +9332,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -10031,6 +10066,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -10712,6 +10753,32 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11162,27 +11229,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 9e7e132..229ba44 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "vite_react_shadcn_ts", + "name": "client-flow", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", @@ -12,7 +12,7 @@ "lint": "eslint .", "preview": "vite preview", "server": "tsx server/index.ts", - "db:reset": "rm -f data/app.db && npm run server", + "db:reset": "node -e \"require('fs').rmSync('data/app.db',{force:true})\" && npm run server", "test": "vitest", "test:run": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.server.json" @@ -46,7 +46,6 @@ "@radix-ui/react-toggle": "^1.1.9", "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", - "@supabase/supabase-js": "^2.89.0", "@tanstack/react-query": "^5.83.0", "@types/nodemailer": "^7.0.5", "bcryptjs": "^3.0.3", @@ -66,6 +65,7 @@ "multer": "^2.0.2", "next-themes": "^0.3.0", "nodemailer": "^7.0.12", + "pdfkit": "^0.19.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -90,6 +90,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.0.0", "@types/node": "^22.16.5", + "@types/pdfkit": "^0.17.6", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@types/supertest": "^6.0.3", diff --git a/server/__tests__/backup.test.ts b/server/__tests__/backup.test.ts new file mode 100644 index 0000000..fe3f594 --- /dev/null +++ b/server/__tests__/backup.test.ts @@ -0,0 +1,109 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import Database from 'better-sqlite3'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { createTestApp } from './helpers.js'; +import type { backupDatabase as BackupDatabaseFn } from '../utils/backup.js'; + +let backupDatabase: typeof BackupDatabaseFn; +let backupDir: string; + +beforeAll(async () => { + // createTestApp() sets DATABASE_PATH/UPLOAD_DIR and dynamically imports the + // app so initializeDatabase() runs (and seeds) against a fresh temp DB. + // server/db/database.ts freezes its DB_PATH constant at module-evaluation + // time, so ../utils/backup.js (which imports getDatabase from it) must ALSO + // be imported dynamically, after createTestApp() and after BACKUP_DIR is set + // - a static top-level import here would bind to the wrong database/dir. + await createTestApp(); + + backupDir = path.join( + os.tmpdir(), + `cff-backup-test-${process.pid}-${crypto.randomBytes(6).toString('hex')}`, + ); + process.env.BACKUP_DIR = backupDir; + + ({ backupDatabase } = await import('../utils/backup.js')); +}); + +describe('backupDatabase()', () => { + it('writes a valid, queryable SQLite copy containing seeded data', async () => { + const backupPath = await backupDatabase(); + + expect(backupPath).toBeTruthy(); + expect(fs.existsSync(backupPath as string)).toBe(true); + expect(path.dirname(backupPath as string)).toBe(backupDir); + expect(path.basename(backupPath as string)).toMatch(/^app-\d{8}-\d{6}\.db$/); + + // Open the backup independently and confirm it's a real, readable SQLite + // database with the seeded data copied over. + const copy = new Database(backupPath as string, { readonly: true }); + try { + const profileCount = copy.prepare('SELECT COUNT(*) AS count FROM profiles').get() as { count: number }; + expect(typeof profileCount.count).toBe('number'); + expect(profileCount.count).toBeGreaterThan(0); + + const settings = copy.prepare('SELECT name FROM company_settings LIMIT 1').get() as + | { name: string } + | undefined; + expect(settings).toBeTruthy(); + } finally { + copy.close(); + } + }); + + it('prunes old backups so only BACKUP_RETENTION newest app-*.db files remain', async () => { + // Seed a handful of fake, older backup files with distinct, sortable + // timestamps so pruning behavior doesn't depend on real-time timestamp + // granularity (the real filenames are only second-precision). + const fakeTimestamps = [ + 'app-20200101-000000.db', + 'app-20200102-000000.db', + 'app-20200103-000000.db', + 'app-20200104-000000.db', + ]; + for (const name of fakeTimestamps) { + fs.writeFileSync(path.join(backupDir, name), 'not a real sqlite file, just for pruning test'); + } + + process.env.BACKUP_RETENTION = '2'; + try { + const backupPath = await backupDatabase(); + expect(backupPath).toBeTruthy(); + + const remaining = fs + .readdirSync(backupDir) + .filter(name => name.startsWith('app-') && name.endsWith('.db')) + .sort(); + + expect(remaining).toHaveLength(2); + // The newest real backup (created just now) must survive, along with + // the next-newest fake one; the older fakes must have been deleted. + expect(remaining).toContain(path.basename(backupPath as string)); + expect(remaining).toContain('app-20200104-000000.db'); + expect(remaining).not.toContain('app-20200101-000000.db'); + expect(remaining).not.toContain('app-20200102-000000.db'); + expect(remaining).not.toContain('app-20200103-000000.db'); + } finally { + delete process.env.BACKUP_RETENTION; + } + }); + + it('returns null and skips writing when BACKUP_INTERVAL_HOURS=0 (disabled)', async () => { + process.env.BACKUP_INTERVAL_HOURS = '0'; + try { + const before = fs.readdirSync(backupDir).length; + const result = await backupDatabase(); + expect(result).toBeNull(); + const after = fs.readdirSync(backupDir).length; + expect(after).toBe(before); + } finally { + delete process.env.BACKUP_INTERVAL_HOURS; + } + }); +}); diff --git a/server/__tests__/contact-sync.test.ts b/server/__tests__/contact-sync.test.ts new file mode 100644 index 0000000..b1340ee --- /dev/null +++ b/server/__tests__/contact-sync.test.ts @@ -0,0 +1,222 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let token: string; + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); +}); + +function auth(req: request.Test) { + return req.set('Authorization', `Bearer ${token}`); +} + +describe('clients/vendors contact_* columns stay in sync with the primary contact_affiliations row', () => { + it('sets client.contact_name/contact_email/contact_phone when a primary affiliation is created', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ + name: 'Priya Nair', + email: 'priya@example.com', + phone: '555-1000', + }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Sync Test Client A' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const affRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientId, + is_primary: true, + start_date: '2026-01-01', + }); + expect(affRes.status).toBe(201); + + const client = await auth(request(app).get(`/api/clients/${clientId}`)); + expect(client.status).toBe(200); + expect(client.body.contact_name).toBe('Priya Nair'); + expect(client.body.contact_email).toBe('priya@example.com'); + expect(client.body.contact_phone).toBe('555-1000'); + }); + + it('clears the columns back to NULL when the primary affiliation is ended, with no other current primary', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ name: 'Ending Test Person' }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Sync Test Client B' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const affRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientId, + is_primary: true, + start_date: '2026-01-01', + }); + expect(affRes.status).toBe(201); + const affId = affRes.body.id; + + let client = await auth(request(app).get(`/api/clients/${clientId}`)); + expect(client.body.contact_name).toBe('Ending Test Person'); + + const endRes = await auth(request(app).patch(`/api/contact_affiliations/${affId}`)).send({ + end_date: '2026-07-01', + }); + expect(endRes.status).toBe(200); + + client = await auth(request(app).get(`/api/clients/${clientId}`)); + expect(client.body.contact_name).toBeNull(); + expect(client.body.contact_email).toBeNull(); + expect(client.body.contact_phone).toBeNull(); + }); + + it('propagates an edit to the primary contact person onto the client', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ + name: 'Editable Person', + email: 'before@example.com', + }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Sync Test Client C' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const affRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientId, + is_primary: true, + start_date: '2026-01-01', + }); + expect(affRes.status).toBe(201); + + let client = await auth(request(app).get(`/api/clients/${clientId}`)); + expect(client.body.contact_email).toBe('before@example.com'); + + const patchRes = await auth(request(app).patch(`/api/contacts/${contactId}`)).send({ + email: 'after@example.com', + }); + expect(patchRes.status).toBe(200); + + client = await auth(request(app).get(`/api/clients/${clientId}`)); + expect(client.body.contact_email).toBe('after@example.com'); + // name/phone unchanged by the email-only edit but still sourced correctly. + expect(client.body.contact_name).toBe('Editable Person'); + }); + + it('vendor equivalent: sets vendor.contact_* when a primary affiliation is created', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ + name: 'Vendor Contact Person', + email: 'vendor.person@example.com', + phone: '555-2000', + }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const vendorRes = await auth(request(app).post('/api/vendors')).send({ name: 'Sync Test Vendor A' }); + expect(vendorRes.status).toBe(201); + const vendorId = vendorRes.body.id; + + const affRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + vendor_id: vendorId, + is_primary: true, + start_date: '2026-01-01', + }); + expect(affRes.status).toBe(201); + + const vendor = await auth(request(app).get(`/api/vendors/${vendorId}`)); + expect(vendor.status).toBe(200); + expect(vendor.body.contact_name).toBe('Vendor Contact Person'); + expect(vendor.body.contact_email).toBe('vendor.person@example.com'); + expect(vendor.body.contact_phone).toBe('555-2000'); + }); + + it('overlapping affiliations (same person, primary at two clients at once) and a return-after-end both stay correct with no exclusivity', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ + name: 'Contractor Person', + email: 'contractor@example.com', + }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const clientDRes = await auth(request(app).post('/api/clients')).send({ name: 'Sync Test Client D' }); + const clientERes = await auth(request(app).post('/api/clients')).send({ name: 'Sync Test Client E' }); + expect(clientDRes.status).toBe(201); + expect(clientERes.status).toBe(201); + const clientDId = clientDRes.body.id; + const clientEId = clientERes.body.id; + + // Primary at client D... + const affDRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientDId, + is_primary: true, + start_date: '2025-01-01', + }); + expect(affDRes.status).toBe(201); + + // ...and primary at client E too, at the same time (contractor scenario). + // Adding this must NOT end or clear the client D affiliation/primary flag. + const affERes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientEId, + is_primary: true, + start_date: '2025-06-01', + }); + expect(affERes.status).toBe(201); + + let clientD = await auth(request(app).get(`/api/clients/${clientDId}`)); + let clientE = await auth(request(app).get(`/api/clients/${clientEId}`)); + expect(clientD.body.contact_name).toBe('Contractor Person'); + expect(clientE.body.contact_name).toBe('Contractor Person'); + + const affDCheck = await auth(request(app).get(`/api/contact_affiliations/${affDRes.body.id}`)); + expect(affDCheck.body.is_primary).toBe(1); + expect(affDCheck.body.end_date).toBeNull(); + + // Now the person leaves client D, then returns later - recorded as + // ending the old affiliation and starting a fresh current one (rather + // than mutating dates on the ended row), matching how the UI's "Edit" + // dialog / re-affiliation flow works. + const endDRes = await auth(request(app).patch(`/api/contact_affiliations/${affDRes.body.id}`)).send({ + end_date: '2025-12-31', + }); + expect(endDRes.status).toBe(200); + + clientD = await auth(request(app).get(`/api/clients/${clientDId}`)); + expect(clientD.body.contact_name).toBeNull(); + // Client E's primary is untouched by ending the (different) client D affiliation. + clientE = await auth(request(app).get(`/api/clients/${clientEId}`)); + expect(clientE.body.contact_name).toBe('Contractor Person'); + + const returnRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientDId, + is_primary: true, + start_date: '2026-03-01', + }); + expect(returnRes.status).toBe(201); + + clientD = await auth(request(app).get(`/api/clients/${clientDId}`)); + expect(clientD.body.contact_name).toBe('Contractor Person'); + + // Both the old (ended) and new (current) client D affiliations for this + // person now exist side by side - no exclusivity/overwriting of history. + const listRes = await auth(request(app).get('/api/contact_affiliations')).query({ + 'contact_id.eq': contactId, + 'client_id.eq': clientDId, + }); + expect(listRes.status).toBe(200); + expect(listRes.body.length).toBe(2); + }); +}); diff --git a/server/__tests__/contacts-migration.test.ts b/server/__tests__/contacts-migration.test.ts new file mode 100644 index 0000000..fa136ea --- /dev/null +++ b/server/__tests__/contacts-migration.test.ts @@ -0,0 +1,331 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import Database from 'better-sqlite3'; +import { fileURLToPath } from 'url'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { initializeDatabase as InitializeDatabaseFn } from '../db/database.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let dbPath: string; +let readDb: InstanceType; +let initializeDatabase: typeof InitializeDatabaseFn; + +/** + * This file (unlike contacts.test.ts) does NOT use helpers.createTestApp(). + * The backfill migrations in server/db/database.ts only do anything the + * *first* time they see rows in the legacy `client_contacts` / + * `vendor_contacts` tables - so to exercise them we must write old-style + * legacy rows directly to the SQLite file BEFORE the app's + * `initializeDatabase()` ever runs against it. createTestApp() picks its own + * temp path and imports the app immediately, which would run the migrations + * against an empty database before we got a chance to seed anything. + * + * BOTH legacy tables are seeded here on purpose: the client and vendor + * migrations run back-to-back on the same boot, so this file also proves + * they coexist (the vendor migration must not be suppressed by `contacts` + * already holding the migrated client people, and vice versa). + * + * Instead we: pick our own temp DB path, apply schema.sql ourselves via a + * throwaway connection, hand-insert a legacy client + client_contacts row, + * close that connection, and only then dynamically import ../index.js so + * its real initializeDatabase() (schema + migrations + seed) runs against + * the pre-seeded file for real. + * + * vitest.config.ts has `isolate: true`, so this file gets its own module + * registry - the dynamic import of ../index.js here is a genuinely fresh + * module evaluation, not a cache hit from another test file. + */ +beforeAll(async () => { + const tempDir = path.join( + os.tmpdir(), + `cff-contacts-migration-test-${process.pid}-${crypto.randomBytes(6).toString('hex')}`, + ); + fs.mkdirSync(tempDir, { recursive: true }); + + dbPath = path.join(tempDir, 'test.db'); + process.env.DATABASE_PATH = dbPath; + process.env.UPLOAD_DIR = path.join(tempDir, 'uploads'); + + const schemaPath = path.join(__dirname, '..', 'db', 'schema.sql'); + const schema = fs.readFileSync(schemaPath, 'utf-8'); + + const seedDb = new Database(dbPath); + seedDb.pragma('foreign_keys = ON'); + seedDb.exec(schema); + + seedDb.prepare(`INSERT INTO clients (id, name) VALUES (?, ?)`).run( + 'legacy-client-1', + 'Legacy Client Pty Ltd', + ); + + // An "active" legacy contact - should end up with a NULL end_date. + seedDb + .prepare( + `INSERT INTO client_contacts + (id, client_id, name, title, email, phone, notes, is_primary, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'legacy-contact-sarah', + 'legacy-client-1', + 'Sarah Chen', + 'Office Manager', + 'sarah@legacy.example', + '555-0100', + 'Prefers email', + 1, + 1, + '2025-01-15 09:00:00', + '2025-06-01 10:00:00', + ); + + // An "inactive" legacy contact - should end up with end_date = date part of updated_at. + seedDb + .prepare( + `INSERT INTO client_contacts + (id, client_id, name, title, email, phone, notes, is_primary, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'legacy-contact-dana', + 'legacy-client-1', + 'Dana Ortiz', + 'Former Contact', + null, + null, + null, + 0, + 0, + '2024-03-10 12:00:00', + '2024-11-20 08:30:00', + ); + + seedDb.prepare(`INSERT INTO vendors (id, name) VALUES (?, ?)`).run( + 'legacy-vendor-1', + 'Legacy Vendor Supplies', + ); + + // An "active" legacy vendor contact - should end up with a NULL end_date. + seedDb + .prepare( + `INSERT INTO vendor_contacts + (id, vendor_id, name, title, email, phone, notes, is_primary, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'legacy-vcontact-marco', + 'legacy-vendor-1', + 'Marco Reyes', + 'Account Manager', + 'marco@vendor.example', + '555-0200', + 'Call after 10am', + 1, + 1, + '2025-02-01 09:30:00', + '2025-05-15 14:00:00', + ); + + // An "inactive" legacy vendor contact - should end up with end_date = date part of updated_at. + seedDb + .prepare( + `INSERT INTO vendor_contacts + (id, vendor_id, name, title, email, phone, notes, is_primary, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'legacy-vcontact-lena', + 'legacy-vendor-1', + 'Lena Wu', + 'Former Rep', + null, + null, + null, + 0, + 0, + '2024-04-05 11:00:00', + '2024-12-01 16:45:00', + ); + + seedDb.close(); + + // Trigger the real migration path by importing the app fresh against the + // pre-seeded DB file. + await import('../index.js'); + ({ initializeDatabase } = await import('../db/database.js')); + + readDb = new Database(dbPath, { readonly: true }); +}); + +afterAll(() => { + readDb?.close(); +}); + +describe('contacts backfill migration (client_contacts -> contacts + contact_affiliations)', () => { + it('converts a legacy active client_contacts row into a contact, reusing the same id', () => { + const contact = readDb.prepare('SELECT * FROM contacts WHERE id = ?').get('legacy-contact-sarah') as any; + expect(contact).toBeTruthy(); + expect(contact.name).toBe('Sarah Chen'); + expect(contact.email).toBe('sarah@legacy.example'); + expect(contact.phone).toBe('555-0100'); + expect(contact.notes).toBe('Prefers email'); + expect(contact.is_active).toBe(1); + expect(contact.created_at).toBe('2025-01-15 09:00:00'); + expect(contact.updated_at).toBe('2025-06-01 10:00:00'); + }); + + it('creates a current (end_date NULL) affiliation to the client for the active legacy contact', () => { + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE contact_id = ?') + .get('legacy-contact-sarah') as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.client_id).toBe('legacy-client-1'); + expect(affiliation.vendor_id).toBeNull(); + expect(affiliation.title).toBe('Office Manager'); + expect(affiliation.is_primary).toBe(1); + expect(affiliation.start_date).toBe('2025-01-15'); + expect(affiliation.end_date).toBeNull(); + }); + + it('creates an ended affiliation (end_date = date part of updated_at) for the inactive legacy contact', () => { + const contact = readDb.prepare('SELECT * FROM contacts WHERE id = ?').get('legacy-contact-dana') as any; + expect(contact).toBeTruthy(); + expect(contact.is_active).toBe(0); + + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE contact_id = ?') + .get('legacy-contact-dana') as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.client_id).toBe('legacy-client-1'); + expect(affiliation.start_date).toBe('2024-03-10'); + expect(affiliation.end_date).toBe('2024-11-20'); + }); + + it('leaves the original client_contacts rows untouched as a frozen archive', () => { + const rows = readDb.prepare('SELECT * FROM client_contacts ORDER BY id').all() as any[]; + expect(rows.length).toBe(2); + expect(rows.find(r => r.id === 'legacy-contact-sarah')).toBeTruthy(); + expect(rows.find(r => r.id === 'legacy-contact-dana')).toBeTruthy(); + }); + + it('is idempotent: re-running initializeDatabase() does not duplicate contacts or affiliations', () => { + initializeDatabase(); + + // 2 client people + 2 vendor people (seeded in beforeAll) = 4 total. + const contactCount = readDb.prepare('SELECT COUNT(*) as count FROM contacts').get() as { count: number }; + expect(contactCount.count).toBe(4); + + const affiliationCount = readDb + .prepare('SELECT COUNT(*) as count FROM contact_affiliations') + .get() as { count: number }; + expect(affiliationCount.count).toBe(4); + }); + + // The "fresh DB, zero client_contacts rows" no-op case is already + // exercised for real by every other test file: each one calls + // helpers.createTestApp() against a brand-new temp DB with no + // client_contacts rows, and initializeDatabase() (including this + // migration) runs as part of that boot. If the empty-legacy-table guard + // didn't hold, or logged/threw on an empty table, those suites would fail + // or emit a spurious log line. A duplicate check here would only be + // re-running the exact same code path against the exact same shape of + // database, so it's intentionally not repeated in this file - see + // contacts.test.ts's beforeAll for the real instance of this case. + // The same reasoning covers the vendor_contacts no-op case below. +}); + +describe('contacts backfill migration (vendor_contacts -> contacts + contact_affiliations)', () => { + it('converts a legacy active vendor_contacts row into a contact, reusing the same id', () => { + const contact = readDb.prepare('SELECT * FROM contacts WHERE id = ?').get('legacy-vcontact-marco') as any; + expect(contact).toBeTruthy(); + expect(contact.name).toBe('Marco Reyes'); + expect(contact.email).toBe('marco@vendor.example'); + expect(contact.phone).toBe('555-0200'); + expect(contact.notes).toBe('Call after 10am'); + expect(contact.is_active).toBe(1); + expect(contact.created_at).toBe('2025-02-01 09:30:00'); + expect(contact.updated_at).toBe('2025-05-15 14:00:00'); + }); + + it('creates a current (end_date NULL) affiliation to the vendor for the active legacy contact', () => { + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE contact_id = ?') + .get('legacy-vcontact-marco') as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.vendor_id).toBe('legacy-vendor-1'); + expect(affiliation.client_id).toBeNull(); + expect(affiliation.title).toBe('Account Manager'); + expect(affiliation.is_primary).toBe(1); + expect(affiliation.start_date).toBe('2025-02-01'); + expect(affiliation.end_date).toBeNull(); + }); + + it('creates an ended affiliation (end_date = date part of updated_at) for the inactive legacy contact', () => { + const contact = readDb.prepare('SELECT * FROM contacts WHERE id = ?').get('legacy-vcontact-lena') as any; + expect(contact).toBeTruthy(); + expect(contact.is_active).toBe(0); + + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE contact_id = ?') + .get('legacy-vcontact-lena') as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.vendor_id).toBe('legacy-vendor-1'); + expect(affiliation.client_id).toBeNull(); + expect(affiliation.start_date).toBe('2024-04-05'); + expect(affiliation.end_date).toBe('2024-12-01'); + }); + + it('leaves the original vendor_contacts rows untouched as a frozen archive', () => { + const rows = readDb.prepare('SELECT * FROM vendor_contacts ORDER BY id').all() as any[]; + expect(rows.length).toBe(2); + expect(rows.find(r => r.id === 'legacy-vcontact-marco')).toBeTruthy(); + expect(rows.find(r => r.id === 'legacy-vcontact-lena')).toBeTruthy(); + }); + + it('is idempotent: re-running initializeDatabase() does not re-migrate vendor_contacts', () => { + initializeDatabase(); + + const vendorAffiliations = readDb + .prepare('SELECT COUNT(*) as count FROM contact_affiliations WHERE vendor_id IS NOT NULL') + .get() as { count: number }; + expect(vendorAffiliations.count).toBe(2); + + const contactCount = readDb.prepare('SELECT COUNT(*) as count FROM contacts').get() as { count: number }; + expect(contactCount.count).toBe(4); + }); +}); + +describe('client and vendor legacy migrations coexist on the same boot', () => { + it('merges both legacy tables into one contacts table with correctly-targeted affiliations', () => { + // All four legacy people made it into contacts, ids preserved. + const ids = (readDb.prepare('SELECT id FROM contacts ORDER BY id').all() as any[]).map(r => r.id); + expect(ids).toEqual([ + 'legacy-contact-dana', + 'legacy-contact-sarah', + 'legacy-vcontact-lena', + 'legacy-vcontact-marco', + ]); + + // Exactly 2 client-targeted and 2 vendor-targeted affiliations, and every + // affiliation points at exactly one of client/vendor (the CHECK invariant). + const branches = readDb + .prepare( + `SELECT + SUM(CASE WHEN client_id IS NOT NULL THEN 1 ELSE 0 END) as clients, + SUM(CASE WHEN vendor_id IS NOT NULL THEN 1 ELSE 0 END) as vendors, + SUM(CASE WHEN (client_id IS NULL) = (vendor_id IS NULL) THEN 1 ELSE 0 END) as invalid + FROM contact_affiliations`, + ) + .get() as { clients: number; vendors: number; invalid: number }; + expect(branches.clients).toBe(2); + expect(branches.vendors).toBe(2); + expect(branches.invalid).toBe(0); + }); +}); diff --git a/server/__tests__/contacts.test.ts b/server/__tests__/contacts.test.ts new file mode 100644 index 0000000..7cf0e43 --- /dev/null +++ b/server/__tests__/contacts.test.ts @@ -0,0 +1,153 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let token: string; + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); +}); + +function auth(req: request.Test) { + return req.set('Authorization', `Bearer ${token}`); +} + +describe('contacts + contact_affiliations (person-centric contacts)', () => { + it('creates a contact, affiliates it with a client, and nests the affiliation with the client name', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ + name: 'Jamie Rivera', + email: 'jamie@example.com', + }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Contact Test Client' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const affiliationRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientId, + title: 'Procurement Lead', + is_primary: true, + start_date: '2026-01-01', + }); + expect(affiliationRes.status).toBe(201); + + const listRes = await auth(request(app).get('/api/contacts')).query({ + select: '*, contact_affiliations(*, clients(name), vendors(name))', + }); + expect(listRes.status).toBe(200); + + const found = listRes.body.find((c: any) => c.id === contactId); + expect(found).toBeTruthy(); + expect(Array.isArray(found.contact_affiliations)).toBe(true); + expect(found.contact_affiliations.length).toBe(1); + + const affiliation = found.contact_affiliations[0]; + expect(affiliation.title).toBe('Procurement Lead'); + expect(affiliation.client_id).toBe(clientId); + expect(affiliation.clients).toBeTruthy(); + expect(affiliation.clients.name).toBe('Contact Test Client'); + // The affiliation is client-side only, so the vendors belongsTo must + // resolve to null rather than throwing or being omitted. + expect(affiliation.vendors).toBeNull(); + }); + + it('lets a person change company: ending the client affiliation and starting a vendor one, both visible on the contact', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ name: 'Morgan Lee' }); + expect(contactRes.status).toBe(201); + const contactId = contactRes.body.id; + + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Old Employer Pty Ltd' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const vendorRes = await auth(request(app).post('/api/vendors')).send({ name: 'New Vendor Co' }); + expect(vendorRes.status).toBe(201); + const vendorId = vendorRes.body.id; + + const clientAffRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + client_id: clientId, + title: 'Account Manager', + is_primary: true, + start_date: '2025-01-01', + }); + expect(clientAffRes.status).toBe(201); + const clientAffId = clientAffRes.body.id; + + // Person changes company: end the old affiliation... + const patchRes = await auth(request(app).patch(`/api/contact_affiliations/${clientAffId}`)).send({ + end_date: '2026-06-30', + }); + expect(patchRes.status).toBe(200); + expect(patchRes.body.end_date).toBe('2026-06-30'); + + // ...and start a new, current one at the vendor. + const vendorAffRes = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactId, + vendor_id: vendorId, + title: 'Consultant', + is_primary: true, + start_date: '2026-07-01', + }); + expect(vendorAffRes.status).toBe(201); + + const listRes = await auth(request(app).get('/api/contacts')).query({ + select: '*, contact_affiliations(*, clients(name), vendors(name))', + }); + expect(listRes.status).toBe(200); + + const found = listRes.body.find((c: any) => c.id === contactId); + expect(found).toBeTruthy(); + expect(found.contact_affiliations.length).toBe(2); + + const clientAff = found.contact_affiliations.find((a: any) => a.id === clientAffId); + const vendorAff = found.contact_affiliations.find((a: any) => a.id === vendorAffRes.body.id); + + expect(clientAff.end_date).toBe('2026-06-30'); + expect(clientAff.clients.name).toBe('Old Employer Pty Ltd'); + + expect(vendorAff.end_date).toBeNull(); + expect(vendorAff.vendors.name).toBe('New Vendor Co'); + }); + + it('rejects a contact_affiliations row with both client_id and vendor_id set (CHECK constraint)', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ name: 'Check Constraint Person A' }); + expect(contactRes.status).toBe(201); + + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Check Constraint Client' }); + expect(clientRes.status).toBe(201); + + const vendorRes = await auth(request(app).post('/api/vendors')).send({ name: 'Check Constraint Vendor' }); + expect(vendorRes.status).toBe(201); + + const res = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactRes.body.id, + client_id: clientRes.body.id, + vendor_id: vendorRes.body.id, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.body.error).toMatch(/CHECK constraint failed/i); + }); + + it('rejects a contact_affiliations row with neither client_id nor vendor_id set (CHECK constraint)', async () => { + const contactRes = await auth(request(app).post('/api/contacts')).send({ name: 'Check Constraint Person B' }); + expect(contactRes.status).toBe(201); + + const res = await auth(request(app).post('/api/contact_affiliations')).send({ + contact_id: contactRes.body.id, + }); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.body.error).toMatch(/CHECK constraint failed/i); + }); +}); diff --git a/server/__tests__/invoice-pdf.test.ts b/server/__tests__/invoice-pdf.test.ts new file mode 100644 index 0000000..8f0374c --- /dev/null +++ b/server/__tests__/invoice-pdf.test.ts @@ -0,0 +1,166 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let token: string; +let clientId: string; +let invoiceAId: string; +let invoiceBId: string; + +const INVOICE_A_NUMBER = 'PDF-TEST-0001'; +const INVOICE_B_NUMBER = 'PDF-TEST-0002'; + +function auth(req: request.Test) { + return req.set('Authorization', `Bearer ${token}`); +} + +/** Buffer the raw (binary) response body so PDF bytes can be asserted on. */ +function binary(req: request.Test) { + return req.buffer(true).parse((res, callback) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks))); + }); +} + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); + + const clientRes = await auth(request(app).post('/api/clients')).send({ + name: 'PDF Test Client', + billing_address: '1 Test Street\nTestville QLD 4000', + }); + expect(clientRes.status).toBe(201); + clientId = clientRes.body.id; + + const invoiceARes = await auth(request(app).post('/api/invoices')).send({ + invoice_number: INVOICE_A_NUMBER, + client_id: clientId, + issue_date: '2026-07-01', + due_date: '2026-08-01', + subtotal: 300, + tax_total: 30, + total: 330, + notes: 'Thanks for your business', + }); + expect(invoiceARes.status).toBe(201); + invoiceAId = invoiceARes.body.id; + + const linesRes = await auth(request(app).post('/api/invoice_lines')).send([ + { + invoice_id: invoiceAId, + description: 'Consulting services', + quantity: 2, + unit_price: 100, + tax_rate: 10, + line_total: 220, + sort_order: 0, + }, + { + invoice_id: invoiceAId, + description: 'On-site support', + quantity: 1, + unit_price: 100, + tax_rate: 10, + line_total: 110, + sort_order: 1, + }, + ]); + expect(linesRes.status).toBe(201); + + const invoiceBRes = await auth(request(app).post('/api/invoices')).send({ + invoice_number: INVOICE_B_NUMBER, + client_id: clientId, + issue_date: '2026-07-05', + due_date: '2026-08-05', + subtotal: 50, + tax_total: 5, + total: 55, + }); + expect(invoiceBRes.status).toBe(201); + invoiceBId = invoiceBRes.body.id; + + const lineBRes = await auth(request(app).post('/api/invoice_lines')).send({ + invoice_id: invoiceBId, + description: 'Small job', + quantity: 1, + unit_price: 50, + tax_rate: 10, + line_total: 55, + sort_order: 0, + }); + expect(lineBRes.status).toBe(201); +}); + +describe('POST /api/functions/generate-invoice-pdf', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .post('/api/functions/generate-invoice-pdf') + .send({ invoiceId: invoiceAId }); + expect(res.status).toBe(401); + }); + + it('returns 404 for an unknown invoiceId', async () => { + const res = await auth(request(app).post('/api/functions/generate-invoice-pdf')).send({ + invoiceId: 'not-a-real-invoice-id', + }); + expect(res.status).toBe(404); + }); + + it('returns a real PDF for a valid invoice, named after the invoice number', async () => { + const res = await binary( + auth(request(app).post('/api/functions/generate-invoice-pdf')).send({ invoiceId: invoiceAId }) + ); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/pdf'); + expect(res.headers['content-disposition']).toContain(INVOICE_A_NUMBER); + + const body = res.body as Buffer; + expect(Buffer.isBuffer(body)).toBe(true); + expect(body.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + }); +}); + +describe('POST /api/functions/generate-invoices-pdf', () => { + it('returns one combined PDF for multiple invoices, larger than a single-invoice PDF', async () => { + const singleRes = await binary( + auth(request(app).post('/api/functions/generate-invoice-pdf')).send({ invoiceId: invoiceAId }) + ); + expect(singleRes.status).toBe(200); + const singleBody = singleRes.body as Buffer; + + const res = await binary( + auth(request(app).post('/api/functions/generate-invoices-pdf')).send({ + invoiceIds: [invoiceAId, invoiceBId], + }) + ); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/pdf'); + + const body = res.body as Buffer; + expect(body.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + expect(body.length).toBeGreaterThan(singleBody.length); + }); + + it('returns 404 when none of the ids exist', async () => { + const res = await auth(request(app).post('/api/functions/generate-invoices-pdf')).send({ + invoiceIds: ['nope-1', 'nope-2'], + }); + expect(res.status).toBe(404); + }); + + it('returns 400 when invoiceIds is not an array', async () => { + const res = await auth(request(app).post('/api/functions/generate-invoices-pdf')).send({ + invoiceIds: 'not-an-array', + }); + expect(res.status).toBe(400); + }); +}); diff --git a/server/__tests__/orphan-contact-migration.test.ts b/server/__tests__/orphan-contact-migration.test.ts new file mode 100644 index 0000000..1cb3cba --- /dev/null +++ b/server/__tests__/orphan-contact-migration.test.ts @@ -0,0 +1,169 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import Database from 'better-sqlite3'; +import { fileURLToPath } from 'url'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { initializeDatabase as InitializeDatabaseFn } from '../db/database.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let dbPath: string; +let readDb: InstanceType; +let initializeDatabase: typeof InitializeDatabaseFn; + +/** + * Exercises the "orphaned inline contact info" migration: clients/vendors + * whose free-text contact_name/contact_email/contact_phone columns hold data + * with no corresponding current primary affiliation. Without conversion, the + * contact_* recompute (which mirrors the current primary affiliated contact) + * would NULL that data on first boot. The migration must instead convert it + * into a contacts row + current primary affiliation, reusing an existing + * contact when the normalized name+email already match. + * + * Same setup technique as contacts-migration.test.ts (own temp DB, seed via a + * throwaway connection BEFORE importing ../index.js; vitest isolate:true + * gives this file a fresh module registry). Kept as a separate file so its + * seeds don't disturb that file's exact-count assertions. + */ +beforeAll(async () => { + const tempDir = path.join( + os.tmpdir(), + `cff-orphan-contact-test-${process.pid}-${crypto.randomBytes(6).toString('hex')}`, + ); + fs.mkdirSync(tempDir, { recursive: true }); + + dbPath = path.join(tempDir, 'test.db'); + process.env.DATABASE_PATH = dbPath; + process.env.UPLOAD_DIR = path.join(tempDir, 'uploads'); + + const schemaPath = path.join(__dirname, '..', 'db', 'schema.sql'); + const schema = fs.readFileSync(schemaPath, 'utf-8'); + + const seedDb = new Database(dbPath); + seedDb.pragma('foreign_keys = ON'); + seedDb.exec(schema); + + // A vendor with hand-typed inline contact info and no affiliations. + seedDb + .prepare( + `INSERT INTO vendors (id, name, contact_name, contact_email, contact_phone, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run('orphan-vendor-1', 'Orphan Vendor Co', 'Wes Field', 'wes@orphan.example', '555-0199', '2025-03-01 08:00:00'); + + // A client with hand-typed inline contact info and no affiliations. + seedDb + .prepare( + `INSERT INTO clients (id, name, contact_name, contact_email, contact_phone, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run('orphan-client-1', 'Orphan Client Pty Ltd', 'Ines Rojas', 'ines@orphanclient.example', null, '2025-04-10 09:15:00'); + + // A pre-existing person whose normalized name+email match a client's inline + // info: the migration must REUSE this contact, not create a duplicate. + seedDb + .prepare('INSERT INTO contacts (id, name, email) VALUES (?, ?, ?)') + .run('existing-shared-person', 'Shared Person', 'shared@example.com'); + seedDb + .prepare( + `INSERT INTO clients (id, name, contact_name, contact_email, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run('orphan-client-2', 'Reuse Client Pty Ltd', ' shared person ', 'SHARED@example.com', '2025-05-01 10:00:00'); + + seedDb.close(); + + await import('../index.js'); + ({ initializeDatabase } = await import('../db/database.js')); + + readDb = new Database(dbPath, { readonly: true }); +}); + +afterAll(() => { + readDb?.close(); +}); + +describe('orphaned inline contact info migration', () => { + it('converts a vendor\'s inline contact info into a contact + current primary affiliation', () => { + const contact = readDb + .prepare('SELECT * FROM contacts WHERE name = ?') + .get('Wes Field') as any; + expect(contact).toBeTruthy(); + expect(contact.email).toBe('wes@orphan.example'); + expect(contact.phone).toBe('555-0199'); + + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE contact_id = ?') + .get(contact.id) as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.vendor_id).toBe('orphan-vendor-1'); + expect(affiliation.client_id).toBeNull(); + expect(affiliation.is_primary).toBe(1); + expect(affiliation.end_date).toBeNull(); + expect(affiliation.start_date).toBe('2025-03-01'); + }); + + it('converts a client\'s inline contact info the same way', () => { + const contact = readDb + .prepare('SELECT * FROM contacts WHERE name = ?') + .get('Ines Rojas') as any; + expect(contact).toBeTruthy(); + + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE contact_id = ?') + .get(contact.id) as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.client_id).toBe('orphan-client-1'); + expect(affiliation.is_primary).toBe(1); + expect(affiliation.end_date).toBeNull(); + }); + + it('preserves the inline contact_* columns instead of wiping them (recompute converges)', () => { + const vendor = readDb.prepare('SELECT * FROM vendors WHERE id = ?').get('orphan-vendor-1') as any; + expect(vendor.contact_name).toBe('Wes Field'); + expect(vendor.contact_email).toBe('wes@orphan.example'); + expect(vendor.contact_phone).toBe('555-0199'); + + const client = readDb.prepare('SELECT * FROM clients WHERE id = ?').get('orphan-client-1') as any; + expect(client.contact_name).toBe('Ines Rojas'); + expect(client.contact_email).toBe('ines@orphanclient.example'); + }); + + it('reuses an existing contact when normalized name+email match, instead of duplicating', () => { + const matches = readDb + .prepare('SELECT * FROM contacts WHERE lower(trim(name)) = ?') + .all('shared person') as any[]; + expect(matches.length).toBe(1); + expect(matches[0].id).toBe('existing-shared-person'); + + const affiliation = readDb + .prepare('SELECT * FROM contact_affiliations WHERE client_id = ?') + .get('orphan-client-2') as any; + expect(affiliation).toBeTruthy(); + expect(affiliation.contact_id).toBe('existing-shared-person'); + expect(affiliation.is_primary).toBe(1); + }); + + it('is idempotent: re-running initializeDatabase() creates no duplicates', () => { + const before = readDb.prepare('SELECT COUNT(*) as count FROM contacts').get() as { count: number }; + const beforeAff = readDb + .prepare('SELECT COUNT(*) as count FROM contact_affiliations') + .get() as { count: number }; + + initializeDatabase(); + + const after = readDb.prepare('SELECT COUNT(*) as count FROM contacts').get() as { count: number }; + const afterAff = readDb + .prepare('SELECT COUNT(*) as count FROM contact_affiliations') + .get() as { count: number }; + + expect(after.count).toBe(before.count); + expect(afterAff.count).toBe(beforeAff.count); + }); +}); diff --git a/server/__tests__/relations.test.ts b/server/__tests__/relations.test.ts new file mode 100644 index 0000000..2917f73 --- /dev/null +++ b/server/__tests__/relations.test.ts @@ -0,0 +1,140 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let token: string; + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); +}); + +function auth(req: request.Test) { + return req.set('Authorization', `Bearer ${token}`); +} + +describe('crud relation resolution (select=*, relation(...))', () => { + it('resolves a single-level belongsTo relation unchanged (invoices -> clients)', async () => { + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Relation Client A' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const invoiceRes = await auth(request(app).post('/api/invoices')).send({ + invoice_number: 'REL-TEST-0001', + client_id: clientId, + due_date: '2026-08-01', + }); + expect(invoiceRes.status).toBe(201); + + const listRes = await auth(request(app).get('/api/invoices')).query({ select: '*, clients(name)' }); + expect(listRes.status).toBe(200); + + const found = listRes.body.find((inv: any) => inv.id === invoiceRes.body.id); + expect(found).toBeTruthy(); + expect(found.clients).toBeTruthy(); + expect(found.clients.name).toBe('Relation Client A'); + }); + + it('resolves payments -> invoices -> clients (the headline bug: nested relation two levels deep)', async () => { + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Relation Client B' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const invoiceRes = await auth(request(app).post('/api/invoices')).send({ + invoice_number: 'REL-TEST-0002', + client_id: clientId, + due_date: '2026-08-01', + }); + expect(invoiceRes.status).toBe(201); + const invoiceId = invoiceRes.body.id; + + const paymentRes = await auth(request(app).post('/api/payments')).send({ + invoice_id: invoiceId, + date: '2026-07-15', + amount: 100, + }); + expect(paymentRes.status).toBe(201); + + const listRes = await auth(request(app).get('/api/payments')).query({ + select: '*, invoices(invoice_number, clients(name))', + }); + expect(listRes.status).toBe(200); + + const found = listRes.body.find((p: any) => p.id === paymentRes.body.id); + expect(found).toBeTruthy(); + expect(found.invoices).toBeTruthy(); + expect(found.invoices.invoice_number).toBe('REL-TEST-0002'); + expect(found.invoices.clients).toBeTruthy(); + expect(found.invoices.clients.name).toBe('Relation Client B'); + }); + + it('resolves a hasMany relation with a nested belongsTo (jobs -> invoices -> clients)', async () => { + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Relation Client C' }); + expect(clientRes.status).toBe(201); + const clientId = clientRes.body.id; + + const jobRes = await auth(request(app).post('/api/jobs')).send({ + job_number: 'REL-JOB-0001', + name: 'Relation Job', + client_id: clientId, + }); + expect(jobRes.status).toBe(201); + const jobId = jobRes.body.id; + + const invoiceRes = await auth(request(app).post('/api/invoices')).send({ + invoice_number: 'REL-TEST-0003', + client_id: clientId, + job_id: jobId, + due_date: '2026-08-01', + }); + expect(invoiceRes.status).toBe(201); + + const listRes = await auth(request(app).get('/api/jobs')).query({ + select: '*, invoices(invoice_number, clients(name))', + }); + expect(listRes.status).toBe(200); + + const found = listRes.body.find((j: any) => j.id === jobId); + expect(found).toBeTruthy(); + expect(Array.isArray(found.invoices)).toBe(true); + expect(found.invoices.length).toBe(1); + expect(found.invoices[0].invoice_number).toBe('REL-TEST-0003'); + expect(found.invoices[0].clients).toBeTruthy(); + expect(found.invoices[0].clients.name).toBe('Relation Client C'); + }); + + it('rejects an unknown relation with a 400 and a precise error message instead of silently dropping it', async () => { + const res = await auth(request(app).get('/api/payments')).query({ + select: '*, not_a_real_relation(foo)', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe(`Unknown relation 'not_a_real_relation' for table 'payments'`); + }); + + it('rejects an unknown nested relation the same way', async () => { + const res = await auth(request(app).get('/api/payments')).query({ + select: '*, invoices(invoice_number, not_a_real_relation(foo))', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe(`Unknown relation 'not_a_real_relation' for table 'invoices'`); + }); + + it('also rejects the unknown relation on the single-record GET /:table/:id route', async () => { + const clientRes = await auth(request(app).post('/api/clients')).send({ name: 'Relation Client D' }); + expect(clientRes.status).toBe(201); + + const res = await auth(request(app).get(`/api/clients/${clientRes.body.id}`)).query({ + select: '*, not_a_real_relation(foo)', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe(`Unknown relation 'not_a_real_relation' for table 'clients'`); + }); +}); diff --git a/server/db/database.ts b/server/db/database.ts index 158bcf8..decb479 100644 --- a/server/db/database.ts +++ b/server/db/database.ts @@ -1,7 +1,8 @@ import Database from 'better-sqlite3'; -import { readFileSync } from 'fs'; +import { readFileSync, mkdirSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import crypto from 'crypto'; // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url); @@ -13,6 +14,13 @@ let db: Database.Database | null = null; export function getDatabase(): Database.Database { if (!db) { + // better-sqlite3 won't create the parent directory, and data/ is + // gitignored — so a fresh checkout has no data/ dir. Create it so the + // server boots on a clean clone instead of crashing. + const dbDir = dirname(DB_PATH); + if (dbDir && !existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); @@ -266,6 +274,302 @@ function runMigrations(database: Database.Database): void { } catch (error) { console.error('Migration error (idx_jobs_job_number):', error); } + + // Migration: Backfill person-centric `contacts` / `contact_affiliations` + // from the legacy `client_contacts` table. + // + // client_contacts pinned a person to exactly one client forever. Contacts + // are now independent people (`contacts`) that can be affiliated with a + // client OR a vendor over a period of time (`contact_affiliations`), so a + // person changing companies is a new affiliation row, not a rewritten one. + // + // client_contacts is left untouched as a frozen archive - the app stops + // reading/writing it, but we don't delete historical data. This migration + // only runs once: it's guarded on `contacts` being empty, so re-running it + // (e.g. on every boot) after the first successful conversion is a no-op. + // On a fresh DB, client_contacts has no rows either, so nothing happens + // and nothing is logged. + try { + const { count: contactsCount } = database + .prepare('SELECT COUNT(*) as count FROM contacts') + .get() as { count: number }; + + if (contactsCount === 0) { + const legacyContacts = database + .prepare('SELECT * FROM client_contacts') + .all() as Array<{ + id: string; + client_id: string; + name: string; + title: string | null; + email: string | null; + phone: string | null; + notes: string | null; + is_primary: number | null; + is_active: number | null; + created_at: string; + updated_at: string; + }>; + + if (legacyContacts.length > 0) { + const insertContact = database.prepare(` + INSERT INTO contacts (id, name, email, phone, notes, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertAffiliation = database.prepare(` + INSERT INTO contact_affiliations + (id, contact_id, client_id, title, is_primary, start_date, end_date, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const datePart = (timestamp: string) => timestamp.split(' ')[0].split('T')[0]; + + const migrateLegacyContacts = database.transaction((rows: typeof legacyContacts) => { + for (const row of rows) { + insertContact.run( + row.id, + row.name, + row.email, + row.phone, + row.notes, + row.is_active, + row.created_at, + row.updated_at, + ); + + insertAffiliation.run( + crypto.randomUUID(), + row.id, + row.client_id, + row.title, + row.is_primary, + datePart(row.created_at), + row.is_active ? null : datePart(row.updated_at), + row.notes, + row.created_at, + row.updated_at, + ); + } + }); + + migrateLegacyContacts(legacyContacts); + console.log(`Migration: Converted ${legacyContacts.length} client_contacts row(s) into contacts + contact_affiliations`); + } + } + } catch (error) { + console.error('Migration error (contacts backfill from client_contacts):', error); + } + + // Migration: Backfill `contacts` / `contact_affiliations` from the legacy + // `vendor_contacts` table, mirroring the client_contacts backfill above. + // + // vendor_contacts is left untouched as a frozen archive, same as + // client_contacts. The contacts-empty guard used above can't be reused + // here: by the time this runs, `contacts` already holds the migrated + // client people, so it is never empty on the boot that should migrate + // vendors. Instead, idempotency is keyed on the ids themselves - contact + // ids are reused from vendor_contacts, so if ANY vendor_contacts id + // already exists in contacts this migration has already run and is + // skipped. On a fresh DB, vendor_contacts has no rows and nothing happens. + try { + const { count: vendorContactsCount } = database + .prepare('SELECT COUNT(*) as count FROM vendor_contacts') + .get() as { count: number }; + + if (vendorContactsCount > 0) { + const { count: alreadyMigratedCount } = database + .prepare( + `SELECT COUNT(*) as count FROM vendor_contacts vc + WHERE EXISTS (SELECT 1 FROM contacts c WHERE c.id = vc.id)`, + ) + .get() as { count: number }; + + if (alreadyMigratedCount === 0) { + const legacyVendorContacts = database + .prepare('SELECT * FROM vendor_contacts') + .all() as Array<{ + id: string; + vendor_id: string; + name: string; + title: string | null; + email: string | null; + phone: string | null; + notes: string | null; + is_primary: number | null; + is_active: number | null; + created_at: string; + updated_at: string; + }>; + + const insertContact = database.prepare(` + INSERT INTO contacts (id, name, email, phone, notes, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertAffiliation = database.prepare(` + INSERT INTO contact_affiliations + (id, contact_id, vendor_id, title, is_primary, start_date, end_date, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const datePart = (timestamp: string) => timestamp.split(' ')[0].split('T')[0]; + + const migrateLegacyVendorContacts = database.transaction((rows: typeof legacyVendorContacts) => { + for (const row of rows) { + insertContact.run( + row.id, + row.name, + row.email, + row.phone, + row.notes, + row.is_active, + row.created_at, + row.updated_at, + ); + + insertAffiliation.run( + crypto.randomUUID(), + row.id, + row.vendor_id, + row.title, + row.is_primary, + datePart(row.created_at), + row.is_active ? null : datePart(row.updated_at), + row.notes, + row.created_at, + row.updated_at, + ); + } + }); + + migrateLegacyVendorContacts(legacyVendorContacts); + console.log(`Migration: Converted ${legacyVendorContacts.length} vendor_contacts row(s) into contacts + contact_affiliations`); + } + } + } catch (error) { + console.error('Migration error (contacts backfill from vendor_contacts):', error); + } + + // Migration: Preserve orphaned free-text contact info by converting it into + // the person-centric model. A client/vendor whose inline contact_name was + // typed by hand (with no corresponding current primary affiliation) would + // otherwise have those columns NULLed by the recompute below, since the new + // model treats them as a mirror of the current primary contact. Convert each + // such org's inline info into a contacts row (reusing an existing contact + // when the normalized name+email already match) plus a current primary + // affiliation, so the recompute converges on the same values and nothing is + // lost. Idempotent: once the affiliation exists, the org no longer + // qualifies as orphaned. + try { + const datePart = (timestamp: string | null) => + ((timestamp ?? '').split(' ')[0].split('T')[0]) || null; + + for (const entity of ['clients', 'vendors'] as const) { + const fkColumn = entity === 'clients' ? 'client_id' : 'vendor_id'; + const orphans = database.prepare(` + SELECT id, contact_name, contact_email, contact_phone, created_at FROM ${entity} o + WHERE contact_name IS NOT NULL AND TRIM(contact_name) <> '' + AND NOT EXISTS ( + SELECT 1 FROM contact_affiliations ca + WHERE ca.${fkColumn} = o.id AND ca.is_primary = 1 AND ca.end_date IS NULL + ) + `).all() as Array<{ + id: string; + contact_name: string; + contact_email: string | null; + contact_phone: string | null; + created_at: string | null; + }>; + + if (orphans.length === 0) continue; + + const findContact = database.prepare(` + SELECT id FROM contacts + WHERE lower(trim(name)) = lower(trim(?)) + AND COALESCE(lower(trim(email)), '') = COALESCE(lower(trim(?)), '') + LIMIT 1 + `); + const insertContact = database.prepare( + 'INSERT INTO contacts (id, name, email, phone) VALUES (?, ?, ?, ?)', + ); + const insertAffiliation = database.prepare(` + INSERT INTO contact_affiliations (id, contact_id, ${fkColumn}, is_primary, start_date) + VALUES (?, ?, ?, 1, ?) + `); + + const convertOrphans = database.transaction((rows: typeof orphans) => { + for (const row of rows) { + const existing = findContact.get(row.contact_name, row.contact_email) as + | { id: string } + | undefined; + let contactId = existing?.id; + if (!contactId) { + contactId = crypto.randomUUID(); + insertContact.run(contactId, row.contact_name.trim(), row.contact_email, row.contact_phone); + } + insertAffiliation.run(crypto.randomUUID(), contactId, row.id, datePart(row.created_at)); + } + }); + + convertOrphans(orphans); + console.log( + `Migration: Converted inline contact info on ${orphans.length} ${entity} row(s) into contacts + primary affiliations`, + ); + } + } catch (error) { + console.error('Migration error (orphaned inline contact_* conversion):', error); + } + + // Migration: Backfill clients.contact_name/contact_email/contact_phone and + // vendors.contact_name/contact_email/contact_phone from each org's current + // primary contact affiliation (contact_affiliations.is_primary = 1 AND + // end_date IS NULL, contact_id -> contacts). Going forward these columns + // are kept fresh automatically by the trg_contact_affiliations_sync_* / + // trg_contacts_sync_au triggers in schema.sql, but those only fire on new + // mutations - rows that predate the triggers (including anything carried + // over from the client_contacts/vendor_contacts backfills above, or old + // free-text values entered before the person-centric contacts model) + // need a one-time recompute so they match the new source of truth. + // + // Safe to run on every boot: the WHERE clause (comparing each column to + // its freshly-computed value with the NULL-safe `IS NOT`) only touches + // rows that actually need a change, so after the first real run this is a + // 0-row, silent no-op - matching the "only log when it actually runs" + // convention used throughout this file. + try { + const clientsResult = database.prepare(` + UPDATE clients SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = clients.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = clients.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = clients.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE + contact_name IS NOT (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = clients.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + OR contact_email IS NOT (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = clients.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + OR contact_phone IS NOT (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = clients.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + `).run(); + if (clientsResult.changes > 0) { + console.log(`Migration: Recomputed contact_name/contact_email/contact_phone for ${clientsResult.changes} client(s) from their primary contact affiliation`); + } + } catch (error) { + console.error('Migration error (clients contact_* backfill from contact_affiliations):', error); + } + + try { + const vendorsResult = database.prepare(` + UPDATE vendors SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = vendors.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = vendors.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = vendors.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE + contact_name IS NOT (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = vendors.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + OR contact_email IS NOT (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = vendors.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + OR contact_phone IS NOT (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = vendors.id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + `).run(); + if (vendorsResult.changes > 0) { + console.log(`Migration: Recomputed contact_name/contact_email/contact_phone for ${vendorsResult.changes} vendor(s) from their primary contact affiliation`); + } + } catch (error) { + console.error('Migration error (vendors contact_* backfill from contact_affiliations):', error); + } } export function closeDatabase(): void { diff --git a/server/db/schema.sql b/server/db/schema.sql index d648614..ecbd57f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -184,7 +184,9 @@ CREATE TABLE IF NOT EXISTS vendors ( created_at TEXT DEFAULT (datetime('now')) ); --- Vendor Contacts +-- Vendor Contacts. FROZEN ARCHIVE - like client_contacts above, this table is +-- superseded by the person-centric contacts + contact_affiliations tables +-- (see the migration in database.ts); the app no longer reads or writes it. CREATE TABLE IF NOT EXISTS vendor_contacts ( id TEXT PRIMARY KEY, vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE, @@ -199,6 +201,130 @@ CREATE TABLE IF NOT EXISTS vendor_contacts ( updated_at TEXT DEFAULT (datetime('now')) ); +-- Contacts (people, independent of any one organisation). client_contacts and +-- vendor_contacts remain as frozen archives (see the migrations in +-- database.ts); new contact data lives here, affiliated to clients/vendors +-- over time via contact_affiliations below. +CREATE TABLE IF NOT EXISTS contacts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + phone TEXT, + notes TEXT, + is_active INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); + +-- A contact's affiliation with a client OR a vendor over a period of time. +-- end_date NULL = current affiliation. +CREATE TABLE IF NOT EXISTS contact_affiliations ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + client_id TEXT REFERENCES clients(id) ON DELETE CASCADE, + vendor_id TEXT REFERENCES vendors(id) ON DELETE CASCADE, + title TEXT, + is_primary INTEGER DEFAULT 0, + start_date TEXT, + end_date TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + CHECK ((client_id IS NULL) <> (vendor_id IS NULL)) +); +CREATE INDEX IF NOT EXISTS idx_contact_affiliations_contact ON contact_affiliations(contact_id); +CREATE INDEX IF NOT EXISTS idx_contact_affiliations_client ON contact_affiliations(client_id); +CREATE INDEX IF NOT EXISTS idx_contact_affiliations_vendor ON contact_affiliations(vendor_id); + +-- Keep clients.contact_name/contact_email/contact_phone and +-- vendors.contact_name/contact_email/contact_phone (read by invoice-emailing +-- fallback and the external API) in sync with each org's *current primary* +-- contact (contact_affiliations.is_primary = 1 AND end_date IS NULL). These +-- triggers recompute from scratch on every relevant mutation, so every +-- mutation path (insert, primary/end_date change, delete, or an edit to the +-- contact's own name/email/phone) converges on the same result: the primary +-- contact's info, or NULL if the org currently has no primary. Overlapping +-- affiliations are allowed (a person can be primary at more than one org at +-- once) - each org's columns are recomputed independently, so there is no +-- cross-org exclusivity here. +-- +-- WHERE id = NEW.client_id / OLD.client_id is a deliberate no-op when the +-- affiliation targets a vendor (client_id NULL): `id = NULL` matches zero +-- rows, so the clients-branch of these triggers is inert for vendor-side +-- affiliations, and vice versa for the vendors branch. +CREATE TRIGGER IF NOT EXISTS trg_contact_affiliations_sync_ai +AFTER INSERT ON contact_affiliations +BEGIN + UPDATE clients SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = NEW.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = NEW.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = NEW.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE id = NEW.client_id; + + UPDATE vendors SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = NEW.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = NEW.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = NEW.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE id = NEW.vendor_id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_contact_affiliations_sync_au +AFTER UPDATE OF is_primary, end_date ON contact_affiliations +BEGIN + UPDATE clients SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = NEW.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = NEW.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = NEW.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE id = NEW.client_id; + + UPDATE vendors SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = NEW.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = NEW.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = NEW.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE id = NEW.vendor_id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_contact_affiliations_sync_ad +AFTER DELETE ON contact_affiliations +BEGIN + UPDATE clients SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = OLD.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = OLD.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.client_id = OLD.client_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE id = OLD.client_id; + + UPDATE vendors SET + contact_name = (SELECT c.name FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = OLD.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_email = (SELECT c.email FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = OLD.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1), + contact_phone = (SELECT c.phone FROM contact_affiliations ca JOIN contacts c ON c.id = ca.contact_id WHERE ca.vendor_id = OLD.vendor_id AND ca.is_primary = 1 AND ca.end_date IS NULL LIMIT 1) + WHERE id = OLD.vendor_id; +END; + +-- If the primary contact's own name/email/phone changes, propagate to every +-- client/vendor that currently has them as primary (could be more than one - +-- overlapping affiliations are allowed, so this is not a single-row update). +CREATE TRIGGER IF NOT EXISTS trg_contacts_sync_au +AFTER UPDATE OF name, email, phone ON contacts +BEGIN + UPDATE clients SET + contact_name = NEW.name, + contact_email = NEW.email, + contact_phone = NEW.phone + WHERE id IN ( + SELECT client_id FROM contact_affiliations + WHERE contact_id = NEW.id AND is_primary = 1 AND end_date IS NULL AND client_id IS NOT NULL + ); + + UPDATE vendors SET + contact_name = NEW.name, + contact_email = NEW.email, + contact_phone = NEW.phone + WHERE id IN ( + SELECT vendor_id FROM contact_affiliations + WHERE contact_id = NEW.id AND is_primary = 1 AND end_date IS NULL AND vendor_id IS NOT NULL + ); +END; + -- Trading Names CREATE TABLE IF NOT EXISTS trading_names ( id TEXT PRIMARY KEY, diff --git a/server/index.ts b/server/index.ts index 0904fc2..ef9a07a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { readFileSync, existsSync } from 'fs'; import { initializeDatabase, closeDatabase } from './db/database.js'; +import { startBackupSchedule } from './utils/backup.js'; import authRoutes from './routes/auth.js'; import crudRoutes from './routes/crud.js'; import storageRoutes from './routes/storage.js'; @@ -156,6 +157,8 @@ if (process.env.NODE_ENV !== 'test') { console.log(`Database location: ${process.env.DATABASE_PATH || join(process.cwd(), 'data', 'app.db')}`); }); + startBackupSchedule(); + // Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down...'); diff --git a/server/routes/crud.ts b/server/routes/crud.ts index 7fd8a7d..25e749e 100644 --- a/server/routes/crud.ts +++ b/server/routes/crud.ts @@ -11,6 +11,8 @@ const router = Router(); const tableResourceMap: Record = { clients: 'clients', client_contacts: 'clients', + contacts: 'clients', + contact_affiliations: 'clients', jobs: 'jobs', job_assignments: 'jobs', job_assets: 'jobs', @@ -34,6 +36,7 @@ const tableResourceMap: Record = { issue_items: 'issues', issue_jobs: 'issues', issue_bookmarks: 'issues', + issue_bookmark_links: 'issues', kb_articles: 'kb', kb_attachments: 'kb', kb_article_issues: 'kb', @@ -70,14 +73,31 @@ type RelationConfig = { type SelectRelation = { relationName: string; alias?: string; + children: SelectRelation[]; }; +/** + * Thrown when a select references a relation name that has no entry in + * `relationMappings`, or a belongsTo entry that is missing its `fk`. Caught + * in the GET route handlers and turned into a 400 so a typo'd or + * unmapped relation fails loudly instead of silently dropping data. + */ +class InvalidRelationError extends Error {} + type QueryParamValue = string | ParsedQs | (string | ParsedQs)[] | undefined; const relationMappings: Record> = { clients: { locations: { table: 'locations', fk: 'location_id' }, }, + contacts: { + contact_affiliations: { table: 'contact_affiliations', direction: 'hasMany', localKey: 'id', remoteKey: 'contact_id' }, + }, + contact_affiliations: { + contacts: { table: 'contacts', fk: 'contact_id' }, + clients: { table: 'clients', fk: 'client_id' }, + vendors: { table: 'vendors', fk: 'vendor_id' }, + }, jobs: { clients: { table: 'clients', fk: 'client_id' }, locations: { table: 'locations', fk: 'location_id' }, @@ -88,6 +108,12 @@ const relationMappings: Record> = { clients: { table: 'clients', fk: 'client_id' }, jobs: { table: 'jobs', fk: 'job_id' }, }, + invoice_lines: { + invoices: { table: 'invoices', fk: 'invoice_id' }, + }, + payments: { + invoices: { table: 'invoices', fk: 'invoice_id' }, + }, purchases: { vendors: { table: 'vendors', fk: 'vendor_id' }, }, @@ -98,6 +124,7 @@ const relationMappings: Record> = { }, expenses: { jobs: { table: 'jobs', fk: 'job_id' }, + vendors: { table: 'vendors', fk: 'vendor_id' }, }, assets: { assigned_client: { table: 'clients', fk: 'assigned_client_id' }, @@ -107,8 +134,39 @@ const relationMappings: Record> = { jobs: { table: 'jobs', fk: 'job_id' }, assets: { table: 'assets', fk: 'asset_id' }, }, + job_assignments: { + jobs: { table: 'jobs', fk: 'job_id' }, + }, + inventory_movements: { + items: { table: 'items', fk: 'item_id' }, + jobs: { table: 'jobs', fk: 'job_id' }, + }, + issues: { + clients: { table: 'clients', fk: 'client_id' }, + purchases: { table: 'purchases', fk: 'purchase_id' }, + }, + issue_jobs: { + jobs: { table: 'jobs', fk: 'job_id' }, + }, + issue_assets: { + assets: { table: 'assets', fk: 'asset_id' }, + }, + issue_items: { + items: { table: 'items', fk: 'item_id' }, + }, + issue_bookmarks: { + issues: { table: 'issues', fk: 'issue_id' }, + }, + issue_bookmark_links: { + issue_bookmarks: { table: 'issue_bookmarks', fk: 'target_bookmark_id' }, + }, + kb_article_issues: { + kb_articles: { table: 'kb_articles', fk: 'article_id' }, + issues: { table: 'issues', fk: 'issue_id' }, + }, user_roles: { role_id: { table: 'roles', fk: 'role_id' }, + roles: { table: 'roles', fk: 'role_id' }, }, role_permissions: { resource_id: { table: 'resources', fk: 'resource_id' }, @@ -165,9 +223,20 @@ function parseSelect(select: string): { fields: string[]; relations: SelectRelat ? before.split(':', 2).map(part => part.trim()) : [undefined, before]; + // Recurse into the parenthesized inner content so nested relations + // (e.g. `invoices(invoice_number, clients(name))`) aren't discarded. + // Column lists inside `inner` are intentionally ignored here (see + // attachRelations, which always fetches related rows with SELECT *); + // only the nested relation names are kept. Parsing itself is not + // depth-limited - splitSelect's paren-depth tracking already bounds + // recursion to the literal nesting present in the (finite) query + // string. attachRelations is what enforces the business depth cap. + const { relations: children } = parseSelect(inner); + relations.push({ relationName: relationPart, alias: aliasPart, + children, }); continue; } @@ -391,13 +460,23 @@ function buildWhereClause(query: Record, tableName: string): { clau }; } -async function attachRelations(table: string, rows: any[], relations: SelectRelation[]): Promise { +// Business cap on relation nesting depth (1 = the relation directly on the +// queried table, 2 = a relation nested inside that, 3 = one level further). +// parseSelect will happily parse deeper than this (see comment there), but +// attachRelations stops issuing queries/attaching data past this depth so a +// pathologically deep `select=` can't fan out into unbounded DB round-trips. +const MAX_RELATION_DEPTH = 3; + +async function attachRelations(table: string, rows: any[], relations: SelectRelation[], depth = 1): Promise { if (!rows.length || !relations.length) return; + if (depth > MAX_RELATION_DEPTH) return; const tableRelations = relationMappings[table] || {}; for (const rel of relations) { const config = tableRelations[rel.relationName]; - if (!config) continue; + if (!config) { + throw new InvalidRelationError(`Unknown relation '${rel.relationName}' for table '${table}'`); + } const alias = rel.alias || rel.relationName; if (config.direction === 'hasMany') { @@ -417,8 +496,9 @@ async function attachRelations(table: string, rows: any[], relations: SelectRela if (result.error) continue; + const relatedRows = result.data || []; const grouped = new Map(); - (result.data || []).forEach(item => { + relatedRows.forEach(item => { const key = item[remoteKey]; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push(item); @@ -428,9 +508,18 @@ async function attachRelations(table: string, rows: any[], relations: SelectRela const key = row[parentKey]; row[alias] = grouped.get(key) || []; }); + + // Recurse over the flattened (ungrouped) related rows - each row + // object is shared by reference with the arrays attached above, so + // mutating it here is visible on every parent row it was grouped into. + if (rel.children.length) { + await attachRelations(config.table, relatedRows, rel.children, depth + 1); + } } else { const fk = config.fk; - if (!fk) continue; + if (!fk) { + throw new InvalidRelationError(`Relation '${rel.relationName}' for table '${table}' is missing an fk configuration`); + } const ids = Array.from(new Set(rows.map(row => row[fk]).filter(Boolean))); if (!ids.length) { rows.forEach(row => (row[alias] = null)); @@ -446,6 +535,14 @@ async function attachRelations(table: string, rows: any[], relations: SelectRela rows.forEach(row => { row[alias] = map.get(row[fk]) || null; }); + + // Recurse over the unique attached objects (one fetch/attach per + // distinct related row, not per parent row) - same reference-sharing + // reasoning as the hasMany branch above. + if (rel.children.length) { + const uniqueAttached = Array.from(map.values()); + await attachRelations(config.table, uniqueAttached, rel.children, depth + 1); + } } } } @@ -552,7 +649,15 @@ router.get('/:table', (req: AuthRequest, res: Response) => { } const data = result.data || []; - await attachRelations(tableParam, data, relations); + try { + await attachRelations(tableParam, data, relations); + } catch (err) { + if (err instanceof InvalidRelationError) { + res.status(400).json({ error: err.message }); + return; + } + throw err; + } if (normalizeQueryValue(count) === 'exact') { const countResult = queryOne<{ count: number }>(`SELECT COUNT(*) as count FROM ${tableParam} ${clause}`, params); @@ -593,7 +698,15 @@ router.get('/:table/:id', (req: AuthRequest, res: Response) => { return; } - await attachRelations(tableParam, [result.data], relations); + try { + await attachRelations(tableParam, [result.data], relations); + } catch (err) { + if (err instanceof InvalidRelationError) { + res.status(400).json({ error: err.message }); + return; + } + throw err; + } res.json(result.data); }); }); diff --git a/server/routes/functions.ts b/server/routes/functions.ts index a093cad..bb53bbd 100644 --- a/server/routes/functions.ts +++ b/server/routes/functions.ts @@ -3,15 +3,11 @@ import { v4 as uuidv4 } from 'uuid'; import { queryAll, queryOne, execute } from '../db/database.js'; import { authMiddleware, AuthRequest, requirePermission } from '../middleware/auth.js'; import { allocateInvoiceNumber, allocateJobNumber } from '../utils/numbering.js'; +import { generateInvoicePdf, generateInvoicesPdf } from '../utils/invoicePdf.js'; +import { todayLocalDate, formatLocalDate } from '../utils/dates.js'; const router = Router(); -const formatCurrency = (amount: number) => - new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(amount); - -const formatDate = (date: string) => - new Date(date).toLocaleDateString('en-AU', { day: 'numeric', month: 'long', year: 'numeric' }); - router.post('/generate-invoice-pdf', authMiddleware, async (req: AuthRequest, res: Response) => { const requireRead = requirePermission('invoices', 'read'); await requireRead(req, res, async () => { @@ -21,265 +17,59 @@ router.post('/generate-invoice-pdf', authMiddleware, async (req: AuthRequest, re return; } - const invoiceResult = queryOne('SELECT * FROM invoices WHERE id = ?', [invoiceId]); + const invoiceResult = queryOne<{ invoice_number: string }>( + 'SELECT invoice_number FROM invoices WHERE id = ?', + [invoiceId] + ); if (invoiceResult.error || !invoiceResult.data) { res.status(404).json({ error: 'Invoice not found' }); return; } - const invoice = invoiceResult.data; - const client = invoice.client_id - ? queryOne('SELECT * FROM clients WHERE id = ?', [invoice.client_id]).data - : null; - const job = invoice.job_id - ? queryOne('SELECT * FROM jobs WHERE id = ?', [invoice.job_id]).data - : null; - const tradingName = job?.trading_name_id - ? queryOne('SELECT * FROM trading_names WHERE id = ?', [job.trading_name_id]).data - : null; - - const lines = queryAll('SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order', [invoiceId]).data || []; - const company = queryOne('SELECT * FROM company_settings LIMIT 1').data || {}; + try { + const pdf = await generateInvoicePdf(invoiceId); + if (!pdf) { + res.status(404).json({ error: 'Invoice not found' }); + return; + } - // Get the default tax name from tax_rates or use "GST" as fallback - const defaultTaxRate = company?.default_tax_rate_id - ? queryOne('SELECT name FROM tax_rates WHERE id = ?', [company.default_tax_rate_id]).data - : null; - const taxDisplayName = lines[0]?.tax_name || defaultTaxRate?.name || 'GST'; - - let clientCredit = 0; - if (invoice.client_id) { - const creditInvoices = queryAll( - `SELECT total, amount_paid, status FROM invoices - WHERE client_id = ? AND id != ? AND status IN ('paid', 'partially_paid', 'sent', 'overdue')`, - [invoice.client_id, invoiceId] - ).data || []; - - clientCredit = creditInvoices.reduce((sum: number, inv: any) => { - const overpayment = (inv.amount_paid || 0) - (inv.total || 0); - return sum + (overpayment > 0 ? overpayment : 0); - }, 0); + const filename = `${invoiceResult.data.invoice_number}.pdf`.replace(/["\\]/g, ''); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(pdf); + } catch (error: any) { + res.status(500).json({ error: error?.message || 'Failed to generate PDF' }); } + }); +}); - let companyDisplayName = company?.name || 'Company Name'; - if (tradingName?.name) { - companyDisplayName = `${company?.name || 'Company'} trading as ${tradingName.name}`; - } else if (company?.trading_name) { - companyDisplayName = `${company.name} trading as ${company.trading_name}`; +router.post('/generate-invoices-pdf', authMiddleware, async (req: AuthRequest, res: Response) => { + const requireRead = requirePermission('invoices', 'read'); + await requireRead(req, res, async () => { + const { invoiceIds } = req.body; + if ( + !Array.isArray(invoiceIds) || + invoiceIds.length === 0 || + invoiceIds.length > 100 || + !invoiceIds.every(id => typeof id === 'string') + ) { + res.status(400).json({ error: 'invoiceIds must be a non-empty array of at most 100 invoice ids' }); + return; } - // Use trading name ABN if available, otherwise fall back to company ABN - const displayAbn = tradingName?.abn || company?.abn; - - let paymentDetailsHtml = ''; - if (tradingName) { - const paymentParts: string[] = []; - - if (tradingName.bank_name || tradingName.bank_bsb || tradingName.bank_account_number) { - let bankHtml = '
Bank Transfer:
'; - if (tradingName.bank_name) bankHtml += `Bank: ${tradingName.bank_name}
`; - if (tradingName.bank_account_name) bankHtml += `Account Name: ${tradingName.bank_account_name}
`; - if (tradingName.bank_bsb) bankHtml += `BSB: ${tradingName.bank_bsb}
`; - if (tradingName.bank_account_number) bankHtml += `Account: ${tradingName.bank_account_number}`; - bankHtml += '
'; - paymentParts.push(bankHtml); + try { + const pdf = await generateInvoicesPdf(invoiceIds); + if (!pdf) { + res.status(404).json({ error: 'No matching invoices found' }); + return; } - if (tradingName.paypal_email) { - paymentParts.push(`
PayPal: ${tradingName.paypal_email}
`); - } - - if (tradingName.other_payment_details) { - paymentParts.push( - `
Other:
${tradingName.other_payment_details.replace(/\n/g, '
')}
` - ); - } - - if (paymentParts.length > 0) { - paymentDetailsHtml = ` -
-
Payment Details
- ${paymentParts.join('')} -
- `; - } + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="invoices-${todayLocalDate()}.pdf"`); + res.send(pdf); + } catch (error: any) { + res.status(500).json({ error: error?.message || 'Failed to generate PDF' }); } - - const html = ` - - - - - - - -
-
-
-
${companyDisplayName}
-
- ${company?.address || ''}
- ${company?.email ? `Email: ${company.email}` : ''}
- ${company?.phone ? `Phone: ${company.phone}` : ''}
- ${displayAbn ? `ABN: ${displayAbn}` : ''} -
-
-
-
INVOICE
-
${invoice.invoice_number}
-
-
- -
-
-
Bill To
-
${client?.name || 'Client'}
-
${client?.billing_address || ''}
-
-
-
Issue Date
-
${formatDate(invoice.issue_date)}
-
-
-
Due Date
-
${formatDate(invoice.due_date)}
-
-
-
Status
-
${invoice.status.replace('_', ' ')}
-
-
- -
- - - - - - - - - - - ${(lines || []) - .map( - (line: any) => ` - - - - - - - - ` - ) - .join('')} - -
DescriptionQtyUnit Price${taxDisplayName}Total
${line.description}${line.quantity}${formatCurrency(line.unit_price)}${line.tax_rate}%${formatCurrency(line.line_total)}
- -
-
-
- Subtotal - ${formatCurrency(invoice.subtotal)} -
-
- ${taxDisplayName} - ${formatCurrency(invoice.tax_total)} -
-
- Total - ${formatCurrency(invoice.total)} -
- ${ - clientCredit > 0 - ? ` -
- Account Credit - -${formatCurrency(clientCredit)} -
- ` - : '' - } - ${ - invoice.amount_paid > 0 - ? ` -
- Amount Paid - ${formatCurrency(invoice.amount_paid)} -
- ` - : '' - } -
- Balance Due - ${formatCurrency(Math.max(0, invoice.total - (invoice.amount_paid || 0) - clientCredit))} -
-
-
- - ${paymentDetailsHtml} - - ${ - invoice.notes || invoice.terms - ? ` -
- ${ - invoice.notes - ? ` -
Notes
-
${invoice.notes}
- ` - : '' - } - ${ - invoice.terms - ? ` -
Terms
-
${invoice.terms}
- ` - : '' - } -
- ` - : '' - } - - - - `; - - res.json({ html, invoice: invoice.invoice_number }); }); }); @@ -287,7 +77,7 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r const requireWrite = requirePermission('invoices', 'write'); await requireWrite(req, res, async () => { const today = new Date(); - const todayStr = today.toISOString().split('T')[0]; + const todayStr = todayLocalDate(); const jobs = queryAll( `SELECT * FROM jobs WHERE is_recurring = 1 AND status = 'active' AND client_id IS NOT NULL AND next_invoice_date IS NOT NULL` @@ -481,7 +271,7 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r client.id, jobId, todayStr, - dueDate.toISOString().split('T')[0], + formatLocalDate(dueDate), 'draft', subtotal, taxTotal, @@ -516,7 +306,7 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r if (jobIdsFromRecurring.has(jobId) && job.next_invoice_date) { const nextInvoiceDate = new Date(job.next_invoice_date); nextInvoiceDate.setMonth(nextInvoiceDate.getMonth() + 1); - execute('UPDATE jobs SET next_invoice_date = ? WHERE id = ?', [nextInvoiceDate.toISOString().split('T')[0], jobId]); + execute('UPDATE jobs SET next_invoice_date = ? WHERE id = ?', [formatLocalDate(nextInvoiceDate), jobId]); } assetsForJob.forEach(asset => { @@ -528,7 +318,7 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r } else { nextDate.setMonth(nextDate.getMonth() + 1); } - execute('UPDATE job_assets SET next_invoice_date = ? WHERE id = ?', [nextDate.toISOString().split('T')[0], asset.id]); + execute('UPDATE job_assets SET next_invoice_date = ? WHERE id = ?', [formatLocalDate(nextDate), asset.id]); }); invoicesCreated.push(invoiceNumber); diff --git a/server/routes/mail.ts b/server/routes/mail.ts index 6a9b071..142ab2c 100644 --- a/server/routes/mail.ts +++ b/server/routes/mail.ts @@ -1,7 +1,9 @@ import { Router, Response } from 'express'; import { queryOne } from '../db/database.js'; import { authMiddleware, AuthRequest, requirePermission } from '../middleware/auth.js'; -import { sendInvoiceEmail, sendInviteEmail } from '../utils/email.js'; +import { sendInvoiceEmail, sendInviteEmail, escapeHtml } from '../utils/email.js'; +import { makeCurrencyFormatter } from '../utils/invoicePdf.js'; +import { formatDisplayDate } from '../utils/dates.js'; const router = Router(); @@ -59,36 +61,37 @@ router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Respo companyDisplayName = `${company.name} trading as ${company.trading_name}`; } - const formatCurrency = (amount: number) => - new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(amount); + const formatCurrency = makeCurrencyFormatter(company); - const formatDate = (date: string) => - new Date(date).toLocaleDateString('en-AU', { day: 'numeric', month: 'long', year: 'numeric' }); + const dateLocale = /^[a-z]{2}(-[A-Z]{2})?$/.test(company?.currency_locale || '') + ? company.currency_locale + : 'en-AU'; + const formatDate = (date: string) => formatDisplayDate(date, dateLocale); // Generate invoice HTML const invoiceHtml = `
-
${companyDisplayName}
+
${escapeHtml(companyDisplayName)}
- ${company?.address || ''}
- ${company?.email ? `Email: ${company.email}` : ''}
- ${company?.phone ? `Phone: ${company.phone}` : ''}
- ${company?.abn ? `ABN: ${company.abn}` : ''} + ${escapeHtml(company?.address || '')}
+ ${company?.email ? `Email: ${escapeHtml(company.email)}` : ''}
+ ${company?.phone ? `Phone: ${escapeHtml(company.phone)}` : ''}
+ ${company?.abn ? `ABN: ${escapeHtml(company.abn)}` : ''}
INVOICE
-
${invoice.invoice_number}
+
${escapeHtml(invoice.invoice_number)}
Bill To
-
${client?.name || 'Client'}
-
${client?.billing_address || ''}
+
${escapeHtml(client?.name || 'Client')}
+
${escapeHtml(client?.billing_address || '')}
Issue Date
@@ -106,7 +109,7 @@ router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Respo Description Qty Unit Price - ${taxDisplayName} + ${escapeHtml(taxDisplayName)} Total @@ -116,8 +119,8 @@ router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Respo .map( (line: any) => ` - ${line.description} - ${line.quantity} + ${escapeHtml(line.description)} + ${escapeHtml(line.quantity)} ${formatCurrency(line.unit_price)} ${line.tax_rate || 0}% ${formatCurrency(line.line_total)} @@ -136,7 +139,7 @@ router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Respo ${formatCurrency(invoice.subtotal)}
- ${taxDisplayName} + ${escapeHtml(taxDisplayName)} ${formatCurrency(invoice.tax_total)}
@@ -165,7 +168,7 @@ router.post('/send-invoice', authMiddleware, async (req: AuthRequest, res: Respo ? `
Notes
-
${invoice.notes}
+
${escapeHtml(invoice.notes)}
` : '' diff --git a/server/utils/backup.ts b/server/utils/backup.ts new file mode 100644 index 0000000..af74740 --- /dev/null +++ b/server/utils/backup.ts @@ -0,0 +1,144 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { getDatabase } from '../db/database.js'; + +const BACKUP_FILE_PREFIX = 'app-'; +const BACKUP_FILE_SUFFIX = '.db'; + +function getBackupDir(): string { + return process.env.BACKUP_DIR || path.join(process.cwd(), 'data', 'backups'); +} + +/** + * Parses a positive-integer-ish env var, falling back to `defaultValue` when + * unset, non-numeric, or NaN. `allowZero` permits 0 through (used to signal + * "disabled" for BACKUP_INTERVAL_HOURS). + */ +function parseIntEnv(value: string | undefined, defaultValue: number, allowZero = false): number { + if (value === undefined || value === '') return defaultValue; + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) return defaultValue; + if (parsed < 0) return defaultValue; + if (parsed === 0 && !allowZero) return defaultValue; + return parsed; +} + +function getBackupIntervalHours(): number { + return parseIntEnv(process.env.BACKUP_INTERVAL_HOURS, 24, /* allowZero */ true); +} + +function getBackupRetention(): number { + return parseIntEnv(process.env.BACKUP_RETENTION, 7); +} + +/** Filesystem-safe timestamp: 20260709-153045 (no colons, no dots). */ +function formatTimestamp(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const y = date.getFullYear(); + const mo = pad(date.getMonth() + 1); + const d = pad(date.getDate()); + const h = pad(date.getHours()); + const mi = pad(date.getMinutes()); + const s = pad(date.getSeconds()); + return `${y}${mo}${d}-${h}${mi}${s}`; +} + +/** + * Deletes older backups so only the newest `retention` files matching + * `app-*.db` remain in `dir`. Never throws - callers are expected to be + * inside a try/catch already, but this is defensive on its own too. + */ +async function pruneOldBackups(dir: string, retention: number): Promise { + try { + const entries = await fs.readdir(dir); + const backupFiles = entries.filter( + name => name.startsWith(BACKUP_FILE_PREFIX) && name.endsWith(BACKUP_FILE_SUFFIX) + ); + + if (backupFiles.length <= retention) return; + + // Sort newest-first by filename (timestamp format sorts lexicographically + // in chronological order), then delete everything past the retention count. + const sorted = backupFiles.sort().reverse(); + const toDelete = sorted.slice(retention); + + await Promise.all( + toDelete.map(async name => { + try { + await fs.unlink(path.join(dir, name)); + } catch (error) { + console.error(`Backup cleanup: failed to delete old backup ${name}:`, error); + } + }) + ); + } catch (error) { + console.error('Backup cleanup: failed to prune old backups:', error); + } +} + +/** + * Writes a live copy of the SQLite database to BACKUP_DIR using + * better-sqlite3's online backup API (safe to run while the app is serving + * requests / WAL is active). Returns the path written, or null if backups + * are disabled (BACKUP_INTERVAL_HOURS=0) or the backup failed. + * + * Never throws - a backup failure must not crash or otherwise affect the + * caller (e.g. the server's startup/interval path). + */ +export async function backupDatabase(): Promise { + if (getBackupIntervalHours() === 0) { + return null; + } + + try { + const dir = getBackupDir(); + await fs.mkdir(dir, { recursive: true }); + + const filename = `${BACKUP_FILE_PREFIX}${formatTimestamp(new Date())}${BACKUP_FILE_SUFFIX}`; + const destination = path.join(dir, filename); + + const database = getDatabase(); + await database.backup(destination); + + await pruneOldBackups(dir, getBackupRetention()); + + console.log(`Backup: wrote database backup to ${destination}`); + return destination; + } catch (error) { + console.error('Backup: failed to back up database:', error); + return null; + } +} + +let backupIntervalHandle: NodeJS.Timeout | null = null; + +/** + * Starts the automatic backup schedule: one backup immediately + * (fire-and-forget, errors are handled internally by backupDatabase), then + * one every BACKUP_INTERVAL_HOURS. Does nothing if backups are disabled + * (BACKUP_INTERVAL_HOURS=0). + * + * The interval timer is `.unref()`d so it never keeps the Node process + * alive on its own - graceful shutdown (SIGTERM/SIGINT) is unaffected. + */ +export function startBackupSchedule(): void { + const intervalHours = getBackupIntervalHours(); + if (intervalHours === 0) { + return; + } + + void backupDatabase(); + + const intervalMs = intervalHours * 60 * 60 * 1000; + backupIntervalHandle = setInterval(() => { + void backupDatabase(); + }, intervalMs).unref(); +} + +/** Stops the schedule started by startBackupSchedule(). Mainly for tests. */ +export function stopBackupSchedule(): void { + if (backupIntervalHandle) { + clearInterval(backupIntervalHandle); + backupIntervalHandle = null; + } +} diff --git a/server/utils/dates.ts b/server/utils/dates.ts new file mode 100644 index 0000000..e0565d3 --- /dev/null +++ b/server/utils/dates.ts @@ -0,0 +1,37 @@ +/** + * Server-side local-date helpers (mirrors src/lib/dates.ts on the frontend). + * + * `Date#toISOString()` always renders in UTC, so for UTC+ timezones (this + * app targets Australia) slicing the date out of an ISO string resolves to + * "yesterday" for part of the day. These helpers work in local time instead. + * Never parse a date-only 'YYYY-MM-DD' string with `new Date(str)` — that + * parses as UTC midnight and can shift the date by a day depending on the + * server's local timezone. + */ + +/** Format a Date as YYYY-MM-DD using its *local* date parts. */ +export function formatLocalDate(d: Date): string { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** Today's date as YYYY-MM-DD in local time. */ +export function todayLocalDate(): string { + return formatLocalDate(new Date()); +} + +/** + * Human-readable date for display (e.g. "12 July 2026"), parsed from a + * 'YYYY-MM-DD' string by splitting into parts — never `new Date(str)`. + */ +export function formatDisplayDate(s: string, locale = 'en-AU'): string { + const [year, month, day] = s.slice(0, 10).split('-').map(Number); + const d = new Date(year, (month || 1) - 1, day || 1); + try { + return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' }); + } catch { + return d.toLocaleDateString('en-AU', { day: 'numeric', month: 'long', year: 'numeric' }); + } +} diff --git a/server/utils/email.ts b/server/utils/email.ts index 8f85110..bfe38e8 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -1,6 +1,16 @@ import nodemailer from 'nodemailer'; import type { Transporter } from 'nodemailer'; +/** Escape a value for interpolation into HTML (shared with routes/mail.ts). */ +export function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + interface EmailConfig { host: string; port: number; @@ -107,8 +117,8 @@ export async function sendInvoiceEmail(
-

Invoice ${invoiceNumber}

-

From ${companyName}

+

Invoice ${escapeHtml(invoiceNumber)}

+

From ${escapeHtml(companyName)}

Please find your invoice attached below.

@@ -120,7 +130,7 @@ export async function sendInvoiceEmail(

If you have any questions about this invoice, please reply to this email.

@@ -164,24 +174,24 @@ export async function sendInviteEmail(

You're invited!

-

Join ${companyName}

+

Join ${escapeHtml(companyName)}

Hi there,

-

${inviterName} has invited you to join the team at ${companyName}.

+

${escapeHtml(inviterName)} has invited you to join the team at ${escapeHtml(companyName)}.

Click the button below to create your account and get started:

Or copy and paste this link into your browser:

-

${signupUrl}

+

${escapeHtml(signupUrl)}

diff --git a/server/utils/invoicePdf.ts b/server/utils/invoicePdf.ts new file mode 100644 index 0000000..b8444ed --- /dev/null +++ b/server/utils/invoicePdf.ts @@ -0,0 +1,391 @@ +import PDFDocument from 'pdfkit'; +import { queryAll, queryOne } from '../db/database.js'; +import { formatDisplayDate } from './dates.js'; + +/** + * Server-side invoice PDF rendering (pdfkit — pure JS, built-in standard + * fonts, no headless browser). Replaces the old popup + window.print() flow. + */ + +export interface InvoiceData { + invoice: any; + client: any | null; + job: any | null; + tradingName: any | null; + lines: any[]; + company: any; + taxDisplayName: string; + clientCredit: number; + companyDisplayName: string; + displayAbn: string | null; +} + +/** Gather everything needed to render one invoice. Returns null if the invoice doesn't exist. */ +export function gatherInvoiceData(invoiceId: string): InvoiceData | null { + const invoiceResult = queryOne('SELECT * FROM invoices WHERE id = ?', [invoiceId]); + if (invoiceResult.error || !invoiceResult.data) { + return null; + } + + const invoice = invoiceResult.data; + const client = invoice.client_id + ? queryOne('SELECT * FROM clients WHERE id = ?', [invoice.client_id]).data + : null; + const job = invoice.job_id + ? queryOne('SELECT * FROM jobs WHERE id = ?', [invoice.job_id]).data + : null; + const tradingName = job?.trading_name_id + ? queryOne('SELECT * FROM trading_names WHERE id = ?', [job.trading_name_id]).data + : null; + + const lines = queryAll('SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order', [invoiceId]).data || []; + const company = queryOne('SELECT * FROM company_settings LIMIT 1').data || {}; + + // Get the default tax name from tax_rates or use "GST" as fallback + const defaultTaxRate = company?.default_tax_rate_id + ? queryOne('SELECT name FROM tax_rates WHERE id = ?', [company.default_tax_rate_id]).data + : null; + const taxDisplayName = lines[0]?.tax_name || defaultTaxRate?.name || 'GST'; + + let clientCredit = 0; + if (invoice.client_id) { + const creditInvoices = queryAll( + `SELECT total, amount_paid, status FROM invoices + WHERE client_id = ? AND id != ? AND status IN ('paid', 'partially_paid', 'sent', 'overdue')`, + [invoice.client_id, invoiceId] + ).data || []; + + clientCredit = creditInvoices.reduce((sum: number, inv: any) => { + const overpayment = (inv.amount_paid || 0) - (inv.total || 0); + return sum + (overpayment > 0 ? overpayment : 0); + }, 0); + } + + let companyDisplayName = company?.name || 'Company Name'; + if (tradingName?.name) { + companyDisplayName = `${company?.name || 'Company'} trading as ${tradingName.name}`; + } else if (company?.trading_name) { + companyDisplayName = `${company.name} trading as ${company.trading_name}`; + } + + // Use trading name ABN if available, otherwise fall back to company ABN + const displayAbn = tradingName?.abn || company?.abn || null; + + return { + invoice, + client, + job, + tradingName, + lines, + company, + taxDisplayName, + clientCredit, + companyDisplayName, + displayAbn, + }; +} + +/** + * Build an Intl.NumberFormat currency formatter from company_settings, + * validating the same way the frontend BrandingContext does and falling + * back to en-AU / AUD. Shared with the send-invoice email in mail.ts. + */ +export function makeCurrencyFormatter(company: any): (amount: number) => string { + let currencyLocale = company?.currency_locale || 'en-AU'; + if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(currencyLocale)) { + currencyLocale = 'en-AU'; + } + + let currency = company?.currency || 'AUD'; + if (!/^[A-Z]{3}$/.test(currency)) { + currency = 'AUD'; + } + + let formatter: Intl.NumberFormat; + try { + formatter = new Intl.NumberFormat(currencyLocale, { style: 'currency', currency }); + } catch { + formatter = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }); + } + return (amount: number) => formatter.format(amount || 0); +} + +// --------------------------------------------------------------------------- +// Layout constants (A4, 50pt margins) +// --------------------------------------------------------------------------- +const MARGIN = 50; +const GRAY = '#666666'; +const BLACK = '#1a1a1a'; +const LIGHT_RULE = '#dddddd'; + +const FONT = 'Helvetica'; +const FONT_BOLD = 'Helvetica-Bold'; + +function humanizeStatus(status: string): string { + return String(status || '') + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** Ensure at least `needed` points of vertical space remain, else start a new page. */ +function ensureSpace(doc: PDFKit.PDFDocument, needed: number): void { + const bottom = doc.page.height - doc.page.margins.bottom; + if (doc.y + needed > bottom) { + doc.addPage(); + } +} + +/** Draw one invoice into the given document at the current page. */ +export function renderInvoice(doc: PDFKit.PDFDocument, data: InvoiceData): void { + const { invoice, client, tradingName, lines, company, taxDisplayName, clientCredit, companyDisplayName, displayAbn } = data; + const formatCurrency = makeCurrencyFormatter(company); + const localeForDates = /^[a-z]{2}(-[A-Z]{2})?$/.test(company?.currency_locale || '') + ? company.currency_locale + : 'en-AU'; + + const pageWidth = doc.page.width; + const contentWidth = pageWidth - MARGIN * 2; + const topY = doc.page.margins.top; + + // ------------------------------------------------------------------------- + // Header: company block (left) / INVOICE title + number (right) + // ------------------------------------------------------------------------- + const headerLeftWidth = contentWidth * 0.6; + doc.font(FONT_BOLD).fontSize(18).fillColor(BLACK); + doc.text(companyDisplayName, MARGIN, topY, { width: headerLeftWidth }); + + doc.font(FONT).fontSize(9).fillColor(GRAY); + doc.moveDown(0.3); + const companyDetails: string[] = []; + if (company?.address) companyDetails.push(company.address); + if (company?.email) companyDetails.push(`Email: ${company.email}`); + if (company?.phone) companyDetails.push(`Phone: ${company.phone}`); + if (displayAbn) companyDetails.push(`ABN: ${displayAbn}`); + if (companyDetails.length > 0) { + doc.text(companyDetails.join('\n'), MARGIN, doc.y, { width: headerLeftWidth }); + } + const leftHeaderBottom = doc.y; + + const headerRightX = MARGIN + contentWidth * 0.6; + const headerRightWidth = contentWidth * 0.4; + doc.font(FONT_BOLD).fontSize(24).fillColor(BLACK); + doc.text('INVOICE', headerRightX, topY, { width: headerRightWidth, align: 'right' }); + doc.font(FONT).fontSize(11).fillColor(GRAY); + doc.text(String(invoice.invoice_number || ''), headerRightX, doc.y + 2, { width: headerRightWidth, align: 'right' }); + const rightHeaderBottom = doc.y; + + doc.y = Math.max(leftHeaderBottom, rightHeaderBottom) + 25; + + // ------------------------------------------------------------------------- + // Meta band: Bill To / Issue Date / Due Date / Status + // ------------------------------------------------------------------------- + const metaY = doc.y; + const billToWidth = contentWidth * 0.4; + const metaColWidth = (contentWidth - billToWidth) / 3; + + const metaLabel = (label: string, x: number, width: number) => { + doc.font(FONT).fontSize(8).fillColor(GRAY); + doc.text(label.toUpperCase(), x, metaY, { width, characterSpacing: 0.5 }); + }; + + metaLabel('Bill To', MARGIN, billToWidth); + doc.font(FONT_BOLD).fontSize(10).fillColor(BLACK); + doc.text(client?.name || 'Client', MARGIN, metaY + 12, { width: billToWidth - 10 }); + if (client?.billing_address) { + doc.font(FONT).fontSize(9).fillColor(GRAY); + doc.text(String(client.billing_address), MARGIN, doc.y + 2, { width: billToWidth - 10 }); + } + const billToBottom = doc.y; + + const metaValue = (label: string, value: string, colIndex: number) => { + const x = MARGIN + billToWidth + metaColWidth * colIndex; + metaLabel(label, x, metaColWidth); + doc.font(FONT_BOLD).fontSize(10).fillColor(BLACK); + doc.text(value, x, metaY + 12, { width: metaColWidth - 6 }); + return doc.y; + }; + + const issueBottom = metaValue('Issue Date', invoice.issue_date ? formatDisplayDate(invoice.issue_date, localeForDates) : '-', 0); + const dueBottom = metaValue('Due Date', invoice.due_date ? formatDisplayDate(invoice.due_date, localeForDates) : '-', 1); + const statusBottom = metaValue('Status', humanizeStatus(invoice.status), 2); + + doc.y = Math.max(billToBottom, issueBottom, dueBottom, statusBottom) + 25; + + // ------------------------------------------------------------------------- + // Line-items table + // ------------------------------------------------------------------------- + const colDesc = { x: MARGIN, width: contentWidth * 0.5 }; + const colQty = { x: MARGIN + contentWidth * 0.5, width: contentWidth * 0.1 }; + const colPrice = { x: MARGIN + contentWidth * 0.6, width: contentWidth * 0.15 }; + const colTax = { x: MARGIN + contentWidth * 0.75, width: contentWidth * 0.1 }; + const colTotal = { x: MARGIN + contentWidth * 0.85, width: contentWidth * 0.15 }; + const CELL_PAD = 6; + + const drawTableHeader = () => { + const y = doc.y; + doc.font(FONT_BOLD).fontSize(8).fillColor(BLACK); + doc.text('DESCRIPTION', colDesc.x, y, { width: colDesc.width - CELL_PAD, characterSpacing: 0.5 }); + doc.text('QTY', colQty.x, y, { width: colQty.width - CELL_PAD, align: 'right', characterSpacing: 0.5 }); + doc.text('UNIT PRICE', colPrice.x, y, { width: colPrice.width - CELL_PAD, align: 'right', characterSpacing: 0.5 }); + doc.text(taxDisplayName.toUpperCase(), colTax.x, y, { width: colTax.width - CELL_PAD, align: 'right', characterSpacing: 0.5 }); + doc.text('TOTAL', colTotal.x, y, { width: colTotal.width, align: 'right', characterSpacing: 0.5 }); + const headerBottom = y + 12; + doc.moveTo(MARGIN, headerBottom).lineTo(MARGIN + contentWidth, headerBottom).lineWidth(1).strokeColor(BLACK).stroke(); + doc.y = headerBottom + 8; + }; + + ensureSpace(doc, 60); + drawTableHeader(); + + doc.font(FONT).fontSize(9); + for (const line of lines) { + const description = String(line.description || ''); + const descHeight = doc.heightOfString(description, { width: colDesc.width - CELL_PAD }); + const rowHeight = Math.max(descHeight, 11) + 8; + + // Paginate: if this row won't fit, start a new page and repeat the header + const bottom = doc.page.height - doc.page.margins.bottom; + if (doc.y + rowHeight > bottom) { + doc.addPage(); + drawTableHeader(); + doc.font(FONT).fontSize(9); + } + + const rowY = doc.y; + doc.fillColor(BLACK); + doc.text(description, colDesc.x, rowY, { width: colDesc.width - CELL_PAD }); + doc.text(String(line.quantity ?? ''), colQty.x, rowY, { width: colQty.width - CELL_PAD, align: 'right' }); + doc.text(formatCurrency(line.unit_price), colPrice.x, rowY, { width: colPrice.width - CELL_PAD, align: 'right' }); + doc.text(`${line.tax_rate ?? 0}%`, colTax.x, rowY, { width: colTax.width - CELL_PAD, align: 'right' }); + doc.text(formatCurrency(line.line_total), colTotal.x, rowY, { width: colTotal.width, align: 'right' }); + + const rowBottom = rowY + rowHeight; + doc.moveTo(MARGIN, rowBottom).lineTo(MARGIN + contentWidth, rowBottom).lineWidth(0.5).strokeColor(LIGHT_RULE).stroke(); + doc.y = rowBottom + 8; + } + + // ------------------------------------------------------------------------- + // Totals block (right-aligned) + // ------------------------------------------------------------------------- + const totalsWidth = 220; + const totalsX = MARGIN + contentWidth - totalsWidth; + const totalsLabelWidth = totalsWidth * 0.55; + const totalsValueWidth = totalsWidth * 0.45; + + const balanceDue = Math.max(0, (invoice.total || 0) - (invoice.amount_paid || 0) - clientCredit); + const totalsRows: Array<{ label: string; value: string; bold?: boolean; ruleAbove?: boolean; color?: string }> = [ + { label: 'Subtotal', value: formatCurrency(invoice.subtotal) }, + { label: taxDisplayName, value: formatCurrency(invoice.tax_total) }, + { label: 'Total', value: formatCurrency(invoice.total), bold: true, ruleAbove: true }, + ]; + if (clientCredit > 0) { + totalsRows.push({ label: 'Account Credit', value: `-${formatCurrency(clientCredit)}`, color: '#28a745' }); + } + if ((invoice.amount_paid || 0) > 0) { + totalsRows.push({ label: 'Amount Paid', value: formatCurrency(invoice.amount_paid) }); + } + totalsRows.push({ label: 'Balance Due', value: formatCurrency(balanceDue), bold: true }); + + ensureSpace(doc, totalsRows.length * 18 + 20); + doc.y += 6; + for (const row of totalsRows) { + if (row.ruleAbove) { + doc.moveTo(totalsX, doc.y).lineTo(totalsX + totalsWidth, doc.y).lineWidth(1).strokeColor(BLACK).stroke(); + doc.y += 6; + } + const rowY = doc.y; + doc.font(row.bold ? FONT_BOLD : FONT).fontSize(row.bold ? 11 : 9).fillColor(row.color || BLACK); + doc.text(row.label, totalsX, rowY, { width: totalsLabelWidth }); + doc.text(row.value, totalsX + totalsLabelWidth, rowY, { width: totalsValueWidth, align: 'right' }); + doc.y = rowY + (row.bold ? 18 : 15); + } + + // ------------------------------------------------------------------------- + // Payment details (from trading name, if any) + // ------------------------------------------------------------------------- + const paymentLines: string[] = []; + if (tradingName) { + if (tradingName.bank_name || tradingName.bank_bsb || tradingName.bank_account_number) { + paymentLines.push('Bank Transfer:'); + if (tradingName.bank_name) paymentLines.push(` Bank: ${tradingName.bank_name}`); + if (tradingName.bank_account_name) paymentLines.push(` Account Name: ${tradingName.bank_account_name}`); + if (tradingName.bank_bsb) paymentLines.push(` BSB: ${tradingName.bank_bsb}`); + if (tradingName.bank_account_number) paymentLines.push(` Account: ${tradingName.bank_account_number}`); + } + if (tradingName.paypal_email) { + paymentLines.push(`PayPal: ${tradingName.paypal_email}`); + } + if (tradingName.other_payment_details) { + paymentLines.push('Other:'); + paymentLines.push(String(tradingName.other_payment_details)); + } + } + + const drawSection = (label: string, body: string) => { + doc.font(FONT).fontSize(9); + const bodyHeight = doc.heightOfString(body, { width: contentWidth }); + ensureSpace(doc, bodyHeight + 30); + doc.y += 14; + doc.font(FONT).fontSize(8).fillColor(GRAY); + doc.text(label.toUpperCase(), MARGIN, doc.y, { width: contentWidth, characterSpacing: 0.5 }); + doc.font(FONT).fontSize(9).fillColor(BLACK); + doc.text(body, MARGIN, doc.y + 3, { width: contentWidth }); + }; + + if (paymentLines.length > 0) { + drawSection('Payment Details', paymentLines.join('\n')); + } + if (invoice.notes) { + drawSection('Notes', String(invoice.notes)); + } + if (invoice.terms) { + drawSection('Terms', String(invoice.terms)); + } +} + +/** Collect a PDFDocument's output stream into a single Buffer. */ +function collectPdf(doc: PDFKit.PDFDocument): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + doc.end(); + }); +} + +function createDocument(): PDFKit.PDFDocument { + return new PDFDocument({ size: 'A4', margin: MARGIN, bufferPages: true }); +} + +/** Generate a single-invoice PDF. Returns null if the invoice doesn't exist. */ +export async function generateInvoicePdf(invoiceId: string): Promise { + const data = gatherInvoiceData(invoiceId); + if (!data) return null; + + const doc = createDocument(); + renderInvoice(doc, data); + return collectPdf(doc); +} + +/** + * Generate one combined PDF for multiple invoices — each invoice starts on + * its own page. Unknown ids are skipped; returns null if none were found. + */ +export async function generateInvoicesPdf(invoiceIds: string[]): Promise { + const found = invoiceIds + .map(id => gatherInvoiceData(id)) + .filter((d): d is InvoiceData => d !== null); + + if (found.length === 0) return null; + + const doc = createDocument(); + found.forEach((data, index) => { + if (index > 0) { + doc.addPage(); + } + renderInvoice(doc, data); + }); + return collectPdf(doc); +} diff --git a/src/App.tsx b/src/App.tsx index dad09aa..cccbf26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense } from "react"; +import { ThemeProvider } from "next-themes"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -9,6 +10,7 @@ import { PermissionProvider } from "@/contexts/PermissionContext"; import { BrandingProvider } from "@/contexts/BrandingContext"; import ProtectedRoute from "@/components/ProtectedRoute"; import AppLayout from "@/components/layout/AppLayout"; +import { ConfirmProvider } from "@/components/ConfirmDialog"; // Login is the common entry point, so keep it eager to avoid a load flash. import Login from "./pages/Login"; @@ -20,6 +22,8 @@ const ResetPassword = lazy(() => import("./pages/ResetPassword")); const Dashboard = lazy(() => import("./pages/Dashboard")); const Clients = lazy(() => import("./pages/Clients")); const ClientDetail = lazy(() => import("./pages/ClientDetail")); +const Contacts = lazy(() => import("./pages/Contacts")); +const ContactDetail = lazy(() => import("./pages/ContactDetail")); const Jobs = lazy(() => import("./pages/Jobs")); const JobDetail = lazy(() => import("./pages/JobDetail")); const Invoices = lazy(() => import("./pages/Invoices")); @@ -49,64 +53,79 @@ const Docs = lazy(() => import("./pages/Docs")); const ActivityLog = lazy(() => import("./pages/ActivityLog")); const NotFound = lazy(() => import("./pages/NotFound")); -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, // 30s: avoid refetching on every mount + gcTime: 5 * 60_000, + retry: 1, + refetchOnWindowFocus: true, + }, + }, +}); const App = () => ( - - - - - - - - - Loading…
}> - - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - - - - - - - + + + + + + + + + + + Loading…
}> + + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + + + + + + + ); export default App; diff --git a/src/components/ActivityDetails.tsx b/src/components/ActivityDetails.tsx index 2b48732..ba20c25 100644 --- a/src/components/ActivityDetails.tsx +++ b/src/components/ActivityDetails.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { ArrowRight, Minus, Plus } from "lucide-react"; +import { useBranding } from "@/contexts/BrandingContext"; interface ActivityDetailsProps { action: string; @@ -13,8 +14,9 @@ const EXCLUDED_FIELDS = [ "updated_by", "deleted_at", "password", "key_hash" ]; -// Fields to format specially -const formatValue = (key: string, value: unknown): string => { +// Fields to format specially. `formatCurrency` is passed in from the caller +// (BrandingContext) since this is a plain function, not a hook. +const formatValue = (key: string, value: unknown, formatCurrency: (amount: number) => string): string => { if (value === null || value === undefined) return "—"; if (typeof value === "boolean") return value ? "Yes" : "No"; if (typeof value === "object") { @@ -23,12 +25,9 @@ const formatValue = (key: string, value: unknown): string => { } if (typeof value === "number") { // Format currency-like fields - if (key.includes("amount") || key.includes("price") || key.includes("cost") || + if (key.includes("amount") || key.includes("price") || key.includes("cost") || key.includes("rate") || key.includes("total") || key.includes("balance")) { - return new Intl.NumberFormat("en-AU", { - style: "currency", - currency: "AUD" - }).format(value); + return formatCurrency(value); } return value.toString(); } @@ -46,6 +45,7 @@ const formatFieldName = (key: string): string => { }; export default function ActivityDetails({ action, oldValues, newValues }: ActivityDetailsProps) { + const { formatCurrency } = useBranding(); const changes = useMemo(() => { const oldObj = (typeof oldValues === 'object' && oldValues !== null) ? oldValues as Record : null; const newObj = (typeof newValues === 'object' && newValues !== null) ? newValues as Record : null; @@ -117,38 +117,38 @@ export default function ActivityDetails({ action, oldValues, newValues }: Activi
{change.type === "added" && ( <> - + {formatFieldName(change.field)}: - {formatValue(change.field, change.newValue)} + {formatValue(change.field, change.newValue, formatCurrency)} )} {change.type === "removed" && ( <> - + {formatFieldName(change.field)}: - {formatValue(change.field, change.oldValue)} + {formatValue(change.field, change.oldValue, formatCurrency)} )} {change.type === "changed" && ( <> - + {formatFieldName(change.field)}: - {formatValue(change.field, change.oldValue)} + {formatValue(change.field, change.oldValue, formatCurrency)} - {formatValue(change.field, change.newValue)} + {formatValue(change.field, change.newValue, formatCurrency)} )} diff --git a/src/components/AffiliatedContacts.tsx b/src/components/AffiliatedContacts.tsx new file mode 100644 index 0000000..8cdd838 --- /dev/null +++ b/src/components/AffiliatedContacts.tsx @@ -0,0 +1,499 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { Plus, Search, Star, UserMinus, User } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useConfirm } from '@/components/ConfirmDialog'; +import { useToast } from '@/hooks/use-toast'; +import { supabase } from '@/integrations/supabase/client'; +import type { Tables } from '@/integrations/supabase/types'; +import { todayLocal } from '@/lib/dates'; +import { cn } from '@/lib/utils'; + +type Contact = Tables<'contacts'>; +type ContactAffiliation = Tables<'contact_affiliations'> & { contacts: Contact | null }; + +type AffiliatedContactsProps = + | { entityType: 'client'; entityId: string } + | { entityType: 'vendor'; entityId: string }; + +const emptyNewPerson = { name: '', title: '', email: '', phone: '' }; + +/** + * Generalized "who's affiliated with this client/vendor" panel, built on the + * person-centric `contacts` + `contact_affiliations` model. Replaces the old + * per-client `ClientContacts` component. + */ +export default function AffiliatedContacts({ entityType, entityId }: AffiliatedContactsProps) { + const { toast } = useToast(); + const confirm = useConfirm(); + const queryClient = useQueryClient(); + const entityColumn = entityType === 'client' ? 'client_id' : 'vendor_id'; + const affiliationsKey = ['affiliated-contacts', entityType, entityId]; + + const [dialogOpen, setDialogOpen] = useState(false); + const [mode, setMode] = useState<'new' | 'link'>('new'); + const [saving, setSaving] = useState(false); + + const [newPerson, setNewPerson] = useState(emptyNewPerson); + const [newPersonPrimary, setNewPersonPrimary] = useState(false); + const [newPersonStartDate, setNewPersonStartDate] = useState(todayLocal()); + const [newPersonEndDate, setNewPersonEndDate] = useState(''); + + const [linkSearch, setLinkSearch] = useState(''); + const [linkSelectedId, setLinkSelectedId] = useState(null); + const [linkTitle, setLinkTitle] = useState(''); + const [linkPrimary, setLinkPrimary] = useState(false); + const [linkStartDate, setLinkStartDate] = useState(todayLocal()); + const [linkEndDate, setLinkEndDate] = useState(''); + + const { data: affiliations = [], isLoading } = useQuery({ + queryKey: affiliationsKey, + queryFn: async () => { + const { data, error } = await supabase + .from('contact_affiliations') + .select('*, contacts(*)') + .eq(entityColumn, entityId) + .is('end_date', null) + .order('is_primary', { ascending: false }); + if (error) throw new Error(error.message); + return (data || []) as ContactAffiliation[]; + }, + }); + + const currentContactIds = new Set(affiliations.map(a => a.contact_id)); + + const { data: searchableContacts = [] } = useQuery({ + queryKey: ['contacts-searchable'], + queryFn: async () => { + const { data, error } = await supabase + .from('contacts') + .select('*') + .eq('is_active', true) + .order('name'); + if (error) throw new Error(error.message); + return (data || []) as Contact[]; + }, + enabled: dialogOpen && mode === 'link', + }); + + const linkCandidates = searchableContacts.filter(c => { + if (currentContactIds.has(c.id)) return false; + const q = linkSearch.trim().toLowerCase(); + if (!q) return true; + return c.name.toLowerCase().includes(q) || (c.email ?? '').toLowerCase().includes(q); + }); + + const sortedAffiliations = [...affiliations].sort((a, b) => { + if (!!a.is_primary !== !!b.is_primary) { + return a.is_primary ? -1 : 1; + } + return (a.contacts?.name ?? '').localeCompare(b.contacts?.name ?? ''); + }); + + function resetDialog() { + setMode('new'); + setNewPerson(emptyNewPerson); + setNewPersonPrimary(false); + setNewPersonStartDate(todayLocal()); + setNewPersonEndDate(''); + setLinkSearch(''); + setLinkSelectedId(null); + setLinkTitle(''); + setLinkPrimary(false); + setLinkStartDate(todayLocal()); + setLinkEndDate(''); + } + + function invalidateAffiliations() { + queryClient.invalidateQueries({ queryKey: affiliationsKey }); + } + + /** Clear `is_primary` on every current affiliation for this client or vendor. */ + async function clearCurrentPrimary() { + await supabase + .from('contact_affiliations') + .update({ is_primary: false }) + .eq(entityColumn, entityId) + .eq('is_primary', true) + .is('end_date', null); + } + + async function handleCreateNewPerson() { + if (!newPerson.name.trim()) { + toast({ title: 'Error', description: 'Name is required', variant: 'destructive' }); + return; + } + + if (newPersonEndDate && newPersonStartDate && newPersonEndDate < newPersonStartDate) { + toast({ title: 'Error', description: 'End date cannot be before start date', variant: 'destructive' }); + return; + } + + setSaving(true); + const { data: contact, error: contactError } = await supabase + .from('contacts') + .insert({ + name: newPerson.name.trim(), + email: newPerson.email.trim() || null, + phone: newPerson.phone.trim() || null, + }) + .select() + .single(); + + if (contactError || !contact) { + toast({ title: 'Error', description: contactError?.message ?? 'Failed to create contact', variant: 'destructive' }); + setSaving(false); + return; + } + + if (newPersonPrimary) { + await clearCurrentPrimary(); + } + + const { error: affiliationError } = await supabase.from('contact_affiliations').insert({ + contact_id: contact.id, + [entityColumn]: entityId, + title: newPerson.title.trim() || null, + is_primary: newPersonPrimary, + start_date: newPersonStartDate || todayLocal(), + end_date: newPersonEndDate || null, + }); + + setSaving(false); + if (affiliationError) { + toast({ title: 'Error', description: affiliationError.message, variant: 'destructive' }); + return; + } + + toast({ title: 'Success', description: 'Contact added' }); + setDialogOpen(false); + resetDialog(); + invalidateAffiliations(); + } + + async function handleLinkExisting() { + if (!linkSelectedId) { + toast({ title: 'Error', description: 'Select a person to link', variant: 'destructive' }); + return; + } + + if (linkEndDate && linkStartDate && linkEndDate < linkStartDate) { + toast({ title: 'Error', description: 'End date cannot be before start date', variant: 'destructive' }); + return; + } + + setSaving(true); + + if (linkPrimary) { + await clearCurrentPrimary(); + } + + const { error } = await supabase.from('contact_affiliations').insert({ + contact_id: linkSelectedId, + [entityColumn]: entityId, + title: linkTitle.trim() || null, + is_primary: linkPrimary, + start_date: linkStartDate || todayLocal(), + end_date: linkEndDate || null, + }); + + setSaving(false); + if (error) { + toast({ title: 'Error', description: error.message, variant: 'destructive' }); + return; + } + + toast({ title: 'Success', description: 'Contact linked' }); + setDialogOpen(false); + resetDialog(); + invalidateAffiliations(); + } + + async function handleSetPrimary(affiliation: ContactAffiliation) { + await clearCurrentPrimary(); + const { error } = await supabase + .from('contact_affiliations') + .update({ is_primary: true }) + .eq('id', affiliation.id); + + if (error) { + toast({ title: 'Error', description: error.message, variant: 'destructive' }); + } else { + toast({ title: 'Success', description: 'Primary contact updated' }); + invalidateAffiliations(); + } + } + + async function handleEndAffiliation(affiliation: ContactAffiliation) { + const personName = affiliation.contacts?.name ?? 'This person'; + if (!(await confirm({ + title: 'End this affiliation?', + description: `${personName} stays in Contacts with their full history — they'll just no longer show as current here.`, + confirmLabel: 'End Affiliation', + }))) return; + + const { error } = await supabase + .from('contact_affiliations') + .update({ end_date: todayLocal() }) + .eq('id', affiliation.id); + + if (error) { + toast({ title: 'Error', description: error.message, variant: 'destructive' }); + } else { + toast({ title: 'Success', description: 'Affiliation ended' }); + invalidateAffiliations(); + } + } + + if (isLoading) { + return
Loading contacts...
; + } + + return ( + + + + + Contacts ({affiliations.length}) + + { + setDialogOpen(open); + if (!open) resetDialog(); + }} + > + + + + + + Add Contact + + setMode(v as 'new' | 'link')}> + + New Person + Link Existing + + + +
+ + setNewPerson({ ...newPerson, name: e.target.value })} + placeholder="Full name" + /> +
+
+ + setNewPerson({ ...newPerson, title: e.target.value })} + placeholder="Job title" + /> +
+
+
+ + setNewPerson({ ...newPerson, email: e.target.value })} + placeholder="email@example.com" + /> +
+
+ + setNewPerson({ ...newPerson, phone: e.target.value })} + placeholder="Phone number" + /> +
+
+
+
+ + setNewPersonStartDate(e.target.value)} + /> +
+
+ + setNewPersonEndDate(e.target.value)} + /> +
+
+
+ setNewPersonPrimary(!!checked)} + /> + +
+ +
+ + +
+ + setLinkSearch(e.target.value)} + /> +
+ +
+ {linkCandidates.length === 0 ? ( +

+ No matching people found +

+ ) : ( + linkCandidates.map(c => ( + + )) + )} +
+
+
+ + setLinkTitle(e.target.value)} + placeholder="Job title at this entity" + /> +
+
+
+ + setLinkStartDate(e.target.value)} + /> +
+
+ + setLinkEndDate(e.target.value)} + /> +
+
+
+ setLinkPrimary(!!checked)} + /> + +
+ +
+
+
+
+
+ + {sortedAffiliations.length === 0 ? ( +

No contacts yet

+ ) : ( +
+ {sortedAffiliations.map(affiliation => { + const contact = affiliation.contacts; + return ( +
+
+
+ + {contact?.name ?? 'Unknown'} + + {affiliation.title && ( + · {affiliation.title} + )} + {!!affiliation.is_primary && ( + + + Primary + + )} +
+
+ {contact?.email && {contact.email}} + {contact?.phone && {contact.phone}} +
+
+
+ {!affiliation.is_primary && ( + + )} + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/ClientContacts.tsx b/src/components/ClientContacts.tsx deleted file mode 100644 index 18f501b..0000000 --- a/src/components/ClientContacts.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { useEffect, useState } from 'react'; -import { supabase } from '@/integrations/supabase/client'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { useToast } from '@/hooks/use-toast'; -import { useAuth } from '@/contexts/AuthContext'; -import { Plus, Trash2, Star, Pencil, X, Check, User, History } from 'lucide-react'; - -interface Contact { - id: string; - client_id: string; - name: string; - title: string | null; - email: string | null; - phone: string | null; - is_primary: boolean; - is_active: boolean; - notes: string | null; - created_at: string; -} - -interface ContactHistoryEvent { - id: string; - event_type: string; - description: string; - created_at: string; - old_values: any; - new_values: any; -} - -interface ClientContactsProps { - clientId: string; -} - -export default function ClientContacts({ clientId }: ClientContactsProps) { - const { toast } = useToast(); - const { user } = useAuth(); - const [contacts, setContacts] = useState([]); - const [history, setHistory] = useState([]); - const [showHistory, setShowHistory] = useState(false); - const [loading, setLoading] = useState(true); - const [showAddForm, setShowAddForm] = useState(false); - const [editingId, setEditingId] = useState(null); - const [newContact, setNewContact] = useState({ - name: '', - title: '', - email: '', - phone: '', - }); - - useEffect(() => { - fetchContacts(); - fetchHistory(); - }, [clientId]); - - async function fetchContacts() { - setLoading(true); - const { data } = await supabase - .from('client_contacts') - .select('*') - .eq('client_id', clientId) - .eq('is_active', true) - .order('is_primary', { ascending: false }) - .order('name'); - setContacts(data || []); - setLoading(false); - } - - async function fetchHistory() { - const { data } = await supabase - .from('client_contact_history') - .select('*') - .eq('client_id', clientId) - .order('created_at', { ascending: false }) - .limit(50); - setHistory(data || []); - } - - async function handleAddContact() { - if (!newContact.name.trim()) { - toast({ title: 'Error', description: 'Name is required', variant: 'destructive' }); - return; - } - - const isFirst = contacts.length === 0; - const { data, error } = await supabase.from('client_contacts').insert({ - client_id: clientId, - name: newContact.name.trim(), - title: newContact.title || null, - email: newContact.email || null, - phone: newContact.phone || null, - is_primary: isFirst, - }).select().single(); - - if (error) { - toast({ title: 'Error', description: error.message, variant: 'destructive' }); - } else { - // Log history - await supabase.from('client_contact_history').insert({ - client_id: clientId, - contact_id: data.id, - event_type: 'created', - description: `Contact "${newContact.name}" added`, - new_values: data as any, - created_by: user?.id, - } as any); - - toast({ title: 'Success', description: 'Contact added' }); - setNewContact({ name: '', title: '', email: '', phone: '' }); - setShowAddForm(false); - fetchContacts(); - fetchHistory(); - } - } - - async function handleSetPrimary(contactId: string) { - // Unset current primary - await supabase.from('client_contacts').update({ is_primary: false }).eq('client_id', clientId).eq('is_primary', true); - // Set new primary - const { error } = await supabase.from('client_contacts').update({ is_primary: true }).eq('id', contactId); - - if (error) { - toast({ title: 'Error', description: error.message, variant: 'destructive' }); - } else { - const contact = contacts.find(c => c.id === contactId); - await supabase.from('client_contact_history').insert({ - client_id: clientId, - contact_id: contactId, - event_type: 'set_primary', - description: `"${contact?.name}" set as primary contact`, - created_by: user?.id, - } as any); - - toast({ title: 'Success', description: 'Primary contact updated' }); - fetchContacts(); - fetchHistory(); - } - } - - async function handleDeleteContact(contact: Contact) { - if (!confirm(`Remove ${contact.name} from contacts?`)) return; - - const { error } = await supabase.from('client_contacts').update({ is_active: false }).eq('id', contact.id); - - if (error) { - toast({ title: 'Error', description: error.message, variant: 'destructive' }); - } else { - await supabase.from('client_contact_history').insert({ - client_id: clientId, - contact_id: contact.id, - event_type: 'deleted', - description: `Contact "${contact.name}" removed`, - old_values: contact as any, - created_by: user?.id, - } as any); - - toast({ title: 'Success', description: 'Contact removed' }); - fetchContacts(); - fetchHistory(); - } - } - - async function handleUpdateContact(contact: Contact) { - const { error } = await supabase.from('client_contacts').update({ - name: contact.name, - title: contact.title, - email: contact.email, - phone: contact.phone, - }).eq('id', contact.id); - - if (error) { - toast({ title: 'Error', description: error.message, variant: 'destructive' }); - } else { - await supabase.from('client_contact_history').insert({ - client_id: clientId, - contact_id: contact.id, - event_type: 'updated', - description: `Contact "${contact.name}" updated`, - new_values: contact as any, - created_by: user?.id, - } as any); - - toast({ title: 'Success', description: 'Contact updated' }); - setEditingId(null); - fetchContacts(); - fetchHistory(); - } - } - - if (loading) { - return
Loading contacts...
; - } - - return ( -
- - - - - Contacts ({contacts.length}) - -
- - -
-
- - {showAddForm && ( -
-
-
- - setNewContact({ ...newContact, name: e.target.value })} - placeholder="Full name" - /> -
-
- - setNewContact({ ...newContact, title: e.target.value })} - placeholder="Job title" - /> -
-
- - setNewContact({ ...newContact, email: e.target.value })} - placeholder="email@example.com" - /> -
-
- - setNewContact({ ...newContact, phone: e.target.value })} - placeholder="Phone number" - /> -
-
-
- - -
-
- )} - - {contacts.length === 0 ? ( -

No contacts added yet

- ) : ( -
- {contacts.map(contact => ( -
- {editingId === contact.id ? ( -
- setContacts(contacts.map(c => c.id === contact.id ? { ...c, name: e.target.value } : c))} - placeholder="Name" - /> - setContacts(contacts.map(c => c.id === contact.id ? { ...c, title: e.target.value } : c))} - placeholder="Title" - /> - setContacts(contacts.map(c => c.id === contact.id ? { ...c, email: e.target.value } : c))} - placeholder="Email" - /> - setContacts(contacts.map(c => c.id === contact.id ? { ...c, phone: e.target.value } : c))} - placeholder="Phone" - /> -
- ) : ( -
-
- {contact.name} - {contact.title && · {contact.title}} - {contact.is_primary && ( - - - Primary - - )} -
-
- {contact.email && {contact.email}} - {contact.phone && {contact.phone}} -
-
- )} -
- {editingId === contact.id ? ( - <> - - - - ) : ( - <> - {!contact.is_primary && ( - - )} - - - - )} -
-
- ))} -
- )} -
-
- - {showHistory && ( - - - - - Contact History - - - - {history.length === 0 ? ( -

No history yet

- ) : ( -
- {history.map(event => ( -
- {event.event_type} - {event.description} - - {new Date(event.created_at).toLocaleDateString('en-AU')} - -
- ))} -
- )} -
-
- )} -
- ); -} \ No newline at end of file diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..e997354 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,611 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + LayoutDashboard, + Users, + Briefcase, + FileText, + CreditCard, + Package, + HardDrive, + AlertCircle, + BarChart3, + Settings, + Store, + Shield, + Building2, + Key, + HelpCircle, + History, + Contact, +} from 'lucide-react'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { usePermissions } from '@/contexts/PermissionContext'; +import { supabase } from '@/integrations/supabase/client'; + +// Returns the platform-appropriate shortcut label for the Search button hint +// (⌘K on Mac, Ctrl K everywhere else). +export function getCommandShortcutLabel(): string { + if (typeof navigator === 'undefined') return 'Ctrl K'; + const platform = navigator.platform || navigator.userAgent || ''; + return /Mac|iPhone|iPad|iPod/i.test(platform) ? '⌘K' : 'Ctrl K'; +} + +interface NavPage { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + resource?: string; +} + +// Mirrors AppLayout's flat list of destinations (same icons, same resources) +// so the "Pages" group matches the sidebar exactly. +const pages: NavPage[] = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Clients', href: '/clients', icon: Users, resource: 'clients' }, + { name: 'Jobs', href: '/jobs', icon: Briefcase, resource: 'jobs' }, + { name: 'Issues', href: '/issues', icon: AlertCircle, resource: 'issues' }, + { name: 'Invoices', href: '/invoices', icon: FileText, resource: 'invoices' }, + { name: 'Payments', href: '/payments', icon: CreditCard, resource: 'payments' }, + { name: 'Banking', href: '/banking', icon: Building2, resource: 'banking' }, + { name: 'Vendors', href: '/vendors', icon: Store, resource: 'vendors' }, + { name: 'Inventory', href: '/inventory', icon: Package, resource: 'inventory' }, + { name: 'Assets', href: '/assets', icon: HardDrive, resource: 'assets' }, + { name: 'Locations', href: '/locations', icon: Building2, resource: 'locations' }, + { name: 'Knowledge Base', href: '/knowledge-base', icon: FileText, resource: 'kb' }, + { name: 'Reports', href: '/reports', icon: BarChart3, resource: 'reports' }, + { name: 'Activity Log', href: '/activity-log', icon: History, resource: 'reports' }, + { name: 'Team', href: '/team', icon: Users, resource: 'team' }, + { name: 'Roles', href: '/roles', icon: Shield, resource: 'settings' }, + { name: 'API Keys', href: '/api-keys', icon: Key, resource: 'settings' }, + { name: 'Settings', href: '/settings', icon: Settings, resource: 'settings' }, + { name: 'Help & Docs', href: '/docs', icon: HelpCircle }, +]; + +interface QuickAction { + name: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + resource: string; +} + +const quickActions: QuickAction[] = [ + { name: 'New Client', href: '/clients/new', icon: Users, resource: 'clients' }, + { name: 'New Job', href: '/jobs/new', icon: Briefcase, resource: 'jobs' }, + { name: 'New Invoice', href: '/invoices/new', icon: FileText, resource: 'invoices' }, + { name: 'Log Issue', href: '/issues/new', icon: AlertCircle, resource: 'issues' }, +]; + +interface ClientHit { + id: string; + name: string; + trading_name: string | null; +} +interface ContactHit { + id: string; + name: string; + email: string | null; +} +interface JobHit { + id: string; + name: string; + job_number: string; +} +interface InvoiceHit { + id: string; + invoice_number: string; + clients: { name: string } | null; +} +interface VendorHit { + id: string; + name: string; +} +interface ItemHit { + id: string; + name: string; + sku: string; +} +interface AssetHit { + id: string; + name: string; + asset_tag: string; +} +interface IssueHit { + id: string; + title: string; +} +interface KbArticleHit { + id: string; + title: string; +} +interface LocationHit { + id: string; + name: string; +} +interface BankAccountHit { + id: string; + name: string; +} +interface TeamHit { + id: string; + full_name: string | null; + email: string; +} + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const navigate = useNavigate(); + const { canRead, canWrite } = usePermissions(); + const [search, setSearch] = useState(''); + + // Global Ctrl/Cmd+K toggle. + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onOpenChange(!open); + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onOpenChange]); + + // Reset the query each time the palette closes so it reopens fresh. + useEffect(() => { + if (!open) setSearch(''); + }, [open]); + + const runCommand = (fn: () => void) => { + onOpenChange(false); + fn(); + }; + + const canReadClients = canRead('clients'); + const canReadJobs = canRead('jobs'); + const canReadInvoices = canRead('invoices'); + const canReadVendors = canRead('vendors'); + const canReadInventory = canRead('inventory'); + const canReadAssets = canRead('assets'); + const canReadIssues = canRead('issues'); + const canReadKb = canRead('kb'); + const canReadLocations = canRead('locations'); + const canReadBanking = canRead('banking'); + const canReadTeam = canRead('team'); + + const { data: clients = [] } = useQuery({ + queryKey: ['command-palette', 'clients'], + queryFn: async () => { + const { data, error } = await supabase + .from('clients') + .select('id, name, trading_name') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as ClientHit[]; + }, + enabled: open && canReadClients, + staleTime: 60_000, + }); + + const { data: contacts = [] } = useQuery({ + queryKey: ['command-palette', 'contacts'], + queryFn: async () => { + const { data, error } = await supabase + .from('contacts') + .select('id, name, email') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as ContactHit[]; + }, + enabled: open && canReadClients, + staleTime: 60_000, + }); + + const { data: jobs = [] } = useQuery({ + queryKey: ['command-palette', 'jobs'], + queryFn: async () => { + const { data, error } = await supabase + .from('jobs') + .select('id, name, job_number') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as JobHit[]; + }, + enabled: open && canReadJobs, + staleTime: 60_000, + }); + + const { data: invoices = [] } = useQuery({ + queryKey: ['command-palette', 'invoices'], + queryFn: async () => { + const { data, error } = await supabase + .from('invoices') + .select('id, invoice_number, clients(name)') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as unknown as InvoiceHit[]; + }, + enabled: open && canReadInvoices, + staleTime: 60_000, + }); + + const { data: vendors = [] } = useQuery({ + queryKey: ['command-palette', 'vendors'], + queryFn: async () => { + const { data, error } = await supabase + .from('vendors') + .select('id, name') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as VendorHit[]; + }, + enabled: open && canReadVendors, + staleTime: 60_000, + }); + + const { data: items = [] } = useQuery({ + queryKey: ['command-palette', 'items'], + queryFn: async () => { + const { data, error } = await supabase + .from('items') + .select('id, name, sku') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as ItemHit[]; + }, + enabled: open && canReadInventory, + staleTime: 60_000, + }); + + const { data: assets = [] } = useQuery({ + queryKey: ['command-palette', 'assets'], + queryFn: async () => { + const { data, error } = await supabase + .from('assets') + .select('id, name, asset_tag') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as AssetHit[]; + }, + enabled: open && canReadAssets, + staleTime: 60_000, + }); + + const { data: issues = [] } = useQuery({ + queryKey: ['command-palette', 'issues'], + queryFn: async () => { + const { data, error } = await supabase + .from('issues') + .select('id, title') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as IssueHit[]; + }, + enabled: open && canReadIssues, + staleTime: 60_000, + }); + + const { data: kbArticles = [] } = useQuery({ + queryKey: ['command-palette', 'kb_articles'], + queryFn: async () => { + const { data, error } = await supabase + .from('kb_articles') + .select('id, title') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as KbArticleHit[]; + }, + enabled: open && canReadKb, + staleTime: 60_000, + }); + + const { data: locations = [] } = useQuery({ + queryKey: ['command-palette', 'locations'], + queryFn: async () => { + const { data, error } = await supabase + .from('locations') + .select('id, name') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as LocationHit[]; + }, + enabled: open && canReadLocations, + staleTime: 60_000, + }); + + const { data: bankAccounts = [] } = useQuery({ + queryKey: ['command-palette', 'bank_accounts'], + queryFn: async () => { + const { data, error } = await supabase + .from('bank_accounts') + .select('id, name') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as BankAccountHit[]; + }, + enabled: open && canReadBanking, + staleTime: 60_000, + }); + + const { data: team = [] } = useQuery({ + queryKey: ['command-palette', 'profiles'], + queryFn: async () => { + const { data, error } = await supabase + .from('profiles') + .select('id, full_name, email') + .order('created_at', { ascending: false }) + .limit(300); + if (error) throw error; + return (data ?? []) as TeamHit[]; + }, + enabled: open && canReadTeam, + staleTime: 60_000, + }); + + const hasQuery = search.trim().length > 0; + + return ( + + Command Palette + + Search pages and records, or run a quick action. + + + + No results found. + + + {quickActions + .filter(action => canWrite(action.resource)) + .map(action => ( + runCommand(() => navigate(action.href))} + > + + {action.name} + + ))} + + + + {pages + .filter(page => !page.resource || canRead(page.resource)) + .map(page => ( + runCommand(() => navigate(page.href))} + > + + {page.name} + + ))} + + + {hasQuery && canReadClients && clients.length > 0 && ( + + {clients.map(client => ( + runCommand(() => navigate(`/clients/${client.id}`))} + > + + {client.name} + {client.trading_name && ( + {client.trading_name} + )} + + ))} + + )} + + {hasQuery && canReadClients && contacts.length > 0 && ( + + {contacts.map(contact => ( + runCommand(() => navigate(`/contacts/${contact.id}`))} + > + + {contact.name} + {contact.email && ( + {contact.email} + )} + + ))} + + )} + + {hasQuery && canReadJobs && jobs.length > 0 && ( + + {jobs.map(job => ( + runCommand(() => navigate(`/jobs/${job.id}`))} + > + + {job.name} + {job.job_number} + + ))} + + )} + + {hasQuery && canReadInvoices && invoices.length > 0 && ( + + {invoices.map(invoice => ( + runCommand(() => navigate(`/invoices/${invoice.id}`))} + > + + {invoice.invoice_number} + {invoice.clients?.name && ( + {invoice.clients.name} + )} + + ))} + + )} + + {hasQuery && canReadVendors && vendors.length > 0 && ( + + {vendors.map(vendor => ( + runCommand(() => navigate(`/vendors/${vendor.id}`))} + > + + {vendor.name} + + ))} + + )} + + {hasQuery && canReadInventory && items.length > 0 && ( + + {items.map(item => ( + runCommand(() => navigate(`/inventory/${item.id}`))} + > + + {item.name} + {item.sku} + + ))} + + )} + + {hasQuery && canReadAssets && assets.length > 0 && ( + + {assets.map(asset => ( + runCommand(() => navigate(`/assets/${asset.id}`))} + > + + {asset.name} + {asset.asset_tag} + + ))} + + )} + + {hasQuery && canReadIssues && issues.length > 0 && ( + + {issues.map(issue => ( + runCommand(() => navigate(`/issues/${issue.id}`))} + > + + {issue.title} + + ))} + + )} + + {hasQuery && canReadKb && kbArticles.length > 0 && ( + + {kbArticles.map(article => ( + runCommand(() => navigate(`/knowledge-base/${article.id}`))} + > + + {article.title} + + ))} + + )} + + {hasQuery && canReadLocations && locations.length > 0 && ( + + {locations.map(loc => ( + runCommand(() => navigate(`/locations/${loc.id}`))} + > + + {loc.name} + + ))} + + )} + + {hasQuery && canReadBanking && bankAccounts.length > 0 && ( + + {bankAccounts.map(account => ( + runCommand(() => navigate(`/banking/${account.id}`))} + > + + {account.name} + + ))} + + )} + + {hasQuery && canReadTeam && team.length > 0 && ( + + {team.map(member => ( + runCommand(() => navigate(`/team/${member.id}`))} + > + + {member.full_name || member.email} + {member.full_name && ( + {member.email} + )} + + ))} + + )} + + + ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..c12d475 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,117 @@ +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export interface ConfirmOptions { + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + /** Style the confirm button as a destructive action (e.g. delete/remove). */ + destructive?: boolean; +} + +interface QueuedConfirm extends ConfirmOptions { + resolve: (value: boolean) => void; +} + +type ConfirmFn = (options: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(null); + +/** + * Renders a single shadcn AlertDialog and exposes a promise-based `confirm()` + * function via context. Requests made while a dialog is already open are + * queued and shown one after another. + */ +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [request, setRequest] = useState(null); + const queueRef = useRef([]); + const activeRef = useRef(null); + const confirmedRef = useRef(false); + + const showNext = useCallback(() => { + const next = queueRef.current.shift() ?? null; + activeRef.current = next; + confirmedRef.current = false; + setRequest(next); + setOpen(next !== null); + }, []); + + const confirm = useCallback((options) => { + return new Promise((resolve) => { + queueRef.current.push({ ...options, resolve }); + if (!activeRef.current) { + showNext(); + } + }); + }, [showNext]); + + const handleOpenChange = useCallback((next: boolean) => { + if (next) { + setOpen(true); + return; + } + const finished = activeRef.current; + activeRef.current = null; + finished?.resolve(confirmedRef.current); + setOpen(false); + // Let the close animation finish before presenting the next dialog. + window.setTimeout(showNext, 150); + }, [showNext]); + + const handleConfirmClick = useCallback(() => { + confirmedRef.current = true; + }, []); + + return ( + + {children} + + + + {request?.title} + {request?.description && ( + + {request.description} + + )} + + + {request?.cancelLabel ?? 'Cancel'} + + {request?.confirmLabel ?? 'Confirm'} + + + + + + ); +} + +/** + * Returns a function to open a confirm dialog: `await confirm({ title, ... })`. + * Resolves `true` when the user confirms, `false` on cancel or dismiss + * (Escape key, etc). Must be called from within a `ConfirmProvider`. + */ +export function useConfirm(): ConfirmFn { + const ctx = useContext(ConfirmContext); + if (!ctx) { + throw new Error('useConfirm must be used within a ConfirmProvider'); + } + return ctx; +} diff --git a/src/components/EditPurchaseDialog.tsx b/src/components/EditPurchaseDialog.tsx index ae971b2..ec1c055 100644 --- a/src/components/EditPurchaseDialog.tsx +++ b/src/components/EditPurchaseDialog.tsx @@ -14,6 +14,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Checkbox } from '@/components/ui/checkbox'; import { Upload, X, Plus, Trash2 } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; +import { useBranding } from '@/contexts/BrandingContext'; +import { todayLocal } from '@/lib/dates'; import { uuid } from '@/lib/utils'; import type { Tables } from '@/integrations/supabase/types'; @@ -56,6 +58,7 @@ interface EditPurchaseDialogProps { export default function EditPurchaseDialog({ open, onOpenChange, onSuccess, purchase }: EditPurchaseDialogProps) { const { toast } = useToast(); + const { formatCurrency } = useBranding(); const fileInputRef = useRef(null); const [saving, setSaving] = useState(false); @@ -67,7 +70,7 @@ export default function EditPurchaseDialog({ open, onOpenChange, onSuccess, purc const [defaultGstRate, setDefaultGstRate] = useState(10); const [editData, setEditData] = useState({ - date: new Date().toISOString().split('T')[0], + date: todayLocal(), vendor_id: '', vendor_name: '', description: '', @@ -630,7 +633,7 @@ export default function EditPurchaseDialog({ open, onOpenChange, onSuccess, purc ))}
- Allocated: ${allocations.reduce((sum, a) => sum + a.amount, 0).toFixed(2)} / Total: ${total.toFixed(2)} + Allocated: {formatCurrency(allocations.reduce((sum, a) => sum + a.amount, 0))} / Total: {formatCurrency(total)}
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..cfc07fd --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description?: string; + action?: ReactNode; + className?: string; +} + +export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ +

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ); +} diff --git a/src/components/JobHistory.tsx b/src/components/JobHistory.tsx index d0925b1..e35e775 100644 --- a/src/components/JobHistory.tsx +++ b/src/components/JobHistory.tsx @@ -2,10 +2,12 @@ import { useEffect, useState } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { - Clock, FileText, DollarSign, Package, AlertCircle, - Wrench, Users, TrendingUp, History +import { + Clock, FileText, DollarSign, Package, AlertCircle, + Wrench, Users, TrendingUp, History } from 'lucide-react'; +import { useBranding } from '@/contexts/BrandingContext'; +import { parseDateOnly } from '@/lib/dates'; interface HistoryEvent { id: string; @@ -23,6 +25,7 @@ interface JobHistoryProps { } export default function JobHistory({ jobId }: JobHistoryProps) { + const { formatCurrency } = useBranding(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -70,7 +73,7 @@ export default function JobHistory({ jobId }: JobHistoryProps) { id: `exp-${exp.id}`, type: 'expense', date: exp.date, - title: `Expense: $${exp.amount}`, + title: `Expense: ${formatCurrency(exp.amount)}`, description: exp.description, amount: exp.amount, metadata: exp, @@ -84,7 +87,7 @@ export default function JobHistory({ jobId }: JobHistoryProps) { type: 'invoice', date: inv.issue_date, title: `Invoice: ${inv.invoice_number}`, - description: `Total: $${inv.total}`, + description: `Total: ${formatCurrency(inv.total)}`, amount: inv.total, status: inv.status, metadata: inv, @@ -98,7 +101,7 @@ export default function JobHistory({ jobId }: JobHistoryProps) { type: 'job_asset', date: ja.rental_start_date, title: `Asset Added: ${(ja as any).assets?.name || 'Asset'}`, - description: `Rate: $${ja.rental_rate}/${ja.billing_frequency}`, + description: `Rate: ${formatCurrency(ja.rental_rate)}/${ja.billing_frequency}`, amount: ja.rental_rate, status: ja.is_active ? 'active' : 'ended', metadata: ja, @@ -174,13 +177,13 @@ export default function JobHistory({ jobId }: JobHistoryProps) { const getTypeColor = (type: string) => { switch (type) { - case 'timesheet': return 'bg-blue-100 text-blue-700'; - case 'expense': return 'bg-amber-100 text-amber-700'; - case 'invoice': return 'bg-green-100 text-green-700'; - case 'job_asset': return 'bg-purple-100 text-purple-700'; - case 'issue': return 'bg-red-100 text-red-700'; - case 'inventory': return 'bg-cyan-100 text-cyan-700'; - default: return 'bg-gray-100 text-gray-700'; + case 'timesheet': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; + case 'expense': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'; + case 'invoice': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'; + case 'job_asset': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'; + case 'issue': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'; + case 'inventory': return 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'; + default: return 'bg-muted text-muted-foreground'; } }; @@ -223,8 +226,8 @@ export default function JobHistory({ jobId }: JobHistoryProps) {

{event.description}

- {new Date(event.date).toLocaleDateString('en-AU', { - day: 'numeric', month: 'short', year: 'numeric' + {parseDateOnly(event.date.slice(0, 10)).toLocaleDateString('en-AU', { + day: 'numeric', month: 'short', year: 'numeric' })}

diff --git a/src/components/MakePaymentDialog.tsx b/src/components/MakePaymentDialog.tsx index 3a46586..d21230e 100644 --- a/src/components/MakePaymentDialog.tsx +++ b/src/components/MakePaymentDialog.tsx @@ -14,6 +14,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Checkbox } from '@/components/ui/checkbox'; import { Upload, X, Plus, Trash2, FileSpreadsheet } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; +import { useBranding } from '@/contexts/BrandingContext'; +import { todayLocal } from '@/lib/dates'; import { uuid } from '@/lib/utils'; import ImportBillCSVDialog, { ImportedAllocation } from '@/components/purchases/ImportBillCSVDialog'; @@ -52,6 +54,7 @@ interface MakePaymentDialogProps { export default function MakePaymentDialog({ open, onOpenChange, onSuccess }: MakePaymentDialogProps) { const { toast } = useToast(); + const { formatCurrency } = useBranding(); const fileInputRef = useRef(null); const [saving, setSaving] = useState(false); @@ -65,7 +68,7 @@ export default function MakePaymentDialog({ open, onOpenChange, onSuccess }: Mak const [defaultGstRate, setDefaultGstRate] = useState(10); // Default 10% GST const [purchase, setPurchase] = useState({ - date: new Date().toISOString().split('T')[0], + date: todayLocal(), vendor_id: '', vendor_name: '', description: '', @@ -322,7 +325,7 @@ export default function MakePaymentDialog({ open, onOpenChange, onSuccess }: Mak // Reset form setPurchase({ - date: new Date().toISOString().split('T')[0], + date: todayLocal(), vendor_id: '', vendor_name: '', description: '', @@ -355,7 +358,7 @@ export default function MakePaymentDialog({ open, onOpenChange, onSuccess }: Mak
- Make Payment / Record Expense + Pay Vendor / Record Expense
diff --git a/src/components/PrimaryContactSelector.tsx b/src/components/PrimaryContactSelector.tsx new file mode 100644 index 0000000..c7b8d66 --- /dev/null +++ b/src/components/PrimaryContactSelector.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { supabase } from '@/integrations/supabase/client'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import type { Tables } from '@/integrations/supabase/types'; + +type Contact = Tables<'contacts'>; +type ContactAffiliation = Tables<'contact_affiliations'> & { contacts: Contact | null }; + +interface PrimaryContactSelectorProps { + entityType: 'client' | 'vendor'; + entityId: string; +} + +/** + * "Who's the primary contact for this client/vendor" panel: a Select of + * every *currently* affiliated person (end_date null), backed directly by + * contact_affiliations.is_primary (clear-then-set, scoped to this entity). + * Choosing "None" just clears the current primary without picking a new one. + */ +export default function PrimaryContactSelector({ entityType, entityId }: PrimaryContactSelectorProps) { + const { toast } = useToast(); + const entityColumn = entityType === 'client' ? 'client_id' : 'vendor_id'; + + const [affiliations, setAffiliations] = useState([]); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + fetchAffiliations(); + }, [entityType, entityId]); + + async function fetchAffiliations() { + const { data, error } = await supabase + .from('contact_affiliations') + .select('*, contacts(*)') + .eq(entityColumn, entityId) + .is('end_date', null) + .order('is_primary', { ascending: false }); + + if (error) { + console.error('Error fetching affiliated contacts:', error); + } + setAffiliations((data || []) as ContactAffiliation[]); + setLoading(false); + } + + const primary = affiliations.find((a) => a.is_primary); + + async function handleChange(value: string) { + setUpdating(true); + + // Clear-then-set, scoped to this entity only - a person can still be + // primary elsewhere at the same time (overlapping affiliations, e.g. a + // contractor at more than one client), so this never touches other orgs. + await supabase + .from('contact_affiliations') + .update({ is_primary: false }) + .eq(entityColumn, entityId) + .eq('is_primary', true) + .is('end_date', null); + + if (value !== 'none') { + const { error } = await supabase + .from('contact_affiliations') + .update({ is_primary: true }) + .eq('id', value); + + if (error) { + toast({ title: 'Error', description: error.message, variant: 'destructive' }); + setUpdating(false); + await fetchAffiliations(); + return; + } + } + + toast({ title: 'Success', description: 'Primary contact updated' }); + await fetchAffiliations(); + setUpdating(false); + } + + if (loading) { + return ( +
+ +
+
+ ); + } + + return ( +
+ + {affiliations.length > 0 ? ( + + ) : ( +

+ No contacts affiliated yet. Add contacts in the Contacts tab. +

+ )} + {primary?.contacts && ( +
+ + {primary.contacts.name} + + {primary.contacts.email &&

{primary.contacts.email}

} + {primary.contacts.phone &&

{primary.contacts.phone}

} +
+ )} +
+ ); +} diff --git a/src/components/RecordPaymentDialog.tsx b/src/components/RecordPaymentDialog.tsx new file mode 100644 index 0000000..b25da7f --- /dev/null +++ b/src/components/RecordPaymentDialog.tsx @@ -0,0 +1,436 @@ +import { useEffect, useState } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/hooks/use-toast'; +import { useBranding } from '@/contexts/BrandingContext'; +import { todayLocal } from '@/lib/dates'; +import type { Tables } from '@/integrations/supabase/types'; + +type Invoice = Tables<'invoices'> & { clients?: { name: string } | null }; + +interface RecordPaymentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultInvoiceId?: string; + onSuccess?: () => void; +} + +const emptyPayment = () => ({ + invoice_id: '', + amount: 0, + date: todayLocal(), + method: '', + reference: '', + notes: '', + bank_account_id: '', +}); + +export default function RecordPaymentDialog({ open, onOpenChange, defaultInvoiceId, onSuccess }: RecordPaymentDialogProps) { + const { toast } = useToast(); + const { formatCurrency } = useBranding(); + const [loading, setLoading] = useState(false); + const [invoices, setInvoices] = useState([]); + const [bankAccounts, setBankAccounts] = useState<{ id: string; name: string; current_balance: number }[]>([]); + const [clientCredit, setClientCredit] = useState(0); + const [creditToApply, setCreditToApply] = useState(0); + const [overpaidInvoices, setOverpaidInvoices] = useState<{ id: string; overpayment: number }[]>([]); + const [newPayment, setNewPayment] = useState(emptyPayment()); + + useEffect(() => { + if (open) { + fetchData(); + } else { + // Reset form state when the dialog closes + setNewPayment(emptyPayment()); + setClientCredit(0); + setCreditToApply(0); + setOverpaidInvoices([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + async function fetchData() { + setLoading(true); + const [invoicesRes, bankRes] = await Promise.all([ + supabase + .from('invoices') + .select('*, clients(name)') + .in('status', ['sent', 'partially_paid', 'overdue']) + .order('invoice_number'), + supabase + .from('bank_accounts') + .select('id, name, current_balance') + .eq('is_active', true) + .order('name'), + ]); + + const payableInvoices = invoicesRes.data || []; + setInvoices(payableInvoices); + setBankAccounts(bankRes.data || []); + setLoading(false); + + if (defaultInvoiceId && payableInvoices.some((inv) => inv.id === defaultInvoiceId)) { + handleInvoiceSelect(defaultInvoiceId, payableInvoices); + } + } + + async function fetchClientCredit(clientId: string) { + // Calculate credit from overpaid invoices (amount_paid > total on paid invoices) + const { data: clientInvoices } = await supabase + .from('invoices') + .select('id, total, amount_paid, status') + .eq('client_id', clientId) + .in('status', ['paid', 'partially_paid', 'sent', 'overdue']); + + if (!clientInvoices) { + setClientCredit(0); + return { credit: 0, overpaidInvoices: [] as { id: string; overpayment: number }[] }; + } + + // Find overpaid invoices and calculate total credit + const overpaid = clientInvoices + .filter((inv) => (inv.amount_paid || 0) > (inv.total || 0)) + .map((inv) => ({ + id: inv.id, + overpayment: (inv.amount_paid || 0) - (inv.total || 0), + })); + + const credit = overpaid.reduce((sum, inv) => sum + inv.overpayment, 0); + + setClientCredit(credit); + return { credit, overpaidInvoices: overpaid }; + } + + async function handleInvoiceSelect(invoiceId: string, invoiceList: Invoice[] = invoices) { + const invoice = invoiceList.find((i) => i.id === invoiceId); + if (!invoice) return; + + const amountDue = invoice.total - invoice.amount_paid; + + // Fetch client credit and overpaid invoices + const result = await fetchClientCredit(invoice.client_id); + setOverpaidInvoices(result.overpaidInvoices); + + // Auto-apply credit (capped at amount due) + const creditApplied = Math.min(result.credit, amountDue); + setCreditToApply(creditApplied); + + // Set payment amount to remaining after credit + const paymentAmount = amountDue - creditApplied; + + setNewPayment((prev) => ({ + ...prev, + invoice_id: invoiceId, + amount: paymentAmount, + })); + } + + async function handleAddPayment() { + if (!newPayment.invoice_id) { + toast({ title: 'Error', description: 'Invoice is required', variant: 'destructive' }); + return; + } + + const totalApplied = newPayment.amount + creditToApply; + if (totalApplied <= 0) { + toast({ title: 'Error', description: 'Total payment (cash + credit) must be greater than 0', variant: 'destructive' }); + return; + } + + const invoice = invoices.find((i) => i.id === newPayment.invoice_id); + if (!invoice) return; + + // If credit is being applied, reduce the overpaid invoices' amount_paid + if (creditToApply > 0) { + let remainingCreditToDeduct = creditToApply; + + // Deduct credit from overpaid invoices (FIFO order) + for (const overpaidInv of overpaidInvoices) { + if (remainingCreditToDeduct <= 0) break; + + const deductFromThis = Math.min(overpaidInv.overpayment, remainingCreditToDeduct); + + // Get current amount_paid for this invoice + const { data: currentInv } = await supabase + .from('invoices') + .select('amount_paid, total') + .eq('id', overpaidInv.id) + .single(); + + if (currentInv) { + const newAmountPaid = currentInv.amount_paid - deductFromThis; + const newStatus = newAmountPaid >= currentInv.total ? 'paid' : 'partially_paid'; + + await supabase + .from('invoices') + .update({ amount_paid: newAmountPaid, status: newStatus }) + .eq('id', overpaidInv.id); + } + + remainingCreditToDeduct -= deductFromThis; + } + + // Record the credit payment on the target invoice + const { data: userData } = await supabase.auth.getUser(); + const { error: creditError } = await supabase.from('payments').insert({ + invoice_id: newPayment.invoice_id, + amount: creditToApply, + date: newPayment.date, + method: 'credit', + reference: 'Credit applied from overpayment', + notes: 'Automatic credit application', + created_by: userData.user?.id, + }); + + if (creditError) { + toast({ title: 'Error', description: creditError.message, variant: 'destructive' }); + return; + } + } + + // If there's a cash payment component, record it + if (newPayment.amount > 0) { + const { data: userData } = await supabase.auth.getUser(); + const { error } = await supabase.from('payments').insert({ + invoice_id: newPayment.invoice_id, + amount: newPayment.amount, + date: newPayment.date, + method: newPayment.method, + reference: newPayment.reference, + notes: newPayment.notes, + created_by: userData.user?.id, + }); + + if (error) { + toast({ title: 'Error', description: error.message, variant: 'destructive' }); + return; + } + + // Update bank account balance if selected + if (newPayment.bank_account_id) { + const bankAccount = bankAccounts.find((b) => b.id === newPayment.bank_account_id); + if (bankAccount) { + await supabase + .from('bank_accounts') + .update({ current_balance: bankAccount.current_balance + newPayment.amount }) + .eq('id', newPayment.bank_account_id); + } + } + } + + // Update invoice amount_paid and status + const newAmountPaid = invoice.amount_paid + totalApplied; + const newStatus = newAmountPaid >= invoice.total ? 'paid' : 'partially_paid'; + + await supabase + .from('invoices') + .update({ amount_paid: newAmountPaid, status: newStatus }) + .eq('id', newPayment.invoice_id); + + toast({ title: 'Success', description: 'Payment recorded' }); + setNewPayment(emptyPayment()); + setClientCredit(0); + setCreditToApply(0); + setOverpaidInvoices([]); + onOpenChange(false); + onSuccess?.(); + } + + const defaultInvoiceInvalid = !loading && !!defaultInvoiceId && !invoices.some((inv) => inv.id === defaultInvoiceId); + const noPayableInvoices = !loading && invoices.length === 0; + const showEmptyState = !loading && (defaultInvoiceInvalid || noPayableInvoices); + const emptyStateMessage = defaultInvoiceInvalid + ? "This invoice isn't awaiting payment." + : 'No invoices awaiting payment. Draft invoices must be marked as Sent before a payment can be recorded.'; + + const selectedInvoice = invoices.find((i) => i.id === newPayment.invoice_id); + + return ( + + + + Record Payment Received + +
+ {showEmptyState ? ( +

+ {emptyStateMessage} +

+ ) : ( +
+ + +
+ )} + + {/* Credit section - only show if there's credit available */} + {newPayment.invoice_id && clientCredit > 0 && ( +
+
+ + Available Credit + + + {formatCurrency(clientCredit)} + +
+
+ + { + if (!selectedInvoice) return; + const amountDue = selectedInvoice.total - selectedInvoice.amount_paid; + const newCredit = Math.min(parseFloat(e.target.value) || 0, clientCredit, amountDue); + setCreditToApply(newCredit); + // Adjust payment amount accordingly + setNewPayment((prev) => ({ ...prev, amount: amountDue - newCredit })); + }} + /> +

+ Set to 0 to pay the full amount without using credit +

+
+
+ )} + +
+
+ + setNewPayment({ ...newPayment, amount: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setNewPayment({ ...newPayment, date: e.target.value })} + /> +
+
+ + {/* Payment summary */} + {newPayment.invoice_id && ( +
+
+ Amount Due: + {formatCurrency((selectedInvoice?.total || 0) - (selectedInvoice?.amount_paid || 0))} +
+ {creditToApply > 0 && ( +
+ Credit Applied: + -{formatCurrency(creditToApply)} +
+ )} +
+ Cash/Bank Payment: + {formatCurrency(newPayment.amount)} +
+
+ Total Applied: + {formatCurrency(newPayment.amount + creditToApply)} +
+
+ )} + +
+
+ + +
+
+ + setNewPayment({ ...newPayment, reference: e.target.value })} + /> +
+
+ + {/* Bank Account Selection */} + {bankAccounts.length > 0 && ( +
+ + +

+ Select to automatically update the bank balance +

+
+ )} + +
+ +