From 6d7b4672ceb9cac3ee8b8aa77858d874127ea83b Mon Sep 17 00:00:00 2001 From: Corianas Date: Thu, 9 Jul 2026 19:20:28 +0800 Subject: [PATCH 01/28] Add OpenAPI spec and an MCP server for the external API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the scoped external API (/api/external) usable by AI agents and codegen tools. - openapi.yaml: OpenAPI 3.1 contract for /api/external — bearer API-key auth, the resource+id paths, CRUD operations, response/error shapes, the full resource enum, the resource→permission-group mapping, and request/response examples. Validates clean. Linked from api-examples.md. - mcp-server/: a local stdio MCP server (TypeScript) wrapping the API so an agent can list/get/create/update/delete records through an API key. Six tools (list_resources, list_records, get_record, create_record, update_record, delete_record) with proper read-only/destructive/ idempotent annotations and actionable error mapping (401/403/400/404). Config via CLIENTFLOW_API_URL/CLIENTFLOW_API_KEY; refuses to start without them and never prints the key. Includes README with a Claude Desktop config block and unit tests for the api-client (9 passing). All access remains gated server-side by the key's scopes and the owning user's role — this adds no privileges. No changes to existing app behavior. Co-Authored-By: Claude Fable 5 --- api-examples.md | 9 + mcp-server/.gitignore | 4 + mcp-server/README.md | 93 +++ mcp-server/package-lock.json | 1215 +++++++++++++++++++++++++++++ mcp-server/package.json | 29 + mcp-server/src/api-client.test.ts | 64 ++ mcp-server/src/api-client.ts | 156 ++++ mcp-server/src/index.ts | 217 ++++++ mcp-server/src/resources.ts | 65 ++ mcp-server/tsconfig.json | 19 + openapi.yaml | 549 +++++++++++++ 11 files changed, 2420 insertions(+) create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/README.md create mode 100644 mcp-server/package-lock.json create mode 100644 mcp-server/package.json create mode 100644 mcp-server/src/api-client.test.ts create mode 100644 mcp-server/src/api-client.ts create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/resources.ts create mode 100644 mcp-server/tsconfig.json create mode 100644 openapi.yaml 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/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 From 19b0912971a5610027184f0d27fc3d8b3f3efb39 Mon Sep 17 00:00:00 2001 From: Corianas Date: Thu, 9 Jul 2026 19:25:33 +0800 Subject: [PATCH 02/28] Housekeeping: drop dead Supabase/bun leftovers, fix scripts, add CLAUDE.md - package.json: rename vite_react_shadcn_ts -> client-flow, version 0.1.0 - remove @supabase/supabase-js (unused; the app uses the local client shim) - make db:reset cross-platform (node fs.rmSync instead of `rm -f`) - delete the dead supabase/ directory (Deno edge functions + migrations superseded by server/routes/ and server/db/) and the stale bun.lockb (the project uses npm) - add a tracked CLAUDE.md documenting architecture, the security model, numbering, conventions, and gotchas (AGENTS.md is gitignored, so its guidance did not travel with the repo) Build and the 23-test suite still pass. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 116 +++ bun.lockb | Bin 198722 -> 0 bytes package-lock.json | 134 +-- package.json | 7 +- supabase/config.toml | 13 - supabase/functions/api/index.ts | 345 -------- supabase/functions/bill-import/index.ts | 813 ------------------ .../functions/generate-invoice-pdf/index.ts | 357 -------- .../functions/generate-job-invoices/index.ts | 391 --------- ...9_318f3bdb-860d-46d5-932c-db0557da2a3f.sql | 10 - ...9_4f43bc21-3af1-46f2-893d-28102000f079.sql | 2 - ...2_c431535a-1d45-4d53-b09f-42b3e6743d7c.sql | 14 - ...8_37cd5dde-8e15-415c-988e-cd5b8c179e0b.sql | 28 - ...3_7c9e6d32-b00c-44e8-9193-5bbbab30d5ea.sql | 42 - ...6_df067dc0-4823-413f-82c0-c356a2d704c8.sql | 94 -- ...5_f8c210e5-403d-481d-a80e-33c38befd203.sql | 6 - ...9_eaad9aff-4e47-4e4f-8e93-4c4e3ea61f58.sql | 13 - ...0_1fcdff72-7a96-463c-aa01-6b5b0ad1bc58.sql | 57 -- ...0_77f7bade-3d64-4ad4-91e9-24eb24bee6d9.sql | 13 - ...3_bdcbbf2c-702d-46e6-a755-178f55abe4d5.sql | 38 - ...6_4a77b6e0-4c95-44a5-b3eb-5543f9ca29ff.sql | 27 - ...5_3c281695-2c96-437d-8267-d5abb37ace79.sql | 200 ----- ...9_791aaf2f-2009-41de-bd30-62aac6b87677.sql | 290 ------- ...0_91ca73aa-c2df-46e5-9665-973292f39463.sql | 93 -- ...7_51b9410b-08cd-4df3-b5ad-1b0cde0abe37.sql | 90 -- ...5_08077ead-85d3-4435-b78b-b56d10a5268e.sql | 141 --- ...7_b1cd77ec-04ea-4d0f-bf3f-ef32b79347cf.sql | 124 --- ...3_92e6f7d0-0209-45e7-8f62-49071906351c.sql | 202 ----- ...7_9a28dbdb-fc59-4deb-afe6-039ad0cef6d9.sql | 4 - ...4_cf35c81a-06f9-4756-992c-103c9df5a0ac.sql | 3 - ...3_d3b55249-6457-4898-905b-3c2b82157825.sql | 123 --- ...1_01ce507f-63ee-4ef7-8070-4ff40d31c88d.sql | 285 ------ ...8_df2fe27f-c55a-4dab-86a6-8629f76d5680.sql | 86 -- ...4_13e50e55-f366-442a-8a08-caf153ec04c5.sql | 55 -- ...9_11a950d1-6705-472b-b402-fa0cf4f1d0e2.sql | 62 -- ...1_65deee7f-50bb-46cc-ac05-992bc5cc113b.sql | 40 - ...2_24322e3c-5559-49d4-9193-5e76b3a0e94e.sql | 1 - ...9_87cf3849-ff70-4d42-84ba-b17c1f57c22c.sql | 16 - ...1_3fdd6601-19a6-41b3-8370-cb9acd8b527a.sql | 11 - 39 files changed, 123 insertions(+), 4223 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 bun.lockb delete mode 100644 supabase/config.toml delete mode 100644 supabase/functions/api/index.ts delete mode 100644 supabase/functions/bill-import/index.ts delete mode 100644 supabase/functions/generate-invoice-pdf/index.ts delete mode 100644 supabase/functions/generate-job-invoices/index.ts delete mode 100644 supabase/migrations/20251227095709_318f3bdb-860d-46d5-932c-db0557da2a3f.sql delete mode 100644 supabase/migrations/20251227112509_4f43bc21-3af1-46f2-893d-28102000f079.sql delete mode 100644 supabase/migrations/20251227122712_c431535a-1d45-4d53-b09f-42b3e6743d7c.sql delete mode 100644 supabase/migrations/20251227202838_37cd5dde-8e15-415c-988e-cd5b8c179e0b.sql delete mode 100644 supabase/migrations/20251227211203_7c9e6d32-b00c-44e8-9193-5bbbab30d5ea.sql delete mode 100644 supabase/migrations/20251227214026_df067dc0-4823-413f-82c0-c356a2d704c8.sql delete mode 100644 supabase/migrations/20251227220055_f8c210e5-403d-481d-a80e-33c38befd203.sql delete mode 100644 supabase/migrations/20251227222909_eaad9aff-4e47-4e4f-8e93-4c4e3ea61f58.sql delete mode 100644 supabase/migrations/20251227224410_1fcdff72-7a96-463c-aa01-6b5b0ad1bc58.sql delete mode 100644 supabase/migrations/20251227225150_77f7bade-3d64-4ad4-91e9-24eb24bee6d9.sql delete mode 100644 supabase/migrations/20251227235353_bdcbbf2c-702d-46e6-a755-178f55abe4d5.sql delete mode 100644 supabase/migrations/20251228000636_4a77b6e0-4c95-44a5-b3eb-5543f9ca29ff.sql delete mode 100644 supabase/migrations/20260102061415_3c281695-2c96-437d-8267-d5abb37ace79.sql delete mode 100644 supabase/migrations/20260102061549_791aaf2f-2009-41de-bd30-62aac6b87677.sql delete mode 100644 supabase/migrations/20260102072900_91ca73aa-c2df-46e5-9665-973292f39463.sql delete mode 100644 supabase/migrations/20260102084127_51b9410b-08cd-4df3-b5ad-1b0cde0abe37.sql delete mode 100644 supabase/migrations/20260102091335_08077ead-85d3-4435-b78b-b56d10a5268e.sql delete mode 100644 supabase/migrations/20260102092237_b1cd77ec-04ea-4d0f-bf3f-ef32b79347cf.sql delete mode 100644 supabase/migrations/20260102094723_92e6f7d0-0209-45e7-8f62-49071906351c.sql delete mode 100644 supabase/migrations/20260103031907_9a28dbdb-fc59-4deb-afe6-039ad0cef6d9.sql delete mode 100644 supabase/migrations/20260103033024_cf35c81a-06f9-4756-992c-103c9df5a0ac.sql delete mode 100644 supabase/migrations/20260103044313_d3b55249-6457-4898-905b-3c2b82157825.sql delete mode 100644 supabase/migrations/20260103050111_01ce507f-63ee-4ef7-8070-4ff40d31c88d.sql delete mode 100644 supabase/migrations/20260103053638_df2fe27f-c55a-4dab-86a6-8629f76d5680.sql delete mode 100644 supabase/migrations/20260103110024_13e50e55-f366-442a-8a08-caf153ec04c5.sql delete mode 100644 supabase/migrations/20260103124909_11a950d1-6705-472b-b402-fa0cf4f1d0e2.sql delete mode 100644 supabase/migrations/20260103132131_65deee7f-50bb-46cc-ac05-992bc5cc113b.sql delete mode 100644 supabase/migrations/20260103143302_24322e3c-5559-49d4-9193-5e76b3a0e94e.sql delete mode 100644 supabase/migrations/20260103152759_87cf3849-ff70-4d42-84ba-b17c1f57c22c.sql delete mode 100644 supabase/migrations/20260104010611_3fdd6601-19a6-41b3-8370-cb9acd8b527a.sql 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/bun.lockb b/bun.lockb deleted file mode 100644 index 495270568c33b979477cfd3b4f8a96076eb4d25b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 198722 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�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 G-~R`F03^`> diff --git a/package-lock.json b/package-lock.json index b94647f..c6f60a7 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", @@ -3922,86 +3921,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", @@ -4501,12 +4420,6 @@ "@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/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -4601,15 +4514,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", @@ -7192,15 +7096,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", @@ -11162,27 +11057,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..ceb61c8 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", diff --git a/supabase/config.toml b/supabase/config.toml deleted file mode 100644 index 4ede033..0000000 --- a/supabase/config.toml +++ /dev/null @@ -1,13 +0,0 @@ -project_id = "tmwetaohthghggimcjxb" - -[functions.generate-job-invoices] -verify_jwt = false - -[functions.generate-invoice-pdf] -verify_jwt = false - -[functions.api] -verify_jwt = false - -[functions.bill-import] -verify_jwt = false diff --git a/supabase/functions/api/index.ts b/supabase/functions/api/index.ts deleted file mode 100644 index 8f7d6a2..0000000 --- a/supabase/functions/api/index.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; -import { createClient } from "jsr:@supabase/supabase-js@2"; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -// Supported resources and their table mappings -const RESOURCE_MAP: Record = { - 'clients': { table: 'clients', resource: 'clients' }, - 'jobs': { table: 'jobs', resource: 'jobs' }, - 'invoices': { table: 'invoices', resource: 'invoices' }, - 'payments': { table: 'payments', resource: 'payments' }, - 'assets': { table: 'assets', resource: 'assets' }, - 'issues': { table: 'issues', resource: 'issues' }, - 'vendors': { table: 'vendors', resource: 'vendors' }, - 'items': { table: 'items', resource: 'items' }, - 'expenses': { table: 'expenses', resource: 'expenses' }, - 'timesheets': { table: 'timesheets', resource: 'timesheets' }, - 'bank-accounts': { table: 'bank_accounts', resource: 'banking' }, - 'bank-transactions': { table: 'bank_transactions', resource: 'banking' }, - 'profiles': { table: 'profiles', resource: 'team' }, - 'kb-articles': { table: 'kb_articles', resource: 'knowledge_base' }, - 'kb-attachments': { table: 'kb_attachments', resource: 'knowledge_base' }, - 'kb-article-issues': { table: 'kb_article_issues', resource: 'knowledge_base' }, - 'locations': { table: 'locations', resource: 'locations' }, - 'location-contacts': { table: 'location_contacts', resource: 'locations' }, - 'bill-import-sessions': { table: 'bill_import_sessions', resource: 'payments' }, -}; - -interface ApiKeyValidation { - key_user_id: string; - key_api_key_id: string; - key_scopes: string[]; -} - -serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - const startTime = Date.now(); - const url = new URL(req.url); - const pathParts = url.pathname.split('/').filter(Boolean); - - // Expected path: /api/v1/{resource}/{id?} - // After edge function routing: /{resource}/{id?} - const resource = pathParts[0]; - const id = pathParts[1]; - - // Initialize Supabase admin client for logging - const supabaseAdmin = createClient( - Deno.env.get('SUPABASE_URL') ?? '', - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '' - ); - - let apiKeyId: string | null = null; - let userId: string | null = null; - - try { - // Extract API key from Authorization header - const authHeader = req.headers.get('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - return new Response( - JSON.stringify({ error: 'Missing or invalid Authorization header' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - const apiKey = authHeader.replace('Bearer ', ''); - - // Validate API key using the database function - const { data: keyData, error: keyError } = await supabaseAdmin - .rpc('validate_api_key', { raw_key: apiKey }); - - if (keyError || !keyData || keyData.length === 0) { - console.error('API key validation failed:', keyError); - return new Response( - JSON.stringify({ error: 'Invalid or expired API key' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - const validation: ApiKeyValidation = keyData[0]; - apiKeyId = validation.key_api_key_id; - userId = validation.key_user_id; - const scopes = validation.key_scopes || []; - - // Check if resource is valid - if (!resource || !RESOURCE_MAP[resource]) { - const availableResources = Object.keys(RESOURCE_MAP); - return new Response( - JSON.stringify({ - error: 'Invalid resource', - available_resources: availableResources, - documentation: 'Use GET /api/{resource} to list, POST to create, PUT/DELETE with /{id}' - }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - const { table, resource: permResource } = RESOURCE_MAP[resource]; - - // Check scopes if defined - if (scopes.length > 0 && !scopes.includes(permResource) && !scopes.includes('*')) { - return new Response( - JSON.stringify({ error: 'API key does not have access to this resource' }), - { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Check user permissions using the permission functions - const isReadOperation = req.method === 'GET'; - const permissionCheck = isReadOperation ? 'can_read' : 'can_write'; - - const { data: hasPermission } = await supabaseAdmin - .rpc(permissionCheck, { _resource_name: permResource }) - .setHeader('Authorization', `Bearer ${await createUserToken(supabaseAdmin, userId)}`); - - // For API keys, we check permissions differently - use service role but verify against user's role - const { data: userPermission } = await supabaseAdmin - .rpc('get_user_permission', { _user_id: userId, _resource_name: permResource }); - - const canRead = userPermission === 'read' || userPermission === 'write'; - const canWrite = userPermission === 'write'; - - if (isReadOperation && !canRead) { - return new Response( - JSON.stringify({ error: 'Permission denied: no read access to this resource' }), - { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - if (!isReadOperation && !canWrite) { - return new Response( - JSON.stringify({ error: 'Permission denied: no write access to this resource' }), - { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - let result: any; - let statusCode = 200; - - switch (req.method) { - case 'GET': - result = await handleGet(supabaseAdmin, table, id, url.searchParams); - break; - case 'POST': - if (id) { - return new Response( - JSON.stringify({ error: 'POST requests should not include an ID' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - const createBody = await req.json(); - result = await handleCreate(supabaseAdmin, table, createBody); - statusCode = 201; - break; - case 'PUT': - case 'PATCH': - if (!id) { - return new Response( - JSON.stringify({ error: 'PUT/PATCH requests require an ID' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - const updateBody = await req.json(); - result = await handleUpdate(supabaseAdmin, table, id, updateBody); - break; - case 'DELETE': - if (!id) { - return new Response( - JSON.stringify({ error: 'DELETE requests require an ID' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - result = await handleDelete(supabaseAdmin, table, id); - statusCode = 204; - break; - default: - return new Response( - JSON.stringify({ error: 'Method not allowed' }), - { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Log the request - const duration = Date.now() - startTime; - await logRequest(supabaseAdmin, { - api_key_id: apiKeyId, - endpoint: `/${resource}${id ? '/' + id : ''}`, - method: req.method, - status_code: statusCode, - request_body: req.method !== 'GET' ? await safeParseBody(req) : null, - response_summary: result.error ? result.error : `Success: ${result.data?.length || 1} records`, - ip_address: req.headers.get('x-forwarded-for') || 'unknown', - user_agent: req.headers.get('user-agent') || 'unknown', - duration_ms: duration, - }); - - if (result.error) { - return new Response( - JSON.stringify({ error: result.error }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - if (statusCode === 204) { - return new Response(null, { status: 204, headers: corsHeaders }); - } - - return new Response( - JSON.stringify({ - data: result.data, - meta: result.meta || {}, - }), - { status: statusCode, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('API Error:', errorMessage); - - // Log error - if (apiKeyId) { - const duration = Date.now() - startTime; - await logRequest(supabaseAdmin, { - api_key_id: apiKeyId, - endpoint: `/${resource}${id ? '/' + id : ''}`, - method: req.method, - status_code: 500, - response_summary: `Error: ${errorMessage}`, - ip_address: req.headers.get('x-forwarded-for') || 'unknown', - user_agent: req.headers.get('user-agent') || 'unknown', - duration_ms: duration, - }); - } - - return new Response( - JSON.stringify({ error: 'Internal server error' }), - { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } -}); - -async function handleGet(supabase: any, table: string, id: string | undefined, params: URLSearchParams) { - let query = supabase.from(table).select('*', { count: 'exact' }); - - if (id) { - // Single record - const { data, error } = await query.eq('id', id).single(); - if (error) return { error: error.message }; - return { data }; - } - - // List with pagination - const limit = parseInt(params.get('limit') || '25'); - const offset = parseInt(params.get('offset') || '0'); - const sort = params.get('sort') || 'created_at'; - const order = params.get('order') || 'desc'; - - query = query - .order(sort, { ascending: order === 'asc' }) - .range(offset, offset + limit - 1); - - // Apply filters (e.g., ?filter[status]=active) - for (const [key, value] of params.entries()) { - if (key.startsWith('filter[') && key.endsWith(']')) { - const field = key.slice(7, -1); - query = query.eq(field, value); - } - } - - const { data, error, count } = await query; - if (error) return { error: error.message }; - - return { - data, - meta: { - total: count, - limit, - offset, - has_more: (offset + limit) < (count || 0), - }, - }; -} - -async function handleCreate(supabase: any, table: string, body: any) { - const { data, error } = await supabase - .from(table) - .insert(body) - .select() - .single(); - - if (error) return { error: error.message }; - return { data }; -} - -async function handleUpdate(supabase: any, table: string, id: string, body: any) { - const { data, error } = await supabase - .from(table) - .update(body) - .eq('id', id) - .select() - .single(); - - if (error) return { error: error.message }; - return { data }; -} - -async function handleDelete(supabase: any, table: string, id: string) { - const { error } = await supabase - .from(table) - .delete() - .eq('id', id); - - if (error) return { error: error.message }; - return { data: null }; -} - -async function logRequest(supabase: any, log: any) { - try { - await supabase.from('api_request_log').insert(log); - } catch (e) { - console.error('Failed to log API request:', e); - } -} - -async function safeParseBody(req: Request): Promise { - try { - const clone = req.clone(); - return await clone.json(); - } catch { - return null; - } -} - -// Helper to create a user-context token (not implemented - using service role with permission check) -async function createUserToken(supabase: any, userId: string): Promise { - // This is a placeholder - in production you might want to create a short-lived token - // For now, we validate permissions using get_user_permission function - return ''; -} diff --git a/supabase/functions/bill-import/index.ts b/supabase/functions/bill-import/index.ts deleted file mode 100644 index 1de1d4b..0000000 --- a/supabase/functions/bill-import/index.ts +++ /dev/null @@ -1,813 +0,0 @@ -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; - -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', -}; - -// Fuzzy matching utilities (duplicated from src/lib/fuzzyMatch.ts for edge function) -function levenshteinDistance(str1: string, str2: string): number { - const m = str1.length; - const n = str2.length; - - if (m === 0) return n; - if (n === 0) return m; - - const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); - - for (let i = 0; i <= m; i++) dp[i][0] = i; - for (let j = 0; j <= n; j++) dp[0][j] = j; - - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - dp[i][j] = Math.min( - dp[i - 1][j] + 1, - dp[i][j - 1] + 1, - dp[i - 1][j - 1] + cost - ); - } - } - - return dp[m][n]; -} - -function normalizeString(str: string): string { - return str - .toLowerCase() - .trim() - .replace(/[\-\/\(\)]/g, ' ') - .replace(/\s+/g, ' ') - .replace(/\b(the|a|an)\b/gi, '') - .trim(); -} - -function similarityScore(str1: string, str2: string): number { - const s1 = normalizeString(str1); - const s2 = normalizeString(str2); - - if (s1 === s2) return 1; - if (s1.length === 0 || s2.length === 0) return 0; - - if (s1.includes(s2) || s2.includes(s1)) { - const shorter = Math.min(s1.length, s2.length); - const longer = Math.max(s1.length, s2.length); - return 0.7 + (0.3 * shorter / longer); - } - - const distance = levenshteinDistance(s1, s2); - const maxLength = Math.max(s1.length, s2.length); - - return Math.max(0, 1 - distance / maxLength); -} - -function getConfidenceLevel(score: number): 'high' | 'medium' | 'low' { - if (score >= 0.8) return 'high'; - if (score >= 0.5) return 'medium'; - return 'low'; -} - -interface InventoryItem { - id: string; - name: string; - sku: string; - category?: string | null; - unit?: string | null; - unit_cost?: number | null; -} - -interface MatchResult { - item_id: string; - item_name: string; - sku: string; - score: number; - confidence: 'high' | 'medium' | 'low'; - unit?: string | null; - unit_cost?: number | null; -} - -interface SavedMapping { - id: string; - vendor_id: string | null; - vendor_item_name: string; - item_id: string; - quantity_multiplier: number; - item?: InventoryItem; -} - -// Parse quantity patterns from item names like "Ctn-24", "6-pack", "x12", "dozen" -function parseQuantityMultiplier(itemName: string): { multiplier: number; detected: string | null } { - const patterns = [ - { regex: /\bctn[\s\-\/]?(\d+)\b/i, group: 1 }, // Ctn-24, CTN 24, Carton/24 - { regex: /\bcarton[\s\-\/]?(\d+)\b/i, group: 1 }, // Carton-24 - { regex: /\b(\d+)[\s\-]?(?:pk|pack)\b/i, group: 1 }, // 6-pack, 6pk, 6 pack - { regex: /\bx[\s]?(\d+)\b/i, group: 1 }, // x12, x 24 - { regex: /\b(\d+)[\s\-]?(?:case|cs)\b/i, group: 1 }, // 24-case, 12cs - { regex: /\bdozen\b/i, group: null, value: 12 }, // dozen - { regex: /\bhalf[\s\-]?dozen\b/i, group: null, value: 6 }, // half-dozen - ]; - - for (const pattern of patterns) { - const match = itemName.match(pattern.regex); - if (match) { - const multiplier = pattern.value ?? parseInt(match[pattern.group!], 10); - if (multiplier > 1) { - return { multiplier, detected: match[0] }; - } - } - } - - return { multiplier: 1, detected: null }; -} - -function findBestMatches( - query: string, - items: InventoryItem[], - threshold: number = 0.4, - maxResults: number = 5 -): MatchResult[] { - const results: { item: InventoryItem; score: number }[] = []; - - for (const item of items) { - const skuScore = similarityScore(query, item.sku) * 1.5; - const nameScore = similarityScore(query, item.name); - const categoryScore = item.category ? similarityScore(query, item.category) * 0.3 : 0; - - let bestScore = Math.max(nameScore, skuScore, categoryScore); - bestScore = Math.min(1, bestScore); - - if (bestScore >= threshold) { - results.push({ item, score: bestScore }); - } - } - - return results - .sort((a, b) => b.score - a.score) - .slice(0, maxResults) - .map(r => ({ - item_id: r.item.id, - item_name: r.item.name, - sku: r.item.sku, - score: r.score, - confidence: getConfidenceLevel(r.score), - unit: r.item.unit, - unit_cost: r.item.unit_cost - })); -} - -Deno.serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - const url = new URL(req.url); - const pathParts = url.pathname.split('/').filter(Boolean); - // Expected paths: /bill-import or /bill-import/:id or /bill-import/:id/confirm - const sessionId = pathParts[1] || null; - const action = pathParts[2] || null; - - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Get user from auth header - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - const token = authHeader.replace('Bearer ', ''); - const { data: { user }, error: authError } = await supabase.auth.getUser(token); - - if (authError || !user) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - try { - // POST /bill-import - Create new import session with fuzzy matching - if (req.method === 'POST' && !sessionId) { - const body = await req.json(); - const { vendor_id, vendor_name, csv_data, column_mapping, file_name } = body; - - if (!csv_data || !Array.isArray(csv_data) || csv_data.length < 2) { - return new Response(JSON.stringify({ error: 'Invalid CSV data' }), { - status: 400, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // Fetch inventory items for matching - const { data: items, error: itemsError } = await supabase - .from('items') - .select('id, name, sku, category, unit, unit_cost') - .eq('is_active', true); - - if (itemsError) throw itemsError; - - // Fetch saved vendor item mappings - const { data: savedMappings, error: mappingsError } = await supabase - .from('vendor_item_mappings') - .select('id, vendor_id, vendor_item_name, item_id, quantity_multiplier') - .or(vendor_id ? `vendor_id.eq.${vendor_id},vendor_id.is.null` : 'vendor_id.is.null'); - - if (mappingsError) { - console.warn('Could not fetch saved mappings:', mappingsError); - } - - // Create lookup for saved mappings (vendor-specific takes priority over global) - const mappingLookup = new Map(); - for (const mapping of (savedMappings || [])) { - const key = mapping.vendor_item_name.toLowerCase().trim(); - const existing = mappingLookup.get(key); - // Vendor-specific mappings take priority over global (null vendor_id) - if (!existing || (mapping.vendor_id && !existing.vendor_id)) { - mappingLookup.set(key, mapping); - } - } - - // Parse rows and run fuzzy matching - const matchedRows = []; - let totalAmount = 0; - const headerRow = csv_data[0]; - - // Track row-level vendor/location/date for aggregation - const rowVendors: string[] = []; - const rowLocations: string[] = []; - const rowDates: string[] = []; - - for (let i = 1; i < csv_data.length; i++) { - const row = csv_data[i]; - const mapping = column_mapping || {}; - - const itemName = mapping.item_name !== undefined ? row[mapping.item_name] : row[0]; - const quantity = parseFloat(mapping.quantity !== undefined ? row[mapping.quantity] : row[1]) || 1; - const unitPrice = parseFloat(mapping.unit_price !== undefined ? row[mapping.unit_price] : row[2]) || 0; - const lineTotal = parseFloat(mapping.line_total !== undefined ? row[mapping.line_total] : row[3]) || (quantity * unitPrice); - - // Parse optional fields: vendor, location, date - const rowVendor = mapping.vendor !== undefined ? row[mapping.vendor]?.trim() : null; - const rowLocation = mapping.location !== undefined ? row[mapping.location]?.trim() : null; - const rowDate = mapping.date !== undefined ? row[mapping.date]?.trim() : null; - - if (rowVendor) rowVendors.push(rowVendor); - if (rowLocation) rowLocations.push(rowLocation); - if (rowDate) rowDates.push(rowDate); - - totalAmount += lineTotal; - - // Check for saved mapping first - const savedMapping = mappingLookup.get((itemName || '').toLowerCase().trim()); - - // Parse quantity multiplier from item name - const { multiplier: detectedMultiplier, detected: detectedPattern } = parseQuantityMultiplier(itemName || ''); - - if (savedMapping) { - // Use saved mapping - find the item details - const mappedItem = items?.find(item => item.id === savedMapping.item_id); - const effectiveQuantity = quantity * savedMapping.quantity_multiplier; - const effectiveUnitPrice = lineTotal / effectiveQuantity; - - matchedRows.push({ - row_index: i, - original: { - item_name: itemName, - quantity, - unit_price: unitPrice, - line_total: lineTotal, - vendor: rowVendor, - location: rowLocation, - date: rowDate, - raw: row - }, - saved_mapping: { - mapping_id: savedMapping.id, - item_id: savedMapping.item_id, - item_name: mappedItem?.name || 'Unknown Item', - sku: mappedItem?.sku || '', - quantity_multiplier: savedMapping.quantity_multiplier, - effective_quantity: effectiveQuantity, - effective_unit_price: effectiveUnitPrice - }, - detected_quantity: detectedPattern ? { pattern: detectedPattern, multiplier: detectedMultiplier } : null, - matches: mappedItem ? [{ - item_id: mappedItem.id, - item_name: mappedItem.name, - sku: mappedItem.sku, - score: 1, - confidence: 'high' as const, - unit: mappedItem.unit, - unit_cost: mappedItem.unit_cost - }] : [], - selected_match: { - item_id: savedMapping.item_id, - score: 1, - from_saved_mapping: true, - quantity_multiplier: savedMapping.quantity_multiplier - }, - allocation_type: 'inventory_restock' - }); - } else { - // Run fuzzy matching - const matches = findBestMatches(itemName || '', items || []); - const highConfidenceMatch = matches.find(m => m.confidence === 'high'); - - matchedRows.push({ - row_index: i, - original: { - item_name: itemName, - quantity, - unit_price: unitPrice, - line_total: lineTotal, - vendor: rowVendor, - location: rowLocation, - date: rowDate, - raw: row - }, - saved_mapping: null, - detected_quantity: detectedPattern ? { pattern: detectedPattern, multiplier: detectedMultiplier } : null, - matches, - selected_match: highConfidenceMatch ? { - item_id: highConfidenceMatch.item_id, - score: highConfidenceMatch.score, - quantity_multiplier: detectedMultiplier // Suggest detected multiplier - } : null, - allocation_type: highConfidenceMatch ? 'inventory_restock' : null - }); - } - } - - // Determine vendor/location/date from CSV if not provided in request - const getMostCommon = (arr: string[]) => { - if (arr.length === 0) return null; - const counts = arr.reduce((acc, val) => { - acc[val] = (acc[val] || 0) + 1; - return acc; - }, {} as Record); - return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; - }; - - const csvVendorName = getMostCommon(rowVendors); - const csvLocation = getMostCommon(rowLocations); - const csvDate = getMostCommon(rowDates); - - // Use CSV-extracted values if not provided in request body - const finalVendorName = vendor_name || csvVendorName; - - // Create session - const { data: session, error: sessionError } = await supabase - .from('bill_import_sessions') - .insert({ - vendor_id, - vendor_name: finalVendorName, - file_name, - raw_data: csv_data, - column_mapping, - matched_rows: matchedRows, - status: 'matched', - total_amount: totalAmount, - created_by: user.id - }) - .select() - .single(); - - if (sessionError) throw sessionError; - - const summary = { - total_rows: matchedRows.length, - high_confidence: matchedRows.filter(r => r.matches[0]?.confidence === 'high' || r.saved_mapping).length, - medium_confidence: matchedRows.filter(r => !r.saved_mapping && r.matches[0]?.confidence === 'medium').length, - low_confidence: matchedRows.filter(r => !r.saved_mapping && (r.matches[0]?.confidence === 'low' || r.matches.length === 0)).length, - saved_mappings_used: matchedRows.filter(r => r.saved_mapping).length, - total_amount: totalAmount - }; - - // Include extracted CSV metadata in response - const extracted_metadata = { - vendor_name: csvVendorName, - location: csvLocation, - date: csvDate - }; - - console.log(`Created bill import session ${session.id} with ${matchedRows.length} rows (${summary.saved_mappings_used} from saved mappings)`); - - return new Response(JSON.stringify({ - session_id: session.id, - status: session.status, - matched_rows: matchedRows, - summary, - extracted_metadata - }), { - status: 201, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // GET /bill-import/:id - Get session details - if (req.method === 'GET' && sessionId && !action) { - const { data: session, error } = await supabase - .from('bill_import_sessions') - .select('*') - .eq('id', sessionId) - .single(); - - if (error || !session) { - return new Response(JSON.stringify({ error: 'Session not found' }), { - status: 404, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - return new Response(JSON.stringify(session), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // PUT /bill-import/:id - Update match selections - if (req.method === 'PUT' && sessionId && !action) { - const body = await req.json(); - const { matched_rows: updatedRows } = body; - - // Get current session - const { data: session, error: fetchError } = await supabase - .from('bill_import_sessions') - .select('*') - .eq('id', sessionId) - .single(); - - if (fetchError || !session) { - return new Response(JSON.stringify({ error: 'Session not found' }), { - status: 404, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // Merge updates into existing matched_rows - const currentRows = session.matched_rows as any[]; - for (const update of updatedRows) { - const idx = currentRows.findIndex(r => r.row_index === update.row_index); - if (idx !== -1) { - if (update.selected_match !== undefined) { - currentRows[idx].selected_match = update.selected_match; - } - if (update.allocation_type !== undefined) { - currentRows[idx].allocation_type = update.allocation_type; - } - if (update.description !== undefined) { - currentRows[idx].description = update.description; - } - if (update.create_new_item !== undefined) { - currentRows[idx].create_new_item = update.create_new_item; - } - if (update.quantity_multiplier !== undefined) { - currentRows[idx].selected_match = { - ...currentRows[idx].selected_match, - quantity_multiplier: update.quantity_multiplier - }; - } - if (update.save_mapping !== undefined) { - currentRows[idx].save_mapping = update.save_mapping; - } - } - } - - const { data: updated, error: updateError } = await supabase - .from('bill_import_sessions') - .update({ matched_rows: currentRows, updated_at: new Date().toISOString() }) - .eq('id', sessionId) - .select() - .single(); - - if (updateError) throw updateError; - - console.log(`Updated bill import session ${sessionId}`); - - return new Response(JSON.stringify(updated), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // POST /bill-import/:id/confirm - Finalize import - if (req.method === 'POST' && sessionId && action === 'confirm') { - const body = await req.json(); - const { date, payment_method, reference, bank_account_id, notes, location_id, save_mappings } = body; - - // Get session - const { data: session, error: fetchError } = await supabase - .from('bill_import_sessions') - .select('*') - .eq('id', sessionId) - .single(); - - if (fetchError || !session) { - return new Response(JSON.stringify({ error: 'Session not found' }), { - status: 404, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - if (session.status === 'completed') { - return new Response(JSON.stringify({ error: 'Session already completed' }), { - status: 400, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - const matchedRows = session.matched_rows as any[]; - - // Extract date from CSV if not provided - const csvDates = matchedRows - .map(r => r.original?.date) - .filter(Boolean); - const csvDate = csvDates.length > 0 ? csvDates[0] : null; - - // Parse and validate the date - try various formats - const parseDate = (dateStr: string | null): string => { - if (!dateStr) return new Date().toISOString().split('T')[0]; - - // Try to parse the date string - const parsed = new Date(dateStr); - if (!isNaN(parsed.getTime())) { - return parsed.toISOString().split('T')[0]; - } - - // Try DD/MM/YYYY format (common in AU) - const ddmmyyyy = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); - if (ddmmyyyy) { - const [, day, month, year] = ddmmyyyy; - return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; - } - - return new Date().toISOString().split('T')[0]; - }; - - const finalDate = parseDate(date || csvDate); - - // Create items for rows marked as create_new_item - const newItemIds: Record = {}; - for (const row of matchedRows) { - if (row.create_new_item) { - const { data: newItem, error: itemError } = await supabase - .from('items') - .insert({ - name: row.original.item_name, - sku: row.create_new_item.sku || `SKU-${Date.now()}-${row.row_index}`, - unit: row.create_new_item.unit || 'each', - unit_cost: row.original.unit_price, - current_stock: 0, - is_active: true, - user_id: user.id - }) - .select() - .single(); - - if (itemError) throw itemError; - newItemIds[row.row_index] = newItem.id; - row.selected_match = { item_id: newItem.id, quantity_multiplier: row.selected_match?.quantity_multiplier || 1 }; - row.allocation_type = 'inventory_restock'; - } - } - - // Create purchase record - const { data: purchase, error: purchaseError } = await supabase - .from('purchases') - .insert({ - vendor_id: session.vendor_id, - date: finalDate, - amount: session.total_amount, - total: session.total_amount, - payment_method: payment_method || 'other', - reference, - notes, - description: `Bill import: ${session.file_name || 'CSV Import'}`, - created_by: user.id - }) - .select() - .single(); - - if (purchaseError) throw purchaseError; - - // Create allocations and update inventory - let allocationsCreated = 0; - let inventoryUpdated = 0; - let mappingsSaved = 0; - - for (const row of matchedRows) { - const quantityMultiplier = row.selected_match?.quantity_multiplier || row.saved_mapping?.quantity_multiplier || 1; - const effectiveQuantity = row.original.quantity * quantityMultiplier; - const effectiveUnitPrice = row.original.line_total / effectiveQuantity; - - if (row.allocation_type === 'inventory_restock' && row.selected_match?.item_id) { - // Create inventory restock allocation - const { error: allocError } = await supabase - .from('purchase_allocations') - .insert({ - purchase_id: purchase.id, - allocation_type: 'inventory_restock', - item_id: row.selected_match.item_id, - quantity: effectiveQuantity, - amount: row.original.line_total, - description: row.original.item_name - }); - - if (allocError) throw allocError; - allocationsCreated++; - - // Update inventory stock - const { data: item } = await supabase - .from('items') - .select('current_stock, unit_cost') - .eq('id', row.selected_match.item_id) - .single(); - - if (item) { - const newStock = (item.current_stock || 0) + effectiveQuantity; - const purchaseUnitCost = effectiveUnitPrice; - - // Log inventory movement first (with movement_date for proper WAC calculation) - await supabase - .from('inventory_movements') - .insert({ - item_id: row.selected_match.item_id, - movement_type: 'purchase', - quantity: effectiveQuantity, - unit_cost: purchaseUnitCost, - movement_date: finalDate, - reference: `Purchase ${purchase.id}`, - notes: quantityMultiplier > 1 - ? `Bill import from ${session.file_name || 'CSV'} (${row.original.quantity} x ${quantityMultiplier} multiplier)` - : `Bill import from ${session.file_name || 'CSV'}`, - created_by: user.id - }); - - // Calculate weighted average cost from recent purchases - const { data: recentPurchases } = await supabase - .from('inventory_movements') - .select('quantity, unit_cost') - .eq('item_id', row.selected_match.item_id) - .eq('movement_type', 'purchase') - .order('movement_date', { ascending: false, nullsFirst: false }) - .order('created_at', { ascending: false }) - .limit(5); - - let weightedAvgCost = purchaseUnitCost; - if (recentPurchases && recentPurchases.length > 0) { - let totalValue = 0; - let totalQuantity = 0; - for (const p of recentPurchases) { - totalValue += (p.quantity || 0) * (p.unit_cost || 0); - totalQuantity += p.quantity || 0; - } - weightedAvgCost = totalQuantity > 0 ? totalValue / totalQuantity : purchaseUnitCost; - } - - // Check if price change is significant (>$0.01 AND >1%) - const oldCost = item.unit_cost || 0; - const priceDiff = Math.abs(oldCost - weightedAvgCost); - const percentDiff = oldCost > 0 ? (priceDiff / oldCost) * 100 : 100; - const significantChange = priceDiff >= 0.01 && percentDiff >= 1; - - // Update stock, and unit cost only if significant change - await supabase - .from('items') - .update({ - current_stock: newStock, - ...(significantChange ? { unit_cost: weightedAvgCost } : {}) - }) - .eq('id', row.selected_match.item_id); - - // Log price history only if cost changed significantly - if (significantChange) { - await supabase - .from('item_price_history') - .insert({ - item_id: row.selected_match.item_id, - old_unit_cost: item.unit_cost, - new_unit_cost: weightedAvgCost, - reason: 'Bill import restock (weighted avg)', - changed_by: user.id - }); - } - - inventoryUpdated++; - } - - // Save mapping if requested and not already from a saved mapping - const shouldSaveMapping = save_mappings !== false && - !row.saved_mapping && - row.save_mapping !== false && - row.original.item_name; - - if (shouldSaveMapping) { - // Check if mapping already exists - const { data: existingMapping } = await supabase - .from('vendor_item_mappings') - .select('id') - .eq('vendor_item_name', row.original.item_name) - .eq('vendor_id', session.vendor_id) - .maybeSingle(); - - if (!existingMapping) { - const { error: mappingError } = await supabase - .from('vendor_item_mappings') - .insert({ - vendor_id: session.vendor_id, - vendor_item_name: row.original.item_name, - item_id: row.selected_match.item_id, - quantity_multiplier: quantityMultiplier, - created_by: user.id - }); - - if (!mappingError) { - mappingsSaved++; - console.log(`Saved mapping: "${row.original.item_name}" -> item ${row.selected_match.item_id} (x${quantityMultiplier})`); - } - } - } - } else if (row.allocation_type === 'general') { - // Create general expense allocation - const { error: allocError } = await supabase - .from('purchase_allocations') - .insert({ - purchase_id: purchase.id, - allocation_type: 'general', - amount: row.original.line_total, - description: row.description || row.original.item_name - }); - - if (allocError) throw allocError; - allocationsCreated++; - } - } - - // Update session status - await supabase - .from('bill_import_sessions') - .update({ status: 'completed', updated_at: new Date().toISOString() }) - .eq('id', sessionId); - - console.log(`Completed bill import session ${sessionId}: purchase ${purchase.id}, ${allocationsCreated} allocations, ${inventoryUpdated} inventory updates, ${mappingsSaved} new mappings saved`); - - return new Response(JSON.stringify({ - purchase_id: purchase.id, - allocations_created: allocationsCreated, - inventory_updated: inventoryUpdated, - mappings_saved: mappingsSaved, - total: session.total_amount - }), { - status: 201, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // DELETE /bill-import/:id - Cancel/delete session - if (req.method === 'DELETE' && sessionId) { - const { error } = await supabase - .from('bill_import_sessions') - .delete() - .eq('id', sessionId); - - if (error) throw error; - - console.log(`Deleted bill import session ${sessionId}`); - - return new Response(JSON.stringify({ success: true }), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - // GET /bill-import - List all sessions - if (req.method === 'GET' && !sessionId) { - const { data: sessions, error } = await supabase - .from('bill_import_sessions') - .select('*') - .order('created_at', { ascending: false }); - - if (error) throw error; - - return new Response(JSON.stringify(sessions), { - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } - - return new Response(JSON.stringify({ error: 'Method not allowed' }), { - status: 405, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - - } catch (err) { - const error = err as Error; - console.error('Bill import error:', error); - return new Response(JSON.stringify({ error: error.message }), { - status: 500, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } -}); diff --git a/supabase/functions/generate-invoice-pdf/index.ts b/supabase/functions/generate-invoice-pdf/index.ts deleted file mode 100644 index 41dbe67..0000000 --- a/supabase/functions/generate-invoice-pdf/index.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.89.0"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", -}; - -serve(async (req) => { - if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); - } - - try { - const supabaseUrl = Deno.env.get("SUPABASE_URL")!; - const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; - const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!; - - // Authentication check - require valid user session - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - console.error("No authorization header provided"); - return new Response( - JSON.stringify({ error: 'Unauthorized - No authorization header' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Create client with user's token to verify authentication - const userClient = createClient(supabaseUrl, supabaseAnonKey, { - global: { headers: { Authorization: authHeader } } - }); - - const { data: { user }, error: authError } = await userClient.auth.getUser(); - - if (authError || !user) { - console.error("Authentication failed:", authError?.message); - return new Response( - JSON.stringify({ error: 'Unauthorized - Invalid session' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Verify user has read access to invoices using RLS function - const { data: hasAccess, error: permError } = await userClient.rpc('can_read', { _resource_name: 'invoices' }); - - if (permError || !hasAccess) { - console.error("Permission denied for user:", user.id, permError?.message); - return new Response( - JSON.stringify({ error: 'Forbidden - No access to invoices' }), - { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - console.log("User authenticated:", user.id); - - const { invoiceId } = await req.json(); - - if (!invoiceId) { - throw new Error("Invoice ID is required"); - } - - // Use service role for data fetching after authentication is verified - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - console.log("Generating PDF for invoice:", invoiceId); - - // Fetch invoice with client and job (for trading name) - const { data: invoice, error: invoiceError } = await supabase - .from("invoices") - .select("*, clients(*), jobs(trading_name_id)") - .eq("id", invoiceId) - .single(); - - if (invoiceError || !invoice) { - throw new Error("Invoice not found"); - } - - // Fetch invoice lines - const { data: lines } = await supabase - .from("invoice_lines") - .select("*") - .eq("invoice_id", invoiceId) - .order("sort_order"); - - // Fetch company settings - const { data: company } = await supabase - .from("company_settings") - .select("*") - .limit(1) - .single(); - - // Fetch trading name if job has one - let tradingName = null; - if (invoice.jobs?.trading_name_id) { - const { data: tn } = await supabase - .from("trading_names") - .select("*") - .eq("id", invoice.jobs.trading_name_id) - .single(); - tradingName = tn; - } - - // Calculate client credit (overpayments from other invoices) - let clientCredit = 0; - if (invoice.client_id) { - const { data: clientInvoices } = await supabase - .from("invoices") - .select("id, total, amount_paid, status") - .eq("client_id", invoice.client_id) - .neq("id", invoiceId) // Exclude current invoice - .in("status", ["paid", "partially_paid", "sent", "overdue"]); - - if (clientInvoices) { - clientCredit = clientInvoices.reduce((sum: number, inv: any) => { - const overpayment = (inv.amount_paid || 0) - (inv.total || 0); - return sum + (overpayment > 0 ? overpayment : 0); - }, 0); - } - console.log("Client credit calculated:", clientCredit); - } - - 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" }); - - // Determine company display name - 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}`; - } - - // Build payment details HTML - let paymentDetailsHtml = ''; - if (tradingName) { - const paymentParts = []; - - 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); - } - - 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('')} -
- `; - } - } - - // Generate HTML for PDF - const html = ` - - - - - - - -
-
-
-
${companyDisplayName}
-
- ${company?.address || ""}
- ${company?.email ? `Email: ${company.email}` : ""}
- ${company?.phone ? `Phone: ${company.phone}` : ""}
- ${company?.abn ? `ABN: ${company.abn}` : ""} -
-
-
-
INVOICE
-
${invoice.invoice_number}
-
-
- -
-
-
Bill To
-
${invoice.clients?.name || "Client"}
-
${invoice.clients?.billing_address || ""}
-
-
-
Issue Date
-
${formatDate(invoice.issue_date)}
-
-
-
Due Date
-
${formatDate(invoice.due_date)}
-
-
-
Status
-
${invoice.status.replace("_", " ")}
-
-
- -
- - - - - - - - - - - ${(lines || []) - .map( - (line: any) => ` - - - - - - - - ` - ) - .join("")} - -
DescriptionQtyUnit PriceTaxTotal
${line.description}${line.quantity}${formatCurrency(line.unit_price)}${line.tax_rate}%${formatCurrency(line.line_total)}
- -
-
-
- Subtotal - ${formatCurrency(invoice.subtotal)} -
-
- Tax - ${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}
- ` - : "" - } -
- ` - : "" - } - - - - `; - - return new Response( - JSON.stringify({ html, invoice: invoice.invoice_number }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } catch (error: any) { - console.error("Error generating invoice PDF:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } -}); diff --git a/supabase/functions/generate-job-invoices/index.ts b/supabase/functions/generate-job-invoices/index.ts deleted file mode 100644 index cace856..0000000 --- a/supabase/functions/generate-job-invoices/index.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.89.0"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", -}; - -serve(async (req) => { - if (req.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders }); - } - - try { - const supabaseUrl = Deno.env.get("SUPABASE_URL")!; - const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; - const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!; - - // Authentication check - require valid user session - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - console.error("No authorization header provided"); - return new Response( - JSON.stringify({ error: 'Unauthorized - No authorization header' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Create client with user's token to verify authentication - const userClient = createClient(supabaseUrl, supabaseAnonKey, { - global: { headers: { Authorization: authHeader } } - }); - - const { data: { user }, error: authError } = await userClient.auth.getUser(); - - if (authError || !user) { - console.error("Authentication failed:", authError?.message); - return new Response( - JSON.stringify({ error: 'Unauthorized - Invalid session' }), - { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - // Verify user has write access to invoices (creating invoices requires write permission) - const { data: hasAccess, error: permError } = await userClient.rpc('can_write', { _resource_name: 'invoices' }); - - if (permError || !hasAccess) { - console.error("Permission denied for user:", user.id, permError?.message); - return new Response( - JSON.stringify({ error: 'Forbidden - No write access to invoices' }), - { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - } - - console.log("User authenticated:", user.id); - - // Use service role for data operations after authentication is verified - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - console.log("Starting job invoice generation..."); - - const today = new Date(); - const todayStr = today.toISOString().split("T")[0]; - - // Find recurring jobs that need invoicing (where next_invoice_date - lead_days <= today) - const { data: jobs, error: jobsError } = await supabase - .from("jobs") - .select(` - *, - clients!jobs_client_id_fkey(id, name, payment_terms) - `) - .eq("is_recurring", true) - .eq("status", "active") - .not("client_id", "is", null) - .not("next_invoice_date", "is", null); - - if (jobsError) { - console.error("Error fetching jobs:", jobsError); - throw jobsError; - } - - // Filter jobs where we should generate invoice based on lead days - const jobsDueByRecurring = (jobs || []).filter(job => { - const nextInvoice = new Date(job.next_invoice_date); - const leadDays = job.invoice_lead_days || 7; - const generateDate = new Date(nextInvoice); - generateDate.setDate(generateDate.getDate() - leadDays); - return generateDate <= today; - }); - - // Also find jobs with job_assets due for invoicing - const { data: jobAssetsData, error: jobAssetsError } = await supabase - .from("job_assets") - .select(` - *, - jobs!job_assets_job_id_fkey( - id, job_number, name, client_id, hourly_rate, status, - clients!jobs_client_id_fkey(id, name, payment_terms) - ), - assets!job_assets_asset_id_fkey(id, name, asset_tag) - `) - .eq("is_active", true) - .not("next_invoice_date", "is", null); - - if (jobAssetsError) { - console.error("Error fetching job_assets:", jobAssetsError); - throw jobAssetsError; - } - - // Filter job_assets that are due - const jobAssetsDue = (jobAssetsData || []).filter(ja => { - const nextInvoice = new Date(ja.next_invoice_date); - const leadDays = ja.invoice_lead_days || 7; - const generateDate = new Date(nextInvoice); - generateDate.setDate(generateDate.getDate() - leadDays); - // Also check rental period is active - const rentalStart = new Date(ja.rental_start_date); - const rentalEnd = ja.rental_end_date ? new Date(ja.rental_end_date) : null; - if (rentalStart > today) return false; - if (rentalEnd && rentalEnd < today) return false; - return generateDate <= today && ja.jobs?.status === 'active'; - }); - - // Collect unique job IDs that need invoices - const jobIdsFromAssets = new Set(jobAssetsDue.map(ja => ja.job_id)); - const jobIdsFromRecurring = new Set(jobsDueByRecurring.map(j => j.id)); - const allJobIds = new Set([...jobIdsFromAssets, ...jobIdsFromRecurring]); - - console.log(`Found ${allJobIds.size} jobs due for invoicing (${jobsDueByRecurring.length} recurring, ${jobIdsFromAssets.size} with assets)`); - - if (allJobIds.size === 0) { - return new Response( - JSON.stringify({ message: "No job invoices to generate", count: 0 }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } - - // Get company settings - const { data: settings } = await supabase - .from("company_settings") - .select("id, invoice_prefix, invoice_next_number, default_tax_rate, default_payment_terms") - .limit(1) - .single(); - - const prefix = settings?.invoice_prefix || "INV"; - let nextNum = settings?.invoice_next_number || 1; - const defaultTaxRate = settings?.default_tax_rate || 10; - const defaultTerms = settings?.default_payment_terms || 30; - - // Get already invoiced timesheet/expense IDs - const { data: existingLines } = await supabase - .from("invoice_lines") - .select("timesheet_id, expense_id"); - - const invoicedTimesheetIds = new Set((existingLines || []).filter(l => l.timesheet_id).map(l => l.timesheet_id)); - const invoicedExpenseIds = new Set((existingLines || []).filter(l => l.expense_id).map(l => l.expense_id)); - - const invoicesCreated: string[] = []; - - // Build a map of job data for jobs with assets - const jobDataMap = new Map(); - for (const ja of jobAssetsDue) { - if (ja.jobs) { - jobDataMap.set(ja.job_id, ja.jobs); - } - } - // Add recurring jobs to map - for (const job of jobsDueByRecurring) { - jobDataMap.set(job.id, job); - } - - // Group job_assets by job_id - const jobAssetsMap = new Map(); - for (const ja of jobAssetsDue) { - if (!jobAssetsMap.has(ja.job_id)) { - jobAssetsMap.set(ja.job_id, []); - } - jobAssetsMap.get(ja.job_id)!.push(ja); - } - - for (const jobId of allJobIds) { - const job = jobDataMap.get(jobId); - if (!job) continue; - - const client = job.clients; - if (!client) continue; - - const invoiceNumber = `${prefix}-${String(nextNum).padStart(5, "0")}`; - const paymentTerms = client.payment_terms || defaultTerms; - const dueDate = new Date(); - dueDate.setDate(dueDate.getDate() + paymentTerms); - - console.log(`Processing job ${job.job_number} (${job.name})`); - - // Calculate line items - const lineItems: any[] = []; - let sortOrder = 0; - - // Add recurring service rate (if applicable and this job is in recurring list) - if (jobIdsFromRecurring.has(jobId) && job.recurring_rate && job.recurring_rate > 0) { - const lineTotal = job.recurring_rate; - lineItems.push({ - description: `Monthly service fee - ${job.name}`, - quantity: 1, - unit: "month", - unit_price: job.recurring_rate, - tax_rate: defaultTaxRate, - line_total: lineTotal, - sort_order: sortOrder++, - }); - } - - // Add job_assets rental lines - const assetsForJob = jobAssetsMap.get(jobId) || []; - for (const ja of assetsForJob) { - const asset = ja.assets; - if (!asset) continue; - - // Determine billing period description - const billingPeriodStart = new Date(ja.next_invoice_date); - billingPeriodStart.setMonth(billingPeriodStart.getMonth() - 1); - const billingPeriodEnd = new Date(ja.next_invoice_date); - billingPeriodEnd.setDate(billingPeriodEnd.getDate() - 1); - - const periodStr = `${billingPeriodStart.toLocaleDateString('en-AU', { month: 'short', year: 'numeric' })}`; - - lineItems.push({ - description: `Asset rental: ${asset.name} (${asset.asset_tag}) – ${periodStr}`, - quantity: 1, - unit: ja.billing_frequency || "month", - unit_price: ja.rental_rate, - tax_rate: defaultTaxRate, - line_total: ja.rental_rate, - sort_order: sortOrder++, - }); - } - - // Get unbilled timesheets for this job - const { data: timesheets } = await supabase - .from("timesheets") - .select("*") - .eq("job_id", jobId) - .eq("is_billable", true); - - const unbilledTimesheets = (timesheets || []).filter(ts => !invoicedTimesheetIds.has(ts.id)); - - // Get unbilled expenses for this job - const { data: expenses } = await supabase - .from("expenses") - .select("*") - .eq("job_id", jobId) - .eq("is_billable", true); - - const unbilledExpenses = (expenses || []).filter(exp => !invoicedExpenseIds.has(exp.id)); - - // Add time entries - for (const ts of unbilledTimesheets) { - const rate = ts.rate_override || job.hourly_rate || 0; - const lineTotal = ts.hours * rate; - lineItems.push({ - description: `${ts.description || 'Time'} (${ts.date})`, - quantity: ts.hours, - unit: "hours", - unit_price: rate, - tax_rate: defaultTaxRate, - line_total: lineTotal, - timesheet_id: ts.id, - sort_order: sortOrder++, - }); - invoicedTimesheetIds.add(ts.id); - } - - // Add expenses - for (const exp of unbilledExpenses) { - lineItems.push({ - description: exp.description, - quantity: 1, - unit: "each", - unit_price: exp.amount, - tax_rate: defaultTaxRate, - line_total: exp.amount, - expense_id: exp.id, - sort_order: sortOrder++, - }); - invoicedExpenseIds.add(exp.id); - } - - // Skip if no line items - if (lineItems.length === 0) { - console.log(`No billable items for job ${job.job_number}, skipping`); - continue; - } - - // Calculate totals - const subtotal = lineItems.reduce((sum, line) => sum + line.line_total, 0); - const taxTotal = subtotal * (defaultTaxRate / 100); - const total = subtotal + taxTotal; - - console.log(`Creating invoice ${invoiceNumber} with ${lineItems.length} lines, total: ${total}`); - - // Create invoice - const { data: invoice, error: invoiceError } = await supabase - .from("invoices") - .insert({ - invoice_number: invoiceNumber, - client_id: client.id, - job_id: jobId, - issue_date: todayStr, - due_date: dueDate.toISOString().split("T")[0], - status: "draft", - subtotal, - tax_total: taxTotal, - total, - notes: `Invoice for ${job.name} (${job.job_number})`, - }) - .select() - .single(); - - if (invoiceError) { - console.error(`Error creating invoice for job ${jobId}:`, invoiceError); - continue; - } - - // Create invoice lines - const linesWithInvoiceId = lineItems.map(line => ({ - ...line, - invoice_id: invoice.id, - })); - - await supabase.from("invoice_lines").insert(linesWithInvoiceId); - - // Update job's next invoice date (if recurring) - if (jobIdsFromRecurring.has(jobId) && job.next_invoice_date) { - const nextInvoiceDate = new Date(job.next_invoice_date); - nextInvoiceDate.setMonth(nextInvoiceDate.getMonth() + 1); - - await supabase - .from("jobs") - .update({ next_invoice_date: nextInvoiceDate.toISOString().split("T")[0] }) - .eq("id", jobId); - } - - // Update job_assets next_invoice_date - for (const ja of assetsForJob) { - const nextDate = new Date(ja.next_invoice_date); - if (ja.billing_frequency === 'monthly') { - nextDate.setMonth(nextDate.getMonth() + 1); - } else if (ja.billing_frequency === 'quarterly') { - nextDate.setMonth(nextDate.getMonth() + 3); - } else if (ja.billing_frequency === 'yearly') { - nextDate.setFullYear(nextDate.getFullYear() + 1); - } else { - nextDate.setMonth(nextDate.getMonth() + 1); - } - - await supabase - .from("job_assets") - .update({ next_invoice_date: nextDate.toISOString().split("T")[0] }) - .eq("id", ja.id); - } - - invoicesCreated.push(invoiceNumber); - nextNum++; - } - - // Update next invoice number in settings - if (invoicesCreated.length > 0 && settings) { - await supabase - .from("company_settings") - .update({ invoice_next_number: nextNum }) - .eq("id", settings.id); - } - - console.log(`Created ${invoicesCreated.length} invoices:`, invoicesCreated); - - return new Response( - JSON.stringify({ - message: `Generated ${invoicesCreated.length} draft invoices`, - count: invoicesCreated.length, - invoices: invoicesCreated, - }), - { headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } catch (error: any) { - console.error("Error in generate-job-invoices:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } - ); - } -}); diff --git a/supabase/migrations/20251227095709_318f3bdb-860d-46d5-932c-db0557da2a3f.sql b/supabase/migrations/20251227095709_318f3bdb-860d-46d5-932c-db0557da2a3f.sql deleted file mode 100644 index 9acdf85..0000000 --- a/supabase/migrations/20251227095709_318f3bdb-860d-46d5-932c-db0557da2a3f.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add rental fields to assets table -ALTER TABLE public.assets -ADD COLUMN is_rental boolean DEFAULT false, -ADD COLUMN monthly_rate numeric DEFAULT NULL, -ADD COLUMN rental_start_date date DEFAULT NULL, -ADD COLUMN rented_to_client_id uuid REFERENCES public.clients(id) DEFAULT NULL, -ADD COLUMN next_invoice_date date DEFAULT NULL; - --- Create index for finding assets due for invoicing -CREATE INDEX idx_assets_rental_invoice ON public.assets(is_rental, next_invoice_date) WHERE is_rental = true; \ No newline at end of file diff --git a/supabase/migrations/20251227112509_4f43bc21-3af1-46f2-893d-28102000f079.sql b/supabase/migrations/20251227112509_4f43bc21-3af1-46f2-893d-28102000f079.sql deleted file mode 100644 index 3f04694..0000000 --- a/supabase/migrations/20251227112509_4f43bc21-3af1-46f2-893d-28102000f079.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add 'written_off' to the invoice_status enum -ALTER TYPE invoice_status ADD VALUE 'written_off'; \ No newline at end of file diff --git a/supabase/migrations/20251227122712_c431535a-1d45-4d53-b09f-42b3e6743d7c.sql b/supabase/migrations/20251227122712_c431535a-1d45-4d53-b09f-42b3e6743d7c.sql deleted file mode 100644 index 1f4d621..0000000 --- a/supabase/migrations/20251227122712_c431535a-1d45-4d53-b09f-42b3e6743d7c.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Add recurring billing fields to jobs table -ALTER TABLE public.jobs -ADD COLUMN is_recurring boolean DEFAULT false, -ADD COLUMN billing_day integer DEFAULT 1, -ADD COLUMN recurring_rate numeric DEFAULT 0, -ADD COLUMN next_invoice_date date, -ADD COLUMN invoice_lead_days integer DEFAULT 7; - --- Add comment for clarity -COMMENT ON COLUMN public.jobs.is_recurring IS 'Whether this job has recurring monthly billing'; -COMMENT ON COLUMN public.jobs.billing_day IS 'Day of month to generate invoice (1-28)'; -COMMENT ON COLUMN public.jobs.recurring_rate IS 'Monthly recurring rate for job services'; -COMMENT ON COLUMN public.jobs.next_invoice_date IS 'Next date to generate recurring invoice'; -COMMENT ON COLUMN public.jobs.invoice_lead_days IS 'Days before billing date to generate invoice'; \ No newline at end of file diff --git a/supabase/migrations/20251227202838_37cd5dde-8e15-415c-988e-cd5b8c179e0b.sql b/supabase/migrations/20251227202838_37cd5dde-8e15-415c-988e-cd5b8c179e0b.sql deleted file mode 100644 index 0f7f198..0000000 --- a/supabase/migrations/20251227202838_37cd5dde-8e15-415c-988e-cd5b8c179e0b.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Create trading_names table for multiple trading names -CREATE TABLE public.trading_names ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - name TEXT NOT NULL, - is_default BOOLEAN DEFAULT false, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Enable RLS -ALTER TABLE public.trading_names ENABLE ROW LEVEL SECURITY; - --- Policies for trading_names -CREATE POLICY "All can view trading names" ON public.trading_names FOR SELECT USING (true); -CREATE POLICY "Admins can manage trading names" ON public.trading_names FOR ALL USING (is_admin_or_owner(auth.uid())); - --- Add default_hourly_rate to company_settings -ALTER TABLE public.company_settings ADD COLUMN IF NOT EXISTS default_hourly_rate NUMERIC DEFAULT 0; - --- Add trading_name_id to jobs table -ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS trading_name_id UUID REFERENCES public.trading_names(id); - --- Insert default trading name from existing company settings -INSERT INTO public.trading_names (name, is_default) -SELECT COALESCE(trading_name, name, 'Default'), true -FROM public.company_settings -LIMIT 1 -ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/supabase/migrations/20251227211203_7c9e6d32-b00c-44e8-9193-5bbbab30d5ea.sql b/supabase/migrations/20251227211203_7c9e6d32-b00c-44e8-9193-5bbbab30d5ea.sql deleted file mode 100644 index 8383ff7..0000000 --- a/supabase/migrations/20251227211203_7c9e6d32-b00c-44e8-9193-5bbbab30d5ea.sql +++ /dev/null @@ -1,42 +0,0 @@ --- Create junction tables for issues to support multiple jobs and assets - -CREATE TABLE public.issue_jobs ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - issue_id UUID NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE, - job_id UUID NOT NULL REFERENCES public.jobs(id) ON DELETE CASCADE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - UNIQUE(issue_id, job_id) -); - -CREATE TABLE public.issue_assets ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - issue_id UUID NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE, - asset_id UUID NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - UNIQUE(issue_id, asset_id) -); - --- Enable RLS -ALTER TABLE public.issue_jobs ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.issue_assets ENABLE ROW LEVEL SECURITY; - --- RLS Policies for issue_jobs -CREATE POLICY "All can view issue_jobs" ON public.issue_jobs - FOR SELECT USING (true); - -CREATE POLICY "Staff can manage issue_jobs" ON public.issue_jobs - FOR ALL USING (NOT has_role(auth.uid(), 'readonly')); - --- RLS Policies for issue_assets -CREATE POLICY "All can view issue_assets" ON public.issue_assets - FOR SELECT USING (true); - -CREATE POLICY "Staff can manage issue_assets" ON public.issue_assets - FOR ALL USING (NOT has_role(auth.uid(), 'readonly')); - --- Migrate existing data from issues table to junction tables -INSERT INTO public.issue_jobs (issue_id, job_id) -SELECT id, job_id FROM public.issues WHERE job_id IS NOT NULL; - -INSERT INTO public.issue_assets (issue_id, asset_id) -SELECT id, asset_id FROM public.issues WHERE asset_id IS NOT NULL; \ No newline at end of file diff --git a/supabase/migrations/20251227214026_df067dc0-4823-413f-82c0-c356a2d704c8.sql b/supabase/migrations/20251227214026_df067dc0-4823-413f-82c0-c356a2d704c8.sql deleted file mode 100644 index 43b9c36..0000000 --- a/supabase/migrations/20251227214026_df067dc0-4823-413f-82c0-c356a2d704c8.sql +++ /dev/null @@ -1,94 +0,0 @@ --- Create item_price_history table to track price changes -CREATE TABLE public.item_price_history ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - item_id UUID NOT NULL REFERENCES public.items(id) ON DELETE CASCADE, - old_unit_cost NUMERIC, - new_unit_cost NUMERIC, - old_sales_price NUMERIC, - new_sales_price NUMERIC, - changed_by UUID, - changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - reason TEXT -); - --- Enable RLS on price history -ALTER TABLE public.item_price_history ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "All can view price history" ON public.item_price_history FOR SELECT USING (true); -CREATE POLICY "Staff can manage price history" ON public.item_price_history FOR ALL USING (NOT has_role(auth.uid(), 'readonly'::app_role)); - --- Add image_url columns to items and assets -ALTER TABLE public.items ADD COLUMN IF NOT EXISTS image_url TEXT; -ALTER TABLE public.assets ADD COLUMN IF NOT EXISTS image_url TEXT; - --- Create purchases table for recording payments/receipts with allocation -CREATE TABLE public.purchases ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - date DATE NOT NULL DEFAULT CURRENT_DATE, - vendor_id UUID REFERENCES public.vendors(id), - vendor_name TEXT, - description TEXT NOT NULL, - amount NUMERIC NOT NULL, - tax_amount NUMERIC DEFAULT 0, - total NUMERIC NOT NULL, - receipt_url TEXT, - payment_method TEXT, - reference TEXT, - notes TEXT, - created_by UUID, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Enable RLS on purchases -ALTER TABLE public.purchases ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "All can view purchases" ON public.purchases FOR SELECT USING (true); -CREATE POLICY "Staff can manage purchases" ON public.purchases FOR ALL USING (NOT has_role(auth.uid(), 'readonly'::app_role)); - --- Create purchase_allocations table to track how purchases are allocated -CREATE TABLE public.purchase_allocations ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - purchase_id UUID NOT NULL REFERENCES public.purchases(id) ON DELETE CASCADE, - allocation_type TEXT NOT NULL CHECK (allocation_type IN ('job_expense', 'inventory_restock', 'general')), - job_id UUID REFERENCES public.jobs(id), - item_id UUID REFERENCES public.items(id), - quantity INTEGER, - amount NUMERIC NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Enable RLS on purchase allocations -ALTER TABLE public.purchase_allocations ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "All can view purchase allocations" ON public.purchase_allocations FOR SELECT USING (true); -CREATE POLICY "Staff can manage purchase allocations" ON public.purchase_allocations FOR ALL USING (NOT has_role(auth.uid(), 'readonly'::app_role)); - --- Create asset_history table for comprehensive asset tracking -CREATE TABLE public.asset_history ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - asset_id UUID NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - event_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - description TEXT NOT NULL, - old_values JSONB, - new_values JSONB, - related_entity_type TEXT, - related_entity_id UUID, - created_by UUID, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Enable RLS on asset history -ALTER TABLE public.asset_history ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "All can view asset history" ON public.asset_history FOR SELECT USING (true); -CREATE POLICY "Staff can manage asset history" ON public.asset_history FOR ALL USING (NOT has_role(auth.uid(), 'readonly'::app_role)); - --- Create storage bucket for images -INSERT INTO storage.buckets (id, name, public) VALUES ('images', 'images', true); - --- Create policies for image uploads -CREATE POLICY "Anyone can view images" ON storage.objects FOR SELECT USING (bucket_id = 'images'); -CREATE POLICY "Authenticated users can upload images" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'images' AND auth.role() = 'authenticated'); -CREATE POLICY "Authenticated users can update their images" ON storage.objects FOR UPDATE USING (bucket_id = 'images' AND auth.role() = 'authenticated'); -CREATE POLICY "Authenticated users can delete images" ON storage.objects FOR DELETE USING (bucket_id = 'images' AND auth.role() = 'authenticated'); \ No newline at end of file diff --git a/supabase/migrations/20251227220055_f8c210e5-403d-481d-a80e-33c38befd203.sql b/supabase/migrations/20251227220055_f8c210e5-403d-481d-a80e-33c38befd203.sql deleted file mode 100644 index c054398..0000000 --- a/supabase/migrations/20251227220055_f8c210e5-403d-481d-a80e-33c38befd203.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add job_asset_id to invoice_lines to track which asset rentals have been invoiced -ALTER TABLE public.invoice_lines -ADD COLUMN job_asset_id uuid REFERENCES public.job_assets(id) ON DELETE SET NULL; - --- Create index for efficient lookups -CREATE INDEX idx_invoice_lines_job_asset_id ON public.invoice_lines(job_asset_id); \ No newline at end of file diff --git a/supabase/migrations/20251227222909_eaad9aff-4e47-4e4f-8e93-4c4e3ea61f58.sql b/supabase/migrations/20251227222909_eaad9aff-4e47-4e4f-8e93-4c4e3ea61f58.sql deleted file mode 100644 index fbea2e3..0000000 --- a/supabase/migrations/20251227222909_eaad9aff-4e47-4e4f-8e93-4c4e3ea61f58.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Add default_tax_rate_id and billable defaults to company_settings -ALTER TABLE public.company_settings -ADD COLUMN IF NOT EXISTS default_tax_rate_id uuid REFERENCES public.tax_rates(id), -ADD COLUMN IF NOT EXISTS default_billable_time boolean DEFAULT true, -ADD COLUMN IF NOT EXISTS default_billable_expenses boolean DEFAULT false; - --- Add tax_rate_id to invoice_lines (keep tax_rate for backwards compatibility) -ALTER TABLE public.invoice_lines -ADD COLUMN IF NOT EXISTS tax_rate_id uuid REFERENCES public.tax_rates(id); - --- Update invoice_lines to store actual tax name for display -ALTER TABLE public.invoice_lines -ADD COLUMN IF NOT EXISTS tax_name text; \ No newline at end of file diff --git a/supabase/migrations/20251227224410_1fcdff72-7a96-463c-aa01-6b5b0ad1bc58.sql b/supabase/migrations/20251227224410_1fcdff72-7a96-463c-aa01-6b5b0ad1bc58.sql deleted file mode 100644 index 2f89595..0000000 --- a/supabase/migrations/20251227224410_1fcdff72-7a96-463c-aa01-6b5b0ad1bc58.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Create client_contacts table for multiple contacts per client -CREATE TABLE public.client_contacts ( - id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - client_id uuid NOT NULL REFERENCES public.clients(id) ON DELETE CASCADE, - name text NOT NULL, - title text, - email text, - phone text, - is_primary boolean DEFAULT false, - is_active boolean DEFAULT true, - notes text, - created_at timestamp with time zone NOT NULL DEFAULT now(), - updated_at timestamp with time zone NOT NULL DEFAULT now() -); - --- Enable RLS -ALTER TABLE public.client_contacts ENABLE ROW LEVEL SECURITY; - --- RLS policies -CREATE POLICY "All can view client contacts" -ON public.client_contacts FOR SELECT -USING (true); - -CREATE POLICY "Staff can manage client contacts" -ON public.client_contacts FOR ALL -USING (NOT has_role(auth.uid(), 'readonly')); - --- Create client_contact_history table for tracking changes -CREATE TABLE public.client_contact_history ( - id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - client_id uuid NOT NULL, - contact_id uuid, - event_type text NOT NULL, -- 'created', 'updated', 'deleted', 'set_primary' - description text NOT NULL, - old_values jsonb, - new_values jsonb, - created_by uuid, - created_at timestamp with time zone NOT NULL DEFAULT now() -); - --- Enable RLS -ALTER TABLE public.client_contact_history ENABLE ROW LEVEL SECURITY; - --- RLS policies -CREATE POLICY "All can view client contact history" -ON public.client_contact_history FOR SELECT -USING (true); - -CREATE POLICY "Staff can insert client contact history" -ON public.client_contact_history FOR INSERT -WITH CHECK (NOT has_role(auth.uid(), 'readonly')); - --- Trigger to update updated_at -CREATE TRIGGER update_client_contacts_updated_at -BEFORE UPDATE ON public.client_contacts -FOR EACH ROW -EXECUTE FUNCTION public.update_updated_at(); \ No newline at end of file diff --git a/supabase/migrations/20251227225150_77f7bade-3d64-4ad4-91e9-24eb24bee6d9.sql b/supabase/migrations/20251227225150_77f7bade-3d64-4ad4-91e9-24eb24bee6d9.sql deleted file mode 100644 index 825463a..0000000 --- a/supabase/migrations/20251227225150_77f7bade-3d64-4ad4-91e9-24eb24bee6d9.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Add billing details to trading_names table -ALTER TABLE public.trading_names -ADD COLUMN IF NOT EXISTS bank_name text, -ADD COLUMN IF NOT EXISTS bank_bsb text, -ADD COLUMN IF NOT EXISTS bank_account_number text, -ADD COLUMN IF NOT EXISTS bank_account_name text, -ADD COLUMN IF NOT EXISTS paypal_email text, -ADD COLUMN IF NOT EXISTS other_payment_details text; - --- Add billable defaults to clients table -ALTER TABLE public.clients -ADD COLUMN IF NOT EXISTS default_billable_time boolean DEFAULT true, -ADD COLUMN IF NOT EXISTS default_billable_expenses boolean DEFAULT false; \ No newline at end of file diff --git a/supabase/migrations/20251227235353_bdcbbf2c-702d-46e6-a755-178f55abe4d5.sql b/supabase/migrations/20251227235353_bdcbbf2c-702d-46e6-a755-178f55abe4d5.sql deleted file mode 100644 index e249e50..0000000 --- a/supabase/migrations/20251227235353_bdcbbf2c-702d-46e6-a755-178f55abe4d5.sql +++ /dev/null @@ -1,38 +0,0 @@ --- Add vendor_contacts table (similar to client_contacts) -CREATE TABLE public.vendor_contacts ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - vendor_id UUID NOT NULL REFERENCES public.vendors(id) ON DELETE CASCADE, - name TEXT NOT NULL, - title TEXT, - email TEXT, - phone TEXT, - notes TEXT, - is_primary BOOLEAN DEFAULT false, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Add credit_balance to vendors -ALTER TABLE public.vendors ADD COLUMN credit_balance NUMERIC DEFAULT 0; - --- Add vendor_id to issues table for linking issues to vendors -ALTER TABLE public.issues ADD COLUMN vendor_id UUID REFERENCES public.vendors(id); - --- Enable RLS on vendor_contacts -ALTER TABLE public.vendor_contacts ENABLE ROW LEVEL SECURITY; - --- RLS policies for vendor_contacts -CREATE POLICY "All can view vendor contacts" - ON public.vendor_contacts FOR SELECT - USING (true); - -CREATE POLICY "Staff can manage vendor contacts" - ON public.vendor_contacts FOR ALL - USING (NOT has_role(auth.uid(), 'readonly'::app_role)); - --- Trigger for updated_at -CREATE TRIGGER update_vendor_contacts_updated_at - BEFORE UPDATE ON public.vendor_contacts - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at(); \ No newline at end of file diff --git a/supabase/migrations/20251228000636_4a77b6e0-4c95-44a5-b3eb-5543f9ca29ff.sql b/supabase/migrations/20251228000636_4a77b6e0-4c95-44a5-b3eb-5543f9ca29ff.sql deleted file mode 100644 index 51f47cd..0000000 --- a/supabase/migrations/20251228000636_4a77b6e0-4c95-44a5-b3eb-5543f9ca29ff.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Add purchase_id to issues table for linking issues to purchases -ALTER TABLE public.issues ADD COLUMN purchase_id uuid REFERENCES public.purchases(id); - --- Create issue_items junction table for linking issues to inventory items -CREATE TABLE public.issue_items ( - id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - issue_id uuid NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE, - item_id uuid NOT NULL REFERENCES public.items(id) ON DELETE CASCADE, - created_at timestamp with time zone NOT NULL DEFAULT now(), - UNIQUE(issue_id, item_id) -); - --- Enable RLS -ALTER TABLE public.issue_items ENABLE ROW LEVEL SECURITY; - --- RLS policies -CREATE POLICY "All can view issue_items" ON public.issue_items - FOR SELECT USING (true); - -CREATE POLICY "Staff can manage issue_items" ON public.issue_items - FOR ALL USING (NOT has_role(auth.uid(), 'readonly'::app_role)); - --- Create index for faster lookups -CREATE INDEX idx_issues_purchase_id ON public.issues(purchase_id); -CREATE INDEX idx_issues_vendor_id ON public.issues(vendor_id); -CREATE INDEX idx_issue_items_issue_id ON public.issue_items(issue_id); -CREATE INDEX idx_issue_items_item_id ON public.issue_items(item_id); \ No newline at end of file diff --git a/supabase/migrations/20260102061415_3c281695-2c96-437d-8267-d5abb37ace79.sql b/supabase/migrations/20260102061415_3c281695-2c96-437d-8267-d5abb37ace79.sql deleted file mode 100644 index 135139b..0000000 --- a/supabase/migrations/20260102061415_3c281695-2c96-437d-8267-d5abb37ace79.sql +++ /dev/null @@ -1,200 +0,0 @@ --- Create permission_level enum -CREATE TYPE public.permission_level AS ENUM ('none', 'read', 'write'); - --- Create roles table for custom roles -CREATE TABLE public.roles ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text NOT NULL UNIQUE, - description text, - is_system boolean DEFAULT false, - created_at timestamptz DEFAULT now() -); - --- Create resources table (define controllable areas) -CREATE TABLE public.resources ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text NOT NULL UNIQUE, - display_name text NOT NULL, - category text, - sort_order integer DEFAULT 0 -); - --- Create role_permissions table (permission matrix) -CREATE TABLE public.role_permissions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - role_id uuid REFERENCES public.roles(id) ON DELETE CASCADE NOT NULL, - resource_id uuid REFERENCES public.resources(id) ON DELETE CASCADE NOT NULL, - permission permission_level DEFAULT 'none', - created_at timestamptz DEFAULT now(), - UNIQUE(role_id, resource_id) -); - --- Enable RLS on new tables -ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.resources ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.role_permissions ENABLE ROW LEVEL SECURITY; - --- Insert default system roles -INSERT INTO public.roles (name, description, is_system) VALUES - ('owner', 'Full system access including role management', true), - ('admin', 'Manage all data and team members', true), - ('staff', 'Standard access to business operations', true), - ('readonly', 'View-only access to all data', true); - --- Insert all resources -INSERT INTO public.resources (name, display_name, category, sort_order) VALUES - ('clients', 'Clients', 'Sales', 1), - ('jobs', 'Jobs', 'Sales', 2), - ('invoices', 'Invoices', 'Finance', 3), - ('payments', 'Payments', 'Finance', 4), - ('vendors', 'Vendors', 'Purchasing', 5), - ('purchases', 'Purchases', 'Purchasing', 6), - ('items', 'Inventory Items', 'Operations', 7), - ('assets', 'Assets', 'Operations', 8), - ('issues', 'Issues', 'Operations', 9), - ('timesheets', 'Timesheets', 'Operations', 10), - ('expenses', 'Expenses', 'Finance', 11), - ('reports', 'Reports', 'Analytics', 12), - ('team', 'Team Management', 'Settings', 13), - ('settings', 'Company Settings', 'Settings', 14), - ('roles', 'Role Management', 'Settings', 15); - --- Set up default permissions for system roles --- Owner gets write on everything -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, 'write'::permission_level -FROM public.roles r, public.resources res -WHERE r.name = 'owner'; - --- Admin gets write on everything except roles -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, - CASE WHEN res.name = 'roles' THEN 'none'::permission_level ELSE 'write'::permission_level END -FROM public.roles r, public.resources res -WHERE r.name = 'admin'; - --- Staff gets mixed permissions -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, - CASE - WHEN res.name IN ('clients', 'jobs', 'invoices', 'payments', 'issues', 'timesheets', 'expenses') THEN 'write'::permission_level - WHEN res.name IN ('vendors', 'purchases', 'items', 'assets', 'reports', 'settings') THEN 'read'::permission_level - ELSE 'none'::permission_level - END -FROM public.roles r, public.resources res -WHERE r.name = 'staff'; - --- Readonly gets read on everything except team and roles -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, - CASE - WHEN res.name IN ('team', 'roles') THEN 'none'::permission_level - ELSE 'read'::permission_level - END -FROM public.roles r, public.resources res -WHERE r.name = 'readonly'; - --- Add role_id column to user_roles table -ALTER TABLE public.user_roles ADD COLUMN role_id uuid REFERENCES public.roles(id); - --- Migrate existing role enum values to role_id -UPDATE public.user_roles ur -SET role_id = r.id -FROM public.roles r -WHERE ur.role::text = r.name; - --- Make role_id NOT NULL after migration -ALTER TABLE public.user_roles ALTER COLUMN role_id SET NOT NULL; - --- Create function to get user's permission level for a resource -CREATE OR REPLACE FUNCTION public.get_user_permission(_user_id uuid, _resource_name text) -RETURNS permission_level -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public -AS $$ - SELECT COALESCE( - (SELECT rp.permission - FROM role_permissions rp - JOIN roles r ON r.id = rp.role_id - JOIN user_roles ur ON ur.role_id = r.id - JOIN resources res ON res.id = rp.resource_id - WHERE ur.user_id = _user_id AND res.name = _resource_name - ORDER BY - CASE rp.permission - WHEN 'write' THEN 1 - WHEN 'read' THEN 2 - ELSE 3 - END - LIMIT 1), - 'none'::permission_level - ) -$$; - --- Check if user can read a resource -CREATE OR REPLACE FUNCTION public.can_read(_resource_name text) -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public -AS $$ - SELECT get_user_permission(auth.uid(), _resource_name) IN ('read', 'write') -$$; - --- Check if user can write to a resource -CREATE OR REPLACE FUNCTION public.can_write(_resource_name text) -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public -AS $$ - SELECT get_user_permission(auth.uid(), _resource_name) = 'write' -$$; - --- Check if user is owner -CREATE OR REPLACE FUNCTION public.is_owner(_user_id uuid) -RETURNS boolean -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public -AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.user_roles ur - JOIN public.roles r ON r.id = ur.role_id - WHERE ur.user_id = _user_id AND r.name = 'owner' - ) -$$; - --- RLS policies for roles table -CREATE POLICY "Anyone can view roles" -ON public.roles FOR SELECT -TO authenticated -USING (true); - -CREATE POLICY "Only owners can manage roles" -ON public.roles FOR ALL -TO authenticated -USING (is_owner(auth.uid())) -WITH CHECK (is_owner(auth.uid())); - --- RLS policies for resources table -CREATE POLICY "Anyone can view resources" -ON public.resources FOR SELECT -TO authenticated -USING (true); - --- RLS policies for role_permissions table -CREATE POLICY "Anyone can view role permissions" -ON public.role_permissions FOR SELECT -TO authenticated -USING (true); - -CREATE POLICY "Only owners can manage role permissions" -ON public.role_permissions FOR ALL -TO authenticated -USING (is_owner(auth.uid())) -WITH CHECK (is_owner(auth.uid())); \ No newline at end of file diff --git a/supabase/migrations/20260102061549_791aaf2f-2009-41de-bd30-62aac6b87677.sql b/supabase/migrations/20260102061549_791aaf2f-2009-41de-bd30-62aac6b87677.sql deleted file mode 100644 index 8b0c0b3..0000000 --- a/supabase/migrations/20260102061549_791aaf2f-2009-41de-bd30-62aac6b87677.sql +++ /dev/null @@ -1,290 +0,0 @@ --- Update RLS policies to use permission-based access instead of user_id scoping --- This converts to single-tenant where all authenticated users share data based on their role permissions - --- CLIENTS -DROP POLICY IF EXISTS "Users can view own clients" ON public.clients; -DROP POLICY IF EXISTS "Users can manage own clients" ON public.clients; -CREATE POLICY "Users can view clients" ON public.clients FOR SELECT TO authenticated USING (can_read('clients')); -CREATE POLICY "Users can insert clients" ON public.clients FOR INSERT TO authenticated WITH CHECK (can_write('clients')); -CREATE POLICY "Users can update clients" ON public.clients FOR UPDATE TO authenticated USING (can_write('clients')); -CREATE POLICY "Users can delete clients" ON public.clients FOR DELETE TO authenticated USING (can_write('clients')); - --- CLIENT_CONTACTS -DROP POLICY IF EXISTS "Users can view own client contacts" ON public.client_contacts; -DROP POLICY IF EXISTS "Users can manage own client contacts" ON public.client_contacts; -CREATE POLICY "Users can view client contacts" ON public.client_contacts FOR SELECT TO authenticated USING (can_read('clients')); -CREATE POLICY "Users can insert client contacts" ON public.client_contacts FOR INSERT TO authenticated WITH CHECK (can_write('clients')); -CREATE POLICY "Users can update client contacts" ON public.client_contacts FOR UPDATE TO authenticated USING (can_write('clients')); -CREATE POLICY "Users can delete client contacts" ON public.client_contacts FOR DELETE TO authenticated USING (can_write('clients')); - --- CLIENT_CONTACT_HISTORY -DROP POLICY IF EXISTS "Users can view own client contact history" ON public.client_contact_history; -DROP POLICY IF EXISTS "Users can insert own client contact history" ON public.client_contact_history; -CREATE POLICY "Users can view client contact history" ON public.client_contact_history FOR SELECT TO authenticated USING (can_read('clients')); -CREATE POLICY "Users can insert client contact history" ON public.client_contact_history FOR INSERT TO authenticated WITH CHECK (can_write('clients')); - --- JOBS -DROP POLICY IF EXISTS "Users can view own jobs" ON public.jobs; -DROP POLICY IF EXISTS "Users can manage own jobs" ON public.jobs; -CREATE POLICY "Users can view jobs" ON public.jobs FOR SELECT TO authenticated USING (can_read('jobs')); -CREATE POLICY "Users can insert jobs" ON public.jobs FOR INSERT TO authenticated WITH CHECK (can_write('jobs')); -CREATE POLICY "Users can update jobs" ON public.jobs FOR UPDATE TO authenticated USING (can_write('jobs')); -CREATE POLICY "Users can delete jobs" ON public.jobs FOR DELETE TO authenticated USING (can_write('jobs')); - --- JOB_ASSETS -DROP POLICY IF EXISTS "Users can view own job_assets" ON public.job_assets; -DROP POLICY IF EXISTS "Users can manage own job_assets" ON public.job_assets; -CREATE POLICY "Users can view job_assets" ON public.job_assets FOR SELECT TO authenticated USING (can_read('jobs')); -CREATE POLICY "Users can insert job_assets" ON public.job_assets FOR INSERT TO authenticated WITH CHECK (can_write('jobs')); -CREATE POLICY "Users can update job_assets" ON public.job_assets FOR UPDATE TO authenticated USING (can_write('jobs')); -CREATE POLICY "Users can delete job_assets" ON public.job_assets FOR DELETE TO authenticated USING (can_write('jobs')); - --- JOB_ASSIGNMENTS -DROP POLICY IF EXISTS "Users can view own job assignments" ON public.job_assignments; -DROP POLICY IF EXISTS "Users can manage own job assignments" ON public.job_assignments; -CREATE POLICY "Users can view job assignments" ON public.job_assignments FOR SELECT TO authenticated USING (can_read('jobs')); -CREATE POLICY "Users can insert job assignments" ON public.job_assignments FOR INSERT TO authenticated WITH CHECK (can_write('jobs')); -CREATE POLICY "Users can update job assignments" ON public.job_assignments FOR UPDATE TO authenticated USING (can_write('jobs')); -CREATE POLICY "Users can delete job assignments" ON public.job_assignments FOR DELETE TO authenticated USING (can_write('jobs')); - --- INVOICES -DROP POLICY IF EXISTS "Users can view own invoices" ON public.invoices; -DROP POLICY IF EXISTS "Users can manage own invoices" ON public.invoices; -CREATE POLICY "Users can view invoices" ON public.invoices FOR SELECT TO authenticated USING (can_read('invoices')); -CREATE POLICY "Users can insert invoices" ON public.invoices FOR INSERT TO authenticated WITH CHECK (can_write('invoices')); -CREATE POLICY "Users can update invoices" ON public.invoices FOR UPDATE TO authenticated USING (can_write('invoices')); -CREATE POLICY "Users can delete invoices" ON public.invoices FOR DELETE TO authenticated USING (can_write('invoices')); - --- INVOICE_LINES -DROP POLICY IF EXISTS "Users can view own invoice lines" ON public.invoice_lines; -DROP POLICY IF EXISTS "Users can manage own invoice lines" ON public.invoice_lines; -CREATE POLICY "Users can view invoice lines" ON public.invoice_lines FOR SELECT TO authenticated USING (can_read('invoices')); -CREATE POLICY "Users can insert invoice lines" ON public.invoice_lines FOR INSERT TO authenticated WITH CHECK (can_write('invoices')); -CREATE POLICY "Users can update invoice lines" ON public.invoice_lines FOR UPDATE TO authenticated USING (can_write('invoices')); -CREATE POLICY "Users can delete invoice lines" ON public.invoice_lines FOR DELETE TO authenticated USING (can_write('invoices')); - --- PAYMENTS -DROP POLICY IF EXISTS "Users can view own payments" ON public.payments; -DROP POLICY IF EXISTS "Users can manage own payments" ON public.payments; -CREATE POLICY "Users can view payments" ON public.payments FOR SELECT TO authenticated USING (can_read('payments')); -CREATE POLICY "Users can insert payments" ON public.payments FOR INSERT TO authenticated WITH CHECK (can_write('payments')); -CREATE POLICY "Users can update payments" ON public.payments FOR UPDATE TO authenticated USING (can_write('payments')); -CREATE POLICY "Users can delete payments" ON public.payments FOR DELETE TO authenticated USING (can_write('payments')); - --- VENDORS -DROP POLICY IF EXISTS "Users can view own vendors" ON public.vendors; -DROP POLICY IF EXISTS "Users can manage own vendors" ON public.vendors; -CREATE POLICY "Users can view vendors" ON public.vendors FOR SELECT TO authenticated USING (can_read('vendors')); -CREATE POLICY "Users can insert vendors" ON public.vendors FOR INSERT TO authenticated WITH CHECK (can_write('vendors')); -CREATE POLICY "Users can update vendors" ON public.vendors FOR UPDATE TO authenticated USING (can_write('vendors')); -CREATE POLICY "Users can delete vendors" ON public.vendors FOR DELETE TO authenticated USING (can_write('vendors')); - --- VENDOR_CONTACTS -DROP POLICY IF EXISTS "Users can view own vendor contacts" ON public.vendor_contacts; -DROP POLICY IF EXISTS "Users can manage own vendor contacts" ON public.vendor_contacts; -CREATE POLICY "Users can view vendor contacts" ON public.vendor_contacts FOR SELECT TO authenticated USING (can_read('vendors')); -CREATE POLICY "Users can insert vendor contacts" ON public.vendor_contacts FOR INSERT TO authenticated WITH CHECK (can_write('vendors')); -CREATE POLICY "Users can update vendor contacts" ON public.vendor_contacts FOR UPDATE TO authenticated USING (can_write('vendors')); -CREATE POLICY "Users can delete vendor contacts" ON public.vendor_contacts FOR DELETE TO authenticated USING (can_write('vendors')); - --- PURCHASES -DROP POLICY IF EXISTS "Users can view own purchases" ON public.purchases; -DROP POLICY IF EXISTS "Users can manage own purchases" ON public.purchases; -CREATE POLICY "Users can view purchases" ON public.purchases FOR SELECT TO authenticated USING (can_read('purchases')); -CREATE POLICY "Users can insert purchases" ON public.purchases FOR INSERT TO authenticated WITH CHECK (can_write('purchases')); -CREATE POLICY "Users can update purchases" ON public.purchases FOR UPDATE TO authenticated USING (can_write('purchases')); -CREATE POLICY "Users can delete purchases" ON public.purchases FOR DELETE TO authenticated USING (can_write('purchases')); - --- PURCHASE_ALLOCATIONS -DROP POLICY IF EXISTS "Users can view own purchase allocations" ON public.purchase_allocations; -DROP POLICY IF EXISTS "Users can manage own purchase allocations" ON public.purchase_allocations; -CREATE POLICY "Users can view purchase allocations" ON public.purchase_allocations FOR SELECT TO authenticated USING (can_read('purchases')); -CREATE POLICY "Users can insert purchase allocations" ON public.purchase_allocations FOR INSERT TO authenticated WITH CHECK (can_write('purchases')); -CREATE POLICY "Users can update purchase allocations" ON public.purchase_allocations FOR UPDATE TO authenticated USING (can_write('purchases')); -CREATE POLICY "Users can delete purchase allocations" ON public.purchase_allocations FOR DELETE TO authenticated USING (can_write('purchases')); - --- ITEMS -DROP POLICY IF EXISTS "Users can view own items" ON public.items; -DROP POLICY IF EXISTS "Users can manage own items" ON public.items; -CREATE POLICY "Users can view items" ON public.items FOR SELECT TO authenticated USING (can_read('items')); -CREATE POLICY "Users can insert items" ON public.items FOR INSERT TO authenticated WITH CHECK (can_write('items')); -CREATE POLICY "Users can update items" ON public.items FOR UPDATE TO authenticated USING (can_write('items')); -CREATE POLICY "Users can delete items" ON public.items FOR DELETE TO authenticated USING (can_write('items')); - --- ITEM_PRICE_HISTORY -DROP POLICY IF EXISTS "Users can view own price history" ON public.item_price_history; -DROP POLICY IF EXISTS "Users can manage own price history" ON public.item_price_history; -CREATE POLICY "Users can view price history" ON public.item_price_history FOR SELECT TO authenticated USING (can_read('items')); -CREATE POLICY "Users can insert price history" ON public.item_price_history FOR INSERT TO authenticated WITH CHECK (can_write('items')); -CREATE POLICY "Users can update price history" ON public.item_price_history FOR UPDATE TO authenticated USING (can_write('items')); -CREATE POLICY "Users can delete price history" ON public.item_price_history FOR DELETE TO authenticated USING (can_write('items')); - --- INVENTORY_MOVEMENTS -DROP POLICY IF EXISTS "Users can view own inventory movements" ON public.inventory_movements; -DROP POLICY IF EXISTS "Users can manage own inventory movements" ON public.inventory_movements; -CREATE POLICY "Users can view inventory movements" ON public.inventory_movements FOR SELECT TO authenticated USING (can_read('items')); -CREATE POLICY "Users can insert inventory movements" ON public.inventory_movements FOR INSERT TO authenticated WITH CHECK (can_write('items')); -CREATE POLICY "Users can update inventory movements" ON public.inventory_movements FOR UPDATE TO authenticated USING (can_write('items')); -CREATE POLICY "Users can delete inventory movements" ON public.inventory_movements FOR DELETE TO authenticated USING (can_write('items')); - --- ASSETS -DROP POLICY IF EXISTS "Users can view own assets" ON public.assets; -DROP POLICY IF EXISTS "Users can manage own assets" ON public.assets; -CREATE POLICY "Users can view assets" ON public.assets FOR SELECT TO authenticated USING (can_read('assets')); -CREATE POLICY "Users can insert assets" ON public.assets FOR INSERT TO authenticated WITH CHECK (can_write('assets')); -CREATE POLICY "Users can update assets" ON public.assets FOR UPDATE TO authenticated USING (can_write('assets')); -CREATE POLICY "Users can delete assets" ON public.assets FOR DELETE TO authenticated USING (can_write('assets')); - --- ASSET_HISTORY -DROP POLICY IF EXISTS "Users can view own asset history" ON public.asset_history; -DROP POLICY IF EXISTS "Users can manage own asset history" ON public.asset_history; -CREATE POLICY "Users can view asset history" ON public.asset_history FOR SELECT TO authenticated USING (can_read('assets')); -CREATE POLICY "Users can insert asset history" ON public.asset_history FOR INSERT TO authenticated WITH CHECK (can_write('assets')); -CREATE POLICY "Users can update asset history" ON public.asset_history FOR UPDATE TO authenticated USING (can_write('assets')); -CREATE POLICY "Users can delete asset history" ON public.asset_history FOR DELETE TO authenticated USING (can_write('assets')); - --- ASSET_MAINTENANCE -DROP POLICY IF EXISTS "Users can view own asset maintenance" ON public.asset_maintenance; -DROP POLICY IF EXISTS "Users can manage own asset maintenance" ON public.asset_maintenance; -CREATE POLICY "Users can view asset maintenance" ON public.asset_maintenance FOR SELECT TO authenticated USING (can_read('assets')); -CREATE POLICY "Users can insert asset maintenance" ON public.asset_maintenance FOR INSERT TO authenticated WITH CHECK (can_write('assets')); -CREATE POLICY "Users can update asset maintenance" ON public.asset_maintenance FOR UPDATE TO authenticated USING (can_write('assets')); -CREATE POLICY "Users can delete asset maintenance" ON public.asset_maintenance FOR DELETE TO authenticated USING (can_write('assets')); - --- ASSET_VERSIONS -DROP POLICY IF EXISTS "Users can view own asset versions" ON public.asset_versions; -DROP POLICY IF EXISTS "Users can manage own asset versions" ON public.asset_versions; -CREATE POLICY "Users can view asset versions" ON public.asset_versions FOR SELECT TO authenticated USING (can_read('assets')); -CREATE POLICY "Users can insert asset versions" ON public.asset_versions FOR INSERT TO authenticated WITH CHECK (can_write('assets')); -CREATE POLICY "Users can update asset versions" ON public.asset_versions FOR UPDATE TO authenticated USING (can_write('assets')); -CREATE POLICY "Users can delete asset versions" ON public.asset_versions FOR DELETE TO authenticated USING (can_write('assets')); - --- ISSUES -DROP POLICY IF EXISTS "Users can view own issues" ON public.issues; -DROP POLICY IF EXISTS "Users can manage own issues" ON public.issues; -CREATE POLICY "Users can view issues" ON public.issues FOR SELECT TO authenticated USING (can_read('issues')); -CREATE POLICY "Users can insert issues" ON public.issues FOR INSERT TO authenticated WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update issues" ON public.issues FOR UPDATE TO authenticated USING (can_write('issues')); -CREATE POLICY "Users can delete issues" ON public.issues FOR DELETE TO authenticated USING (can_write('issues')); - --- ISSUE_ASSETS -DROP POLICY IF EXISTS "Users can view own issue_assets" ON public.issue_assets; -DROP POLICY IF EXISTS "Users can manage own issue_assets" ON public.issue_assets; -CREATE POLICY "Users can view issue_assets" ON public.issue_assets FOR SELECT TO authenticated USING (can_read('issues')); -CREATE POLICY "Users can insert issue_assets" ON public.issue_assets FOR INSERT TO authenticated WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update issue_assets" ON public.issue_assets FOR UPDATE TO authenticated USING (can_write('issues')); -CREATE POLICY "Users can delete issue_assets" ON public.issue_assets FOR DELETE TO authenticated USING (can_write('issues')); - --- ISSUE_COMMENTS -DROP POLICY IF EXISTS "Users can view own issue comments" ON public.issue_comments; -DROP POLICY IF EXISTS "Users can add own issue comments" ON public.issue_comments; -CREATE POLICY "Users can view issue comments" ON public.issue_comments FOR SELECT TO authenticated USING (can_read('issues')); -CREATE POLICY "Users can insert issue comments" ON public.issue_comments FOR INSERT TO authenticated WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update issue comments" ON public.issue_comments FOR UPDATE TO authenticated USING (can_write('issues')); -CREATE POLICY "Users can delete issue comments" ON public.issue_comments FOR DELETE TO authenticated USING (can_write('issues')); - --- ISSUE_ITEMS -DROP POLICY IF EXISTS "Users can view own issue_items" ON public.issue_items; -DROP POLICY IF EXISTS "Users can manage own issue_items" ON public.issue_items; -CREATE POLICY "Users can view issue_items" ON public.issue_items FOR SELECT TO authenticated USING (can_read('issues')); -CREATE POLICY "Users can insert issue_items" ON public.issue_items FOR INSERT TO authenticated WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update issue_items" ON public.issue_items FOR UPDATE TO authenticated USING (can_write('issues')); -CREATE POLICY "Users can delete issue_items" ON public.issue_items FOR DELETE TO authenticated USING (can_write('issues')); - --- ISSUE_JOBS -DROP POLICY IF EXISTS "Users can view own issue_jobs" ON public.issue_jobs; -DROP POLICY IF EXISTS "Users can manage own issue_jobs" ON public.issue_jobs; -CREATE POLICY "Users can view issue_jobs" ON public.issue_jobs FOR SELECT TO authenticated USING (can_read('issues')); -CREATE POLICY "Users can insert issue_jobs" ON public.issue_jobs FOR INSERT TO authenticated WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update issue_jobs" ON public.issue_jobs FOR UPDATE TO authenticated USING (can_write('issues')); -CREATE POLICY "Users can delete issue_jobs" ON public.issue_jobs FOR DELETE TO authenticated USING (can_write('issues')); - --- TIMESHEETS -DROP POLICY IF EXISTS "Users can view own timesheets" ON public.timesheets; -DROP POLICY IF EXISTS "Users can manage own timesheets" ON public.timesheets; -CREATE POLICY "Users can view timesheets" ON public.timesheets FOR SELECT TO authenticated USING (can_read('timesheets')); -CREATE POLICY "Users can insert timesheets" ON public.timesheets FOR INSERT TO authenticated WITH CHECK (can_write('timesheets')); -CREATE POLICY "Users can update timesheets" ON public.timesheets FOR UPDATE TO authenticated USING (can_write('timesheets')); -CREATE POLICY "Users can delete timesheets" ON public.timesheets FOR DELETE TO authenticated USING (can_write('timesheets')); - --- EXPENSES -DROP POLICY IF EXISTS "Users can view own expenses" ON public.expenses; -DROP POLICY IF EXISTS "Users can manage own expenses" ON public.expenses; -CREATE POLICY "Users can view expenses" ON public.expenses FOR SELECT TO authenticated USING (can_read('expenses')); -CREATE POLICY "Users can insert expenses" ON public.expenses FOR INSERT TO authenticated WITH CHECK (can_write('expenses')); -CREATE POLICY "Users can update expenses" ON public.expenses FOR UPDATE TO authenticated USING (can_write('expenses')); -CREATE POLICY "Users can delete expenses" ON public.expenses FOR DELETE TO authenticated USING (can_write('expenses')); - --- ACCOUNTS -DROP POLICY IF EXISTS "Users can view own accounts" ON public.accounts; -DROP POLICY IF EXISTS "Users can manage own accounts" ON public.accounts; -CREATE POLICY "Users can view accounts" ON public.accounts FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert accounts" ON public.accounts FOR INSERT TO authenticated WITH CHECK (can_write('settings')); -CREATE POLICY "Users can update accounts" ON public.accounts FOR UPDATE TO authenticated USING (can_write('settings')); -CREATE POLICY "Users can delete accounts" ON public.accounts FOR DELETE TO authenticated USING (can_write('settings')); - --- TAX_RATES -DROP POLICY IF EXISTS "Users can view own tax rates" ON public.tax_rates; -DROP POLICY IF EXISTS "Users can manage own tax rates" ON public.tax_rates; -CREATE POLICY "Users can view tax rates" ON public.tax_rates FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert tax rates" ON public.tax_rates FOR INSERT TO authenticated WITH CHECK (can_write('settings')); -CREATE POLICY "Users can update tax rates" ON public.tax_rates FOR UPDATE TO authenticated USING (can_write('settings')); -CREATE POLICY "Users can delete tax rates" ON public.tax_rates FOR DELETE TO authenticated USING (can_write('settings')); - --- TRADING_NAMES -DROP POLICY IF EXISTS "Users can view own trading names" ON public.trading_names; -DROP POLICY IF EXISTS "Users can manage own trading names" ON public.trading_names; -CREATE POLICY "Users can view trading names" ON public.trading_names FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert trading names" ON public.trading_names FOR INSERT TO authenticated WITH CHECK (can_write('settings')); -CREATE POLICY "Users can update trading names" ON public.trading_names FOR UPDATE TO authenticated USING (can_write('settings')); -CREATE POLICY "Users can delete trading names" ON public.trading_names FOR DELETE TO authenticated USING (can_write('settings')); - --- COMPANY_SETTINGS -DROP POLICY IF EXISTS "Users can view own company settings" ON public.company_settings; -DROP POLICY IF EXISTS "Users can manage own company settings" ON public.company_settings; -CREATE POLICY "Users can view company settings" ON public.company_settings FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert company settings" ON public.company_settings FOR INSERT TO authenticated WITH CHECK (can_write('settings')); -CREATE POLICY "Users can update company settings" ON public.company_settings FOR UPDATE TO authenticated USING (can_write('settings')); -CREATE POLICY "Users can delete company settings" ON public.company_settings FOR DELETE TO authenticated USING (can_write('settings')); - --- JOURNAL_ENTRIES -DROP POLICY IF EXISTS "Users can view own journal entries" ON public.journal_entries; -DROP POLICY IF EXISTS "Users can insert own journal entries" ON public.journal_entries; -CREATE POLICY "Users can view journal entries" ON public.journal_entries FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert journal entries" ON public.journal_entries FOR INSERT TO authenticated WITH CHECK (can_write('settings')); - --- JOURNAL_LINES -DROP POLICY IF EXISTS "Users can view own journal lines" ON public.journal_lines; -DROP POLICY IF EXISTS "Users can insert own journal lines" ON public.journal_lines; -CREATE POLICY "Users can view journal lines" ON public.journal_lines FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert journal lines" ON public.journal_lines FOR INSERT TO authenticated WITH CHECK (can_write('settings')); - --- ACTIVITY_LOG -DROP POLICY IF EXISTS "Users can view own activity log" ON public.activity_log; -DROP POLICY IF EXISTS "Users can insert activity log" ON public.activity_log; -CREATE POLICY "Users can view activity log" ON public.activity_log FOR SELECT TO authenticated USING (can_read('settings')); -CREATE POLICY "Users can insert activity log" ON public.activity_log FOR INSERT TO authenticated WITH CHECK (true); - --- ATTACHMENTS -DROP POLICY IF EXISTS "Users can view own attachments" ON public.attachments; -DROP POLICY IF EXISTS "Users can manage own attachments" ON public.attachments; -CREATE POLICY "Users can view attachments" ON public.attachments FOR SELECT TO authenticated USING (true); -CREATE POLICY "Users can insert attachments" ON public.attachments FOR INSERT TO authenticated WITH CHECK (true); -CREATE POLICY "Users can update attachments" ON public.attachments FOR UPDATE TO authenticated USING (true); -CREATE POLICY "Users can delete attachments" ON public.attachments FOR DELETE TO authenticated USING (true); - --- USER_ROLES - Update to use can_write('team') for management -DROP POLICY IF EXISTS "Admins can view all roles" ON public.user_roles; -DROP POLICY IF EXISTS "Admins can manage roles" ON public.user_roles; -CREATE POLICY "Users can view user roles" ON public.user_roles FOR SELECT TO authenticated USING (can_read('team')); -CREATE POLICY "Users can insert user roles" ON public.user_roles FOR INSERT TO authenticated WITH CHECK (can_write('team')); -CREATE POLICY "Users can update user roles" ON public.user_roles FOR UPDATE TO authenticated USING (can_write('team')); -CREATE POLICY "Users can delete user roles" ON public.user_roles FOR DELETE TO authenticated USING (can_write('team')); - --- PROFILES - Everyone can view, only update own -DROP POLICY IF EXISTS "Users can view all profiles" ON public.profiles; -DROP POLICY IF EXISTS "Users can insert own profile" ON public.profiles; -DROP POLICY IF EXISTS "Users can update own profile" ON public.profiles; -CREATE POLICY "Users can view profiles" ON public.profiles FOR SELECT TO authenticated USING (true); -CREATE POLICY "Users can insert profiles" ON public.profiles FOR INSERT TO authenticated WITH CHECK (auth.uid() = id); -CREATE POLICY "Users can update profiles" ON public.profiles FOR UPDATE TO authenticated USING (auth.uid() = id OR can_write('team')); \ No newline at end of file diff --git a/supabase/migrations/20260102072900_91ca73aa-c2df-46e5-9665-973292f39463.sql b/supabase/migrations/20260102072900_91ca73aa-c2df-46e5-9665-973292f39463.sql deleted file mode 100644 index 103765b..0000000 --- a/supabase/migrations/20260102072900_91ca73aa-c2df-46e5-9665-973292f39463.sql +++ /dev/null @@ -1,93 +0,0 @@ --- Create bank_accounts table -CREATE TABLE public.bank_accounts ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text NOT NULL, - account_id uuid REFERENCES accounts(id), - bank_name text, - bsb text, - account_number text, - opening_balance numeric DEFAULT 0, - opening_balance_date date, - current_balance numeric DEFAULT 0, - is_active boolean DEFAULT true, - is_default boolean DEFAULT false, - user_id uuid, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now() -); - --- Create bank_transactions table -CREATE TABLE public.bank_transactions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - bank_account_id uuid REFERENCES bank_accounts(id) ON DELETE CASCADE NOT NULL, - date date NOT NULL, - description text NOT NULL, - amount numeric NOT NULL, - reference text, - balance_after numeric, - matched_payment_id uuid REFERENCES payments(id) ON DELETE SET NULL, - matched_purchase_id uuid REFERENCES purchases(id) ON DELETE SET NULL, - is_reconciled boolean DEFAULT false, - reconciled_at timestamptz, - reconciled_by uuid, - import_batch_id uuid, - imported_at timestamptz, - created_at timestamptz DEFAULT now() -); - --- Add banking resource -INSERT INTO public.resources (name, display_name, category, sort_order) -VALUES ('banking', 'Banking', 'Finance', 15); - --- Add banking permissions to existing roles -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, - CASE - WHEN r.name IN ('owner', 'admin') THEN 'write'::permission_level - WHEN r.name = 'staff' THEN 'read'::permission_level - ELSE 'none'::permission_level - END -FROM public.roles r -CROSS JOIN public.resources res -WHERE res.name = 'banking'; - --- Enable RLS -ALTER TABLE public.bank_accounts ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.bank_transactions ENABLE ROW LEVEL SECURITY; - --- RLS policies for bank_accounts -CREATE POLICY "Users can view bank accounts" ON public.bank_accounts - FOR SELECT USING (can_read('banking')); - -CREATE POLICY "Users can insert bank accounts" ON public.bank_accounts - FOR INSERT WITH CHECK (can_write('banking')); - -CREATE POLICY "Users can update bank accounts" ON public.bank_accounts - FOR UPDATE USING (can_write('banking')); - -CREATE POLICY "Users can delete bank accounts" ON public.bank_accounts - FOR DELETE USING (can_write('banking')); - --- RLS policies for bank_transactions -CREATE POLICY "Users can view bank transactions" ON public.bank_transactions - FOR SELECT USING (can_read('banking')); - -CREATE POLICY "Users can insert bank transactions" ON public.bank_transactions - FOR INSERT WITH CHECK (can_write('banking')); - -CREATE POLICY "Users can update bank transactions" ON public.bank_transactions - FOR UPDATE USING (can_write('banking')); - -CREATE POLICY "Users can delete bank transactions" ON public.bank_transactions - FOR DELETE USING (can_write('banking')); - --- Add updated_at trigger for bank_accounts -CREATE TRIGGER update_bank_accounts_updated_at - BEFORE UPDATE ON public.bank_accounts - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at(); - --- Create indexes for performance -CREATE INDEX idx_bank_transactions_account ON public.bank_transactions(bank_account_id); -CREATE INDEX idx_bank_transactions_date ON public.bank_transactions(date DESC); -CREATE INDEX idx_bank_transactions_reconciled ON public.bank_transactions(is_reconciled); \ No newline at end of file diff --git a/supabase/migrations/20260102084127_51b9410b-08cd-4df3-b5ad-1b0cde0abe37.sql b/supabase/migrations/20260102084127_51b9410b-08cd-4df3-b5ad-1b0cde0abe37.sql deleted file mode 100644 index f2b2851..0000000 --- a/supabase/migrations/20260102084127_51b9410b-08cd-4df3-b5ad-1b0cde0abe37.sql +++ /dev/null @@ -1,90 +0,0 @@ --- Create asset_documents table for PDF uploads -CREATE TABLE public.asset_documents ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - asset_id UUID NOT NULL REFERENCES public.assets(id) ON DELETE CASCADE, - name TEXT NOT NULL, - file_url TEXT NOT NULL, - file_type TEXT NOT NULL DEFAULT 'pdf', - file_size INTEGER, - uploaded_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Create issue_bookmarks table for resolution bookmarks -CREATE TABLE public.issue_bookmarks ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - issue_id UUID NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE, - bookmark_label TEXT NOT NULL, - content TEXT NOT NULL, - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() -); - --- Create issue_bookmark_links for cross-issue references -CREATE TABLE public.issue_bookmark_links ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - source_issue_id UUID NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE, - target_bookmark_id UUID NOT NULL REFERENCES public.issue_bookmarks(id) ON DELETE CASCADE, - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - UNIQUE(source_issue_id, target_bookmark_id) -); - --- Enable RLS on all new tables -ALTER TABLE public.asset_documents ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.issue_bookmarks ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.issue_bookmark_links ENABLE ROW LEVEL SECURITY; - --- RLS policies for asset_documents -CREATE POLICY "Users can view asset documents" ON public.asset_documents - FOR SELECT USING (can_read('assets')); - -CREATE POLICY "Users can insert asset documents" ON public.asset_documents - FOR INSERT WITH CHECK (can_write('assets')); - -CREATE POLICY "Users can update asset documents" ON public.asset_documents - FOR UPDATE USING (can_write('assets')); - -CREATE POLICY "Users can delete asset documents" ON public.asset_documents - FOR DELETE USING (can_write('assets')); - --- RLS policies for issue_bookmarks -CREATE POLICY "Users can view issue bookmarks" ON public.issue_bookmarks - FOR SELECT USING (can_read('issues')); - -CREATE POLICY "Users can insert issue bookmarks" ON public.issue_bookmarks - FOR INSERT WITH CHECK (can_write('issues')); - -CREATE POLICY "Users can update issue bookmarks" ON public.issue_bookmarks - FOR UPDATE USING (can_write('issues')); - -CREATE POLICY "Users can delete issue bookmarks" ON public.issue_bookmarks - FOR DELETE USING (can_write('issues')); - --- RLS policies for issue_bookmark_links -CREATE POLICY "Users can view bookmark links" ON public.issue_bookmark_links - FOR SELECT USING (can_read('issues')); - -CREATE POLICY "Users can insert bookmark links" ON public.issue_bookmark_links - FOR INSERT WITH CHECK (can_write('issues')); - -CREATE POLICY "Users can delete bookmark links" ON public.issue_bookmark_links - FOR DELETE USING (can_write('issues')); - --- Create a documents storage bucket for PDFs -INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) -VALUES ('documents', 'documents', true, 10485760, ARRAY['application/pdf']) -ON CONFLICT (id) DO NOTHING; - --- Storage policies for documents bucket -CREATE POLICY "Anyone can view documents" ON storage.objects - FOR SELECT USING (bucket_id = 'documents'); - -CREATE POLICY "Authenticated users can upload documents" ON storage.objects - FOR INSERT WITH CHECK (bucket_id = 'documents' AND auth.role() = 'authenticated'); - -CREATE POLICY "Authenticated users can update documents" ON storage.objects - FOR UPDATE USING (bucket_id = 'documents' AND auth.role() = 'authenticated'); - -CREATE POLICY "Authenticated users can delete documents" ON storage.objects - FOR DELETE USING (bucket_id = 'documents' AND auth.role() = 'authenticated'); \ No newline at end of file diff --git a/supabase/migrations/20260102091335_08077ead-85d3-4435-b78b-b56d10a5268e.sql b/supabase/migrations/20260102091335_08077ead-85d3-4435-b78b-b56d10a5268e.sql deleted file mode 100644 index 36c40e2..0000000 --- a/supabase/migrations/20260102091335_08077ead-85d3-4435-b78b-b56d10a5268e.sql +++ /dev/null @@ -1,141 +0,0 @@ --- Create api_keys table for service account/AI access -CREATE TABLE public.api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL, - name TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - key_prefix TEXT NOT NULL, - scopes TEXT[] DEFAULT '{}', - last_used_at TIMESTAMPTZ, - expires_at TIMESTAMPTZ, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ DEFAULT now(), - created_by UUID -); - --- Create indexes for fast lookups -CREATE INDEX idx_api_keys_key_hash ON public.api_keys(key_hash); -CREATE INDEX idx_api_keys_user_id ON public.api_keys(user_id); - --- Create api_request_log table for auditing -CREATE TABLE public.api_request_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - api_key_id UUID REFERENCES public.api_keys(id) ON DELETE SET NULL, - endpoint TEXT NOT NULL, - method TEXT NOT NULL, - status_code INTEGER, - request_body JSONB, - response_summary TEXT, - ip_address TEXT, - user_agent TEXT, - duration_ms INTEGER, - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE INDEX idx_api_request_log_api_key ON public.api_request_log(api_key_id); -CREATE INDEX idx_api_request_log_created_at ON public.api_request_log(created_at); - --- Enable RLS -ALTER TABLE public.api_keys ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.api_request_log ENABLE ROW LEVEL SECURITY; - --- RLS policies for api_keys - Owners can manage all, users can view their own -CREATE POLICY "Owners can manage all API keys" -ON public.api_keys FOR ALL -USING (is_owner(auth.uid())); - -CREATE POLICY "Users can view their own API keys" -ON public.api_keys FOR SELECT -USING (user_id = auth.uid()); - -CREATE POLICY "Users can create their own API keys" -ON public.api_keys FOR INSERT -WITH CHECK (user_id = auth.uid() OR is_owner(auth.uid())); - -CREATE POLICY "Users can revoke their own API keys" -ON public.api_keys FOR UPDATE -USING (user_id = auth.uid()) -WITH CHECK (user_id = auth.uid()); - -CREATE POLICY "Users can delete their own API keys" -ON public.api_keys FOR DELETE -USING (user_id = auth.uid()); - --- RLS policies for api_request_log -CREATE POLICY "Owners can view all API logs" -ON public.api_request_log FOR SELECT -USING (is_owner(auth.uid())); - -CREATE POLICY "Users can view logs for their own keys" -ON public.api_request_log FOR SELECT -USING ( - EXISTS ( - SELECT 1 FROM public.api_keys ak - WHERE ak.id = api_request_log.api_key_id - AND ak.user_id = auth.uid() - ) -); - -CREATE POLICY "System can insert API logs" -ON public.api_request_log FOR INSERT -WITH CHECK (true); - --- Function to validate API key and return user info -CREATE OR REPLACE FUNCTION public.validate_api_key(raw_key TEXT) -RETURNS TABLE ( - key_user_id UUID, - key_api_key_id UUID, - key_scopes TEXT[] -) -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - key_record RECORD; - computed_hash TEXT; -BEGIN - -- Compute hash of the provided key - computed_hash := encode(sha256(raw_key::bytea), 'hex'); - - -- Find matching key - SELECT ak.id, ak.user_id, ak.scopes, ak.is_active, ak.expires_at - INTO key_record - FROM api_keys ak - WHERE ak.key_hash = computed_hash; - - -- Key not found - IF NOT FOUND THEN - RETURN; - END IF; - - -- Key is inactive - IF NOT key_record.is_active THEN - RETURN; - END IF; - - -- Key is expired - IF key_record.expires_at IS NOT NULL AND key_record.expires_at < now() THEN - RETURN; - END IF; - - -- Update last used timestamp - UPDATE api_keys SET last_used_at = now() WHERE id = key_record.id; - - -- Return valid key info - RETURN QUERY SELECT key_record.user_id, key_record.id, key_record.scopes; -END; -$$; - --- Add api_keys resource to resources table for permission management -INSERT INTO public.resources (name, display_name, category) -VALUES ('api_keys', 'API Keys', 'settings') -ON CONFLICT (name) DO NOTHING; - --- Grant write permission on api_keys to owner role -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, 'write'::permission_level -FROM public.roles r -CROSS JOIN public.resources res -WHERE r.name = 'owner' AND res.name = 'api_keys' -ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/supabase/migrations/20260102092237_b1cd77ec-04ea-4d0f-bf3f-ef32b79347cf.sql b/supabase/migrations/20260102092237_b1cd77ec-04ea-4d0f-bf3f-ef32b79347cf.sql deleted file mode 100644 index ebbc712..0000000 --- a/supabase/migrations/20260102092237_b1cd77ec-04ea-4d0f-bf3f-ef32b79347cf.sql +++ /dev/null @@ -1,124 +0,0 @@ --- Add additional columns to profiles table -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS phone TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS birthday DATE; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS address TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS emergency_contact_name TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS emergency_contact_phone TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS department TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS job_title TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS start_date DATE; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS notes TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS avatar_url TEXT; -ALTER TABLE profiles ADD COLUMN IF NOT EXISTS hourly_rate NUMERIC; - --- Create profile_history table for audit trail -CREATE TABLE public.profile_history ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - description TEXT NOT NULL, - old_values JSONB, - new_values JSONB, - changed_by UUID, - api_key_id UUID REFERENCES api_keys(id), - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE INDEX idx_profile_history_profile ON profile_history(profile_id); -CREATE INDEX idx_profile_history_created ON profile_history(created_at); - --- Enable RLS on profile_history -ALTER TABLE profile_history ENABLE ROW LEVEL SECURITY; - --- RLS policies for profile_history -CREATE POLICY "Users can view profile history" -ON profile_history FOR SELECT -USING (can_read('team')); - -CREATE POLICY "Users can insert profile history" -ON profile_history FOR INSERT -WITH CHECK (can_write('team')); - --- Create trigger function for automatic history logging -CREATE OR REPLACE FUNCTION log_profile_changes() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - changes_description TEXT; -BEGIN - IF TG_OP = 'UPDATE' THEN - -- Build description of what changed - changes_description := 'Profile updated'; - - IF OLD.full_name IS DISTINCT FROM NEW.full_name THEN - changes_description := changes_description || ': name changed'; - END IF; - - IF OLD.phone IS DISTINCT FROM NEW.phone THEN - changes_description := changes_description || ': phone changed'; - END IF; - - IF OLD.department IS DISTINCT FROM NEW.department THEN - changes_description := changes_description || ': department changed'; - END IF; - - IF OLD.job_title IS DISTINCT FROM NEW.job_title THEN - changes_description := changes_description || ': job title changed'; - END IF; - - IF OLD.is_active IS DISTINCT FROM NEW.is_active THEN - IF NEW.is_active THEN - changes_description := 'Profile reactivated'; - ELSE - changes_description := 'Profile deactivated'; - END IF; - END IF; - - INSERT INTO profile_history (profile_id, event_type, description, old_values, new_values, changed_by) - VALUES ( - NEW.id, - 'updated', - changes_description, - jsonb_build_object( - 'full_name', OLD.full_name, - 'phone', OLD.phone, - 'birthday', OLD.birthday, - 'address', OLD.address, - 'department', OLD.department, - 'job_title', OLD.job_title, - 'emergency_contact_name', OLD.emergency_contact_name, - 'emergency_contact_phone', OLD.emergency_contact_phone, - 'notes', OLD.notes, - 'is_active', OLD.is_active, - 'hourly_rate', OLD.hourly_rate - ), - jsonb_build_object( - 'full_name', NEW.full_name, - 'phone', NEW.phone, - 'birthday', NEW.birthday, - 'address', NEW.address, - 'department', NEW.department, - 'job_title', NEW.job_title, - 'emergency_contact_name', NEW.emergency_contact_name, - 'emergency_contact_phone', NEW.emergency_contact_phone, - 'notes', NEW.notes, - 'is_active', NEW.is_active, - 'hourly_rate', NEW.hourly_rate - ), - auth.uid() - ); - END IF; - RETURN NEW; -END; -$$; - --- Create trigger -DROP TRIGGER IF EXISTS profile_changes_trigger ON profiles; -CREATE TRIGGER profile_changes_trigger -AFTER UPDATE ON profiles -FOR EACH ROW -EXECUTE FUNCTION log_profile_changes(); \ No newline at end of file diff --git a/supabase/migrations/20260102094723_92e6f7d0-0209-45e7-8f62-49071906351c.sql b/supabase/migrations/20260102094723_92e6f7d0-0209-45e7-8f62-49071906351c.sql deleted file mode 100644 index e2ae33f..0000000 --- a/supabase/migrations/20260102094723_92e6f7d0-0209-45e7-8f62-49071906351c.sql +++ /dev/null @@ -1,202 +0,0 @@ --- ===================================================== --- KNOWLEDGE BASE SYSTEM --- ===================================================== - --- Create kb_articles table -CREATE TABLE public.kb_articles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - title TEXT NOT NULL, - slug TEXT UNIQUE, - content TEXT NOT NULL, - summary TEXT, - category TEXT, - tags TEXT[], - status TEXT NOT NULL DEFAULT 'draft', - view_count INTEGER DEFAULT 0, - created_by UUID REFERENCES public.profiles(id), - updated_by UUID REFERENCES public.profiles(id), - published_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Create kb_attachments table (images/PDFs up to 10MB) -CREATE TABLE public.kb_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - article_id UUID NOT NULL REFERENCES public.kb_articles(id) ON DELETE CASCADE, - file_name TEXT NOT NULL, - file_url TEXT NOT NULL, - file_type TEXT NOT NULL, - file_size INTEGER NOT NULL, - uploaded_by UUID REFERENCES public.profiles(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Create kb_article_issues junction table (flexible multi-stage linking) -CREATE TABLE public.kb_article_issues ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - article_id UUID NOT NULL REFERENCES public.kb_articles(id) ON DELETE CASCADE, - issue_id UUID NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE, - link_type TEXT NOT NULL DEFAULT 'reference', - stage_notes TEXT, - applied_by UUID REFERENCES public.profiles(id), - applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), - helped_resolve BOOLEAN, - UNIQUE(article_id, issue_id, link_type) -); - --- Create kb_article_history table -CREATE TABLE public.kb_article_history ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - article_id UUID NOT NULL REFERENCES public.kb_articles(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - description TEXT, - old_values JSONB, - new_values JSONB, - changed_by UUID REFERENCES public.profiles(id), - api_key_id UUID REFERENCES public.api_keys(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Create indexes for kb tables -CREATE INDEX idx_kb_articles_category ON public.kb_articles(category); -CREATE INDEX idx_kb_articles_status ON public.kb_articles(status); -CREATE INDEX idx_kb_articles_tags ON public.kb_articles USING GIN(tags); -CREATE INDEX idx_kb_attachments_article ON public.kb_attachments(article_id); -CREATE INDEX idx_kb_article_issues_article ON public.kb_article_issues(article_id); -CREATE INDEX idx_kb_article_issues_issue ON public.kb_article_issues(issue_id); - --- Enable RLS on kb tables -ALTER TABLE public.kb_articles ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.kb_attachments ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.kb_article_issues ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.kb_article_history ENABLE ROW LEVEL SECURITY; - --- RLS policies for kb_articles -CREATE POLICY "Users can view KB articles" ON public.kb_articles FOR SELECT USING (can_read('issues')); -CREATE POLICY "Users can insert KB articles" ON public.kb_articles FOR INSERT WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update KB articles" ON public.kb_articles FOR UPDATE USING (can_write('issues')); -CREATE POLICY "Users can delete KB articles" ON public.kb_articles FOR DELETE USING (can_write('issues')); - --- RLS policies for kb_attachments -CREATE POLICY "Users can view KB attachments" ON public.kb_attachments FOR SELECT USING (can_read('issues')); -CREATE POLICY "Users can insert KB attachments" ON public.kb_attachments FOR INSERT WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update KB attachments" ON public.kb_attachments FOR UPDATE USING (can_write('issues')); -CREATE POLICY "Users can delete KB attachments" ON public.kb_attachments FOR DELETE USING (can_write('issues')); - --- RLS policies for kb_article_issues -CREATE POLICY "Users can view KB article issues" ON public.kb_article_issues FOR SELECT USING (can_read('issues')); -CREATE POLICY "Users can insert KB article issues" ON public.kb_article_issues FOR INSERT WITH CHECK (can_write('issues')); -CREATE POLICY "Users can update KB article issues" ON public.kb_article_issues FOR UPDATE USING (can_write('issues')); -CREATE POLICY "Users can delete KB article issues" ON public.kb_article_issues FOR DELETE USING (can_write('issues')); - --- RLS policies for kb_article_history -CREATE POLICY "Users can view KB article history" ON public.kb_article_history FOR SELECT USING (can_read('issues')); -CREATE POLICY "Users can insert KB article history" ON public.kb_article_history FOR INSERT WITH CHECK (can_write('issues')); - --- ===================================================== --- LOCATIONS SYSTEM --- ===================================================== - --- Create locations table -CREATE TABLE public.locations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - location_type TEXT, - address_line1 TEXT, - address_line2 TEXT, - city TEXT, - state TEXT, - postcode TEXT, - country TEXT DEFAULT 'Australia', - latitude DECIMAL(10, 8), - longitude DECIMAL(11, 8), - phone TEXT, - email TEXT, - notes TEXT, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Create location_contacts table -CREATE TABLE public.location_contacts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - location_id UUID NOT NULL REFERENCES public.locations(id) ON DELETE CASCADE, - name TEXT NOT NULL, - title TEXT, - phone TEXT, - email TEXT, - is_primary BOOLEAN DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Add location_id to existing tables -ALTER TABLE public.vendors ADD COLUMN location_id UUID REFERENCES public.locations(id); -ALTER TABLE public.clients ADD COLUMN location_id UUID REFERENCES public.locations(id); -ALTER TABLE public.assets ADD COLUMN location_id UUID REFERENCES public.locations(id); -ALTER TABLE public.jobs ADD COLUMN location_id UUID REFERENCES public.locations(id); - --- Create indexes for locations -CREATE INDEX idx_locations_type ON public.locations(location_type); -CREATE INDEX idx_locations_active ON public.locations(is_active); -CREATE INDEX idx_location_contacts_location ON public.location_contacts(location_id); -CREATE INDEX idx_vendors_location ON public.vendors(location_id); -CREATE INDEX idx_clients_location ON public.clients(location_id); -CREATE INDEX idx_assets_location ON public.assets(location_id); -CREATE INDEX idx_jobs_location ON public.jobs(location_id); - --- Enable RLS on location tables -ALTER TABLE public.locations ENABLE ROW LEVEL SECURITY; -ALTER TABLE public.location_contacts ENABLE ROW LEVEL SECURITY; - --- RLS policies for locations (use settings resource since it's general config) -CREATE POLICY "Users can view locations" ON public.locations FOR SELECT USING (can_read('settings')); -CREATE POLICY "Users can insert locations" ON public.locations FOR INSERT WITH CHECK (can_write('settings')); -CREATE POLICY "Users can update locations" ON public.locations FOR UPDATE USING (can_write('settings')); -CREATE POLICY "Users can delete locations" ON public.locations FOR DELETE USING (can_write('settings')); - --- RLS policies for location_contacts -CREATE POLICY "Users can view location contacts" ON public.location_contacts FOR SELECT USING (can_read('settings')); -CREATE POLICY "Users can insert location contacts" ON public.location_contacts FOR INSERT WITH CHECK (can_write('settings')); -CREATE POLICY "Users can update location contacts" ON public.location_contacts FOR UPDATE USING (can_write('settings')); -CREATE POLICY "Users can delete location contacts" ON public.location_contacts FOR DELETE USING (can_write('settings')); - --- ===================================================== --- STORAGE BUCKET FOR KB FILES --- ===================================================== - --- Create kb-files bucket with 10MB limit -INSERT INTO storage.buckets (id, name, public, file_size_limit) -VALUES ('kb-files', 'kb-files', true, 10485760); - --- Storage policies for kb-files bucket -CREATE POLICY "Users can upload KB files" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'kb-files' AND can_write('issues')); -CREATE POLICY "Users can view KB files" ON storage.objects FOR SELECT USING (bucket_id = 'kb-files' AND can_read('issues')); -CREATE POLICY "Users can delete KB files" ON storage.objects FOR DELETE USING (bucket_id = 'kb-files' AND can_write('issues')); - --- ===================================================== --- TRIGGERS --- ===================================================== - --- Add updated_at trigger for kb_articles -CREATE TRIGGER update_kb_articles_updated_at - BEFORE UPDATE ON public.kb_articles - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at(); - --- Add updated_at trigger for locations -CREATE TRIGGER update_locations_updated_at - BEFORE UPDATE ON public.locations - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at(); - --- ===================================================== --- ADD RESOURCES FOR PERMISSIONS --- ===================================================== - --- Add kb and locations to resources table if it exists -INSERT INTO public.resources (name, display_name, category, sort_order) VALUES - ('knowledge_base', 'Knowledge Base', 'Operations', 55), - ('locations', 'Locations', 'Settings', 85) -ON CONFLICT (name) DO NOTHING; \ No newline at end of file diff --git a/supabase/migrations/20260103031907_9a28dbdb-fc59-4deb-afe6-039ad0cef6d9.sql b/supabase/migrations/20260103031907_9a28dbdb-fc59-4deb-afe6-039ad0cef6d9.sql deleted file mode 100644 index b1d1f4a..0000000 --- a/supabase/migrations/20260103031907_9a28dbdb-fc59-4deb-afe6-039ad0cef6d9.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add branding columns to company_settings -ALTER TABLE company_settings -ADD COLUMN IF NOT EXISTS app_name TEXT DEFAULT 'WorkFlow', -ADD COLUMN IF NOT EXISTS favicon_url TEXT; \ No newline at end of file diff --git a/supabase/migrations/20260103033024_cf35c81a-06f9-4756-992c-103c9df5a0ac.sql b/supabase/migrations/20260103033024_cf35c81a-06f9-4756-992c-103c9df5a0ac.sql deleted file mode 100644 index a55fbf6..0000000 --- a/supabase/migrations/20260103033024_cf35c81a-06f9-4756-992c-103c9df5a0ac.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add setup_completed flag to company_settings -ALTER TABLE company_settings -ADD COLUMN IF NOT EXISTS setup_completed BOOLEAN DEFAULT FALSE; \ No newline at end of file diff --git a/supabase/migrations/20260103044313_d3b55249-6457-4898-905b-3c2b82157825.sql b/supabase/migrations/20260103044313_d3b55249-6457-4898-905b-3c2b82157825.sql deleted file mode 100644 index a16d5a6..0000000 --- a/supabase/migrations/20260103044313_d3b55249-6457-4898-905b-3c2b82157825.sql +++ /dev/null @@ -1,123 +0,0 @@ - --- Add entity_name and description columns to activity_log -ALTER TABLE activity_log -ADD COLUMN IF NOT EXISTS entity_name TEXT, -ADD COLUMN IF NOT EXISTS description TEXT; - --- Generic activity logging function with entity name extraction -CREATE OR REPLACE FUNCTION log_activity() -RETURNS TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $$ -DECLARE - v_entity_name TEXT; - v_description TEXT; -BEGIN - -- Extract entity name based on table - IF TG_OP = 'DELETE' THEN - v_entity_name := CASE TG_TABLE_NAME - WHEN 'clients' THEN OLD.name - WHEN 'jobs' THEN OLD.name - WHEN 'invoices' THEN OLD.invoice_number - WHEN 'issues' THEN OLD.title - WHEN 'assets' THEN OLD.name - WHEN 'items' THEN OLD.name - WHEN 'vendors' THEN OLD.name - WHEN 'timesheets' THEN NULL - WHEN 'expenses' THEN OLD.description - WHEN 'payments' THEN NULL - ELSE NULL - END; - ELSE - v_entity_name := CASE TG_TABLE_NAME - WHEN 'clients' THEN NEW.name - WHEN 'jobs' THEN NEW.name - WHEN 'invoices' THEN NEW.invoice_number - WHEN 'issues' THEN NEW.title - WHEN 'assets' THEN NEW.name - WHEN 'items' THEN NEW.name - WHEN 'vendors' THEN NEW.name - WHEN 'timesheets' THEN NULL - WHEN 'expenses' THEN NEW.description - WHEN 'payments' THEN NULL - ELSE NULL - END; - END IF; - - -- Build description - v_description := TG_OP || ' ' || TG_TABLE_NAME; - - IF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES ( - auth.uid(), - 'created', - TG_TABLE_NAME, - NEW.id, - v_entity_name, - v_description, - to_jsonb(NEW) - ); - RETURN NEW; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES ( - auth.uid(), - 'updated', - TG_TABLE_NAME, - NEW.id, - v_entity_name, - v_description, - to_jsonb(OLD), - to_jsonb(NEW) - ); - RETURN NEW; - ELSIF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES ( - auth.uid(), - 'deleted', - TG_TABLE_NAME, - OLD.id, - v_entity_name, - v_description, - to_jsonb(OLD) - ); - RETURN OLD; - END IF; - RETURN NULL; -END; -$$; - --- Create triggers for each table -CREATE TRIGGER clients_activity_log AFTER INSERT OR UPDATE OR DELETE ON clients - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER jobs_activity_log AFTER INSERT OR UPDATE OR DELETE ON jobs - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER invoices_activity_log AFTER INSERT OR UPDATE ON invoices - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER payments_activity_log AFTER INSERT ON payments - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER issues_activity_log AFTER INSERT OR UPDATE ON issues - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER assets_activity_log AFTER INSERT OR UPDATE OR DELETE ON assets - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER items_activity_log AFTER INSERT OR UPDATE ON items - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER timesheets_activity_log AFTER INSERT OR UPDATE OR DELETE ON timesheets - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER expenses_activity_log AFTER INSERT OR UPDATE OR DELETE ON expenses - FOR EACH ROW EXECUTE FUNCTION log_activity(); - -CREATE TRIGGER vendors_activity_log AFTER INSERT OR UPDATE ON vendors - FOR EACH ROW EXECUTE FUNCTION log_activity(); diff --git a/supabase/migrations/20260103050111_01ce507f-63ee-4ef7-8070-4ff40d31c88d.sql b/supabase/migrations/20260103050111_01ce507f-63ee-4ef7-8070-4ff40d31c88d.sql deleted file mode 100644 index 5a06493..0000000 --- a/supabase/migrations/20260103050111_01ce507f-63ee-4ef7-8070-4ff40d31c88d.sql +++ /dev/null @@ -1,285 +0,0 @@ --- Fix the log_activity function to use JSONB extraction instead of direct column references -CREATE OR REPLACE FUNCTION public.log_activity() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path TO 'public' -AS $function$ -DECLARE - v_entity_name TEXT; - v_description TEXT; -BEGIN - -- Extract entity name based on table - IF TG_OP = 'DELETE' THEN - v_entity_name := CASE TG_TABLE_NAME - WHEN 'clients' THEN OLD.name - WHEN 'jobs' THEN OLD.name - WHEN 'invoices' THEN OLD.invoice_number - WHEN 'issues' THEN OLD.title - WHEN 'assets' THEN OLD.name - WHEN 'items' THEN OLD.name - WHEN 'vendors' THEN OLD.name - WHEN 'timesheets' THEN NULL - WHEN 'expenses' THEN OLD.description - WHEN 'payments' THEN NULL - ELSE NULL - END; - ELSE - v_entity_name := CASE TG_TABLE_NAME - WHEN 'clients' THEN NEW.name - WHEN 'jobs' THEN NEW.name - WHEN 'invoices' THEN NEW.invoice_number - WHEN 'issues' THEN NEW.title - WHEN 'assets' THEN NEW.name - WHEN 'items' THEN NEW.name - WHEN 'vendors' THEN NEW.name - WHEN 'timesheets' THEN NULL - WHEN 'expenses' THEN NEW.description - WHEN 'payments' THEN NULL - ELSE NULL - END; - END IF; - - -- Build description - v_description := TG_OP || ' ' || TG_TABLE_NAME; - - IF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES ( - auth.uid(), - 'created', - TG_TABLE_NAME, - NEW.id, - v_entity_name, - v_description, - to_jsonb(NEW) - ); - RETURN NEW; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES ( - auth.uid(), - 'updated', - TG_TABLE_NAME, - NEW.id, - v_entity_name, - v_description, - to_jsonb(OLD), - to_jsonb(NEW) - ); - RETURN NEW; - ELSIF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES ( - auth.uid(), - 'deleted', - TG_TABLE_NAME, - OLD.id, - v_entity_name, - v_description, - to_jsonb(OLD) - ); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$; - --- Drop existing triggers and recreate them for specific tables only --- This ensures each table only triggers for its own columns - -DROP TRIGGER IF EXISTS clients_activity_log ON clients; -DROP TRIGGER IF EXISTS jobs_activity_log ON jobs; -DROP TRIGGER IF EXISTS invoices_activity_log ON invoices; -DROP TRIGGER IF EXISTS payments_activity_log ON payments; -DROP TRIGGER IF EXISTS issues_activity_log ON issues; -DROP TRIGGER IF EXISTS assets_activity_log ON assets; -DROP TRIGGER IF EXISTS items_activity_log ON items; -DROP TRIGGER IF EXISTS timesheets_activity_log ON timesheets; -DROP TRIGGER IF EXISTS expenses_activity_log ON expenses; -DROP TRIGGER IF EXISTS vendors_activity_log ON vendors; - --- Create individual trigger functions for each table to avoid column reference issues -CREATE OR REPLACE FUNCTION log_clients_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'clients', OLD.id, OLD.name, 'DELETE clients', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'clients', NEW.id, NEW.name, 'UPDATE clients', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'clients', NEW.id, NEW.name, 'INSERT clients', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_jobs_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'jobs', OLD.id, OLD.name, 'DELETE jobs', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'jobs', NEW.id, NEW.name, 'UPDATE jobs', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'jobs', NEW.id, NEW.name, 'INSERT jobs', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_invoices_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'invoices', OLD.id, OLD.invoice_number, 'DELETE invoices', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'invoices', NEW.id, NEW.invoice_number, 'UPDATE invoices', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'invoices', NEW.id, NEW.invoice_number, 'INSERT invoices', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_payments_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'payments', NEW.id, NULL, 'INSERT payments', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_issues_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'issues', OLD.id, OLD.title, 'DELETE issues', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'issues', NEW.id, NEW.title, 'UPDATE issues', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'issues', NEW.id, NEW.title, 'INSERT issues', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_assets_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'assets', OLD.id, OLD.name, 'DELETE assets', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'assets', NEW.id, NEW.name, 'UPDATE assets', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'assets', NEW.id, NEW.name, 'INSERT assets', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_items_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'items', OLD.id, OLD.name, 'DELETE items', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'items', NEW.id, NEW.name, 'UPDATE items', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'items', NEW.id, NEW.name, 'INSERT items', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_timesheets_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'timesheets', OLD.id, NULL, 'DELETE timesheets', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'timesheets', NEW.id, NULL, 'UPDATE timesheets', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'timesheets', NEW.id, NULL, 'INSERT timesheets', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_expenses_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'expenses', OLD.id, OLD.description, 'DELETE expenses', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'expenses', NEW.id, NEW.description, 'UPDATE expenses', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'expenses', NEW.id, NEW.description, 'INSERT expenses', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - -CREATE OR REPLACE FUNCTION log_vendors_activity() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values) - VALUES (auth.uid(), 'deleted', 'vendors', OLD.id, OLD.name, 'DELETE vendors', to_jsonb(OLD)); - RETURN OLD; - ELSIF TG_OP = 'UPDATE' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, old_values, new_values) - VALUES (auth.uid(), 'updated', 'vendors', NEW.id, NEW.name, 'UPDATE vendors', to_jsonb(OLD), to_jsonb(NEW)); - RETURN NEW; - ELSIF TG_OP = 'INSERT' THEN - INSERT INTO activity_log (user_id, action, entity_type, entity_id, entity_name, description, new_values) - VALUES (auth.uid(), 'created', 'vendors', NEW.id, NEW.name, 'INSERT vendors', to_jsonb(NEW)); - RETURN NEW; - END IF; - RETURN NULL; -END; $$; - --- Create triggers using table-specific functions -CREATE TRIGGER clients_activity_log AFTER INSERT OR UPDATE OR DELETE ON clients FOR EACH ROW EXECUTE FUNCTION log_clients_activity(); -CREATE TRIGGER jobs_activity_log AFTER INSERT OR UPDATE OR DELETE ON jobs FOR EACH ROW EXECUTE FUNCTION log_jobs_activity(); -CREATE TRIGGER invoices_activity_log AFTER INSERT OR UPDATE ON invoices FOR EACH ROW EXECUTE FUNCTION log_invoices_activity(); -CREATE TRIGGER payments_activity_log AFTER INSERT ON payments FOR EACH ROW EXECUTE FUNCTION log_payments_activity(); -CREATE TRIGGER issues_activity_log AFTER INSERT OR UPDATE ON issues FOR EACH ROW EXECUTE FUNCTION log_issues_activity(); -CREATE TRIGGER assets_activity_log AFTER INSERT OR UPDATE OR DELETE ON assets FOR EACH ROW EXECUTE FUNCTION log_assets_activity(); -CREATE TRIGGER items_activity_log AFTER INSERT OR UPDATE ON items FOR EACH ROW EXECUTE FUNCTION log_items_activity(); -CREATE TRIGGER timesheets_activity_log AFTER INSERT OR UPDATE OR DELETE ON timesheets FOR EACH ROW EXECUTE FUNCTION log_timesheets_activity(); -CREATE TRIGGER expenses_activity_log AFTER INSERT OR UPDATE OR DELETE ON expenses FOR EACH ROW EXECUTE FUNCTION log_expenses_activity(); -CREATE TRIGGER vendors_activity_log AFTER INSERT OR UPDATE ON vendors FOR EACH ROW EXECUTE FUNCTION log_vendors_activity(); \ No newline at end of file diff --git a/supabase/migrations/20260103053638_df2fe27f-c55a-4dab-86a6-8629f76d5680.sql b/supabase/migrations/20260103053638_df2fe27f-c55a-4dab-86a6-8629f76d5680.sql deleted file mode 100644 index 6b5316d..0000000 --- a/supabase/migrations/20260103053638_df2fe27f-c55a-4dab-86a6-8629f76d5680.sql +++ /dev/null @@ -1,86 +0,0 @@ --- Add currency columns to company_settings -ALTER TABLE public.company_settings -ADD COLUMN IF NOT EXISTS currency text DEFAULT 'AUD', -ADD COLUMN IF NOT EXISTS currency_locale text DEFAULT 'en-AU'; - --- Add default_role_id to company_settings -ALTER TABLE public.company_settings -ADD COLUMN IF NOT EXISTS default_role_id uuid REFERENCES public.roles(id); - --- Insert activity_log resource -INSERT INTO public.resources (name, display_name, category) -VALUES ('activity_log', 'Activity Log', 'Settings') -ON CONFLICT (name) DO NOTHING; - --- Set default permissions for activity_log resource -INSERT INTO public.role_permissions (role_id, resource_id, permission) -SELECT r.id, res.id, - CASE - WHEN r.name = 'owner' THEN 'write'::permission_level - WHEN r.name = 'admin' THEN 'read'::permission_level - WHEN r.name = 'readonly' THEN 'read'::permission_level - ELSE 'none'::permission_level - END -FROM public.roles r -CROSS JOIN public.resources res -WHERE res.name = 'activity_log' -ON CONFLICT DO NOTHING; - --- Update handle_new_user function to use default_role_id -CREATE OR REPLACE FUNCTION public.handle_new_user() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public -AS $function$ -DECLARE - v_default_role_id uuid; - v_staff_role_id uuid; -BEGIN - -- Insert profile - INSERT INTO public.profiles (id, email, full_name) - VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data ->> 'full_name'); - - -- First user becomes owner - IF (SELECT COUNT(*) FROM public.user_roles) = 0 THEN - INSERT INTO public.user_roles (user_id, role_id) - SELECT NEW.id, id FROM public.roles WHERE name = 'owner' LIMIT 1; - ELSE - -- Get configured default role, fallback to staff - SELECT default_role_id INTO v_default_role_id - FROM public.company_settings - LIMIT 1; - - IF v_default_role_id IS NOT NULL THEN - INSERT INTO public.user_roles (user_id, role_id) - VALUES (NEW.id, v_default_role_id); - ELSE - SELECT id INTO v_staff_role_id FROM public.roles WHERE name = 'staff' LIMIT 1; - INSERT INTO public.user_roles (user_id, role_id) - VALUES (NEW.id, v_staff_role_id); - END IF; - END IF; - - -- Create default company settings for new user - INSERT INTO public.company_settings (user_id, name) - VALUES (NEW.id, 'My Company'); - - -- Create default tax rate for new user - INSERT INTO public.tax_rates (user_id, name, rate, is_default) - VALUES (NEW.id, 'GST', 10, true); - - -- Create default chart of accounts for new user - INSERT INTO public.accounts (user_id, code, name, type, is_system) VALUES - (NEW.id, '1000', 'Bank Account', 'asset', true), - (NEW.id, '1100', 'Accounts Receivable', 'asset', true), - (NEW.id, '2000', 'Accounts Payable', 'liability', true), - (NEW.id, '2100', 'GST Collected', 'liability', true), - (NEW.id, '2200', 'GST Paid', 'asset', true), - (NEW.id, '3000', 'Retained Earnings', 'equity', true), - (NEW.id, '4000', 'Sales Revenue', 'income', true), - (NEW.id, '5000', 'Cost of Goods Sold', 'cogs', true), - (NEW.id, '6000', 'Operating Expenses', 'expense', true); - - RETURN NEW; -END; -$function$; \ No newline at end of file diff --git a/supabase/migrations/20260103110024_13e50e55-f366-442a-8a08-caf153ec04c5.sql b/supabase/migrations/20260103110024_13e50e55-f366-442a-8a08-caf153ec04c5.sql deleted file mode 100644 index e4bde9b..0000000 --- a/supabase/migrations/20260103110024_13e50e55-f366-442a-8a08-caf153ec04c5.sql +++ /dev/null @@ -1,55 +0,0 @@ -CREATE OR REPLACE FUNCTION public.handle_new_user() -RETURNS trigger -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO 'public' -AS $$ -DECLARE - v_default_role_id uuid; - v_staff_role_id uuid; -BEGIN - -- Insert profile for the new user - INSERT INTO public.profiles (id, email, full_name) - VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data ->> 'full_name'); - - -- First user becomes owner AND creates shared data - IF (SELECT COUNT(*) FROM public.user_roles) = 0 THEN - INSERT INTO public.user_roles (user_id, role_id) - SELECT NEW.id, id FROM public.roles WHERE name = 'owner' LIMIT 1; - - -- Only create company settings, tax rates, and accounts for the FIRST user - INSERT INTO public.company_settings (user_id, name) - VALUES (NEW.id, 'My Company'); - - INSERT INTO public.tax_rates (user_id, name, rate, is_default) - VALUES (NEW.id, 'GST', 10, true); - - INSERT INTO public.accounts (user_id, code, name, type, is_system) VALUES - (NEW.id, '1000', 'Bank Account', 'asset', true), - (NEW.id, '1100', 'Accounts Receivable', 'asset', true), - (NEW.id, '2000', 'Accounts Payable', 'liability', true), - (NEW.id, '2100', 'GST Collected', 'liability', true), - (NEW.id, '2200', 'GST Paid', 'asset', true), - (NEW.id, '3000', 'Retained Earnings', 'equity', true), - (NEW.id, '4000', 'Sales Revenue', 'income', true), - (NEW.id, '5000', 'Cost of Goods Sold', 'cogs', true), - (NEW.id, '6000', 'Operating Expenses', 'expense', true); - ELSE - -- Subsequent users get the configured default role only - SELECT default_role_id INTO v_default_role_id - FROM public.company_settings - LIMIT 1; - - IF v_default_role_id IS NOT NULL THEN - INSERT INTO public.user_roles (user_id, role_id) - VALUES (NEW.id, v_default_role_id); - ELSE - SELECT id INTO v_staff_role_id FROM public.roles WHERE name = 'staff' LIMIT 1; - INSERT INTO public.user_roles (user_id, role_id) - VALUES (NEW.id, v_staff_role_id); - END IF; - END IF; - - RETURN NEW; -END; -$$; \ No newline at end of file diff --git a/supabase/migrations/20260103124909_11a950d1-6705-472b-b402-fa0cf4f1d0e2.sql b/supabase/migrations/20260103124909_11a950d1-6705-472b-b402-fa0cf4f1d0e2.sql deleted file mode 100644 index 36c8f37..0000000 --- a/supabase/migrations/20260103124909_11a950d1-6705-472b-b402-fa0cf4f1d0e2.sql +++ /dev/null @@ -1,62 +0,0 @@ --- Create a function that checks if initial setup is complete --- This bypasses RLS so any authenticated user can check -CREATE OR REPLACE FUNCTION public.is_setup_complete() -RETURNS boolean -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path TO 'public' -AS $$ -DECLARE - v_setup_completed boolean; -BEGIN - SELECT setup_completed INTO v_setup_completed - FROM public.company_settings - LIMIT 1; - - -- If no settings exist or not completed, return false - RETURN COALESCE(v_setup_completed, false); -END; -$$; - --- Grant execute to authenticated users -GRANT EXECUTE ON FUNCTION public.is_setup_complete() TO authenticated; - --- Store pending bill imports for API-driven workflow -CREATE TABLE public.bill_import_sessions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - vendor_id uuid REFERENCES vendors(id), - vendor_name text, - file_name text, - raw_data jsonb NOT NULL, - column_mapping jsonb, - matched_rows jsonb, - status text NOT NULL DEFAULT 'pending', - total_amount numeric, - created_by uuid, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now() -); - -ALTER TABLE bill_import_sessions ENABLE ROW LEVEL SECURITY; - -CREATE POLICY "Users can view bill import sessions" - ON bill_import_sessions FOR SELECT - USING (can_read('payments')); - -CREATE POLICY "Users can create bill import sessions" - ON bill_import_sessions FOR INSERT - WITH CHECK (can_write('payments')); - -CREATE POLICY "Users can update bill import sessions" - ON bill_import_sessions FOR UPDATE - USING (can_write('payments')); - -CREATE POLICY "Users can delete bill import sessions" - ON bill_import_sessions FOR DELETE - USING (can_write('payments')); - --- Trigger for updated_at -CREATE TRIGGER update_bill_import_sessions_updated_at - BEFORE UPDATE ON bill_import_sessions - FOR EACH ROW - EXECUTE FUNCTION update_updated_at(); \ No newline at end of file diff --git a/supabase/migrations/20260103132131_65deee7f-50bb-46cc-ac05-992bc5cc113b.sql b/supabase/migrations/20260103132131_65deee7f-50bb-46cc-ac05-992bc5cc113b.sql deleted file mode 100644 index 60f8f95..0000000 --- a/supabase/migrations/20260103132131_65deee7f-50bb-46cc-ac05-992bc5cc113b.sql +++ /dev/null @@ -1,40 +0,0 @@ --- Create vendor_item_mappings table for remembering bill import mappings -CREATE TABLE public.vendor_item_mappings ( - id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - vendor_id uuid REFERENCES public.vendors(id) ON DELETE CASCADE, - vendor_item_name text NOT NULL, - item_id uuid NOT NULL REFERENCES public.items(id) ON DELETE CASCADE, - quantity_multiplier numeric NOT NULL DEFAULT 1, - notes text, - created_by uuid, - created_at timestamp with time zone NOT NULL DEFAULT now(), - last_used_at timestamp with time zone NOT NULL DEFAULT now(), - UNIQUE(vendor_id, vendor_item_name) -); - --- Enable RLS -ALTER TABLE public.vendor_item_mappings ENABLE ROW LEVEL SECURITY; - --- Create RLS policies -CREATE POLICY "Users can view vendor item mappings" - ON public.vendor_item_mappings FOR SELECT - USING (can_read('payments')); - -CREATE POLICY "Users can create vendor item mappings" - ON public.vendor_item_mappings FOR INSERT - WITH CHECK (can_write('payments')); - -CREATE POLICY "Users can update vendor item mappings" - ON public.vendor_item_mappings FOR UPDATE - USING (can_write('payments')); - -CREATE POLICY "Users can delete vendor item mappings" - ON public.vendor_item_mappings FOR DELETE - USING (can_write('payments')); - --- Create index for faster lookups -CREATE INDEX idx_vendor_item_mappings_lookup - ON public.vendor_item_mappings(vendor_id, vendor_item_name); - -CREATE INDEX idx_vendor_item_mappings_item - ON public.vendor_item_mappings(item_id); \ No newline at end of file diff --git a/supabase/migrations/20260103143302_24322e3c-5559-49d4-9193-5e76b3a0e94e.sql b/supabase/migrations/20260103143302_24322e3c-5559-49d4-9193-5e76b3a0e94e.sql deleted file mode 100644 index 11524c9..0000000 --- a/supabase/migrations/20260103143302_24322e3c-5559-49d4-9193-5e76b3a0e94e.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE purchase_allocations ADD COLUMN description text; \ No newline at end of file diff --git a/supabase/migrations/20260103152759_87cf3849-ff70-4d42-84ba-b17c1f57c22c.sql b/supabase/migrations/20260103152759_87cf3849-ff70-4d42-84ba-b17c1f57c22c.sql deleted file mode 100644 index 97b6fb9..0000000 --- a/supabase/migrations/20260103152759_87cf3849-ff70-4d42-84ba-b17c1f57c22c.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Fix STORAGE_EXPOSURE: Make buckets private and update policies - --- Make all buckets private -UPDATE storage.buckets SET public = false WHERE id IN ('images', 'documents', 'kb-files'); - --- Drop overly permissive policies -DROP POLICY IF EXISTS "Anyone can view images" ON storage.objects; -DROP POLICY IF EXISTS "Anyone can view documents" ON storage.objects; - --- Create authenticated policies for images bucket -CREATE POLICY "Authenticated users can view images" ON storage.objects - FOR SELECT USING (bucket_id = 'images' AND auth.role() = 'authenticated'); - --- Create authenticated policies for documents bucket -CREATE POLICY "Authenticated users can view documents" ON storage.objects - FOR SELECT USING (bucket_id = 'documents' AND auth.role() = 'authenticated'); \ No newline at end of file diff --git a/supabase/migrations/20260104010611_3fdd6601-19a6-41b3-8370-cb9acd8b527a.sql b/supabase/migrations/20260104010611_3fdd6601-19a6-41b3-8370-cb9acd8b527a.sql deleted file mode 100644 index e32819b..0000000 --- a/supabase/migrations/20260104010611_3fdd6601-19a6-41b3-8370-cb9acd8b527a.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add movement_date column to track actual purchase date (separate from entry date) -ALTER TABLE public.inventory_movements -ADD COLUMN movement_date date; - --- Backfill existing data with created_at date -UPDATE public.inventory_movements -SET movement_date = created_at::date -WHERE movement_date IS NULL; - --- Create index for efficient queries by movement_date -CREATE INDEX idx_inventory_movements_movement_date ON public.inventory_movements(item_id, movement_date DESC); \ No newline at end of file From 9c31616b163d5e4c3bacb1e78a3b57c7be9120de Mon Sep 17 00:00:00 2001 From: Corianas Date: Thu, 9 Jul 2026 19:29:26 +0800 Subject: [PATCH 03/28] Add automatic SQLite backups Since the local SQLite file holds real business/financial data, add an online backup (better-sqlite3 db.backup(), safe while the app serves requests): - server/utils/backup.ts: backupDatabase() writes a timestamped copy to BACKUP_DIR and prunes to the newest BACKUP_RETENTION files; it never throws, so a backup failure can't affect request handling. startBackupSchedule() runs one backup on boot and every BACKUP_INTERVAL_HOURS (unref'd so it doesn't block shutdown; disabled when BACKUP_INTERVAL_HOURS=0). - index.ts starts the schedule after listen (skipped under test). - Defaults: BACKUP_DIR=./data/backups (already gitignored), 24h, keep 7. - Documented in .env.example and README; tested (valid independently- openable copy + retention). Suite now 26 passing. Co-Authored-By: Claude Fable 5 --- .env.example | 8 ++ README.md | 11 +++ server/__tests__/backup.test.ts | 109 ++++++++++++++++++++++++ server/index.ts | 3 + server/utils/backup.ts | 144 ++++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 server/__tests__/backup.test.ts create mode 100644 server/utils/backup.ts 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/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/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/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/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; + } +} From 66b0a2d378f0e35a715e61df35048f26c9d33767 Mon Sep 17 00:00:00 2001 From: Corianas Date: Fri, 10 Jul 2026 05:05:13 +0800 Subject: [PATCH 04/28] Create the DB directory on boot so a fresh checkout runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-sqlite3 does not create the parent directory of the database file, and data/ is gitignored — so `npm run dev` on a clean clone crashed with "Cannot open database because the directory does not exist". getDatabase() now mkdir -p's the DB's parent directory before opening. (Tests were unaffected because the test helper creates its own temp dir.) Co-Authored-By: Claude Fable 5 --- server/db/database.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/db/database.ts b/server/db/database.ts index 158bcf8..b72cb8f 100644 --- a/server/db/database.ts +++ b/server/db/database.ts @@ -1,5 +1,5 @@ import Database from 'better-sqlite3'; -import { readFileSync } from 'fs'; +import { readFileSync, mkdirSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -13,6 +13,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'); From 5a147554049935b26817e749aba299837bd1a3aa Mon Sep 17 00:00:00 2001 From: Corianas Date: Fri, 10 Jul 2026 05:05:13 +0800 Subject: [PATCH 05/28] Migrate Dashboard to TanStack Query TanStack Query was installed but unused; every page hand-rolled useState/useEffect fetching. Start adopting it, beginning with the read-only Dashboard as the template: - App.tsx: give the QueryClient sensible defaults (30s staleTime, 5m gcTime, retry once, refetch on window focus) - Dashboard: replace the big fetch-everything useEffect + seven useState slices with a single useQuery(['dashboard']); the queryFn returns the same composed data and every query/filter/derivation is unchanged. Manual refresh after "generate invoices" now calls refetch(). Behavior and rendered output are identical. Verified in the running app: login, dashboard renders (stat cards, activity, setup wizard branch) with no query errors. Co-Authored-By: Claude Fable 5 --- src/App.tsx | 11 +- src/pages/Dashboard.tsx | 310 +++++++++++++++++++++------------------- 2 files changed, 169 insertions(+), 152 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dad09aa..fd2cdb4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,7 +49,16 @@ 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 = () => ( diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index b8dba0d..5389067 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -83,6 +84,26 @@ interface ActivityLogEntry { user_name?: string; } +interface DashboardData { + stats: DashboardStats; + bankAccounts: BankAccount[]; + outstandingInvoices: OutstandingInvoice[]; + openIssues: OpenIssue[]; + lowStockItems: LowStockItem[]; + topJobs: any[]; + activityItems: ActivityLogEntry[]; +} + +const DEFAULT_STATS: DashboardStats = { + outstandingInvoices: 0, + outstandingAmount: 0, + cashCollectedThisMonth: 0, + openIssues: 0, + activeJobs: 0, + lowStockItems: 0, + totalBankBalance: 0, +}; + const ENTITY_TYPES = [ { value: 'all', label: 'All Activity' }, { value: 'clients', label: 'Clients' }, @@ -94,172 +115,159 @@ const ENTITY_TYPES = [ { value: 'items', label: 'Inventory' }, ]; +async function fetchDashboardData(): Promise { + // Fetch outstanding invoices with client info + const { data: invoices } = await supabase + .from('invoices') + .select('id, invoice_number, total, amount_paid, status, due_date, clients(name)') + .in('status', ['sent', 'partially_paid', 'overdue']) + .order('due_date', { ascending: true }) + .limit(10); + + const outstandingAmount = invoices?.reduce((sum, inv) => sum + (inv.total - inv.amount_paid), 0) || 0; + + // Fetch cash collected this month + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const { data: payments } = await supabase + .from('payments') + .select('amount') + .gte('date', startOfMonth.toISOString().split('T')[0]); + + const cashCollected = payments?.reduce((sum, p) => sum + p.amount, 0) || 0; + + // Fetch open issues with client info + const { data: issues } = await supabase + .from('issues') + .select('id, title, severity, status, created_at, clients(name)') + .in('status', ['open', 'in_progress']) + .order('severity', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(10); + + // Fetch active jobs count + const { count: jobCount } = await supabase + .from('jobs') + .select('*', { count: 'exact', head: true }) + .eq('status', 'active'); + + // Fetch low stock items + const { data: items } = await supabase + .from('items') + .select('id, name, sku, current_stock, reorder_level') + .eq('is_active', true); + + const lowStock = (items || []).filter(item => + (item.current_stock || 0) <= (item.reorder_level || 0) && (item.reorder_level || 0) > 0 + ); + + // Fetch top 5 jobs by revenue + const { data: jobs } = await supabase + .from('jobs') + .select(` + id, + name, + job_number, + invoices (total, amount_paid) + `) + .eq('status', 'active') + .limit(5); + + const jobsWithRevenue = jobs?.map(job => ({ + ...job, + revenue: job.invoices?.reduce((sum: number, inv: any) => sum + inv.amount_paid, 0) || 0, + })).sort((a, b) => b.revenue - a.revenue) || []; + + // Fetch bank accounts + const { data: bankAccountsData } = await supabase + .from('bank_accounts') + .select('id, name, bank_name, current_balance, is_default') + .eq('is_active', true) + .order('is_default', { ascending: false }) + .order('name'); + + const totalBankBalance = (bankAccountsData || []).reduce( + (sum, acc) => sum + Number(acc.current_balance || 0), 0 + ); + + // Fetch recent activity from activity_log + const { data: activityLogData } = await supabase + .from('activity_log') + .select('id, user_id, action, entity_type, entity_id, entity_name, created_at') + .order('created_at', { ascending: false }) + .limit(20); + + // Fetch user profiles for activity log entries + const userIds = [...new Set((activityLogData || []).filter(a => a.user_id).map(a => a.user_id!))]; + let profilesMap: Record = {}; + if (userIds.length > 0) { + const { data: profiles } = await supabase + .from('profiles') + .select('id, full_name, email') + .in('id', userIds); + + profiles?.forEach(p => { + profilesMap[p.id] = p.full_name || p.email || 'Unknown'; + }); + } + + const enrichedActivity = (activityLogData || []).map(entry => ({ + ...entry, + user_name: entry.user_id ? profilesMap[entry.user_id] || 'System' : 'System', + })); + + return { + stats: { + outstandingInvoices: invoices?.length || 0, + outstandingAmount, + cashCollectedThisMonth: cashCollected, + openIssues: issues?.length || 0, + activeJobs: jobCount || 0, + lowStockItems: lowStock.length, + totalBankBalance, + }, + bankAccounts: bankAccountsData || [], + outstandingInvoices: invoices || [], + openIssues: issues || [], + lowStockItems: lowStock, + topJobs: jobsWithRevenue, + activityItems: enrichedActivity, + }; +} + export default function Dashboard() { const { toast } = useToast(); const { branding, formatCurrency } = useBranding(); const { isComplete: setupComplete, isLoading: setupLoading, refetch: refetchSetup } = useSetupComplete(); - const [stats, setStats] = useState({ - outstandingInvoices: 0, - outstandingAmount: 0, - cashCollectedThisMonth: 0, - openIssues: 0, - activeJobs: 0, - lowStockItems: 0, - totalBankBalance: 0, - }); - const [bankAccounts, setBankAccounts] = useState([]); - const [outstandingInvoices, setOutstandingInvoices] = useState([]); - const [openIssues, setOpenIssues] = useState([]); - const [lowStockItems, setLowStockItems] = useState([]); - const [topJobs, setTopJobs] = useState([]); - const [activityItems, setActivityItems] = useState([]); const [activityFilter, setActivityFilter] = useState('all'); - const [loading, setLoading] = useState(true); const [generatingInvoices, setGeneratingInvoices] = useState(false); - useEffect(() => { - async function fetchDashboardData() { - try { - // Fetch outstanding invoices with client info - const { data: invoices } = await supabase - .from('invoices') - .select('id, invoice_number, total, amount_paid, status, due_date, clients(name)') - .in('status', ['sent', 'partially_paid', 'overdue']) - .order('due_date', { ascending: true }) - .limit(10); - - const outstandingAmount = invoices?.reduce((sum, inv) => sum + (inv.total - inv.amount_paid), 0) || 0; - setOutstandingInvoices(invoices || []); - - // Fetch cash collected this month - const startOfMonth = new Date(); - startOfMonth.setDate(1); - startOfMonth.setHours(0, 0, 0, 0); - - const { data: payments } = await supabase - .from('payments') - .select('amount') - .gte('date', startOfMonth.toISOString().split('T')[0]); - - const cashCollected = payments?.reduce((sum, p) => sum + p.amount, 0) || 0; - - // Fetch open issues with client info - const { data: issues } = await supabase - .from('issues') - .select('id, title, severity, status, created_at, clients(name)') - .in('status', ['open', 'in_progress']) - .order('severity', { ascending: false }) - .order('created_at', { ascending: false }) - .limit(10); - - setOpenIssues(issues || []); - - // Fetch active jobs count - const { count: jobCount } = await supabase - .from('jobs') - .select('*', { count: 'exact', head: true }) - .eq('status', 'active'); - - // Fetch low stock items - const { data: items } = await supabase - .from('items') - .select('id, name, sku, current_stock, reorder_level') - .eq('is_active', true); - - const lowStock = (items || []).filter(item => - (item.current_stock || 0) <= (item.reorder_level || 0) && (item.reorder_level || 0) > 0 - ); - setLowStockItems(lowStock); - - // Fetch top 5 jobs by revenue - const { data: jobs } = await supabase - .from('jobs') - .select(` - id, - name, - job_number, - invoices (total, amount_paid) - `) - .eq('status', 'active') - .limit(5); - - const jobsWithRevenue = jobs?.map(job => ({ - ...job, - revenue: job.invoices?.reduce((sum: number, inv: any) => sum + inv.amount_paid, 0) || 0, - })).sort((a, b) => b.revenue - a.revenue) || []; - - // Fetch bank accounts - const { data: bankAccountsData } = await supabase - .from('bank_accounts') - .select('id, name, bank_name, current_balance, is_default') - .eq('is_active', true) - .order('is_default', { ascending: false }) - .order('name'); - - const totalBankBalance = (bankAccountsData || []).reduce( - (sum, acc) => sum + Number(acc.current_balance || 0), 0 - ); - setBankAccounts(bankAccountsData || []); - - // Fetch recent activity from activity_log - const { data: activityLogData } = await supabase - .from('activity_log') - .select('id, user_id, action, entity_type, entity_id, entity_name, created_at') - .order('created_at', { ascending: false }) - .limit(20); - - // Fetch user profiles for activity log entries - const userIds = [...new Set((activityLogData || []).filter(a => a.user_id).map(a => a.user_id!))]; - let profilesMap: Record = {}; - if (userIds.length > 0) { - const { data: profiles } = await supabase - .from('profiles') - .select('id, full_name, email') - .in('id', userIds); - - profiles?.forEach(p => { - profilesMap[p.id] = p.full_name || p.email || 'Unknown'; - }); - } - - const enrichedActivity = (activityLogData || []).map(entry => ({ - ...entry, - user_name: entry.user_id ? profilesMap[entry.user_id] || 'System' : 'System', - })); - - setActivityItems(enrichedActivity); - - setStats({ - outstandingInvoices: invoices?.length || 0, - outstandingAmount, - cashCollectedThisMonth: cashCollected, - openIssues: issues?.length || 0, - activeJobs: jobCount || 0, - lowStockItems: lowStock.length, - totalBankBalance, - }); - - setTopJobs(jobsWithRevenue); - } catch (error) { - console.error('Error fetching dashboard data:', error); - } finally { - setLoading(false); - } - } - - fetchDashboardData(); - }, []); + const { data, isLoading, refetch } = useQuery({ + queryKey: ['dashboard'], + queryFn: fetchDashboardData, + }); + const stats = data?.stats ?? DEFAULT_STATS; + const bankAccounts = data?.bankAccounts ?? []; + const outstandingInvoices = data?.outstandingInvoices ?? []; + const openIssues = data?.openIssues ?? []; + const lowStockItems = data?.lowStockItems ?? []; + const topJobs = data?.topJobs ?? []; + const activityItems = data?.activityItems ?? []; + const loading = isLoading; async function handleGenerateJobInvoices() { setGeneratingInvoices(true); try { - const { data, error } = await supabase.functions.invoke('generate-job-invoices'); + const { data: invokeData, error } = await supabase.functions.invoke('generate-job-invoices'); if (error) throw error; toast({ title: 'Job Invoices', - description: data.message, + description: invokeData.message, }); + refetch(); } catch (error: any) { toast({ title: 'Error', From bcd79767fac9d18b0e0c0dcb23622ed9ad1fd13d Mon Sep 17 00:00:00 2001 From: Corianas Date: Fri, 10 Jul 2026 05:22:50 +0800 Subject: [PATCH 06/28] Migrate Clients and Jobs list pages to TanStack Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue the TanStack rollout from Dashboard to the two simplest high-traffic list pages. Both are read-only: extract the fetch into a module-level queryFn and replace useState+useEffect with useQuery(['clients']) / useQuery(['jobs']). Client-side search filtering and all rendering are unchanged. Verified both render in the running app with no query errors. Invoices is intentionally left for a separate pass — it has bulk-mutation actions (status update, send) that warrant useMutation + invalidation. Co-Authored-By: Claude Fable 5 --- src/pages/Clients.tsx | 67 ++++++++++++++++++++----------------------- src/pages/Jobs.tsx | 42 +++++++++++++-------------- 2 files changed, 51 insertions(+), 58 deletions(-) diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index a6fb507..bce9568 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; @@ -21,47 +22,41 @@ type Contact = Tables<'client_contacts'>; type ClientWithContact = Client & { primary_contact?: Contact | null }; -export default function Clients() { - const [clients, setClients] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); +async function fetchClients(): Promise { + const { data: clientsData, error } = await supabase + .from('clients') + .select('*') + .order('name'); - useEffect(() => { - async function fetchClients() { - const { data: clientsData, error } = await supabase - .from('clients') - .select('*') - .order('name'); - - if (error) { - console.error('Error fetching clients:', error); - setLoading(false); - return; - } + if (error) { + console.error('Error fetching clients:', error); + return []; + } - // Fetch primary contacts for all clients - const { data: contacts } = await supabase - .from('client_contacts') - .select('*') - .eq('is_primary', true) - .eq('is_active', true); + // Fetch primary contacts for all clients + const { data: contacts } = await supabase + .from('client_contacts') + .select('*') + .eq('is_primary', true) + .eq('is_active', true); - const contactMap = new Map(); - contacts?.forEach(c => contactMap.set(c.client_id, c)); + const contactMap = new Map(); + contacts?.forEach(c => contactMap.set(c.client_id, c)); - const clientsWithContacts = (clientsData || []).map(client => ({ - ...client, - primary_contact: contactMap.get(client.id) || null - })); + return (clientsData || []).map(client => ({ + ...client, + primary_contact: contactMap.get(client.id) || null, + })); +} - setClients(clientsWithContacts); - setLoading(false); - } - - fetchClients(); - }, []); +export default function Clients() { + const [search, setSearch] = useState(''); + const { data: clients = [], isLoading: loading } = useQuery({ + queryKey: ['clients'], + queryFn: fetchClients, + }); - const filteredClients = clients.filter(client => + const filteredClients = clients.filter(client => client.name.toLowerCase().includes(search.toLowerCase()) || client.trading_name?.toLowerCase().includes(search.toLowerCase()) || client.primary_contact?.email?.toLowerCase().includes(search.toLowerCase()) || diff --git a/src/pages/Jobs.tsx b/src/pages/Jobs.tsx index 0e18f51..f140b55 100644 --- a/src/pages/Jobs.tsx +++ b/src/pages/Jobs.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; @@ -25,30 +26,27 @@ const statusColors: Record = { archived: 'secondary', }; +async function fetchJobs(): Promise { + const { data, error } = await supabase + .from('jobs') + .select('*, clients(name)') + .order('created_at', { ascending: false }); + + if (error) { + console.error('Error fetching jobs:', error); + return []; + } + return data || []; +} + export default function Jobs() { - const [jobs, setJobs] = useState([]); - const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const { data: jobs = [], isLoading: loading } = useQuery({ + queryKey: ['jobs'], + queryFn: fetchJobs, + }); - useEffect(() => { - async function fetchJobs() { - const { data, error } = await supabase - .from('jobs') - .select('*, clients(name)') - .order('created_at', { ascending: false }); - - if (error) { - console.error('Error fetching jobs:', error); - } else { - setJobs(data || []); - } - setLoading(false); - } - - fetchJobs(); - }, []); - - const filteredJobs = jobs.filter(job => + const filteredJobs = jobs.filter(job => job.name.toLowerCase().includes(search.toLowerCase()) || job.job_number.toLowerCase().includes(search.toLowerCase()) || job.clients?.name?.toLowerCase().includes(search.toLowerCase()) From b4d56c17b5f8a0ec3ad8f6f83490ef785430f72c Mon Sep 17 00:00:00 2001 From: Corianas Date: Fri, 10 Jul 2026 05:27:02 +0800 Subject: [PATCH 07/28] Migrate Inventory/Assets/Issues/Locations lists to TanStack Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue the rollout to the remaining read-only list pages, following the Clients/Jobs pattern: module-level queryFn + useQuery, local UI state and rendering unchanged. Locations composes its multi-step fetch (locations + per-location counts) into one queryFn. Vendors was intentionally skipped — it has an inline insert action that needs the useMutation pattern instead. Verified Locations and Assets render in the running app with no query errors. Build passes. Co-Authored-By: Claude Fable 5 --- src/pages/Assets.tsx | 54 +++++++++++++++---------------- src/pages/Inventory.tsx | 54 +++++++++++++++---------------- src/pages/Issues.tsx | 23 +++++++------ src/pages/Locations.tsx | 72 ++++++++++++++++++++--------------------- 4 files changed, 99 insertions(+), 104 deletions(-) diff --git a/src/pages/Assets.tsx b/src/pages/Assets.tsx index 6ed6c4d..285a002 100644 --- a/src/pages/Assets.tsx +++ b/src/pages/Assets.tsx @@ -1,15 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Plus, Search } from 'lucide-react'; @@ -24,28 +25,25 @@ const statusColors: Record = { retired: 'outline', }; +async function fetchAssets(): Promise { + const { data, error } = await supabase + .from('assets') + .select('*, clients:assigned_client_id(name)') + .order('name'); + + if (error) { + console.error('Error fetching assets:', error); + return []; + } + return (data as any) || []; +} + export default function Assets() { - const [assets, setAssets] = useState([]); - const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); - - useEffect(() => { - async function fetchAssets() { - const { data, error } = await supabase - .from('assets') - .select('*, clients:assigned_client_id(name)') - .order('name'); - - if (error) { - console.error('Error fetching assets:', error); - } else { - setAssets((data as any) || []); - } - setLoading(false); - } - - fetchAssets(); - }, []); + const { data: assets = [], isLoading: loading } = useQuery({ + queryKey: ['assets'], + queryFn: fetchAssets, + }); const filteredAssets = assets.filter(asset => asset.name.toLowerCase().includes(search.toLowerCase()) || diff --git a/src/pages/Inventory.tsx b/src/pages/Inventory.tsx index d8509e7..39bc1f2 100644 --- a/src/pages/Inventory.tsx +++ b/src/pages/Inventory.tsx @@ -1,15 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Plus, Search, AlertTriangle } from 'lucide-react'; @@ -17,28 +18,25 @@ import type { Tables } from '@/integrations/supabase/types'; type Item = Tables<'items'>; -export default function Inventory() { - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); +async function fetchItems(): Promise { + const { data, error } = await supabase + .from('items') + .select('*') + .order('name'); - useEffect(() => { - fetchItems(); - }, []); - - async function fetchItems() { - const { data, error } = await supabase - .from('items') - .select('*') - .order('name'); - - if (error) { - console.error('Error fetching items:', error); - } else { - setItems(data || []); - } - setLoading(false); + if (error) { + console.error('Error fetching items:', error); + return []; } + return data || []; +} + +export default function Inventory() { + const [search, setSearch] = useState(''); + const { data: items = [], isLoading: loading } = useQuery({ + queryKey: ['inventory'], + queryFn: fetchItems, + }); const filteredItems = items.filter(item => item.name.toLowerCase().includes(search.toLowerCase()) || diff --git a/src/pages/Issues.tsx b/src/pages/Issues.tsx index 07aaed9..366f046 100644 --- a/src/pages/Issues.tsx +++ b/src/pages/Issues.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; @@ -13,19 +14,17 @@ type Issue = Tables<'issues'> & { clients?: { name: string } | null }; const severityColors: Record = { low: 'secondary', medium: 'default', high: 'outline', critical: 'destructive' }; const statusColors: Record = { open: 'destructive', in_progress: 'default', resolved: 'secondary', closed: 'outline' }; +async function fetchIssues(): Promise { + const { data } = await supabase.from('issues').select('*, clients(name)').order('created_at', { ascending: false }); + return data || []; +} + export default function Issues() { - const [issues, setIssues] = useState([]); - const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); - - useEffect(() => { - async function fetchIssues() { - const { data } = await supabase.from('issues').select('*, clients(name)').order('created_at', { ascending: false }); - setIssues(data || []); - setLoading(false); - } - fetchIssues(); - }, []); + const { data: issues = [], isLoading: loading } = useQuery({ + queryKey: ['issues'], + queryFn: fetchIssues, + }); const filteredIssues = issues.filter(i => i.title.toLowerCase().includes(search.toLowerCase())); diff --git a/src/pages/Locations.tsx b/src/pages/Locations.tsx index 383ae55..d264aa5 100644 --- a/src/pages/Locations.tsx +++ b/src/pages/Locations.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Link, useNavigate } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; @@ -35,45 +36,44 @@ const LOCATION_TYPES = [ 'Other', ]; -export default function Locations() { - const navigate = useNavigate(); - const [locations, setLocations] = useState([]); - const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [typeFilter, setTypeFilter] = useState('all'); +async function fetchLocations(): Promise { + const { data, error } = await supabase + .from('locations') + .select('*') + .order('name'); - useEffect(() => { - fetchLocations(); - }, []); + if (error || !data) { + return []; + } - async function fetchLocations() { - const { data, error } = await supabase - .from('locations') - .select('*') - .order('name'); + // Fetch linked entity counts + const locationsWithCounts = await Promise.all( + data.map(async (location) => { + const [vendors, clients, assets] = await Promise.all([ + supabase.from('vendors').select('id', { count: 'exact', head: true }).eq('location_id', location.id), + supabase.from('clients').select('id', { count: 'exact', head: true }).eq('location_id', location.id), + supabase.from('assets').select('id', { count: 'exact', head: true }).eq('location_id', location.id), + ]); - if (!error && data) { - // Fetch linked entity counts - const locationsWithCounts = await Promise.all( - data.map(async (location) => { - const [vendors, clients, assets] = await Promise.all([ - supabase.from('vendors').select('id', { count: 'exact', head: true }).eq('location_id', location.id), - supabase.from('clients').select('id', { count: 'exact', head: true }).eq('location_id', location.id), - supabase.from('assets').select('id', { count: 'exact', head: true }).eq('location_id', location.id), - ]); + return { + ...location, + vendor_count: vendors.count || 0, + client_count: clients.count || 0, + asset_count: assets.count || 0, + }; + }) + ); + return locationsWithCounts; +} - return { - ...location, - vendor_count: vendors.count || 0, - client_count: clients.count || 0, - asset_count: assets.count || 0, - }; - }) - ); - setLocations(locationsWithCounts); - } - setLoading(false); - } +export default function Locations() { + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const { data: locations = [], isLoading: loading } = useQuery({ + queryKey: ['locations'], + queryFn: fetchLocations, + }); const filteredLocations = locations.filter((location) => { const matchesSearch = From 772f52374bab249f66c5ea5b5d6cbceb94a5411d Mon Sep 17 00:00:00 2001 From: Corianas Date: Fri, 10 Jul 2026 16:42:19 +0800 Subject: [PATCH 08/28] Fix UTC date defaults and setup wizard invoice numbering - Add src/lib/dates.ts local-date helpers and replace every toISOString().split('T')[0] call site; date defaults and derived dates (due dates, start-of-month, rental billing) no longer resolve to yesterday in UTC+ timezones - Setup wizard now previews and saves invoice numbers with the exact server formula (prefix includes separator, 5-digit padding), so the first invoice matches the wizard's promise - New-account form sends billable defaults explicitly so the saved record matches what the toggles displayed Co-Authored-By: Claude Fable 5 --- src/components/EditPurchaseDialog.tsx | 3 +- .../purchases/ImportBillCSVDialog.tsx | 3 +- src/components/setup/SetupWizard.tsx | 2 +- .../setup/steps/BankAccountStep.tsx | 3 +- src/components/setup/steps/CompletionStep.tsx | 2 +- src/components/setup/steps/InvoiceStep.tsx | 11 ++++-- src/hooks/useAssetConflicts.ts | 3 +- src/lib/dates.ts | 37 +++++++++++++++++++ src/pages/ClientDetail.tsx | 4 +- src/pages/Dashboard.tsx | 3 +- src/pages/JobDetail.tsx | 24 ++++++------ 11 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 src/lib/dates.ts diff --git a/src/components/EditPurchaseDialog.tsx b/src/components/EditPurchaseDialog.tsx index ae971b2..7e705a8 100644 --- a/src/components/EditPurchaseDialog.tsx +++ b/src/components/EditPurchaseDialog.tsx @@ -14,6 +14,7 @@ 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 { todayLocal } from '@/lib/dates'; import { uuid } from '@/lib/utils'; import type { Tables } from '@/integrations/supabase/types'; @@ -67,7 +68,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: '', diff --git a/src/components/purchases/ImportBillCSVDialog.tsx b/src/components/purchases/ImportBillCSVDialog.tsx index 99454aa..807718a 100644 --- a/src/components/purchases/ImportBillCSVDialog.tsx +++ b/src/components/purchases/ImportBillCSVDialog.tsx @@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Upload, ArrowLeft, ArrowRight, Check, AlertCircle, Bookmark, X } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; +import { todayLocal } from '@/lib/dates'; interface ImportBillCSVDialogProps { open: boolean; @@ -398,7 +399,7 @@ export default function ImportBillCSVDialog({ }, body: JSON.stringify({ save_mappings: true, - date: new Date().toISOString().split('T')[0] + date: todayLocal() }), } ); diff --git a/src/components/setup/SetupWizard.tsx b/src/components/setup/SetupWizard.tsx index 7af40c3..f706c91 100644 --- a/src/components/setup/SetupWizard.tsx +++ b/src/components/setup/SetupWizard.tsx @@ -64,7 +64,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { tradingName: { name: '', bankAccountName: '', bsb: '', accountNumber: '', paypalEmail: '' }, taxRate: { name: 'GST', rate: 10 }, bankAccount: { name: '', bankName: '', bsb: '', accountNumber: '', openingBalance: 0, openingBalanceDate: '' }, - invoice: { prefix: 'INV', nextNumber: 1, paymentTerms: 30 }, + invoice: { prefix: 'INV-', nextNumber: 1, paymentTerms: 30 }, }); const progress = (currentStep / (STEPS.length + 1)) * 100; diff --git a/src/components/setup/steps/BankAccountStep.tsx b/src/components/setup/steps/BankAccountStep.tsx index 055f68d..f211e83 100644 --- a/src/components/setup/steps/BankAccountStep.tsx +++ b/src/components/setup/steps/BankAccountStep.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Landmark } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; +import { todayLocal } from '@/lib/dates'; import { toast } from 'sonner'; interface BankAccountStepProps { @@ -40,7 +41,7 @@ export function BankAccountStep({ data, onUpdate, onNext, onBack }: BankAccountS account_number: data.accountNumber.trim() || null, opening_balance: data.openingBalance || 0, current_balance: data.openingBalance || 0, - opening_balance_date: data.openingBalanceDate || new Date().toISOString().split('T')[0], + opening_balance_date: data.openingBalanceDate || todayLocal(), is_default: true, is_active: true, }); diff --git a/src/components/setup/steps/CompletionStep.tsx b/src/components/setup/steps/CompletionStep.tsx index a0364e1..42ac5c4 100644 --- a/src/components/setup/steps/CompletionStep.tsx +++ b/src/components/setup/steps/CompletionStep.tsx @@ -21,7 +21,7 @@ export function CompletionStep({ wizardData, onComplete }: CompletionStepProps) { label: 'Trading as', value: wizardData.tradingName.name, show: !!wizardData.tradingName.name }, { label: 'Tax rate', value: `${wizardData.taxRate.name} (${wizardData.taxRate.rate}%)`, show: !!wizardData.taxRate.name }, { label: 'Bank account', value: wizardData.bankAccount.name, show: !!wizardData.bankAccount.name }, - { label: 'Invoice format', value: `${wizardData.invoice.prefix}-${String(wizardData.invoice.nextNumber).padStart(4, '0')}`, show: !!wizardData.invoice.prefix }, + { label: 'Invoice format', value: `${wizardData.invoice.prefix}${String(wizardData.invoice.nextNumber).padStart(5, '0')}`, show: !!wizardData.invoice.prefix }, ].filter(item => item.show); return ( diff --git a/src/components/setup/steps/InvoiceStep.tsx b/src/components/setup/steps/InvoiceStep.tsx index f34a911..2b02397 100644 --- a/src/components/setup/steps/InvoiceStep.tsx +++ b/src/components/setup/steps/InvoiceStep.tsx @@ -26,7 +26,7 @@ export function InvoiceStep({ data, onUpdate, onNext, onBack }: InvoiceStepProps const { error } = await supabase .from('company_settings') .update({ - invoice_prefix: data.prefix.trim() || 'INV', + invoice_prefix: data.prefix.trim() || 'INV-', invoice_next_number: data.nextNumber || 1, default_payment_terms: data.paymentTerms || 30, setup_completed: true, @@ -44,8 +44,8 @@ export function InvoiceStep({ data, onUpdate, onNext, onBack }: InvoiceStepProps }; const formatInvoiceNumber = () => { - const num = String(data.nextNumber).padStart(4, '0'); - return `${data.prefix}-${num}`; + const num = String(data.nextNumber).padStart(5, '0'); + return `${data.prefix}${num}`; }; return ( @@ -64,11 +64,14 @@ export function InvoiceStep({ data, onUpdate, onNext, onBack }: InvoiceStepProps onUpdate({ prefix: e.target.value.toUpperCase() })} maxLength={10} /> +

+ Include a separator if you want one, e.g. INV- +

diff --git a/src/hooks/useAssetConflicts.ts b/src/hooks/useAssetConflicts.ts index 1b5be16..cec04fc 100644 --- a/src/hooks/useAssetConflicts.ts +++ b/src/hooks/useAssetConflicts.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { supabase } from '@/integrations/supabase/client'; +import { todayLocal } from '@/lib/dates'; import type { Tables } from '@/integrations/supabase/types'; type JobAsset = Tables<'job_assets'>; @@ -24,7 +25,7 @@ export function useAssetConflicts(currentJobId?: string): ConflictResult { useEffect(() => { async function fetchRentals() { - const today = new Date().toISOString().split('T')[0]; + const today = todayLocal(); // Fetch all rentals that either: // 1. Have no end date (ongoing), OR diff --git a/src/lib/dates.ts b/src/lib/dates.ts new file mode 100644 index 0000000..892b100 --- /dev/null +++ b/src/lib/dates.ts @@ -0,0 +1,37 @@ +/** + * Local-date helpers. + * + * `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. + */ + +/** Format a Date as YYYY-MM-DD using its *local* date parts. */ +export function formatDateOnly(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 todayLocal(): string { + return formatDateOnly(new Date()); +} + +/** + * Parse a 'YYYY-MM-DD' string into a local Date (midnight local time). + * Do NOT use `new Date(s)` for date-only strings — that parses as UTC and + * can shift the date by a day depending on the local timezone. + */ +export function parseDateOnly(s: string): Date { + const [year, month, day] = s.split('-').map(Number); + return new Date(year, month - 1, day); +} + +/** Return a new Date offset by `days` (local arithmetic; handles month/year rollover). */ +export function addDays(d: Date, days: number): Date { + const result = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + result.setDate(result.getDate() + days); + return result; +} diff --git a/src/pages/ClientDetail.tsx b/src/pages/ClientDetail.tsx index e8cf678..d58b487 100644 --- a/src/pages/ClientDetail.tsx +++ b/src/pages/ClientDetail.tsx @@ -36,6 +36,8 @@ export default function ClientDetail() { payment_terms: 30, notes: '', is_active: true, + default_billable_time: true, + default_billable_expenses: true, }); const [jobs, setJobs] = useState([]); const [invoices, setInvoices] = useState([]); @@ -309,7 +311,7 @@ export default function ClientDetail() {
setClient({ ...client, default_billable_expenses: checked } as any)} /> diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 5389067..6df7426 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -10,6 +10,7 @@ import { useToast } from '@/hooks/use-toast'; import { useSetupComplete } from '@/hooks/useSetupComplete'; import { useBranding } from '@/contexts/BrandingContext'; import { SetupWizard } from '@/components/setup/SetupWizard'; +import { formatDateOnly } from '@/lib/dates'; import { formatDistanceToNow } from 'date-fns'; import { DollarSign, @@ -134,7 +135,7 @@ async function fetchDashboardData(): Promise { const { data: payments } = await supabase .from('payments') .select('amount') - .gte('date', startOfMonth.toISOString().split('T')[0]); + .gte('date', formatDateOnly(startOfMonth)); const cashCollected = payments?.reduce((sum, p) => sum + p.amount, 0) || 0; diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index 19fc181..a2e314b 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -13,6 +13,7 @@ import { Switch } from '@/components/ui/switch'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/contexts/AuthContext'; +import { addDays, formatDateOnly, parseDateOnly, todayLocal } from '@/lib/dates'; import { ArrowLeft, Save, Trash2, Plus, X, FileText, Pencil, AlertTriangle, Ban, Package, History } from 'lucide-react'; import { useAssetConflicts } from '@/hooks/useAssetConflicts'; import JobHistory from '@/components/JobHistory'; @@ -94,14 +95,14 @@ export default function JobDetail() { }); const [newTime, setNewTime] = useState({ - date: new Date().toISOString().split('T')[0], + date: todayLocal(), hours: '', description: '', is_billable: true, // Will be updated from settings }); - + const [newExpense, setNewExpense] = useState({ - date: new Date().toISOString().split('T')[0], + date: todayLocal(), amount: '', description: '', category: '', @@ -112,7 +113,7 @@ export default function JobDetail() { const [newJobAsset, setNewJobAsset] = useState({ asset_id: '', - rental_start_date: new Date().toISOString().split('T')[0], + rental_start_date: todayLocal(), rental_end_date: '', billing_frequency: 'monthly', billing_day: 1, @@ -338,7 +339,7 @@ export default function JobDetail() { toast({ title: 'Error', description: error.message, variant: 'destructive' }); } else { toast({ title: 'Success', description: 'Time entry added' }); - setNewTime({ date: new Date().toISOString().split('T')[0], hours: '', description: '', is_billable: billableDefaults.time }); + setNewTime({ date: todayLocal(), hours: '', description: '', is_billable: billableDefaults.time }); setShowTimeForm(false); fetchRelatedData(); } @@ -364,7 +365,7 @@ export default function JobDetail() { toast({ title: 'Error', description: error.message, variant: 'destructive' }); } else { toast({ title: 'Success', description: 'Expense added' }); - setNewExpense({ date: new Date().toISOString().split('T')[0], amount: '', description: '', category: '', is_billable: billableDefaults.expenses }); + setNewExpense({ date: todayLocal(), amount: '', description: '', category: '', is_billable: billableDefaults.expenses }); setShowExpenseForm(false); fetchRelatedData(); } @@ -407,7 +408,7 @@ export default function JobDetail() { } // Calculate next invoice date based on start date and billing day - const startDate = new Date(newJobAsset.rental_start_date); + const startDate = parseDateOnly(newJobAsset.rental_start_date); let nextInvoiceDate = new Date(startDate); nextInvoiceDate.setDate(newJobAsset.billing_day); if (nextInvoiceDate <= startDate) { @@ -424,7 +425,7 @@ export default function JobDetail() { rental_rate: parseFloat(newJobAsset.rental_rate), invoice_lead_days: newJobAsset.invoice_lead_days, billing_in_advance: newJobAsset.billing_in_advance, - next_invoice_date: nextInvoiceDate.toISOString().split('T')[0], + next_invoice_date: formatDateOnly(nextInvoiceDate), is_active: true, }); @@ -439,7 +440,7 @@ export default function JobDetail() { toast({ title: 'Success', description: 'Asset rental added' }); setNewJobAsset({ asset_id: '', - rental_start_date: new Date().toISOString().split('T')[0], + rental_start_date: todayLocal(), rental_end_date: '', billing_frequency: 'monthly', billing_day: 1, @@ -731,8 +732,7 @@ export default function JobDetail() { const taxTotal = subtotal * (taxRate / 100); const total = subtotal + taxTotal; - const dueDate = new Date(); - dueDate.setDate(dueDate.getDate() + 30); + const dueDate = addDays(new Date(), 30); const { data: invoice, error } = await supabase .from('invoices') @@ -744,7 +744,7 @@ export default function JobDetail() { subtotal, tax_total: taxTotal, total, - due_date: dueDate.toISOString().split('T')[0], + due_date: formatDateOnly(dueDate), created_by: user?.id, }) .select() From 2af106dc22fa2253acf6ce81bba9a8f509a0f5da Mon Sep 17 00:00:00 2001 From: Corianas Date: Fri, 10 Jul 2026 16:42:34 +0800 Subject: [PATCH 09/28] Add mark-as-sent and record-payment actions to the invoice page - Extract the Record Payment dialog into RecordPaymentDialog.tsx (self-fetching, optional preselected invoice, form reset on close) - Invoice page: one-click "Mark as Sent" on drafts and "Record Payment" on payable invoices, refreshing totals on success - Payment dialog shows an explanatory empty state instead of a silently empty invoice dropdown, with submit disabled - Rename ambiguous Payments-page buttons to "Receive Payment" / "Pay Vendor"; hide Void status on new invoices; fix stat-card pluralization Co-Authored-By: Claude Fable 5 --- src/components/MakePaymentDialog.tsx | 7 +- src/components/RecordPaymentDialog.tsx | 441 +++++++++++++++++++++++++ src/pages/InvoiceDetail.tsx | 55 ++- src/pages/Payments.tsx | 422 ++--------------------- 4 files changed, 520 insertions(+), 405 deletions(-) create mode 100644 src/components/RecordPaymentDialog.tsx diff --git a/src/components/MakePaymentDialog.tsx b/src/components/MakePaymentDialog.tsx index 3a46586..835ab0b 100644 --- a/src/components/MakePaymentDialog.tsx +++ b/src/components/MakePaymentDialog.tsx @@ -14,6 +14,7 @@ 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 { todayLocal } from '@/lib/dates'; import { uuid } from '@/lib/utils'; import ImportBillCSVDialog, { ImportedAllocation } from '@/components/purchases/ImportBillCSVDialog'; @@ -65,7 +66,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 +323,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 +356,7 @@ export default function MakePaymentDialog({ open, onOpenChange, onSuccess }: Mak
- Make Payment / Record Expense + Pay Vendor / Record Expense