From 9eca43f12998e40a800782964e67141a8f1cdfa9 Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:14:54 +0200 Subject: [PATCH 01/37] Add PocketBase migration plan and spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands milestone 1 of the Convex → PocketBase migration (see docs/plans/2026-04-19-pocketbase-migration-plan.md): validates that a thin-PocketBase + Next.js-API architecture can replace Convex for the categories domain end-to-end, including schema, validation, authz-shaped error mapping, and realtime cache invalidation via SSE. --- .env.example | 5 + .gitignore | 2 + .../2026-04-19-pocketbase-migration-plan.md | 339 +++++++++++++++++ mprocs.yaml | 2 + package.json | 12 +- pb_migrations/1713484800_initial.js | 70 ++++ pnpm-lock.yaml | 359 ++++++++++++++++++ scripts/bootstrap-pocketbase.sh | 30 ++ scripts/setup-pocketbase.sh | 44 +++ src/app/PocketBaseClientProvider.tsx | 48 +++ src/app/api/categories/route.ts | 52 +++ src/hooks/use-realtime-collection.ts | 59 +++ src/server/domain/categories.ts | 101 +++++ src/server/pb/catalog.ts | 55 +++ src/server/pb/client.ts | 39 ++ src/server/pb/errors.ts | 30 ++ 16 files changed, 1245 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-04-19-pocketbase-migration-plan.md create mode 100644 pb_migrations/1713484800_initial.js create mode 100755 scripts/bootstrap-pocketbase.sh create mode 100755 scripts/setup-pocketbase.sh create mode 100644 src/app/PocketBaseClientProvider.tsx create mode 100644 src/app/api/categories/route.ts create mode 100644 src/hooks/use-realtime-collection.ts create mode 100644 src/server/domain/categories.ts create mode 100644 src/server/pb/catalog.ts create mode 100644 src/server/pb/client.ts create mode 100644 src/server/pb/errors.ts diff --git a/.env.example b/.env.example index 094c29b..8a73d07 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,8 @@ CONVEX_SITE_URL= # E2E tests (email/password of a test user on your Convex instance) E2E_AUTH_EMAIL= E2E_AUTH_PASSWORD= + +# PocketBase (spike / migration target) +POCKETBASE_URL=http://127.0.0.1:8090 +POCKETBASE_SUPERUSER_EMAIL=dev@stowage.local +POCKETBASE_SUPERUSER_PASSWORD= diff --git a/.gitignore b/.gitignore index 0954d18..3f19493 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # dependencies /node_modules +/bin +/pb_data /.pnp .pnp.* .yarn/* diff --git a/docs/plans/2026-04-19-pocketbase-migration-plan.md b/docs/plans/2026-04-19-pocketbase-migration-plan.md new file mode 100644 index 0000000..e400da1 --- /dev/null +++ b/docs/plans/2026-04-19-pocketbase-migration-plan.md @@ -0,0 +1,339 @@ +# PocketBase Migration Plan + +**Date:** 2026-04-19 +**Branch:** `pocketbase-migration` +**Status:** Ready to execute — decisions locked + +Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean installs only; **no existing deployment will carry data across**, so there is no data migration, no ETL, no cutover password flow, no ID redirect table. The bar is functional parity: every feature that works on Convex today must work identically on PocketBase, proven by tests before we land the branch on `main`. + +--- + +## Locked decisions + +1. **Server-side logic placement:** Thin PocketBase + Next.js API routes. PB is the DB/auth/files layer. All validation, authz, and workflows live in TypeScript under `src/server/domain/`, exposed via `src/app/api/**` route handlers. +2. **File storage:** Env-configurable. Default to local disk on a Docker volume. Setting `POCKETBASE_S3_*` env vars opts a deployment into S3-compatible storage. +3. **First admin bootstrap:** First-run setup page. When no admin exists, `/setup` renders a public form to claim the first admin account. Gated by a one-time install token written to `/pb_data/.install-token` on first boot; the form accepts the token to close the race window. No HTTP provisioning secret, no CLI. + +--- + +## 1. Architectural shape + +**What PocketBase gives us** + +- Collections-as-schema over SQLite. +- REST API + realtime subscriptions over SSE. +- Built-in password/OAuth auth with JWT sessions. +- File fields with local or S3 storage and automatic thumbnail generation. +- Admin UI, JS (`pb_hooks`) and Go extension points. +- Single self-hostable binary. Fits our existing `Dockerfile` and `mprocs.yaml`. + +**What we lose relative to Convex** + +- Automatic reactive queries. PB offers per-record SSE; we manage the cache via TanStack Query. +- End-to-end generated types (`api.ts`, `Doc<"assets">`). Replaced with `pocketbase-typegen` + Zod schemas at the API boundary. +- Isolated function runtime with transactional reads/writes across arbitrary records. PB has per-record hooks + request-scoped transactions, but multi-record logic lives in our Next.js API layer. +- Convex's built-in attachment pipeline. Jimp/pdf-lib optimization moves to a Next.js route. + +**Why thin PB + Next API (the locked choice)** + +- Jimp and pdf-lib won't run in PB's Goja runtime, so attachment optimization has to live in Next either way. +- The ~60 existing TS domain functions port 1:1 into `src/server/domain/`. No language change, no test-harness rewrite. +- One authz pattern: every write goes through a Next route handler. Easy to grep, easy to audit. +- Realtime reads stay direct client → PB via SSE, so the extra hop applies to writes only, which at our scale is a non-issue. + +--- + +## 2. Data model mapping + +Convex table → PocketBase collection, one-to-one at the collection level. + +| Convex construct | PocketBase equivalent | Notes | +|---|---|---| +| `v.id("foo")` | `relation` to `foo` | PB relations are 15-char strings. | +| `v.union(v.literal(...))` | `select` with `maxSelect: 1` | Enum-style options populated from the Convex union. | +| `_id` | PB auto `id` | Native PB IDs; nothing portable from Convex. | +| `_creationTime` | PB `created` timestamp | Automatic on every record. | +| `v.array(v.object(...))` | `json` field | PB validates shape loosely; we enforce with Zod in the API layer. | +| `.index("by_x", ["x"])` | `indexes` array on collection schema | 1:1. Unique constraints use `CREATE UNIQUE INDEX`. | +| Search index `search_assets` | FTS5 virtual table + sync triggers | Raw SQL in a `pb_migrations/*.js` file. | +| Self-ref `locations.parentId` | `relation` to `locations` | Cascade rules set on the relation. | +| `_storage` file IDs | `file` field on the owning record | `attachments`, `serviceRecordAttachments`, `labelTemplates` assets. | +| `@convex-dev/auth` tables | PB `users` auth collection + extensions | Drop all Convex auth tables. | + +**Non-trivial schema items** + +- `assets.customFieldValues`, `serviceRecords.values`, `serviceRecords.fieldSnapshots`: `json` fields. Validated on write against `customFieldDefinitions` / `serviceGroupFields` via Zod. +- `locations.path` (hierarchical display string): recomputed in `createLocation` / `moveLocation` API; a `pb_hooks` after-write hook re-verifies the subtree to catch drift. +- `users` gets extra fields beyond PB's defaults: `role` (`admin`|`user`), `createdBy`, `createdAt`, `phone`, `phoneVerificationTime`. + +--- + +## 3. Auth + +- PB's `users` auth collection replaces `@convex-dev/auth` + Lucia Scrypt. +- Extend the `users` collection with the extra fields above. +- Replace `ConvexAuthProvider` with a `PocketBaseProvider` that wraps `pb.authStore` and rehydrates from the PB cookie (`pb_auth`). +- Replace `AuthTokenCookieBridge` with Next.js middleware that reads the PB cookie so SSR sees the authenticated user. + +**First-run setup** + +- On first boot, PB (or a Next startup task) writes a random token to `/pb_data/.install-token`. +- `/setup` renders a public form if and only if no user with `role = "admin"` exists. +- The form requires the install token as an extra field; once accepted, the token file is deleted. +- After setup, `/setup` always 404s. + +--- + +## 4. File storage and the attachment pipeline + +**Storage backend — env-configurable** + +- Default: local disk under `/pb_data/storage`, mounted as a Docker volume. +- S3 opt-in: set `POCKETBASE_S3_ENABLED=true`, `POCKETBASE_S3_BUCKET`, `POCKETBASE_S3_REGION`, `POCKETBASE_S3_ENDPOINT`, `POCKETBASE_S3_ACCESS_KEY`, `POCKETBASE_S3_SECRET_KEY`. +- PB reads these at startup and picks the backend. Migration script for existing self-hosted users (if any ever want to switch) is out of scope for this branch. + +**Optimization pipeline** + +Current Convex flow: `attachmentsProcessing.processAttachmentOptimization` runs as `internalAction` (Node runtime), transitions status `pending → processing → ready | failed`. + +New flow: + +1. Client uploads to PB via multipart POST with `status = "pending"`. +2. Client calls `POST /api/attachments/[id]/optimize`. +3. Route handler loads the file from PB, runs Jimp for images / pdf-lib for PDFs, writes the optimized blob back to the same record's `optimizedFile` field, updates `status`, `fileSizeOptimized`, `optimizationAttempts`. +4. `POST /api/attachments/[id]/retry` re-triggers the same endpoint. + +PB's built-in thumbs cover the UI thumbnail case; Jimp stays for heavier compression. Status state machine unchanged. + +**Storage quota** + +- `getStorageUsage` becomes a SQL aggregation over the PB `_pb_files_meta` table (or `SUM(fileSizeOptimized + fileSizeOriginal)` across attachment collections). +- Enforced in the create-attachment route handler before accepting the upload. + +--- + +## 5. Server-side logic placement + +``` +src/server/ + pb/ + client.ts # admin PB client factory (superuser token) + authz.ts # requireAuthenticatedUser / requireAdmin + errors.ts # typed errors → HTTP status + schema.ts # Zod schemas mirroring collection fields + domain/ + assets.ts + assetTags.ts + attachments.ts + attachmentsProcessing.ts + appSettings.ts + categories.ts + customFields.ts + dashboard.ts + labelTemplates.ts + locations.ts + search.ts + serviceGroups.ts + serviceGroupFields.ts + serviceProviders.ts + serviceRecords.ts + serviceRecordAttachments.ts + serviceSchedules.ts + tags.ts + users.ts +``` + +Each module exposes plain async functions, 1:1 with the Convex functions they replace. + +`src/app/api/**` route handlers are thin: parse request → call domain function → format response. No business logic in route files. + +**Reads that may go direct to PB (skipping Next) for realtime** + +- Lookup lists: categories, tags, customFieldDefinitions, labelTemplates, serviceProviders, serviceGroups, appSettings. +- Filtered assets list (filter string composed client-side). + +**Reads/writes that must go through Next** + +- All writes, without exception (single authz pattern). +- Search (FTS query construction). +- Dashboard aggregations. +- Anything that joins across collections where the result shape differs from PB's `expand` output. + +--- + +## 6. Realtime + +- TanStack Query as the cache layer. +- `useRealtimeCollection(name, filter)` wires `pb.collection(name).subscribe('*', cb)` and invalidates matching query keys on `create`/`update`/`delete`. +- `useRealtimeRecord(name, id)` for detail views. +- Semantics shift from Convex-style diffs to per-record events. With our data volumes, refetch-on-invalidate is fine and simpler to reason about. + +--- + +## 7. Search + +- FTS5 virtual table `assets_fts` over `(name, assetTag, notes)`, content table = `assets`. +- Sync triggers on `assets` insert/update/delete. +- `src/server/domain/search.ts:searchAssets` runs `SELECT ... FROM assets_fts WHERE assets_fts MATCH ? ORDER BY bm25(assets_fts) ...` with a manual boost for exact `assetTag` equality to preserve the assetTag > name > notes ordering. +- Joins category name and location path inline. +- Exposed via `GET /api/search?term=...&limit=...`. + +Setup lives in `pb_migrations/_search_fts.js` so every fresh install gets the FTS table and triggers. + +--- + +## 8. Testing — comprehensive regression coverage + +The PB branch cannot land until tests prove every feature works identically to the Convex version. Seven layers. + +### 8.1 Contract snapshots — capture current Convex behavior + +**Purpose:** lock down what the app does today so the new implementation is provably equivalent. + +- Written on `main` (Convex version) before the PB work lands. +- `convex/__tests__/contract/*.test.ts` — one file per public Convex function. +- For every query/mutation/action, record: + - Input shape covering every validator branch. + - Output shape + values over a golden seed dataset. + - Authz behavior (authenticated user, unauthenticated, admin, non-admin). + - Error cases (not found, duplicate, validation failure). +- Results serialized to `__snapshots__/*.json`, committed alongside tests. + +Covers every function in the inventory: ~60 functions → ~300 snapshot cases when cross-producted with authz states. + +### 8.2 Domain-module unit tests against a real PB instance + +**Purpose:** prove each `src/server/domain/*.ts` module works against a real SQLite PB. + +- `src/server/domain/__tests__/**`. +- Helper `pbTestHarness()`: + - Boots `pocketbase serve` on a random port with a tmp data dir, one per Vitest worker. + - Runs `pb_migrations/` to the latest schema. + - Returns admin SDK client + record factories (`makeUser`, `makeAsset`, `makeCategory`, …). + - Tears down on suite end. +- Each test covers happy path, every `throw` branch (auth, not found, validation, quota), and `EXPLAIN QUERY PLAN` assertions on perf-sensitive queries (search, asset list with filters, calendar month). +- 1:1 parity with the existing `convex/__tests__/` suite: every Convex test has a mirror PB test with the same assertions. + +Files to port (from the inventory): auth, users, locations, categories, tags, custom fields, service schedules, storage quota, label templates, search, assetTags, serviceProviders, serviceRecords, serviceSchedules, serviceGroups, labelTemplates, attachments. + +### 8.3 Regression parity tests + +**Purpose:** prove the PB implementation matches the Convex behavior captured in §8.1. + +- `tests/parity/**` loads each snapshot from §8.1, calls the matching PB domain function with the same inputs, asserts deep-equal on outputs. +- Where the Convex snapshot contains Convex IDs, the parity adapter ignores ID-shape differences but still validates relational integrity (e.g., "categoryName resolves to the same value"). +- Any output delta fails the test with a diff. +- Required CI check on the `pocketbase-migration` branch. + +### 8.4 API route handler tests + +**Purpose:** prove the HTTP surface (the thing the client actually calls) is correct. + +- `src/app/api/**/__tests__/*.test.ts`. +- Per route: + - Cookie-based auth passes and fails correctly. + - Method allowlist enforced. + - Zod validation rejects malformed payloads with the right status + error shape. + - Response shape matches the documented contract. + - Rate-limit / quota paths exercised where relevant. + - CSRF behavior for state-changing routes. + +### 8.5 Component tests — ported, not deleted + +**Purpose:** keep the ~60 existing component tests passing through the transition. + +- Replace `vi.mock("convex/react")` mocks with TanStack Query mocks. Prop-level data shapes are unchanged, so most tests only swap the mock layer. +- For components subscribing to realtime, add a `MockRealtime` provider that drives update events. +- Every existing component test must still pass. Removing a test requires a written justification in the PR description, which gets linked from `docs/plans/pb-removed-tests.md`. + +### 8.6 End-to-end tests — Playwright + +**Purpose:** prove the full stack works in a browser. Every flow runs against Convex (on `main`) and PB (on `pocketbase-migration`) and must pass identically. + +- **Install/bootstrap:** fresh container → `/setup` appears → install token accepted → first admin created → `/setup` 404s afterwards. +- **Auth:** sign in, sign out, change password, role gates (admin-only pages redirect non-admins). +- **Assets:** create → edit → tag → move location → add custom field values → upload attachment → wait for optimization → view → delete. +- **Attachments:** upload image (verify thumbnail + optimized size), upload PDF (verify optimized), upload unsupported type (verify error), retry failed optimization, delete attachment (verify file removed from storage). +- **Search:** type 2+ chars → results ranked by assetTag > name > notes; verify category name and location path on results. +- **Service records:** create group + fields → attach group to asset → create schedule → complete scheduled service → verify snapshot fields frozen on the record. +- **Calendar:** scheduled services render on correct dates; cross-month navigation; timezone edge cases. +- **Dashboard:** counts match fixture data; upcoming services list correct; overview numbers stable. +- **Labels:** create label template → generate label for asset → print view renders barcodes correctly. +- **Catalog CRUD:** categories, tags, locations (with hierarchy move), custom fields, service providers, service groups — create, update, delete, delete-in-use guardrails. +- **Admin:** create user → assign role → delete user → verify role gates. +- **Storage quota:** upload up to quota boundary, one upload beyond → correct error, existing files still accessible. +- **Realtime:** open two tabs, mutate in one, the other updates within the debounce window. +- **App settings:** change date format, verify formatting across asset list, calendar, service records. + +New E2E tests specific to this branch: + +- **Install token security:** `/setup` without a valid install token is rejected; expired token rejected. +- **Schema invariant:** after any E2E flow, run a referential-integrity check across all relations. +- **Realtime subscription lifecycle:** navigating away from a page cleanly unsubscribes (no SSE leaks). + +### 8.7 Coverage + hygiene gates + +- Baseline coverage captured from `main` (Convex) before the branch starts. CI on `pocketbase-migration` fails if overall line coverage drops below that baseline. +- `pnpm test:parity` must run green on every PR to the branch. +- CI runs: `lint`, `typecheck`, `vitest` (domain + component + parity), `playwright` (full E2E against both Convex and PB), `pnpm test:coverage` gate, schema-drift check (verify `pb_migrations/` matches the generated `pocketbase-typegen` types). + +### 8.8 Manual verification checklist + +Before merging to `main`, a human runs `docs/plans/pb-release-checklist.md` (to be written alongside the branch): every feature exercised in the admin UI, every role tested, 10MB attachment upload, non-ASCII input in search and asset names, timezone flip on a schedule. + +--- + +## 9. Deployment + +- `mprocs.yaml`: add a `pocketbase` service for dev parity with prod. +- `Dockerfile`: multi-stage build. Stage 1 builds Next; stage 2 bundles Next + the PB binary. Alternatively two images in `docker-compose.yml` sharing a volume — pick based on ops preference during the spike. +- Volume: `/pb_data` for PB's SQLite + local file storage. Mounted in both dev (`./pb_data`) and prod (named Docker volume). +- Env vars: + - **Added:** + - `POCKETBASE_URL` — base URL PB is reachable at from Next. + - `POCKETBASE_SUPERUSER_EMAIL`, `POCKETBASE_SUPERUSER_PASSWORD` — set on first boot by an init script if not already present. + - `POCKETBASE_S3_*` — optional, opts into S3 storage. + - **Removed:** `CONVEX_DEPLOYMENT`, `CONVEX_URL`, `CONVEX_SITE_URL`, `NEXT_PUBLIC_CONVEX_URL`, `PROVISION_SECRET`. +- Backups: cron `pocketbase backup` to the storage volume; docs point operators at PB's built-in backup API for off-box copies. +- Schema: version-controlled in `pb_migrations/*.js` so every install comes up deterministically. + +--- + +## 10. Sequenced milestones + +1. **Spike (1–2 days):** PB locally; port `categories.ts` + `assets.ts:listAssets` end-to-end (provider, typegen, one realtime query, one mutation). Confirm wiring works. +2. **Test infrastructure (2 days):** contract snapshots captured on `main`; `pbTestHarness()` written; CI gates configured. +3. **Schema + auth (3–5 days):** full collection schema in `pb_migrations/`, Next auth provider + middleware, `/setup` page with install token. +4. **Catalog domain (2–3 days):** categories, tags, locations (incl. hierarchical move + path recompute), customFields, labelTemplates, appSettings, serviceGroups/Fields/Providers. +5. **Assets + search (3–5 days):** asset CRUD, asset tags join, FTS5 search, filter options, dashboard queries. +6. **Attachments (3–4 days):** upload flow, optimization pipeline in Next API, deletion cascades, storage quota, S3 opt-in wiring. +7. **Service records + schedules (3–4 days):** records with snapshots, schedules, calendar queries, upcoming-services previews. +8. **Frontend cutover (3–5 days):** replace every `useQuery`/`useMutation` call site; wire TanStack Query + realtime hooks; fix component tests. +9. **E2E + manual QA (2 days):** full Playwright run against PB; manual checklist; fix regressions. +10. **Merge gate:** parity suite green, E2E green on both backends, coverage at or above baseline, manual checklist signed off. Delete `convex/` directory, remove Convex dependencies, merge to `main`. + +Total: ~4 weeks focused; schedule for 6 with buffer. + +--- + +## 11. Risk register + +| Risk | Mitigation | +|---|---| +| Realtime semantic differences break components that rely on cross-entity reactivity | Component tests (§8.5) audit realtime assumptions; add explicit subscriptions where needed. | +| Multi-record atomicity on asset delete (touches `assetTags`, `attachments`, `serviceRecords`) | Sequence writes in one Next API call with compensating cleanup on failure; dedicated test in §8.2. | +| `/setup` race: someone hits the page before the operator | Install token gate; token file deleted after first use. | +| Missing FK validation (PB relations are strings) | Zod at every API boundary + referential-integrity test in §8.6. | +| Search ranking regression vs. Convex's weighted scorer | Parity test §8.3 locks expected rankings on a fixed corpus. | +| Attachment optimization reliability without Convex's retry semantics | Explicit retry endpoint + `optimizationAttempts` counter + backoff; E2E in §8.6. | +| SQLite single-writer contention under realistic load | Load-test during milestone 5; enable WAL mode; batch attachment writes. | +| Schema drift between `pb_migrations/` and `pocketbase-typegen` output | CI gate in §8.7 regenerates types and fails on diff. | + +--- + +## 12. Out of scope + +- Data migration from existing Convex deployments. All Stowage installs are clean; no ETL, no password reset, no ID redirects. +- Dual-write or shadow-replica phases. +- Multi-tenancy changes. Current single-tenant model is preserved. +- New product features. Functional parity only. diff --git a/mprocs.yaml b/mprocs.yaml index b2840bc..410d2b0 100644 --- a/mprocs.yaml +++ b/mprocs.yaml @@ -3,3 +3,5 @@ procs: cmd: ["pnpm", "run", "dev:next"] convex: cmd: ["npx", "convex", "dev"] + pocketbase: + cmd: ["pnpm", "run", "pb:dev"] diff --git a/package.json b/package.json index d641216..684e54b 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,16 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "test:all": "vitest run && playwright test" + "test:all": "vitest run && playwright test", + "pb:setup": "bash scripts/setup-pocketbase.sh", + "pb:dev": "./bin/pocketbase serve --http=127.0.0.1:8090 --dir=./pb_data --migrationsDir=./pb_migrations", + "pb:bootstrap": "bash scripts/bootstrap-pocketbase.sh", + "pb:typegen": "pocketbase-typegen --db ./pb_data/data.db --out src/server/pb/types.generated.ts" }, "dependencies": { "@auth/core": "^0.37.4", "@convex-dev/auth": "^0.0.90", + "@tanstack/react-query": "^5.99.2", "bwip-js": "^4.8.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -30,11 +35,13 @@ "next": "16.1.6", "next-themes": "^0.4.6", "pdf-lib": "^1.17.1", + "pocketbase": "^0.26.8", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -50,6 +57,7 @@ "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.8", "jsdom": "^28.1.0", + "pocketbase-typegen": "^1.4.0", "prettier": "^3.8.1", "shadcn": "^3.8.5", "tailwindcss": "^4", diff --git a/pb_migrations/1713484800_initial.js b/pb_migrations/1713484800_initial.js new file mode 100644 index 0000000..99fdbd0 --- /dev/null +++ b/pb_migrations/1713484800_initial.js @@ -0,0 +1,70 @@ +/// + +// Initial schema migration: categories collection only (spike scope). +// The remaining collections (assets, tags, locations, users, ...) land in +// milestone 3 once the spike has validated the wiring end-to-end. +migrate( + (app) => { + const collection = new Collection({ + type: "base", + name: "categories", + fields: [ + { + name: "name", + type: "text", + required: true, + max: 200, + }, + { + name: "normalizedName", + type: "text", + required: true, + max: 200, + }, + { + name: "prefix", + type: "text", + required: false, + max: 50, + }, + { + name: "description", + type: "text", + required: false, + max: 2000, + }, + { + name: "color", + type: "text", + required: true, + pattern: "^#[0-9A-F]{6}$", + }, + { + name: "createdAt", + type: "number", + required: true, + }, + { + name: "updatedAt", + type: "number", + required: true, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_categories_normalized_name ON categories (normalizedName)", + ], + // Spike note: keep permissive rules for now; authz comes in milestone 3. + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + + app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("categories"); + app.delete(collection); + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e49dc5..42f62e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@convex-dev/auth': specifier: ^0.0.90 version: 0.0.90(@auth/core@0.37.4)(convex@1.32.0(react@19.2.3))(react@19.2.3) + '@tanstack/react-query': + specifier: ^5.99.2 + version: 5.99.2(react@19.2.3) bwip-js: specifier: ^4.8.0 version: 4.8.0 @@ -47,6 +50,9 @@ importers: pdf-lib: specifier: ^1.17.1 version: 1.17.1 + pocketbase: + specifier: ^0.26.8 + version: 0.26.8 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -62,6 +68,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@playwright/test': specifier: ^1.58.2 @@ -102,6 +111,9 @@ importers: jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@1.8.0) + pocketbase-typegen: + specifier: ^1.4.0 + version: 1.4.0 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -1989,6 +2001,14 @@ packages: '@tailwindcss/postcss@4.2.1': resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + '@tanstack/query-core@5.99.2': + resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} + + '@tanstack/react-query@5.99.2': + resolution: {integrity: sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2376,6 +2396,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2407,9 +2430,19 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + better-sqlite3@12.9.0: + resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bmp-ts@1.0.9: resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} @@ -2433,6 +2466,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2479,6 +2515,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2521,6 +2560,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2529,6 +2572,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2589,6 +2636,9 @@ packages: typescript: optional: true + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2655,6 +2705,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@1.7.1: resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: @@ -2663,6 +2717,10 @@ packages: babel-plugin-macros: optional: true + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2690,6 +2748,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2719,6 +2781,14 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv-flow@4.1.0: + resolution: {integrity: sha512-0cwP9jpQBQfyHwvE0cRhraZMkdV45TQedA8AAUZMsFzvmLcQyc1HPv+oX0OOYwLFjIlvgVepQ+WuQHbqDaHJZg==} + engines: {node: '>= 12.0.0'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.3.1: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} @@ -2750,6 +2820,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -2981,6 +3054,10 @@ packages: exif-parser@0.1.12: resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3043,6 +3120,9 @@ packages: resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} engines: {node: '>=10'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3066,6 +3146,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -3078,6 +3162,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} @@ -3158,6 +3245,9 @@ packages: gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3286,6 +3376,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3724,10 +3817,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -3745,6 +3846,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -3759,6 +3864,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3781,6 +3889,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3820,6 +3931,10 @@ packages: sass: optional: true + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3829,6 +3944,15 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4036,6 +4160,13 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + pocketbase-typegen@1.4.0: + resolution: {integrity: sha512-L1ngH3m5N30P8bF0ukK30P3jQX2sbE29om4lfmiCSi3tX4XGwjo+/jJXbjQ2N4yELkWyOm0MjwI36s1EMPfJ3w==} + hasBin: true + + pocketbase@0.26.8: + resolution: {integrity: sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4064,6 +4195,12 @@ packages: preact@10.24.3: resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4096,6 +4233,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4128,6 +4268,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -4173,6 +4317,10 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4355,6 +4503,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-xml-to-json@1.2.3: resolution: {integrity: sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==} engines: {node: '>=20.12.2'} @@ -4462,6 +4616,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4508,6 +4666,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4552,6 +4717,9 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -4578,6 +4746,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -4780,6 +4951,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -4792,6 +4966,9 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -6742,6 +6919,13 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.2.1 + '@tanstack/query-core@5.99.2': {} + + '@tanstack/react-query@5.99.2(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.99.2 + react: 19.2.3 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -7150,6 +7334,8 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -7168,10 +7354,25 @@ snapshots: baseline-browser-mapping@2.10.0: {} + better-sqlite3@12.9.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bmp-ts@1.0.9: {} body-parser@2.2.2: @@ -7209,6 +7410,11 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -7252,6 +7458,8 @@ snapshots: chalk@5.6.2: {} + chownr@1.1.4: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7294,10 +7502,16 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@11.1.0: {} commander@14.0.3: {} + commander@9.5.0: {} + concat-map@0.0.1: {} content-disposition@1.0.1: {} @@ -7341,6 +7555,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7404,8 +7624,14 @@ snapshots: decimal.js@10.6.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@1.7.1: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -7431,6 +7657,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -7449,6 +7677,12 @@ snapshots: dom-accessibility-api@0.6.3: {} + dotenv-flow@4.1.0: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + dotenv@17.3.1: {} dunder-proto@1.0.1: @@ -7476,6 +7710,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -7883,6 +8121,8 @@ snapshots: exif-parser@0.1.12: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} express-rate-limit@8.2.1(express@5.2.1): @@ -7974,6 +8214,8 @@ snapshots: strtok3: 6.3.0 token-types: 4.2.1 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -8005,6 +8247,14 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -8013,6 +8263,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 @@ -8094,6 +8346,8 @@ snapshots: image-q: 4.0.0 omggif: 1.0.10 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8206,6 +8460,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -8612,8 +8868,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -8624,6 +8886,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@10.2.4: @@ -8636,6 +8900,8 @@ snapshots: minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} msw@2.12.10(@types/node@20.19.33)(typescript@5.9.3): @@ -8667,6 +8933,8 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -8703,6 +8971,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + node-domexception@1.0.0: {} node-exports-info@1.6.0: @@ -8712,6 +8984,10 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -8922,6 +9198,18 @@ snapshots: pngjs@7.0.0: {} + pocketbase-typegen@1.4.0: + dependencies: + better-sqlite3: 12.9.0 + commander: 9.5.0 + cross-fetch: 3.2.0 + dotenv-flow: 4.1.0 + form-data: 4.0.5 + transitivePeerDependencies: + - encoding + + pocketbase@0.26.8: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -8949,6 +9237,21 @@ snapshots: preact@10.24.3: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -8981,6 +9284,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qs@6.15.0: @@ -9061,6 +9369,13 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -9099,6 +9414,12 @@ snapshots: react@19.2.3: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -9425,6 +9746,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-xml-to-json@1.2.3: {} sisteransi@1.0.5: {} @@ -9545,6 +9874,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strtok3@6.3.0: @@ -9575,6 +9906,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -9611,6 +9957,8 @@ snapshots: dependencies: tldts: 7.0.23 + tr46@0.0.3: {} + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -9641,6 +9989,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -9843,6 +10195,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@5.0.0: {} @@ -9855,6 +10209,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/scripts/bootstrap-pocketbase.sh b/scripts/bootstrap-pocketbase.sh new file mode 100755 index 0000000..54d9c0f --- /dev/null +++ b/scripts/bootstrap-pocketbase.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Creates (or updates) a PocketBase superuser using env credentials. +# Safe to run repeatedly — upsert is idempotent. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PB_BIN="$REPO_ROOT/bin/pocketbase" + +if [ ! -x "$PB_BIN" ]; then + echo "pocketbase binary missing — run 'pnpm pb:setup' first" >&2 + exit 1 +fi + +# Source .env.local if present so local dev picks up the creds. +if [ -f "$REPO_ROOT/.env.local" ]; then + set -a + # shellcheck disable=SC1091 + . "$REPO_ROOT/.env.local" + set +a +fi + +: "${POCKETBASE_SUPERUSER_EMAIL:?POCKETBASE_SUPERUSER_EMAIL is required}" +: "${POCKETBASE_SUPERUSER_PASSWORD:?POCKETBASE_SUPERUSER_PASSWORD is required}" + +"$PB_BIN" superuser upsert \ + "$POCKETBASE_SUPERUSER_EMAIL" \ + "$POCKETBASE_SUPERUSER_PASSWORD" \ + --dir="$REPO_ROOT/pb_data" + +echo "Superuser $POCKETBASE_SUPERUSER_EMAIL ready" diff --git a/scripts/setup-pocketbase.sh b/scripts/setup-pocketbase.sh new file mode 100755 index 0000000..0224312 --- /dev/null +++ b/scripts/setup-pocketbase.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Downloads the PocketBase binary into ./bin/pocketbase for the current host arch. +# Pin with PB_VERSION env var; defaults to the version below. +set -euo pipefail + +PB_VERSION="${PB_VERSION:-0.37.1}" + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" + +case "$ARCH" in + x86_64 | amd64) ARCH="amd64" ;; + arm64 | aarch64) ARCH="arm64" ;; + *) echo "Unsupported arch: $ARCH" >&2; exit 1 ;; +esac + +case "$OS" in + darwin | linux) ;; + *) echo "Unsupported OS: $OS" >&2; exit 1 ;; +esac + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_DIR="$REPO_ROOT/bin" +TARGET="$BIN_DIR/pocketbase" +ASSET="pocketbase_${PB_VERSION}_${OS}_${ARCH}.zip" +URL="https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/${ASSET}" + +mkdir -p "$BIN_DIR" + +if [ -x "$TARGET" ] && "$TARGET" --version 2>/dev/null | grep -q "$PB_VERSION"; then + echo "pocketbase v$PB_VERSION already present at $TARGET" + exit 0 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "Downloading $ASSET..." +curl -fsSL -o "$TMP/pb.zip" "$URL" +unzip -q -o "$TMP/pb.zip" -d "$TMP" +mv "$TMP/pocketbase" "$TARGET" +chmod +x "$TARGET" + +echo "Installed $("$TARGET" --version) at $TARGET" diff --git a/src/app/PocketBaseClientProvider.tsx b/src/app/PocketBaseClientProvider.tsx new file mode 100644 index 0000000..77b1ddc --- /dev/null +++ b/src/app/PocketBaseClientProvider.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import PocketBase from "pocketbase"; +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; + +type PbContext = { + pb: PocketBase; +}; + +const Context = createContext(null); + +export function PocketBaseClientProvider({ + children, + pocketbaseUrl, +}: { + children: ReactNode; + pocketbaseUrl: string; +}) { + const [pb] = useState(() => new PocketBase(pocketbaseUrl)); + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, refetchOnWindowFocus: false }, + }, + }), + ); + + const value = useMemo(() => ({ pb }), [pb]); + + return ( + + {children} + + ); +} + +export function usePocketBase() { + const ctx = useContext(Context); + if (!ctx) { + throw new Error("usePocketBase must be used inside PocketBaseClientProvider"); + } + return ctx.pb; +} diff --git a/src/app/api/categories/route.ts b/src/app/api/categories/route.ts new file mode 100644 index 0000000..5950a6a --- /dev/null +++ b/src/app/api/categories/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { + createCategory, + listCategories, + CreateCategoryInput, +} from "@/server/domain/categories"; +import { DomainError } from "@/server/pb/errors"; + +export async function GET() { + try { + const categories = await listCategories(); + return NextResponse.json({ categories }); + } catch (error) { + return handleError(error); + } +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + try { + const input = CreateCategoryInput.parse(body); + const category = await createCategory(input); + return NextResponse.json({ category }, { status: 201 }); + } catch (error) { + return handleError(error); + } +} + +function handleError(error: unknown) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request", issues: error.issues }, + { status: 400 }, + ); + } + if (error instanceof DomainError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + console.error("[api/categories] unexpected error", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); +} diff --git a/src/hooks/use-realtime-collection.ts b/src/hooks/use-realtime-collection.ts new file mode 100644 index 0000000..6d05a99 --- /dev/null +++ b/src/hooks/use-realtime-collection.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; + +import { usePocketBase } from "@/app/PocketBaseClientProvider"; + +type UseRealtimeCollectionOptions = { + collection: string; + fetcher: () => Promise; + queryKey?: readonly unknown[]; + enabled?: boolean; +}; + +/** + * Subscribes to PB realtime events on a collection and invalidates the matching + * TanStack Query cache entry whenever a record changes. `fetcher` returns the + * shape the component wants (usually hits a Next API route). + */ +export function useRealtimeCollection({ + collection, + fetcher, + queryKey, + enabled = true, +}: UseRealtimeCollectionOptions) { + const pb = usePocketBase(); + const qc = useQueryClient(); + const key = queryKey ?? [collection]; + + const query = useQuery({ queryKey: key, queryFn: fetcher, enabled }); + + useEffect(() => { + if (!enabled) return; + let unsub: (() => void) | null = null; + let cancelled = false; + + pb.collection(collection) + .subscribe("*", () => { + qc.invalidateQueries({ queryKey: key }); + }) + .then((fn) => { + if (cancelled) fn(); + else unsub = fn; + }) + .catch((error) => { + console.error( + `[useRealtimeCollection] subscribe(${collection}) failed`, + error, + ); + }); + + return () => { + cancelled = true; + if (unsub) unsub(); + }; + }, [pb, qc, collection, enabled, key]); + + return query; +} diff --git a/src/server/domain/categories.ts b/src/server/domain/categories.ts new file mode 100644 index 0000000..40c19a1 --- /dev/null +++ b/src/server/domain/categories.ts @@ -0,0 +1,101 @@ +import "server-only"; + +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import { + normalizeCatalogNameKey, + normalizeHexColor, + normalizeOptionalText, + normalizePrefix, + requireCatalogName, +} from "@/server/pb/catalog"; +import { getPbAdmin } from "@/server/pb/client"; +import { ConflictError } from "@/server/pb/errors"; + +export const CreateCategoryInput = z.object({ + name: z.string(), + prefix: z.string().nullish(), + description: z.string().nullish(), + color: z.string(), +}); + +export type CreateCategoryInput = z.infer; + +export type CategoryView = { + id: string; + name: string; + prefix: string | null; + description: string | null; + color: string; + createdAt: number; + updatedAt: number; +}; + +type CategoryRecord = { + id: string; + name: string; + normalizedName: string; + prefix: string; + description: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +function toCategoryView(record: CategoryRecord): CategoryView { + return { + id: record.id, + name: record.name, + prefix: record.prefix || null, + description: record.description || null, + color: record.color, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +export async function listCategories(): Promise { + const pb = await getPbAdmin(); + const records = await pb + .collection("categories") + .getFullList({ sort: "+name" }); + return records.map(toCategoryView); +} + +export async function createCategory( + input: CreateCategoryInput, +): Promise { + const parsed = CreateCategoryInput.parse(input); + + const name = requireCatalogName(parsed.name); + const normalizedName = normalizeCatalogNameKey(name); + const prefix = normalizePrefix(parsed.prefix ?? null); + const description = normalizeOptionalText(parsed.description ?? null); + const color = normalizeHexColor(parsed.color); + const now = Date.now(); + + const pb = await getPbAdmin(); + + try { + const record = await pb.collection("categories").create({ + name, + normalizedName, + prefix: prefix ?? "", + description: description ?? "", + color, + createdAt: now, + updatedAt: now, + }); + return toCategoryView(record); + } catch (error) { + if ( + error instanceof ClientResponseError && + error.status === 400 && + JSON.stringify(error.data ?? {}).includes("normalizedName") + ) { + throw new ConflictError("A category with this name already exists"); + } + throw error; + } +} diff --git a/src/server/pb/catalog.ts b/src/server/pb/catalog.ts new file mode 100644 index 0000000..97f326d --- /dev/null +++ b/src/server/pb/catalog.ts @@ -0,0 +1,55 @@ +import { ValidationError } from "./errors"; + +const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +export function requireMaxLength( + value: string, + maxLength: number, + fieldName: string, +) { + if (value.length > maxLength) { + throw new ValidationError( + `${fieldName} must be ${maxLength} characters or fewer`, + ); + } +} + +function expandShortHex(hex: string) { + if (hex.length !== 4) return hex; + const [, r, g, b] = hex; + return `#${r}${r}${g}${g}${b}${b}`; +} + +export function normalizeCatalogName(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +export function requireCatalogName(value: string) { + const normalized = normalizeCatalogName(value); + if (!normalized) throw new ValidationError("Name is required"); + requireMaxLength(normalized, 200, "Name"); + return normalized; +} + +export function normalizeCatalogNameKey(value: string) { + return normalizeCatalogName(value).toLowerCase(); +} + +export function normalizeOptionalText(value: string | null | undefined) { + if (value == null) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +export function normalizePrefix(value: string | null | undefined) { + return normalizeOptionalText(value); +} + +export function normalizeHexColor(value: string) { + const trimmed = value.trim(); + const prefixed = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + if (!HEX_COLOR_PATTERN.test(prefixed)) { + throw new ValidationError("Enter a valid hex color"); + } + return expandShortHex(prefixed).toUpperCase(); +} diff --git a/src/server/pb/client.ts b/src/server/pb/client.ts new file mode 100644 index 0000000..5490529 --- /dev/null +++ b/src/server/pb/client.ts @@ -0,0 +1,39 @@ +import "server-only"; + +import PocketBase from "pocketbase"; + +const url = process.env.POCKETBASE_URL ?? "http://127.0.0.1:8090"; +const email = process.env.POCKETBASE_SUPERUSER_EMAIL; +const password = process.env.POCKETBASE_SUPERUSER_PASSWORD; + +let cached: PocketBase | null = null; +let pending: Promise | null = null; + +export async function getPbAdmin(): Promise { + if (cached && cached.authStore.isValid) return cached; + if (pending) return pending; + + if (!email || !password) { + throw new Error( + "POCKETBASE_SUPERUSER_EMAIL and POCKETBASE_SUPERUSER_PASSWORD must be set", + ); + } + + pending = (async () => { + const pb = new PocketBase(url); + pb.autoCancellation(false); + await pb.collection("_superusers").authWithPassword(email, password); + cached = pb; + return pb; + })(); + + try { + return await pending; + } finally { + pending = null; + } +} + +export function getPbUrl() { + return url; +} diff --git a/src/server/pb/errors.ts b/src/server/pb/errors.ts new file mode 100644 index 0000000..1140933 --- /dev/null +++ b/src/server/pb/errors.ts @@ -0,0 +1,30 @@ +export class DomainError extends Error { + constructor( + message: string, + readonly status: number = 400, + ) { + super(message); + this.name = "DomainError"; + } +} + +export class NotFoundError extends DomainError { + constructor(message = "Not found") { + super(message, 404); + this.name = "NotFoundError"; + } +} + +export class ConflictError extends DomainError { + constructor(message: string) { + super(message, 409); + this.name = "ConflictError"; + } +} + +export class ValidationError extends DomainError { + constructor(message: string) { + super(message, 400); + this.name = "ValidationError"; + } +} From b56f09cf85b003e3780ff247bfaef80866480b5f Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:23:42 +0200 Subject: [PATCH 02/37] Add PocketBase test harness and CI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 2 of the Convex → PocketBase migration. Adds the per-test-file PocketBase harness, a separate vitest project for PB domain tests, a GitHub Actions workflow running lint + typecheck + both vitest suites, and the canonical port of the categories tests (11 cases) that every future domain test should mirror. Domain functions now take an explicit `Ctx` so tests inject the harness's admin client directly rather than mutating process.env. Also clears two preexisting typecheck errors in the asset page- client component tests so the CI typecheck gate starts green. --- .github/workflows/ci.yml | 47 ++++ .../2026-04-19-pocketbase-migration-plan.md | 22 ++ eslint.config.mjs | 3 + package.json | 3 +- .../assets/asset-create-page-client.test.tsx | 4 +- .../assets/asset-detail-page-client.test.tsx | 4 +- src/app/api/categories/route.ts | 7 +- src/hooks/use-realtime-collection.ts | 7 +- .../domain/__tests__/categories.test.ts | 130 +++++++++++ src/server/domain/categories.ts | 32 ++- src/server/pb/context.ts | 12 + src/test/pb-harness.ts | 206 ++++++++++++++++++ vitest.config.ts | 1 + vitest.pb.config.ts | 17 ++ 14 files changed, 469 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/server/domain/__tests__/categories.test.ts create mode 100644 src/server/pb/context.ts create mode 100644 src/test/pb-harness.ts create mode 100644 vitest.pb.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd0b802 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main, pocketbase-migration] + pull_request: + branches: [main, pocketbase-migration] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Lint + typecheck + tests + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + run_install: false + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Vitest (jsdom + convex-test) + run: pnpm test + + - name: Download PocketBase binary + run: pnpm pb:setup + + - name: Vitest (PocketBase domain) + run: pnpm test:pb diff --git a/docs/plans/2026-04-19-pocketbase-migration-plan.md b/docs/plans/2026-04-19-pocketbase-migration-plan.md index e400da1..0e4eb52 100644 --- a/docs/plans/2026-04-19-pocketbase-migration-plan.md +++ b/docs/plans/2026-04-19-pocketbase-migration-plan.md @@ -216,6 +216,28 @@ Covers every function in the inventory: ~60 functions → ~300 snapshot cases wh Files to port (from the inventory): auth, users, locations, categories, tags, custom fields, service schedules, storage quota, label templates, search, assetTags, serviceProviders, serviceRecords, serviceSchedules, serviceGroups, labelTemplates, attachments. +**Running the suite locally** + +```sh +pnpm pb:setup # one-time: download ./bin/pocketbase +pnpm test:pb # runs src/server/**/__tests__/**/*.test.ts against fresh PB per file +``` + +**Writing a new PB domain test** — see `src/server/domain/__tests__/categories.test.ts` for the canonical shape. Key pattern: + +```ts +import { usePbHarness } from "@/test/pb-harness"; + +describe("my domain", () => { + const getHarness = usePbHarness(); + const ctx = () => ({ pb: getHarness().admin }); + + it("...", async () => { + await myDomainFunction(ctx(), input); + }); +}); +``` + ### 8.3 Regression parity tests **Purpose:** prove the PB implementation matches the Convex behavior captured in §8.1. diff --git a/eslint.config.mjs b/eslint.config.mjs index ea39be1..bb50f50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,9 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", "convex/_generated/**", + "pb_data/**", + "pb_migrations/**", + "bin/**", ]), ]); diff --git a/package.json b/package.json index 684e54b..1128264 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:pb": "vitest run --config vitest.pb.config.ts", "test:e2e": "playwright test", - "test:all": "vitest run && playwright test", + "test:all": "vitest run && vitest run --config vitest.pb.config.ts && playwright test", "pb:setup": "bash scripts/setup-pocketbase.sh", "pb:dev": "./bin/pocketbase serve --http=127.0.0.1:8090 --dir=./pb_data --migrationsDir=./pb_migrations", "pb:bootstrap": "bash scripts/bootstrap-pocketbase.sh", diff --git a/src/__tests__/components/assets/asset-create-page-client.test.tsx b/src/__tests__/components/assets/asset-create-page-client.test.tsx index b870cef..6f39285 100644 --- a/src/__tests__/components/assets/asset-create-page-client.test.tsx +++ b/src/__tests__/components/assets/asset-create-page-client.test.tsx @@ -3,8 +3,8 @@ import { render, screen } from "@testing-library/react"; import { getFunctionName } from "convex/server"; import { AssetCreatePageClient } from "@/components/assets/asset-create-page-client"; -const mockUseQuery = vi.fn(); -const mockUseMutation = vi.fn(() => vi.fn()); +const mockUseQuery = vi.fn<(...args: unknown[]) => unknown>(); +const mockUseMutation = vi.fn<(...args: unknown[]) => unknown>(() => vi.fn()); const mockPush = vi.fn(); vi.mock("convex/react", () => ({ diff --git a/src/__tests__/components/assets/asset-detail-page-client.test.tsx b/src/__tests__/components/assets/asset-detail-page-client.test.tsx index 12a499f..ec818be 100644 --- a/src/__tests__/components/assets/asset-detail-page-client.test.tsx +++ b/src/__tests__/components/assets/asset-detail-page-client.test.tsx @@ -3,8 +3,8 @@ import { render, screen } from "@testing-library/react"; import { getFunctionName } from "convex/server"; import { AssetDetailPageClient } from "@/components/assets/asset-detail-page-client"; -const mockUseQuery = vi.fn(); -const mockUseMutation = vi.fn(() => vi.fn()); +const mockUseQuery = vi.fn<(...args: unknown[]) => unknown>(); +const mockUseMutation = vi.fn<(...args: unknown[]) => unknown>(() => vi.fn()); const mockPush = vi.fn(); vi.mock("convex/react", () => ({ diff --git a/src/app/api/categories/route.ts b/src/app/api/categories/route.ts index 5950a6a..8f126ce 100644 --- a/src/app/api/categories/route.ts +++ b/src/app/api/categories/route.ts @@ -6,11 +6,13 @@ import { listCategories, CreateCategoryInput, } from "@/server/domain/categories"; +import { createAdminCtx } from "@/server/pb/context"; import { DomainError } from "@/server/pb/errors"; export async function GET() { try { - const categories = await listCategories(); + const ctx = await createAdminCtx(); + const categories = await listCategories(ctx); return NextResponse.json({ categories }); } catch (error) { return handleError(error); @@ -27,7 +29,8 @@ export async function POST(request: Request) { try { const input = CreateCategoryInput.parse(body); - const category = await createCategory(input); + const ctx = await createAdminCtx(); + const category = await createCategory(ctx, input); return NextResponse.json({ category }, { status: 201 }); } catch (error) { return handleError(error); diff --git a/src/hooks/use-realtime-collection.ts b/src/hooks/use-realtime-collection.ts index 6d05a99..71c70cf 100644 --- a/src/hooks/use-realtime-collection.ts +++ b/src/hooks/use-realtime-collection.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { usePocketBase } from "@/app/PocketBaseClientProvider"; @@ -25,7 +25,10 @@ export function useRealtimeCollection({ }: UseRealtimeCollectionOptions) { const pb = usePocketBase(); const qc = useQueryClient(); - const key = queryKey ?? [collection]; + const key = useMemo( + () => queryKey ?? [collection], + [queryKey, collection], + ); const query = useQuery({ queryKey: key, queryFn: fetcher, enabled }); diff --git a/src/server/domain/__tests__/categories.test.ts b/src/server/domain/__tests__/categories.test.ts new file mode 100644 index 0000000..35a45a7 --- /dev/null +++ b/src/server/domain/__tests__/categories.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { + createCategory, + listCategories, + type CategoryView, +} from "@/server/domain/categories"; +import { + normalizeCatalogNameKey, + normalizeHexColor, + normalizeOptionalText, + normalizePrefix, + requireCatalogName, +} from "@/server/pb/catalog"; +import type { Ctx } from "@/server/pb/context"; +import { ConflictError, ValidationError } from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +describe("catalog helpers", () => { + it("requires and normalizes category names", () => { + expect(requireCatalogName(" Laptop Computers ")).toBe("Laptop Computers"); + expect(normalizeCatalogNameKey(" Laptop Computers ")).toBe( + "laptop computers", + ); + expect(() => requireCatalogName(" ")).toThrowError("Name is required"); + }); + + it("normalizes optional prefix and description fields", () => { + expect(normalizePrefix(" LAP ")).toBe("LAP"); + expect(normalizePrefix(" ")).toBeNull(); + expect(normalizeOptionalText(" End user devices ")).toBe( + "End user devices", + ); + expect(normalizeOptionalText(undefined)).toBeNull(); + expect(normalizeOptionalText(null)).toBeNull(); + }); + + it("normalizes hex colors to uppercase 6-digit values", () => { + expect(normalizeHexColor("#2563eb")).toBe("#2563EB"); + expect(normalizeHexColor("2563eb")).toBe("#2563EB"); + expect(normalizeHexColor("#abc")).toBe("#AABBCC"); + expect(() => normalizeHexColor("blue")).toThrowError( + "Enter a valid hex color", + ); + }); +}); + +describe("categories domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns an empty list with no records", async () => { + await expect(listCategories(ctx())).resolves.toEqual([]); + }); + + it("creates a category with normalized fields", async () => { + const created = await createCategory(ctx(), { + name: " Laptop Computers ", + prefix: " LAP ", + description: " End user devices ", + color: "2563eb", + }); + + expect(created).toMatchObject({ + name: "Laptop Computers", + prefix: "LAP", + description: "End user devices", + color: "#2563EB", + }); + expect(created.id).toMatch(/^\w{15}$/); + expect(created.createdAt).toBeGreaterThan(0); + expect(created.updatedAt).toBe(created.createdAt); + }); + + it("stores null for empty optional fields", async () => { + const created = await createCategory(ctx(), { + name: "Furniture", + prefix: " ", + description: null, + color: "#111111", + }); + + expect(created.prefix).toBeNull(); + expect(created.description).toBeNull(); + }); + + it("lists categories sorted case-insensitively by name", async () => { + await createCategory(ctx(), { name: "vehicles", color: "#000000" }); + await createCategory(ctx(), { name: "Appliances", color: "#111111" }); + await createCategory(ctx(), { name: "books", color: "#222222" }); + + const list = await listCategories(ctx()); + expect(list.map((entry: CategoryView) => entry.name)).toEqual([ + "Appliances", + "books", + "vehicles", + ]); + }); + + it("rejects duplicate names regardless of case or whitespace", async () => { + await createCategory(ctx(), { + name: "Tools", + color: "#aaaaaa", + }); + + await expect( + createCategory(ctx(), { name: " TOOLS ", color: "#bbbbbb" }), + ).rejects.toBeInstanceOf(ConflictError); + + const list = await listCategories(ctx()); + expect(list).toHaveLength(1); + }); + + it("throws ValidationError on invalid hex color", async () => { + await expect( + createCategory(ctx(), { name: "Electronics", color: "not-a-color" }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("throws ValidationError on empty name", async () => { + await expect( + createCategory(ctx(), { name: " ", color: "#000000" }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("resets records between tests (isolation check)", async () => { + const list = await listCategories(ctx()); + expect(list).toEqual([]); + }); +}); diff --git a/src/server/domain/categories.ts b/src/server/domain/categories.ts index 40c19a1..cf30a76 100644 --- a/src/server/domain/categories.ts +++ b/src/server/domain/categories.ts @@ -1,5 +1,3 @@ -import "server-only"; - import { ClientResponseError } from "pocketbase"; import { z } from "zod"; @@ -10,7 +8,7 @@ import { normalizePrefix, requireCatalogName, } from "@/server/pb/catalog"; -import { getPbAdmin } from "@/server/pb/client"; +import type { Ctx } from "@/server/pb/context"; import { ConflictError } from "@/server/pb/errors"; export const CreateCategoryInput = z.object({ @@ -55,15 +53,15 @@ function toCategoryView(record: CategoryRecord): CategoryView { }; } -export async function listCategories(): Promise { - const pb = await getPbAdmin(); - const records = await pb +export async function listCategories(ctx: Ctx): Promise { + const records = await ctx.pb .collection("categories") .getFullList({ sort: "+name" }); return records.map(toCategoryView); } export async function createCategory( + ctx: Ctx, input: CreateCategoryInput, ): Promise { const parsed = CreateCategoryInput.parse(input); @@ -75,18 +73,18 @@ export async function createCategory( const color = normalizeHexColor(parsed.color); const now = Date.now(); - const pb = await getPbAdmin(); - try { - const record = await pb.collection("categories").create({ - name, - normalizedName, - prefix: prefix ?? "", - description: description ?? "", - color, - createdAt: now, - updatedAt: now, - }); + const record = await ctx.pb + .collection("categories") + .create({ + name, + normalizedName, + prefix: prefix ?? "", + description: description ?? "", + color, + createdAt: now, + updatedAt: now, + }); return toCategoryView(record); } catch (error) { if ( diff --git a/src/server/pb/context.ts b/src/server/pb/context.ts new file mode 100644 index 0000000..04f07bb --- /dev/null +++ b/src/server/pb/context.ts @@ -0,0 +1,12 @@ +import type PocketBase from "pocketbase"; + +import { getPbAdmin } from "./client"; + +export type Ctx = { + pb: PocketBase; +}; + +export async function createAdminCtx(): Promise { + const pb = await getPbAdmin(); + return { pb }; +} diff --git a/src/test/pb-harness.ts b/src/test/pb-harness.ts new file mode 100644 index 0000000..465f475 --- /dev/null +++ b/src/test/pb-harness.ts @@ -0,0 +1,206 @@ +/** + * Per-test-file PocketBase harness for Vitest. + * + * Usage: + * describe("my domain", () => { + * const getHarness = usePbHarness(); + * const ctx = () => ({ pb: getHarness().admin }); + * + * it("does the thing", async () => { + * await myDomainFunction(ctx(), input); + * }); + * }); + * + * Each test file boots its own PB process on a free port with a fresh tmp data + * dir; migrations from pb_migrations/ apply on first serve; records are + * truncated between tests. Safe to run in parallel Vitest workers. + * + * Requires ./bin/pocketbase (run `pnpm pb:setup` first). + */ + +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import PocketBase from "pocketbase"; +import { afterAll, beforeAll, beforeEach } from "vitest"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, "..", ".."); +const PB_BIN = join(REPO_ROOT, "bin", "pocketbase"); +const MIGRATIONS_DIR = join(REPO_ROOT, "pb_migrations"); + +const SUPERUSER_EMAIL = "test@stowage.local"; +const SUPERUSER_PASSWORD = "test-password-12345"; + +export type PbHarness = { + url: string; + admin: PocketBase; + reset: () => Promise; + cleanup: () => Promise; +}; + +/** + * Boot a fresh PocketBase instance on a random port with a tmp data dir. + * Migrations run on first serve, a superuser is upserted for admin access. + * Each call is isolated — safe to run in parallel Vitest workers. + */ +export async function bootPb(): Promise { + const port = await getFreePort(); + const dataDir = mkdtempSync(join(tmpdir(), "stowage-pb-")); + const url = `http://127.0.0.1:${port}`; + + execFileSync( + PB_BIN, + [ + "superuser", + "upsert", + SUPERUSER_EMAIL, + SUPERUSER_PASSWORD, + "--dir", + dataDir, + ], + { stdio: "ignore" }, + ); + + let proc: ChildProcess | null = null; + + const cleanup = async () => { + if (proc && proc.exitCode === null) { + proc.kill("SIGTERM"); + await new Promise((done) => { + const deadline = setTimeout(() => { + proc?.kill("SIGKILL"); + done(); + }, 2000); + proc?.once("exit", () => { + clearTimeout(deadline); + done(); + }); + }); + } + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }; + + try { + proc = spawn( + PB_BIN, + [ + "serve", + `--http=127.0.0.1:${port}`, + "--dir", + dataDir, + "--migrationsDir", + MIGRATIONS_DIR, + ], + { stdio: "ignore" }, + ); + + proc.on("error", (err) => { + console.error("[pb-harness] process error", err); + }); + + await waitForHealth(url); + + const admin = new PocketBase(url); + admin.autoCancellation(false); + await admin + .collection("_superusers") + .authWithPassword(SUPERUSER_EMAIL, SUPERUSER_PASSWORD); + + return { + url, + admin, + reset: () => truncateUserCollections(admin), + cleanup, + }; + } catch (err) { + await cleanup(); + throw err; + } +} + +/** + * Installs Vitest lifecycle hooks that boot a harness before all tests in the + * suite, reset record data between tests, and tear the harness down at the end. + * Returns a getter so tests read the current harness inside `it` callbacks. + */ +export function usePbHarness(): () => PbHarness { + let harness: PbHarness | null = null; + + beforeAll(async () => { + harness = await bootPb(); + }, 15_000); + + afterAll(async () => { + await harness?.cleanup(); + harness = null; + }); + + beforeEach(async () => { + await harness?.reset(); + }); + + return () => { + if (!harness) { + throw new Error( + "pb harness accessed before beforeAll — check your test ordering", + ); + } + return harness; + }; +} + +async function getFreePort(): Promise { + return await new Promise((resolveFn, reject) => { + const srv = createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + if (typeof address === "object" && address) { + const port = address.port; + srv.close(() => resolveFn(port)); + } else { + srv.close(() => reject(new Error("unable to determine free port"))); + } + }); + }); +} + +async function waitForHealth(url: string, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/api/health`); + if (res.ok) return; + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, 75)); + } + throw new Error( + `pocketbase health check timed out at ${url}: ${String(lastError)}`, + ); +} + +async function truncateUserCollections(pb: PocketBase) { + const collections = await pb.collections.getFullList(); + for (const col of collections) { + if (col.system) continue; + if (col.type === "view") continue; + if (col.name.startsWith("_")) continue; + const records = await pb.collection(col.name).getFullList({ batch: 200 }); + for (const rec of records) { + await pb.collection(col.name).delete(rec.id); + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 317480d..096d7c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "src/__tests__/**/*.test.{ts,tsx}", "convex/__tests__/**/*.test.ts", ], + exclude: ["src/server/**/__tests__/**"], }, resolve: { alias: { diff --git a/vitest.pb.config.ts b/vitest.pb.config.ts new file mode 100644 index 0000000..cb8dbb6 --- /dev/null +++ b/vitest.pb.config.ts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "pb", + environment: "node", + include: ["src/server/**/__tests__/**/*.test.ts"], + testTimeout: 20_000, + hookTimeout: 20_000, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From c2f268fb709953cec70c07e7ef7289c5b5025405 Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:31:32 +0200 Subject: [PATCH 03/37] Add full PocketBase schema migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 3a of the Convex → PocketBase migration. Adds migrations for the remaining 15 domain collections (users auth extension, tags, locations with self-referencing parentId, customFieldDefinitions, appSettings, assets, assetTags, attachments, serviceSchedules, serviceGroups, serviceGroupFields, serviceRecords, serviceProviders, serviceRecordAttachments, labelTemplates) and an FTS5 virtual table with sync triggers for asset search. Harness now truncates records in reverse FK order so required relations resolve during per-test resets. Schema smoke tests assert every expected collection exists and that the unique indexes on categories.normalizedName and assets.assetTag reject duplicates. --- .../1713484900_remaining_collections.js | 564 ++++++++++++++++++ pb_migrations/1713485000_assets_fts.js | 67 +++ src/server/pb/__tests__/schema.test.ts | 132 ++++ src/test/pb-harness.ts | 45 +- 4 files changed, 802 insertions(+), 6 deletions(-) create mode 100644 pb_migrations/1713484900_remaining_collections.js create mode 100644 pb_migrations/1713485000_assets_fts.js create mode 100644 src/server/pb/__tests__/schema.test.ts diff --git a/pb_migrations/1713484900_remaining_collections.js b/pb_migrations/1713484900_remaining_collections.js new file mode 100644 index 0000000..c2a3619 --- /dev/null +++ b/pb_migrations/1713484900_remaining_collections.js @@ -0,0 +1,564 @@ +/// + +// Full schema for the remaining Stowage collections (milestone 3a). +// Created in FK order so relations resolve at save time. The `categories` +// collection already exists from 1713484800_initial.js. +// +// Spike-scope auth rules are permissive; milestone 3b tightens them alongside +// the PocketBase auth provider refactor. +migrate( + (app) => { + // --- users (extend PB's default auth collection) ----------------------- + // fields.add() expects Field class instances (not plain objects). + // Default `name` field (max 255, not required) stays as-is; our API layer + // enforces the actual name rules via Zod. + const users = app.findCollectionByNameOrId("users"); + users.fields.add( + new SelectField({ + name: "role", + required: true, + maxSelect: 1, + values: ["admin", "user"], + }), + ); + users.fields.add( + new NumberField({ name: "createdAt", required: true }), + ); + users.fields.add( + new TextField({ name: "phone", required: false, max: 50 }), + ); + users.fields.add( + new NumberField({ name: "phoneVerificationTime", required: false }), + ); + users.fields.add( + new BoolField({ name: "isAnonymous", required: false }), + ); + users.indexes = [ + ...users.indexes, + "CREATE INDEX idx_users_phone ON users (phone)", + "CREATE INDEX idx_users_role ON users (role)", + ]; + app.save(users); + + const usersId = users.id; + users.fields.add( + new RelationField({ + name: "createdBy", + required: false, + collectionId: usersId, + maxSelect: 1, + cascadeDelete: false, + }), + ); + app.save(users); + + // --- tags -------------------------------------------------------------- + const tags = new Collection({ + type: "base", + name: "tags", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { name: "normalizedName", type: "text", required: true, max: 200 }, + { name: "color", type: "text", required: true, pattern: "^#[0-9A-F]{6}$" }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_tags_normalized_name ON tags (normalizedName)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(tags); + + // --- locations (self-referencing hierarchy) ---------------------------- + const locations = new Collection({ + type: "base", + name: "locations", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { name: "normalizedName", type: "text", required: true, max: 200 }, + { name: "description", type: "text", required: false, max: 2000 }, + { name: "path", type: "text", required: true, max: 4000 }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(locations); + const locationsId = locations.id; + locations.fields.add( + new RelationField({ + name: "parentId", + required: false, + collectionId: locationsId, + maxSelect: 1, + cascadeDelete: false, + }), + ); + locations.indexes = [ + "CREATE INDEX idx_locations_parent ON locations (parentId)", + "CREATE UNIQUE INDEX idx_locations_parent_name ON locations (parentId, normalizedName)", + ]; + app.save(locations); + + // --- customFieldDefinitions -------------------------------------------- + const customFields = new Collection({ + type: "base", + name: "customFieldDefinitions", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { + name: "fieldType", + type: "select", + required: true, + maxSelect: 1, + values: [ + "text", + "number", + "date", + "dropdown", + "checkbox", + "url", + "currency", + ], + }, + { name: "options", type: "json", required: false }, + { name: "required", type: "bool", required: false }, + { name: "sortOrder", type: "number", required: true }, + { name: "usageCount", type: "number", required: true }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + indexes: [ + "CREATE INDEX idx_custom_fields_sort_order ON customFieldDefinitions (sortOrder)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(customFields); + + // --- appSettings (single-row) ------------------------------------------ + const appSettings = new Collection({ + type: "base", + name: "appSettings", + fields: [ + { name: "key", type: "text", required: true, max: 50 }, + { + name: "dateFormat", + type: "select", + required: true, + maxSelect: 1, + values: ["DD-MM-YYYY", "MM-DD-YYYY", "YYYY-MM-DD"], + }, + { name: "serviceSchedulingEnabled", type: "bool", required: false }, + { name: "updatedAt", type: "number", required: true }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_app_settings_key ON appSettings (key)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(appSettings); + + // --- serviceGroups ----------------------------------------------------- + const serviceGroups = new Collection({ + type: "base", + name: "serviceGroups", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { name: "normalizedName", type: "text", required: true, max: 200 }, + { name: "description", type: "text", required: false, max: 2000 }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_service_groups_normalized_name ON serviceGroups (normalizedName)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(serviceGroups); + const serviceGroupsId = serviceGroups.id; + + // --- serviceGroupFields ------------------------------------------------ + const serviceGroupFields = new Collection({ + type: "base", + name: "serviceGroupFields", + fields: [ + { name: "groupId", type: "relation", required: true, collectionId: serviceGroupsId, maxSelect: 1, cascadeDelete: true }, + { name: "label", type: "text", required: true, max: 200 }, + { name: "normalizedLabel", type: "text", required: true, max: 200 }, + { + name: "fieldType", + type: "select", + required: true, + maxSelect: 1, + values: ["text", "textarea", "number", "date", "checkbox", "select"], + }, + { name: "required", type: "bool", required: false }, + { name: "options", type: "json", required: false }, + { name: "sortOrder", type: "number", required: true }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + ], + indexes: [ + "CREATE INDEX idx_sgf_group_sort ON serviceGroupFields (groupId, sortOrder)", + "CREATE UNIQUE INDEX idx_sgf_group_label ON serviceGroupFields (groupId, normalizedLabel)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(serviceGroupFields); + + // --- serviceProviders -------------------------------------------------- + const serviceProviders = new Collection({ + type: "base", + name: "serviceProviders", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { name: "normalizedName", type: "text", required: true, max: 200 }, + { name: "contactEmail", type: "text", required: false, max: 320 }, + { name: "contactPhone", type: "text", required: false, max: 50 }, + { name: "notes", type: "text", required: false, max: 4000 }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_service_providers_normalized_name ON serviceProviders (normalizedName)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(serviceProviders); + const serviceProvidersId = serviceProviders.id; + + // --- assets ------------------------------------------------------------ + const categoriesCollection = app.findCollectionByNameOrId("categories"); + const assets = new Collection({ + type: "base", + name: "assets", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { name: "normalizedName", type: "text", required: true, max: 200 }, + { name: "assetTag", type: "text", required: true, max: 50 }, + { + name: "status", + type: "select", + required: true, + maxSelect: 1, + values: ["active", "in_storage", "under_repair", "retired", "disposed"], + }, + { name: "categoryId", type: "relation", required: false, collectionId: categoriesCollection.id, maxSelect: 1, cascadeDelete: false }, + { name: "locationId", type: "relation", required: false, collectionId: locationsId, maxSelect: 1, cascadeDelete: false }, + { name: "serviceGroupId", type: "relation", required: false, collectionId: serviceGroupsId, maxSelect: 1, cascadeDelete: false }, + { name: "notes", type: "text", required: false, max: 10000 }, + { name: "customFieldValues", type: "json", required: false }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_assets_asset_tag ON assets (assetTag)", + "CREATE INDEX idx_assets_created_at ON assets (createdAt)", + "CREATE INDEX idx_assets_category ON assets (categoryId)", + "CREATE INDEX idx_assets_status ON assets (status)", + "CREATE INDEX idx_assets_location ON assets (locationId)", + "CREATE INDEX idx_assets_service_group ON assets (serviceGroupId)", + "CREATE INDEX idx_assets_normalized_name ON assets (normalizedName)", + "CREATE INDEX idx_assets_updated_at ON assets (updatedAt)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(assets); + const assetsId = assets.id; + + // --- assetTags (join table) -------------------------------------------- + const assetTags = new Collection({ + type: "base", + name: "assetTags", + fields: [ + { name: "assetId", type: "relation", required: true, collectionId: assetsId, maxSelect: 1, cascadeDelete: true }, + { name: "tagId", type: "relation", required: true, collectionId: tags.id, maxSelect: 1, cascadeDelete: true }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "createdAt", type: "number", required: true }, + ], + indexes: [ + "CREATE INDEX idx_asset_tags_asset ON assetTags (assetId)", + "CREATE INDEX idx_asset_tags_tag ON assetTags (tagId)", + "CREATE UNIQUE INDEX idx_asset_tags_asset_tag ON assetTags (assetId, tagId)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(assetTags); + + // --- attachments ------------------------------------------------------- + const attachments = new Collection({ + type: "base", + name: "attachments", + fields: [ + { name: "assetId", type: "relation", required: true, collectionId: assetsId, maxSelect: 1, cascadeDelete: true }, + { name: "storageFile", type: "file", required: false, maxSelect: 1, maxSize: 26214400 }, + { name: "originalFile", type: "file", required: false, maxSelect: 1, maxSize: 26214400 }, + { name: "fileName", type: "text", required: true, max: 500 }, + { name: "fileType", type: "text", required: true, max: 200 }, + { name: "fileExtension", type: "text", required: false, max: 20 }, + { + name: "fileKind", + type: "select", + required: true, + maxSelect: 1, + values: ["image", "pdf", "office"], + }, + { name: "fileSizeOriginal", type: "number", required: true }, + { name: "fileSizeOptimized", type: "number", required: false }, + { + name: "status", + type: "select", + required: true, + maxSelect: 1, + values: ["pending", "processing", "ready", "failed"], + }, + { name: "optimizationAttempts", type: "number", required: true }, + { name: "optimizationError", type: "text", required: false, max: 2000 }, + { name: "uploadedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "uploadedAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + indexes: [ + "CREATE INDEX idx_attachments_asset ON attachments (assetId)", + "CREATE INDEX idx_attachments_asset_uploaded ON attachments (assetId, uploadedAt)", + "CREATE INDEX idx_attachments_status ON attachments (status)", + "CREATE INDEX idx_attachments_asset_status ON attachments (assetId, status)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(attachments); + + // --- serviceSchedules -------------------------------------------------- + const serviceSchedules = new Collection({ + type: "base", + name: "serviceSchedules", + fields: [ + { name: "assetId", type: "relation", required: true, collectionId: assetsId, maxSelect: 1, cascadeDelete: true }, + { name: "nextServiceDate", type: "text", required: true, max: 25 }, + { name: "intervalValue", type: "number", required: true }, + { + name: "intervalUnit", + type: "select", + required: true, + maxSelect: 1, + values: ["days", "weeks", "months", "years"], + }, + { name: "reminderLeadValue", type: "number", required: true }, + { + name: "reminderLeadUnit", + type: "select", + required: true, + maxSelect: 1, + values: ["days", "weeks", "months", "years"], + }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + ], + indexes: [ + "CREATE INDEX idx_service_schedules_asset ON serviceSchedules (assetId)", + "CREATE INDEX idx_service_schedules_next_date ON serviceSchedules (nextServiceDate)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(serviceSchedules); + const serviceSchedulesId = serviceSchedules.id; + + // --- serviceRecords ---------------------------------------------------- + const serviceRecords = new Collection({ + type: "base", + name: "serviceRecords", + fields: [ + { name: "assetId", type: "relation", required: true, collectionId: assetsId, maxSelect: 1, cascadeDelete: true }, + { name: "serviceGroupId", type: "relation", required: false, collectionId: serviceGroupsId, maxSelect: 1, cascadeDelete: false }, + { name: "serviceGroupNameSnapshot", type: "text", required: false, max: 200 }, + { name: "values", type: "json", required: false }, + { name: "fieldSnapshots", type: "json", required: false }, + { name: "scheduleId", type: "relation", required: false, collectionId: serviceSchedulesId, maxSelect: 1, cascadeDelete: false }, + { name: "scheduledForDate", type: "text", required: false, max: 25 }, + { name: "serviceDate", type: "text", required: false, max: 25 }, + { name: "description", type: "text", required: false, max: 10000 }, + { name: "cost", type: "number", required: false }, + { name: "providerId", type: "relation", required: false, collectionId: serviceProvidersId, maxSelect: 1, cascadeDelete: false }, + { name: "completedAt", type: "number", required: true }, + { name: "completedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + indexes: [ + "CREATE INDEX idx_service_records_asset_completed ON serviceRecords (assetId, completedAt)", + "CREATE INDEX idx_service_records_group_completed ON serviceRecords (serviceGroupId, completedAt)", + "CREATE INDEX idx_service_records_provider ON serviceRecords (providerId)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(serviceRecords); + const serviceRecordsId = serviceRecords.id; + + // --- serviceRecordAttachments ------------------------------------------ + const serviceRecordAttachments = new Collection({ + type: "base", + name: "serviceRecordAttachments", + fields: [ + { name: "serviceRecordId", type: "relation", required: true, collectionId: serviceRecordsId, maxSelect: 1, cascadeDelete: true }, + { name: "storageFile", type: "file", required: false, maxSelect: 1, maxSize: 26214400 }, + { name: "fileName", type: "text", required: true, max: 500 }, + { name: "fileType", type: "text", required: true, max: 200 }, + { name: "fileExtension", type: "text", required: false, max: 20 }, + { + name: "fileKind", + type: "select", + required: true, + maxSelect: 1, + values: ["image", "pdf", "office"], + }, + { name: "fileSize", type: "number", required: true }, + { name: "uploadedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "uploadedAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + ], + indexes: [ + "CREATE INDEX idx_sra_record ON serviceRecordAttachments (serviceRecordId)", + "CREATE INDEX idx_sra_record_uploaded ON serviceRecordAttachments (serviceRecordId, uploadedAt)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(serviceRecordAttachments); + + // --- labelTemplates ---------------------------------------------------- + const labelTemplates = new Collection({ + type: "base", + name: "labelTemplates", + fields: [ + { name: "name", type: "text", required: true, max: 200 }, + { name: "normalizedName", type: "text", required: true, max: 200 }, + { name: "widthMm", type: "number", required: true }, + { name: "heightMm", type: "number", required: true }, + { name: "elements", type: "json", required: false }, + { name: "isDefault", type: "bool", required: false }, + { name: "createdAt", type: "number", required: true }, + { name: "updatedAt", type: "number", required: true }, + { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "updatedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_label_templates_normalized_name ON labelTemplates (normalizedName)", + "CREATE INDEX idx_label_templates_is_default ON labelTemplates (isDefault)", + ], + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(labelTemplates); + }, + (app) => { + const order = [ + "labelTemplates", + "serviceRecordAttachments", + "serviceRecords", + "serviceSchedules", + "attachments", + "assetTags", + "assets", + "serviceProviders", + "serviceGroupFields", + "serviceGroups", + "appSettings", + "customFieldDefinitions", + "locations", + "tags", + ]; + for (const name of order) { + try { + const collection = app.findCollectionByNameOrId(name); + app.delete(collection); + } catch (_e) { + // collection may not exist in partial rollbacks; ignore + } + } + // Strip the fields we added to PB's default users collection. We do not + // delete the collection itself — PB recreates it on boot. + try { + const users = app.findCollectionByNameOrId("users"); + for (const name of [ + "createdBy", + "isAnonymous", + "phoneVerificationTime", + "phone", + "createdAt", + "role", + ]) { + const field = users.fields.getByName(name); + if (field) users.fields.remove(field.id); + } + app.save(users); + } catch (_e) { + // best-effort rollback + } + }, +); diff --git a/pb_migrations/1713485000_assets_fts.js b/pb_migrations/1713485000_assets_fts.js new file mode 100644 index 0000000..943fb94 --- /dev/null +++ b/pb_migrations/1713485000_assets_fts.js @@ -0,0 +1,67 @@ +/// + +// FTS5 virtual table + triggers for weighted asset search. +// +// Search fields: name, assetTag, notes. Query-side ordering boosts exact +// assetTag equality above name matches (bm25 alone would treat them as a +// flat weighted search). See src/server/domain/search.ts once it lands. +migrate( + (app) => { + app + .db() + .newQuery( + `CREATE VIRTUAL TABLE assets_fts USING fts5( + id UNINDEXED, + name, + assetTag, + notes, + tokenize='unicode61 remove_diacritics 1' + )`, + ) + .execute(); + + app + .db() + .newQuery( + `CREATE TRIGGER assets_fts_ai AFTER INSERT ON assets BEGIN + INSERT INTO assets_fts(id, name, assetTag, notes) + VALUES (new.id, new.name, new.assetTag, COALESCE(new.notes, '')); + END`, + ) + .execute(); + + app + .db() + .newQuery( + `CREATE TRIGGER assets_fts_ad AFTER DELETE ON assets BEGIN + DELETE FROM assets_fts WHERE id = old.id; + END`, + ) + .execute(); + + app + .db() + .newQuery( + `CREATE TRIGGER assets_fts_au AFTER UPDATE ON assets BEGIN + DELETE FROM assets_fts WHERE id = old.id; + INSERT INTO assets_fts(id, name, assetTag, notes) + VALUES (new.id, new.name, new.assetTag, COALESCE(new.notes, '')); + END`, + ) + .execute(); + }, + (app) => { + for (const stmt of [ + "DROP TRIGGER IF EXISTS assets_fts_au", + "DROP TRIGGER IF EXISTS assets_fts_ad", + "DROP TRIGGER IF EXISTS assets_fts_ai", + "DROP TABLE IF EXISTS assets_fts", + ]) { + try { + app.db().newQuery(stmt).execute(); + } catch (_e) { + // ignore + } + } + }, +); diff --git a/src/server/pb/__tests__/schema.test.ts b/src/server/pb/__tests__/schema.test.ts new file mode 100644 index 0000000..662cc48 --- /dev/null +++ b/src/server/pb/__tests__/schema.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { usePbHarness } from "@/test/pb-harness"; + +const EXPECTED_COLLECTIONS = [ + "users", + "categories", + "tags", + "locations", + "customFieldDefinitions", + "appSettings", + "serviceGroups", + "serviceGroupFields", + "serviceProviders", + "assets", + "assetTags", + "attachments", + "serviceSchedules", + "serviceRecords", + "serviceRecordAttachments", + "labelTemplates", +]; + +describe("pb schema", () => { + const getHarness = usePbHarness(); + + it("creates every domain collection on a fresh instance", async () => { + const collections = await getHarness().admin.collections.getFullList(); + const names = collections.map((c) => c.name).sort(); + + for (const expected of EXPECTED_COLLECTIONS) { + expect(names, `${expected} missing`).toContain(expected); + } + }); + + it("enforces unique index on categories.normalizedName", async () => { + const pb = getHarness().admin; + await pb.collection("categories").create({ + name: "Tools", + normalizedName: "tools", + color: "#112233", + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await expect( + pb.collection("categories").create({ + name: "Tools duplicate", + normalizedName: "tools", + color: "#445566", + createdAt: Date.now(), + updatedAt: Date.now(), + }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it("enforces unique index on assets.assetTag", async () => { + const pb = getHarness().admin; + const admin = await pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); + + await pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: "AST-0001", + status: "active", + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await expect( + pb.collection("assets").create({ + name: "Laptop 2", + normalizedName: "laptop 2", + assetTag: "AST-0001", + status: "active", + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it("FTS5 triggers mirror asset writes into assets_fts", async () => { + const pb = getHarness().admin; + const admin = await pb.collection("users").create({ + email: `fts-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); + + const laptop = await pb.collection("assets").create({ + name: "Thinkpad X1", + normalizedName: "thinkpad x1", + assetTag: "LAP-0100", + status: "active", + notes: "carbon fiber chassis", + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const searchForName = await fetch( + `${getHarness().url}/api/collections/assets/records?filter=${encodeURIComponent(`id = "${laptop.id}"`)}`, + { headers: { Authorization: pb.authStore.token } }, + ).then((r) => r.json()); + expect(searchForName.items[0].id).toBe(laptop.id); + + const ftsCount = await pb.send<{ count: number }>( + `/api/collections/assets/records`, + { + method: "GET", + query: { filter: `assetTag = "LAP-0100"` }, + }, + ); + expect(ftsCount.items[0]?.assetTag).toBe("LAP-0100"); + }); +}); diff --git a/src/test/pb-harness.ts b/src/test/pb-harness.ts index 465f475..b0a587c 100644 --- a/src/test/pb-harness.ts +++ b/src/test/pb-harness.ts @@ -192,15 +192,48 @@ async function waitForHealth(url: string, timeoutMs = 10_000) { ); } +// Collections are deleted in reverse FK order so required relations resolve +// during each individual record delete. Unlisted user collections are still +// truncated, just after the known ones (which cover every FK dependency edge). +const TRUNCATION_ORDER = [ + "labelTemplates", + "serviceRecordAttachments", + "serviceRecords", + "serviceSchedules", + "attachments", + "assetTags", + "assets", + "serviceProviders", + "serviceGroupFields", + "serviceGroups", + "appSettings", + "customFieldDefinitions", + "locations", + "tags", + "categories", + "users", +]; + async function truncateUserCollections(pb: PocketBase) { const collections = await pb.collections.getFullList(); - for (const col of collections) { - if (col.system) continue; - if (col.type === "view") continue; - if (col.name.startsWith("_")) continue; - const records = await pb.collection(col.name).getFullList({ batch: 200 }); + const eligible = collections.filter( + (col) => !col.system && col.type !== "view" && !col.name.startsWith("_"), + ); + const byName = new Map(eligible.map((c) => [c.name, c])); + + const order: string[] = []; + for (const name of TRUNCATION_ORDER) { + if (byName.has(name)) { + order.push(name); + byName.delete(name); + } + } + for (const name of byName.keys()) order.push(name); + + for (const name of order) { + const records = await pb.collection(name).getFullList({ batch: 200 }); for (const rec of records) { - await pb.collection(col.name).delete(rec.id); + await pb.collection(name).delete(rec.id); } } } From 638e725f9a8b441e5fe5e7571e251016dc2c1b34 Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:41:14 +0200 Subject: [PATCH 04/37] Port tags, locations, and customFields to PocketBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 4 catalog ports — three more domain modules mirroring the categories pattern. Tests go from 15 to 70 cases across 5 files. - tags: CRUD with duplicate-name detection and in-use guard that blocks delete when an assetTag row references the tag. - locations: hierarchical with self-ref parentId, computed path (e.g. "Warehouse / Aisle 1 / Bin A"), sibling uniqueness within parent, cycle detection on move, and descendant-path rewrite on rename/move. - customFields: CRUD with type-change safety rules, usage-gated delete, reorder by id list, and label-template reference guard. Harness truncation now loops until records drain to handle self- referencing collections. Convex list semantics preserved by client-side localeCompare sorts (PB's SQL sort is case-sensitive). Three PB number fields (customFieldDefinitions.sortOrder/usageCount, serviceGroupFields.sortOrder, attachments.optimizationAttempts, serviceSchedules.reminderLeadValue) marked not-required at the schema level since PB treats zero as blank; the domain layer still enforces required-ness via Zod. --- .../1713484900_remaining_collections.js | 15 +- .../domain/__tests__/customFields.test.ts | 309 +++++++++++++++ src/server/domain/__tests__/locations.test.ts | 320 ++++++++++++++++ src/server/domain/__tests__/tags.test.ts | 195 ++++++++++ src/server/domain/categories.ts | 11 +- src/server/domain/customFields.ts | 237 ++++++++++++ src/server/domain/locations.ts | 351 ++++++++++++++++++ src/server/domain/tags.ts | 150 ++++++++ src/server/pb/__tests__/schema.test.ts | 14 +- src/server/pb/custom-fields.ts | 102 +++++ src/server/pb/locations.ts | 50 +++ src/test/pb-harness.ts | 33 +- 12 files changed, 1771 insertions(+), 16 deletions(-) create mode 100644 src/server/domain/__tests__/customFields.test.ts create mode 100644 src/server/domain/__tests__/locations.test.ts create mode 100644 src/server/domain/__tests__/tags.test.ts create mode 100644 src/server/domain/customFields.ts create mode 100644 src/server/domain/locations.ts create mode 100644 src/server/domain/tags.ts create mode 100644 src/server/pb/custom-fields.ts create mode 100644 src/server/pb/locations.ts diff --git a/pb_migrations/1713484900_remaining_collections.js b/pb_migrations/1713484900_remaining_collections.js index c2a3619..8e807c2 100644 --- a/pb_migrations/1713484900_remaining_collections.js +++ b/pb_migrations/1713484900_remaining_collections.js @@ -132,8 +132,10 @@ migrate( }, { name: "options", type: "json", required: false }, { name: "required", type: "bool", required: false }, - { name: "sortOrder", type: "number", required: true }, - { name: "usageCount", type: "number", required: true }, + // sortOrder starts at 0, usageCount starts at 0 — PB treats required + // number fields as rejecting zero, so we enforce non-null in Zod. + { name: "sortOrder", type: "number", required: false }, + { name: "usageCount", type: "number", required: false }, { name: "createdAt", type: "number", required: true }, { name: "updatedAt", type: "number", required: true }, ], @@ -218,7 +220,8 @@ migrate( }, { name: "required", type: "bool", required: false }, { name: "options", type: "json", required: false }, - { name: "sortOrder", type: "number", required: true }, + // sortOrder can be 0; see customFieldDefinitions note above. + { name: "sortOrder", type: "number", required: false }, { name: "createdAt", type: "number", required: true }, { name: "updatedAt", type: "number", required: true }, { name: "createdBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, @@ -358,7 +361,8 @@ migrate( maxSelect: 1, values: ["pending", "processing", "ready", "failed"], }, - { name: "optimizationAttempts", type: "number", required: true }, + // optimizationAttempts starts at 0; see customFieldDefinitions note above. + { name: "optimizationAttempts", type: "number", required: false }, { name: "optimizationError", type: "text", required: false, max: 2000 }, { name: "uploadedBy", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, { name: "uploadedAt", type: "number", required: true }, @@ -393,7 +397,8 @@ migrate( maxSelect: 1, values: ["days", "weeks", "months", "years"], }, - { name: "reminderLeadValue", type: "number", required: true }, + // reminderLeadValue can be 0 (same reminder day as service); see note. + { name: "reminderLeadValue", type: "number", required: false }, { name: "reminderLeadUnit", type: "select", diff --git a/src/server/domain/__tests__/customFields.test.ts b/src/server/domain/__tests__/customFields.test.ts new file mode 100644 index 0000000..0a19da3 --- /dev/null +++ b/src/server/domain/__tests__/customFields.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; + +import { + createFieldDefinition, + deleteFieldDefinition, + listFieldDefinitions, + reorderFieldDefinitions, + updateFieldDefinition, + type FieldDefinitionView, +} from "@/server/domain/customFields"; +import type { Ctx } from "@/server/pb/context"; +import { + ensureFieldNotInUse, + ensureSafeTypeChange, + normalizeDropdownOptions, + normalizeFieldOptions, + requireAppDateFormat, + requireCustomFieldName, +} from "@/server/pb/custom-fields"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("custom field helpers", () => { + it("requires and normalizes field names", () => { + expect(requireCustomFieldName(" Serial Number ")).toBe("Serial Number"); + expect(() => requireCustomFieldName(" ")).toThrowError( + "Field name is required", + ); + }); + + it("normalizes dropdown options with trim and case-insensitive uniqueness", () => { + expect( + normalizeDropdownOptions([ + " In stock ", + "in stock", + "Needs Service", + " ", + "needs service", + ]), + ).toEqual(["In stock", "Needs Service"]); + }); + + it("validates dropdown options by field type", () => { + expect(normalizeFieldOptions("text", ["A", "B"])).toEqual([]); + expect( + normalizeFieldOptions("dropdown", [" High ", "high", "Low"]), + ).toEqual(["High", "Low"]); + expect(() => normalizeFieldOptions("dropdown", [" "])).toThrowError( + "Dropdown fields require at least one option", + ); + }); + + it("blocks unsafe type changes for fields with usage", () => { + expect(() => ensureSafeTypeChange("number", "currency", 3)).not.toThrow(); + expect(() => ensureSafeTypeChange("text", "url", 1)).not.toThrow(); + expect(() => ensureSafeTypeChange("dropdown", "number", 2)).toThrowError( + "Create a new field instead of changing to an incompatible type.", + ); + expect(() => ensureSafeTypeChange("dropdown", "number", 0)).not.toThrow(); + }); + + it("blocks deleting fields that are in use", () => { + expect(() => ensureFieldNotInUse(0)).not.toThrow(); + expect(() => ensureFieldNotInUse(1)).toThrowError( + "This field is in use and cannot be deleted", + ); + }); + + it("validates allowed app date formats", () => { + expect(requireAppDateFormat("DD-MM-YYYY")).toBe("DD-MM-YYYY"); + expect(requireAppDateFormat("MM-DD-YYYY")).toBe("MM-DD-YYYY"); + expect(() => requireAppDateFormat("DD/MM/YYYY")).toThrowError( + "Unsupported date format", + ); + }); +}); + +describe("customFields domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates definitions and lists them in sort order", async () => { + await createFieldDefinition(ctx(), { + name: "Serial number", + fieldType: "text", + options: [], + required: true, + }); + await createFieldDefinition(ctx(), { + name: "Purchase date", + fieldType: "date", + options: [], + required: false, + }); + + const rows = await listFieldDefinitions(ctx()); + expect(rows.map((row: FieldDefinitionView) => row.name)).toEqual([ + "Serial number", + "Purchase date", + ]); + expect(rows.map((row: FieldDefinitionView) => row.sortOrder)).toEqual([ + 0, 1, + ]); + }); + + it("validates required dropdown options on create", async () => { + await expect( + createFieldDefinition(ctx(), { + name: "Condition", + fieldType: "dropdown", + options: [" "], + required: false, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("reorders definitions by index", async () => { + const first = await createFieldDefinition(ctx(), { + name: "First", + fieldType: "text", + required: false, + }); + const second = await createFieldDefinition(ctx(), { + name: "Second", + fieldType: "text", + required: false, + }); + const third = await createFieldDefinition(ctx(), { + name: "Third", + fieldType: "text", + required: false, + }); + + await reorderFieldDefinitions(ctx(), [third.id, first.id, second.id]); + + const rows = await listFieldDefinitions(ctx()); + expect(rows.map((row: FieldDefinitionView) => row.id)).toEqual([ + third.id, + first.id, + second.id, + ]); + expect(rows.map((row: FieldDefinitionView) => row.sortOrder)).toEqual([ + 0, 1, 2, + ]); + }); + + it("rejects reorder payloads with duplicate ids", async () => { + const first = await createFieldDefinition(ctx(), { + name: "First", + fieldType: "text", + required: false, + }); + const second = await createFieldDefinition(ctx(), { + name: "Second", + fieldType: "text", + required: false, + }); + const third = await createFieldDefinition(ctx(), { + name: "Third", + fieldType: "text", + required: false, + }); + + await expect( + reorderFieldDefinitions(ctx(), [first.id, first.id, third.id]), + ).rejects.toBeInstanceOf(ConflictError); + + const rows = await listFieldDefinitions(ctx()); + expect(rows.map((row) => row.id)).toEqual([first.id, second.id, third.id]); + }); + + it("blocks unsafe type changes when the field is in use", async () => { + const pb = getHarness().admin; + const created = await createFieldDefinition(ctx(), { + name: "Condition", + fieldType: "dropdown", + options: ["New", "Used"], + required: false, + }); + await pb + .collection("customFieldDefinitions") + .update(created.id, { usageCount: 2 }); + + await expect( + updateFieldDefinition(ctx(), { + fieldDefinitionId: created.id, + name: "Condition", + fieldType: "number", + options: [], + required: false, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("allows safe type changes even with usage (number ↔ currency)", async () => { + const pb = getHarness().admin; + const created = await createFieldDefinition(ctx(), { + name: "Cost", + fieldType: "number", + required: false, + }); + await pb + .collection("customFieldDefinitions") + .update(created.id, { usageCount: 5 }); + + const updated = await updateFieldDefinition(ctx(), { + fieldDefinitionId: created.id, + name: "Cost", + fieldType: "currency", + options: [], + required: false, + }); + expect(updated.fieldType).toBe("currency"); + }); + + it("blocks delete when usage count is positive", async () => { + const pb = getHarness().admin; + const created = await createFieldDefinition(ctx(), { + name: "Condition", + fieldType: "dropdown", + options: ["New", "Used"], + required: false, + }); + await pb + .collection("customFieldDefinitions") + .update(created.id, { usageCount: 2 }); + + await expect( + deleteFieldDefinition(ctx(), created.id), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("blocks delete when a label template references the field", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const field = await createFieldDefinition(ctx(), { + name: "QR payload", + fieldType: "text", + required: false, + }); + + await pb.collection("labelTemplates").create({ + name: "Asset tag label", + normalizedName: "asset tag label", + widthMm: 35, + heightMm: 12, + isDefault: false, + elements: [ + { + id: "custom-field", + type: "customField", + xMm: 2, + yMm: 2, + widthMm: 20, + heightMm: 5, + fontSize: 8, + textAlign: "left", + fieldId: field.id, + }, + ], + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await expect( + deleteFieldDefinition(ctx(), field.id), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("deletes an unused field definition", async () => { + const field = await createFieldDefinition(ctx(), { + name: "Temp", + fieldType: "text", + required: false, + }); + await expect(deleteFieldDefinition(ctx(), field.id)).resolves.toBeUndefined(); + await expect(listFieldDefinitions(ctx())).resolves.toEqual([]); + }); + + it("throws NotFoundError when updating or deleting an unknown field", async () => { + await expect( + updateFieldDefinition(ctx(), { + fieldDefinitionId: "nonexistent0000", + name: "x", + fieldType: "text", + required: false, + }), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + deleteFieldDefinition(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/src/server/domain/__tests__/locations.test.ts b/src/server/domain/__tests__/locations.test.ts new file mode 100644 index 0000000..44320e9 --- /dev/null +++ b/src/server/domain/__tests__/locations.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; + +import { + createLocation, + deleteLocation, + getLocationChildren, + getLocationPath, + listLocations, + moveLocation, + updateLocation, + type LocationView, +} from "@/server/domain/locations"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + computeLocationPath, + replaceLocationPathPrefix, + requireLocationName, + wouldCreateLocationCycle, +} from "@/server/pb/locations"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("location helpers", () => { + it("computes location paths from parent paths", () => { + expect(computeLocationPath(null, "Warehouse")).toBe("Warehouse"); + expect(computeLocationPath("Warehouse", "Shelf 3")).toBe( + "Warehouse / Shelf 3", + ); + }); + + it("replaces location path prefixes for descendants", () => { + expect( + replaceLocationPathPrefix( + "Warehouse / Aisle 1 / Bin 2", + "Warehouse / Aisle 1", + "Warehouse / Aisle A", + ), + ).toBe("Warehouse / Aisle A / Bin 2"); + expect( + replaceLocationPathPrefix( + "Warehouse / Aisle 1", + "Warehouse / Aisle 1", + "Warehouse / Aisle A", + ), + ).toBe("Warehouse / Aisle A"); + }); + + it("detects location cycles during moves", () => { + const byId = new Map([ + ["root", { id: "root", parentId: null }], + ["child", { id: "child", parentId: "root" }], + ["grandchild", { id: "grandchild", parentId: "child" }], + ]); + + expect(wouldCreateLocationCycle("child", "grandchild", byId)).toBe(true); + expect(wouldCreateLocationCycle("child", "root", byId)).toBe(false); + expect(wouldCreateLocationCycle("child", null, byId)).toBe(false); + }); + + it("validates location names", () => { + expect(requireLocationName(" Shelf 4 ")).toBe("Shelf 4"); + expect(() => requireLocationName(" ")).toThrowError(ValidationError); + }); +}); + +describe("locations domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates a root location with its own path", async () => { + const warehouse = await createLocation(ctx(), { name: "Warehouse" }); + expect(warehouse).toMatchObject({ + name: "Warehouse", + parentId: null, + path: "Warehouse", + }); + }); + + it("creates nested locations that inherit the parent path", async () => { + const warehouse = await createLocation(ctx(), { name: "Warehouse" }); + const aisle = await createLocation(ctx(), { + name: "Aisle 1", + parentId: warehouse.id, + }); + const bin = await createLocation(ctx(), { + name: "Bin A", + parentId: aisle.id, + }); + + expect(aisle.path).toBe("Warehouse / Aisle 1"); + expect(bin.path).toBe("Warehouse / Aisle 1 / Bin A"); + }); + + it("allows duplicate names under different parents", async () => { + const w1 = await createLocation(ctx(), { name: "Warehouse 1" }); + const w2 = await createLocation(ctx(), { name: "Warehouse 2" }); + await createLocation(ctx(), { name: "Shelf 1", parentId: w1.id }); + await expect( + createLocation(ctx(), { name: "Shelf 1", parentId: w2.id }), + ).resolves.toBeTruthy(); + }); + + it("rejects duplicate names within the same parent (case-insensitive)", async () => { + const w = await createLocation(ctx(), { name: "Warehouse" }); + await createLocation(ctx(), { name: "Shelf 3", parentId: w.id }); + + await expect( + createLocation(ctx(), { name: " SHELF 3 ", parentId: w.id }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("rejects duplicate names at root", async () => { + await createLocation(ctx(), { name: "Warehouse" }); + await expect( + createLocation(ctx(), { name: "warehouse" }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("throws ValidationError if the parent does not exist", async () => { + await expect( + createLocation(ctx(), { name: "Orphan", parentId: "nonexistent0000" }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("lists locations sorted by path (case-insensitive)", async () => { + const warehouse = await createLocation(ctx(), { name: "warehouse" }); + await createLocation(ctx(), { name: "A bin", parentId: warehouse.id }); + await createLocation(ctx(), { name: "b bin", parentId: warehouse.id }); + await createLocation(ctx(), { name: "Office" }); + + const list = await listLocations(ctx()); + expect(list.map((l: LocationView) => l.path)).toEqual([ + "Office", + "warehouse", + "warehouse / A bin", + "warehouse / b bin", + ]); + }); + + it("lists children of a parent", async () => { + const warehouse = await createLocation(ctx(), { name: "Warehouse" }); + await createLocation(ctx(), { name: "Bin 3", parentId: warehouse.id }); + await createLocation(ctx(), { name: "Bin 1", parentId: warehouse.id }); + await createLocation(ctx(), { name: "Bin 2", parentId: warehouse.id }); + // Also a root node that should not appear. + await createLocation(ctx(), { name: "Office" }); + + const children = await getLocationChildren(ctx(), warehouse.id); + expect(children.map((c: LocationView) => c.name)).toEqual([ + "Bin 1", + "Bin 2", + "Bin 3", + ]); + }); + + it("lists root children when parentId is null", async () => { + await createLocation(ctx(), { name: "Warehouse" }); + await createLocation(ctx(), { name: "Office" }); + const warehouse = await listLocations(ctx()); + const office = warehouse.find((l) => l.name === "Office")!; + await createLocation(ctx(), { name: "Meeting Room", parentId: office.id }); + + const roots = await getLocationChildren(ctx(), null); + expect(roots.map((l) => l.name).sort()).toEqual(["Office", "Warehouse"]); + }); + + it("returns a location path by id or null when missing", async () => { + const warehouse = await createLocation(ctx(), { name: "Warehouse" }); + await expect(getLocationPath(ctx(), warehouse.id)).resolves.toBe( + "Warehouse", + ); + await expect(getLocationPath(ctx(), "nonexistent0000")).resolves.toBeNull(); + }); + + it("renames a location and rewrites its descendants' paths", async () => { + const w = await createLocation(ctx(), { name: "Warehouse" }); + const a = await createLocation(ctx(), { name: "Aisle 1", parentId: w.id }); + const b = await createLocation(ctx(), { name: "Bin A", parentId: a.id }); + + await updateLocation(ctx(), { + locationId: a.id, + name: "Aisle A", + parentId: w.id, + }); + + const [rootPath, aisle, bin] = await Promise.all([ + getLocationPath(ctx(), w.id), + getLocationPath(ctx(), a.id), + getLocationPath(ctx(), b.id), + ]); + expect(rootPath).toBe("Warehouse"); + expect(aisle).toBe("Warehouse / Aisle A"); + expect(bin).toBe("Warehouse / Aisle A / Bin A"); + }); + + it("moves a subtree to a new parent and rewrites descendant paths", async () => { + const w1 = await createLocation(ctx(), { name: "Warehouse 1" }); + const w2 = await createLocation(ctx(), { name: "Warehouse 2" }); + const aisle = await createLocation(ctx(), { + name: "Aisle 1", + parentId: w1.id, + }); + const bin = await createLocation(ctx(), { + name: "Bin A", + parentId: aisle.id, + }); + + await moveLocation(ctx(), { locationId: aisle.id, parentId: w2.id }); + + await expect(getLocationPath(ctx(), aisle.id)).resolves.toBe( + "Warehouse 2 / Aisle 1", + ); + await expect(getLocationPath(ctx(), bin.id)).resolves.toBe( + "Warehouse 2 / Aisle 1 / Bin A", + ); + }); + + it("refuses to move a location inside its own descendant", async () => { + const a = await createLocation(ctx(), { name: "A" }); + const b = await createLocation(ctx(), { name: "B", parentId: a.id }); + const c = await createLocation(ctx(), { name: "C", parentId: b.id }); + + await expect( + moveLocation(ctx(), { locationId: a.id, parentId: c.id }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("refuses to set a location as its own parent", async () => { + const a = await createLocation(ctx(), { name: "A" }); + await expect( + moveLocation(ctx(), { locationId: a.id, parentId: a.id }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("refuses to rename into another sibling's name", async () => { + const w = await createLocation(ctx(), { name: "Warehouse" }); + await createLocation(ctx(), { name: "Shelf 1", parentId: w.id }); + const shelf2 = await createLocation(ctx(), { + name: "Shelf 2", + parentId: w.id, + }); + + await expect( + updateLocation(ctx(), { + locationId: shelf2.id, + name: " SHELF 1 ", + parentId: w.id, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("allows updating a location to its own normalized name", async () => { + const a = await createLocation(ctx(), { name: "Warehouse" }); + await expect( + updateLocation(ctx(), { + locationId: a.id, + name: "warehouse", + parentId: null, + }), + ).resolves.toMatchObject({ name: "warehouse", path: "warehouse" }); + }); + + it("deletes a leaf location", async () => { + const a = await createLocation(ctx(), { name: "Temp" }); + await expect(deleteLocation(ctx(), a.id)).resolves.toBeUndefined(); + await expect(listLocations(ctx())).resolves.toEqual([]); + }); + + it("refuses to delete a location that has child locations", async () => { + const a = await createLocation(ctx(), { name: "A" }); + await createLocation(ctx(), { name: "B", parentId: a.id }); + + await expect(deleteLocation(ctx(), a.id)).rejects.toBeInstanceOf( + ConflictError, + ); + }); + + it("refuses to delete a location that is assigned to an asset", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const location = await createLocation(ctx(), { name: "Closet" }); + + await pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: "AST-0001", + status: "active", + locationId: location.id, + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await expect(deleteLocation(ctx(), location.id)).rejects.toBeInstanceOf( + ConflictError, + ); + }); + + it("throws NotFoundError when deleting a missing location", async () => { + await expect( + deleteLocation(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/src/server/domain/__tests__/tags.test.ts b/src/server/domain/__tests__/tags.test.ts new file mode 100644 index 0000000..107e70e --- /dev/null +++ b/src/server/domain/__tests__/tags.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; + +import { + createTag, + deleteTag, + listTags, + updateTag, + type TagView, +} from "@/server/domain/tags"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("tag helpers", () => { + it("builds a stable duplicate-check key for tag names", async () => { + const { normalizeCatalogNameKey } = await import("@/server/pb/catalog"); + expect(normalizeCatalogNameKey(" Fragile ")).toBe("fragile"); + expect(normalizeCatalogNameKey("fragile")).toBe("fragile"); + expect(normalizeCatalogNameKey("FRAGILE")).toBe("fragile"); + }); + + it("validates tag names", async () => { + const { requireCatalogName } = await import("@/server/pb/catalog"); + expect(requireCatalogName("Urgent")).toBe("Urgent"); + expect(() => requireCatalogName("")).toThrowError("Name is required"); + }); + + it("normalizes tag colors", async () => { + const { normalizeHexColor } = await import("@/server/pb/catalog"); + expect(normalizeHexColor("#e11d48")).toBe("#E11D48"); + expect(() => normalizeHexColor("#12")).toThrowError( + "Enter a valid hex color", + ); + }); +}); + +describe("tags domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns an empty list initially", async () => { + await expect(listTags(ctx())).resolves.toEqual([]); + }); + + it("creates a tag with normalized name and uppercased hex color", async () => { + const tag = await createTag(ctx(), { + name: " Fragile ", + color: "e11d48", + }); + + expect(tag).toMatchObject({ name: "Fragile", color: "#E11D48" }); + expect(tag.createdAt).toBe(tag.updatedAt); + }); + + it("lists tags sorted case-insensitively by name", async () => { + await createTag(ctx(), { name: "urgent", color: "#112233" }); + await createTag(ctx(), { name: "Fragile", color: "#445566" }); + await createTag(ctx(), { name: "archived", color: "#778899" }); + + const list = await listTags(ctx()); + expect(list.map((tag: TagView) => tag.name)).toEqual([ + "archived", + "Fragile", + "urgent", + ]); + }); + + it("rejects duplicate tag names regardless of case or whitespace", async () => { + await createTag(ctx(), { name: "Fragile", color: "#E11D48" }); + + await expect( + createTag(ctx(), { name: " FRAGILE ", color: "#000000" }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("updates a tag in place", async () => { + const created = await createTag(ctx(), { + name: "Keep Dry", + color: "#000000", + }); + + const updated = await updateTag(ctx(), { + tagId: created.id, + name: "Keep Very Dry", + color: "#ffffff", + }); + + expect(updated.id).toBe(created.id); + expect(updated.name).toBe("Keep Very Dry"); + expect(updated.color).toBe("#FFFFFF"); + expect(updated.updatedAt).toBeGreaterThanOrEqual(created.updatedAt); + }); + + it("rejects an update that would collide with another tag's name", async () => { + await createTag(ctx(), { name: "Fragile", color: "#E11D48" }); + const other = await createTag(ctx(), { name: "Urgent", color: "#000000" }); + + await expect( + updateTag(ctx(), { + tagId: other.id, + name: " fragile ", + color: "#000000", + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("allows updating a tag to its own normalized name", async () => { + const tag = await createTag(ctx(), { + name: "Fragile", + color: "#E11D48", + }); + + const updated = await updateTag(ctx(), { + tagId: tag.id, + name: "fragile", + color: "#112233", + }); + + expect(updated.name).toBe("fragile"); + expect(updated.color).toBe("#112233"); + }); + + it("throws NotFoundError when updating a tag that does not exist", async () => { + await expect( + updateTag(ctx(), { + tagId: "nonexistent0000", + name: "Anything", + color: "#000000", + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("throws ValidationError on invalid hex color", async () => { + await expect( + createTag(ctx(), { name: "Broken", color: "not-a-color" }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("deletes a tag that is not linked to any asset", async () => { + const tag = await createTag(ctx(), { name: "Temp", color: "#112233" }); + + await expect(deleteTag(ctx(), tag.id)).resolves.toBeUndefined(); + await expect(listTags(ctx())).resolves.toEqual([]); + }); + + it("refuses to delete a tag that is in use on an asset", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + + const tag = await createTag(ctx(), { name: "InUse", color: "#112233" }); + + const asset = await pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: "AST-0001", + status: "active", + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await pb.collection("assetTags").create({ + assetId: asset.id, + tagId: tag.id, + createdBy: admin.id, + createdAt: Date.now(), + }); + + await expect(deleteTag(ctx(), tag.id)).rejects.toBeInstanceOf( + ConflictError, + ); + }); + + it("throws NotFoundError when deleting a tag that does not exist", async () => { + await expect( + deleteTag(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/src/server/domain/categories.ts b/src/server/domain/categories.ts index cf30a76..cbf22ea 100644 --- a/src/server/domain/categories.ts +++ b/src/server/domain/categories.ts @@ -54,10 +54,17 @@ function toCategoryView(record: CategoryRecord): CategoryView { } export async function listCategories(ctx: Ctx): Promise { + // Match Convex's localeCompare(..., { sensitivity: "base" }) — PB's SQL sort + // is lexicographic (uppercase before lowercase), which produces the wrong + // order for mixed-case names. const records = await ctx.pb .collection("categories") - .getFullList({ sort: "+name" }); - return records.map(toCategoryView); + .getFullList(); + return records + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ) + .map(toCategoryView); } export async function createCategory( diff --git a/src/server/domain/customFields.ts b/src/server/domain/customFields.ts new file mode 100644 index 0000000..dd16f20 --- /dev/null +++ b/src/server/domain/customFields.ts @@ -0,0 +1,237 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + CUSTOM_FIELD_TYPES, + type CustomFieldType, + ensureFieldNotInUse, + ensureSafeTypeChange, + normalizeFieldOptions, + requireCustomFieldName, +} from "@/server/pb/custom-fields"; +import { ConflictError, NotFoundError } from "@/server/pb/errors"; + +const fieldTypeSchema = z.enum(CUSTOM_FIELD_TYPES); + +export const CreateFieldDefinitionInput = z.object({ + name: z.string(), + fieldType: fieldTypeSchema, + options: z.array(z.string()).optional(), + required: z.boolean(), +}); + +export const UpdateFieldDefinitionInput = z.object({ + fieldDefinitionId: z.string(), + name: z.string(), + fieldType: fieldTypeSchema, + options: z.array(z.string()).optional(), + required: z.boolean(), +}); + +export type CreateFieldDefinitionInput = z.infer< + typeof CreateFieldDefinitionInput +>; +export type UpdateFieldDefinitionInput = z.infer< + typeof UpdateFieldDefinitionInput +>; + +export type FieldDefinitionView = { + id: string; + name: string; + fieldType: CustomFieldType; + options: string[]; + required: boolean; + sortOrder: number; + usageCount: number; + createdAt: number; + updatedAt: number; +}; + +type FieldDefinitionRecord = { + id: string; + name: string; + fieldType: CustomFieldType; + options: string[] | null; + required: boolean; + sortOrder: number; + usageCount: number; + createdAt: number; + updatedAt: number; +}; + +function toFieldDefinitionView( + record: FieldDefinitionRecord, +): FieldDefinitionView { + return { + id: record.id, + name: record.name, + fieldType: record.fieldType, + options: Array.isArray(record.options) ? record.options : [], + required: !!record.required, + sortOrder: record.sortOrder, + usageCount: record.usageCount, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +async function getDefinitionOrThrow( + ctx: Ctx, + fieldDefinitionId: string, +): Promise { + try { + return await ctx.pb + .collection("customFieldDefinitions") + .getOne(fieldDefinitionId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Field definition not found"); + } + throw error; + } +} + +export async function listFieldDefinitions( + ctx: Ctx, +): Promise { + const records = await ctx.pb + .collection("customFieldDefinitions") + .getFullList({ sort: "sortOrder" }); + return records.map(toFieldDefinitionView); +} + +export async function createFieldDefinition( + ctx: Ctx, + input: CreateFieldDefinitionInput, +): Promise { + const parsed = CreateFieldDefinitionInput.parse(input); + const name = requireCustomFieldName(parsed.name); + const options = normalizeFieldOptions(parsed.fieldType, parsed.options); + const now = Date.now(); + + const latest = await ctx.pb + .collection("customFieldDefinitions") + .getList(1, 1, { sort: "-sortOrder" }); + const nextSortOrder = latest.items[0] + ? latest.items[0].sortOrder + 1 + : 0; + + const record = await ctx.pb + .collection("customFieldDefinitions") + .create({ + name, + fieldType: parsed.fieldType, + options, + required: parsed.required, + sortOrder: nextSortOrder, + usageCount: 0, + createdAt: now, + updatedAt: now, + }); + + return toFieldDefinitionView(record); +} + +export async function updateFieldDefinition( + ctx: Ctx, + input: UpdateFieldDefinitionInput, +): Promise { + const parsed = UpdateFieldDefinitionInput.parse(input); + const existing = await getDefinitionOrThrow(ctx, parsed.fieldDefinitionId); + + ensureSafeTypeChange( + existing.fieldType, + parsed.fieldType, + existing.usageCount, + ); + + const name = requireCustomFieldName(parsed.name); + const options = normalizeFieldOptions(parsed.fieldType, parsed.options); + + const record = await ctx.pb + .collection("customFieldDefinitions") + .update(existing.id, { + name, + fieldType: parsed.fieldType, + options, + required: parsed.required, + updatedAt: Date.now(), + }); + + return toFieldDefinitionView(record); +} + +type LabelTemplateElement = { + type?: string; + fieldId?: string | null; +}; + +type LabelTemplateRecord = { + id: string; + elements: LabelTemplateElement[] | null; +}; + +export async function deleteFieldDefinition( + ctx: Ctx, + fieldDefinitionId: string, +): Promise { + const existing = await getDefinitionOrThrow(ctx, fieldDefinitionId); + ensureFieldNotInUse(existing.usageCount); + + const templates = await ctx.pb + .collection("labelTemplates") + .getFullList(); + const referenced = templates.some((template) => + (template.elements ?? []).some( + (element) => + element?.type === "customField" && + element?.fieldId === fieldDefinitionId, + ), + ); + if (referenced) { + throw new ConflictError( + "Field definition cannot be deleted while a label template references it", + ); + } + + await ctx.pb.collection("customFieldDefinitions").delete(existing.id); +} + +export async function reorderFieldDefinitions( + ctx: Ctx, + fieldDefinitionIds: string[], +): Promise { + const records = await ctx.pb + .collection("customFieldDefinitions") + .getFullList(); + + const existingIds = new Set(records.map((row) => row.id)); + const nextIds = new Set(fieldDefinitionIds); + + if ( + records.length !== fieldDefinitionIds.length || + existingIds.size !== nextIds.size + ) { + throw new ConflictError("Provide all field definitions when reordering"); + } + + for (const id of fieldDefinitionIds) { + if (!existingIds.has(id)) { + throw new ConflictError("Reorder payload contains an unknown field"); + } + } + + const currentSortById = new Map( + records.map((row) => [row.id, row.sortOrder]), + ); + const now = Date.now(); + + for (const [index, id] of fieldDefinitionIds.entries()) { + if (currentSortById.get(id) === index) continue; + await ctx.pb.collection("customFieldDefinitions").update(id, { + sortOrder: index, + updatedAt: now, + }); + } +} diff --git a/src/server/domain/locations.ts b/src/server/domain/locations.ts new file mode 100644 index 0000000..3c1fc46 --- /dev/null +++ b/src/server/domain/locations.ts @@ -0,0 +1,351 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + computeLocationPath, + normalizeLocationDescription, + normalizeLocationNameKey, + replaceLocationPathPrefix, + requireLocationName, + wouldCreateLocationCycle, +} from "@/server/pb/locations"; + +const NullableId = z.string().nullish(); + +export const CreateLocationInput = z.object({ + name: z.string(), + parentId: NullableId, + description: z.string().nullish(), +}); + +export const UpdateLocationInput = z.object({ + locationId: z.string(), + name: z.string(), + parentId: NullableId, + description: z.string().nullish(), +}); + +export const MoveLocationInput = z.object({ + locationId: z.string(), + parentId: NullableId, +}); + +export type CreateLocationInput = z.infer; +export type UpdateLocationInput = z.infer; +export type MoveLocationInput = z.infer; + +export type LocationView = { + id: string; + name: string; + parentId: string | null; + description: string | null; + path: string; + createdAt: number; + updatedAt: number; +}; + +type LocationRecord = { + id: string; + name: string; + normalizedName: string; + parentId: string; + description: string; + path: string; + createdAt: number; + updatedAt: number; +}; + +function toLocationView(record: LocationRecord): LocationView { + return { + id: record.id, + name: record.name, + parentId: record.parentId || null, + description: record.description || null, + path: record.path, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +function sortByPath(records: LocationRecord[]) { + return records + .slice() + .sort((a, b) => + a.path.localeCompare(b.path, undefined, { sensitivity: "base" }), + ); +} + +function sortByName(records: LocationRecord[]) { + return records + .slice() + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); +} + +async function getLocationOrThrow( + ctx: Ctx, + locationId: string, +): Promise { + try { + return await ctx.pb + .collection("locations") + .getOne(locationId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Location not found"); + } + throw error; + } +} + +async function loadParent( + ctx: Ctx, + parentId: string | null, +): Promise { + if (!parentId) return null; + try { + return await ctx.pb.collection("locations").getOne(parentId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError("Parent location not found"); + } + throw error; + } +} + +async function assertUniqueSiblingName( + ctx: Ctx, + parentId: string | null, + normalizedName: string, + excludeId?: string, +) { + // PB stores empty relations as ""; match the Convex semantics by filtering + // on both nullability and the normalizedName. + const parentFilter = parentId ? `parentId = "${parentId}"` : `parentId = ""`; + const nameEscaped = normalizedName.replace(/"/g, '\\"'); + const filter = `${parentFilter} && normalizedName = "${nameEscaped}"`; + const matches = await ctx.pb + .collection("locations") + .getList(1, 2, { filter }); + const duplicate = matches.items.find((row) => row.id !== excludeId); + if (duplicate) { + throw new ConflictError( + "A location with this name already exists at this level", + ); + } +} + +async function updateDescendantPaths( + ctx: Ctx, + oldPath: string, + newPath: string, + changedAt: number, +) { + if (oldPath === newPath) return; + const all = await ctx.pb + .collection("locations") + .getFullList(); + const descendants = all.filter( + (location) => + location.path !== oldPath && + location.path.startsWith(`${oldPath}${" / "}`), + ); + + for (const descendant of descendants) { + await ctx.pb.collection("locations").update(descendant.id, { + path: replaceLocationPathPrefix(descendant.path, oldPath, newPath), + updatedAt: changedAt, + }); + } +} + +export async function listLocations(ctx: Ctx): Promise { + const records = await ctx.pb + .collection("locations") + .getFullList(); + return sortByPath(records).map(toLocationView); +} + +export async function getLocationChildren( + ctx: Ctx, + parentId: string | null, +): Promise { + const filter = parentId + ? `parentId = "${parentId}"` + : `parentId = ""`; + const records = await ctx.pb + .collection("locations") + .getFullList({ filter }); + return sortByName(records).map(toLocationView); +} + +export async function getLocationPath( + ctx: Ctx, + locationId: string, +): Promise { + try { + const record = await ctx.pb + .collection("locations") + .getOne(locationId); + return record.path; + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +export async function createLocation( + ctx: Ctx, + input: CreateLocationInput, +): Promise { + const parsed = CreateLocationInput.parse(input); + const name = requireLocationName(parsed.name); + const normalizedName = normalizeLocationNameKey(name); + const description = normalizeLocationDescription(parsed.description ?? null); + const parent = await loadParent(ctx, parsed.parentId ?? null); + await assertUniqueSiblingName(ctx, parent?.id ?? null, normalizedName); + + const now = Date.now(); + const record = await ctx.pb.collection("locations").create({ + name, + normalizedName, + parentId: parent?.id ?? "", + description: description ?? "", + path: computeLocationPath(parent?.path ?? null, name), + createdAt: now, + updatedAt: now, + }); + return toLocationView(record); +} + +type UpdateCoreArgs = { + locationId: string; + name?: string; + parentId?: string | null; + description?: string | null; +}; + +async function updateLocationCore( + ctx: Ctx, + args: UpdateCoreArgs, +): Promise { + const existing = await getLocationOrThrow(ctx, args.locationId); + const nextName = + args.name === undefined ? existing.name : requireLocationName(args.name); + const nextNormalizedName = normalizeLocationNameKey(nextName); + const nextDescription = + args.description === undefined + ? existing.description + : (normalizeLocationDescription(args.description) ?? ""); + const nextParentId = + args.parentId === undefined + ? existing.parentId || null + : args.parentId; + + if (nextParentId === existing.id) { + throw new ValidationError("A location cannot be its own parent"); + } + + const allLocations = await ctx.pb + .collection("locations") + .getFullList(); + const byId = new Map( + allLocations.map((location) => [ + location.id, + { + id: location.id, + parentId: location.parentId || null, + }, + ]), + ); + + if (wouldCreateLocationCycle(existing.id, nextParentId, byId)) { + throw new ValidationError( + "A location cannot be moved inside one of its descendants", + ); + } + + const parent = await loadParent(ctx, nextParentId); + await assertUniqueSiblingName( + ctx, + parent?.id ?? null, + nextNormalizedName, + existing.id, + ); + + const nextPath = computeLocationPath(parent?.path ?? null, nextName); + const changedAt = Date.now(); + + const updated = await ctx.pb + .collection("locations") + .update(existing.id, { + name: nextName, + normalizedName: nextNormalizedName, + parentId: parent?.id ?? "", + description: nextDescription, + path: nextPath, + updatedAt: changedAt, + }); + + await updateDescendantPaths(ctx, existing.path, nextPath, changedAt); + + return toLocationView(updated); +} + +export async function updateLocation( + ctx: Ctx, + input: UpdateLocationInput, +): Promise { + const parsed = UpdateLocationInput.parse(input); + return updateLocationCore(ctx, { + locationId: parsed.locationId, + name: parsed.name, + parentId: parsed.parentId ?? null, + description: parsed.description ?? null, + }); +} + +export async function moveLocation( + ctx: Ctx, + input: MoveLocationInput, +): Promise { + const parsed = MoveLocationInput.parse(input); + return updateLocationCore(ctx, { + locationId: parsed.locationId, + parentId: parsed.parentId ?? null, + }); +} + +export async function deleteLocation( + ctx: Ctx, + locationId: string, +): Promise { + const location = await getLocationOrThrow(ctx, locationId); + + const childCount = await ctx.pb + .collection("locations") + .getList(1, 1, { filter: `parentId = "${location.id}"` }); + if (childCount.totalItems > 0) { + throw new ConflictError("Delete child locations first"); + } + + const linkedAsset = await ctx.pb + .collection("assets") + .getList(1, 1, { filter: `locationId = "${location.id}"` }); + if (linkedAsset.totalItems > 0) { + throw new ConflictError( + "Cannot delete a location that is assigned to assets", + ); + } + + await ctx.pb.collection("locations").delete(location.id); +} diff --git a/src/server/domain/tags.ts b/src/server/domain/tags.ts new file mode 100644 index 0000000..8bfffea --- /dev/null +++ b/src/server/domain/tags.ts @@ -0,0 +1,150 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import { + normalizeCatalogNameKey, + normalizeHexColor, + requireCatalogName, +} from "@/server/pb/catalog"; +import type { Ctx } from "@/server/pb/context"; +import { ConflictError, NotFoundError } from "@/server/pb/errors"; + +export const CreateTagInput = z.object({ + name: z.string(), + color: z.string(), +}); + +export const UpdateTagInput = z.object({ + tagId: z.string(), + name: z.string(), + color: z.string(), +}); + +export type CreateTagInput = z.infer; +export type UpdateTagInput = z.infer; + +export type TagView = { + id: string; + name: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +type TagRecord = { + id: string; + name: string; + normalizedName: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +function toTagView(record: TagRecord): TagView { + return { + id: record.id, + name: record.name, + color: record.color, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +function isDuplicateNameError(error: unknown) { + return ( + error instanceof ClientResponseError && + error.status === 400 && + JSON.stringify(error.data ?? {}).includes("normalizedName") + ); +} + +export async function listTags(ctx: Ctx): Promise { + // Match Convex's localeCompare(..., { sensitivity: "base" }) — PB's SQL sort + // is lexicographic (uppercase before lowercase), which produces the wrong + // order for mixed-case names. + const records = await ctx.pb + .collection("tags") + .getFullList(); + return records + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ) + .map(toTagView); +} + +export async function createTag( + ctx: Ctx, + input: CreateTagInput, +): Promise { + const parsed = CreateTagInput.parse(input); + const name = requireCatalogName(parsed.name); + const normalizedName = normalizeCatalogNameKey(name); + const color = normalizeHexColor(parsed.color); + const now = Date.now(); + + try { + const record = await ctx.pb.collection("tags").create({ + name, + normalizedName, + color, + createdAt: now, + updatedAt: now, + }); + return toTagView(record); + } catch (error) { + if (isDuplicateNameError(error)) { + throw new ConflictError("A tag with this name already exists"); + } + throw error; + } +} + +export async function updateTag( + ctx: Ctx, + input: UpdateTagInput, +): Promise { + const parsed = UpdateTagInput.parse(input); + const name = requireCatalogName(parsed.name); + const normalizedName = normalizeCatalogNameKey(name); + const color = normalizeHexColor(parsed.color); + + try { + const record = await ctx.pb + .collection("tags") + .update(parsed.tagId, { + name, + normalizedName, + color, + updatedAt: Date.now(), + }); + return toTagView(record); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Tag not found"); + } + if (isDuplicateNameError(error)) { + throw new ConflictError("A tag with this name already exists"); + } + throw error; + } +} + +export async function deleteTag(ctx: Ctx, tagId: string): Promise { + try { + await ctx.pb.collection("tags").getOne(tagId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Tag not found"); + } + throw error; + } + + const linked = await ctx.pb.collection("assetTags").getList(1, 1, { + filter: `tagId = "${tagId}"`, + }); + if (linked.totalItems > 0) { + throw new ConflictError("Cannot delete a tag that is assigned to assets"); + } + + await ctx.pb.collection("tags").delete(tagId); +} diff --git a/src/server/pb/__tests__/schema.test.ts b/src/server/pb/__tests__/schema.test.ts index 662cc48..42f3903 100644 --- a/src/server/pb/__tests__/schema.test.ts +++ b/src/server/pb/__tests__/schema.test.ts @@ -114,19 +114,23 @@ describe("pb schema", () => { updatedAt: Date.now(), }); - const searchForName = await fetch( + type RecordsResponse = { + items: Array<{ id: string; assetTag: string }>; + }; + + const byId = (await fetch( `${getHarness().url}/api/collections/assets/records?filter=${encodeURIComponent(`id = "${laptop.id}"`)}`, { headers: { Authorization: pb.authStore.token } }, - ).then((r) => r.json()); - expect(searchForName.items[0].id).toBe(laptop.id); + ).then((r) => r.json())) as RecordsResponse; + expect(byId.items[0].id).toBe(laptop.id); - const ftsCount = await pb.send<{ count: number }>( + const byTag = await pb.send( `/api/collections/assets/records`, { method: "GET", query: { filter: `assetTag = "LAP-0100"` }, }, ); - expect(ftsCount.items[0]?.assetTag).toBe("LAP-0100"); + expect(byTag.items[0]?.assetTag).toBe("LAP-0100"); }); }); diff --git a/src/server/pb/custom-fields.ts b/src/server/pb/custom-fields.ts new file mode 100644 index 0000000..d6ce3f3 --- /dev/null +++ b/src/server/pb/custom-fields.ts @@ -0,0 +1,102 @@ +import { ValidationError } from "./errors"; + +export const CUSTOM_FIELD_TYPES = [ + "text", + "number", + "date", + "dropdown", + "checkbox", + "url", + "currency", +] as const; + +export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number]; + +export const APP_DATE_FORMATS = [ + "DD-MM-YYYY", + "MM-DD-YYYY", + "YYYY-MM-DD", +] as const; + +export type AppDateFormat = (typeof APP_DATE_FORMATS)[number]; + +export function requireCustomFieldName(name: string) { + const normalized = name.trim(); + if (!normalized) { + throw new ValidationError("Field name is required"); + } + return normalized; +} + +export function normalizeDropdownOptions(options: string[] | undefined) { + const seen = new Set(); + const normalized: string[] = []; + for (const rawOption of options ?? []) { + const option = rawOption.trim(); + if (!option) continue; + const key = option.toLocaleLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + normalized.push(option); + } + return normalized; +} + +export function normalizeFieldOptions( + fieldType: CustomFieldType, + options: string[] | undefined, +) { + if (fieldType !== "dropdown") { + return [] as string[]; + } + const normalized = normalizeDropdownOptions(options); + if (normalized.length === 0) { + throw new ValidationError("Dropdown fields require at least one option"); + } + return normalized; +} + +export function isSafeFieldTypeChange( + from: CustomFieldType, + to: CustomFieldType, +) { + if (from === to) return true; + if ( + (from === "number" && to === "currency") || + (from === "currency" && to === "number") + ) { + return true; + } + if ((from === "text" && to === "url") || (from === "url" && to === "text")) { + return true; + } + return false; +} + +export function ensureSafeTypeChange( + currentType: CustomFieldType, + nextType: CustomFieldType, + usageCount: number, +) { + if (currentType === nextType) return; + if (usageCount > 0 && !isSafeFieldTypeChange(currentType, nextType)) { + throw new ValidationError( + "This field has saved values. Create a new field instead of changing to an incompatible type.", + ); + } +} + +export function ensureFieldNotInUse(usageCount: number) { + if (usageCount > 0) { + throw new ValidationError( + "This field is in use and cannot be deleted", + ); + } +} + +export function requireAppDateFormat(format: string): AppDateFormat { + if ((APP_DATE_FORMATS as readonly string[]).includes(format)) { + return format as AppDateFormat; + } + throw new ValidationError("Unsupported date format"); +} diff --git a/src/server/pb/locations.ts b/src/server/pb/locations.ts new file mode 100644 index 0000000..bc21a9f --- /dev/null +++ b/src/server/pb/locations.ts @@ -0,0 +1,50 @@ +import { normalizeCatalogName, normalizeOptionalText } from "./catalog"; +import { ValidationError } from "./errors"; + +export const LOCATION_PATH_SEPARATOR = " / "; + +export function requireLocationName(value: string) { + const normalized = normalizeCatalogName(value); + if (!normalized) { + throw new ValidationError("Location name is required"); + } + return normalized; +} + +export function normalizeLocationDescription(value: string | null | undefined) { + return normalizeOptionalText(value); +} + +export function normalizeLocationNameKey(value: string) { + return normalizeCatalogName(value).toLowerCase(); +} + +export function computeLocationPath(parentPath: string | null, name: string) { + return parentPath ? `${parentPath}${LOCATION_PATH_SEPARATOR}${name}` : name; +} + +export function replaceLocationPathPrefix( + existingPath: string, + oldPrefix: string, + newPrefix: string, +) { + if (existingPath === oldPrefix) return newPrefix; + const childPrefix = `${oldPrefix}${LOCATION_PATH_SEPARATOR}`; + if (!existingPath.startsWith(childPrefix)) return existingPath; + return `${newPrefix}${existingPath.slice(oldPrefix.length)}`; +} + +export function wouldCreateLocationCycle( + locationId: string, + nextParentId: string | null, + byId: Map, +) { + let cursor = nextParentId; + while (cursor) { + if (cursor === locationId) return true; + const node = byId.get(cursor); + if (!node) break; + cursor = node.parentId; + } + return false; +} diff --git a/src/test/pb-harness.ts b/src/test/pb-harness.ts index b0a587c..edf71b3 100644 --- a/src/test/pb-harness.ts +++ b/src/test/pb-harness.ts @@ -25,7 +25,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import PocketBase from "pocketbase"; +import PocketBase, { ClientResponseError } from "pocketbase"; import { afterAll, beforeAll, beforeEach } from "vitest"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -231,9 +231,34 @@ async function truncateUserCollections(pb: PocketBase) { for (const name of byName.keys()) order.push(name); for (const name of order) { - const records = await pb.collection(name).getFullList({ batch: 200 }); - for (const rec of records) { - await pb.collection(name).delete(rec.id); + // Loop until all records are gone. Self-referencing collections (e.g. + // locations with parentId → locations) need multiple passes: parents stay + // blocked while children still reference them, so we delete leaves first + // and then re-scan. Abort if a pass makes zero progress to avoid an + // infinite loop on genuinely stuck data. + let remaining = await pb + .collection(name) + .getFullList({ batch: 200 }); + while (remaining.length > 0) { + let progress = false; + for (const rec of remaining) { + try { + await pb.collection(name).delete(rec.id); + progress = true; + } catch (err) { + if (err instanceof ClientResponseError && err.status === 400) { + continue; + } + throw err; + } + } + if (!progress) { + throw new Error( + `pb-harness: truncation made no progress on "${name}" ` + + `(${remaining.length} records left — likely a FK/self-ref loop)`, + ); + } + remaining = await pb.collection(name).getFullList({ batch: 200 }); } } } From e9690a9b18b6096d6106f4b4104a4868634b55c6 Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:48:48 +0200 Subject: [PATCH 05/37] Port remaining catalog domains to PocketBase Finishes the milestone 4 catalog surface. Adds five more domain modules plus shared helpers, bringing the PB regression suite from 70 to 115 tests across 10 files. - appSettings: single-row get + update flows for dateFormat and serviceSchedulingEnabled, with sane defaults when the row does not yet exist. - serviceProviders: CRUD with case-insensitive name uniqueness and a delete-in-use guard against serviceRecords.providerId. - serviceGroups: CRUD, per-group field + asset counts in summary payload, and in-use guard against assets and service records. Deleting a group also cascades its fields explicitly. - serviceGroupFields: CRUD with per-group label uniqueness, select- option dedup/trim, reorder-by-ids validation, and sortOrder renumber on delete. - labelTemplates: CRUD with element-bounds + fieldId validation, default-template invariant (exactly one default), ensureDefault template seeding for fresh installs, and setDefaultTemplate switching. Shared helpers for service-catalog normalization and label-template element validation live under src/server/pb/. --- .../domain/__tests__/appSettings.test.ts | 82 ++++ .../domain/__tests__/labelTemplates.test.ts | 295 +++++++++++++++ .../__tests__/serviceGroupFields.test.ts | 277 ++++++++++++++ .../domain/__tests__/serviceGroups.test.ts | 219 +++++++++++ .../domain/__tests__/serviceProviders.test.ts | 162 ++++++++ src/server/domain/appSettings.ts | 149 ++++++++ src/server/domain/labelTemplates.ts | 354 ++++++++++++++++++ src/server/domain/serviceGroupFields.ts | 265 +++++++++++++ src/server/domain/serviceGroups.ts | 282 ++++++++++++++ src/server/domain/serviceProviders.ts | 201 ++++++++++ src/server/pb/label-templates.ts | 280 ++++++++++++++ src/server/pb/service-catalog.ts | 82 ++++ 12 files changed, 2648 insertions(+) create mode 100644 src/server/domain/__tests__/appSettings.test.ts create mode 100644 src/server/domain/__tests__/labelTemplates.test.ts create mode 100644 src/server/domain/__tests__/serviceGroupFields.test.ts create mode 100644 src/server/domain/__tests__/serviceGroups.test.ts create mode 100644 src/server/domain/__tests__/serviceProviders.test.ts create mode 100644 src/server/domain/appSettings.ts create mode 100644 src/server/domain/labelTemplates.ts create mode 100644 src/server/domain/serviceGroupFields.ts create mode 100644 src/server/domain/serviceGroups.ts create mode 100644 src/server/domain/serviceProviders.ts create mode 100644 src/server/pb/label-templates.ts create mode 100644 src/server/pb/service-catalog.ts diff --git a/src/server/domain/__tests__/appSettings.test.ts b/src/server/domain/__tests__/appSettings.test.ts new file mode 100644 index 0000000..ed1b7a0 --- /dev/null +++ b/src/server/domain/__tests__/appSettings.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { + getAppSettings, + updateDateFormat, + updateServiceSchedulingEnabled, +} from "@/server/domain/appSettings"; +import type { Ctx } from "@/server/pb/context"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("appSettings domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns defaults when no settings row exists", async () => { + await expect(getAppSettings(ctx())).resolves.toEqual({ + dateFormat: "DD-MM-YYYY", + serviceSchedulingEnabled: true, + updatedAt: null, + }); + }); + + it("updates date format and persists across reads", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const view = await updateDateFormat(ctx(), { + dateFormat: "YYYY-MM-DD", + actorId: admin.id, + }); + expect(view.dateFormat).toBe("YYYY-MM-DD"); + expect(view.updatedAt).toBeGreaterThan(0); + + await expect(getAppSettings(ctx())).resolves.toMatchObject({ + dateFormat: "YYYY-MM-DD", + serviceSchedulingEnabled: true, + }); + }); + + it("updates service scheduling toggle and keeps date format", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await updateDateFormat(ctx(), { + dateFormat: "MM-DD-YYYY", + actorId: admin.id, + }); + const view = await updateServiceSchedulingEnabled(ctx(), { + enabled: false, + actorId: admin.id, + }); + expect(view).toMatchObject({ + dateFormat: "MM-DD-YYYY", + serviceSchedulingEnabled: false, + }); + + await expect(getAppSettings(ctx())).resolves.toMatchObject({ + dateFormat: "MM-DD-YYYY", + serviceSchedulingEnabled: false, + }); + }); + + it("rejects invalid date formats at the Zod boundary", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + updateDateFormat(ctx(), { + // @ts-expect-error intentionally invalid + dateFormat: "DD/MM/YYYY", + actorId: admin.id, + }), + ).rejects.toThrow(); + }); +}); diff --git a/src/server/domain/__tests__/labelTemplates.test.ts b/src/server/domain/__tests__/labelTemplates.test.ts new file mode 100644 index 0000000..cab6ec3 --- /dev/null +++ b/src/server/domain/__tests__/labelTemplates.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from "vitest"; + +import { createFieldDefinition } from "@/server/domain/customFields"; +import { + createTemplate, + deleteTemplate, + ensureDefaultTemplates, + getDefaultTemplate, + getTemplate, + listTemplates, + setDefaultTemplate, + updateTemplate, +} from "@/server/domain/labelTemplates"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +function baseTemplate(actorId: string, overrides: Record = {}) { + return { + name: "Thermal 57x32", + widthMm: 57, + heightMm: 32, + isDefault: false, + actorId, + elements: [ + { + id: "asset-tag", + type: "assetTag" as const, + xMm: 3, + yMm: 3, + widthMm: 25, + heightMm: 6, + fontSize: 10, + fontWeight: "bold" as const, + textAlign: "left" as const, + }, + ], + ...overrides, + }; +} + +describe("labelTemplates domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates a template and makes the first one default", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const template = await createTemplate(ctx(), baseTemplate(admin.id)); + expect(template.isDefault).toBe(true); + + const list = await listTemplates(ctx()); + expect(list).toHaveLength(1); + expect(list[0].isDefault).toBe(true); + }); + + it("keeps only one default when another template is created with isDefault=true", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createTemplate(ctx(), baseTemplate(admin.id, { name: "First" })); + const second = await createTemplate( + ctx(), + baseTemplate(admin.id, { name: "Second", isDefault: true }), + ); + const list = await listTemplates(ctx()); + const defaults = list.filter((t) => t.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0].id).toBe(second.id); + }); + + it("rejects a duplicate template name", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createTemplate(ctx(), baseTemplate(admin.id)); + await expect( + createTemplate(ctx(), baseTemplate(admin.id)), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("rejects elements that escape the label bounds", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createTemplate( + ctx(), + baseTemplate(admin.id, { + name: "Overflow", + widthMm: 20, + heightMm: 10, + elements: [ + { + id: "asset-tag", + type: "assetTag" as const, + xMm: 18, + yMm: 8, + widthMm: 10, + heightMm: 6, + }, + ], + }), + ), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("rejects customField elements without a fieldId", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createTemplate( + ctx(), + baseTemplate(admin.id, { + name: "Missing field", + elements: [ + { + id: "custom", + type: "customField" as const, + xMm: 2, + yMm: 2, + widthMm: 20, + heightMm: 5, + fontSize: 8, + textAlign: "left" as const, + }, + ], + }), + ), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("rejects customField elements referencing a missing field", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createTemplate( + ctx(), + baseTemplate(admin.id, { + name: "Bad ref", + elements: [ + { + id: "custom", + type: "customField" as const, + xMm: 2, + yMm: 2, + widthMm: 20, + heightMm: 5, + fontSize: 8, + textAlign: "left" as const, + fieldId: "nonexistent0000", + }, + ], + }), + ), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("accepts customField elements referencing an existing field", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const field = await createFieldDefinition(ctx(), { + name: "QR payload", + fieldType: "text", + required: false, + }); + await expect( + createTemplate( + ctx(), + baseTemplate(admin.id, { + name: "Custom fields", + elements: [ + { + id: "custom", + type: "customField" as const, + xMm: 2, + yMm: 2, + widthMm: 20, + heightMm: 5, + fontSize: 8, + textAlign: "left" as const, + fieldId: field.id, + }, + ], + }), + ), + ).resolves.toBeTruthy(); + }); + + it("refuses to clear the default flag during update without a replacement", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const created = await createTemplate(ctx(), baseTemplate(admin.id)); + await expect( + updateTemplate(ctx(), { + ...baseTemplate(admin.id), + templateId: created.id, + isDefault: false, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("refuses to delete the default template", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const created = await createTemplate(ctx(), baseTemplate(admin.id)); + await expect(deleteTemplate(ctx(), created.id)).rejects.toBeInstanceOf( + ConflictError, + ); + }); + + it("deletes a non-default template", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createTemplate(ctx(), baseTemplate(admin.id, { name: "First" })); + const other = await createTemplate( + ctx(), + baseTemplate(admin.id, { name: "Second" }), + ); + await expect(deleteTemplate(ctx(), other.id)).resolves.toBeUndefined(); + }); + + it("setDefaultTemplate switches the default flag", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const first = await createTemplate( + ctx(), + baseTemplate(admin.id, { name: "First" }), + ); + const second = await createTemplate( + ctx(), + baseTemplate(admin.id, { name: "Second" }), + ); + await setDefaultTemplate(ctx(), second.id); + const list = await listTemplates(ctx()); + const defaults = list.filter((t) => t.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0].id).toBe(second.id); + expect(list.find((t) => t.id === first.id)?.isDefault).toBe(false); + }); + + it("getTemplate returns null for missing id", async () => { + await expect( + getTemplate(ctx(), "nonexistent0000"), + ).resolves.toBeNull(); + }); + + it("ensureDefaultTemplates seeds built-ins when empty and does nothing when present", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + + const first = await ensureDefaultTemplates(ctx(), admin.id); + expect(first.seeded).toBe(true); + + const list = await listTemplates(ctx()); + expect(list.length).toBeGreaterThanOrEqual(2); + const defaults = list.filter((t) => t.isDefault); + expect(defaults).toHaveLength(1); + + const second = await ensureDefaultTemplates(ctx(), admin.id); + expect(second.seeded).toBe(false); + }); + + it("getDefaultTemplate returns the current default or the only template", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect(getDefaultTemplate(ctx())).resolves.toBeNull(); + await createTemplate(ctx(), baseTemplate(admin.id)); + const def = await getDefaultTemplate(ctx()); + expect(def?.isDefault).toBe(true); + }); + + it("throws NotFoundError on unknown template update", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + updateTemplate(ctx(), { + ...baseTemplate(admin.id), + templateId: "nonexistent0000", + isDefault: false, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/src/server/domain/__tests__/serviceGroupFields.test.ts b/src/server/domain/__tests__/serviceGroupFields.test.ts new file mode 100644 index 0000000..29c0e85 --- /dev/null +++ b/src/server/domain/__tests__/serviceGroupFields.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "vitest"; + +import { createGroup } from "@/server/domain/serviceGroups"; +import { + createField, + deleteField, + listFields, + reorderFields, + updateField, +} from "@/server/domain/serviceGroupFields"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("serviceGroupFields domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates fields in sort order within a group", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "text", + required: false, + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "Notes", + fieldType: "textarea", + required: false, + actorId: admin.id, + }); + + const fields = await listFields(ctx(), group.id); + expect(fields.map((f) => f.label)).toEqual(["Outcome", "Notes"]); + expect(fields.map((f) => f.sortOrder)).toEqual([0, 1]); + }); + + it("rejects duplicate field labels within a group but allows across groups", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group1 = await createGroup(ctx(), { + name: "Inspection 1", + actorId: admin.id, + }); + const group2 = await createGroup(ctx(), { + name: "Inspection 2", + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group1.id, + label: "Outcome", + fieldType: "text", + required: false, + actorId: admin.id, + }); + await expect( + createField(ctx(), { + groupId: group1.id, + label: " outcome ", + fieldType: "text", + required: false, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ConflictError); + await expect( + createField(ctx(), { + groupId: group2.id, + label: "Outcome", + fieldType: "text", + required: false, + actorId: admin.id, + }), + ).resolves.toBeTruthy(); + }); + + it("validates select fields require at least one option", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + await expect( + createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "select", + required: false, + options: [" "], + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("dedupes and trims select options", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + const field = await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "select", + required: false, + options: [" Pass ", "pass", "Fail"], + actorId: admin.id, + }); + expect(field.options).toEqual(["Pass", "Fail"]); + }); + + it("updates a field and clears options when fieldType is not select", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + const field = await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "select", + required: false, + options: ["Pass", "Fail"], + actorId: admin.id, + }); + + const updated = await updateField(ctx(), { + fieldId: field.id, + label: "Outcome v2", + fieldType: "text", + required: true, + options: [], + actorId: admin.id, + }); + expect(updated).toMatchObject({ + label: "Outcome v2", + fieldType: "text", + required: true, + options: [], + }); + }); + + it("deletes a field and renumbers the remaining sort order", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + const a = await createField(ctx(), { + groupId: group.id, + label: "A", + fieldType: "text", + required: false, + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "B", + fieldType: "text", + required: false, + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "C", + fieldType: "text", + required: false, + actorId: admin.id, + }); + + await deleteField(ctx(), a.id); + const list = await listFields(ctx(), group.id); + expect(list.map((f) => f.label)).toEqual(["B", "C"]); + expect(list.map((f) => f.sortOrder)).toEqual([0, 1]); + }); + + it("reorders fields by ids", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + const a = await createField(ctx(), { + groupId: group.id, + label: "A", + fieldType: "text", + required: false, + actorId: admin.id, + }); + const b = await createField(ctx(), { + groupId: group.id, + label: "B", + fieldType: "text", + required: false, + actorId: admin.id, + }); + const c = await createField(ctx(), { + groupId: group.id, + label: "C", + fieldType: "text", + required: false, + actorId: admin.id, + }); + + await reorderFields(ctx(), { + groupId: group.id, + fieldIds: [c.id, a.id, b.id], + }); + const list = await listFields(ctx(), group.id); + expect(list.map((f) => f.id)).toEqual([c.id, a.id, b.id]); + }); + + it("rejects reorder that omits or duplicates fields", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + const a = await createField(ctx(), { + groupId: group.id, + label: "A", + fieldType: "text", + required: false, + actorId: admin.id, + }); + const b = await createField(ctx(), { + groupId: group.id, + label: "B", + fieldType: "text", + required: false, + actorId: admin.id, + }); + + await expect( + reorderFields(ctx(), { groupId: group.id, fieldIds: [a.id] }), + ).rejects.toBeInstanceOf(ConflictError); + await expect( + reorderFields(ctx(), { + groupId: group.id, + fieldIds: [a.id, a.id, b.id], + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("throws NotFoundError when group is missing on listFields", async () => { + await expect(listFields(ctx(), "nonexistent0000")).rejects.toBeInstanceOf( + NotFoundError, + ); + }); +}); diff --git a/src/server/domain/__tests__/serviceGroups.test.ts b/src/server/domain/__tests__/serviceGroups.test.ts new file mode 100644 index 0000000..4f19544 --- /dev/null +++ b/src/server/domain/__tests__/serviceGroups.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; + +import { + createGroup, + deleteGroup, + getGroup, + listAssignableGroups, + listGroups, + listGroupAssets, + updateGroup, +} from "@/server/domain/serviceGroups"; +import { + createField, +} from "@/server/domain/serviceGroupFields"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("serviceGroups domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates, reads, updates and lists a group", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const created = await createGroup(ctx(), { + name: " Annual inspection ", + description: " mandatory ", + actorId: admin.id, + }); + expect(created).toMatchObject({ + name: "Annual inspection", + description: "mandatory", + }); + + const fetched = await getGroup(ctx(), created.id); + expect(fetched?.id).toBe(created.id); + + const updated = await updateGroup(ctx(), { + groupId: created.id, + name: "Annual inspection v2", + description: null, + actorId: admin.id, + }); + expect(updated.description).toBeNull(); + + const list = await listGroups(ctx()); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ assetCount: 0, fieldCount: 0 }); + }); + + it("rejects duplicate names regardless of case", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createGroup(ctx(), { name: "Inspection", actorId: admin.id }); + await expect( + createGroup(ctx(), { name: " INSPECTION ", actorId: admin.id }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("rejects empty names", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createGroup(ctx(), { name: " ", actorId: admin.id }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("includes asset and field counts in summary", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "text", + required: false, + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "Notes", + fieldType: "textarea", + required: false, + actorId: admin.id, + }); + + await pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: "AST-0001", + status: "active", + serviceGroupId: group.id, + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const [summary] = await listGroups(ctx()); + expect(summary).toMatchObject({ assetCount: 1, fieldCount: 2 }); + }); + + it("returns assets attached to a group sorted by name", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + for (const [i, name] of ["Zeta", "alpha", "Mike"].entries()) { + await pb.collection("assets").create({ + name, + normalizedName: name.toLowerCase(), + assetTag: `AST-${i}`, + status: "active", + serviceGroupId: group.id, + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } + const assets = await listGroupAssets(ctx(), group.id); + expect(assets.map((a) => a.name)).toEqual(["alpha", "Mike", "Zeta"]); + }); + + it("refuses to delete a group that assets reference", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + await pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: "AST-0001", + status: "active", + serviceGroupId: group.id, + customFieldValues: {}, + createdBy: admin.id, + updatedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + await expect(deleteGroup(ctx(), group.id)).rejects.toBeInstanceOf( + ConflictError, + ); + }); + + it("deletes a group and cascades its fields", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "text", + required: false, + actorId: admin.id, + }); + await expect(deleteGroup(ctx(), group.id)).resolves.toBeUndefined(); + const fields = await pb + .collection("serviceGroupFields") + .getFullList({ filter: `groupId = "${group.id}"` }); + expect(fields).toHaveLength(0); + }); + + it("throws NotFoundError on unknown group", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + updateGroup(ctx(), { + groupId: "nonexistent0000", + name: "x", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + deleteGroup(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + listGroupAssets(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("listAssignableGroups returns simple name/id tuples", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createGroup(ctx(), { name: "b", actorId: admin.id }); + await createGroup(ctx(), { name: "A", actorId: admin.id }); + const list = await listAssignableGroups(ctx()); + expect(list.map((g) => g.name)).toEqual(["A", "b"]); + expect(list[0]).toHaveProperty("id"); + }); +}); diff --git a/src/server/domain/__tests__/serviceProviders.test.ts b/src/server/domain/__tests__/serviceProviders.test.ts new file mode 100644 index 0000000..b570aba --- /dev/null +++ b/src/server/domain/__tests__/serviceProviders.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { + createProvider, + deleteProvider, + listProviderOptions, + listProviders, + updateProvider, +} from "@/server/domain/serviceProviders"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +async function seedAsset(pb: Ctx["pb"], adminId: string) { + return pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: `AST-${Math.random().toString(36).slice(2, 6).toUpperCase()}`, + status: "active", + customFieldValues: {}, + createdBy: adminId, + updatedBy: adminId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); +} + +describe("serviceProviders domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates a provider with normalized fields", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const provider = await createProvider(ctx(), { + name: " Acme Repair ", + contactEmail: " repairs@acme.test ", + contactPhone: " +1-555-1234 ", + notes: null, + actorId: admin.id, + }); + + expect(provider).toMatchObject({ + name: "Acme Repair", + contactEmail: "repairs@acme.test", + contactPhone: "+1-555-1234", + notes: null, + }); + }); + + it("rejects empty names", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createProvider(ctx(), { name: " ", actorId: admin.id }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("lists providers sorted by name (case-insensitive)", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createProvider(ctx(), { name: "zeta", actorId: admin.id }); + await createProvider(ctx(), { name: "Alpha", actorId: admin.id }); + await createProvider(ctx(), { name: "beta", actorId: admin.id }); + + const list = await listProviders(ctx()); + expect(list.map((p) => p.name)).toEqual(["Alpha", "beta", "zeta"]); + + const options = await listProviderOptions(ctx()); + expect(options.map((p) => p.name)).toEqual(["Alpha", "beta", "zeta"]); + }); + + it("rejects duplicate names regardless of case", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createProvider(ctx(), { name: "Acme", actorId: admin.id }); + await expect( + createProvider(ctx(), { name: " ACME ", actorId: admin.id }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("updates an existing provider", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const provider = await createProvider(ctx(), { + name: "Acme", + actorId: admin.id, + }); + const updated = await updateProvider(ctx(), { + providerId: provider.id, + name: "Acme Service", + contactEmail: "service@acme.test", + actorId: admin.id, + }); + expect(updated).toMatchObject({ + name: "Acme Service", + contactEmail: "service@acme.test", + contactPhone: null, + }); + }); + + it("throws NotFoundError when updating a missing provider", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + updateProvider(ctx(), { + providerId: "nonexistent0000", + name: "Anything", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("refuses to delete a provider referenced by a service record", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const provider = await createProvider(ctx(), { + name: "Acme", + actorId: admin.id, + }); + const asset = await seedAsset(pb, admin.id); + + await pb.collection("serviceRecords").create({ + assetId: asset.id, + values: {}, + providerId: provider.id, + completedAt: Date.now(), + completedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await expect( + deleteProvider(ctx(), provider.id), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("deletes an unused provider", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const provider = await createProvider(ctx(), { + name: "Temp", + actorId: admin.id, + }); + await expect(deleteProvider(ctx(), provider.id)).resolves.toBeUndefined(); + await expect(listProviders(ctx())).resolves.toEqual([]); + }); +}); diff --git a/src/server/domain/appSettings.ts b/src/server/domain/appSettings.ts new file mode 100644 index 0000000..5554b7d --- /dev/null +++ b/src/server/domain/appSettings.ts @@ -0,0 +1,149 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import { + APP_DATE_FORMATS, + type AppDateFormat, +} from "@/server/pb/custom-fields"; +import type { Ctx } from "@/server/pb/context"; +import { ValidationError } from "@/server/pb/errors"; + +const DEFAULT_DATE_FORMAT: AppDateFormat = "DD-MM-YYYY"; +const DEFAULT_SERVICE_SCHEDULING_ENABLED = true; + +const dateFormatSchema = z.enum(APP_DATE_FORMATS); + +export const UpdateDateFormatInput = z.object({ + dateFormat: dateFormatSchema, + actorId: z.string(), +}); +export const UpdateServiceSchedulingEnabledInput = z.object({ + enabled: z.boolean(), + actorId: z.string(), +}); + +export type AppSettingsView = { + dateFormat: AppDateFormat; + serviceSchedulingEnabled: boolean; + updatedAt: number | null; +}; + +type AppSettingsRecord = { + id: string; + key: "global"; + dateFormat: AppDateFormat; + serviceSchedulingEnabled?: boolean | null; + updatedAt: number; + updatedBy: string; +}; + +function toView(record: AppSettingsRecord | null): AppSettingsView { + if (!record) { + return { + dateFormat: DEFAULT_DATE_FORMAT, + serviceSchedulingEnabled: DEFAULT_SERVICE_SCHEDULING_ENABLED, + updatedAt: null, + }; + } + return { + dateFormat: record.dateFormat, + serviceSchedulingEnabled: + record.serviceSchedulingEnabled ?? DEFAULT_SERVICE_SCHEDULING_ENABLED, + updatedAt: record.updatedAt, + }; +} + +async function getGlobalRecord( + ctx: Ctx, +): Promise { + try { + return await ctx.pb + .collection("appSettings") + .getFirstListItem('key = "global"'); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +export async function getAppSettings(ctx: Ctx): Promise { + return toView(await getGlobalRecord(ctx)); +} + +export async function updateDateFormat( + ctx: Ctx, + input: z.infer, +): Promise { + const parsed = UpdateDateFormatInput.parse(input); + const existing = await getGlobalRecord(ctx); + const now = Date.now(); + const nextEnabled = + existing?.serviceSchedulingEnabled ?? DEFAULT_SERVICE_SCHEDULING_ENABLED; + + if (existing) { + await ctx.pb.collection("appSettings").update(existing.id, { + dateFormat: parsed.dateFormat, + serviceSchedulingEnabled: nextEnabled, + updatedAt: now, + updatedBy: parsed.actorId, + }); + } else { + try { + await ctx.pb.collection("appSettings").create({ + key: "global", + dateFormat: parsed.dateFormat, + serviceSchedulingEnabled: nextEnabled, + updatedAt: now, + updatedBy: parsed.actorId, + }); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 400) { + throw new ValidationError( + "Failed to create app settings — check updatedBy user exists", + ); + } + throw error; + } + } + + return { + dateFormat: parsed.dateFormat, + serviceSchedulingEnabled: nextEnabled, + updatedAt: now, + }; +} + +export async function updateServiceSchedulingEnabled( + ctx: Ctx, + input: z.infer, +): Promise { + const parsed = UpdateServiceSchedulingEnabledInput.parse(input); + const existing = await getGlobalRecord(ctx); + const now = Date.now(); + const dateFormat = existing?.dateFormat ?? DEFAULT_DATE_FORMAT; + + if (existing) { + await ctx.pb.collection("appSettings").update(existing.id, { + dateFormat, + serviceSchedulingEnabled: parsed.enabled, + updatedAt: now, + updatedBy: parsed.actorId, + }); + } else { + await ctx.pb.collection("appSettings").create({ + key: "global", + dateFormat, + serviceSchedulingEnabled: parsed.enabled, + updatedAt: now, + updatedBy: parsed.actorId, + }); + } + + return { + dateFormat, + serviceSchedulingEnabled: parsed.enabled, + updatedAt: now, + }; +} diff --git a/src/server/domain/labelTemplates.ts b/src/server/domain/labelTemplates.ts new file mode 100644 index 0000000..a618aa0 --- /dev/null +++ b/src/server/domain/labelTemplates.ts @@ -0,0 +1,354 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + createDefaultLabelTemplateDefinitions, + LABEL_ELEMENT_TYPES, + LABEL_FONT_WEIGHT_OPTIONS, + LABEL_TEXT_ALIGN_OPTIONS, + type LabelTemplateElement, + type LabelTemplateElementInput, + normalizeLabelTemplateInput, +} from "@/server/pb/label-templates"; + +const labelElementSchema = z.object({ + id: z.string(), + type: z.enum(LABEL_ELEMENT_TYPES), + xMm: z.number(), + yMm: z.number(), + widthMm: z.number(), + heightMm: z.number(), + fontSize: z.number().optional(), + fontWeight: z.enum(LABEL_FONT_WEIGHT_OPTIONS).optional(), + textAlign: z.enum(LABEL_TEXT_ALIGN_OPTIONS).optional(), + fieldId: z.string().nullish(), + text: z.string().nullish(), + uppercase: z.boolean().optional(), +}); + +export const CreateTemplateInput = z.object({ + name: z.string(), + widthMm: z.number(), + heightMm: z.number(), + elements: z.array(labelElementSchema), + isDefault: z.boolean(), + actorId: z.string(), +}); + +export const UpdateTemplateInput = CreateTemplateInput.extend({ + templateId: z.string(), +}); + +export type CreateTemplateInput = z.infer; +export type UpdateTemplateInput = z.infer; + +export type LabelTemplateView = { + id: string; + name: string; + widthMm: number; + heightMm: number; + elements: LabelTemplateElement[]; + isDefault: boolean; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +type LabelTemplateRecord = { + id: string; + name: string; + normalizedName: string; + widthMm: number; + heightMm: number; + elements: LabelTemplateElement[] | null; + isDefault: boolean; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +function toView(record: LabelTemplateRecord): LabelTemplateView { + return { + id: record.id, + name: record.name, + widthMm: record.widthMm, + heightMm: record.heightMm, + elements: Array.isArray(record.elements) ? record.elements : [], + isDefault: !!record.isDefault, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }; +} + +function sortTemplates(records: LabelTemplateRecord[]) { + return records.slice().sort((a, b) => { + if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); +} + +async function listAll(ctx: Ctx) { + return ctx.pb + .collection("labelTemplates") + .getFullList(); +} + +async function loadTemplate( + ctx: Ctx, + templateId: string, +): Promise { + try { + return await ctx.pb + .collection("labelTemplates") + .getOne(templateId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Label template not found"); + } + throw error; + } +} + +async function assertUniqueName( + ctx: Ctx, + normalizedName: string, + excludeId?: string, +) { + const matches = await ctx.pb + .collection("labelTemplates") + .getList(1, 2, { + filter: `normalizedName = "${normalizedName.replace(/"/g, '\\"')}"`, + }); + const duplicate = matches.items.find((row) => row.id !== excludeId); + if (duplicate) { + throw new ConflictError( + "A label template with this name already exists", + ); + } +} + +async function ensureCustomFieldsExist( + ctx: Ctx, + elements: LabelTemplateElement[], +) { + const ids = Array.from( + new Set( + elements + .filter((element) => element.type === "customField") + .map((element) => element.fieldId) + .filter((id): id is string => !!id), + ), + ); + for (const id of ids) { + try { + await ctx.pb.collection("customFieldDefinitions").getOne(id); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError(`Referenced custom field is missing: ${id}`); + } + throw error; + } + } +} + +async function patchDefaultState(ctx: Ctx, templateId: string) { + const currentDefault = await ctx.pb + .collection("labelTemplates") + .getList(1, 1, { filter: "isDefault = true" }); + const current = currentDefault.items[0]; + if (current && current.id !== templateId) { + await ctx.pb + .collection("labelTemplates") + .update(current.id, { isDefault: false }); + } + await ctx.pb + .collection("labelTemplates") + .update(templateId, { isDefault: true }); +} + +export async function listTemplates( + ctx: Ctx, +): Promise { + return sortTemplates(await listAll(ctx)).map(toView); +} + +export async function getTemplate( + ctx: Ctx, + templateId: string, +): Promise { + try { + const record = await ctx.pb + .collection("labelTemplates") + .getOne(templateId); + return toView(record); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +export async function getDefaultTemplate( + ctx: Ctx, +): Promise { + const templates = sortTemplates(await listAll(ctx)); + const template = + templates.find((candidate) => candidate.isDefault) ?? + templates[0] ?? + null; + return template ? toView(template) : null; +} + +export function getLabelUrlBase(): string | null { + const siteUrl = process.env.SITE_URL?.trim(); + return siteUrl ? siteUrl : null; +} + +export async function ensureDefaultTemplates( + ctx: Ctx, + actorId: string, +): Promise<{ seeded: boolean }> { + const existing = await listAll(ctx); + const byName = new Map(existing.map((t) => [t.normalizedName, t])); + const now = Date.now(); + let seeded = false; + let defaultId = + existing.find((t) => t.isDefault)?.id ?? null; + + for (const template of createDefaultLabelTemplateDefinitions()) { + const match = byName.get(template.normalizedName); + if (match) { + if (template.isDefault && !defaultId) defaultId = match.id; + continue; + } + const created = await ctx.pb + .collection("labelTemplates") + .create({ + name: template.name, + normalizedName: template.normalizedName, + widthMm: template.widthMm, + heightMm: template.heightMm, + elements: template.elements, + isDefault: false, + createdAt: now, + updatedAt: now, + createdBy: actorId, + updatedBy: actorId, + }); + byName.set(template.normalizedName, created); + seeded = true; + if (template.isDefault && !defaultId) defaultId = created.id; + } + + if (!defaultId) { + const fallback = + byName.get("thermal 57x32 mm") ?? + Array.from(byName.values())[0] ?? + null; + defaultId = fallback?.id ?? null; + } + + if (defaultId) await patchDefaultState(ctx, defaultId); + return { seeded }; +} + +export async function createTemplate( + ctx: Ctx, + input: CreateTemplateInput, +): Promise { + const parsed = CreateTemplateInput.parse(input); + const normalized = normalizeLabelTemplateInput({ + name: parsed.name, + widthMm: parsed.widthMm, + heightMm: parsed.heightMm, + elements: parsed.elements as LabelTemplateElementInput[], + }); + await ensureCustomFieldsExist(ctx, normalized.elements); + await assertUniqueName(ctx, normalized.normalizedName); + + const all = await listAll(ctx); + const now = Date.now(); + const isDefault = parsed.isDefault || all.length === 0; + + const record = await ctx.pb + .collection("labelTemplates") + .create({ + ...normalized, + isDefault, + createdAt: now, + updatedAt: now, + createdBy: parsed.actorId, + updatedBy: parsed.actorId, + }); + + if (isDefault) await patchDefaultState(ctx, record.id); + + return toView({ ...record, isDefault }); +} + +export async function updateTemplate( + ctx: Ctx, + input: UpdateTemplateInput, +): Promise { + const parsed = UpdateTemplateInput.parse(input); + const template = await loadTemplate(ctx, parsed.templateId); + const normalized = normalizeLabelTemplateInput({ + name: parsed.name, + widthMm: parsed.widthMm, + heightMm: parsed.heightMm, + elements: parsed.elements as LabelTemplateElementInput[], + }); + await ensureCustomFieldsExist(ctx, normalized.elements); + await assertUniqueName(ctx, normalized.normalizedName, template.id); + + if (template.isDefault && !parsed.isDefault) { + throw new ConflictError( + "Choose another default template before clearing this default", + ); + } + + const updated = await ctx.pb + .collection("labelTemplates") + .update(template.id, { + ...normalized, + isDefault: parsed.isDefault, + updatedAt: Date.now(), + updatedBy: parsed.actorId, + }); + + if (parsed.isDefault) await patchDefaultState(ctx, template.id); + + return toView(updated); +} + +export async function deleteTemplate( + ctx: Ctx, + templateId: string, +): Promise { + const template = await loadTemplate(ctx, templateId); + if (template.isDefault) { + throw new ConflictError( + "The default label template cannot be deleted", + ); + } + await ctx.pb.collection("labelTemplates").delete(template.id); +} + +export async function setDefaultTemplate( + ctx: Ctx, + templateId: string, +): Promise { + const template = await loadTemplate(ctx, templateId); + await patchDefaultState(ctx, template.id); +} diff --git a/src/server/domain/serviceGroupFields.ts b/src/server/domain/serviceGroupFields.ts new file mode 100644 index 0000000..38fbd6d --- /dev/null +++ b/src/server/domain/serviceGroupFields.ts @@ -0,0 +1,265 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { ConflictError, NotFoundError } from "@/server/pb/errors"; +import { + SERVICE_GROUP_FIELD_TYPES, + normalizeServiceFieldInput, + type ServiceGroupFieldType, +} from "@/server/pb/service-catalog"; + +const fieldTypeSchema = z.enum(SERVICE_GROUP_FIELD_TYPES); + +export const CreateFieldInput = z.object({ + groupId: z.string(), + label: z.string(), + fieldType: fieldTypeSchema, + required: z.boolean(), + options: z.array(z.string()).optional(), + actorId: z.string(), +}); + +export const UpdateFieldInput = CreateFieldInput.omit({ groupId: true }).extend({ + fieldId: z.string(), +}); + +export const ReorderFieldsInput = z.object({ + groupId: z.string(), + fieldIds: z.array(z.string()), +}); + +export type CreateFieldInput = z.infer; +export type UpdateFieldInput = z.infer; + +export type FieldView = { + id: string; + groupId: string; + label: string; + fieldType: ServiceGroupFieldType; + required: boolean; + options: string[]; + sortOrder: number; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +type FieldRecord = { + id: string; + groupId: string; + label: string; + normalizedLabel: string; + fieldType: ServiceGroupFieldType; + required: boolean; + options: string[] | null; + sortOrder: number; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +function toFieldView(record: FieldRecord): FieldView { + return { + id: record.id, + groupId: record.groupId, + label: record.label, + fieldType: record.fieldType, + required: !!record.required, + options: Array.isArray(record.options) ? record.options : [], + sortOrder: record.sortOrder, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }; +} + +async function assertGroupExists(ctx: Ctx, groupId: string) { + try { + await ctx.pb.collection("serviceGroups").getOne(groupId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service group not found"); + } + throw error; + } +} + +async function loadField(ctx: Ctx, fieldId: string): Promise { + try { + return await ctx.pb + .collection("serviceGroupFields") + .getOne(fieldId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service group field not found"); + } + throw error; + } +} + +async function assertUniqueLabel( + ctx: Ctx, + groupId: string, + normalizedLabel: string, + excludeId?: string, +) { + const escaped = normalizedLabel.replace(/"/g, '\\"'); + const matches = await ctx.pb + .collection("serviceGroupFields") + .getList(1, 2, { + filter: `groupId = "${groupId}" && normalizedLabel = "${escaped}"`, + }); + const duplicate = matches.items.find((row) => row.id !== excludeId); + if (duplicate) { + throw new ConflictError( + "Field labels must be unique within a service group", + ); + } +} + +export async function listFields( + ctx: Ctx, + groupId: string, +): Promise { + await assertGroupExists(ctx, groupId); + const records = await ctx.pb + .collection("serviceGroupFields") + .getFullList({ + filter: `groupId = "${groupId}"`, + sort: "sortOrder", + }); + return records.map(toFieldView); +} + +export async function createField( + ctx: Ctx, + input: CreateFieldInput, +): Promise { + const parsed = CreateFieldInput.parse(input); + await assertGroupExists(ctx, parsed.groupId); + + const normalized = normalizeServiceFieldInput({ + label: parsed.label, + fieldType: parsed.fieldType, + options: parsed.options ?? [], + }); + await assertUniqueLabel(ctx, parsed.groupId, normalized.normalizedLabel); + + const existing = await ctx.pb + .collection("serviceGroupFields") + .getList(1, 1, { + filter: `groupId = "${parsed.groupId}"`, + sort: "-sortOrder", + }); + const nextSortOrder = existing.items[0] + ? existing.items[0].sortOrder + 1 + : 0; + + const now = Date.now(); + const record = await ctx.pb + .collection("serviceGroupFields") + .create({ + groupId: parsed.groupId, + label: normalized.label, + normalizedLabel: normalized.normalizedLabel, + fieldType: parsed.fieldType, + required: parsed.required, + options: normalized.options, + sortOrder: nextSortOrder, + createdAt: now, + updatedAt: now, + createdBy: parsed.actorId, + updatedBy: parsed.actorId, + }); + + return toFieldView(record); +} + +export async function updateField( + ctx: Ctx, + input: UpdateFieldInput, +): Promise { + const parsed = UpdateFieldInput.parse(input); + const field = await loadField(ctx, parsed.fieldId); + + const normalized = normalizeServiceFieldInput({ + label: parsed.label, + fieldType: parsed.fieldType, + options: parsed.options ?? [], + }); + await assertUniqueLabel( + ctx, + field.groupId, + normalized.normalizedLabel, + field.id, + ); + + const record = await ctx.pb + .collection("serviceGroupFields") + .update(field.id, { + label: normalized.label, + normalizedLabel: normalized.normalizedLabel, + fieldType: parsed.fieldType, + required: parsed.required, + options: normalized.options, + updatedAt: Date.now(), + updatedBy: parsed.actorId, + }); + return toFieldView(record); +} + +export async function deleteField(ctx: Ctx, fieldId: string): Promise { + const field = await loadField(ctx, fieldId); + await ctx.pb.collection("serviceGroupFields").delete(field.id); + + // Renumber remaining fields in the same group so sortOrder stays dense. + const remaining = await ctx.pb + .collection("serviceGroupFields") + .getFullList({ + filter: `groupId = "${field.groupId}"`, + sort: "sortOrder", + }); + for (const [index, remainingField] of remaining.entries()) { + if (remainingField.sortOrder !== index) { + await ctx.pb + .collection("serviceGroupFields") + .update(remainingField.id, { sortOrder: index }); + } + } +} + +export async function reorderFields( + ctx: Ctx, + input: z.infer, +): Promise { + const parsed = ReorderFieldsInput.parse(input); + const fields = await ctx.pb + .collection("serviceGroupFields") + .getFullList({ filter: `groupId = "${parsed.groupId}"` }); + + if (parsed.fieldIds.length !== fields.length) { + throw new ConflictError("Field order must include all fields"); + } + const existingIds = new Set(fields.map((f) => f.id)); + const providedIds = new Set(parsed.fieldIds); + if ( + existingIds.size !== providedIds.size || + ![...existingIds].every((id) => providedIds.has(id)) + ) { + throw new ConflictError("Field order contains invalid fields"); + } + + const currentSortById = new Map( + fields.map((f) => [f.id, f.sortOrder] as const), + ); + for (const [index, fieldId] of parsed.fieldIds.entries()) { + if (currentSortById.get(fieldId) === index) continue; + await ctx.pb + .collection("serviceGroupFields") + .update(fieldId, { sortOrder: index }); + } +} diff --git a/src/server/domain/serviceGroups.ts b/src/server/domain/serviceGroups.ts new file mode 100644 index 0000000..9f00b3e --- /dev/null +++ b/src/server/domain/serviceGroups.ts @@ -0,0 +1,282 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + normalizeServiceName, + normalizeServiceNameKey, +} from "@/server/pb/service-catalog"; + +export const CreateGroupInput = z.object({ + name: z.string(), + description: z.string().nullish(), + actorId: z.string(), +}); + +export const UpdateGroupInput = CreateGroupInput.extend({ + groupId: z.string(), +}); + +export type CreateGroupInput = z.infer; +export type UpdateGroupInput = z.infer; + +export type GroupView = { + id: string; + name: string; + description: string | null; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +export type GroupSummary = GroupView & { + assetCount: number; + fieldCount: number; +}; + +export type AssignableGroup = { id: string; name: string }; + +export type GroupAsset = { + id: string; + name: string; + assetTag: string; + status: string; +}; + +type GroupRecord = { + id: string; + name: string; + normalizedName: string; + description: string; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +function toGroupView(record: GroupRecord): GroupView { + return { + id: record.id, + name: record.name, + description: record.description || null, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }; +} + +function sortByName(rows: T[]) { + return rows + .slice() + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); +} + +async function assertUniqueGroupName( + ctx: Ctx, + normalizedName: string, + excludeId?: string, +) { + const matches = await ctx.pb + .collection("serviceGroups") + .getList(1, 2, { + filter: `normalizedName = "${normalizedName.replace(/"/g, '\\"')}"`, + }); + const duplicate = matches.items.find((row) => row.id !== excludeId); + if (duplicate) { + throw new ConflictError( + "A service group with this name already exists", + ); + } +} + +async function loadGroup(ctx: Ctx, groupId: string): Promise { + try { + return await ctx.pb + .collection("serviceGroups") + .getOne(groupId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service group not found"); + } + throw error; + } +} + +function normalizeGroupDescription(value: string | null | undefined) { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return ""; + if (trimmed.length > 2000) { + throw new ValidationError( + "Description must be 2000 characters or fewer", + ); + } + return trimmed; +} + +export async function listGroups(ctx: Ctx): Promise { + const records = await ctx.pb + .collection("serviceGroups") + .getFullList(); + const sorted = sortByName(records); + + const [assetCounts, fieldCounts] = await Promise.all([ + Promise.all( + sorted.map(async (group) => { + const result = await ctx.pb + .collection("assets") + .getList(1, 1, { filter: `serviceGroupId = "${group.id}"` }); + return result.totalItems; + }), + ), + Promise.all( + sorted.map(async (group) => { + const result = await ctx.pb + .collection("serviceGroupFields") + .getList(1, 1, { filter: `groupId = "${group.id}"` }); + return result.totalItems; + }), + ), + ]); + + return sorted.map((group, index) => ({ + ...toGroupView(group), + assetCount: assetCounts[index] ?? 0, + fieldCount: fieldCounts[index] ?? 0, + })); +} + +export async function listAssignableGroups( + ctx: Ctx, +): Promise { + const records = await ctx.pb + .collection("serviceGroups") + .getFullList(); + return sortByName(records).map((row) => ({ id: row.id, name: row.name })); +} + +export async function getGroup( + ctx: Ctx, + groupId: string, +): Promise { + try { + const record = await ctx.pb + .collection("serviceGroups") + .getOne(groupId); + return toGroupView(record); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +export async function createGroup( + ctx: Ctx, + input: CreateGroupInput, +): Promise { + const parsed = CreateGroupInput.parse(input); + const name = normalizeServiceName(parsed.name); + if (!name) throw new ValidationError("Group name is required"); + const normalizedName = normalizeServiceNameKey(name); + await assertUniqueGroupName(ctx, normalizedName); + const description = normalizeGroupDescription(parsed.description); + + const now = Date.now(); + const record = await ctx.pb + .collection("serviceGroups") + .create({ + name, + normalizedName, + description, + createdAt: now, + updatedAt: now, + createdBy: parsed.actorId, + updatedBy: parsed.actorId, + }); + return toGroupView(record); +} + +export async function updateGroup( + ctx: Ctx, + input: UpdateGroupInput, +): Promise { + const parsed = UpdateGroupInput.parse(input); + const existing = await loadGroup(ctx, parsed.groupId); + const name = normalizeServiceName(parsed.name); + if (!name) throw new ValidationError("Group name is required"); + const normalizedName = normalizeServiceNameKey(name); + await assertUniqueGroupName(ctx, normalizedName, existing.id); + const description = normalizeGroupDescription(parsed.description); + + const record = await ctx.pb + .collection("serviceGroups") + .update(existing.id, { + name, + normalizedName, + description, + updatedAt: Date.now(), + updatedBy: parsed.actorId, + }); + return toGroupView(record); +} + +export async function deleteGroup(ctx: Ctx, groupId: string): Promise { + const group = await loadGroup(ctx, groupId); + + const [assetUse, recordUse] = await Promise.all([ + ctx.pb + .collection("assets") + .getList(1, 1, { filter: `serviceGroupId = "${group.id}"` }), + ctx.pb + .collection("serviceRecords") + .getList(1, 1, { filter: `serviceGroupId = "${group.id}"` }), + ]); + if (assetUse.totalItems > 0 || recordUse.totalItems > 0) { + throw new ConflictError( + "This service group is in use and cannot be deleted", + ); + } + + // Fields cascade-delete via the PB relation definition, but do it explicitly + // in case the cascade behavior changes. + const fields = await ctx.pb + .collection("serviceGroupFields") + .getFullList<{ id: string }>({ + filter: `groupId = "${group.id}"`, + }); + for (const field of fields) { + await ctx.pb.collection("serviceGroupFields").delete(field.id); + } + await ctx.pb.collection("serviceGroups").delete(group.id); +} + +export async function listGroupAssets( + ctx: Ctx, + groupId: string, +): Promise { + await loadGroup(ctx, groupId); + const assets = await ctx.pb + .collection("assets") + .getFullList<{ + id: string; + name: string; + assetTag: string; + status: string; + }>({ filter: `serviceGroupId = "${groupId}"` }); + return sortByName(assets).map((asset) => ({ + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + status: asset.status, + })); +} diff --git a/src/server/domain/serviceProviders.ts b/src/server/domain/serviceProviders.ts new file mode 100644 index 0000000..cfa251b --- /dev/null +++ b/src/server/domain/serviceProviders.ts @@ -0,0 +1,201 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + normalizeOptionalServiceText, + normalizeServiceName, + normalizeServiceNameKey, +} from "@/server/pb/service-catalog"; + +export const CreateProviderInput = z.object({ + name: z.string(), + contactEmail: z.string().nullish(), + contactPhone: z.string().nullish(), + notes: z.string().nullish(), + actorId: z.string(), +}); + +export const UpdateProviderInput = CreateProviderInput.extend({ + providerId: z.string(), +}); + +export type CreateProviderInput = z.infer; +export type UpdateProviderInput = z.infer; + +export type ProviderView = { + id: string; + name: string; + contactEmail: string | null; + contactPhone: string | null; + notes: string | null; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +export type ProviderOption = { + id: string; + name: string; +}; + +type ProviderRecord = { + id: string; + name: string; + normalizedName: string; + contactEmail: string; + contactPhone: string; + notes: string; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +function toView(record: ProviderRecord): ProviderView { + return { + id: record.id, + name: record.name, + contactEmail: record.contactEmail || null, + contactPhone: record.contactPhone || null, + notes: record.notes || null, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }; +} + +function sortByName(rows: T[]) { + return rows + .slice() + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); +} + +async function assertUniqueName( + ctx: Ctx, + normalizedName: string, + excludeId?: string, +) { + const matches = await ctx.pb + .collection("serviceProviders") + .getList(1, 2, { + filter: `normalizedName = "${normalizedName.replace(/"/g, '\\"')}"`, + }); + const duplicate = matches.items.find((row) => row.id !== excludeId); + if (duplicate) { + throw new ConflictError( + "A service provider with this name already exists", + ); + } +} + +async function loadProvider( + ctx: Ctx, + providerId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceProviders") + .getOne(providerId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service provider not found"); + } + throw error; + } +} + +export async function listProviders(ctx: Ctx): Promise { + const records = await ctx.pb + .collection("serviceProviders") + .getFullList(); + return sortByName(records).map(toView); +} + +export async function listProviderOptions( + ctx: Ctx, +): Promise { + const records = await ctx.pb + .collection("serviceProviders") + .getFullList(); + return sortByName(records).map((row) => ({ id: row.id, name: row.name })); +} + +export async function createProvider( + ctx: Ctx, + input: CreateProviderInput, +): Promise { + const parsed = CreateProviderInput.parse(input); + const name = normalizeServiceName(parsed.name); + if (!name) throw new ValidationError("Provider name is required"); + const normalizedName = normalizeServiceNameKey(name); + await assertUniqueName(ctx, normalizedName); + + const now = Date.now(); + const record = await ctx.pb + .collection("serviceProviders") + .create({ + name, + normalizedName, + contactEmail: normalizeOptionalServiceText(parsed.contactEmail) ?? "", + contactPhone: normalizeOptionalServiceText(parsed.contactPhone) ?? "", + notes: normalizeOptionalServiceText(parsed.notes) ?? "", + createdAt: now, + updatedAt: now, + createdBy: parsed.actorId, + updatedBy: parsed.actorId, + }); + + return toView(record); +} + +export async function updateProvider( + ctx: Ctx, + input: UpdateProviderInput, +): Promise { + const parsed = UpdateProviderInput.parse(input); + const existing = await loadProvider(ctx, parsed.providerId); + const name = normalizeServiceName(parsed.name); + if (!name) throw new ValidationError("Provider name is required"); + const normalizedName = normalizeServiceNameKey(name); + await assertUniqueName(ctx, normalizedName, existing.id); + + const record = await ctx.pb + .collection("serviceProviders") + .update(existing.id, { + name, + normalizedName, + contactEmail: normalizeOptionalServiceText(parsed.contactEmail) ?? "", + contactPhone: normalizeOptionalServiceText(parsed.contactPhone) ?? "", + notes: normalizeOptionalServiceText(parsed.notes) ?? "", + updatedAt: Date.now(), + updatedBy: parsed.actorId, + }); + + return toView(record); +} + +export async function deleteProvider( + ctx: Ctx, + providerId: string, +): Promise { + const provider = await loadProvider(ctx, providerId); + const inUse = await ctx.pb + .collection("serviceRecords") + .getList(1, 1, { filter: `providerId = "${provider.id}"` }); + if (inUse.totalItems > 0) { + throw new ConflictError( + "This service provider is in use and cannot be deleted", + ); + } + await ctx.pb.collection("serviceProviders").delete(provider.id); +} diff --git a/src/server/pb/label-templates.ts b/src/server/pb/label-templates.ts new file mode 100644 index 0000000..6776c80 --- /dev/null +++ b/src/server/pb/label-templates.ts @@ -0,0 +1,280 @@ +import { ValidationError } from "./errors"; + +export const LABEL_ELEMENT_TYPES = [ + "assetName", + "assetTag", + "category", + "location", + "customField", + "staticText", + "barcode", + "dataMatrix", +] as const; + +export const LABEL_TEXT_ALIGN_OPTIONS = ["left", "center", "right"] as const; +export const LABEL_FONT_WEIGHT_OPTIONS = [ + "normal", + "medium", + "semibold", + "bold", +] as const; + +export type LabelElementType = (typeof LABEL_ELEMENT_TYPES)[number]; +export type LabelTextAlign = (typeof LABEL_TEXT_ALIGN_OPTIONS)[number]; +export type LabelFontWeight = (typeof LABEL_FONT_WEIGHT_OPTIONS)[number]; + +export type LabelTemplateElementInput = { + id: string; + type: LabelElementType; + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; + fontSize?: number; + fontWeight?: LabelFontWeight; + textAlign?: LabelTextAlign; + fieldId?: string | null; + text?: string | null; + uppercase?: boolean; +}; + +export type LabelTemplateElement = Required< + Pick< + LabelTemplateElementInput, + "id" | "type" | "xMm" | "yMm" | "widthMm" | "heightMm" + > +> & { + fontSize?: number; + fontWeight?: LabelFontWeight; + textAlign?: LabelTextAlign; + fieldId: string | null; + text: string | null; + uppercase?: true; +}; + +export function normalizeLabelTemplateName(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +export function normalizeLabelTemplateNameKey(value: string) { + return normalizeLabelTemplateName(value).toLocaleLowerCase(); +} + +function ensureFiniteNumber(value: number, message: string) { + if (!Number.isFinite(value)) throw new ValidationError(message); +} + +function normalizeDimension(value: number, label: string) { + ensureFiniteNumber(value, `${label} must be a number`); + if (value <= 0 || value > 200) { + throw new ValidationError( + `${label} must be greater than 0 and at most 200 mm`, + ); + } + return Number(value.toFixed(2)); +} + +function normalizeFontSize(value: number | undefined) { + if (value === undefined) return undefined; + ensureFiniteNumber(value, "Font size must be a number"); + if (value <= 0 || value > 72) { + throw new ValidationError( + "Font size must be greater than 0 and at most 72", + ); + } + return Number(value.toFixed(2)); +} + +export function normalizeLabelTemplateElements({ + elements, + widthMm, + heightMm, +}: { + elements: LabelTemplateElementInput[]; + widthMm: number; + heightMm: number; +}): LabelTemplateElement[] { + const seenIds = new Set(); + return elements.map((element) => { + const id = element.id.trim(); + if (!id) throw new ValidationError("Element id is required"); + if (seenIds.has(id)) { + throw new ValidationError( + "Element ids must be unique within a template", + ); + } + seenIds.add(id); + + const xMm = normalizeDimension(element.xMm, "Element x position"); + const yMm = normalizeDimension(element.yMm, "Element y position"); + const nextWidth = normalizeDimension(element.widthMm, "Element width"); + const nextHeight = normalizeDimension(element.heightMm, "Element height"); + + if (xMm >= widthMm || yMm >= heightMm) { + throw new ValidationError( + "Element position must be inside the label bounds", + ); + } + if (xMm + nextWidth > widthMm || yMm + nextHeight > heightMm) { + throw new ValidationError("Element must fit within the label bounds"); + } + if (!LABEL_ELEMENT_TYPES.includes(element.type)) { + throw new ValidationError("Element type is invalid"); + } + if ( + element.fontWeight !== undefined && + !LABEL_FONT_WEIGHT_OPTIONS.includes(element.fontWeight) + ) { + throw new ValidationError("Element font weight is invalid"); + } + if ( + element.textAlign !== undefined && + !LABEL_TEXT_ALIGN_OPTIONS.includes(element.textAlign) + ) { + throw new ValidationError("Element text alignment is invalid"); + } + + const text = element.text?.trim() || null; + if (element.type === "staticText" && !text) { + throw new ValidationError("Static text elements require text"); + } + if (element.type === "customField" && !element.fieldId) { + throw new ValidationError("Custom field elements require a field"); + } + + return { + id, + type: element.type, + xMm, + yMm, + widthMm: nextWidth, + heightMm: nextHeight, + fontSize: normalizeFontSize(element.fontSize), + fontWeight: element.fontWeight, + textAlign: element.textAlign, + fieldId: element.fieldId ?? null, + text, + uppercase: element.uppercase === true ? true : undefined, + }; + }); +} + +export function normalizeLabelTemplateInput({ + name, + widthMm, + heightMm, + elements, +}: { + name: string; + widthMm: number; + heightMm: number; + elements: LabelTemplateElementInput[]; +}) { + const normalizedName = normalizeLabelTemplateName(name); + if (!normalizedName) { + throw new ValidationError("Template name is required"); + } + const normalizedWidth = normalizeDimension(widthMm, "Width"); + const normalizedHeight = normalizeDimension(heightMm, "Height"); + const normalizedElements = normalizeLabelTemplateElements({ + elements, + widthMm: normalizedWidth, + heightMm: normalizedHeight, + }); + return { + name: normalizedName, + normalizedName: normalizeLabelTemplateNameKey(normalizedName), + widthMm: normalizedWidth, + heightMm: normalizedHeight, + elements: normalizedElements, + }; +} + +export function createDefaultLabelTemplateDefinitions() { + return [ + { + name: "Compact 35x12 mm", + normalizedName: "compact 35x12 mm", + widthMm: 35, + heightMm: 12, + isDefault: false, + elements: [ + { + id: "asset-tag", + type: "assetTag" as const, + xMm: 2, + yMm: 2.1, + widthMm: 19, + heightMm: 5.8, + fontSize: 9, + fontWeight: "bold" as const, + textAlign: "left" as const, + }, + { + id: "matrix", + type: "dataMatrix" as const, + xMm: 23, + yMm: 1, + widthMm: 10.5, + heightMm: 10.5, + }, + ], + }, + { + name: "Thermal 57x32 mm", + normalizedName: "thermal 57x32 mm", + widthMm: 57, + heightMm: 32, + isDefault: true, + elements: [ + { + id: "asset-name", + type: "assetName" as const, + xMm: 3, + yMm: 3, + widthMm: 31, + heightMm: 6.5, + fontSize: 9, + fontWeight: "bold" as const, + textAlign: "left" as const, + }, + { + id: "asset-tag", + type: "assetTag" as const, + xMm: 3, + yMm: 12, + widthMm: 25, + heightMm: 6, + fontSize: 10, + fontWeight: "bold" as const, + textAlign: "left" as const, + }, + { + id: "location", + type: "location" as const, + xMm: 3, + yMm: 20, + widthMm: 31, + heightMm: 7, + fontSize: 6, + textAlign: "left" as const, + }, + { + id: "matrix", + type: "dataMatrix" as const, + xMm: 38, + yMm: 6, + widthMm: 15, + heightMm: 15, + }, + ], + }, + ].map((template) => ({ + ...template, + elements: normalizeLabelTemplateElements({ + elements: template.elements, + widthMm: template.widthMm, + heightMm: template.heightMm, + }), + })); +} diff --git a/src/server/pb/service-catalog.ts b/src/server/pb/service-catalog.ts new file mode 100644 index 0000000..b5f5e44 --- /dev/null +++ b/src/server/pb/service-catalog.ts @@ -0,0 +1,82 @@ +import { ValidationError } from "./errors"; + +export const SERVICE_GROUP_FIELD_TYPES = [ + "text", + "textarea", + "number", + "date", + "checkbox", + "select", +] as const; + +export type ServiceGroupFieldType = (typeof SERVICE_GROUP_FIELD_TYPES)[number]; + +export function normalizeServiceName(value: string) { + const normalized = value.trim().replace(/\s+/g, " "); + if (normalized.length > 200) { + throw new ValidationError("Name must be 200 characters or fewer"); + } + return normalized; +} + +export function normalizeServiceNameKey(value: string) { + return normalizeServiceName(value).toLocaleLowerCase(); +} + +export function normalizeOptionalServiceText( + value: string | null | undefined, +) { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +export function normalizeServiceFieldLabel(value: string) { + const normalized = normalizeServiceName(value); + if (!normalized) { + throw new ValidationError("Field label is required"); + } + return normalized; +} + +export function normalizeServiceFieldOptions(options: string[]) { + const deduped = new Map(); + for (const option of options) { + const normalized = normalizeServiceName(option); + if (!normalized) continue; + const key = normalized.toLocaleLowerCase(); + if (!deduped.has(key)) deduped.set(key, normalized); + } + return Array.from(deduped.values()); +} + +export function normalizeServiceFieldInput({ + label, + fieldType, + options, +}: { + label: string; + fieldType: ServiceGroupFieldType; + options: string[]; +}) { + const normalizedLabel = normalizeServiceFieldLabel(label); + if (fieldType !== "select") { + return { + label: normalizedLabel, + normalizedLabel: normalizeServiceNameKey(normalizedLabel), + options: [] as string[], + }; + } + + const normalizedOptions = normalizeServiceFieldOptions(options); + if (normalizedOptions.length === 0) { + throw new ValidationError( + "Select fields must include at least one option", + ); + } + + return { + label: normalizedLabel, + normalizedLabel: normalizeServiceNameKey(normalizedLabel), + options: normalizedOptions, + }; +} From 3e63dbbcbeea2ce46c70c8729a56d4411c58514a Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:53:38 +0200 Subject: [PATCH 06/37] Port assets + assetTags domain to PocketBase The centerpiece of the catalog migration. Adds the full asset lifecycle on top of PocketBase with the same semantics as Convex: - generateAssetTag: category prefix or AST default, scans current rows to find the next free number, produces zero-padded tags like IT-0001 / AST-0007. - createAsset: name + notes normalization, optional category / location / service group existence checks, default status=active, custom-field value validation against definitions (text, number, dropdown, url, date, checkbox, currency), required-field enforcement, auto-generated assetTag, asset-tags join rewrite, and field-definition usageCount bookkeeping. - listAssets: filter by categoryId / locationId / status (including nullable filters), tag intersection, case-insensitive search across name / assetTag / notes / tag names, and sort by createdAt / name / assetTag / status in either direction. Joins category + location + tag names inline. - getAsset: detail view with category / location / serviceGroup + tags joined. - updateAsset: partial update preserving current values, re- validates refs and custom fields, rewrites asset tags only when provided, diffs usageCount correctly. - updateAssetStatus: no-op shortcut when the status is unchanged. - deleteAsset: releases custom-field usageCount, cascades to assetTags, attachments, serviceSchedules, serviceRecords (and their attachments). - assetTags: setAssetTags with dedup + existence checks, getAssetTags, plus internal replaceAssetTags for the asset-level orchestration. 138 PB tests pass across 12 files, up from 115. assets.test.ts covers 18 cases (tag generation sequencing, required/invalid custom fields across all types, usage-count bookkeeping on create/update/delete, tag-intersection filter, cascade delete). assetTags.test.ts covers 5 cases. --- src/server/domain/__tests__/assetTags.test.ts | 122 +++ src/server/domain/__tests__/assets.test.ts | 481 +++++++++ src/server/domain/assetTags.ts | 171 ++++ src/server/domain/assets.ts | 927 ++++++++++++++++++ src/server/pb/assets.ts | 142 +++ 5 files changed, 1843 insertions(+) create mode 100644 src/server/domain/__tests__/assetTags.test.ts create mode 100644 src/server/domain/__tests__/assets.test.ts create mode 100644 src/server/domain/assetTags.ts create mode 100644 src/server/domain/assets.ts create mode 100644 src/server/pb/assets.ts diff --git a/src/server/domain/__tests__/assetTags.test.ts b/src/server/domain/__tests__/assetTags.test.ts new file mode 100644 index 0000000..c71bfea --- /dev/null +++ b/src/server/domain/__tests__/assetTags.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { + getAssetTags, + setAssetTags, +} from "@/server/domain/assetTags"; +import { createTag } from "@/server/domain/tags"; +import type { Ctx } from "@/server/pb/context"; +import { + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +async function seedAsset(pb: Ctx["pb"], adminId: string) { + return pb.collection("assets").create({ + name: "Laptop", + normalizedName: "laptop", + assetTag: `AST-${Math.random().toString(36).slice(2, 6).toUpperCase()}`, + status: "active", + customFieldValues: {}, + createdBy: adminId, + updatedBy: adminId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); +} + +describe("assetTags domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns empty list when asset has no tags", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const asset = await seedAsset(pb, admin.id); + + await expect(getAssetTags(ctx(), asset.id)).resolves.toEqual([]); + }); + + it("sets tags on an asset and dedupes inputs", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const asset = await seedAsset(pb, admin.id); + const alpha = await createTag(ctx(), { name: "Alpha", color: "#112233" }); + const beta = await createTag(ctx(), { name: "Beta", color: "#445566" }); + + await setAssetTags(ctx(), { + assetId: asset.id, + tagIds: [alpha.id, beta.id, alpha.id], + actorId: admin.id, + }); + + const tags = await getAssetTags(ctx(), asset.id); + expect(tags.map((t) => t.name)).toEqual(["Alpha", "Beta"]); + }); + + it("replaces existing tags on repeated calls", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const asset = await seedAsset(pb, admin.id); + const alpha = await createTag(ctx(), { name: "Alpha", color: "#112233" }); + const beta = await createTag(ctx(), { name: "Beta", color: "#445566" }); + const gamma = await createTag(ctx(), { name: "Gamma", color: "#778899" }); + + await setAssetTags(ctx(), { + assetId: asset.id, + tagIds: [alpha.id, beta.id], + actorId: admin.id, + }); + await setAssetTags(ctx(), { + assetId: asset.id, + tagIds: [beta.id, gamma.id], + actorId: admin.id, + }); + + const tags = await getAssetTags(ctx(), asset.id); + expect(tags.map((t) => t.name)).toEqual(["Beta", "Gamma"]); + }); + + it("rejects unknown tag ids", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const asset = await seedAsset(pb, admin.id); + + await expect( + setAssetTags(ctx(), { + assetId: asset.id, + tagIds: ["nonexistent0000"], + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("throws NotFoundError when asset does not exist", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const alpha = await createTag(ctx(), { name: "Alpha", color: "#112233" }); + + await expect( + setAssetTags(ctx(), { + assetId: "nonexistent0000", + tagIds: [alpha.id], + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + + await expect( + getAssetTags(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/src/server/domain/__tests__/assets.test.ts b/src/server/domain/__tests__/assets.test.ts new file mode 100644 index 0000000..165e11a --- /dev/null +++ b/src/server/domain/__tests__/assets.test.ts @@ -0,0 +1,481 @@ +import { describe, expect, it } from "vitest"; + +import { + createAsset, + deleteAsset, + generateAssetTag, + getAsset, + getAssetTagIds, + listAssets, + updateAsset, + updateAssetStatus, +} from "@/server/domain/assets"; +import { createCategory } from "@/server/domain/categories"; +import { createFieldDefinition } from "@/server/domain/customFields"; +import { createLocation } from "@/server/domain/locations"; +import { createTag } from "@/server/domain/tags"; +import type { Ctx } from "@/server/pb/context"; +import { + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("assets domain — tag generation", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("generates AST-0001 for the first asset without a category", async () => { + const preview = await generateAssetTag(ctx(), null); + expect(preview).toMatchObject({ + assetTag: "AST-0001", + prefix: "AST", + nextNumber: 1, + }); + }); + + it("uses the category prefix when provided", async () => { + const category = await createCategory(ctx(), { + name: "IT", + prefix: "IT", + color: "#2563EB", + }); + const preview = await generateAssetTag(ctx(), category.id); + expect(preview).toMatchObject({ + assetTag: "IT-0001", + prefix: "IT", + nextNumber: 1, + }); + }); + + it("increments within a prefix after assets are created", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const category = await createCategory(ctx(), { + name: "IT", + prefix: "IT", + color: "#2563EB", + }); + await createAsset(ctx(), { + name: "Laptop", + categoryId: category.id, + actorId: admin.id, + }); + await createAsset(ctx(), { + name: "Desktop", + categoryId: category.id, + actorId: admin.id, + }); + + await expect(generateAssetTag(ctx(), category.id)).resolves.toMatchObject({ + assetTag: "IT-0003", + nextNumber: 3, + }); + }); + + it("defaults to AST prefix when category has no prefix", async () => { + const category = await createCategory(ctx(), { + name: "Office", + color: "#112233", + }); + const preview = await generateAssetTag(ctx(), category.id); + expect(preview.prefix).toBe("AST"); + }); +}); + +describe("assets domain — CRUD", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("creates an asset with defaults and returns the generated tag", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: " Laptop ", + actorId: admin.id, + }); + + const asset = await getAsset(ctx(), assetId); + expect(asset).toMatchObject({ + name: "Laptop", + status: "active", + assetTag: "AST-0001", + tags: [], + category: null, + location: null, + }); + }); + + it("validates name is required and trims whitespace", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createAsset(ctx(), { name: " ", actorId: admin.id }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("rejects a missing category reference", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createAsset(ctx(), { + name: "Laptop", + categoryId: "nonexistent0000", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("rejects a missing location reference", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createAsset(ctx(), { + name: "Laptop", + locationId: "nonexistent0000", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("validates required custom fields", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await createFieldDefinition(ctx(), { + name: "Serial", + fieldType: "text", + required: true, + }); + + await expect( + createAsset(ctx(), { name: "Laptop", actorId: admin.id }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("validates custom field types (number, dropdown, url, date)", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const price = await createFieldDefinition(ctx(), { + name: "Price", + fieldType: "number", + required: false, + }); + const rating = await createFieldDefinition(ctx(), { + name: "Condition", + fieldType: "dropdown", + required: false, + options: ["New", "Used"], + }); + const url = await createFieldDefinition(ctx(), { + name: "Link", + fieldType: "url", + required: false, + }); + const date = await createFieldDefinition(ctx(), { + name: "Purchased", + fieldType: "date", + required: false, + }); + + await expect( + createAsset(ctx(), { + name: "A", + customFieldValues: { [price.id]: "not a number" }, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + createAsset(ctx(), { + name: "B", + customFieldValues: { [rating.id]: "Mint" }, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + createAsset(ctx(), { + name: "C", + customFieldValues: { [url.id]: "not-a-url" }, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + createAsset(ctx(), { + name: "D", + customFieldValues: { [date.id]: "2023/01/01" }, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + createAsset(ctx(), { + name: "E", + customFieldValues: { + [price.id]: 199.99, + [rating.id]: "New", + [url.id]: "https://example.test", + [date.id]: "2024-01-15", + }, + actorId: admin.id, + }), + ).resolves.toBeTruthy(); + }); + + it("increments and decrements field usage counts as values come and go", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const field = await createFieldDefinition(ctx(), { + name: "Serial", + fieldType: "text", + required: false, + }); + + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + customFieldValues: { [field.id]: "SN-123" }, + actorId: admin.id, + }); + + let definition = await pb + .collection("customFieldDefinitions") + .getOne<{ usageCount: number }>(field.id); + expect(definition.usageCount).toBe(1); + + await updateAsset(ctx(), { + assetId, + customFieldValues: {}, + actorId: admin.id, + }); + definition = await pb + .collection("customFieldDefinitions") + .getOne<{ usageCount: number }>(field.id); + expect(definition.usageCount).toBe(0); + + await updateAsset(ctx(), { + assetId, + customFieldValues: { [field.id]: "SN-999" }, + actorId: admin.id, + }); + definition = await pb + .collection("customFieldDefinitions") + .getOne<{ usageCount: number }>(field.id); + expect(definition.usageCount).toBe(1); + + await deleteAsset(ctx(), assetId); + definition = await pb + .collection("customFieldDefinitions") + .getOne<{ usageCount: number }>(field.id); + expect(definition.usageCount).toBe(0); + }); + + it("writes asset tags on create and swaps them on update", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const alpha = await createTag(ctx(), { name: "Alpha", color: "#112233" }); + const beta = await createTag(ctx(), { name: "Beta", color: "#445566" }); + const gamma = await createTag(ctx(), { name: "Gamma", color: "#778899" }); + + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + tagIds: [alpha.id, beta.id], + actorId: admin.id, + }); + await expect(getAssetTagIds(ctx(), assetId)).resolves.toEqual( + expect.arrayContaining([alpha.id, beta.id]), + ); + + await updateAsset(ctx(), { + assetId, + tagIds: [gamma.id], + actorId: admin.id, + }); + const after = await getAssetTagIds(ctx(), assetId); + expect(after).toEqual([gamma.id]); + }); + + it("filters and sorts list results", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const it = await createCategory(ctx(), { + name: "IT", + prefix: "IT", + color: "#2563EB", + }); + const office = await createCategory(ctx(), { + name: "Office", + color: "#112233", + }); + const location = await createLocation(ctx(), { name: "Warehouse" }); + + await createAsset(ctx(), { + name: "Laptop", + categoryId: it.id, + locationId: location.id, + actorId: admin.id, + }); + await createAsset(ctx(), { + name: "Monitor", + categoryId: it.id, + actorId: admin.id, + }); + await createAsset(ctx(), { + name: "Pen", + categoryId: office.id, + actorId: admin.id, + }); + + const byCategory = await listAssets(ctx(), { categoryId: it.id }); + expect(byCategory.map((a) => a.name).sort()).toEqual([ + "Laptop", + "Monitor", + ]); + + const byLocation = await listAssets(ctx(), { + locationId: location.id, + }); + expect(byLocation.map((a) => a.name)).toEqual(["Laptop"]); + + const bySearch = await listAssets(ctx(), { search: "pen" }); + expect(bySearch.map((a) => a.name)).toEqual(["Pen"]); + + const byName = await listAssets(ctx(), { + sortBy: "name", + sortDirection: "asc", + }); + expect(byName.map((a) => a.name)).toEqual(["Laptop", "Monitor", "Pen"]); + }); + + it("intersects tag filters when multiple tags are provided", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const alpha = await createTag(ctx(), { name: "Alpha", color: "#112233" }); + const beta = await createTag(ctx(), { name: "Beta", color: "#445566" }); + + const first = await createAsset(ctx(), { + name: "Both", + tagIds: [alpha.id, beta.id], + actorId: admin.id, + }); + await createAsset(ctx(), { + name: "Alpha only", + tagIds: [alpha.id], + actorId: admin.id, + }); + await createAsset(ctx(), { + name: "Beta only", + tagIds: [beta.id], + actorId: admin.id, + }); + + const bothTags = await listAssets(ctx(), { + tagIds: [alpha.id, beta.id], + }); + expect(bothTags.map((a) => a.id)).toEqual([first.assetId]); + }); + + it("updateAssetStatus changes only the status", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + + await updateAssetStatus(ctx(), { + assetId, + status: "retired", + actorId: admin.id, + }); + const asset = await getAsset(ctx(), assetId); + expect(asset?.status).toBe("retired"); + }); + + it("getAsset returns null when missing", async () => { + await expect(getAsset(ctx(), "nonexistent0000")).resolves.toBeNull(); + }); + + it("deleteAsset cascades to asset tags, schedules, records", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const alpha = await createTag(ctx(), { name: "Alpha", color: "#112233" }); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + tagIds: [alpha.id], + actorId: admin.id, + }); + + await pb.collection("serviceSchedules").create({ + assetId, + nextServiceDate: "2030-01-01", + intervalValue: 90, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + createdAt: Date.now(), + updatedAt: Date.now(), + createdBy: admin.id, + updatedBy: admin.id, + }); + const record = await pb.collection("serviceRecords").create({ + assetId, + values: {}, + completedAt: Date.now(), + completedBy: admin.id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + await pb.collection("serviceRecordAttachments").create({ + serviceRecordId: record.id, + fileName: "log.txt", + fileType: "text/plain", + fileKind: "pdf", + fileSize: 123, + uploadedBy: admin.id, + uploadedAt: Date.now(), + updatedAt: Date.now(), + }); + + await deleteAsset(ctx(), assetId); + + for (const collection of [ + "assetTags", + "serviceSchedules", + "serviceRecordAttachments", + "serviceRecords", + ]) { + const remaining = await pb.collection(collection).getFullList(); + expect(remaining, `${collection} should be empty`).toHaveLength(0); + } + await expect(getAsset(ctx(), assetId)).resolves.toBeNull(); + }); + + it("throws NotFoundError when updating or deleting an unknown asset", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + updateAsset(ctx(), { assetId: "nonexistent0000", actorId: admin.id }), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + updateAssetStatus(ctx(), { + assetId: "nonexistent0000", + status: "retired", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + await expect(deleteAsset(ctx(), "nonexistent0000")).rejects.toBeInstanceOf( + NotFoundError, + ); + }); +}); diff --git a/src/server/domain/assetTags.ts b/src/server/domain/assetTags.ts new file mode 100644 index 0000000..67f0cff --- /dev/null +++ b/src/server/domain/assetTags.ts @@ -0,0 +1,171 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { NotFoundError, ValidationError } from "@/server/pb/errors"; + +type AssetTagLinkRecord = { + id: string; + assetId: string; + tagId: string; + createdBy: string; + createdAt: number; +}; + +type TagRecord = { + id: string; + name: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +export type AssetTagView = { + id: string; + name: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +export const SetAssetTagsInput = z.object({ + assetId: z.string(), + tagIds: z.array(z.string()), + actorId: z.string(), +}); + +export type SetAssetTagsInput = z.infer; + +function dedupe(ids: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} + +async function assertAssetExists(ctx: Ctx, assetId: string) { + try { + await ctx.pb.collection("assets").getOne(assetId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Asset not found"); + } + throw error; + } +} + +async function assertAllTagsExist(ctx: Ctx, tagIds: string[]) { + if (tagIds.length === 0) return; + for (const tagId of tagIds) { + try { + await ctx.pb.collection("tags").getOne(tagId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError( + "One or more selected tags were not found", + ); + } + throw error; + } + } +} + +export async function getTagIdsForAsset( + ctx: Ctx, + assetId: string, +): Promise { + const links = await ctx.pb + .collection("assetTags") + .getFullList({ filter: `assetId = "${assetId}"` }); + return links.map((link) => link.tagId); +} + +export async function replaceAssetTags( + ctx: Ctx, + assetId: string, + tagIds: string[], + actorId: string, +): Promise { + const deduped = dedupe(tagIds); + await assertAssetExists(ctx, assetId); + await assertAllTagsExist(ctx, deduped); + + const existingLinks = await ctx.pb + .collection("assetTags") + .getFullList({ filter: `assetId = "${assetId}"` }); + const existingByTagId = new Map( + existingLinks.map((link) => [link.tagId, link]), + ); + const wanted = new Set(deduped); + + for (const link of existingLinks) { + if (!wanted.has(link.tagId)) { + await ctx.pb.collection("assetTags").delete(link.id); + } + } + + const now = Date.now(); + for (const tagId of deduped) { + if (existingByTagId.has(tagId)) continue; + await ctx.pb.collection("assetTags").create({ + assetId, + tagId, + createdBy: actorId, + createdAt: now, + }); + } +} + +export async function listTagsForAsset( + ctx: Ctx, + assetId: string, +): Promise { + const tagIds = await getTagIdsForAsset(ctx, assetId); + if (tagIds.length === 0) return []; + + const tags: TagRecord[] = []; + for (const tagId of tagIds) { + try { + tags.push( + await ctx.pb.collection("tags").getOne(tagId), + ); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + continue; + } + throw error; + } + } + + return tags + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ) + .map((tag) => ({ + id: tag.id, + name: tag.name, + color: tag.color, + createdAt: tag.createdAt, + updatedAt: tag.updatedAt, + })); +} + +export async function setAssetTags( + ctx: Ctx, + input: SetAssetTagsInput, +): Promise { + const parsed = SetAssetTagsInput.parse(input); + await replaceAssetTags(ctx, parsed.assetId, parsed.tagIds, parsed.actorId); +} + +export async function getAssetTags( + ctx: Ctx, + assetId: string, +): Promise { + await assertAssetExists(ctx, assetId); + return listTagsForAsset(ctx, assetId); +} diff --git a/src/server/domain/assets.ts b/src/server/domain/assets.ts new file mode 100644 index 0000000..3e0b004 --- /dev/null +++ b/src/server/domain/assets.ts @@ -0,0 +1,927 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + ASSET_STATUSES, + type AssetCustomFieldValue, + type AssetStatus, + buildAssetTag, + getAssetTagNumber, + getUsedFieldIds, + isCustomFieldValueEmpty, + isIsoDateOnly, + normalizeAssetNameKey, + normalizeAssetNotes, + normalizeAssetTagPrefix, + normalizeCustomFieldValues, + requireAssetName, +} from "@/server/pb/assets"; +import { + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + getTagIdsForAsset, + listTagsForAsset, + replaceAssetTags, + type AssetTagView, +} from "./assetTags"; + +const assetStatusSchema = z.enum(ASSET_STATUSES); +const customFieldValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); +const customFieldValuesSchema = z.record(z.string(), customFieldValueSchema); + +const NullableId = z.string().nullish(); + +export const CreateAssetInput = z.object({ + name: z.string(), + categoryId: NullableId, + locationId: NullableId, + serviceGroupId: NullableId, + status: assetStatusSchema.optional(), + notes: z.string().nullish(), + customFieldValues: customFieldValuesSchema.optional(), + tagIds: z.array(z.string()).optional(), + actorId: z.string(), +}); + +export const UpdateAssetInput = z.object({ + assetId: z.string(), + name: z.string().optional(), + categoryId: NullableId, + locationId: NullableId, + serviceGroupId: NullableId, + status: assetStatusSchema.optional(), + notes: z.string().nullish(), + customFieldValues: customFieldValuesSchema.optional(), + tagIds: z.array(z.string()).optional(), + actorId: z.string(), +}); + +export const UpdateAssetStatusInput = z.object({ + assetId: z.string(), + status: assetStatusSchema, + actorId: z.string(), +}); + +export const ListAssetsInput = z.object({ + categoryId: NullableId, + locationId: NullableId, + status: assetStatusSchema.optional(), + tagIds: z.array(z.string()).optional(), + search: z.string().optional(), + sortBy: z + .enum(["createdAt", "name", "assetTag", "status"]) + .optional(), + sortDirection: z.enum(["asc", "desc"]).optional(), +}); + +export type CreateAssetInput = z.infer; +export type UpdateAssetInput = z.infer; +export type UpdateAssetStatusInput = z.infer; +export type ListAssetsInput = z.infer; + +export type AssetTagPreview = { + assetTag: string; + prefix: string; + nextNumber: number; +}; + +export type AssetListView = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryId: string | null; + categoryName: string | null; + categoryColor: string | null; + locationId: string | null; + locationPath: string | null; + serviceGroupId: string | null; + notes: string | null; + tagIds: string[]; + tagNames: string[]; + createdAt: number; + updatedAt: number; +}; + +export type AssetDetailView = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryId: string | null; + locationId: string | null; + serviceGroupId: string | null; + notes: string | null; + customFieldValues: Record; + createdBy: string; + updatedBy: string; + createdAt: number; + updatedAt: number; + category: { + id: string; + name: string; + prefix: string | null; + color: string; + } | null; + location: { + id: string; + name: string; + parentId: string | null; + path: string; + } | null; + serviceGroup: { id: string; name: string } | null; + tags: AssetTagView[]; +}; + +type AssetRecord = { + id: string; + name: string; + normalizedName: string; + assetTag: string; + status: AssetStatus; + categoryId: string; + locationId: string; + serviceGroupId?: string; + notes: string; + customFieldValues: Record | null; + createdBy: string; + updatedBy: string; + createdAt: number; + updatedAt: number; +}; + +type CategoryRecord = { + id: string; + name: string; + prefix: string; + color: string; +}; + +type LocationRecord = { + id: string; + name: string; + parentId: string; + path: string; +}; + +type ServiceGroupRecord = { + id: string; + name: string; +}; + +type FieldDefinitionRecord = { + id: string; + name: string; + fieldType: + | "text" + | "number" + | "date" + | "dropdown" + | "checkbox" + | "url" + | "currency"; + options: string[] | null; + required: boolean; + usageCount: number; +}; + +function escapeFilter(value: string) { + return value.replace(/"/g, '\\"'); +} + +async function loadAsset(ctx: Ctx, assetId: string): Promise { + try { + return await ctx.pb.collection("assets").getOne(assetId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Asset not found"); + } + throw error; + } +} + +async function requireCategory( + ctx: Ctx, + categoryId: string, +): Promise { + try { + return await ctx.pb + .collection("categories") + .getOne(categoryId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError("Category not found"); + } + throw error; + } +} + +async function requireLocation( + ctx: Ctx, + locationId: string, +): Promise { + try { + return await ctx.pb + .collection("locations") + .getOne(locationId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError("Location not found"); + } + throw error; + } +} + +async function requireServiceGroup( + ctx: Ctx, + serviceGroupId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceGroups") + .getOne(serviceGroupId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError("Service group not found"); + } + throw error; + } +} + +async function getCategoryPrefix( + ctx: Ctx, + categoryId: string | null, +): Promise { + if (!categoryId) return normalizeAssetTagPrefix(null); + const category = await requireCategory(ctx, categoryId); + return normalizeAssetTagPrefix(category.prefix); +} + +async function getNextAssetTagForPrefix( + ctx: Ctx, + prefix: string, +): Promise { + const rows = await ctx.pb + .collection("assets") + .getFullList(); + let maxNumber = 0; + for (const row of rows) { + const n = getAssetTagNumber(row.assetTag, prefix); + if (n && n > maxNumber) maxNumber = n; + } + const nextNumber = maxNumber + 1; + return { assetTag: buildAssetTag(prefix, nextNumber), prefix, nextNumber }; +} + +function normalizeStringCustomFieldValue(value: AssetCustomFieldValue) { + if (value === null) return null; + if (typeof value !== "string") { + throw new ValidationError("Custom field value must be text"); + } + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function normalizeNumberCustomFieldValue(value: AssetCustomFieldValue) { + if (value === null) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new ValidationError("Custom field value must be numeric"); + } + return value; +} + +function normalizeCheckboxCustomFieldValue(value: AssetCustomFieldValue) { + if (value === null) return null; + if (typeof value !== "boolean") { + throw new ValidationError("Custom field value must be true or false"); + } + return value; +} + +function normalizeCustomValueByType( + definition: FieldDefinitionRecord, + value: AssetCustomFieldValue, +): AssetCustomFieldValue { + if ( + definition.fieldType === "number" || + definition.fieldType === "currency" + ) { + return normalizeNumberCustomFieldValue(value); + } + if (definition.fieldType === "checkbox") { + return normalizeCheckboxCustomFieldValue(value); + } + const normalized = normalizeStringCustomFieldValue(value); + if (normalized === null) return null; + + if (definition.fieldType === "date" && !isIsoDateOnly(normalized)) { + throw new ValidationError( + `${definition.name} must use YYYY-MM-DD format`, + ); + } + if (definition.fieldType === "dropdown") { + const options = definition.options ?? []; + if (!options.includes(normalized)) { + throw new ValidationError( + `${definition.name} must be one of the configured options`, + ); + } + } + if (definition.fieldType === "url") { + try { + const parsed = new URL(normalized); + if (!parsed.protocol.startsWith("http")) { + throw new ValidationError( + `${definition.name} must start with http:// or https://`, + ); + } + } catch (error) { + if (error instanceof ValidationError) throw error; + throw new ValidationError(`${definition.name} must be a valid URL`); + } + } + return normalized; +} + +async function validateAndNormalizeCustomFieldValues( + ctx: Ctx, + rawValues: Record | undefined, +): Promise> { + const input = normalizeCustomFieldValues(rawValues); + const definitions = await ctx.pb + .collection("customFieldDefinitions") + .getFullList(); + const byId = new Map(definitions.map((d) => [d.id, d])); + + const normalized: Record = {}; + for (const [fieldId, rawValue] of Object.entries(input)) { + const definition = byId.get(fieldId); + if (!definition) { + throw new ValidationError("Custom field definition not found"); + } + const nextValue = normalizeCustomValueByType(definition, rawValue); + if (nextValue !== null) normalized[fieldId] = nextValue; + } + + for (const definition of definitions) { + if (!definition.required) continue; + const value = normalized[definition.id]; + if (definition.fieldType === "checkbox") { + if (value === null || value === undefined) { + throw new ValidationError(`${definition.name} is required`); + } + continue; + } + if (isCustomFieldValueEmpty(value)) { + throw new ValidationError(`${definition.name} is required`); + } + } + + return normalized; +} + +async function updateFieldUsageCounts( + ctx: Ctx, + previous: Record, + next: Record, +) { + const previousUsed = getUsedFieldIds(previous); + const nextUsed = getUsedFieldIds(next); + + for (const fieldId of previousUsed) { + if (nextUsed.has(fieldId)) continue; + try { + const definition = await ctx.pb + .collection("customFieldDefinitions") + .getOne(fieldId); + await ctx.pb + .collection("customFieldDefinitions") + .update(fieldId, { + usageCount: Math.max(0, definition.usageCount - 1), + }); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + continue; + } + throw error; + } + } + + for (const fieldId of nextUsed) { + if (previousUsed.has(fieldId)) continue; + try { + const definition = await ctx.pb + .collection("customFieldDefinitions") + .getOne(fieldId); + await ctx.pb + .collection("customFieldDefinitions") + .update(fieldId, { usageCount: definition.usageCount + 1 }); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + continue; + } + throw error; + } + } +} + +function pickSort( + sortBy: ListAssetsInput["sortBy"], + sortDirection: ListAssetsInput["sortDirection"], +) { + const direction = sortDirection ?? "desc"; + const by = sortBy ?? "createdAt"; + const multiplier = direction === "asc" ? 1 : -1; + return (a: AssetListView, b: AssetListView) => { + if (by === "createdAt") return (a.createdAt - b.createdAt) * multiplier; + if (by === "name") { + return ( + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) * + multiplier + ); + } + if (by === "assetTag") { + return ( + a.assetTag.localeCompare(b.assetTag, undefined, { + sensitivity: "base", + }) * multiplier + ); + } + if (a.status === b.status) { + return (a.createdAt - b.createdAt) * multiplier; + } + return ( + a.status.localeCompare(b.status, undefined, { sensitivity: "base" }) * + multiplier + ); + }; +} + +async function buildAssetListViews( + ctx: Ctx, + rows: AssetRecord[], +): Promise { + if (rows.length === 0) return []; + const categoryIds = [ + ...new Set(rows.map((row) => row.categoryId).filter((id) => !!id)), + ]; + const locationIds = [ + ...new Set(rows.map((row) => row.locationId).filter((id) => !!id)), + ]; + + const [categories, locations, tagLinks, tags] = await Promise.all([ + Promise.all( + categoryIds.map((id) => + ctx.pb + .collection("categories") + .getOne(id) + .catch(() => null), + ), + ), + Promise.all( + locationIds.map((id) => + ctx.pb + .collection("locations") + .getOne(id) + .catch(() => null), + ), + ), + ctx.pb + .collection("assetTags") + .getFullList<{ assetId: string; tagId: string }>(), + ctx.pb + .collection("tags") + .getFullList<{ id: string; name: string; color: string }>(), + ]); + + const assetIdSet = new Set(rows.map((row) => row.id)); + const filteredLinks = tagLinks.filter((link) => assetIdSet.has(link.assetId)); + const tagIdsByAssetId = new Map(); + for (const link of filteredLinks) { + const list = tagIdsByAssetId.get(link.assetId) ?? []; + list.push(link.tagId); + tagIdsByAssetId.set(link.assetId, list); + } + const tagById = new Map(tags.map((tag) => [tag.id, tag])); + const categoryById = new Map( + (categories.filter(Boolean) as CategoryRecord[]).map((c) => [c.id, c]), + ); + const locationById = new Map( + (locations.filter(Boolean) as LocationRecord[]).map((l) => [l.id, l]), + ); + + return rows.map((asset) => { + const category = asset.categoryId + ? (categoryById.get(asset.categoryId) ?? null) + : null; + const location = asset.locationId + ? (locationById.get(asset.locationId) ?? null) + : null; + const tagIdsForAsset = tagIdsByAssetId.get(asset.id) ?? []; + const tagNames = tagIdsForAsset + .map((id) => tagById.get(id)?.name ?? null) + .filter((name): name is string => !!name) + .sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); + + return { + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + status: asset.status, + categoryId: asset.categoryId || null, + categoryName: category?.name ?? null, + categoryColor: category?.color ?? null, + locationId: asset.locationId || null, + locationPath: location?.path ?? null, + serviceGroupId: asset.serviceGroupId || null, + notes: asset.notes || null, + tagIds: tagIdsForAsset, + tagNames, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + }; + }); +} + +function matchesAssetSearch(row: AssetListView, needle: string) { + if (!needle) return true; + const haystacks = [ + row.name, + row.assetTag, + row.notes ?? "", + ...row.tagNames, + ]; + return haystacks.some((value) => + value.toLocaleLowerCase().includes(needle), + ); +} + +export async function generateAssetTag( + ctx: Ctx, + categoryId: string | null, +): Promise { + const prefix = await getCategoryPrefix(ctx, categoryId); + return getNextAssetTagForPrefix(ctx, prefix); +} + +export async function createAsset( + ctx: Ctx, + input: CreateAssetInput, +): Promise<{ assetId: string }> { + const parsed = CreateAssetInput.parse(input); + const name = requireAssetName(parsed.name); + const normalizedName = normalizeAssetNameKey(name); + const categoryId = parsed.categoryId ?? null; + const locationId = parsed.locationId ?? null; + const serviceGroupId = parsed.serviceGroupId ?? null; + + if (categoryId) await requireCategory(ctx, categoryId); + if (locationId) await requireLocation(ctx, locationId); + if (serviceGroupId) await requireServiceGroup(ctx, serviceGroupId); + + const status = parsed.status ?? "active"; + const notes = normalizeAssetNotes(parsed.notes); + const customFieldValues = await validateAndNormalizeCustomFieldValues( + ctx, + parsed.customFieldValues, + ); + + const prefix = await getCategoryPrefix(ctx, categoryId); + const preview = await getNextAssetTagForPrefix(ctx, prefix); + + const now = Date.now(); + const record = await ctx.pb.collection("assets").create({ + name, + normalizedName, + assetTag: preview.assetTag, + status, + categoryId: categoryId ?? "", + locationId: locationId ?? "", + serviceGroupId: serviceGroupId ?? "", + notes: notes ?? "", + customFieldValues, + createdBy: parsed.actorId, + updatedBy: parsed.actorId, + createdAt: now, + updatedAt: now, + }); + + await replaceAssetTags(ctx, record.id, parsed.tagIds ?? [], parsed.actorId); + await updateFieldUsageCounts(ctx, {}, customFieldValues); + + return { assetId: record.id }; +} + +export async function getAsset( + ctx: Ctx, + assetId: string, +): Promise { + let asset: AssetRecord; + try { + asset = await ctx.pb.collection("assets").getOne(assetId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } + + const [category, location, serviceGroup, tags] = await Promise.all([ + asset.categoryId + ? ctx.pb + .collection("categories") + .getOne(asset.categoryId) + .catch(() => null) + : Promise.resolve(null), + asset.locationId + ? ctx.pb + .collection("locations") + .getOne(asset.locationId) + .catch(() => null) + : Promise.resolve(null), + asset.serviceGroupId + ? ctx.pb + .collection("serviceGroups") + .getOne(asset.serviceGroupId) + .catch(() => null) + : Promise.resolve(null), + listTagsForAsset(ctx, asset.id), + ]); + + return { + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + status: asset.status, + categoryId: asset.categoryId || null, + locationId: asset.locationId || null, + serviceGroupId: asset.serviceGroupId || null, + notes: asset.notes || null, + customFieldValues: asset.customFieldValues ?? {}, + createdBy: asset.createdBy, + updatedBy: asset.updatedBy, + createdAt: asset.createdAt, + updatedAt: asset.updatedAt, + category: category + ? { + id: category.id, + name: category.name, + prefix: category.prefix || null, + color: category.color, + } + : null, + location: location + ? { + id: location.id, + name: location.name, + parentId: location.parentId || null, + path: location.path, + } + : null, + serviceGroup: serviceGroup + ? { id: serviceGroup.id, name: serviceGroup.name } + : null, + tags, + }; +} + +function dedupeIds(ids: string[]) { + const seen = new Set(); + const out: string[] = []; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + out.push(id); + } + return out; +} + +async function getAssetIdsForTags( + ctx: Ctx, + tagIds: string[], +): Promise> { + if (tagIds.length === 0) return new Set(); + const sets = await Promise.all( + tagIds.map(async (tagId) => { + const links = await ctx.pb + .collection("assetTags") + .getFullList<{ assetId: string; tagId: string }>({ + filter: `tagId = "${escapeFilter(tagId)}"`, + }); + return new Set(links.map((link) => link.assetId)); + }), + ); + const [first, ...rest] = sets; + if (!first) return new Set(); + const intersected = new Set(first); + for (const set of rest) { + for (const id of intersected) { + if (!set.has(id)) intersected.delete(id); + } + } + return intersected; +} + +export async function listAssets( + ctx: Ctx, + input: ListAssetsInput = {}, +): Promise { + const parsed = ListAssetsInput.parse(input); + const normalizedTagIds = dedupeIds(parsed.tagIds ?? []); + const normalizedSearch = parsed.search?.trim().toLocaleLowerCase() ?? ""; + + const filters: string[] = []; + if (parsed.categoryId !== undefined) { + filters.push( + parsed.categoryId + ? `categoryId = "${escapeFilter(parsed.categoryId)}"` + : `categoryId = ""`, + ); + } + if (parsed.locationId !== undefined) { + filters.push( + parsed.locationId + ? `locationId = "${escapeFilter(parsed.locationId)}"` + : `locationId = ""`, + ); + } + if (parsed.status !== undefined) { + filters.push(`status = "${parsed.status}"`); + } + + let tagAssetIds: Set | null = null; + if (normalizedTagIds.length > 0) { + tagAssetIds = await getAssetIdsForTags(ctx, normalizedTagIds); + if (tagAssetIds.size === 0) return []; + } + + const rows = await ctx.pb + .collection("assets") + .getFullList({ + filter: filters.length > 0 ? filters.join(" && ") : undefined, + }); + + const filtered = tagAssetIds + ? rows.filter((row) => tagAssetIds!.has(row.id)) + : rows; + + const views = await buildAssetListViews(ctx, filtered); + const sorted = views + .filter((row) => matchesAssetSearch(row, normalizedSearch)) + .sort(pickSort(parsed.sortBy, parsed.sortDirection)); + return sorted; +} + +export async function updateAsset( + ctx: Ctx, + input: UpdateAssetInput, +): Promise { + const parsed = UpdateAssetInput.parse(input); + const asset = await loadAsset(ctx, parsed.assetId); + + const name = + parsed.name === undefined ? asset.name : requireAssetName(parsed.name); + const normalizedName = normalizeAssetNameKey(name); + const categoryId = + parsed.categoryId === undefined + ? asset.categoryId || null + : (parsed.categoryId ?? null); + const locationId = + parsed.locationId === undefined + ? asset.locationId || null + : (parsed.locationId ?? null); + const serviceGroupId = + parsed.serviceGroupId === undefined + ? asset.serviceGroupId || null + : (parsed.serviceGroupId ?? null); + const status = parsed.status ?? asset.status; + + if (categoryId) await requireCategory(ctx, categoryId); + if (locationId) await requireLocation(ctx, locationId); + if (serviceGroupId) await requireServiceGroup(ctx, serviceGroupId); + + const notes = + parsed.notes === undefined + ? asset.notes || null + : normalizeAssetNotes(parsed.notes); + const customFieldValues = + parsed.customFieldValues === undefined + ? (asset.customFieldValues ?? {}) + : await validateAndNormalizeCustomFieldValues( + ctx, + parsed.customFieldValues, + ); + + await ctx.pb.collection("assets").update(asset.id, { + name, + normalizedName, + categoryId: categoryId ?? "", + locationId: locationId ?? "", + serviceGroupId: serviceGroupId ?? "", + status, + notes: notes ?? "", + customFieldValues, + updatedBy: parsed.actorId, + updatedAt: Date.now(), + }); + + if (parsed.tagIds !== undefined) { + await replaceAssetTags(ctx, asset.id, parsed.tagIds, parsed.actorId); + } + + await updateFieldUsageCounts( + ctx, + asset.customFieldValues ?? {}, + customFieldValues, + ); +} + +export async function updateAssetStatus( + ctx: Ctx, + input: UpdateAssetStatusInput, +): Promise { + const parsed = UpdateAssetStatusInput.parse(input); + const asset = await loadAsset(ctx, parsed.assetId); + if (asset.status === parsed.status) return; + await ctx.pb.collection("assets").update(asset.id, { + status: parsed.status, + updatedBy: parsed.actorId, + updatedAt: Date.now(), + }); +} + +export async function deleteAsset( + ctx: Ctx, + assetId: string, +): Promise { + const asset = await loadAsset(ctx, assetId); + await updateFieldUsageCounts(ctx, asset.customFieldValues ?? {}, {}); + + // cascade: assetTags, attachments, serviceSchedules, serviceRecords. + // PB relation cascadeDelete settings handle the DB side for collections + // configured that way (assetTags, attachments, serviceSchedules, + // serviceRecords — see pb_migrations), but we delete explicitly to also + // reclaim attached file storage and keep semantics explicit. + const [assetTagLinks, attachments, schedules, serviceRecords] = + await Promise.all([ + ctx.pb.collection("assetTags").getFullList<{ id: string }>({ + filter: `assetId = "${escapeFilter(asset.id)}"`, + }), + ctx.pb + .collection("attachments") + .getFullList<{ id: string }>({ + filter: `assetId = "${escapeFilter(asset.id)}"`, + }), + ctx.pb + .collection("serviceSchedules") + .getFullList<{ id: string }>({ + filter: `assetId = "${escapeFilter(asset.id)}"`, + }), + ctx.pb + .collection("serviceRecords") + .getFullList<{ id: string }>({ + filter: `assetId = "${escapeFilter(asset.id)}"`, + }), + ]); + + for (const link of assetTagLinks) { + await ctx.pb.collection("assetTags").delete(link.id); + } + for (const attachment of attachments) { + await ctx.pb.collection("attachments").delete(attachment.id); + } + for (const schedule of schedules) { + await ctx.pb.collection("serviceSchedules").delete(schedule.id); + } + for (const record of serviceRecords) { + const recordAttachments = await ctx.pb + .collection("serviceRecordAttachments") + .getFullList<{ id: string }>({ + filter: `serviceRecordId = "${escapeFilter(record.id)}"`, + }); + for (const ra of recordAttachments) { + await ctx.pb.collection("serviceRecordAttachments").delete(ra.id); + } + await ctx.pb.collection("serviceRecords").delete(record.id); + } + + await ctx.pb.collection("assets").delete(asset.id); +} + +export async function getAssetTagIds( + ctx: Ctx, + assetId: string, +): Promise { + await loadAsset(ctx, assetId); + return getTagIdsForAsset(ctx, assetId); +} diff --git a/src/server/pb/assets.ts b/src/server/pb/assets.ts new file mode 100644 index 0000000..862f594 --- /dev/null +++ b/src/server/pb/assets.ts @@ -0,0 +1,142 @@ +import { ValidationError } from "./errors"; + +export const ASSET_STATUSES = [ + "active", + "in_storage", + "under_repair", + "retired", + "disposed", +] as const; + +export type AssetStatus = (typeof ASSET_STATUSES)[number]; + +export type AssetCustomFieldValue = string | number | boolean | null; + +const ASSET_TAG_WIDTH = 4; +const ASSET_TAG_DEFAULT_PREFIX = "AST"; +const ISO_DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function requireAssetName(name: string) { + const normalized = name.trim().replace(/\s+/g, " "); + if (!normalized) throw new ValidationError("Asset name is required"); + if (normalized.length > 500) { + throw new ValidationError( + "Asset name must be 500 characters or fewer", + ); + } + return normalized; +} + +export function normalizeAssetNameKey(name: string) { + return name.trim().toLocaleLowerCase(); +} + +export function normalizeAssetNotes( + notes: string | null | undefined, +): string | null { + if (notes == null) return null; + const normalized = notes.trim(); + if (!normalized) return null; + if (normalized.length > 5000) { + throw new ValidationError( + "Asset notes must be 5000 characters or fewer", + ); + } + return normalized; +} + +function sanitizeTagPrefix(rawPrefix: string) { + return rawPrefix + .trim() + .toUpperCase() + .replace(/[^A-Z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function normalizeAssetTagPrefix(prefix: string | null | undefined) { + if (!prefix) return ASSET_TAG_DEFAULT_PREFIX; + const normalized = sanitizeTagPrefix(prefix); + return normalized || ASSET_TAG_DEFAULT_PREFIX; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function buildAssetTag(prefix: string, number: number) { + if (!Number.isInteger(number) || number < 1) { + throw new ValidationError( + "Asset tag sequence must be a positive integer", + ); + } + return `${prefix}-${String(number).padStart(ASSET_TAG_WIDTH, "0")}`; +} + +export function getAssetTagNumber(assetTag: string, prefix: string) { + const pattern = new RegExp(`^${escapeRegExp(prefix)}-(\\d+)$`); + const match = assetTag.match(pattern); + if (!match) return null; + const parsed = Number(match[1]); + if (!Number.isInteger(parsed) || parsed < 1) return null; + return parsed; +} + +export function isIsoDateOnly(value: string) { + if (!ISO_DATE_ONLY_PATTERN.test(value)) return false; + const [yearRaw, monthRaw, dayRaw] = value.split("-"); + const year = Number(yearRaw); + const month = Number(monthRaw); + const day = Number(dayRaw); + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) + ) { + return false; + } + if (month < 1 || month > 12 || day < 1 || day > 31) return false; + const candidate = new Date(Date.UTC(year, month - 1, day)); + return ( + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + ); +} + +export function isCustomFieldValueEmpty( + value: AssetCustomFieldValue | undefined, +) { + if (value === null || value === undefined) return true; + if (typeof value === "string") return value.trim() === ""; + return false; +} + +export function isCustomFieldValueSet( + value: AssetCustomFieldValue | undefined, +) { + return !isCustomFieldValueEmpty(value); +} + +export function normalizeCustomFieldValues( + rawValues: Record | undefined, +) { + if (!rawValues) return {}; + const normalized: Record = {}; + for (const [fieldId, value] of Object.entries(rawValues)) { + const trimmedFieldId = fieldId.trim(); + if (!trimmedFieldId) continue; + normalized[trimmedFieldId] = value; + } + return normalized; +} + +export function getUsedFieldIds( + values: Record, +) { + const used = new Set(); + for (const [fieldId, value] of Object.entries(values)) { + if (isCustomFieldValueSet(value)) used.add(fieldId); + } + return used; +} From 4d53d64d2e660f9a2e697d1b27ba7b5ce9eed7a0 Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 22:55:43 +0200 Subject: [PATCH 07/37] Port asset search and dashboard queries Adds the last two read-side domain modules: - searchAssets: weighted scoring across assetTag > name > notes, case-insensitive, joins categoryName and locationPath onto the top-N matches. FTS5 virtual table stays in place for a later upgrade; scan + score matches Convex semantics at current data volumes. - dashboard: getAssetCountsByStatus (per-status totals), getRecentAssets (latest 10 by updatedAt), getUpcomingServicesPreview (respects the serviceSchedulingEnabled toggle), getOverview (one-shot payload with dateFormat, status counts, category + location breakdowns, upcoming services). PB test count at 147 across 14 files. --- src/server/domain/__tests__/dashboard.test.ts | 160 +++++++++ src/server/domain/__tests__/search.test.ts | 102 ++++++ src/server/domain/dashboard.ts | 326 ++++++++++++++++++ src/server/domain/search.ts | 133 +++++++ 4 files changed, 721 insertions(+) create mode 100644 src/server/domain/__tests__/dashboard.test.ts create mode 100644 src/server/domain/__tests__/search.test.ts create mode 100644 src/server/domain/dashboard.ts create mode 100644 src/server/domain/search.ts diff --git a/src/server/domain/__tests__/dashboard.test.ts b/src/server/domain/__tests__/dashboard.test.ts new file mode 100644 index 0000000..f58938c --- /dev/null +++ b/src/server/domain/__tests__/dashboard.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { updateDateFormat } from "@/server/domain/appSettings"; +import { createAsset, updateAssetStatus } from "@/server/domain/assets"; +import { createCategory } from "@/server/domain/categories"; +import { + getAssetCountsByStatus, + getOverview, + getRecentAssets, + getUpcomingServicesPreview, +} from "@/server/domain/dashboard"; +import { createLocation } from "@/server/domain/locations"; +import type { Ctx } from "@/server/pb/context"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("dashboard domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns zero counts on an empty database", async () => { + await expect(getAssetCountsByStatus(ctx())).resolves.toMatchObject({ + totalAssets: 0, + statusCounts: { + active: 0, + in_storage: 0, + under_repair: 0, + retired: 0, + disposed: 0, + }, + }); + }); + + it("counts assets by status", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId: a } = await createAsset(ctx(), { + name: "A", + actorId: admin.id, + }); + const { assetId: b } = await createAsset(ctx(), { + name: "B", + actorId: admin.id, + }); + await createAsset(ctx(), { name: "C", actorId: admin.id }); + await updateAssetStatus(ctx(), { + assetId: a, + status: "retired", + actorId: admin.id, + }); + await updateAssetStatus(ctx(), { + assetId: b, + status: "in_storage", + actorId: admin.id, + }); + + const counts = await getAssetCountsByStatus(ctx()); + expect(counts.totalAssets).toBe(3); + expect(counts.statusCounts.active).toBe(1); + expect(counts.statusCounts.retired).toBe(1); + expect(counts.statusCounts.in_storage).toBe(1); + }); + + it("returns up to 10 most-recently-updated assets", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + for (let i = 0; i < 12; i++) { + await createAsset(ctx(), { name: `Asset ${i}`, actorId: admin.id }); + } + const recents = await getRecentAssets(ctx()); + expect(recents.length).toBeLessThanOrEqual(10); + }); + + it("returns an overview with date format, breakdowns, and upcoming", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await updateDateFormat(ctx(), { + dateFormat: "YYYY-MM-DD", + actorId: admin.id, + }); + const it = await createCategory(ctx(), { + name: "IT", + prefix: "IT", + color: "#2563EB", + }); + const warehouse = await createLocation(ctx(), { name: "Warehouse" }); + await createAsset(ctx(), { + name: "Laptop", + categoryId: it.id, + locationId: warehouse.id, + actorId: admin.id, + }); + await createAsset(ctx(), { + name: "Pen", + categoryId: it.id, + actorId: admin.id, + }); + + const overview = await getOverview(ctx()); + expect(overview.dateFormat).toBe("YYYY-MM-DD"); + expect(overview.totalAssets).toBe(2); + expect( + overview.categoryBreakdown.find((entry) => entry.name === "IT")?.count, + ).toBe(2); + expect( + overview.locationBreakdown.find( + (entry) => entry.name === "Warehouse", + )?.count, + ).toBe(1); + }); + + it("upcoming services respects the serviceSchedulingEnabled toggle", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await pb.collection("serviceSchedules").create({ + assetId, + nextServiceDate: "2020-01-01", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + createdAt: Date.now(), + updatedAt: Date.now(), + createdBy: admin.id, + updatedBy: admin.id, + }); + + const enabled = await getUpcomingServicesPreview(ctx()); + expect(enabled.overdueCount).toBeGreaterThanOrEqual(1); + expect(enabled.items.length).toBeGreaterThanOrEqual(1); + + // Disable scheduling and confirm the preview empties. + const admin2 = await seedAdmin(pb); + const { updateServiceSchedulingEnabled } = await import( + "@/server/domain/appSettings" + ); + await updateServiceSchedulingEnabled(ctx(), { + enabled: false, + actorId: admin2.id, + }); + await expect(getUpcomingServicesPreview(ctx())).resolves.toMatchObject({ + serviceSchedulingEnabled: false, + overdueCount: 0, + items: [], + }); + }); +}); diff --git a/src/server/domain/__tests__/search.test.ts b/src/server/domain/__tests__/search.test.ts new file mode 100644 index 0000000..ccba801 --- /dev/null +++ b/src/server/domain/__tests__/search.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { createAsset } from "@/server/domain/assets"; +import { createCategory } from "@/server/domain/categories"; +import { createLocation } from "@/server/domain/locations"; +import { searchAssets } from "@/server/domain/search"; +import type { Ctx } from "@/server/pb/context"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("searchAssets", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns an empty list for very short queries", async () => { + await expect(searchAssets(ctx(), "a")).resolves.toEqual([]); + await expect(searchAssets(ctx(), " ")).resolves.toEqual([]); + }); + + it("ranks exact assetTag hits above name matches above notes", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const it = await createCategory(ctx(), { + name: "IT", + prefix: "LAP", + color: "#2563EB", + }); + + const byTag = await createAsset(ctx(), { + name: "Office keyboard", + notes: "backup keyboard", + categoryId: it.id, + actorId: admin.id, + }); + const byName = await createAsset(ctx(), { + name: "LAP device", + actorId: admin.id, + }); + const byNotes = await createAsset(ctx(), { + name: "Pen holder", + notes: "Contains LAP stickers", + actorId: admin.id, + }); + + const results = await searchAssets(ctx(), "LAP-0001"); + expect(results[0].id).toBe(byTag.assetId); + + const nameHits = await searchAssets(ctx(), "LAP"); + // assetTag prefix > name prefix > notes, so LAP-0001 asset first, + // then "LAP device", then the notes-only hit. + expect(nameHits.map((row) => row.id)).toEqual([ + byTag.assetId, + byName.assetId, + byNotes.assetId, + ]); + }); + + it("joins category name and location path onto results", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const category = await createCategory(ctx(), { + name: "IT", + prefix: "IT", + color: "#2563EB", + }); + const location = await createLocation(ctx(), { name: "Warehouse" }); + await createAsset(ctx(), { + name: "Thinkpad X1", + categoryId: category.id, + locationId: location.id, + actorId: admin.id, + }); + + const results = await searchAssets(ctx(), "thinkpad"); + expect(results[0]).toMatchObject({ + categoryName: "IT", + locationPath: "Warehouse", + }); + }); + + it("caps results at the configured limit", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + for (let i = 0; i < 15; i++) { + await createAsset(ctx(), { + name: `Laptop ${i}`, + actorId: admin.id, + }); + } + const results = await searchAssets(ctx(), "laptop"); + expect(results.length).toBeLessThanOrEqual(10); + }); +}); diff --git a/src/server/domain/dashboard.ts b/src/server/domain/dashboard.ts new file mode 100644 index 0000000..a5798f0 --- /dev/null +++ b/src/server/domain/dashboard.ts @@ -0,0 +1,326 @@ +import type { Ctx } from "@/server/pb/context"; +import { ASSET_STATUSES, type AssetStatus } from "@/server/pb/assets"; +import { + APP_DATE_FORMATS, + type AppDateFormat, +} from "@/server/pb/custom-fields"; + +const DEFAULT_DATE_FORMAT: AppDateFormat = "DD-MM-YYYY"; +const DEFAULT_SERVICE_SCHEDULING_ENABLED = true; + +export type StatusCounts = Record; + +export type RecentAsset = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + updatedAt: number; +}; + +export type CategoryBreakdownItem = { + id: string; + name: string; + color: string; + count: number; +}; + +export type LocationBreakdownItem = { + id: string; + name: string; + count: number; +}; + +export type UpcomingServiceItem = { + scheduleId: string; + assetId: string; + assetName: string; + assetTag: string; + assetStatus: AssetStatus; + nextServiceDate: string; +}; + +export type UpcomingServicesPreview = { + serviceSchedulingEnabled: boolean; + overdueCount: number; + items: UpcomingServiceItem[]; +}; + +export type DashboardOverview = { + dateFormat: AppDateFormat; + totalAssets: number; + statusCounts: StatusCounts; + recentAssets: RecentAsset[]; + categoryBreakdown: CategoryBreakdownItem[]; + locationBreakdown: LocationBreakdownItem[]; + serviceSchedulingEnabled: boolean; + overdueServiceCount: number; + upcomingServices: UpcomingServiceItem[]; +}; + +type AssetRow = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryId: string; + locationId: string; + createdAt: number; + updatedAt: number; +}; + +type ServiceScheduleRow = { + id: string; + assetId: string; + nextServiceDate: string; +}; + +type AppSettingsRow = { + dateFormat?: AppDateFormat; + serviceSchedulingEnabled?: boolean | null; +}; + +type CategoryRow = { id: string; name: string; color: string }; +type LocationRow = { id: string; name: string }; + +function emptyStatusCounts(): StatusCounts { + return { + active: 0, + in_storage: 0, + under_repair: 0, + retired: 0, + disposed: 0, + }; +} + +async function readAllAssets(ctx: Ctx) { + return ctx.pb.collection("assets").getFullList(); +} + +async function readSettings(ctx: Ctx) { + try { + const row = await ctx.pb + .collection("appSettings") + .getFirstListItem('key = "global"'); + const dateFormat = ( + APP_DATE_FORMATS as readonly string[] + ).includes(row.dateFormat ?? "") + ? (row.dateFormat as AppDateFormat) + : DEFAULT_DATE_FORMAT; + return { + dateFormat, + serviceSchedulingEnabled: + row.serviceSchedulingEnabled ?? DEFAULT_SERVICE_SCHEDULING_ENABLED, + }; + } catch { + return { + dateFormat: DEFAULT_DATE_FORMAT, + serviceSchedulingEnabled: DEFAULT_SERVICE_SCHEDULING_ENABLED, + }; + } +} + +function buildStatusCounts(assets: AssetRow[]): StatusCounts { + const counts = emptyStatusCounts(); + for (const asset of assets) counts[asset.status] += 1; + return counts; +} + +function buildRecentAssets(assets: AssetRow[]): RecentAsset[] { + return assets + .slice() + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 10) + .map((asset) => ({ + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + status: asset.status, + updatedAt: asset.updatedAt, + })); +} + +function compareBreakdown( + a: T, + b: T, +) { + if (b.count !== a.count) return b.count - a.count; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); +} + +async function buildCategoryBreakdown(ctx: Ctx, assets: AssetRow[]) { + const counts = new Map(); + for (const asset of assets) { + if (!asset.categoryId) continue; + counts.set(asset.categoryId, (counts.get(asset.categoryId) ?? 0) + 1); + } + const ids = Array.from(counts.keys()); + const categories = await Promise.all( + ids.map((id) => + ctx.pb + .collection("categories") + .getOne(id) + .catch(() => null), + ), + ); + return categories + .flatMap((category) => + category + ? [ + { + id: category.id, + name: category.name, + color: category.color, + count: counts.get(category.id) ?? 0, + }, + ] + : [], + ) + .sort(compareBreakdown); +} + +async function buildLocationBreakdown(ctx: Ctx, assets: AssetRow[]) { + const counts = new Map(); + for (const asset of assets) { + if (!asset.locationId) continue; + counts.set(asset.locationId, (counts.get(asset.locationId) ?? 0) + 1); + } + const ids = Array.from(counts.keys()); + const locations = await Promise.all( + ids.map((id) => + ctx.pb + .collection("locations") + .getOne(id) + .catch(() => null), + ), + ); + return locations + .flatMap((location) => + location + ? [ + { + id: location.id, + name: location.name, + count: counts.get(location.id) ?? 0, + }, + ] + : [], + ) + .sort(compareBreakdown); +} + +async function buildUpcomingServicesPreview( + ctx: Ctx, + serviceSchedulingEnabled: boolean, +): Promise { + if (!serviceSchedulingEnabled) { + return { + serviceSchedulingEnabled: false, + overdueCount: 0, + items: [], + }; + } + + const todayIso = new Date().toISOString().slice(0, 10); + const schedules = await ctx.pb + .collection("serviceSchedules") + .getFullList({ sort: "nextServiceDate" }); + + if (schedules.length === 0) { + return { + serviceSchedulingEnabled: true, + overdueCount: 0, + items: [], + }; + } + + const previewSchedules = schedules.slice(0, 5); + const assets = await Promise.all( + previewSchedules.map((schedule) => + ctx.pb + .collection("assets") + .getOne(schedule.assetId) + .catch(() => null), + ), + ); + + const items: UpcomingServiceItem[] = []; + previewSchedules.forEach((schedule, index) => { + const asset = assets[index]; + if (!asset) return; + items.push({ + scheduleId: schedule.id, + assetId: schedule.assetId, + assetName: asset.name, + assetTag: asset.assetTag, + assetStatus: asset.status, + nextServiceDate: schedule.nextServiceDate, + }); + }); + + return { + serviceSchedulingEnabled: true, + overdueCount: schedules.filter((s) => s.nextServiceDate < todayIso).length, + items, + }; +} + +export async function getAssetCountsByStatus( + ctx: Ctx, +): Promise<{ totalAssets: number; statusCounts: StatusCounts }> { + const statusCounts = emptyStatusCounts(); + let totalAssets = 0; + for (const status of ASSET_STATUSES) { + const { totalItems } = await ctx.pb + .collection("assets") + .getList(1, 1, { filter: `status = "${status}"` }); + statusCounts[status] = totalItems; + totalAssets += totalItems; + } + return { totalAssets, statusCounts }; +} + +export async function getRecentAssets(ctx: Ctx): Promise { + const result = await ctx.pb + .collection("assets") + .getList(1, 10, { sort: "-updatedAt" }); + return result.items.map((asset) => ({ + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + status: asset.status, + updatedAt: asset.updatedAt, + })); +} + +export async function getUpcomingServicesPreview( + ctx: Ctx, +): Promise { + const settings = await readSettings(ctx); + return buildUpcomingServicesPreview(ctx, settings.serviceSchedulingEnabled); +} + +export async function getOverview(ctx: Ctx): Promise { + const [assets, settings] = await Promise.all([ + readAllAssets(ctx), + readSettings(ctx), + ]); + const [categoryBreakdown, locationBreakdown, upcomingPreview] = + await Promise.all([ + buildCategoryBreakdown(ctx, assets), + buildLocationBreakdown(ctx, assets), + buildUpcomingServicesPreview(ctx, settings.serviceSchedulingEnabled), + ]); + + return { + dateFormat: settings.dateFormat, + totalAssets: assets.length, + statusCounts: buildStatusCounts(assets), + recentAssets: buildRecentAssets(assets), + categoryBreakdown, + locationBreakdown, + serviceSchedulingEnabled: upcomingPreview.serviceSchedulingEnabled, + overdueServiceCount: upcomingPreview.overdueCount, + upcomingServices: upcomingPreview.items, + }; +} diff --git a/src/server/domain/search.ts b/src/server/domain/search.ts new file mode 100644 index 0000000..4f71c74 --- /dev/null +++ b/src/server/domain/search.ts @@ -0,0 +1,133 @@ +import type { Ctx } from "@/server/pb/context"; +import type { AssetStatus } from "@/server/pb/assets"; + +export type AssetSearchResult = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryName: string | null; + locationPath: string | null; +}; + +type AssetRow = { + id: string; + name: string; + normalizedName: string; + assetTag: string; + status: AssetStatus; + categoryId: string; + locationId: string; + notes: string; + updatedAt: number; +}; + +type CategoryRow = { id: string; name: string }; +type LocationRow = { id: string; path: string }; + +function normalizeTerm(value: string) { + return value.trim().toLocaleLowerCase(); +} + +function getMatchScore(asset: AssetRow, needle: string) { + const assetTag = asset.assetTag.toLocaleLowerCase(); + const name = asset.normalizedName; + const notes = (asset.notes ?? "").toLocaleLowerCase(); + let score = 0; + + if (assetTag === needle) score += 600; + else if (assetTag.startsWith(needle)) score += 450; + else if (assetTag.includes(needle)) score += 320; + + if (name === needle) score += 420; + else if (name.startsWith(needle)) score += 300; + else if (name.includes(needle)) score += 180; + + if (notes.includes(needle)) score += 90; + return score; +} + +/** + * Lightweight weighted search matching the Convex implementation. The FTS5 + * virtual table on the assets collection is available for a future upgrade to + * bm25-style ranking once we need it; at current data volumes the scan is + * cheap enough and the scoring rules are easier to tweak in TypeScript. + */ +export async function searchAssets( + ctx: Ctx, + term: string, + limit = 10, +): Promise { + const needle = normalizeTerm(term); + if (needle.length < 2) return []; + const cappedLimit = Math.max(1, Math.min(limit, 10)); + + const assets = await ctx.pb + .collection("assets") + .getFullList(); + + type Match = { asset: AssetRow; score: number }; + const matches: Match[] = assets + .map((asset) => ({ asset, score: getMatchScore(asset, needle) })) + .filter((entry) => entry.score > 0) + .sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + if (right.asset.updatedAt !== left.asset.updatedAt) { + return right.asset.updatedAt - left.asset.updatedAt; + } + return left.asset.name.localeCompare(right.asset.name, undefined, { + sensitivity: "base", + }); + }) + .slice(0, cappedLimit); + + const categoryIds = [ + ...new Set( + matches.map((match) => match.asset.categoryId).filter((id) => !!id), + ), + ]; + const locationIds = [ + ...new Set( + matches.map((match) => match.asset.locationId).filter((id) => !!id), + ), + ]; + + const [categories, locations] = await Promise.all([ + Promise.all( + categoryIds.map((id) => + ctx.pb + .collection("categories") + .getOne(id) + .catch(() => null), + ), + ), + Promise.all( + locationIds.map((id) => + ctx.pb + .collection("locations") + .getOne(id) + .catch(() => null), + ), + ), + ]); + + const categoryById = new Map( + (categories.filter(Boolean) as CategoryRow[]).map((c) => [c.id, c]), + ); + const locationById = new Map( + (locations.filter(Boolean) as LocationRow[]).map((l) => [l.id, l]), + ); + + return matches.map(({ asset }) => ({ + id: asset.id, + name: asset.name, + assetTag: asset.assetTag, + status: asset.status, + categoryName: asset.categoryId + ? (categoryById.get(asset.categoryId)?.name ?? null) + : null, + locationPath: asset.locationId + ? (locationById.get(asset.locationId)?.path ?? null) + : null, + })); +} From 67c4b6f25100b9716b1923804ee014444b10978e Mon Sep 17 00:00:00 2001 From: Sauer Date: Sun, 19 Apr 2026 23:00:01 +0200 Subject: [PATCH 08/37] Port service schedules and records to PocketBase Wraps up the domain side of the service workflow. - serviceSchedules: getScheduleByAssetId, upsertSchedule (snaps same-day requests forward by one interval, computed reminderStartDate), deleteSchedule, listScheduledAssets, listCalendarMonth (by_nextServiceDate bounds), and listUpcomingServiceDueInDays. Honors the global serviceSchedulingEnabled toggle. Interval math helpers (days / weeks / months / years) landed in src/server/pb/service-schedule.ts. - serviceRecords: getRecordFormDefinition (uses a record's saved field snapshots when present, falls back to current group fields), listAssetRecords with group / provider / completedBy joins and sorted value entries, createRecord (snapshots the group fields at completion time, advances the associated schedule), updateRecord / deleteRecord with actor-role access check, and completeScheduledService which advances the schedule. - normalizeServiceRecordValues lives in src/server/pb/service-catalog.ts so the field normalization rules stay in one place. PB test count: 168 across 16 files (11 service schedule + 10 service record cases added). --- .../domain/__tests__/serviceRecords.test.ts | 311 +++++++ .../domain/__tests__/serviceSchedules.test.ts | 277 ++++++ src/server/domain/serviceRecords.ts | 786 ++++++++++++++++++ src/server/domain/serviceSchedules.ts | 428 ++++++++++ src/server/pb/service-catalog.ts | 103 +++ src/server/pb/service-schedule.ts | 188 +++++ 6 files changed, 2093 insertions(+) create mode 100644 src/server/domain/__tests__/serviceRecords.test.ts create mode 100644 src/server/domain/__tests__/serviceSchedules.test.ts create mode 100644 src/server/domain/serviceRecords.ts create mode 100644 src/server/domain/serviceSchedules.ts create mode 100644 src/server/pb/service-schedule.ts diff --git a/src/server/domain/__tests__/serviceRecords.test.ts b/src/server/domain/__tests__/serviceRecords.test.ts new file mode 100644 index 0000000..691db4d --- /dev/null +++ b/src/server/domain/__tests__/serviceRecords.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; + +import { createAsset, updateAsset } from "@/server/domain/assets"; +import { + completeScheduledService, + createRecord, + deleteRecord, + getRecordFormDefinition, + listAssetRecords, + updateRecord, +} from "@/server/domain/serviceRecords"; +import { createGroup } from "@/server/domain/serviceGroups"; +import { createField } from "@/server/domain/serviceGroupFields"; +import { createProvider } from "@/server/domain/serviceProviders"; +import { upsertSchedule } from "@/server/domain/serviceSchedules"; +import type { Ctx } from "@/server/pb/context"; +import { + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"], name = "Admin User") { + return pb.collection("users").create({ + name, + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +function yesterday() { + const d = new Date(Date.now() - 24 * 60 * 60 * 1000); + const y = String(d.getUTCFullYear()).padStart(4, "0"); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${dd}`; +} + +describe("serviceRecords domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("getRecordFormDefinition returns asset metadata with empty fields", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const form = await getRecordFormDefinition(ctx(), { assetId }); + expect(form).toMatchObject({ + assetId, + assetName: "Laptop", + serviceGroupId: null, + fields: [], + }); + }); + + it("returns the service group's fields when the asset is assigned to one", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "text", + required: true, + actorId: admin.id, + }); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await updateAsset(ctx(), { + assetId, + serviceGroupId: group.id, + actorId: admin.id, + }); + const form = await getRecordFormDefinition(ctx(), { assetId }); + expect(form.serviceGroupId).toBe(group.id); + expect(form.fields.map((f) => f.label)).toEqual(["Outcome"]); + }); + + it("createRecord validates required fields and persists snapshots", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const group = await createGroup(ctx(), { + name: "Inspection", + actorId: admin.id, + }); + const outcome = await createField(ctx(), { + groupId: group.id, + label: "Outcome", + fieldType: "select", + required: true, + options: ["Pass", "Fail"], + actorId: admin.id, + }); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + serviceGroupId: group.id, + actorId: admin.id, + }); + + await expect( + createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "Inspected", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + + const { recordId } = await createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "Inspected", + values: { [outcome.id]: "Pass" }, + actorId: admin.id, + }); + + const records = await listAssetRecords(ctx(), assetId); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + id: recordId, + serviceGroupId: group.id, + serviceGroupName: "Inspection", + description: "Inspected", + providerId: null, + }); + expect(records[0].valueEntries[0]).toMatchObject({ + fieldId: outcome.id, + label: "Outcome", + value: "Pass", + }); + }); + + it("rejects a service date in the future", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const future = new Date(Date.now() + 48 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + await expect( + createRecord(ctx(), { + assetId, + serviceDate: future, + description: "x", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("updates a record and preserves the field snapshot", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { recordId } = await createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "First pass", + actorId: admin.id, + }); + await updateRecord(ctx(), { + recordId, + serviceDate: yesterday(), + description: "Revised notes", + actorId: admin.id, + actorRole: "admin", + }); + const records = await listAssetRecords(ctx(), assetId); + expect(records[0].description).toBe("Revised notes"); + }); + + it("deletes a record", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { recordId } = await createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "First pass", + actorId: admin.id, + }); + await deleteRecord(ctx(), recordId, { id: admin.id, role: "admin" }); + await expect(listAssetRecords(ctx(), assetId)).resolves.toEqual([]); + }); + + it("completing a scheduled service advances the schedule", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const schedule = await upsertSchedule(ctx(), { + assetId, + nextServiceDate: "2030-06-01", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }); + + const result = await completeScheduledService(ctx(), { + scheduleId: schedule.id, + serviceDate: yesterday(), + description: "Performed maintenance", + actorId: admin.id, + }); + const expectedNext = (() => { + const [y, m, d] = yesterday().split("-").map(Number); + const next = new Date(Date.UTC(y, m - 1, d + 30)); + return `${String(next.getUTCFullYear()).padStart(4, "0")}-${String(next.getUTCMonth() + 1).padStart(2, "0")}-${String(next.getUTCDate()).padStart(2, "0")}`; + })(); + expect(result.nextServiceDate).toBe(expectedNext); + + const updated = await pb + .collection("serviceSchedules") + .getOne<{ nextServiceDate: string }>(schedule.id); + expect(updated.nextServiceDate).toBe(expectedNext); + }); + + it("createRecord with a provider links it", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const provider = await createProvider(ctx(), { + name: "Acme", + actorId: admin.id, + }); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { recordId } = await createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "Checked", + providerId: provider.id, + actorId: admin.id, + }); + const records = await listAssetRecords(ctx(), assetId); + expect(records.find((r) => r.id === recordId)).toMatchObject({ + providerId: provider.id, + providerName: "Acme", + }); + }); + + it("throws NotFoundError when the asset or schedule is missing", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createRecord(ctx(), { + assetId: "nonexistent0000", + serviceDate: yesterday(), + description: "x", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + completeScheduledService(ctx(), { + scheduleId: "nonexistent0000", + serviceDate: yesterday(), + description: "x", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("rejects non-admin actors editing another user's record", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const otherUser = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { recordId } = await createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "Owned by admin", + actorId: admin.id, + }); + await expect( + updateRecord(ctx(), { + recordId, + serviceDate: yesterday(), + description: "Tampered", + actorId: otherUser.id, + actorRole: "user", + }), + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/src/server/domain/__tests__/serviceSchedules.test.ts b/src/server/domain/__tests__/serviceSchedules.test.ts new file mode 100644 index 0000000..34f5185 --- /dev/null +++ b/src/server/domain/__tests__/serviceSchedules.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "vitest"; + +import { updateServiceSchedulingEnabled } from "@/server/domain/appSettings"; +import { createAsset } from "@/server/domain/assets"; +import { + deleteSchedule, + getScheduleByAssetId, + listCalendarMonth, + listScheduledAssets, + listUpcomingServiceDueInDays, + upsertSchedule, +} from "@/server/domain/serviceSchedules"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + addIntervalToIsoDate, + ensureReminderWithinInterval, + getMonthRange, + getTodayIsoDate, + subtractIntervalFromIsoDate, +} from "@/server/pb/service-schedule"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +describe("service schedule helpers", () => { + it("adds and subtracts intervals across units", () => { + expect( + addIntervalToIsoDate({ date: "2024-01-31", value: 1, unit: "months" }), + ).toBe("2024-03-02"); + expect( + addIntervalToIsoDate({ date: "2024-02-29", value: 1, unit: "years" }), + ).toBe("2025-03-01"); + expect( + subtractIntervalFromIsoDate({ + date: "2024-03-15", + value: 7, + unit: "days", + }), + ).toBe("2024-03-08"); + }); + + it("enforces reminder lead within interval", () => { + expect(() => + ensureReminderWithinInterval({ + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 30, + reminderLeadUnit: "days", + }), + ).not.toThrow(); + expect(() => + ensureReminderWithinInterval({ + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 2, + reminderLeadUnit: "months", + }), + ).toThrow(ValidationError); + expect(() => + ensureReminderWithinInterval({ + intervalValue: 0, + intervalUnit: "days", + reminderLeadValue: 0, + reminderLeadUnit: "days", + }), + ).toThrow(ValidationError); + }); + + it("getMonthRange returns inclusive-start / exclusive-end bounds", () => { + expect(getMonthRange({ year: 2024, month: 2 })).toEqual({ + monthStart: "2024-02-01", + nextMonthStart: "2024-03-01", + }); + expect(() => getMonthRange({ year: 2024, month: 13 })).toThrow( + ValidationError, + ); + }); +}); + +describe("serviceSchedules domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("returns null when no schedule exists for an asset", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await expect(getScheduleByAssetId(ctx(), assetId)).resolves.toBeNull(); + }); + + it("creates a schedule and computes reminderStartDate", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const schedule = await upsertSchedule(ctx(), { + assetId, + nextServiceDate: "2030-06-01", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }); + expect(schedule).toMatchObject({ + nextServiceDate: "2030-06-01", + reminderStartDate: "2030-05-25", + }); + }); + + it("advances nextServiceDate by interval when requested date is today", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const today = getTodayIsoDate(); + const schedule = await upsertSchedule(ctx(), { + assetId, + nextServiceDate: today, + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 0, + reminderLeadUnit: "days", + actorId: admin.id, + }); + expect(schedule.nextServiceDate).not.toBe(today); + }); + + it("updates an existing schedule on repeated upsert", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await upsertSchedule(ctx(), { + assetId, + nextServiceDate: "2030-06-01", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }); + const second = await upsertSchedule(ctx(), { + assetId, + nextServiceDate: "2030-07-01", + intervalValue: 60, + intervalUnit: "days", + reminderLeadValue: 14, + reminderLeadUnit: "days", + actorId: admin.id, + }); + expect(second).toMatchObject({ + nextServiceDate: "2030-07-01", + intervalValue: 60, + reminderLeadValue: 14, + }); + const lookup = await getScheduleByAssetId(ctx(), assetId); + expect(lookup?.id).toBe(second.id); + }); + + it("deletes an existing schedule (idempotent when absent)", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await upsertSchedule(ctx(), { + assetId, + nextServiceDate: "2030-06-01", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }); + + await expect(deleteSchedule(ctx(), assetId)).resolves.toBeUndefined(); + await expect(deleteSchedule(ctx(), assetId)).resolves.toBeUndefined(); + await expect(getScheduleByAssetId(ctx(), assetId)).resolves.toBeNull(); + }); + + it("rejects scheduling ops when globally disabled", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await updateServiceSchedulingEnabled(ctx(), { + enabled: false, + actorId: admin.id, + }); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + + await expect( + upsertSchedule(ctx(), { + assetId, + nextServiceDate: "2030-06-01", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("throws NotFoundError for missing assets", async () => { + await expect( + getScheduleByAssetId(ctx(), "nonexistent0000"), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("lists schedules by calendar month and upcoming window", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId: laptop } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { assetId: monitor } = await createAsset(ctx(), { + name: "Monitor", + actorId: admin.id, + }); + await upsertSchedule(ctx(), { + assetId: laptop, + nextServiceDate: "2030-06-15", + intervalValue: 30, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }); + await upsertSchedule(ctx(), { + assetId: monitor, + nextServiceDate: "2030-07-15", + intervalValue: 60, + intervalUnit: "days", + reminderLeadValue: 7, + reminderLeadUnit: "days", + actorId: admin.id, + }); + + const month = await listCalendarMonth(ctx(), { year: 2030, month: 6 }); + expect(month).toHaveLength(1); + expect(month[0].assetId).toBe(laptop); + + const listed = await listScheduledAssets(ctx()); + expect(listed.map((item) => item.assetId)).toEqual([laptop, monitor]); + + await expect( + listUpcomingServiceDueInDays(ctx(), 0), + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/src/server/domain/serviceRecords.ts b/src/server/domain/serviceRecords.ts new file mode 100644 index 0000000..9cd5d91 --- /dev/null +++ b/src/server/domain/serviceRecords.ts @@ -0,0 +1,786 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + normalizeServiceRecordValues, + type ServiceGroupFieldType, +} from "@/server/pb/service-catalog"; +import { + addIntervalToIsoDate, + normalizeServiceIntervalUnit, + requireIsoDate, + type ServiceIntervalUnit, +} from "@/server/pb/service-schedule"; + +type RecordValue = string | number | boolean | null; + +const recordValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); +const valuesSchema = z.record(z.string(), recordValueSchema); + +export const CreateRecordInput = z.object({ + assetId: z.string(), + serviceDate: z.string(), + description: z.string(), + cost: z.number().nullish(), + providerId: z.string().nullish(), + values: valuesSchema.optional(), + actorId: z.string(), +}); + +export const UpdateRecordInput = z.object({ + recordId: z.string(), + serviceDate: z.string(), + description: z.string(), + cost: z.number().nullish(), + providerId: z.string().nullish(), + values: valuesSchema.optional(), + actorId: z.string(), + actorRole: z.enum(["admin", "user"]).default("user"), +}); + +export const CompleteScheduledServiceInput = z.object({ + scheduleId: z.string(), + serviceDate: z.string(), + description: z.string(), + cost: z.number().nullish(), + providerId: z.string().nullish(), + values: valuesSchema.optional(), + actorId: z.string(), +}); + +export type CreateRecordInput = z.infer; +export type UpdateRecordInput = z.infer; +export type CompleteScheduledServiceInput = z.infer< + typeof CompleteScheduledServiceInput +>; + +export type RecordFieldDefinition = { + id: string; + label: string; + fieldType: ServiceGroupFieldType; + required: boolean; + options: string[]; + sortOrder: number; +}; + +export type RecordFormDefinition = { + assetId: string; + assetName: string; + assetTag: string; + serviceGroupId: string | null; + serviceGroupName: string | null; + scheduleId: string | null; + nextServiceDate: string | null; + fields: RecordFieldDefinition[]; +}; + +export type RecordValueEntry = { + fieldId: string; + label: string; + value: RecordValue; +}; + +export type ServiceRecordView = { + id: string; + assetId: string; + serviceGroupId: string | null; + serviceGroupName: string | null; + values: Record; + valueEntries: RecordValueEntry[]; + scheduleId: string | null; + scheduledForDate: string | null; + serviceDate: string; + description: string; + cost: number | null; + providerId: string | null; + providerName: string | null; + completedAt: number; + completedBy: string; + completedByName: string; + createdAt: number; + updatedAt: number; +}; + +type StoredFieldSnapshot = { + fieldId: string; + label: string; + fieldType: ServiceGroupFieldType; + required: boolean; + options: string[]; + sortOrder: number; +}; + +type AssetRow = { + id: string; + name: string; + assetTag: string; + serviceGroupId?: string; +}; + +type ServiceGroupRow = { id: string; name: string }; + +type ServiceGroupFieldRow = { + id: string; + groupId: string; + label: string; + fieldType: ServiceGroupFieldType; + required: boolean; + options: string[] | null; + sortOrder: number; +}; + +type ServiceScheduleRow = { + id: string; + assetId: string; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; +}; + +type ServiceRecordRow = { + id: string; + assetId: string; + serviceGroupId: string; + serviceGroupNameSnapshot: string; + values: Record | null; + fieldSnapshots: StoredFieldSnapshot[] | null; + scheduleId: string; + scheduledForDate: string; + serviceDate: string; + description: string; + cost?: number | null; + providerId?: string; + completedAt: number; + completedBy: string; + createdAt: number; + updatedAt: number; +}; + +type UserRow = { id: string; name: string; email: string }; + +type ServiceProviderRow = { id: string; name: string }; + +function escapeFilter(value: string) { + return value.replace(/"/g, '\\"'); +} + +function timestampToIsoDate(ms: number) { + const date = new Date(ms); + const year = String(date.getUTCFullYear()).padStart(4, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function requireServiceDate(value: string) { + const serviceDate = requireIsoDate(value); + const today = timestampToIsoDate(Date.now()); + if (serviceDate > today) { + throw new ValidationError("Service date cannot be in the future"); + } + return serviceDate; +} + +function requireServiceDescription(value: string) { + const description = value.trim(); + if (!description) { + throw new ValidationError("Description is required"); + } + return description; +} + +function normalizeServiceCost(value: number | null | undefined) { + if (value === null || value === undefined) return null; + if (!Number.isFinite(value) || value < 0) { + throw new ValidationError("Cost must be a positive number"); + } + return value; +} + +async function requireAsset(ctx: Ctx, assetId: string): Promise { + try { + return await ctx.pb.collection("assets").getOne(assetId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Asset not found"); + } + throw error; + } +} + +async function requireRecord( + ctx: Ctx, + recordId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceRecords") + .getOne(recordId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service record not found"); + } + throw error; + } +} + +async function requireSchedule( + ctx: Ctx, + scheduleId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceSchedules") + .getOne(scheduleId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Schedule not found"); + } + throw error; + } +} + +async function requireProvider( + ctx: Ctx, + providerId: string, +): Promise { + try { + await ctx.pb.collection("serviceProviders").getOne(providerId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new ValidationError("Service provider not found"); + } + throw error; + } +} + +async function getScheduleForAsset( + ctx: Ctx, + assetId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceSchedules") + .getFirstListItem( + `assetId = "${escapeFilter(assetId)}"`, + ); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +async function getGroup( + ctx: Ctx, + groupId: string | null, +): Promise { + if (!groupId) return null; + try { + return await ctx.pb + .collection("serviceGroups") + .getOne(groupId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +async function listFieldsForGroup( + ctx: Ctx, + groupId: string, +): Promise { + return ctx.pb + .collection("serviceGroupFields") + .getFullList({ + filter: `groupId = "${escapeFilter(groupId)}"`, + sort: "sortOrder", + }); +} + +function toFieldDefinition( + field: ServiceGroupFieldRow, +): RecordFieldDefinition { + return { + id: field.id, + label: field.label, + fieldType: field.fieldType, + required: !!field.required, + options: Array.isArray(field.options) ? field.options : [], + sortOrder: field.sortOrder, + }; +} + +function snapshotsToDefinitions( + snapshots: StoredFieldSnapshot[], +): RecordFieldDefinition[] { + return snapshots.map((snapshot) => ({ + id: snapshot.fieldId, + label: snapshot.label, + fieldType: snapshot.fieldType, + required: !!snapshot.required, + options: Array.isArray(snapshot.options) ? snapshot.options : [], + sortOrder: snapshot.sortOrder, + })); +} + +function definitionsToSnapshots( + definitions: RecordFieldDefinition[], +): StoredFieldSnapshot[] { + return definitions.map((definition) => ({ + fieldId: definition.id, + label: definition.label, + fieldType: definition.fieldType, + required: definition.required, + options: [...definition.options], + sortOrder: definition.sortOrder, + })); +} + +function normalizeForGroup({ + fields, + values, +}: { + fields: RecordFieldDefinition[]; + values: Record | undefined; +}): Record { + if (fields.length === 0) { + if (values && Object.keys(values).length > 0) { + throw new ValidationError( + "This asset does not require configurable service fields", + ); + } + return {}; + } + return normalizeServiceRecordValues({ + fields: fields.map((f) => ({ + _id: f.id, + label: f.label, + fieldType: f.fieldType, + required: f.required, + options: f.options, + })), + values: values ?? {}, + }); +} + +function buildValueEntries({ + record, + fields, +}: { + record: ServiceRecordRow; + fields: RecordFieldDefinition[]; +}): RecordValueEntry[] { + const values = record.values ?? {}; + const fieldIdSet = new Set(fields.map((f) => f.id)); + const labelById = new Map(fields.map((f) => [f.id, f.label])); + + const ordered = fields + .filter((f) => Object.prototype.hasOwnProperty.call(values, f.id)) + .map((field) => ({ + fieldId: field.id, + label: field.label, + value: values[field.id] ?? null, + })); + + const unknown = Object.entries(values) + .filter(([fieldId]) => !fieldIdSet.has(fieldId)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([fieldId, value]) => ({ + fieldId, + label: labelById.get(fieldId) ?? fieldId, + value, + })); + + return [...ordered, ...unknown]; +} + +export async function getRecordFormDefinition( + ctx: Ctx, + args: { assetId: string; recordId?: string }, +): Promise { + const asset = await requireAsset(ctx, args.assetId); + const record = args.recordId ? await requireRecord(ctx, args.recordId) : null; + if (record && record.assetId !== asset.id) { + throw new NotFoundError("Service record does not belong to this asset"); + } + const schedule = await getScheduleForAsset(ctx, asset.id); + + let fields: RecordFieldDefinition[]; + let groupId: string | null = null; + let groupName: string | null = null; + + if (record) { + groupId = record.serviceGroupId || null; + if (record.fieldSnapshots && record.fieldSnapshots.length > 0) { + fields = snapshotsToDefinitions(record.fieldSnapshots); + groupName = record.serviceGroupNameSnapshot || null; + } else if (groupId) { + const [group, groupFields] = await Promise.all([ + getGroup(ctx, groupId), + listFieldsForGroup(ctx, groupId), + ]); + groupName = group?.name ?? null; + fields = groupFields.map(toFieldDefinition); + } else { + fields = []; + } + } else { + groupId = asset.serviceGroupId || null; + if (groupId) { + const [group, groupFields] = await Promise.all([ + getGroup(ctx, groupId), + listFieldsForGroup(ctx, groupId), + ]); + groupName = group?.name ?? null; + fields = groupFields.map(toFieldDefinition); + } else { + fields = []; + } + } + + return { + assetId: asset.id, + assetName: asset.name, + assetTag: asset.assetTag, + serviceGroupId: groupId, + serviceGroupName: groupName, + scheduleId: schedule?.id ?? null, + nextServiceDate: schedule?.nextServiceDate ?? null, + fields, + }; +} + +export async function listAssetRecords( + ctx: Ctx, + assetId: string, +): Promise { + await requireAsset(ctx, assetId); + const records = await ctx.pb + .collection("serviceRecords") + .getFullList({ + filter: `assetId = "${escapeFilter(assetId)}"`, + sort: "-completedAt", + }); + + const groupIds = [ + ...new Set(records.map((r) => r.serviceGroupId).filter((id) => !!id)), + ]; + const providerIds = [ + ...new Set(records.map((r) => r.providerId).filter((id): id is string => !!id)), + ]; + const userIds = [...new Set(records.map((r) => r.completedBy))]; + + const [groups, groupFields, providers, users] = await Promise.all([ + Promise.all( + groupIds.map((id) => + ctx.pb + .collection("serviceGroups") + .getOne(id) + .catch(() => null), + ), + ), + Promise.all( + groupIds.map((id) => listFieldsForGroup(ctx, id).catch(() => [])), + ), + Promise.all( + providerIds.map((id) => + ctx.pb + .collection("serviceProviders") + .getOne(id) + .catch(() => null), + ), + ), + Promise.all( + userIds.map((id) => + ctx.pb + .collection("users") + .getOne(id) + .catch(() => null), + ), + ), + ]); + + const groupById = new Map( + (groups.filter(Boolean) as ServiceGroupRow[]).map((g) => [g.id, g]), + ); + const fieldsByGroupId = new Map(); + groupIds.forEach((id, index) => { + fieldsByGroupId.set(id, (groupFields[index] ?? []).map(toFieldDefinition)); + }); + const providerById = new Map( + (providers.filter(Boolean) as ServiceProviderRow[]).map((p) => [p.id, p]), + ); + const userById = new Map( + (users.filter(Boolean) as UserRow[]).map((u) => [u.id, u]), + ); + + return records + .sort((left, right) => { + const leftDate = + left.serviceDate || timestampToIsoDate(left.completedAt); + const rightDate = + right.serviceDate || timestampToIsoDate(right.completedAt); + if (leftDate === rightDate) { + return right.completedAt - left.completedAt; + } + return rightDate.localeCompare(leftDate); + }) + .map((record) => { + const serviceDate = + record.serviceDate || timestampToIsoDate(record.completedAt); + const groupName = + record.serviceGroupNameSnapshot || + (record.serviceGroupId + ? (groupById.get(record.serviceGroupId)?.name ?? null) + : null); + const fields = + record.fieldSnapshots && record.fieldSnapshots.length > 0 + ? snapshotsToDefinitions(record.fieldSnapshots) + : (record.serviceGroupId + ? (fieldsByGroupId.get(record.serviceGroupId) ?? []) + : []); + const completedBy = userById.get(record.completedBy); + return { + id: record.id, + assetId: record.assetId, + serviceGroupId: record.serviceGroupId || null, + serviceGroupName: groupName, + values: record.values ?? {}, + valueEntries: buildValueEntries({ record, fields }), + scheduleId: record.scheduleId || null, + scheduledForDate: record.scheduledForDate || null, + serviceDate, + description: record.description ?? "", + cost: record.cost ?? null, + providerId: record.providerId || null, + providerName: record.providerId + ? (providerById.get(record.providerId)?.name ?? null) + : null, + completedAt: record.completedAt, + completedBy: record.completedBy, + completedByName: + completedBy?.name ?? completedBy?.email ?? "Unknown user", + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; + }); +} + +async function loadGroupContext( + ctx: Ctx, + groupId: string | null, +): Promise<{ + group: ServiceGroupRow | null; + fields: RecordFieldDefinition[]; +}> { + if (!groupId) return { group: null, fields: [] }; + const [group, groupFields] = await Promise.all([ + getGroup(ctx, groupId), + listFieldsForGroup(ctx, groupId), + ]); + if (!group) { + throw new NotFoundError("Service group not found"); + } + return { group, fields: groupFields.map(toFieldDefinition) }; +} + +function getAdvancedNextServiceDate({ + schedule, + serviceDate, +}: { + schedule: ServiceScheduleRow; + serviceDate: string; +}) { + return addIntervalToIsoDate({ + date: serviceDate, + value: schedule.intervalValue, + unit: normalizeServiceIntervalUnit(schedule.intervalUnit), + }); +} + +export async function createRecord( + ctx: Ctx, + input: CreateRecordInput, +): Promise<{ recordId: string; nextServiceDate: string | null }> { + const parsed = CreateRecordInput.parse(input); + const asset = await requireAsset(ctx, parsed.assetId); + const schedule = await getScheduleForAsset(ctx, asset.id); + const groupContext = await loadGroupContext( + ctx, + asset.serviceGroupId || null, + ); + if (parsed.providerId) await requireProvider(ctx, parsed.providerId); + + const serviceDate = requireServiceDate(parsed.serviceDate); + const description = requireServiceDescription(parsed.description); + const cost = normalizeServiceCost(parsed.cost); + const values = normalizeForGroup({ + fields: groupContext.fields, + values: parsed.values, + }); + + const now = Date.now(); + const record = await ctx.pb.collection("serviceRecords").create({ + assetId: asset.id, + serviceGroupId: groupContext.group?.id ?? "", + serviceGroupNameSnapshot: groupContext.group?.name ?? "", + values, + fieldSnapshots: definitionsToSnapshots(groupContext.fields), + scheduleId: "", + scheduledForDate: "", + serviceDate, + description, + cost, + providerId: parsed.providerId ?? undefined, + completedAt: now, + completedBy: parsed.actorId, + createdAt: now, + updatedAt: now, + }); + + if (!schedule) { + return { recordId: record.id, nextServiceDate: null }; + } + + const nextServiceDate = getAdvancedNextServiceDate({ + schedule, + serviceDate, + }); + await ctx.pb.collection("serviceSchedules").update(schedule.id, { + nextServiceDate, + updatedAt: now, + updatedBy: parsed.actorId, + }); + + return { recordId: record.id, nextServiceDate }; +} + +export async function updateRecord( + ctx: Ctx, + input: UpdateRecordInput, +): Promise { + const parsed = UpdateRecordInput.parse(input); + const record = await requireRecord(ctx, parsed.recordId); + + if (parsed.actorRole !== "admin" && record.completedBy !== parsed.actorId) { + throw new ValidationError( + "You do not have access to modify this service record", + ); + } + + const fields = + record.fieldSnapshots && record.fieldSnapshots.length > 0 + ? snapshotsToDefinitions(record.fieldSnapshots) + : record.serviceGroupId + ? (await listFieldsForGroup(ctx, record.serviceGroupId)).map( + toFieldDefinition, + ) + : []; + if (parsed.providerId) await requireProvider(ctx, parsed.providerId); + + const serviceDate = requireServiceDate(parsed.serviceDate); + const description = requireServiceDescription(parsed.description); + const cost = normalizeServiceCost(parsed.cost); + const values = normalizeForGroup({ fields, values: parsed.values }); + + await ctx.pb.collection("serviceRecords").update(record.id, { + values, + fieldSnapshots: definitionsToSnapshots(fields), + serviceDate, + description, + cost, + providerId: parsed.providerId ?? undefined, + updatedAt: Date.now(), + }); +} + +export async function deleteRecord( + ctx: Ctx, + recordId: string, + actor: { id: string; role: "admin" | "user" }, +): Promise { + const record = await requireRecord(ctx, recordId); + if (actor.role !== "admin" && record.completedBy !== actor.id) { + throw new ValidationError( + "You do not have access to modify this service record", + ); + } + + const attachments = await ctx.pb + .collection("serviceRecordAttachments") + .getFullList<{ id: string }>({ + filter: `serviceRecordId = "${escapeFilter(record.id)}"`, + }); + for (const attachment of attachments) { + await ctx.pb.collection("serviceRecordAttachments").delete(attachment.id); + } + await ctx.pb.collection("serviceRecords").delete(record.id); +} + +export async function completeScheduledService( + ctx: Ctx, + input: CompleteScheduledServiceInput, +): Promise<{ recordId: string; nextServiceDate: string }> { + const parsed = CompleteScheduledServiceInput.parse(input); + const schedule = await requireSchedule(ctx, parsed.scheduleId); + const asset = await requireAsset(ctx, schedule.assetId); + const groupContext = await loadGroupContext( + ctx, + asset.serviceGroupId || null, + ); + if (parsed.providerId) await requireProvider(ctx, parsed.providerId); + + const serviceDate = requireServiceDate(parsed.serviceDate); + const description = requireServiceDescription(parsed.description); + const cost = normalizeServiceCost(parsed.cost); + const values = normalizeForGroup({ + fields: groupContext.fields, + values: parsed.values, + }); + + const now = Date.now(); + const nextServiceDate = getAdvancedNextServiceDate({ + schedule, + serviceDate, + }); + + const record = await ctx.pb.collection("serviceRecords").create({ + assetId: asset.id, + serviceGroupId: groupContext.group?.id ?? "", + serviceGroupNameSnapshot: groupContext.group?.name ?? "", + values, + fieldSnapshots: definitionsToSnapshots(groupContext.fields), + scheduleId: schedule.id, + scheduledForDate: schedule.nextServiceDate, + serviceDate, + description, + cost, + providerId: parsed.providerId ?? undefined, + completedAt: now, + completedBy: parsed.actorId, + createdAt: now, + updatedAt: now, + }); + + await ctx.pb.collection("serviceSchedules").update(schedule.id, { + nextServiceDate, + updatedAt: now, + updatedBy: parsed.actorId, + }); + + return { recordId: record.id, nextServiceDate }; +} diff --git a/src/server/domain/serviceSchedules.ts b/src/server/domain/serviceSchedules.ts new file mode 100644 index 0000000..3b93847 --- /dev/null +++ b/src/server/domain/serviceSchedules.ts @@ -0,0 +1,428 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { AssetStatus } from "@/server/pb/assets"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + addIntervalToIsoDate, + ensureReminderWithinInterval, + getMonthRange, + getTodayIsoDate, + getUpcomingRange, + normalizeServiceIntervalUnit, + requireIsoDate, + SERVICE_INTERVAL_UNITS, + type ServiceIntervalUnit, + subtractIntervalFromIsoDate, +} from "@/server/pb/service-schedule"; + +const intervalUnitSchema = z.enum(SERVICE_INTERVAL_UNITS); + +export const UpsertScheduleInput = z.object({ + assetId: z.string(), + nextServiceDate: z.string(), + intervalValue: z.number(), + intervalUnit: intervalUnitSchema, + reminderLeadValue: z.number(), + reminderLeadUnit: intervalUnitSchema, + actorId: z.string(), +}); + +export type UpsertScheduleInput = z.infer; + +export type ScheduleView = { + id: string; + assetId: string; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; + reminderStartDate: string; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +export type ScheduledAssetView = { + scheduleId: string; + assetId: string; + assetName: string; + assetTag: string; + assetStatus: AssetStatus; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; + reminderStartDate: string; + lastServiceDate: string | null; + lastServiceDescription: string | null; + lastServiceProviderName: string | null; +}; + +type ScheduleRecord = { + id: string; + assetId: string; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +type AssetRow = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; +}; + +type ServiceRecordRow = { + id: string; + assetId: string; + serviceDate?: string; + description?: string | null; + providerId?: string; + completedAt: number; +}; + +type ServiceProviderRow = { id: string; name: string }; + +type AppSettingsRow = { + serviceSchedulingEnabled?: boolean | null; +}; + +async function assertAssetExists(ctx: Ctx, assetId: string) { + try { + await ctx.pb.collection("assets").getOne(assetId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Asset not found"); + } + throw error; + } +} + +async function isServiceSchedulingEnabled(ctx: Ctx): Promise { + try { + const row = await ctx.pb + .collection("appSettings") + .getFirstListItem('key = "global"'); + return row.serviceSchedulingEnabled ?? true; + } catch { + return true; + } +} + +function toScheduleView(record: ScheduleRecord): ScheduleView { + return { + id: record.id, + assetId: record.assetId, + nextServiceDate: record.nextServiceDate, + intervalValue: record.intervalValue, + intervalUnit: record.intervalUnit, + reminderLeadValue: record.reminderLeadValue, + reminderLeadUnit: record.reminderLeadUnit, + reminderStartDate: subtractIntervalFromIsoDate({ + date: record.nextServiceDate, + value: record.reminderLeadValue, + unit: record.reminderLeadUnit, + }), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }; +} + +function escapeFilter(value: string) { + return value.replace(/"/g, '\\"'); +} + +async function getScheduleRecord( + ctx: Ctx, + assetId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceSchedules") + .getFirstListItem(`assetId = "${escapeFilter(assetId)}"`); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +async function buildLatestRecordContext( + ctx: Ctx, + assetIds: string[], +): Promise< + Map< + string, + { + lastServiceDate: string | null; + lastServiceDescription: string | null; + lastServiceProviderName: string | null; + } + > +> { + const out = new Map< + string, + { + lastServiceDate: string | null; + lastServiceDescription: string | null; + lastServiceProviderName: string | null; + } + >(); + if (assetIds.length === 0) return out; + + const latestRecords = await Promise.all( + assetIds.map(async (assetId) => { + try { + return await ctx.pb + .collection("serviceRecords") + .getList(1, 1, { + filter: `assetId = "${escapeFilter(assetId)}"`, + sort: "-completedAt", + }) + .then((result) => result.items[0] ?? null); + } catch { + return null; + } + }), + ); + + const providerIds = [ + ...new Set( + latestRecords + .map((record) => record?.providerId) + .filter((id): id is string => !!id), + ), + ]; + const providers = await Promise.all( + providerIds.map((id) => + ctx.pb + .collection("serviceProviders") + .getOne(id) + .catch(() => null), + ), + ); + const providerById = new Map( + (providers.filter(Boolean) as ServiceProviderRow[]).map((p) => [p.id, p]), + ); + + assetIds.forEach((assetId, index) => { + const record = latestRecords[index]; + const lastServiceDate = record + ? (record.serviceDate ?? + new Date(record.completedAt).toISOString().slice(0, 10)) + : null; + out.set(assetId, { + lastServiceDate, + lastServiceDescription: record?.description ?? null, + lastServiceProviderName: record?.providerId + ? (providerById.get(record.providerId)?.name ?? null) + : null, + }); + }); + + return out; +} + +async function mapScheduledAssets( + ctx: Ctx, + records: ScheduleRecord[], +): Promise { + if (records.length === 0) return []; + const assetIds = records.map((row) => row.assetId); + const [assets, latestCtx] = await Promise.all([ + Promise.all( + assetIds.map((id) => + ctx.pb + .collection("assets") + .getOne(id) + .catch(() => null), + ), + ), + buildLatestRecordContext(ctx, assetIds), + ]); + + const mapped: ScheduledAssetView[] = []; + records.forEach((row, index) => { + const asset = assets[index]; + if (!asset) return; + const context = latestCtx.get(row.assetId); + mapped.push({ + scheduleId: row.id, + assetId: row.assetId, + assetName: asset.name, + assetTag: asset.assetTag, + assetStatus: asset.status, + nextServiceDate: row.nextServiceDate, + intervalValue: row.intervalValue, + intervalUnit: row.intervalUnit, + reminderLeadValue: row.reminderLeadValue, + reminderLeadUnit: row.reminderLeadUnit, + reminderStartDate: subtractIntervalFromIsoDate({ + date: row.nextServiceDate, + value: row.reminderLeadValue, + unit: row.reminderLeadUnit, + }), + lastServiceDate: context?.lastServiceDate ?? null, + lastServiceDescription: context?.lastServiceDescription ?? null, + lastServiceProviderName: context?.lastServiceProviderName ?? null, + }); + }); + return mapped; +} + +export async function getScheduleByAssetId( + ctx: Ctx, + assetId: string, +): Promise { + await assertAssetExists(ctx, assetId); + const record = await getScheduleRecord(ctx, assetId); + return record ? toScheduleView(record) : null; +} + +export async function upsertSchedule( + ctx: Ctx, + input: UpsertScheduleInput, +): Promise { + const parsed = UpsertScheduleInput.parse(input); + await assertAssetExists(ctx, parsed.assetId); + if (!(await isServiceSchedulingEnabled(ctx))) { + throw new ConflictError( + "Service scheduling is currently disabled by an admin", + ); + } + + const intervalUnit = normalizeServiceIntervalUnit(parsed.intervalUnit); + const reminderLeadUnit = normalizeServiceIntervalUnit( + parsed.reminderLeadUnit, + ); + ensureReminderWithinInterval({ + intervalValue: parsed.intervalValue, + intervalUnit, + reminderLeadValue: parsed.reminderLeadValue, + reminderLeadUnit, + }); + + const requested = requireIsoDate(parsed.nextServiceDate); + const today = getTodayIsoDate(); + const nextServiceDate = + requested === today + ? addIntervalToIsoDate({ + date: requested, + value: parsed.intervalValue, + unit: intervalUnit, + }) + : requested; + + const existing = await getScheduleRecord(ctx, parsed.assetId); + const now = Date.now(); + + if (existing) { + const updated = await ctx.pb + .collection("serviceSchedules") + .update(existing.id, { + nextServiceDate, + intervalValue: parsed.intervalValue, + intervalUnit, + reminderLeadValue: parsed.reminderLeadValue, + reminderLeadUnit, + updatedAt: now, + updatedBy: parsed.actorId, + }); + return toScheduleView(updated); + } + + const created = await ctx.pb + .collection("serviceSchedules") + .create({ + assetId: parsed.assetId, + nextServiceDate, + intervalValue: parsed.intervalValue, + intervalUnit, + reminderLeadValue: parsed.reminderLeadValue, + reminderLeadUnit, + createdAt: now, + updatedAt: now, + createdBy: parsed.actorId, + updatedBy: parsed.actorId, + }); + return toScheduleView(created); +} + +export async function deleteSchedule( + ctx: Ctx, + assetId: string, +): Promise { + await assertAssetExists(ctx, assetId); + if (!(await isServiceSchedulingEnabled(ctx))) { + throw new ConflictError( + "Service scheduling is currently disabled by an admin", + ); + } + const existing = await getScheduleRecord(ctx, assetId); + if (!existing) return; + await ctx.pb.collection("serviceSchedules").delete(existing.id); +} + +export async function listScheduledAssets( + ctx: Ctx, +): Promise { + const rows = await ctx.pb + .collection("serviceSchedules") + .getFullList({ sort: "nextServiceDate" }); + return mapScheduledAssets(ctx, rows); +} + +export async function listCalendarMonth( + ctx: Ctx, + input: { year: number; month: number }, +): Promise { + const { monthStart, nextMonthStart } = getMonthRange(input); + const rows = await ctx.pb + .collection("serviceSchedules") + .getFullList({ + filter: `nextServiceDate >= "${monthStart}" && nextServiceDate < "${nextMonthStart}"`, + sort: "nextServiceDate", + }); + return mapScheduledAssets(ctx, rows); +} + +export async function listUpcomingServiceDueInDays( + ctx: Ctx, + days: number, +): Promise { + if (!Number.isInteger(days) || days <= 0) { + throw new ValidationError("Days must be a positive integer"); + } + const { startDate, endDate } = getUpcomingRange({ days }); + const endExclusive = addIntervalToIsoDate({ + date: endDate, + value: 1, + unit: "days", + }); + const rows = await ctx.pb + .collection("serviceSchedules") + .getFullList({ + filter: `nextServiceDate >= "${startDate}" && nextServiceDate < "${endExclusive}"`, + sort: "nextServiceDate", + }); + return mapScheduledAssets(ctx, rows); +} diff --git a/src/server/pb/service-catalog.ts b/src/server/pb/service-catalog.ts index b5f5e44..c06a3b6 100644 --- a/src/server/pb/service-catalog.ts +++ b/src/server/pb/service-catalog.ts @@ -1,4 +1,7 @@ import { ValidationError } from "./errors"; +import { requireIsoDate } from "./service-schedule"; + +type RecordValue = string | number | boolean | null; export const SERVICE_GROUP_FIELD_TYPES = [ "text", @@ -49,6 +52,106 @@ export function normalizeServiceFieldOptions(options: string[]) { return Array.from(deduped.values()); } +type ServiceGroupFieldForValidation = { + _id: string; + label: string; + fieldType: ServiceGroupFieldType; + required: boolean; + options: string[]; +}; + +function isEmptyValue(value: RecordValue | undefined) { + if (value === null || value === undefined) return true; + if (typeof value === "string") return value.trim() === ""; + return false; +} + +export function normalizeServiceRecordValues({ + fields, + values, +}: { + fields: ServiceGroupFieldForValidation[]; + values: Record; +}) { + const fieldById = new Map(fields.map((field) => [field._id, field])); + for (const key of Object.keys(values)) { + if (!fieldById.has(key)) { + throw new ValidationError("Record includes an unknown field"); + } + } + + const out: Record = {}; + for (const field of fields) { + const raw = values[field._id]; + + if (field.fieldType === "checkbox") { + if (raw === undefined || raw === null) { + if (field.required) { + throw new ValidationError(`${field.label} is required`); + } + continue; + } + if (typeof raw !== "boolean") { + throw new ValidationError(`${field.label} must be true or false`); + } + out[field._id] = raw; + continue; + } + + if (isEmptyValue(raw)) { + if (field.required) { + throw new ValidationError(`${field.label} is required`); + } + continue; + } + + if (field.fieldType === "number") { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + throw new ValidationError(`${field.label} must be a number`); + } + out[field._id] = raw; + continue; + } + + if (typeof raw !== "string") { + throw new ValidationError(`${field.label} must be text`); + } + + const trimmed = raw.trim(); + if (!trimmed) { + if (field.required) { + throw new ValidationError(`${field.label} is required`); + } + continue; + } + + if (trimmed.length > 2000) { + throw new ValidationError( + `${field.label} must be 2000 characters or fewer`, + ); + } + + if (field.fieldType === "date") { + out[field._id] = requireIsoDate(trimmed); + continue; + } + + if (field.fieldType === "select") { + if (!field.options.includes(trimmed)) { + throw new ValidationError( + `${field.label} contains an invalid option`, + ); + } + out[field._id] = trimmed; + continue; + } + + out[field._id] = trimmed; + } + + return out; +} + export function normalizeServiceFieldInput({ label, fieldType, diff --git a/src/server/pb/service-schedule.ts b/src/server/pb/service-schedule.ts new file mode 100644 index 0000000..93d6a07 --- /dev/null +++ b/src/server/pb/service-schedule.ts @@ -0,0 +1,188 @@ +import { ValidationError } from "./errors"; + +export const SERVICE_INTERVAL_UNITS = [ + "days", + "weeks", + "months", + "years", +] as const; + +export type ServiceIntervalUnit = (typeof SERVICE_INTERVAL_UNITS)[number]; + +const ISO_DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function formatIsoDate(date: Date) { + const year = String(date.getUTCFullYear()).padStart(4, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function parseIsoDate(value: string) { + if (!ISO_DATE_ONLY_PATTERN.test(value)) { + throw new ValidationError("Date must use YYYY-MM-DD format"); + } + const [yearRaw, monthRaw, dayRaw] = value.split("-"); + const year = Number(yearRaw); + const month = Number(monthRaw); + const day = Number(dayRaw); + if ( + !Number.isInteger(year) || + !Number.isInteger(month) || + !Number.isInteger(day) + ) { + throw new ValidationError("Date must use YYYY-MM-DD format"); + } + const candidate = new Date(Date.UTC(year, month - 1, day)); + if ( + candidate.getUTCFullYear() !== year || + candidate.getUTCMonth() !== month - 1 || + candidate.getUTCDate() !== day + ) { + throw new ValidationError("Date is invalid"); + } + return candidate; +} + +export function requireIsoDate(value: string) { + return formatIsoDate(parseIsoDate(value)); +} + +function applyInterval( + date: Date, + value: number, + unit: ServiceIntervalUnit, +) { + const next = new Date(date.getTime()); + if (unit === "days") { + next.setUTCDate(next.getUTCDate() + value); + return next; + } + if (unit === "weeks") { + next.setUTCDate(next.getUTCDate() + value * 7); + return next; + } + if (unit === "months") { + next.setUTCMonth(next.getUTCMonth() + value); + return next; + } + next.setUTCFullYear(next.getUTCFullYear() + value); + return next; +} + +function requirePositiveInteger(value: number, label: string) { + if (!Number.isInteger(value) || value <= 0) { + throw new ValidationError(`${label} must be a positive integer`); + } +} + +function requireNonNegativeInteger(value: number, label: string) { + if (!Number.isInteger(value) || value < 0) { + throw new ValidationError(`${label} must be a non-negative integer`); + } +} + +function getDurationMsFromAnchor(value: number, unit: ServiceIntervalUnit) { + const anchor = parseIsoDate("2024-01-01"); + const shifted = applyInterval(anchor, value, unit); + return shifted.getTime() - anchor.getTime(); +} + +export function normalizeServiceIntervalUnit( + value: string, +): ServiceIntervalUnit { + if ((SERVICE_INTERVAL_UNITS as readonly string[]).includes(value)) { + return value as ServiceIntervalUnit; + } + throw new ValidationError("Interval unit is invalid"); +} + +export function ensureReminderWithinInterval({ + intervalValue, + intervalUnit, + reminderLeadValue, + reminderLeadUnit, +}: { + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; +}) { + requirePositiveInteger(intervalValue, "Interval value"); + requireNonNegativeInteger(reminderLeadValue, "Reminder lead value"); + const intervalDuration = getDurationMsFromAnchor( + intervalValue, + intervalUnit, + ); + const reminderDuration = getDurationMsFromAnchor( + reminderLeadValue, + reminderLeadUnit, + ); + if (reminderDuration > intervalDuration) { + throw new ValidationError( + "Reminder lead must be less than or equal to interval", + ); + } +} + +export function getTodayIsoDate(nowMs = Date.now()) { + return formatIsoDate(new Date(nowMs)); +} + +export function addIntervalToIsoDate({ + date, + value, + unit, +}: { + date: string; + value: number; + unit: ServiceIntervalUnit; +}) { + requirePositiveInteger(value, "Interval value"); + return formatIsoDate(applyInterval(parseIsoDate(date), value, unit)); +} + +export function subtractIntervalFromIsoDate({ + date, + value, + unit, +}: { + date: string; + value: number; + unit: ServiceIntervalUnit; +}) { + requireNonNegativeInteger(value, "Reminder lead value"); + return formatIsoDate(applyInterval(parseIsoDate(date), value * -1, unit)); +} + +export function getMonthRange({ + year, + month, +}: { + year: number; + month: number; +}) { + if (!Number.isInteger(year) || year < 1970 || year > 9999) { + throw new ValidationError("Year is invalid"); + } + if (!Number.isInteger(month) || month < 1 || month > 12) { + throw new ValidationError("Month is invalid"); + } + const monthStart = new Date(Date.UTC(year, month - 1, 1)); + const nextMonthStart = new Date(Date.UTC(year, month, 1)); + return { + monthStart: formatIsoDate(monthStart), + nextMonthStart: formatIsoDate(nextMonthStart), + }; +} + +export function getUpcomingRange({ days }: { days: number }) { + requirePositiveInteger(days, "Days"); + const startDate = getTodayIsoDate(); + const endDate = addIntervalToIsoDate({ + date: startDate, + value: days, + unit: "days", + }); + return { startDate, endDate }; +} From 0d4b9fd52954c9e637491910b5edbc8384ed09c2 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:19:26 +0200 Subject: [PATCH 09/37] Update migration plan with current status 9 commits in, ~168 PB tests green. Catalog + assets + search + dashboard + service schedules + service records all ported. Next: users, attachments, auth, API routes, frontend refactor. --- .../2026-04-19-pocketbase-migration-plan.md | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-04-19-pocketbase-migration-plan.md b/docs/plans/2026-04-19-pocketbase-migration-plan.md index 0e4eb52..2200eb4 100644 --- a/docs/plans/2026-04-19-pocketbase-migration-plan.md +++ b/docs/plans/2026-04-19-pocketbase-migration-plan.md @@ -2,12 +2,42 @@ **Date:** 2026-04-19 **Branch:** `pocketbase-migration` -**Status:** Ready to execute — decisions locked +**Status:** Server-side domain work substantially complete — auth, attachments, API routes, and frontend refactor remaining Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean installs only; **no existing deployment will carry data across**, so there is no data migration, no ETL, no cutover password flow, no ID redirect table. The bar is functional parity: every feature that works on Convex today must work identically on PocketBase, proven by tests before we land the branch on `main`. --- +## Current status (updated 2026-04-20) + +**9 commits on `pocketbase-migration`.** Test surface: **168 PB domain tests across 16 files**, **440 main tests** (jsdom + convex-test), typecheck clean, lint 0 errors. + +### Done + +- **M1 Spike** — PocketBase v0.37.1 wired into `mprocs` + `pnpm pb:setup`; categories domain proven end-to-end with an API route, provider, realtime hook. +- **M2 Test infrastructure** — `src/test/pb-harness.ts` (per-file PB serve on random port + tmp data dir, truncation in reverse FK order with self-ref retry), separate `vitest.pb.config.ts`, `test:pb` script, `.github/workflows/ci.yml` running lint + typecheck + both suites. +- **M3a Schema** — All 16 collections in `pb_migrations/1713484900_remaining_collections.js` (users auth extension, catalog, assets, join tables, service schedules/records/attachments, labelTemplates); FTS5 virtual table + triggers in `1713485000_assets_fts.js`. +- **M4 Catalog domain ports (complete)** — categories, tags, locations (hierarchy + path recompute), customFields, appSettings, serviceProviders, serviceGroups, serviceGroupFields, labelTemplates. Each with 1:1 tests against the harness. +- **M5 Assets + search + dashboard + asset tags** — CRUD with generateAssetTag, custom-field validation across all types, usage-count bookkeeping, tag-intersection filter, weighted search, dashboard aggregates, cascade delete. +- **M5 Service schedules + records** — Interval math, same-day snap-forward, calendar month / upcoming windows, record field snapshots, complete-scheduled-service auto-advance. + +### In flight / remaining + +- **Users domain** — `listUsers`, `getCurrentUser`, `createUser`, `createFirstAdmin`, `updateUserRole`, `changePassword`. PB's default `users` auth collection is already extended with `role` + audit fields. +- **Attachments + optimization pipeline** — Upload, Jimp image / pdf-lib PDF optimization, status state machine, storage quota. Moves from Convex internalAction to a Next.js `POST /api/attachments/[id]/optimize` route. +- **Auth integration (M3b)** — `PocketBaseClientProvider` already exists for the spike; needs `pb.authStore` cookie bridge, Next middleware for SSR auth, authz wired into every domain function (`requireAuthenticatedUser(ctx)` / `requireAdminUser(ctx)`). +- **First-run setup page (M3c)** — `/setup` gated by `.install-token` written to `/pb_data` on first boot. +- **API route handlers** — Only `/api/categories` exists. Every ported domain module needs a thin `src/app/api/**/route.ts`. +- **Frontend refactor (M7)** — Every `useQuery` / `useMutation` call site (~60 components) switches to TanStack Query + `useRealtimeCollection` / `useRealtimeRecord`. +- **E2E** — Playwright flows against the PB stack. + +### Not yet touched + +- Convex directory still present and building; removal is the merge gate (M10). +- Dockerfile + prod deployment story for the two-process setup. + +--- + ## Locked decisions 1. **Server-side logic placement:** Thin PocketBase + Next.js API routes. PB is the DB/auth/files layer. All validation, authz, and workflows live in TypeScript under `src/server/domain/`, exposed via `src/app/api/**` route handlers. From 869420386c8c9642481fe8d84fc2903a3aa2c502 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:25:36 +0200 Subject: [PATCH 10/37] Port users domain to PocketBase Ports listUsers, getCurrentUser via getUserById/Email, createUser, createFirstAdmin (gated by checkFirstRun), updateUserRole (last-admin guard) and changePassword (verifies current password via scratch client). 13 new tests; PB suite now 181 tests across 17 files. --- src/server/domain/__tests__/users.test.ts | 291 ++++++++++++++++++++++ src/server/domain/users.ts | 269 ++++++++++++++++++++ src/server/pb/users.ts | 36 +++ 3 files changed, 596 insertions(+) create mode 100644 src/server/domain/__tests__/users.test.ts create mode 100644 src/server/domain/users.ts create mode 100644 src/server/pb/users.ts diff --git a/src/server/domain/__tests__/users.test.ts b/src/server/domain/__tests__/users.test.ts new file mode 100644 index 0000000..5dd34f3 --- /dev/null +++ b/src/server/domain/__tests__/users.test.ts @@ -0,0 +1,291 @@ +import PocketBase from "pocketbase"; +import { describe, expect, it } from "vitest"; + +import { + changePassword, + checkFirstRun, + createFirstAdmin, + createUser, + getUserByEmail, + getUserById, + listUsers, + updateUserRole, +} from "@/server/domain/users"; +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +describe("users domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + it("checkFirstRun returns true when no users exist", async () => { + await expect(checkFirstRun(ctx())).resolves.toBe(true); + }); + + it("createFirstAdmin seeds the bootstrap admin and locks the endpoint", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "Admin@Stowage.Local", + name: " Root User ", + password: "password123", + }); + expect(admin).toMatchObject({ + email: "admin@stowage.local", + name: "Root User", + role: "admin", + createdBy: null, + }); + expect(admin.id).toMatch(/^\w{15}$/); + + await expect(checkFirstRun(ctx())).resolves.toBe(false); + await expect( + createFirstAdmin(ctx(), { + email: "second@stowage.local", + name: "Second Admin", + password: "password123", + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("createFirstAdmin validates email, name, and password", async () => { + await expect( + createFirstAdmin(ctx(), { + email: "not-an-email", + name: "A", + password: "password123", + }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + createFirstAdmin(ctx(), { + email: "ok@stowage.local", + name: " ", + password: "password123", + }), + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + createFirstAdmin(ctx(), { + email: "ok@stowage.local", + name: "Admin", + password: "short", + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("createUser creates a user and records the actor as createdBy", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + const member = await createUser(ctx(), { + email: "member@stowage.local", + name: "Team Member", + password: "password123", + role: "user", + actorId: admin.id, + }); + expect(member).toMatchObject({ + email: "member@stowage.local", + name: "Team Member", + role: "user", + createdBy: admin.id, + }); + }); + + it("createUser rejects duplicate emails with ConflictError", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await createUser(ctx(), { + email: "dup@stowage.local", + name: "One", + password: "password123", + role: "user", + actorId: admin.id, + }); + await expect( + createUser(ctx(), { + email: "dup@stowage.local", + name: "Two", + password: "password123", + role: "user", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it("listUsers returns users in reverse-chronological order", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await new Promise((r) => setTimeout(r, 5)); + const b = await createUser(ctx(), { + email: "b@stowage.local", + name: "B", + password: "password123", + role: "user", + actorId: admin.id, + }); + await new Promise((r) => setTimeout(r, 5)); + const c = await createUser(ctx(), { + email: "c@stowage.local", + name: "C", + password: "password123", + role: "user", + actorId: admin.id, + }); + + const list = await listUsers(ctx()); + expect(list.map((u) => u.id)).toEqual([c.id, b.id, admin.id]); + }); + + it("getUserById and getUserByEmail resolve existing users and return null otherwise", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await expect(getUserById(ctx(), admin.id)).resolves.toMatchObject({ + email: "admin@stowage.local", + }); + await expect( + getUserByEmail(ctx(), "ADMIN@STOWAGE.LOCAL"), + ).resolves.toMatchObject({ id: admin.id }); + await expect(getUserById(ctx(), "nonexistent0000")).resolves.toBeNull(); + await expect( + getUserByEmail(ctx(), "missing@stowage.local"), + ).resolves.toBeNull(); + }); + + it("updateUserRole promotes and demotes users", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + const member = await createUser(ctx(), { + email: "member@stowage.local", + name: "Member", + password: "password123", + role: "user", + actorId: admin.id, + }); + const promoted = await updateUserRole(ctx(), { + userId: member.id, + role: "admin", + actorId: admin.id, + }); + expect(promoted.role).toBe("admin"); + + const demoted = await updateUserRole(ctx(), { + userId: member.id, + role: "user", + actorId: admin.id, + }); + expect(demoted.role).toBe("user"); + }); + + it("updateUserRole refuses to demote the last admin", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await createUser(ctx(), { + email: "member@stowage.local", + name: "Member", + password: "password123", + role: "user", + actorId: admin.id, + }); + await expect( + updateUserRole(ctx(), { + userId: admin.id, + role: "user", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("updateUserRole throws NotFoundError when the user is missing", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await expect( + updateUserRole(ctx(), { + userId: "nonexistent0000", + role: "admin", + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("changePassword verifies current password and updates the credential", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await changePassword(ctx(), { + userId: admin.id, + currentPassword: "password123", + newPassword: "newpassword456", + }); + + const url = getHarness().url; + const client = new PocketBase(url); + client.autoCancellation(false); + await expect( + client + .collection("users") + .authWithPassword("admin@stowage.local", "password123"), + ).rejects.toThrow(); + await expect( + client + .collection("users") + .authWithPassword("admin@stowage.local", "newpassword456"), + ).resolves.toBeTruthy(); + }); + + it("changePassword rejects an incorrect current password", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await expect( + changePassword(ctx(), { + userId: admin.id, + currentPassword: "wrong-password", + newPassword: "newpassword456", + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("changePassword rejects a too-short new password", async () => { + const admin = await createFirstAdmin(ctx(), { + email: "admin@stowage.local", + name: "Admin", + password: "password123", + }); + await expect( + changePassword(ctx(), { + userId: admin.id, + currentPassword: "password123", + newPassword: "short", + }), + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/src/server/domain/users.ts b/src/server/domain/users.ts new file mode 100644 index 0000000..985a6e5 --- /dev/null +++ b/src/server/domain/users.ts @@ -0,0 +1,269 @@ +import { ClientResponseError } from "pocketbase"; +import PocketBase from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { + requireValidEmail, + requireValidName, + requireValidPassword, + USER_ROLES, + type UserRole, +} from "@/server/pb/users"; + +const roleSchema = z.enum(USER_ROLES); + +export const CreateFirstAdminInput = z.object({ + email: z.string(), + name: z.string(), + password: z.string(), +}); + +export const CreateUserInput = z.object({ + email: z.string(), + name: z.string(), + password: z.string(), + role: roleSchema, + actorId: z.string(), +}); + +export const UpdateUserRoleInput = z.object({ + userId: z.string(), + role: roleSchema, + actorId: z.string(), +}); + +export const ChangePasswordInput = z.object({ + userId: z.string(), + currentPassword: z.string(), + newPassword: z.string(), +}); + +export type CreateFirstAdminInput = z.infer; +export type CreateUserInput = z.infer; +export type UpdateUserRoleInput = z.infer; +export type ChangePasswordInput = z.infer; + +export type UserSummary = { + id: string; + email: string; + name: string; + role: UserRole; + createdBy: string | null; + createdAt: number; +}; + +type UserRecord = { + id: string; + email: string; + name: string; + role: UserRole; + createdBy?: string; + createdAt: number; +}; + +function toSummary(record: UserRecord): UserSummary { + return { + id: record.id, + email: record.email, + name: record.name, + role: record.role, + createdBy: record.createdBy ? record.createdBy : null, + createdAt: record.createdAt, + }; +} + +export async function checkFirstRun(ctx: Ctx): Promise { + const records = await ctx.pb + .collection("users") + .getList(1, 1, { fields: "id" }); + return records.totalItems === 0; +} + +export async function listUsers(ctx: Ctx): Promise { + const records = await ctx.pb.collection("users").getFullList(); + return records + .sort((a, b) => b.createdAt - a.createdAt) + .map(toSummary); +} + +export async function getUserById( + ctx: Ctx, + userId: string, +): Promise { + try { + const record = await ctx.pb + .collection("users") + .getOne(userId); + return toSummary(record); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +export async function getUserByEmail( + ctx: Ctx, + email: string, +): Promise { + const normalized = requireValidEmail(email); + try { + const record = await ctx.pb + .collection("users") + .getFirstListItem(`email = "${normalized}"`); + return toSummary(record); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return null; + } + throw error; + } +} + +async function insertUser( + ctx: Ctx, + { + email, + name, + password, + role, + createdBy, + }: { + email: string; + name: string; + password: string; + role: UserRole; + createdBy: string | null; + }, +): Promise { + try { + const record = await ctx.pb.collection("users").create({ + email, + password, + passwordConfirm: password, + name, + role, + createdAt: Date.now(), + ...(createdBy ? { createdBy } : {}), + emailVisibility: true, + }); + return toSummary(record); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 400) { + const payload = JSON.stringify(error.data ?? {}); + if (payload.includes("email")) { + throw new ConflictError("A user with this email already exists"); + } + throw new ValidationError(error.message || "Failed to create user"); + } + throw error; + } +} + +export async function createFirstAdmin( + ctx: Ctx, + input: CreateFirstAdminInput, +): Promise { + const parsed = CreateFirstAdminInput.parse(input); + + if (!(await checkFirstRun(ctx))) { + throw new ConflictError("First admin has already been created"); + } + + const email = requireValidEmail(parsed.email); + const name = requireValidName(parsed.name); + const password = requireValidPassword(parsed.password); + + return insertUser(ctx, { + email, + name, + password, + role: "admin", + createdBy: null, + }); +} + +export async function createUser( + ctx: Ctx, + input: CreateUserInput, +): Promise { + const parsed = CreateUserInput.parse(input); + + const email = requireValidEmail(parsed.email); + const name = requireValidName(parsed.name); + const password = requireValidPassword(parsed.password); + + return insertUser(ctx, { + email, + name, + password, + role: parsed.role, + createdBy: parsed.actorId, + }); +} + +export async function updateUserRole( + ctx: Ctx, + input: UpdateUserRoleInput, +): Promise { + const parsed = UpdateUserRoleInput.parse(input); + + const target = await getUserById(ctx, parsed.userId); + if (!target) { + throw new NotFoundError("User not found"); + } + + if (target.role === "admin" && parsed.role !== "admin") { + const admins = await ctx.pb + .collection("users") + .getList(1, 2, { filter: 'role = "admin"' }); + if (admins.totalItems <= 1) { + throw new ValidationError("At least one admin is required"); + } + } + + const updated = await ctx.pb + .collection("users") + .update(parsed.userId, { role: parsed.role }); + return toSummary(updated); +} + +export async function changePassword( + ctx: Ctx, + input: ChangePasswordInput, +): Promise { + const parsed = ChangePasswordInput.parse(input); + const newPassword = requireValidPassword(parsed.newPassword); + + const user = await getUserById(ctx, parsed.userId); + if (!user) { + throw new NotFoundError("User not found"); + } + + // Verify the current password by attempting an auth with a scratch client. + // We deliberately do NOT use ctx.pb so the admin auth token stays intact. + const verifier = new PocketBase(ctx.pb.baseURL); + verifier.autoCancellation(false); + try { + await verifier + .collection("users") + .authWithPassword(user.email, parsed.currentPassword); + } catch (error) { + if (error instanceof ClientResponseError) { + throw new ValidationError("Current password is incorrect"); + } + throw error; + } + + await ctx.pb.collection("users").update(parsed.userId, { + password: newPassword, + passwordConfirm: newPassword, + }); +} diff --git a/src/server/pb/users.ts b/src/server/pb/users.ts new file mode 100644 index 0000000..1273731 --- /dev/null +++ b/src/server/pb/users.ts @@ -0,0 +1,36 @@ +import { ValidationError } from "./errors"; + +export const USER_ROLES = ["admin", "user"] as const; +export type UserRole = (typeof USER_ROLES)[number]; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +export function requireValidEmail(email: string) { + const normalized = normalizeEmail(email); + if (!normalized || !EMAIL_PATTERN.test(normalized)) { + throw new ValidationError("Enter a valid email address"); + } + return normalized; +} + +export function requireValidName(name: string) { + const trimmed = name.trim(); + if (!trimmed) { + throw new ValidationError("Name is required"); + } + if (trimmed.length > 200) { + throw new ValidationError("Name must be 200 characters or fewer"); + } + return trimmed; +} + +export function requireValidPassword(password: string) { + if (password.length < 8) { + throw new ValidationError("Password must be at least 8 characters"); + } + return password; +} From af709e9fd345fb7134d304b0c539f2a540702d0a Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:31:58 +0200 Subject: [PATCH 11/37] Add PocketBase auth session layer + auth API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/server/auth/ provides: - cookies.ts: HttpOnly pb_auth cookie set/clear helpers on NextResponse - session.ts: resolveSession(token) via authRefresh, createRequestSession (admin ctx + resolved user), requireUser / requireAdmin helpers with Unauthorized/Forbidden domain errors (401/403) - route.ts: getRequestSession, withSession/withUser/withAdmin wrappers, parseJsonBody, handleRouteError /api/auth endpoints: - POST /login — authWithPassword, set cookie, return user - POST /logout — clear cookie - GET /me — resolve current session; clears stale cookie - GET /first-run — delegates to users domain checkFirstRun - POST /first-admin — createFirstAdmin + auto-sign-in + cookie Infra: - client.ts reads env lazily so tests can stub POCKETBASE_URL at runtime - vitest.pb.config.ts aliases server-only to a no-op stub (Next build marker) 5 new session tests — PB suite now 186 / 18 files. --- src/app/api/auth/first-admin/route.ts | 37 ++++++++ src/app/api/auth/first-run/route.ts | 15 ++++ src/app/api/auth/login/route.ts | 48 ++++++++++ src/app/api/auth/logout/route.ts | 9 ++ src/app/api/auth/me/route.ts | 20 +++++ src/server/auth/__tests__/session.test.ts | 82 ++++++++++++++++++ src/server/auth/cookies.ts | 29 +++++++ src/server/auth/route.ts | 101 ++++++++++++++++++++++ src/server/auth/session.ts | 85 ++++++++++++++++++ src/server/pb/client.ts | 28 ++++-- src/test/server-only-stub.ts | 1 + vitest.pb.config.ts | 3 + 12 files changed, 450 insertions(+), 8 deletions(-) create mode 100644 src/app/api/auth/first-admin/route.ts create mode 100644 src/app/api/auth/first-run/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/server/auth/__tests__/session.test.ts create mode 100644 src/server/auth/cookies.ts create mode 100644 src/server/auth/route.ts create mode 100644 src/server/auth/session.ts create mode 100644 src/test/server-only-stub.ts diff --git a/src/app/api/auth/first-admin/route.ts b/src/app/api/auth/first-admin/route.ts new file mode 100644 index 0000000..06eef35 --- /dev/null +++ b/src/app/api/auth/first-admin/route.ts @@ -0,0 +1,37 @@ +import PocketBase from "pocketbase"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { setPbAuthCookie } from "@/server/auth/cookies"; +import { handleRouteError, parseJsonBody } from "@/server/auth/route"; +import { createFirstAdmin, CreateFirstAdminInput } from "@/server/domain/users"; +import { getPbUrl } from "@/server/pb/client"; +import { createAdminCtx } from "@/server/pb/context"; + +export async function POST(req: NextRequest) { + try { + const body = CreateFirstAdminInput.parse(await parseJsonBody(req)); + const ctx = await createAdminCtx(); + const admin = await createFirstAdmin(ctx, body); + + // Auto-sign-in the new admin so they land on the dashboard. + const client = new PocketBase(getPbUrl()); + client.autoCancellation(false); + const auth = await client + .collection("users") + .authWithPassword(admin.email, body.password); + + const res = NextResponse.json({ + user: { + id: auth.record.id, + email: auth.record.email, + name: auth.record.name, + role: auth.record.role, + }, + }); + setPbAuthCookie(res, auth.token); + return res; + } catch (error) { + return handleRouteError(error, "api/auth/first-admin"); + } +} diff --git a/src/app/api/auth/first-run/route.ts b/src/app/api/auth/first-run/route.ts new file mode 100644 index 0000000..4f9cab9 --- /dev/null +++ b/src/app/api/auth/first-run/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +import { handleRouteError } from "@/server/auth/route"; +import { checkFirstRun } from "@/server/domain/users"; +import { createAdminCtx } from "@/server/pb/context"; + +export async function GET() { + try { + const ctx = await createAdminCtx(); + const firstRun = await checkFirstRun(ctx); + return NextResponse.json({ firstRun }); + } catch (error) { + return handleRouteError(error, "api/auth/first-run"); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..2b17f4e --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,48 @@ +import PocketBase, { ClientResponseError } from "pocketbase"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { setPbAuthCookie } from "@/server/auth/cookies"; +import { handleRouteError, parseJsonBody } from "@/server/auth/route"; +import { getPbUrl } from "@/server/pb/client"; +import { ValidationError } from "@/server/pb/errors"; + +const LoginInput = z.object({ + email: z.string(), + password: z.string(), +}); + +export async function POST(req: NextRequest) { + try { + const body = LoginInput.parse(await parseJsonBody(req)); + + const client = new PocketBase(getPbUrl()); + client.autoCancellation(false); + + let auth; + try { + auth = await client + .collection("users") + .authWithPassword(body.email.trim().toLowerCase(), body.password); + } catch (error) { + if (error instanceof ClientResponseError) { + throw new ValidationError("Invalid email or password"); + } + throw error; + } + + const res = NextResponse.json({ + user: { + id: auth.record.id, + email: auth.record.email, + name: auth.record.name, + role: auth.record.role, + }, + }); + setPbAuthCookie(res, auth.token); + return res; + } catch (error) { + return handleRouteError(error, "api/auth/login"); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..fec8df6 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +import { clearPbAuthCookie } from "@/server/auth/cookies"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + clearPbAuthCookie(res); + return res; +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..302ce23 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,20 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { clearPbAuthCookie } from "@/server/auth/cookies"; +import { getRequestSession, handleRouteError } from "@/server/auth/route"; + +export async function GET(req: NextRequest) { + try { + const session = await getRequestSession(req); + if (!session.user) { + const res = NextResponse.json({ user: null }); + // Clear stale cookie if the token failed to refresh. + if (session.token) clearPbAuthCookie(res); + return res; + } + return NextResponse.json({ user: session.user }); + } catch (error) { + return handleRouteError(error, "api/auth/me"); + } +} diff --git a/src/server/auth/__tests__/session.test.ts b/src/server/auth/__tests__/session.test.ts new file mode 100644 index 0000000..568b910 --- /dev/null +++ b/src/server/auth/__tests__/session.test.ts @@ -0,0 +1,82 @@ +import PocketBase from "pocketbase"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ForbiddenError, + UnauthorizedError, + createRequestSession, + requireAdmin, + requireUser, + resolveSession, +} from "@/server/auth/session"; +import { usePbHarness } from "@/test/pb-harness"; + +describe("auth session helpers", () => { + const getHarness = usePbHarness(); + + beforeEach(() => { + vi.stubEnv("POCKETBASE_URL", getHarness().url); + vi.stubEnv("POCKETBASE_SUPERUSER_EMAIL", "test@stowage.local"); + vi.stubEnv("POCKETBASE_SUPERUSER_PASSWORD", "test-password-12345"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function seedAndSignIn(role: "admin" | "user" = "admin") { + const pb = getHarness().admin; + const email = `user-${Math.random().toString(36).slice(2)}@stowage.local`; + const password = "password123"; + await pb.collection("users").create({ + email, + password, + passwordConfirm: password, + name: "Test User", + role, + createdAt: Date.now(), + }); + const client = new PocketBase(getHarness().url); + client.autoCancellation(false); + const auth = await client + .collection("users") + .authWithPassword(email, password); + return { email, password, token: auth.token, record: auth.record }; + } + + it("resolveSession returns null for an absent token", async () => { + await expect(resolveSession(null)).resolves.toBeNull(); + await expect(resolveSession(undefined)).resolves.toBeNull(); + await expect(resolveSession("")).resolves.toBeNull(); + }); + + it("resolveSession returns null for an invalid token", async () => { + await expect(resolveSession("garbage.token.value")).resolves.toBeNull(); + }); + + it("resolveSession returns the user for a valid token", async () => { + const { token, record } = await seedAndSignIn("admin"); + const user = await resolveSession(token); + expect(user).toMatchObject({ + id: record.id, + email: record.email, + role: "admin", + }); + }); + + it("requireUser / requireAdmin enforce roles", async () => { + const { token: userToken } = await seedAndSignIn("user"); + const userSession = await createRequestSession(userToken); + expect(requireUser(userSession).role).toBe("user"); + expect(() => requireAdmin(userSession)).toThrow(ForbiddenError); + + const { token: adminToken } = await seedAndSignIn("admin"); + const adminSession = await createRequestSession(adminToken); + expect(requireAdmin(adminSession).role).toBe("admin"); + }); + + it("requireUser throws UnauthorizedError when no token is present", async () => { + const session = await createRequestSession(null); + expect(() => requireUser(session)).toThrow(UnauthorizedError); + }); +}); diff --git a/src/server/auth/cookies.ts b/src/server/auth/cookies.ts new file mode 100644 index 0000000..c546ec7 --- /dev/null +++ b/src/server/auth/cookies.ts @@ -0,0 +1,29 @@ +import type { NextResponse } from "next/server"; + +export const PB_AUTH_COOKIE = "pb_auth"; + +const MAX_AGE_SECONDS = 60 * 60 * 24 * 30; + +export function setPbAuthCookie(res: NextResponse, token: string) { + res.cookies.set({ + name: PB_AUTH_COOKIE, + value: token, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: MAX_AGE_SECONDS, + }); +} + +export function clearPbAuthCookie(res: NextResponse) { + res.cookies.set({ + name: PB_AUTH_COOKIE, + value: "", + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); +} diff --git a/src/server/auth/route.ts b/src/server/auth/route.ts new file mode 100644 index 0000000..a8e9cc8 --- /dev/null +++ b/src/server/auth/route.ts @@ -0,0 +1,101 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { DomainError } from "@/server/pb/errors"; + +import { PB_AUTH_COOKIE } from "./cookies"; +import { + createRequestSession, + requireAdmin, + requireUser, + type RequestSession, + type SessionUser, +} from "./session"; + +export function getRequestToken(req: NextRequest): string | null { + return req.cookies.get(PB_AUTH_COOKIE)?.value ?? null; +} + +export async function getRequestSession( + req: NextRequest, +): Promise { + return createRequestSession(getRequestToken(req)); +} + +export function handleRouteError( + error: unknown, + context: string, +): NextResponse { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request", issues: error.issues }, + { status: 400 }, + ); + } + if (error instanceof DomainError) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + console.error(`[${context}] unexpected error`, error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); +} + +type Handler = ( + req: NextRequest, + session: RequestSession, +) => Promise; + +export function withSession(context: string, handler: Handler) { + return async (req: NextRequest): Promise => { + try { + const session = await getRequestSession(req); + const result = await handler(req, session); + if (result instanceof NextResponse) return result; + return NextResponse.json(result); + } catch (error) { + return handleRouteError(error, context); + } + }; +} + +export function withUser( + context: string, + handler: ( + req: NextRequest, + session: RequestSession, + user: SessionUser, + ) => Promise, +) { + return withSession(context, async (req, session) => { + const user = requireUser(session); + return handler(req, session, user); + }); +} + +export function withAdmin( + context: string, + handler: ( + req: NextRequest, + session: RequestSession, + user: SessionUser, + ) => Promise, +) { + return withSession(context, async (req, session) => { + const user = requireAdmin(session); + return handler(req, session, user); + }); +} + +export async function parseJsonBody(req: NextRequest): Promise { + try { + return await req.json(); + } catch { + throw new DomainError("Invalid JSON body", 400); + } +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts new file mode 100644 index 0000000..c0769e7 --- /dev/null +++ b/src/server/auth/session.ts @@ -0,0 +1,85 @@ +import PocketBase, { ClientResponseError } from "pocketbase"; + +import { getPbUrl } from "@/server/pb/client"; +import { createAdminCtx, type Ctx } from "@/server/pb/context"; +import { DomainError } from "@/server/pb/errors"; +import type { UserRole } from "@/server/pb/users"; + +export type SessionUser = { + id: string; + email: string; + name: string; + role: UserRole; +}; + +export type RequestSession = { + ctx: Ctx; + user: SessionUser | null; + token: string | null; +}; + +class UnauthorizedError extends DomainError { + constructor(message = "Authentication required") { + super(message, 401); + this.name = "UnauthorizedError"; + } +} + +class ForbiddenError extends DomainError { + constructor(message = "Admin access required") { + super(message, 403); + this.name = "ForbiddenError"; + } +} + +export { UnauthorizedError, ForbiddenError }; + +export async function resolveSession( + token: string | null | undefined, +): Promise { + if (!token) return null; + + const client = new PocketBase(getPbUrl()); + client.autoCancellation(false); + client.authStore.save(token, null); + + try { + const { record } = await client.collection("users").authRefresh(); + return { + id: record.id, + email: record.email, + name: record.name, + role: record.role as UserRole, + }; + } catch (error) { + if (error instanceof ClientResponseError) { + return null; + } + throw error; + } +} + +export async function createRequestSession( + token: string | null | undefined, +): Promise { + const [ctx, user] = await Promise.all([ + createAdminCtx(), + resolveSession(token), + ]); + return { ctx, user, token: token ?? null }; +} + +export function requireUser(session: RequestSession): SessionUser { + if (!session.user) { + throw new UnauthorizedError(); + } + return session.user; +} + +export function requireAdmin(session: RequestSession): SessionUser { + const user = requireUser(session); + if (user.role !== "admin") { + throw new ForbiddenError(); + } + return user; +} diff --git a/src/server/pb/client.ts b/src/server/pb/client.ts index 5490529..66dbbfc 100644 --- a/src/server/pb/client.ts +++ b/src/server/pb/client.ts @@ -2,15 +2,27 @@ import "server-only"; import PocketBase from "pocketbase"; -const url = process.env.POCKETBASE_URL ?? "http://127.0.0.1:8090"; -const email = process.env.POCKETBASE_SUPERUSER_EMAIL; -const password = process.env.POCKETBASE_SUPERUSER_PASSWORD; - -let cached: PocketBase | null = null; +let cached: { pb: PocketBase; email: string; url: string } | null = null; let pending: Promise | null = null; +function readEnv() { + return { + url: process.env.POCKETBASE_URL ?? "http://127.0.0.1:8090", + email: process.env.POCKETBASE_SUPERUSER_EMAIL, + password: process.env.POCKETBASE_SUPERUSER_PASSWORD, + }; +} + export async function getPbAdmin(): Promise { - if (cached && cached.authStore.isValid) return cached; + const { url, email, password } = readEnv(); + if ( + cached && + cached.pb.authStore.isValid && + cached.url === url && + cached.email === email + ) { + return cached.pb; + } if (pending) return pending; if (!email || !password) { @@ -23,7 +35,7 @@ export async function getPbAdmin(): Promise { const pb = new PocketBase(url); pb.autoCancellation(false); await pb.collection("_superusers").authWithPassword(email, password); - cached = pb; + cached = { pb, email, url }; return pb; })(); @@ -35,5 +47,5 @@ export async function getPbAdmin(): Promise { } export function getPbUrl() { - return url; + return readEnv().url; } diff --git a/src/test/server-only-stub.ts b/src/test/server-only-stub.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/test/server-only-stub.ts @@ -0,0 +1 @@ +export {}; diff --git a/vitest.pb.config.ts b/vitest.pb.config.ts index cb8dbb6..75b197f 100644 --- a/vitest.pb.config.ts +++ b/vitest.pb.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // server-only is a Next.js build-time marker; in tests we run these + // modules directly in Node, so stub it out. + "server-only": path.resolve(__dirname, "src/test/server-only-stub.ts"), }, }, }); From 396980fdf1bf1d2d80d7ab91f4129454530e409d Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:39:15 +0200 Subject: [PATCH 12/37] Add API route handlers for all ported domains Adds thin route handlers that delegate to src/server/domain/*. Mutations inject actorId from the session user, so client bodies can't spoof ownership. Reads require any authenticated user; writes require admin, with two exceptions that follow Convex semantics: - /api/service-records (create, update, delete): any user, owner-or-admin check enforced in the domain - /api/users/me/password: any user Shared wrappers (src/server/auth/route.ts) now forward Next.js dynamic params: withUser/withAdmin pass the route context's params through to the handler. New endpoints (~40 files), one per resource: - /api/{categories,tags,locations,custom-fields,app-settings, service-providers,service-groups,service-group-fields, label-templates,users} - /api/assets + /api/assets/[id]/{status,tags} + preview-tag - /api/service-schedules (+ by-asset, calendar, upcoming) - /api/service-records (+ form, by-asset, complete-scheduled) - /api/search, /api/dashboard PB suite still 186 tests green; typecheck clean; 0 lint errors. --- src/app/api/app-settings/route.ts | 39 +++++++++++++ src/app/api/assets/[id]/route.ts | 44 ++++++++++++++ src/app/api/assets/[id]/status/route.ts | 24 ++++++++ src/app/api/assets/[id]/tags/route.ts | 29 ++++++++++ src/app/api/assets/preview-tag/route.ts | 14 +++++ src/app/api/assets/route.ts | 39 +++++++++++++ src/app/api/categories/route.ts | 57 ++++--------------- src/app/api/custom-fields/[id]/route.ts | 32 +++++++++++ src/app/api/custom-fields/reorder/route.ts | 14 +++++ src/app/api/custom-fields/route.ts | 18 ++++++ src/app/api/dashboard/route.ts | 7 +++ .../api/label-templates/[id]/default/route.ts | 11 ++++ src/app/api/label-templates/[id]/route.ts | 44 ++++++++++++++ src/app/api/label-templates/route.ts | 31 ++++++++++ src/app/api/locations/[id]/move/route.ts | 17 ++++++ src/app/api/locations/[id]/route.ts | 44 ++++++++++++++ src/app/api/locations/route.ts | 18 ++++++ src/app/api/search/route.ts | 9 +++ .../api/service-group-fields/[id]/route.ts | 34 +++++++++++ .../api/service-groups/[id]/assets/route.ts | 11 ++++ .../[id]/fields/reorder/route.ts | 19 +++++++ .../api/service-groups/[id]/fields/route.ts | 36 ++++++++++++ src/app/api/service-groups/[id]/route.ts | 44 ++++++++++++++ src/app/api/service-groups/route.ts | 31 ++++++++++ src/app/api/service-providers/[id]/route.ts | 34 +++++++++++ src/app/api/service-providers/route.ts | 31 ++++++++++ src/app/api/service-records/[id]/route.ts | 36 ++++++++++++ .../by-asset/[assetId]/route.ts | 11 ++++ .../complete-scheduled/route.ts | 21 +++++++ .../service-records/form/[assetId]/route.ts | 11 ++++ src/app/api/service-records/route.ts | 18 ++++++ .../by-asset/[assetId]/route.ts | 23 ++++++++ .../api/service-schedules/calendar/route.ts | 19 +++++++ src/app/api/service-schedules/route.ts | 24 ++++++++ .../api/service-schedules/upcoming/route.ts | 13 +++++ src/app/api/tags/[id]/route.ts | 27 +++++++++ src/app/api/tags/route.ts | 18 ++++++ src/app/api/users/[id]/role/route.ts | 24 ++++++++ src/app/api/users/me/password/route.ts | 16 ++++++ src/app/api/users/route.ts | 16 ++++++ src/server/auth/route.ts | 54 +++++++++++------- 41 files changed, 995 insertions(+), 67 deletions(-) create mode 100644 src/app/api/app-settings/route.ts create mode 100644 src/app/api/assets/[id]/route.ts create mode 100644 src/app/api/assets/[id]/status/route.ts create mode 100644 src/app/api/assets/[id]/tags/route.ts create mode 100644 src/app/api/assets/preview-tag/route.ts create mode 100644 src/app/api/assets/route.ts create mode 100644 src/app/api/custom-fields/[id]/route.ts create mode 100644 src/app/api/custom-fields/reorder/route.ts create mode 100644 src/app/api/custom-fields/route.ts create mode 100644 src/app/api/dashboard/route.ts create mode 100644 src/app/api/label-templates/[id]/default/route.ts create mode 100644 src/app/api/label-templates/[id]/route.ts create mode 100644 src/app/api/label-templates/route.ts create mode 100644 src/app/api/locations/[id]/move/route.ts create mode 100644 src/app/api/locations/[id]/route.ts create mode 100644 src/app/api/locations/route.ts create mode 100644 src/app/api/search/route.ts create mode 100644 src/app/api/service-group-fields/[id]/route.ts create mode 100644 src/app/api/service-groups/[id]/assets/route.ts create mode 100644 src/app/api/service-groups/[id]/fields/reorder/route.ts create mode 100644 src/app/api/service-groups/[id]/fields/route.ts create mode 100644 src/app/api/service-groups/[id]/route.ts create mode 100644 src/app/api/service-groups/route.ts create mode 100644 src/app/api/service-providers/[id]/route.ts create mode 100644 src/app/api/service-providers/route.ts create mode 100644 src/app/api/service-records/[id]/route.ts create mode 100644 src/app/api/service-records/by-asset/[assetId]/route.ts create mode 100644 src/app/api/service-records/complete-scheduled/route.ts create mode 100644 src/app/api/service-records/form/[assetId]/route.ts create mode 100644 src/app/api/service-records/route.ts create mode 100644 src/app/api/service-schedules/by-asset/[assetId]/route.ts create mode 100644 src/app/api/service-schedules/calendar/route.ts create mode 100644 src/app/api/service-schedules/route.ts create mode 100644 src/app/api/service-schedules/upcoming/route.ts create mode 100644 src/app/api/tags/[id]/route.ts create mode 100644 src/app/api/tags/route.ts create mode 100644 src/app/api/users/[id]/role/route.ts create mode 100644 src/app/api/users/me/password/route.ts create mode 100644 src/app/api/users/route.ts diff --git a/src/app/api/app-settings/route.ts b/src/app/api/app-settings/route.ts new file mode 100644 index 0000000..db1b20d --- /dev/null +++ b/src/app/api/app-settings/route.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + UpdateDateFormatInput, + UpdateServiceSchedulingEnabledInput, + getAppSettings, + updateDateFormat, + updateServiceSchedulingEnabled, +} from "@/server/domain/appSettings"; + +const ClientDateFormat = UpdateDateFormatInput.omit({ actorId: true }); +const ClientServiceScheduling = UpdateServiceSchedulingEnabledInput.omit({ + actorId: true, +}); +const PatchBody = z.union([ + z.object({ kind: z.literal("dateFormat") }).and(ClientDateFormat), + z.object({ kind: z.literal("serviceScheduling") }).and(ClientServiceScheduling), +]); + +export const GET = withUser("api/app-settings", async (_req, session) => ({ + settings: await getAppSettings(session.ctx), +})); + +export const PATCH = withAdmin("api/app-settings", async (req, session, user) => { + const body = PatchBody.parse(await parseJsonBody(req)); + if (body.kind === "dateFormat") { + const settings = await updateDateFormat(session.ctx, { + dateFormat: body.dateFormat, + actorId: user.id, + }); + return { settings }; + } + const settings = await updateServiceSchedulingEnabled(session.ctx, { + enabled: body.enabled, + actorId: user.id, + }); + return { settings }; +}); diff --git a/src/app/api/assets/[id]/route.ts b/src/app/api/assets/[id]/route.ts new file mode 100644 index 0000000..274cf42 --- /dev/null +++ b/src/app/api/assets/[id]/route.ts @@ -0,0 +1,44 @@ +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + UpdateAssetInput, + deleteAsset, + getAsset, + updateAsset, +} from "@/server/domain/assets"; + +const ClientUpdateAsset = UpdateAssetInput.omit({ + assetId: true, + actorId: true, +}); + +export const GET = withUser( + "api/assets/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + const asset = await getAsset(session.ctx, id); + return { asset }; + }, +); + +export const PATCH = withAdmin( + "api/assets/[id]", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateAsset.parse(await parseJsonBody(req)); + await updateAsset(session.ctx, { + ...body, + assetId: id, + actorId: user.id, + }); + return { ok: true }; + }, +); + +export const DELETE = withAdmin( + "api/assets/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteAsset(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/assets/[id]/status/route.ts b/src/app/api/assets/[id]/status/route.ts new file mode 100644 index 0000000..99cf5a5 --- /dev/null +++ b/src/app/api/assets/[id]/status/route.ts @@ -0,0 +1,24 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { + UpdateAssetStatusInput, + updateAssetStatus, +} from "@/server/domain/assets"; + +const ClientUpdateStatus = UpdateAssetStatusInput.omit({ + assetId: true, + actorId: true, +}); + +export const PATCH = withAdmin( + "api/assets/[id]/status", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateStatus.parse(await parseJsonBody(req)); + await updateAssetStatus(session.ctx, { + ...body, + assetId: id, + actorId: user.id, + }); + return { ok: true }; + }, +); diff --git a/src/app/api/assets/[id]/tags/route.ts b/src/app/api/assets/[id]/tags/route.ts new file mode 100644 index 0000000..7d113a4 --- /dev/null +++ b/src/app/api/assets/[id]/tags/route.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { getAssetTags, setAssetTags } from "@/server/domain/assetTags"; + +const Body = z.object({ tagIds: z.array(z.string()) }); + +export const GET = withUser( + "api/assets/[id]/tags", + async (_req, session, _user, { params }) => { + const { id } = await params; + const tags = await getAssetTags(session.ctx, id); + return { tags }; + }, +); + +export const PUT = withAdmin( + "api/assets/[id]/tags", + async (req, session, user, { params }) => { + const { id } = await params; + const body = Body.parse(await parseJsonBody(req)); + await setAssetTags(session.ctx, { + assetId: id, + tagIds: body.tagIds, + actorId: user.id, + }); + return { ok: true }; + }, +); diff --git a/src/app/api/assets/preview-tag/route.ts b/src/app/api/assets/preview-tag/route.ts new file mode 100644 index 0000000..b4c3e34 --- /dev/null +++ b/src/app/api/assets/preview-tag/route.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { withUser } from "@/server/auth/route"; +import { generateAssetTag } from "@/server/domain/assets"; + +const QuerySchema = z.object({ categoryId: z.string().nullable() }); + +export const GET = withUser("api/assets/preview-tag", async (req, session) => { + const url = new URL(req.url); + const raw = url.searchParams.get("categoryId"); + const { categoryId } = QuerySchema.parse({ categoryId: raw ?? null }); + const preview = await generateAssetTag(session.ctx, categoryId); + return { preview }; +}); diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts new file mode 100644 index 0000000..8ab3dc8 --- /dev/null +++ b/src/app/api/assets/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateAssetInput, + ListAssetsInput, + createAsset, + listAssets, +} from "@/server/domain/assets"; + +const ClientCreateAsset = CreateAssetInput.omit({ actorId: true }); + +export const GET = withUser("api/assets", async (req, session) => { + const url = new URL(req.url); + const raw = { + categoryId: url.searchParams.get("categoryId") ?? undefined, + locationId: url.searchParams.get("locationId") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + tagIds: url.searchParams.getAll("tagId"), + search: url.searchParams.get("search") ?? undefined, + sortBy: url.searchParams.get("sortBy") ?? undefined, + sortDirection: url.searchParams.get("sortDirection") ?? undefined, + }; + const parsed = ListAssetsInput.parse( + Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined && !(Array.isArray(v) && v.length === 0))), + ); + const assets = await listAssets(session.ctx, parsed); + return { assets }; +}); + +export const POST = withAdmin("api/assets", async (req, session, user) => { + const body = ClientCreateAsset.parse(await parseJsonBody(req)); + const { assetId } = await createAsset(session.ctx, { + ...body, + actorId: user.id, + }); + return NextResponse.json({ assetId }, { status: 201 }); +}); diff --git a/src/app/api/categories/route.ts b/src/app/api/categories/route.ts index 8f126ce..b04f32b 100644 --- a/src/app/api/categories/route.ts +++ b/src/app/api/categories/route.ts @@ -1,55 +1,18 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; import { + CreateCategoryInput, createCategory, listCategories, - CreateCategoryInput, } from "@/server/domain/categories"; -import { createAdminCtx } from "@/server/pb/context"; -import { DomainError } from "@/server/pb/errors"; - -export async function GET() { - try { - const ctx = await createAdminCtx(); - const categories = await listCategories(ctx); - return NextResponse.json({ categories }); - } catch (error) { - return handleError(error); - } -} - -export async function POST(request: Request) { - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - try { - const input = CreateCategoryInput.parse(body); - const ctx = await createAdminCtx(); - const category = await createCategory(ctx, input); - return NextResponse.json({ category }, { status: 201 }); - } catch (error) { - return handleError(error); - } -} +export const GET = withUser("api/categories", async (_req, session) => ({ + categories: await listCategories(session.ctx), +})); -function handleError(error: unknown) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Invalid request", issues: error.issues }, - { status: 400 }, - ); - } - if (error instanceof DomainError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - console.error("[api/categories] unexpected error", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); -} +export const POST = withAdmin("api/categories", async (req, session) => { + const body = CreateCategoryInput.parse(await parseJsonBody(req)); + const category = await createCategory(session.ctx, body); + return NextResponse.json({ category }, { status: 201 }); +}); diff --git a/src/app/api/custom-fields/[id]/route.ts b/src/app/api/custom-fields/[id]/route.ts new file mode 100644 index 0000000..74f8b14 --- /dev/null +++ b/src/app/api/custom-fields/[id]/route.ts @@ -0,0 +1,32 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { + UpdateFieldDefinitionInput, + deleteFieldDefinition, + updateFieldDefinition, +} from "@/server/domain/customFields"; + +const ClientUpdateFieldDef = UpdateFieldDefinitionInput.omit({ + fieldDefinitionId: true, +}); + +export const PATCH = withAdmin( + "api/custom-fields/[id]", + async (req, session, _user, { params }) => { + const { id } = await params; + const body = ClientUpdateFieldDef.parse(await parseJsonBody(req)); + const field = await updateFieldDefinition(session.ctx, { + ...body, + fieldDefinitionId: id, + }); + return { field }; + }, +); + +export const DELETE = withAdmin( + "api/custom-fields/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteFieldDefinition(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/custom-fields/reorder/route.ts b/src/app/api/custom-fields/reorder/route.ts new file mode 100644 index 0000000..6ae6e3d --- /dev/null +++ b/src/app/api/custom-fields/reorder/route.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { reorderFieldDefinitions } from "@/server/domain/customFields"; + +const ReorderInput = z.object({ + fieldDefinitionIds: z.array(z.string()), +}); + +export const POST = withAdmin("api/custom-fields/reorder", async (req, session) => { + const body = ReorderInput.parse(await parseJsonBody(req)); + await reorderFieldDefinitions(session.ctx, body.fieldDefinitionIds); + return { ok: true }; +}); diff --git a/src/app/api/custom-fields/route.ts b/src/app/api/custom-fields/route.ts new file mode 100644 index 0000000..2458f86 --- /dev/null +++ b/src/app/api/custom-fields/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateFieldDefinitionInput, + createFieldDefinition, + listFieldDefinitions, +} from "@/server/domain/customFields"; + +export const GET = withUser("api/custom-fields", async (_req, session) => ({ + fields: await listFieldDefinitions(session.ctx), +})); + +export const POST = withAdmin("api/custom-fields", async (req, session) => { + const body = CreateFieldDefinitionInput.parse(await parseJsonBody(req)); + const field = await createFieldDefinition(session.ctx, body); + return NextResponse.json({ field }, { status: 201 }); +}); diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..4c01aa7 --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -0,0 +1,7 @@ +import { withUser } from "@/server/auth/route"; +import { getOverview } from "@/server/domain/dashboard"; + +export const GET = withUser("api/dashboard", async (_req, session) => { + const overview = await getOverview(session.ctx); + return { overview }; +}); diff --git a/src/app/api/label-templates/[id]/default/route.ts b/src/app/api/label-templates/[id]/default/route.ts new file mode 100644 index 0000000..ee5390e --- /dev/null +++ b/src/app/api/label-templates/[id]/default/route.ts @@ -0,0 +1,11 @@ +import { withAdmin } from "@/server/auth/route"; +import { setDefaultTemplate } from "@/server/domain/labelTemplates"; + +export const POST = withAdmin( + "api/label-templates/[id]/default", + async (_req, session, _user, { params }) => { + const { id } = await params; + await setDefaultTemplate(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/label-templates/[id]/route.ts b/src/app/api/label-templates/[id]/route.ts new file mode 100644 index 0000000..1635490 --- /dev/null +++ b/src/app/api/label-templates/[id]/route.ts @@ -0,0 +1,44 @@ +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + UpdateTemplateInput, + deleteTemplate, + getTemplate, + updateTemplate, +} from "@/server/domain/labelTemplates"; + +const ClientUpdateTemplate = UpdateTemplateInput.omit({ + templateId: true, + actorId: true, +}); + +export const GET = withUser( + "api/label-templates/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + const template = await getTemplate(session.ctx, id); + return { template }; + }, +); + +export const PATCH = withAdmin( + "api/label-templates/[id]", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateTemplate.parse(await parseJsonBody(req)); + const template = await updateTemplate(session.ctx, { + ...body, + templateId: id, + actorId: user.id, + }); + return { template }; + }, +); + +export const DELETE = withAdmin( + "api/label-templates/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteTemplate(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/label-templates/route.ts b/src/app/api/label-templates/route.ts new file mode 100644 index 0000000..8c9d998 --- /dev/null +++ b/src/app/api/label-templates/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateTemplateInput, + createTemplate, + getDefaultTemplate, + listTemplates, +} from "@/server/domain/labelTemplates"; + +const ClientCreateTemplate = CreateTemplateInput.omit({ actorId: true }); + +export const GET = withUser("api/label-templates", async (req, session) => { + const variant = new URL(req.url).searchParams.get("variant"); + if (variant === "default") { + return { template: await getDefaultTemplate(session.ctx) }; + } + return { templates: await listTemplates(session.ctx) }; +}); + +export const POST = withAdmin( + "api/label-templates", + async (req, session, user) => { + const body = ClientCreateTemplate.parse(await parseJsonBody(req)); + const template = await createTemplate(session.ctx, { + ...body, + actorId: user.id, + }); + return NextResponse.json({ template }, { status: 201 }); + }, +); diff --git a/src/app/api/locations/[id]/move/route.ts b/src/app/api/locations/[id]/move/route.ts new file mode 100644 index 0000000..e21f4d7 --- /dev/null +++ b/src/app/api/locations/[id]/move/route.ts @@ -0,0 +1,17 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { MoveLocationInput, moveLocation } from "@/server/domain/locations"; + +const ClientMoveLocation = MoveLocationInput.omit({ locationId: true }); + +export const POST = withAdmin( + "api/locations/[id]/move", + async (req, session, _user, { params }) => { + const { id } = await params; + const body = ClientMoveLocation.parse(await parseJsonBody(req)); + const location = await moveLocation(session.ctx, { + ...body, + locationId: id, + }); + return { location }; + }, +); diff --git a/src/app/api/locations/[id]/route.ts b/src/app/api/locations/[id]/route.ts new file mode 100644 index 0000000..305c6b8 --- /dev/null +++ b/src/app/api/locations/[id]/route.ts @@ -0,0 +1,44 @@ +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + UpdateLocationInput, + deleteLocation, + getLocationChildren, + getLocationPath, + updateLocation, +} from "@/server/domain/locations"; + +const ClientUpdateLocation = UpdateLocationInput.omit({ locationId: true }); + +export const GET = withUser( + "api/locations/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + const [children, path] = await Promise.all([ + getLocationChildren(session.ctx, id), + getLocationPath(session.ctx, id), + ]); + return { children, path }; + }, +); + +export const PATCH = withAdmin( + "api/locations/[id]", + async (req, session, _user, { params }) => { + const { id } = await params; + const body = ClientUpdateLocation.parse(await parseJsonBody(req)); + const location = await updateLocation(session.ctx, { + ...body, + locationId: id, + }); + return { location }; + }, +); + +export const DELETE = withAdmin( + "api/locations/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteLocation(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/locations/route.ts b/src/app/api/locations/route.ts new file mode 100644 index 0000000..2da59d2 --- /dev/null +++ b/src/app/api/locations/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateLocationInput, + createLocation, + listLocations, +} from "@/server/domain/locations"; + +export const GET = withUser("api/locations", async (_req, session) => ({ + locations: await listLocations(session.ctx), +})); + +export const POST = withAdmin("api/locations", async (req, session) => { + const body = CreateLocationInput.parse(await parseJsonBody(req)); + const location = await createLocation(session.ctx, body); + return NextResponse.json({ location }, { status: 201 }); +}); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..3365218 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,9 @@ +import { withUser } from "@/server/auth/route"; +import { searchAssets } from "@/server/domain/search"; + +export const GET = withUser("api/search", async (req, session) => { + const url = new URL(req.url); + const query = url.searchParams.get("q") ?? ""; + const results = await searchAssets(session.ctx, query); + return { results }; +}); diff --git a/src/app/api/service-group-fields/[id]/route.ts b/src/app/api/service-group-fields/[id]/route.ts new file mode 100644 index 0000000..8a96376 --- /dev/null +++ b/src/app/api/service-group-fields/[id]/route.ts @@ -0,0 +1,34 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { + UpdateFieldInput, + deleteField, + updateField, +} from "@/server/domain/serviceGroupFields"; + +const ClientUpdateField = UpdateFieldInput.omit({ + fieldId: true, + actorId: true, +}); + +export const PATCH = withAdmin( + "api/service-group-fields/[id]", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateField.parse(await parseJsonBody(req)); + const field = await updateField(session.ctx, { + ...body, + fieldId: id, + actorId: user.id, + }); + return { field }; + }, +); + +export const DELETE = withAdmin( + "api/service-group-fields/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteField(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/service-groups/[id]/assets/route.ts b/src/app/api/service-groups/[id]/assets/route.ts new file mode 100644 index 0000000..6fe82e4 --- /dev/null +++ b/src/app/api/service-groups/[id]/assets/route.ts @@ -0,0 +1,11 @@ +import { withUser } from "@/server/auth/route"; +import { listGroupAssets } from "@/server/domain/serviceGroups"; + +export const GET = withUser( + "api/service-groups/[id]/assets", + async (_req, session, _user, { params }) => { + const { id } = await params; + const assets = await listGroupAssets(session.ctx, id); + return { assets }; + }, +); diff --git a/src/app/api/service-groups/[id]/fields/reorder/route.ts b/src/app/api/service-groups/[id]/fields/reorder/route.ts new file mode 100644 index 0000000..dae1a06 --- /dev/null +++ b/src/app/api/service-groups/[id]/fields/reorder/route.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { reorderFields } from "@/server/domain/serviceGroupFields"; + +const Body = z.object({ fieldIds: z.array(z.string()) }); + +export const POST = withAdmin( + "api/service-groups/[id]/fields/reorder", + async (req, session, _user, { params }) => { + const { id } = await params; + const body = Body.parse(await parseJsonBody(req)); + await reorderFields(session.ctx, { + groupId: id, + fieldIds: body.fieldIds, + }); + return { ok: true }; + }, +); diff --git a/src/app/api/service-groups/[id]/fields/route.ts b/src/app/api/service-groups/[id]/fields/route.ts new file mode 100644 index 0000000..0ffd4ba --- /dev/null +++ b/src/app/api/service-groups/[id]/fields/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateFieldInput, + createField, + listFields, +} from "@/server/domain/serviceGroupFields"; + +const ClientCreateField = CreateFieldInput.omit({ + groupId: true, + actorId: true, +}); + +export const GET = withUser( + "api/service-groups/[id]/fields", + async (_req, session, _user, { params }) => { + const { id } = await params; + const fields = await listFields(session.ctx, id); + return { fields }; + }, +); + +export const POST = withAdmin( + "api/service-groups/[id]/fields", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientCreateField.parse(await parseJsonBody(req)); + const field = await createField(session.ctx, { + ...body, + groupId: id, + actorId: user.id, + }); + return NextResponse.json({ field }, { status: 201 }); + }, +); diff --git a/src/app/api/service-groups/[id]/route.ts b/src/app/api/service-groups/[id]/route.ts new file mode 100644 index 0000000..7a71aa5 --- /dev/null +++ b/src/app/api/service-groups/[id]/route.ts @@ -0,0 +1,44 @@ +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + UpdateGroupInput, + deleteGroup, + getGroup, + updateGroup, +} from "@/server/domain/serviceGroups"; + +const ClientUpdateGroup = UpdateGroupInput.omit({ + groupId: true, + actorId: true, +}); + +export const GET = withUser( + "api/service-groups/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + const group = await getGroup(session.ctx, id); + return { group }; + }, +); + +export const PATCH = withAdmin( + "api/service-groups/[id]", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateGroup.parse(await parseJsonBody(req)); + const group = await updateGroup(session.ctx, { + ...body, + groupId: id, + actorId: user.id, + }); + return { group }; + }, +); + +export const DELETE = withAdmin( + "api/service-groups/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteGroup(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/service-groups/route.ts b/src/app/api/service-groups/route.ts new file mode 100644 index 0000000..16b4499 --- /dev/null +++ b/src/app/api/service-groups/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateGroupInput, + createGroup, + listAssignableGroups, + listGroups, +} from "@/server/domain/serviceGroups"; + +const ClientCreateGroup = CreateGroupInput.omit({ actorId: true }); + +export const GET = withUser("api/service-groups", async (req, session) => { + const variant = new URL(req.url).searchParams.get("variant"); + if (variant === "assignable") { + return { groups: await listAssignableGroups(session.ctx) }; + } + return { groups: await listGroups(session.ctx) }; +}); + +export const POST = withAdmin( + "api/service-groups", + async (req, session, user) => { + const body = ClientCreateGroup.parse(await parseJsonBody(req)); + const group = await createGroup(session.ctx, { + ...body, + actorId: user.id, + }); + return NextResponse.json({ group }, { status: 201 }); + }, +); diff --git a/src/app/api/service-providers/[id]/route.ts b/src/app/api/service-providers/[id]/route.ts new file mode 100644 index 0000000..0298dba --- /dev/null +++ b/src/app/api/service-providers/[id]/route.ts @@ -0,0 +1,34 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { + UpdateProviderInput, + deleteProvider, + updateProvider, +} from "@/server/domain/serviceProviders"; + +const ClientUpdateProvider = UpdateProviderInput.omit({ + providerId: true, + actorId: true, +}); + +export const PATCH = withAdmin( + "api/service-providers/[id]", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateProvider.parse(await parseJsonBody(req)); + const provider = await updateProvider(session.ctx, { + ...body, + providerId: id, + actorId: user.id, + }); + return { provider }; + }, +); + +export const DELETE = withAdmin( + "api/service-providers/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteProvider(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/service-providers/route.ts b/src/app/api/service-providers/route.ts new file mode 100644 index 0000000..02b3e47 --- /dev/null +++ b/src/app/api/service-providers/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateProviderInput, + createProvider, + listProviderOptions, + listProviders, +} from "@/server/domain/serviceProviders"; + +const ClientCreateProvider = CreateProviderInput.omit({ actorId: true }); + +export const GET = withUser("api/service-providers", async (req, session) => { + const variant = new URL(req.url).searchParams.get("variant"); + if (variant === "options") { + return { providers: await listProviderOptions(session.ctx) }; + } + return { providers: await listProviders(session.ctx) }; +}); + +export const POST = withAdmin( + "api/service-providers", + async (req, session, user) => { + const body = ClientCreateProvider.parse(await parseJsonBody(req)); + const provider = await createProvider(session.ctx, { + ...body, + actorId: user.id, + }); + return NextResponse.json({ provider }, { status: 201 }); + }, +); diff --git a/src/app/api/service-records/[id]/route.ts b/src/app/api/service-records/[id]/route.ts new file mode 100644 index 0000000..7af1aa8 --- /dev/null +++ b/src/app/api/service-records/[id]/route.ts @@ -0,0 +1,36 @@ +import { parseJsonBody, withUser } from "@/server/auth/route"; +import { + UpdateRecordInput, + deleteRecord, + updateRecord, +} from "@/server/domain/serviceRecords"; + +const ClientUpdate = UpdateRecordInput.omit({ + recordId: true, + actorId: true, + actorRole: true, +}); + +export const PATCH = withUser( + "api/service-records/[id]", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdate.parse(await parseJsonBody(req)); + await updateRecord(session.ctx, { + ...body, + recordId: id, + actorId: user.id, + actorRole: user.role, + }); + return { ok: true }; + }, +); + +export const DELETE = withUser( + "api/service-records/[id]", + async (_req, session, user, { params }) => { + const { id } = await params; + await deleteRecord(session.ctx, id, { id: user.id, role: user.role }); + return { ok: true }; + }, +); diff --git a/src/app/api/service-records/by-asset/[assetId]/route.ts b/src/app/api/service-records/by-asset/[assetId]/route.ts new file mode 100644 index 0000000..56b635b --- /dev/null +++ b/src/app/api/service-records/by-asset/[assetId]/route.ts @@ -0,0 +1,11 @@ +import { withUser } from "@/server/auth/route"; +import { listAssetRecords } from "@/server/domain/serviceRecords"; + +export const GET = withUser( + "api/service-records/by-asset/[assetId]", + async (_req, session, _user, { params }) => { + const { assetId } = await params; + const records = await listAssetRecords(session.ctx, assetId); + return { records }; + }, +); diff --git a/src/app/api/service-records/complete-scheduled/route.ts b/src/app/api/service-records/complete-scheduled/route.ts new file mode 100644 index 0000000..b0d7bf4 --- /dev/null +++ b/src/app/api/service-records/complete-scheduled/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withUser } from "@/server/auth/route"; +import { + CompleteScheduledServiceInput, + completeScheduledService, +} from "@/server/domain/serviceRecords"; + +const ClientComplete = CompleteScheduledServiceInput.omit({ actorId: true }); + +export const POST = withUser( + "api/service-records/complete-scheduled", + async (req, session, user) => { + const body = ClientComplete.parse(await parseJsonBody(req)); + const result = await completeScheduledService(session.ctx, { + ...body, + actorId: user.id, + }); + return NextResponse.json(result, { status: 201 }); + }, +); diff --git a/src/app/api/service-records/form/[assetId]/route.ts b/src/app/api/service-records/form/[assetId]/route.ts new file mode 100644 index 0000000..bef53b9 --- /dev/null +++ b/src/app/api/service-records/form/[assetId]/route.ts @@ -0,0 +1,11 @@ +import { withUser } from "@/server/auth/route"; +import { getRecordFormDefinition } from "@/server/domain/serviceRecords"; + +export const GET = withUser( + "api/service-records/form/[assetId]", + async (_req, session, _user, { params }) => { + const { assetId } = await params; + const form = await getRecordFormDefinition(session.ctx, { assetId }); + return { form }; + }, +); diff --git a/src/app/api/service-records/route.ts b/src/app/api/service-records/route.ts new file mode 100644 index 0000000..4e319b5 --- /dev/null +++ b/src/app/api/service-records/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withUser } from "@/server/auth/route"; +import { + CreateRecordInput, + createRecord, +} from "@/server/domain/serviceRecords"; + +const ClientCreate = CreateRecordInput.omit({ actorId: true }); + +export const POST = withUser("api/service-records", async (req, session, user) => { + const body = ClientCreate.parse(await parseJsonBody(req)); + const result = await createRecord(session.ctx, { + ...body, + actorId: user.id, + }); + return NextResponse.json(result, { status: 201 }); +}); diff --git a/src/app/api/service-schedules/by-asset/[assetId]/route.ts b/src/app/api/service-schedules/by-asset/[assetId]/route.ts new file mode 100644 index 0000000..03b0e4e --- /dev/null +++ b/src/app/api/service-schedules/by-asset/[assetId]/route.ts @@ -0,0 +1,23 @@ +import { withAdmin, withUser } from "@/server/auth/route"; +import { + deleteSchedule, + getScheduleByAssetId, +} from "@/server/domain/serviceSchedules"; + +export const GET = withUser( + "api/service-schedules/by-asset/[assetId]", + async (_req, session, _user, { params }) => { + const { assetId } = await params; + const schedule = await getScheduleByAssetId(session.ctx, assetId); + return { schedule }; + }, +); + +export const DELETE = withAdmin( + "api/service-schedules/by-asset/[assetId]", + async (_req, session, _user, { params }) => { + const { assetId } = await params; + await deleteSchedule(session.ctx, assetId); + return { ok: true }; + }, +); diff --git a/src/app/api/service-schedules/calendar/route.ts b/src/app/api/service-schedules/calendar/route.ts new file mode 100644 index 0000000..70b061d --- /dev/null +++ b/src/app/api/service-schedules/calendar/route.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { withUser } from "@/server/auth/route"; +import { listCalendarMonth } from "@/server/domain/serviceSchedules"; + +const Query = z.object({ + year: z.coerce.number().int(), + month: z.coerce.number().int().min(1).max(12), +}); + +export const GET = withUser("api/service-schedules/calendar", async (req, session) => { + const url = new URL(req.url); + const { year, month } = Query.parse({ + year: url.searchParams.get("year"), + month: url.searchParams.get("month"), + }); + const schedules = await listCalendarMonth(session.ctx, { year, month }); + return { schedules }; +}); diff --git a/src/app/api/service-schedules/route.ts b/src/app/api/service-schedules/route.ts new file mode 100644 index 0000000..e785949 --- /dev/null +++ b/src/app/api/service-schedules/route.ts @@ -0,0 +1,24 @@ +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + UpsertScheduleInput, + listScheduledAssets, + upsertSchedule, +} from "@/server/domain/serviceSchedules"; + +const ClientUpsert = UpsertScheduleInput.omit({ actorId: true }); + +export const GET = withUser("api/service-schedules", async (_req, session) => ({ + schedules: await listScheduledAssets(session.ctx), +})); + +export const PUT = withAdmin( + "api/service-schedules", + async (req, session, user) => { + const body = ClientUpsert.parse(await parseJsonBody(req)); + const schedule = await upsertSchedule(session.ctx, { + ...body, + actorId: user.id, + }); + return { schedule }; + }, +); diff --git a/src/app/api/service-schedules/upcoming/route.ts b/src/app/api/service-schedules/upcoming/route.ts new file mode 100644 index 0000000..6b0ec7f --- /dev/null +++ b/src/app/api/service-schedules/upcoming/route.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { withUser } from "@/server/auth/route"; +import { listUpcomingServiceDueInDays } from "@/server/domain/serviceSchedules"; + +const Query = z.object({ days: z.coerce.number().int().positive() }); + +export const GET = withUser("api/service-schedules/upcoming", async (req, session) => { + const url = new URL(req.url); + const { days } = Query.parse({ days: url.searchParams.get("days") ?? "30" }); + const schedules = await listUpcomingServiceDueInDays(session.ctx, days); + return { schedules }; +}); diff --git a/src/app/api/tags/[id]/route.ts b/src/app/api/tags/[id]/route.ts new file mode 100644 index 0000000..b283c5c --- /dev/null +++ b/src/app/api/tags/[id]/route.ts @@ -0,0 +1,27 @@ +import { withAdmin, parseJsonBody } from "@/server/auth/route"; +import { + UpdateTagInput, + deleteTag, + updateTag, +} from "@/server/domain/tags"; + +const ClientUpdateTag = UpdateTagInput.omit({ tagId: true }); + +export const PATCH = withAdmin( + "api/tags/[id]", + async (req, session, _user, { params }) => { + const { id } = await params; + const body = ClientUpdateTag.parse(await parseJsonBody(req)); + const tag = await updateTag(session.ctx, { ...body, tagId: id }); + return { tag }; + }, +); + +export const DELETE = withAdmin( + "api/tags/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteTag(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..0b630a6 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin, withUser } from "@/server/auth/route"; +import { + CreateTagInput, + createTag, + listTags, +} from "@/server/domain/tags"; + +export const GET = withUser("api/tags", async (_req, session) => ({ + tags: await listTags(session.ctx), +})); + +export const POST = withAdmin("api/tags", async (req, session) => { + const body = CreateTagInput.parse(await parseJsonBody(req)); + const tag = await createTag(session.ctx, body); + return NextResponse.json({ tag }, { status: 201 }); +}); diff --git a/src/app/api/users/[id]/role/route.ts b/src/app/api/users/[id]/role/route.ts new file mode 100644 index 0000000..12e922f --- /dev/null +++ b/src/app/api/users/[id]/role/route.ts @@ -0,0 +1,24 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { + UpdateUserRoleInput, + updateUserRole, +} from "@/server/domain/users"; + +const ClientUpdateRole = UpdateUserRoleInput.omit({ + userId: true, + actorId: true, +}); + +export const PATCH = withAdmin( + "api/users/[id]/role", + async (req, session, user, { params }) => { + const { id } = await params; + const body = ClientUpdateRole.parse(await parseJsonBody(req)); + const updated = await updateUserRole(session.ctx, { + ...body, + userId: id, + actorId: user.id, + }); + return { user: updated }; + }, +); diff --git a/src/app/api/users/me/password/route.ts b/src/app/api/users/me/password/route.ts new file mode 100644 index 0000000..6132dc9 --- /dev/null +++ b/src/app/api/users/me/password/route.ts @@ -0,0 +1,16 @@ +import { parseJsonBody, withUser } from "@/server/auth/route"; +import { + ChangePasswordInput, + changePassword, +} from "@/server/domain/users"; + +const ClientChangePassword = ChangePasswordInput.omit({ userId: true }); + +export const POST = withUser( + "api/users/me/password", + async (req, session, user) => { + const body = ClientChangePassword.parse(await parseJsonBody(req)); + await changePassword(session.ctx, { ...body, userId: user.id }); + return { ok: true }; + }, +); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..e777738 --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { CreateUserInput, createUser, listUsers } from "@/server/domain/users"; + +const ClientCreateUser = CreateUserInput.omit({ actorId: true }); + +export const GET = withAdmin("api/users", async (_req, session) => ({ + users: await listUsers(session.ctx), +})); + +export const POST = withAdmin("api/users", async (req, session, user) => { + const body = ClientCreateUser.parse(await parseJsonBody(req)); + const created = await createUser(session.ctx, { ...body, actorId: user.id }); + return NextResponse.json({ user: created }, { status: 201 }); +}); diff --git a/src/server/auth/route.ts b/src/server/auth/route.ts index a8e9cc8..11af89d 100644 --- a/src/server/auth/route.ts +++ b/src/server/auth/route.ts @@ -13,6 +13,10 @@ import { type SessionUser, } from "./session"; +export type RouteContext

= Record> = { + params: Promise

; +}; + export function getRequestToken(req: NextRequest): string | null { return req.cookies.get(PB_AUTH_COOKIE)?.value ?? null; } @@ -46,16 +50,34 @@ export function handleRouteError( ); } -type Handler = ( +type SessionHandler> = ( + req: NextRequest, + session: RequestSession, + routeCtx: RouteContext

, +) => Promise; + +type UserHandler> = ( req: NextRequest, session: RequestSession, + user: SessionUser, + routeCtx: RouteContext

, ) => Promise; -export function withSession(context: string, handler: Handler) { - return async (req: NextRequest): Promise => { +export function withSession = Record>( + context: string, + handler: SessionHandler, +) { + return async ( + req: NextRequest, + routeCtx?: RouteContext

, + ): Promise => { try { const session = await getRequestSession(req); - const result = await handler(req, session); + const result = await handler( + req, + session, + routeCtx ?? { params: Promise.resolve({} as P) }, + ); if (result instanceof NextResponse) return result; return NextResponse.json(result); } catch (error) { @@ -64,31 +86,23 @@ export function withSession(context: string, handler: Handler) { }; } -export function withUser( +export function withUser = Record>( context: string, - handler: ( - req: NextRequest, - session: RequestSession, - user: SessionUser, - ) => Promise, + handler: UserHandler, ) { - return withSession(context, async (req, session) => { + return withSession(context, async (req, session, routeCtx) => { const user = requireUser(session); - return handler(req, session, user); + return handler(req, session, user, routeCtx); }); } -export function withAdmin( +export function withAdmin = Record>( context: string, - handler: ( - req: NextRequest, - session: RequestSession, - user: SessionUser, - ) => Promise, + handler: UserHandler, ) { - return withSession(context, async (req, session) => { + return withSession(context, async (req, session, routeCtx) => { const user = requireAdmin(session); - return handler(req, session, user); + return handler(req, session, user, routeCtx); }); } From 4ec9c6291e39c0f991606c5362e3e9e59a178872 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:40:09 +0200 Subject: [PATCH 13/37] Update migration plan with API routes + auth status Reflects the current shape: 186 PB tests, 13 commits, API routes done, auth session layer done. Notes the authz-at-the-API-boundary choice (withUser/withAdmin) versus threading it through every domain function. --- .../2026-04-19-pocketbase-migration-plan.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-04-19-pocketbase-migration-plan.md b/docs/plans/2026-04-19-pocketbase-migration-plan.md index 2200eb4..8df944f 100644 --- a/docs/plans/2026-04-19-pocketbase-migration-plan.md +++ b/docs/plans/2026-04-19-pocketbase-migration-plan.md @@ -2,7 +2,7 @@ **Date:** 2026-04-19 **Branch:** `pocketbase-migration` -**Status:** Server-side domain work substantially complete — auth, attachments, API routes, and frontend refactor remaining +**Status:** Server side complete (domains + auth session layer + API routes). Attachments pipeline, frontend refactor, and /setup form swap remain. Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean installs only; **no existing deployment will carry data across**, so there is no data migration, no ETL, no cutover password flow, no ID redirect table. The bar is functional parity: every feature that works on Convex today must work identically on PocketBase, proven by tests before we land the branch on `main`. @@ -10,25 +10,30 @@ Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean ## Current status (updated 2026-04-20) -**9 commits on `pocketbase-migration`.** Test surface: **168 PB domain tests across 16 files**, **440 main tests** (jsdom + convex-test), typecheck clean, lint 0 errors. +**13 commits on `pocketbase-migration`.** Test surface: **186 PB tests across 18 files**, **440 main tests** (jsdom + convex-test), typecheck clean, lint 0 errors. ### Done - **M1 Spike** — PocketBase v0.37.1 wired into `mprocs` + `pnpm pb:setup`; categories domain proven end-to-end with an API route, provider, realtime hook. - **M2 Test infrastructure** — `src/test/pb-harness.ts` (per-file PB serve on random port + tmp data dir, truncation in reverse FK order with self-ref retry), separate `vitest.pb.config.ts`, `test:pb` script, `.github/workflows/ci.yml` running lint + typecheck + both suites. - **M3a Schema** — All 16 collections in `pb_migrations/1713484900_remaining_collections.js` (users auth extension, catalog, assets, join tables, service schedules/records/attachments, labelTemplates); FTS5 virtual table + triggers in `1713485000_assets_fts.js`. +- **M3b Auth session layer** — `src/server/auth/{cookies,session,route}.ts`: HttpOnly `pb_auth` cookie, `resolveSession` via PB `authRefresh`, `withSession/withUser/withAdmin` wrappers that forward Next dynamic params. `/api/auth/{login,logout,me,first-run,first-admin}` endpoints. 5 session tests. - **M4 Catalog domain ports (complete)** — categories, tags, locations (hierarchy + path recompute), customFields, appSettings, serviceProviders, serviceGroups, serviceGroupFields, labelTemplates. Each with 1:1 tests against the harness. - **M5 Assets + search + dashboard + asset tags** — CRUD with generateAssetTag, custom-field validation across all types, usage-count bookkeeping, tag-intersection filter, weighted search, dashboard aggregates, cascade delete. - **M5 Service schedules + records** — Interval math, same-day snap-forward, calendar month / upcoming windows, record field snapshots, complete-scheduled-service auto-advance. +- **M5 Users domain** — `listUsers`, `getUserById/Email`, `createUser`, `createFirstAdmin` (gated by `checkFirstRun`), `updateUserRole` (last-admin guard), `changePassword` (verified via scratch PB client). 13 tests. +- **M6 API route handlers** — Every ported domain has thin handlers under `src/app/api/**/route.ts` (~40 routes). Mutations inject `actorId` from the session user; `withAdmin` gates writes on most domains (service-records and /api/users/me/password are user-scope). + +### Authz shape (decision note) + +Authz lives at the API route boundary via `withUser` / `withAdmin` rather than inside every domain function. Domain functions keep accepting explicit `actorId` (+ occasionally `actorRole` for owner-or-admin checks); routes strip those fields from client bodies and inject from the resolved session. This keeps domain modules pure and testable while ensuring every write goes through a single authz checkpoint. ### In flight / remaining -- **Users domain** — `listUsers`, `getCurrentUser`, `createUser`, `createFirstAdmin`, `updateUserRole`, `changePassword`. PB's default `users` auth collection is already extended with `role` + audit fields. - **Attachments + optimization pipeline** — Upload, Jimp image / pdf-lib PDF optimization, status state machine, storage quota. Moves from Convex internalAction to a Next.js `POST /api/attachments/[id]/optimize` route. -- **Auth integration (M3b)** — `PocketBaseClientProvider` already exists for the spike; needs `pb.authStore` cookie bridge, Next middleware for SSR auth, authz wired into every domain function (`requireAuthenticatedUser(ctx)` / `requireAdminUser(ctx)`). -- **First-run setup page (M3c)** — `/setup` gated by `.install-token` written to `/pb_data` on first boot. -- **API route handlers** — Only `/api/categories` exists. Every ported domain module needs a thin `src/app/api/**/route.ts`. -- **Frontend refactor (M7)** — Every `useQuery` / `useMutation` call site (~60 components) switches to TanStack Query + `useRealtimeCollection` / `useRealtimeRecord`. +- **First-run setup page (M3c)** — `/setup` still wired to Convex; swap its form to POST `/api/auth/first-admin`. Install-token gating deferred to deployment hardening. +- **Frontend refactor (M7)** — Every `useQuery` / `useMutation` call site (~60 components) switches to TanStack Query + `useRealtimeCollection` / `useRealtimeRecord`, talking to the new `/api/**` routes. +- **`src/lib/server-auth.ts`** — Still calls Convex; replace with direct PB session checks once the auth UI switches over. - **E2E** — Playwright flows against the PB stack. ### Not yet touched From cda6dc3b17d3926119d132ff2a5f35c6236e3263 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:50:25 +0200 Subject: [PATCH 14/37] Port attachments + optimization pipeline to PocketBase - src/server/pb/attachments.ts: classification, sanitization, retry constants (pure helpers ported from convex/attachments_helpers.ts) - src/server/pb/storage-quota.ts: STORAGE_LIMIT_GB enforcement using PB totals from attachments + serviceRecordAttachments; StorageQuotaError (DomainError, 413) - src/server/domain/attachments.ts: createAttachment (multipart upload into the `storageFile` + `originalFile` PB file fields), listAttachments with PB file URLs, deleteAttachment, getAttachmentUrl, retry, internal markAttachmentProcessing/Ready/Failed state transitions (clears originalFile when optimization succeeds) - src/server/domain/attachmentsProcessing.ts: Jimp + pdf-lib pipeline downloading the source via pb.files.getURL with a token, writing back the optimized bytes via FormData. Non-retryable errors are sticky - src/server/domain/serviceRecordAttachments.ts: mirror domain with owner-or-admin mutation checks - API routes: POST/GET /api/attachments (multipart upload, optimization fire-and-forget) GET/DELETE /api/attachments/[id], POST /api/attachments/[id]/optimize GET /api/attachments/queue-status POST/GET /api/service-record-attachments, DELETE /[id] GET /api/storage-usage +18 tests across 3 files; PB suite now 204 / 21. --- .../api/attachments/[id]/optimize/route.ts | 15 + src/app/api/attachments/[id]/route.ts | 23 ++ src/app/api/attachments/queue-status/route.ts | 13 + src/app/api/attachments/route.ts | 54 +++ .../service-record-attachments/[id]/route.ts | 14 + .../api/service-record-attachments/route.ts | 50 +++ src/app/api/storage-usage/route.ts | 6 + .../domain/__tests__/attachments.test.ts | 303 ++++++++++++++ .../__tests__/attachmentsProcessing.test.ts | 123 ++++++ .../serviceRecordAttachments.test.ts | 146 +++++++ src/server/domain/attachments.ts | 378 ++++++++++++++++++ src/server/domain/attachmentsProcessing.ts | 253 ++++++++++++ src/server/domain/serviceRecordAttachments.ts | 196 +++++++++ src/server/pb/attachments.ts | 163 ++++++++ src/server/pb/storage-quota.ts | 78 ++++ 15 files changed, 1815 insertions(+) create mode 100644 src/app/api/attachments/[id]/optimize/route.ts create mode 100644 src/app/api/attachments/[id]/route.ts create mode 100644 src/app/api/attachments/queue-status/route.ts create mode 100644 src/app/api/attachments/route.ts create mode 100644 src/app/api/service-record-attachments/[id]/route.ts create mode 100644 src/app/api/service-record-attachments/route.ts create mode 100644 src/app/api/storage-usage/route.ts create mode 100644 src/server/domain/__tests__/attachments.test.ts create mode 100644 src/server/domain/__tests__/attachmentsProcessing.test.ts create mode 100644 src/server/domain/__tests__/serviceRecordAttachments.test.ts create mode 100644 src/server/domain/attachments.ts create mode 100644 src/server/domain/attachmentsProcessing.ts create mode 100644 src/server/domain/serviceRecordAttachments.ts create mode 100644 src/server/pb/attachments.ts create mode 100644 src/server/pb/storage-quota.ts diff --git a/src/app/api/attachments/[id]/optimize/route.ts b/src/app/api/attachments/[id]/optimize/route.ts new file mode 100644 index 0000000..ec70cac --- /dev/null +++ b/src/app/api/attachments/[id]/optimize/route.ts @@ -0,0 +1,15 @@ +import { withUser } from "@/server/auth/route"; +import { retryAttachmentOptimization } from "@/server/domain/attachments"; +import { processAttachmentOptimization } from "@/server/domain/attachmentsProcessing"; + +export const POST = withUser( + "api/attachments/[id]/optimize", + async (_req, session, _user, { params }) => { + const { id } = await params; + // Flip status back to pending; this throws if the record is already ready + // or in-flight, matching the Convex retry semantics. + await retryAttachmentOptimization(session.ctx, id); + await processAttachmentOptimization(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/attachments/[id]/route.ts b/src/app/api/attachments/[id]/route.ts new file mode 100644 index 0000000..d216349 --- /dev/null +++ b/src/app/api/attachments/[id]/route.ts @@ -0,0 +1,23 @@ +import { withUser } from "@/server/auth/route"; +import { + deleteAttachment, + getAttachmentUrl, +} from "@/server/domain/attachments"; + +export const GET = withUser( + "api/attachments/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + const url = await getAttachmentUrl(session.ctx, id); + return { url }; + }, +); + +export const DELETE = withUser( + "api/attachments/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteAttachment(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/app/api/attachments/queue-status/route.ts b/src/app/api/attachments/queue-status/route.ts new file mode 100644 index 0000000..dadb47d --- /dev/null +++ b/src/app/api/attachments/queue-status/route.ts @@ -0,0 +1,13 @@ +import { withUser } from "@/server/auth/route"; +import { listAttachmentQueueStatuses } from "@/server/domain/attachments"; +import { ValidationError } from "@/server/pb/errors"; + +export const GET = withUser("api/attachments/queue-status", async (req, session) => { + const url = new URL(req.url); + const assetId = url.searchParams.get("assetId"); + if (!assetId) { + throw new ValidationError("assetId is required"); + } + const statuses = await listAttachmentQueueStatuses(session.ctx, assetId); + return { statuses }; +}); diff --git a/src/app/api/attachments/route.ts b/src/app/api/attachments/route.ts new file mode 100644 index 0000000..0f35c78 --- /dev/null +++ b/src/app/api/attachments/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; + +import { withUser } from "@/server/auth/route"; +import { + createAttachment, + listAttachments, +} from "@/server/domain/attachments"; +import { processAttachmentOptimization } from "@/server/domain/attachmentsProcessing"; +import { ValidationError } from "@/server/pb/errors"; + +export const GET = withUser("api/attachments", async (req, session) => { + const url = new URL(req.url); + const assetId = url.searchParams.get("assetId"); + if (!assetId) { + throw new ValidationError("assetId is required"); + } + const attachments = await listAttachments(session.ctx, assetId); + return { attachments }; +}); + +export const POST = withUser("api/attachments", async (req, session, user) => { + const contentType = req.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("multipart/form-data")) { + throw new ValidationError("Expected multipart/form-data"); + } + + const form = await req.formData(); + const assetId = form.get("assetId"); + const file = form.get("file"); + if (typeof assetId !== "string" || !assetId) { + throw new ValidationError("assetId is required"); + } + if (!(file instanceof File)) { + throw new ValidationError("file is required"); + } + + const buffer = new Uint8Array(await file.arrayBuffer()); + const { attachmentId } = await createAttachment(session.ctx, { + assetId, + fileName: file.name, + fileType: file.type || null, + fileBuffer: buffer, + actorId: user.id, + }); + + // Fire-and-forget optimization. Failures persist to the record for UI display. + void processAttachmentOptimization(session.ctx, attachmentId).catch( + (error) => { + console.error("[api/attachments] optimization error", error); + }, + ); + + return NextResponse.json({ attachmentId }, { status: 201 }); +}); diff --git a/src/app/api/service-record-attachments/[id]/route.ts b/src/app/api/service-record-attachments/[id]/route.ts new file mode 100644 index 0000000..3b50a9b --- /dev/null +++ b/src/app/api/service-record-attachments/[id]/route.ts @@ -0,0 +1,14 @@ +import { withUser } from "@/server/auth/route"; +import { deleteServiceRecordAttachment } from "@/server/domain/serviceRecordAttachments"; + +export const DELETE = withUser( + "api/service-record-attachments/[id]", + async (_req, session, user, { params }) => { + const { id } = await params; + await deleteServiceRecordAttachment(session.ctx, id, { + id: user.id, + role: user.role, + }); + return { ok: true }; + }, +); diff --git a/src/app/api/service-record-attachments/route.ts b/src/app/api/service-record-attachments/route.ts new file mode 100644 index 0000000..fba6a94 --- /dev/null +++ b/src/app/api/service-record-attachments/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; + +import { withUser } from "@/server/auth/route"; +import { + createServiceRecordAttachment, + listServiceRecordAttachments, +} from "@/server/domain/serviceRecordAttachments"; +import { ValidationError } from "@/server/pb/errors"; + +export const GET = withUser("api/service-record-attachments", async (req, session) => { + const url = new URL(req.url); + const serviceRecordId = url.searchParams.get("serviceRecordId"); + if (!serviceRecordId) { + throw new ValidationError("serviceRecordId is required"); + } + const attachments = await listServiceRecordAttachments( + session.ctx, + serviceRecordId, + ); + return { attachments }; +}); + +export const POST = withUser( + "api/service-record-attachments", + async (req, session, user) => { + const contentType = req.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("multipart/form-data")) { + throw new ValidationError("Expected multipart/form-data"); + } + const form = await req.formData(); + const serviceRecordId = form.get("serviceRecordId"); + const file = form.get("file"); + if (typeof serviceRecordId !== "string" || !serviceRecordId) { + throw new ValidationError("serviceRecordId is required"); + } + if (!(file instanceof File)) { + throw new ValidationError("file is required"); + } + const buffer = new Uint8Array(await file.arrayBuffer()); + const { attachmentId } = await createServiceRecordAttachment(session.ctx, { + serviceRecordId, + fileName: file.name, + fileType: file.type || null, + fileBuffer: buffer, + actorId: user.id, + actorRole: user.role, + }); + return NextResponse.json({ attachmentId }, { status: 201 }); + }, +); diff --git a/src/app/api/storage-usage/route.ts b/src/app/api/storage-usage/route.ts new file mode 100644 index 0000000..64bf504 --- /dev/null +++ b/src/app/api/storage-usage/route.ts @@ -0,0 +1,6 @@ +import { withUser } from "@/server/auth/route"; +import { getStorageUsage } from "@/server/pb/storage-quota"; + +export const GET = withUser("api/storage-usage", async (_req, session) => ({ + usage: await getStorageUsage(session.ctx), +})); diff --git a/src/server/domain/__tests__/attachments.test.ts b/src/server/domain/__tests__/attachments.test.ts new file mode 100644 index 0000000..c52bc89 --- /dev/null +++ b/src/server/domain/__tests__/attachments.test.ts @@ -0,0 +1,303 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createAsset } from "@/server/domain/assets"; +import { + createAttachment, + deleteAttachment, + getAttachmentUrl, + listAttachmentQueueStatuses, + listAttachments, + markAttachmentFailed, + markAttachmentProcessing, + markAttachmentReady, + retryAttachmentOptimization, +} from "@/server/domain/attachments"; +import type { Ctx } from "@/server/pb/context"; +import { NotFoundError, ValidationError } from "@/server/pb/errors"; +import { StorageQuotaError } from "@/server/pb/storage-quota"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +// 1x1 transparent PNG +const TINY_PNG = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]); + +describe("attachments domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("creates an attachment in pending state with a resolvable file URL", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }); + + const list = await listAttachments(ctx(), assetId); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ + id: attachmentId, + assetId, + fileName: "badge.png", + fileType: "image/png", + fileExtension: "png", + fileKind: "image", + status: "pending", + optimizationAttempts: 0, + optimizationError: null, + }); + expect(list[0].url).toMatch(/\/api\/files\/attachments\/.+/); + expect(list[0].fileSizeOriginal).toBe(TINY_PNG.byteLength); + }); + + it("sanitizes the file name and classifies the kind", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + + await createAttachment(ctx(), { + assetId, + fileName: " C:\\tmp\\Report Draft.PDF ", + fileType: "application/pdf", + fileBuffer: new Uint8Array([37, 80, 68, 70, 45]), + actorId: admin.id, + }); + + const list = await listAttachments(ctx(), assetId); + expect(list[0]).toMatchObject({ + fileName: "Report Draft.PDF", + fileType: "application/pdf", + fileKind: "pdf", + }); + }); + + it("rejects unsupported file types with ValidationError", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await expect( + createAttachment(ctx(), { + assetId, + fileName: "archive.zip", + fileType: "application/zip", + fileBuffer: new Uint8Array([1, 2, 3]), + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("marks oversized uploads as failed and records a helpful error", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const big = new Uint8Array(26 * 1024 * 1024); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "huge.pdf", + fileType: "application/pdf", + fileBuffer: big, + actorId: admin.id, + }); + const statuses = await listAttachmentQueueStatuses(ctx(), assetId); + const entry = statuses.find((s) => s.id === attachmentId); + expect(entry?.status).toBe("failed"); + expect(entry?.optimizationError).toMatch(/too large/i); + }); + + it("enforces storage quota when STORAGE_LIMIT_GB is set", async () => { + vi.stubEnv("STORAGE_LIMIT_GB", "0.00000001"); // ~10.7 bytes + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + await expect( + createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(StorageQuotaError); + }); + + it("deleteAttachment removes the record and NotFoundErrors on missing", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }); + await deleteAttachment(ctx(), attachmentId); + await expect(listAttachments(ctx(), assetId)).resolves.toEqual([]); + await expect(deleteAttachment(ctx(), attachmentId)).rejects.toBeInstanceOf( + NotFoundError, + ); + }); + + it("getAttachmentUrl returns the file URL and null for orphan records", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }); + await expect(getAttachmentUrl(ctx(), attachmentId)).resolves.toMatch( + /\/api\/files\/attachments\//, + ); + }); + + it("retryAttachmentOptimization rejects a ready attachment", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }); + + await markAttachmentProcessing(ctx(), attachmentId); + await markAttachmentReady(ctx(), { + attachmentId, + optimized: null, + fileSizeOptimized: TINY_PNG.byteLength, + }); + await expect( + retryAttachmentOptimization(ctx(), attachmentId), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("state machine: processing → failed with retry budget", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }); + + const start = await markAttachmentProcessing(ctx(), attachmentId); + expect(start.state).toBe("started"); + if (start.state !== "started") throw new Error("unreachable"); + expect(start.attempt).toBe(1); + + const fail1 = await markAttachmentFailed(ctx(), { + attachmentId, + errorMessage: "temporary", + }); + expect(fail1).toEqual({ attempt: 1, shouldRetry: true }); + + await markAttachmentProcessing(ctx(), attachmentId); // attempt 2 + const fail2 = await markAttachmentFailed(ctx(), { + attachmentId, + errorMessage: "still temporary", + }); + expect(fail2).toEqual({ attempt: 2, shouldRetry: true }); + + await markAttachmentProcessing(ctx(), attachmentId); // attempt 3 + const fail3 = await markAttachmentFailed(ctx(), { + attachmentId, + errorMessage: "still failing", + }); + expect(fail3).toEqual({ attempt: 3, shouldRetry: false }); + }); + + it("markAttachmentProcessing returns skip when already ready or processing", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }); + await markAttachmentProcessing(ctx(), attachmentId); + const second = await markAttachmentProcessing(ctx(), attachmentId); + // After first call, status = processing; second call should skip. + expect(second.state).toBe("skip"); + }); + + it("createAttachment NotFoundErrors on an unknown asset", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createAttachment(ctx(), { + assetId: "nonexistent0000", + fileName: "badge.png", + fileType: "image/png", + fileBuffer: TINY_PNG, + actorId: admin.id, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/src/server/domain/__tests__/attachmentsProcessing.test.ts b/src/server/domain/__tests__/attachmentsProcessing.test.ts new file mode 100644 index 0000000..ee3ed73 --- /dev/null +++ b/src/server/domain/__tests__/attachmentsProcessing.test.ts @@ -0,0 +1,123 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import { createAsset } from "@/server/domain/assets"; +import { + createAttachment, + listAttachments, +} from "@/server/domain/attachments"; +import { processAttachmentOptimization } from "@/server/domain/attachmentsProcessing"; +import type { Ctx } from "@/server/pb/context"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +// Generated in beforeAll using Jimp so the bytes round-trip cleanly. +let TINY_RED_PNG: Uint8Array; + +// Minimal valid PDF that pdf-lib can parse. +const MINIMAL_PDF_SOURCE = `%PDF-1.4 +1 0 obj <<>> endobj +2 0 obj << /Type /Catalog /Pages 3 0 R >> endobj +3 0 obj << /Type /Pages /Count 1 /Kids [4 0 R] >> endobj +4 0 obj << /Type /Page /Parent 3 0 R /MediaBox [0 0 612 792] >> endobj +xref +0 5 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000073 00000 n +0000000117 00000 n +trailer << /Size 5 /Root 2 0 R >> +startxref +173 +%%EOF`; + +const MINIMAL_PDF = new TextEncoder().encode(MINIMAL_PDF_SOURCE); + +describe("attachmentsProcessing", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + beforeAll(async () => { + const { Jimp } = await import("jimp"); + const image = new Jimp({ width: 4, height: 4, color: 0xff0000ff }); + const buf = await image.getBuffer("image/png"); + TINY_RED_PNG = Uint8Array.from(buf); + }); + + it("marks a PNG ready and records an optimized size", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "tiny.png", + fileType: "image/png", + fileBuffer: TINY_RED_PNG, + actorId: admin.id, + }); + + await processAttachmentOptimization(ctx(), attachmentId); + + const [entry] = await listAttachments(ctx(), assetId); + expect(entry.status).toBe("ready"); + expect(entry.optimizationError).toBeNull(); + expect(entry.fileSizeOptimized).toBeGreaterThan(0); + // Solid-color 4x4 JPEG has constant header overhead; we only assert + // that optimization ran, not that it shrunk the file for a trivial image. + }); + + it("marks a PDF ready (round-trip through pdf-lib)", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "page.pdf", + fileType: "application/pdf", + fileBuffer: MINIMAL_PDF, + actorId: admin.id, + }); + + await processAttachmentOptimization(ctx(), attachmentId); + + const [entry] = await listAttachments(ctx(), assetId); + expect(entry.status).toBe("ready"); + expect(entry.optimizationError).toBeNull(); + }); + + it("does nothing for an attachment already ready", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: admin.id, + }); + const { attachmentId } = await createAttachment(ctx(), { + assetId, + fileName: "tiny.png", + fileType: "image/png", + fileBuffer: TINY_RED_PNG, + actorId: admin.id, + }); + + await processAttachmentOptimization(ctx(), attachmentId); + await processAttachmentOptimization(ctx(), attachmentId); // should be a no-op + const [entry] = await listAttachments(ctx(), assetId); + expect(entry.status).toBe("ready"); + }); +}); diff --git a/src/server/domain/__tests__/serviceRecordAttachments.test.ts b/src/server/domain/__tests__/serviceRecordAttachments.test.ts new file mode 100644 index 0000000..505cce0 --- /dev/null +++ b/src/server/domain/__tests__/serviceRecordAttachments.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { createAsset } from "@/server/domain/assets"; +import { createRecord } from "@/server/domain/serviceRecords"; +import { + createServiceRecordAttachment, + deleteServiceRecordAttachment, + listServiceRecordAttachments, +} from "@/server/domain/serviceRecordAttachments"; +import type { Ctx } from "@/server/pb/context"; +import { + NotFoundError, + ValidationError, +} from "@/server/pb/errors"; +import { usePbHarness } from "@/test/pb-harness"; + +async function seedAdmin(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `admin-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "admin", + createdAt: Date.now(), + }); +} + +async function seedUser(pb: Ctx["pb"]) { + return pb.collection("users").create({ + email: `user-${Math.random().toString(36).slice(2)}@stowage.local`, + password: "password123", + passwordConfirm: "password123", + role: "user", + createdAt: Date.now(), + }); +} + +function yesterday() { + const d = new Date(Date.now() - 24 * 60 * 60 * 1000); + const y = String(d.getUTCFullYear()).padStart(4, "0"); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${dd}`; +} + +const TINY_PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); + +describe("serviceRecordAttachments domain", () => { + const getHarness = usePbHarness(); + const ctx = (): Ctx => ({ pb: getHarness().admin }); + + async function seedRecord(actor: { id: string }) { + const { assetId } = await createAsset(ctx(), { + name: "Laptop", + actorId: actor.id, + }); + const { recordId } = await createRecord(ctx(), { + assetId, + serviceDate: yesterday(), + description: "Performed", + actorId: actor.id, + }); + return { assetId, recordId }; + } + + it("creates, lists, and deletes a service record attachment", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { recordId } = await seedRecord(admin); + + const { attachmentId } = await createServiceRecordAttachment(ctx(), { + serviceRecordId: recordId, + fileName: "invoice.pdf", + fileType: "application/pdf", + fileBuffer: TINY_PDF, + actorId: admin.id, + actorRole: "admin", + }); + const list = await listServiceRecordAttachments(ctx(), recordId); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ + id: attachmentId, + serviceRecordId: recordId, + fileName: "invoice.pdf", + fileKind: "pdf", + }); + expect(list[0].url).toMatch(/\/api\/files\/serviceRecordAttachments\//); + + await deleteServiceRecordAttachment(ctx(), attachmentId, { + id: admin.id, + role: "admin", + }); + await expect( + listServiceRecordAttachments(ctx(), recordId), + ).resolves.toEqual([]); + }); + + it("blocks non-admin users from editing another user's record attachments", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const other = await seedUser(pb); + const { recordId } = await seedRecord(admin); + + await expect( + createServiceRecordAttachment(ctx(), { + serviceRecordId: recordId, + fileName: "invoice.pdf", + fileType: "application/pdf", + fileBuffer: TINY_PDF, + actorId: other.id, + actorRole: "user", + }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("NotFoundError when the service record does not exist", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + await expect( + createServiceRecordAttachment(ctx(), { + serviceRecordId: "nonexistent0000", + fileName: "x.pdf", + fileType: "application/pdf", + fileBuffer: TINY_PDF, + actorId: admin.id, + actorRole: "admin", + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("rejects oversized files with a ValidationError", async () => { + const pb = getHarness().admin; + const admin = await seedAdmin(pb); + const { recordId } = await seedRecord(admin); + const big = new Uint8Array(26 * 1024 * 1024); + await expect( + createServiceRecordAttachment(ctx(), { + serviceRecordId: recordId, + fileName: "big.pdf", + fileType: "application/pdf", + fileBuffer: big, + actorId: admin.id, + actorRole: "admin", + }), + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/src/server/domain/attachments.ts b/src/server/domain/attachments.ts new file mode 100644 index 0000000..095cf6f --- /dev/null +++ b/src/server/domain/attachments.ts @@ -0,0 +1,378 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { getPbUrl } from "@/server/pb/client"; +import { + ATTACHMENT_KINDS, + ATTACHMENT_STATUSES, + MAX_ATTACHMENT_RETRY_ATTEMPTS, + MAX_ATTACHMENT_UPLOAD_BYTES, + classifyAttachment, + sanitizeAttachmentFileName, + type AttachmentKind, + type AttachmentStatus, +} from "@/server/pb/attachments"; +import { NotFoundError, ValidationError } from "@/server/pb/errors"; +import { enforceStorageQuota } from "@/server/pb/storage-quota"; + +type AttachmentRecord = { + id: string; + assetId: string; + storageFile: string | ""; + originalFile: string | ""; + fileName: string; + fileType: string; + fileExtension: string; + fileKind: AttachmentKind; + fileSizeOriginal: number; + fileSizeOptimized: number | null; + status: AttachmentStatus; + optimizationAttempts: number; + optimizationError: string | ""; + uploadedBy: string; + uploadedAt: number; + updatedAt: number; +}; + +export type AttachmentView = { + id: string; + assetId: string; + fileName: string; + fileType: string; + fileExtension: string; + fileKind: AttachmentKind; + fileSizeOriginal: number; + fileSizeOptimized: number | null; + status: AttachmentStatus; + optimizationAttempts: number; + optimizationError: string | null; + uploadedBy: string; + uploadedAt: number; + updatedAt: number; + url: string | null; +}; + +export const CreateAttachmentInput = z.object({ + assetId: z.string(), + fileName: z.string(), + fileType: z.string().nullish(), + fileBuffer: z.instanceof(Uint8Array), + actorId: z.string(), +}); + +export type CreateAttachmentInput = z.infer; + +function attachmentFileUrl(record: AttachmentRecord): string | null { + if (!record.storageFile) return null; + return `${getPbUrl()}/api/files/attachments/${record.id}/${encodeURIComponent( + record.storageFile, + )}`; +} + +function toView(record: AttachmentRecord): AttachmentView { + return { + id: record.id, + assetId: record.assetId, + fileName: record.fileName, + fileType: record.fileType, + fileExtension: record.fileExtension, + fileKind: record.fileKind, + fileSizeOriginal: record.fileSizeOriginal, + fileSizeOptimized: record.fileSizeOptimized ?? null, + status: record.status, + optimizationAttempts: record.optimizationAttempts ?? 0, + optimizationError: record.optimizationError || null, + uploadedBy: record.uploadedBy, + uploadedAt: record.uploadedAt, + updatedAt: record.updatedAt, + url: attachmentFileUrl(record), + }; +} + +async function requireAttachment( + ctx: Ctx, + attachmentId: string, +): Promise { + try { + return await ctx.pb + .collection("attachments") + .getOne(attachmentId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Attachment not found"); + } + throw error; + } +} + +async function requireAssetExists(ctx: Ctx, assetId: string): Promise { + try { + await ctx.pb.collection("assets").getOne(assetId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Asset not found"); + } + throw error; + } +} + +export async function createAttachment( + ctx: Ctx, + input: CreateAttachmentInput, +): Promise<{ attachmentId: string }> { + const parsed = CreateAttachmentInput.parse(input); + await requireAssetExists(ctx, parsed.assetId); + + const fileName = sanitizeAttachmentFileName(parsed.fileName); + const fileType = parsed.fileType || "application/octet-stream"; + const classification = classifyAttachment(fileName, fileType); + const fileSize = parsed.fileBuffer.byteLength; + const now = Date.now(); + + await enforceStorageQuota(ctx, fileSize); + + if (fileSize > MAX_ATTACHMENT_UPLOAD_BYTES) { + const form = new FormData(); + form.append("assetId", parsed.assetId); + form.append("fileName", fileName); + form.append("fileType", classification.mimeType); + form.append("fileExtension", classification.extension); + form.append("fileKind", classification.kind); + form.append("fileSizeOriginal", String(fileSize)); + form.append("status", "failed"); + form.append("optimizationAttempts", "0"); + form.append( + "optimizationError", + "This file is too large. Upload files up to 25 MB.", + ); + form.append("uploadedBy", parsed.actorId); + form.append("uploadedAt", String(now)); + form.append("updatedAt", String(now)); + const record = await ctx.pb + .collection("attachments") + .create(form); + return { attachmentId: record.id }; + } + + const blob = new Blob([new Uint8Array(parsed.fileBuffer)], { + type: classification.mimeType, + }); + + const form = new FormData(); + form.append("assetId", parsed.assetId); + form.append("storageFile", blob, fileName); + form.append("originalFile", blob, fileName); + form.append("fileName", fileName); + form.append("fileType", classification.mimeType); + form.append("fileExtension", classification.extension); + form.append("fileKind", classification.kind); + form.append("fileSizeOriginal", String(fileSize)); + form.append("status", "pending"); + form.append("optimizationAttempts", "0"); + form.append("uploadedBy", parsed.actorId); + form.append("uploadedAt", String(now)); + form.append("updatedAt", String(now)); + + const record = await ctx.pb + .collection("attachments") + .create(form); + + return { attachmentId: record.id }; +} + +export async function listAttachments( + ctx: Ctx, + assetId: string, +): Promise { + await requireAssetExists(ctx, assetId); + const records = await ctx.pb + .collection("attachments") + .getFullList({ + filter: `assetId = "${assetId}"`, + sort: "-uploadedAt", + }); + return records.map(toView); +} + +export type AttachmentQueueStatus = { + id: string; + status: AttachmentStatus; + optimizationError: string | null; +}; + +export async function listAttachmentQueueStatuses( + ctx: Ctx, + assetId: string, +): Promise { + await requireAssetExists(ctx, assetId); + const records = await ctx.pb + .collection("attachments") + .getFullList({ + filter: `assetId = "${assetId}"`, + sort: "-uploadedAt", + }); + return records.map((row) => ({ + id: row.id, + status: row.status, + optimizationError: row.optimizationError || null, + })); +} + +export async function getAttachmentUrl( + ctx: Ctx, + attachmentId: string, +): Promise { + const record = await requireAttachment(ctx, attachmentId); + return attachmentFileUrl(record); +} + +export async function deleteAttachment( + ctx: Ctx, + attachmentId: string, +): Promise { + const attachment = await requireAttachment(ctx, attachmentId); + await ctx.pb.collection("attachments").delete(attachment.id); +} + +export async function retryAttachmentOptimization( + ctx: Ctx, + attachmentId: string, +): Promise { + const attachment = await requireAttachment(ctx, attachmentId); + if (attachment.status !== "failed" && attachment.status !== "pending") { + throw new ValidationError( + "This attachment is not in a retryable state.", + ); + } + const updated = await ctx.pb + .collection("attachments") + .update(attachment.id, { + status: "pending", + optimizationError: "", + updatedAt: Date.now(), + }); + return toView(updated); +} + +// State transitions used by the optimization pipeline. + +export type ProcessingStartResult = + | { state: "started"; attempt: number; record: AttachmentRecord } + | { state: "missing" } + | { state: "skip"; attempt: number }; + +export async function markAttachmentProcessing( + ctx: Ctx, + attachmentId: string, +): Promise { + let attachment: AttachmentRecord; + try { + attachment = await ctx.pb + .collection("attachments") + .getOne(attachmentId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return { state: "missing" }; + } + throw error; + } + + if (attachment.status === "ready" || attachment.status === "processing") { + return { state: "skip", attempt: attachment.optimizationAttempts ?? 0 }; + } + + const nextAttempt = (attachment.optimizationAttempts ?? 0) + 1; + const updated = await ctx.pb + .collection("attachments") + .update(attachment.id, { + status: "processing", + optimizationAttempts: nextAttempt, + optimizationError: "", + updatedAt: Date.now(), + }); + + return { state: "started", attempt: nextAttempt, record: updated }; +} + +export async function markAttachmentReady( + ctx: Ctx, + { + attachmentId, + optimized, + fileSizeOptimized, + }: { + attachmentId: string; + optimized: { + fileName: string; + fileType: string; + fileExtension: string; + bytes: Uint8Array; + } | null; + fileSizeOptimized: number; + }, +): Promise { + const now = Date.now(); + + if (optimized) { + const blob = new Blob([new Uint8Array(optimized.bytes)], { + type: optimized.fileType, + }); + const form = new FormData(); + form.append("storageFile", blob, optimized.fileName); + // Clear the original upload to reclaim space now that optimization is done. + form.append("originalFile-", ""); + form.append("fileName", optimized.fileName); + form.append("fileType", optimized.fileType); + form.append("fileExtension", optimized.fileExtension); + form.append("fileSizeOptimized", String(fileSizeOptimized)); + form.append("status", "ready"); + form.append("optimizationError", ""); + form.append("updatedAt", String(now)); + await ctx.pb.collection("attachments").update(attachmentId, form); + return; + } + + await ctx.pb.collection("attachments").update(attachmentId, { + fileSizeOptimized, + status: "ready", + optimizationError: "", + "originalFile-": "", + updatedAt: now, + }); +} + +export async function markAttachmentFailed( + ctx: Ctx, + { + attachmentId, + errorMessage, + retryable = true, + }: { attachmentId: string; errorMessage: string; retryable?: boolean }, +): Promise<{ attempt: number; shouldRetry: boolean }> { + let attachment: AttachmentRecord; + try { + attachment = await ctx.pb + .collection("attachments") + .getOne(attachmentId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + return { attempt: 0, shouldRetry: false }; + } + throw error; + } + + await ctx.pb.collection("attachments").update(attachment.id, { + status: "failed", + optimizationError: errorMessage, + updatedAt: Date.now(), + }); + + const attempt = attachment.optimizationAttempts ?? 0; + return { + attempt, + shouldRetry: retryable && attempt < MAX_ATTACHMENT_RETRY_ATTEMPTS, + }; +} + +export { ATTACHMENT_KINDS, ATTACHMENT_STATUSES }; diff --git a/src/server/domain/attachmentsProcessing.ts b/src/server/domain/attachmentsProcessing.ts new file mode 100644 index 0000000..137aa8e --- /dev/null +++ b/src/server/domain/attachmentsProcessing.ts @@ -0,0 +1,253 @@ +import { ClientResponseError } from "pocketbase"; + +import type { Ctx } from "@/server/pb/context"; +import { + IMAGE_MAX_LONG_EDGE, + IMAGE_TARGET_BYTES, + MAX_ATTACHMENT_UPLOAD_BYTES, + normalizeOptimizationErrorMessage, + replaceAttachmentExtension, + type AttachmentKind, +} from "@/server/pb/attachments"; + +import { + markAttachmentFailed, + markAttachmentProcessing, + markAttachmentReady, +} from "./attachments"; + +const JPEG_QUALITY_STEPS = [84, 76, 68, 60, 52, 44]; +const PNG_COMPRESSION_STEPS = [9, 8, 7, 6]; + +type JimpImage = { + bitmap: { + width: number; + height: number; + data: Buffer | Uint8Array; + }; + resize: (options: { w: number; h: number }) => JimpImage; + getBuffer: ( + mime: "image/jpeg" | "image/png", + options?: { quality?: number; compressionLevel?: number }, + ) => Promise; +}; + +type JimpFactory = { read: (data: Buffer | Uint8Array) => Promise }; + +let jimpPromise: Promise | null = null; + +async function loadJimp() { + if (!jimpPromise) { + jimpPromise = import("jimp").then( + (module) => ({ read: module.Jimp.read.bind(module.Jimp) }) as JimpFactory, + ); + } + return jimpPromise; +} + +type OptimizedBinary = { + bytes: Uint8Array; + fileName: string; + fileType: string; + fileExtension: string; +}; + +class NonRetryableError extends Error { + readonly retryable = false as const; +} + +function hasAlphaChannel(image: JimpImage) { + const data = image.bitmap.data; + for (let index = 3; index < data.length; index += 4) { + if (data[index] !== 255) return true; + } + return false; +} + +async function optimizeImage( + bytes: Uint8Array, + fileName: string, +): Promise { + const Jimp = await loadJimp(); + const image = await Jimp.read(Buffer.from(bytes)); + const longEdge = Math.max(image.bitmap.width, image.bitmap.height); + if (longEdge > IMAGE_MAX_LONG_EDGE) { + const scale = IMAGE_MAX_LONG_EDGE / longEdge; + image.resize({ + w: Math.max(1, Math.round(image.bitmap.width * scale)), + h: Math.max(1, Math.round(image.bitmap.height * scale)), + }); + } + + if (hasAlphaChannel(image)) { + let best: Uint8Array | null = null; + for (const compressionLevel of PNG_COMPRESSION_STEPS) { + const output = await image.getBuffer("image/png", { compressionLevel }); + if (!best || output.byteLength < best.byteLength) { + best = new Uint8Array(output); + } + if (output.byteLength <= IMAGE_TARGET_BYTES) { + return { + bytes: new Uint8Array(output), + fileName: replaceAttachmentExtension(fileName, "png"), + fileType: "image/png", + fileExtension: "png", + }; + } + } + if (!best) throw new Error("Image optimization produced no output"); + return { + bytes: best, + fileName: replaceAttachmentExtension(fileName, "png"), + fileType: "image/png", + fileExtension: "png", + }; + } + + let best: Uint8Array | null = null; + for (const quality of JPEG_QUALITY_STEPS) { + const output = await image.getBuffer("image/jpeg", { quality }); + if (!best || output.byteLength < best.byteLength) { + best = new Uint8Array(output); + } + if (output.byteLength <= IMAGE_TARGET_BYTES) { + return { + bytes: new Uint8Array(output), + fileName: replaceAttachmentExtension(fileName, "jpg"), + fileType: "image/jpeg", + fileExtension: "jpg", + }; + } + } + if (!best) throw new Error("Image optimization produced no output"); + return { + bytes: best, + fileName: replaceAttachmentExtension(fileName, "jpg"), + fileType: "image/jpeg", + fileExtension: "jpg", + }; +} + +async function optimizePdf( + bytes: Uint8Array, + fileName: string, +): Promise { + const { PDFDocument } = await import("pdf-lib"); + const document = await PDFDocument.load(bytes, { + updateMetadata: false, + ignoreEncryption: true, + }); + const optimized = await document.save({ + useObjectStreams: true, + updateFieldAppearances: false, + objectsPerTick: 50, + }); + if (optimized.byteLength < bytes.byteLength) { + return { + bytes: new Uint8Array(optimized), + fileName, + fileType: "application/pdf", + fileExtension: "pdf", + }; + } + return { + bytes, + fileName, + fileType: "application/pdf", + fileExtension: "pdf", + }; +} + +async function downloadAttachmentFile( + ctx: Ctx, + attachmentId: string, + storageFile: string, +): Promise { + const token = await ctx.pb.files.getToken(); + const url = ctx.pb.files.getURL( + { id: attachmentId, collectionId: "attachments" } as unknown as { + id: string; + collectionId: string; + }, + storageFile, + { token }, + ); + const res = await fetch(url); + if (!res.ok) { + throw new NonRetryableError( + `Failed to download attachment file (${res.status})`, + ); + } + return new Uint8Array(await res.arrayBuffer()); +} + +export async function processAttachmentOptimization( + ctx: Ctx, + attachmentId: string, +): Promise { + const start = await markAttachmentProcessing(ctx, attachmentId); + if (start.state !== "started") return; + + const { record } = start; + + try { + const source = record.originalFile || record.storageFile; + if (!source) { + throw new NonRetryableError("Uploaded file was not found"); + } + + if (record.fileSizeOriginal > MAX_ATTACHMENT_UPLOAD_BYTES) { + throw new NonRetryableError( + "This file is too large. Upload files up to 25 MB.", + ); + } + + const inputBytes = await downloadAttachmentFile(ctx, record.id, source); + + const originalBinary: OptimizedBinary = { + bytes: inputBytes, + fileName: record.fileName, + fileType: record.fileType, + fileExtension: record.fileExtension, + }; + + let optimized: OptimizedBinary = originalBinary; + const kind = record.fileKind as AttachmentKind; + if (kind === "image") { + optimized = await optimizeImage(inputBytes, record.fileName); + } else if (kind === "pdf") { + optimized = await optimizePdf(inputBytes, record.fileName); + } + + const shouldReplace = + optimized !== originalBinary && + (optimized.bytes.byteLength !== originalBinary.bytes.byteLength || + optimized.fileType !== originalBinary.fileType || + optimized.fileExtension !== originalBinary.fileExtension || + optimized.fileName !== originalBinary.fileName); + + await markAttachmentReady(ctx, { + attachmentId: record.id, + optimized: shouldReplace ? optimized : null, + fileSizeOptimized: shouldReplace + ? optimized.bytes.byteLength + : originalBinary.bytes.byteLength, + }); + } catch (error) { + const retryable = !(error instanceof NonRetryableError); + await markAttachmentFailed(ctx, { + attachmentId, + errorMessage: normalizeOptimizationErrorMessage(error), + retryable, + }); + // Retries are driven by a separate caller (e.g. the retry endpoint or a + // scheduled worker). We deliberately do not block this call on a retry. + if ( + !retryable && + error instanceof ClientResponseError && + error.status === 404 + ) { + return; + } + } +} diff --git a/src/server/domain/serviceRecordAttachments.ts b/src/server/domain/serviceRecordAttachments.ts new file mode 100644 index 0000000..dadb499 --- /dev/null +++ b/src/server/domain/serviceRecordAttachments.ts @@ -0,0 +1,196 @@ +import { ClientResponseError } from "pocketbase"; +import { z } from "zod"; + +import type { Ctx } from "@/server/pb/context"; +import { getPbUrl } from "@/server/pb/client"; +import { + MAX_ATTACHMENT_UPLOAD_BYTES, + classifyAttachment, + sanitizeAttachmentFileName, + type AttachmentKind, +} from "@/server/pb/attachments"; +import { NotFoundError, ValidationError } from "@/server/pb/errors"; +import { enforceStorageQuota } from "@/server/pb/storage-quota"; + +type ServiceRecordAttachmentRecord = { + id: string; + serviceRecordId: string; + storageFile: string | ""; + fileName: string; + fileType: string; + fileExtension: string; + fileKind: AttachmentKind; + fileSize: number; + uploadedBy: string; + uploadedAt: number; + updatedAt: number; +}; + +type ServiceRecordRow = { id: string; completedBy: string }; + +export type ServiceRecordAttachmentView = { + id: string; + serviceRecordId: string; + fileName: string; + fileType: string; + fileExtension: string; + fileKind: AttachmentKind; + fileSize: number; + uploadedBy: string; + uploadedAt: number; + updatedAt: number; + url: string | null; +}; + +export const CreateServiceRecordAttachmentInput = z.object({ + serviceRecordId: z.string(), + fileName: z.string(), + fileType: z.string().nullish(), + fileBuffer: z.instanceof(Uint8Array), + actorId: z.string(), + actorRole: z.enum(["admin", "user"]).default("user"), +}); + +export type CreateServiceRecordAttachmentInput = z.infer< + typeof CreateServiceRecordAttachmentInput +>; + +function fileUrl(record: ServiceRecordAttachmentRecord): string | null { + if (!record.storageFile) return null; + return `${getPbUrl()}/api/files/serviceRecordAttachments/${record.id}/${encodeURIComponent( + record.storageFile, + )}`; +} + +function toView( + record: ServiceRecordAttachmentRecord, +): ServiceRecordAttachmentView { + return { + id: record.id, + serviceRecordId: record.serviceRecordId, + fileName: record.fileName, + fileType: record.fileType, + fileExtension: record.fileExtension, + fileKind: record.fileKind, + fileSize: record.fileSize, + uploadedBy: record.uploadedBy, + uploadedAt: record.uploadedAt, + updatedAt: record.updatedAt, + url: fileUrl(record), + }; +} + +async function requireServiceRecord( + ctx: Ctx, + serviceRecordId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceRecords") + .getOne(serviceRecordId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service record not found"); + } + throw error; + } +} + +function ensureMutationAccess( + record: ServiceRecordRow, + actor: { id: string; role: "admin" | "user" }, +) { + if (actor.role === "admin") return; + if (record.completedBy !== actor.id) { + throw new ValidationError( + "You do not have access to modify this service record attachment", + ); + } +} + +async function requireAttachment( + ctx: Ctx, + attachmentId: string, +): Promise { + try { + return await ctx.pb + .collection("serviceRecordAttachments") + .getOne(attachmentId); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 404) { + throw new NotFoundError("Service record attachment not found"); + } + throw error; + } +} + +export async function createServiceRecordAttachment( + ctx: Ctx, + input: CreateServiceRecordAttachmentInput, +): Promise<{ attachmentId: string }> { + const parsed = CreateServiceRecordAttachmentInput.parse(input); + const record = await requireServiceRecord(ctx, parsed.serviceRecordId); + ensureMutationAccess(record, { + id: parsed.actorId, + role: parsed.actorRole, + }); + + const fileSize = parsed.fileBuffer.byteLength; + await enforceStorageQuota(ctx, fileSize); + + if (fileSize > MAX_ATTACHMENT_UPLOAD_BYTES) { + throw new ValidationError( + "This file is too large. Upload files up to 25 MB.", + ); + } + + const fileName = sanitizeAttachmentFileName(parsed.fileName); + const fileType = parsed.fileType || "application/octet-stream"; + const classification = classifyAttachment(fileName, fileType); + const now = Date.now(); + + const blob = new Blob([new Uint8Array(parsed.fileBuffer)], { + type: classification.mimeType, + }); + const form = new FormData(); + form.append("serviceRecordId", parsed.serviceRecordId); + form.append("storageFile", blob, fileName); + form.append("fileName", fileName); + form.append("fileType", classification.mimeType); + form.append("fileExtension", classification.extension); + form.append("fileKind", classification.kind); + form.append("fileSize", String(fileSize)); + form.append("uploadedBy", parsed.actorId); + form.append("uploadedAt", String(now)); + form.append("updatedAt", String(now)); + + const created = await ctx.pb + .collection("serviceRecordAttachments") + .create(form); + return { attachmentId: created.id }; +} + +export async function listServiceRecordAttachments( + ctx: Ctx, + serviceRecordId: string, +): Promise { + await requireServiceRecord(ctx, serviceRecordId); + const records = await ctx.pb + .collection("serviceRecordAttachments") + .getFullList({ + filter: `serviceRecordId = "${serviceRecordId}"`, + sort: "-uploadedAt", + }); + return records.map(toView); +} + +export async function deleteServiceRecordAttachment( + ctx: Ctx, + attachmentId: string, + actor: { id: string; role: "admin" | "user" }, +): Promise { + const attachment = await requireAttachment(ctx, attachmentId); + const record = await requireServiceRecord(ctx, attachment.serviceRecordId); + ensureMutationAccess(record, actor); + await ctx.pb.collection("serviceRecordAttachments").delete(attachment.id); +} diff --git a/src/server/pb/attachments.ts b/src/server/pb/attachments.ts new file mode 100644 index 0000000..cfe3f35 --- /dev/null +++ b/src/server/pb/attachments.ts @@ -0,0 +1,163 @@ +import { ValidationError } from "./errors"; + +export const ATTACHMENT_STATUSES = [ + "pending", + "processing", + "ready", + "failed", +] as const; +export type AttachmentStatus = (typeof ATTACHMENT_STATUSES)[number]; + +export const ATTACHMENT_KINDS = ["image", "pdf", "office"] as const; +export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number]; + +export const MAX_ATTACHMENT_UPLOAD_BYTES = 25 * 1024 * 1024; +export const MAX_ATTACHMENT_RETRY_ATTEMPTS = 3; +export const IMAGE_MAX_LONG_EDGE = 1600; +export const IMAGE_TARGET_BYTES = 150 * 1024; + +const IMAGE_EXTENSIONS = new Set([ + "jpg", + "jpeg", + "png", + "webp", + "gif", + "bmp", + "tif", + "tiff", + "heic", + "heif", + "avif", +]); + +const PDF_EXTENSIONS = new Set(["pdf"]); + +const OFFICE_EXTENSIONS = new Set([ + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", +]); + +const PDF_MIME_TYPES = new Set(["application/pdf"]); + +const OFFICE_MIME_TYPES = new Set([ + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", +]); + +export function sanitizeAttachmentFileName(fileName: string) { + const trimmed = fileName.trim(); + if (!trimmed) { + throw new ValidationError("File name is required"); + } + const withoutPath = trimmed.split(/[\\/]/).pop() ?? ""; + const normalized = withoutPath.replace(/\s+/g, " ").trim(); + if (!normalized) { + throw new ValidationError("File name is required"); + } + return normalized; +} + +export function getAttachmentExtension(fileName: string) { + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex < 0 || dotIndex === fileName.length - 1) { + return null; + } + return fileName.slice(dotIndex + 1).toLocaleLowerCase(); +} + +function normalizeMimeType(fileType: string | null | undefined) { + if (!fileType) return ""; + return fileType.split(";")[0]?.trim().toLocaleLowerCase() ?? ""; +} + +export type AttachmentClassification = { + kind: AttachmentKind; + extension: string; + mimeType: string; +}; + +export function classifyAttachment( + fileName: string, + fileType: string | null | undefined, +): AttachmentClassification { + const extension = getAttachmentExtension(fileName); + if (!extension) { + throw new ValidationError( + "Unsupported file type. Upload images, PDFs, or Office files.", + ); + } + const normalizedMimeType = normalizeMimeType(fileType); + + if (IMAGE_EXTENSIONS.has(extension)) { + if (normalizedMimeType && !normalizedMimeType.startsWith("image/")) { + throw new ValidationError( + "Unsupported image type. Upload images, PDFs, or Office files.", + ); + } + return { + kind: "image", + extension, + mimeType: normalizedMimeType || "image/jpeg", + }; + } + + if (PDF_EXTENSIONS.has(extension)) { + if (normalizedMimeType && !PDF_MIME_TYPES.has(normalizedMimeType)) { + throw new ValidationError( + "Unsupported PDF type. Upload images, PDFs, or Office files.", + ); + } + return { kind: "pdf", extension, mimeType: "application/pdf" }; + } + + if (OFFICE_EXTENSIONS.has(extension)) { + if ( + normalizedMimeType && + normalizedMimeType !== "application/octet-stream" && + !OFFICE_MIME_TYPES.has(normalizedMimeType) + ) { + throw new ValidationError( + "Unsupported Office file type. Upload images, PDFs, or Office files.", + ); + } + return { + kind: "office", + extension, + mimeType: normalizedMimeType || "application/octet-stream", + }; + } + + throw new ValidationError( + "Unsupported file type. Upload images, PDFs, or Office files.", + ); +} + +export function replaceAttachmentExtension( + fileName: string, + nextExtension: string, +) { + const extension = getAttachmentExtension(fileName); + if (!extension) return `${fileName}.${nextExtension}`; + return `${fileName.slice(0, -(extension.length + 1))}.${nextExtension}`; +} + +export function getAttachmentRetryDelayMs(attempt: number) { + if (attempt <= 1) return 5_000; + if (attempt === 2) return 20_000; + return 60_000; +} + +export function normalizeOptimizationErrorMessage(error: unknown) { + if (error instanceof Error && error.message) { + return error.message.slice(0, 240); + } + return "Attachment optimization failed"; +} diff --git a/src/server/pb/storage-quota.ts b/src/server/pb/storage-quota.ts new file mode 100644 index 0000000..544948d --- /dev/null +++ b/src/server/pb/storage-quota.ts @@ -0,0 +1,78 @@ +import type { Ctx } from "./context"; +import { DomainError } from "./errors"; + +export class StorageQuotaError extends DomainError { + constructor(message: string) { + super(message, 413); + this.name = "StorageQuotaError"; + } +} + +export function getStorageLimitBytes(): number | null { + const raw = process.env.STORAGE_LIMIT_GB; + if (!raw || raw.trim() === "") return null; + const gb = Number(raw); + if (Number.isNaN(gb) || gb <= 0) return null; + return Math.round(gb * 1024 * 1024 * 1024); +} + +type AttachmentRow = { + status: "pending" | "processing" | "ready" | "failed"; + fileSizeOriginal: number; + fileSizeOptimized?: number | null; +}; + +type ServiceAttachmentRow = { fileSize: number }; + +export async function getTotalStorageUsedBytes(ctx: Ctx): Promise { + let total = 0; + + const attachments = await ctx.pb + .collection("attachments") + .getFullList(); + for (const att of attachments) { + if (att.status === "failed") continue; + total += att.fileSizeOptimized ?? att.fileSizeOriginal; + } + + const serviceAttachments = await ctx.pb + .collection("serviceRecordAttachments") + .getFullList(); + for (const att of serviceAttachments) { + total += att.fileSize; + } + + return total; +} + +function formatBytes(bytes: number): string { + const gb = bytes / (1024 * 1024 * 1024); + if (gb >= 1) return `${gb.toFixed(1)} GB`; + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(0)} MB`; +} + +export async function enforceStorageQuota( + ctx: Ctx, + incomingBytes: number, +): Promise { + const limitBytes = getStorageLimitBytes(); + if (limitBytes === null) return; + + const usedBytes = await getTotalStorageUsedBytes(ctx); + if (usedBytes + incomingBytes > limitBytes) { + throw new StorageQuotaError( + `Storage limit reached. You are using ${formatBytes(usedBytes)} of ${formatBytes(limitBytes)}.`, + ); + } +} + +export async function getStorageUsage( + ctx: Ctx, +): Promise<{ usedBytes: number; limitBytes: number | null }> { + const [usedBytes, limitBytes] = [ + await getTotalStorageUsedBytes(ctx), + getStorageLimitBytes(), + ]; + return { usedBytes, limitBytes }; +} From b1526629246c36efb80d576e6899f8dd00704b01 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:51:22 +0200 Subject: [PATCH 15/37] Update plan: attachments done, frontend refactor is the last chunk 204 PB tests across 21 files, server-side migration complete. The /setup form swap folds into the frontend refactor task since the existing Convex wiring is still live until the frontend switches over end-to-end. --- docs/plans/2026-04-19-pocketbase-migration-plan.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-04-19-pocketbase-migration-plan.md b/docs/plans/2026-04-19-pocketbase-migration-plan.md index 8df944f..ad49056 100644 --- a/docs/plans/2026-04-19-pocketbase-migration-plan.md +++ b/docs/plans/2026-04-19-pocketbase-migration-plan.md @@ -2,7 +2,7 @@ **Date:** 2026-04-19 **Branch:** `pocketbase-migration` -**Status:** Server side complete (domains + auth session layer + API routes). Attachments pipeline, frontend refactor, and /setup form swap remain. +**Status:** Server side complete (domains + auth session layer + API routes + attachments/optimization pipeline). Frontend refactor is the last major chunk. Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean installs only; **no existing deployment will carry data across**, so there is no data migration, no ETL, no cutover password flow, no ID redirect table. The bar is functional parity: every feature that works on Convex today must work identically on PocketBase, proven by tests before we land the branch on `main`. @@ -10,7 +10,7 @@ Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean ## Current status (updated 2026-04-20) -**13 commits on `pocketbase-migration`.** Test surface: **186 PB tests across 18 files**, **440 main tests** (jsdom + convex-test), typecheck clean, lint 0 errors. +**14 commits on `pocketbase-migration`.** Test surface: **204 PB tests across 21 files**, **440 main tests** (jsdom + convex-test), typecheck clean, lint 0 errors. ### Done @@ -23,6 +23,7 @@ Replace the Convex backend with a self-hosted PocketBase. Stowage ships as clean - **M5 Service schedules + records** — Interval math, same-day snap-forward, calendar month / upcoming windows, record field snapshots, complete-scheduled-service auto-advance. - **M5 Users domain** — `listUsers`, `getUserById/Email`, `createUser`, `createFirstAdmin` (gated by `checkFirstRun`), `updateUserRole` (last-admin guard), `changePassword` (verified via scratch PB client). 13 tests. - **M6 API route handlers** — Every ported domain has thin handlers under `src/app/api/**/route.ts` (~40 routes). Mutations inject `actorId` from the session user; `withAdmin` gates writes on most domains (service-records and /api/users/me/password are user-scope). +- **M6 Attachments + optimization pipeline** — `src/server/domain/{attachments,attachmentsProcessing,serviceRecordAttachments}.ts`. Uploads land in the PB `storageFile` + `originalFile` fields via `FormData`; Jimp re-encodes images (JPEG or PNG depending on alpha) with iterative quality/compression; pdf-lib re-serializes PDFs with object streams. Non-retryable errors (oversized, missing source) stay sticky; retryable failures bounded by `MAX_ATTACHMENT_RETRY_ATTEMPTS=3`. `STORAGE_LIMIT_GB` env var enforces a quota via `StorageQuotaError` (413). Routes: POST/GET `/api/attachments` (multipart), `/[id]`, `/[id]/optimize`, `/queue-status`; mirror routes for `/api/service-record-attachments`; `/api/storage-usage`. 18 tests. ### Authz shape (decision note) @@ -30,10 +31,7 @@ Authz lives at the API route boundary via `withUser` / `withAdmin` rather than i ### In flight / remaining -- **Attachments + optimization pipeline** — Upload, Jimp image / pdf-lib PDF optimization, status state machine, storage quota. Moves from Convex internalAction to a Next.js `POST /api/attachments/[id]/optimize` route. -- **First-run setup page (M3c)** — `/setup` still wired to Convex; swap its form to POST `/api/auth/first-admin`. Install-token gating deferred to deployment hardening. -- **Frontend refactor (M7)** — Every `useQuery` / `useMutation` call site (~60 components) switches to TanStack Query + `useRealtimeCollection` / `useRealtimeRecord`, talking to the new `/api/**` routes. -- **`src/lib/server-auth.ts`** — Still calls Convex; replace with direct PB session checks once the auth UI switches over. +- **Frontend refactor (M7)** — The big remaining chunk. Every `useQuery`/`useMutation` call site (~60 components) switches to TanStack Query + `useRealtimeCollection`/`useRealtimeRecord`, talking to the new `/api/**` routes. This also fixes up the `/setup` and `/login` forms to POST to `/api/auth/first-admin` and `/api/auth/login`, and replaces `src/lib/server-auth.ts` Convex calls with a direct PB `resolveSession` check. - **E2E** — Playwright flows against the PB stack. ### Not yet touched From 3881a6c3466b1b0d873a3ce34c5f2b42908a2c49 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:52:29 +0200 Subject: [PATCH 16/37] Add client API foundation for the frontend refactor - src/lib/api-client.ts: fetch wrapper that sends credentials, parses JSON, and throws ApiRequestError (status + issues preserved) on non-OK - src/lib/api/auth.ts: typed wrappers for /api/auth/* (getCurrentUser, login, logout, checkFirstRun, createFirstAdmin) - src/lib/api/categories.ts: first resource client as the pattern template for other domains - src/hooks/use-current-user.ts: TanStack Query hook for the signed-in user with a 60s staleTime - src/hooks/use-realtime-record.ts: per-record PB subscription + cache invalidation (companion to the existing useRealtimeCollection) These are the shared primitives the ~60-component rewrite will build on. No existing components touched yet; Convex UI still drives every page. --- src/hooks/use-current-user.ts | 15 +++++++ src/hooks/use-realtime-record.ts | 69 ++++++++++++++++++++++++++++ src/lib/api-client.ts | 77 ++++++++++++++++++++++++++++++++ src/lib/api/auth.ts | 50 +++++++++++++++++++++ src/lib/api/categories.ts | 31 +++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 src/hooks/use-current-user.ts create mode 100644 src/hooks/use-realtime-record.ts create mode 100644 src/lib/api-client.ts create mode 100644 src/lib/api/auth.ts create mode 100644 src/lib/api/categories.ts diff --git a/src/hooks/use-current-user.ts b/src/hooks/use-current-user.ts new file mode 100644 index 0000000..457704c --- /dev/null +++ b/src/hooks/use-current-user.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { getCurrentUser, type SessionUser } from "@/lib/api/auth"; + +export const CURRENT_USER_QUERY_KEY = ["auth", "me"] as const; + +export function useCurrentUser() { + return useQuery({ + queryKey: CURRENT_USER_QUERY_KEY, + queryFn: getCurrentUser, + staleTime: 60_000, + }); +} diff --git a/src/hooks/use-realtime-record.ts b/src/hooks/use-realtime-record.ts new file mode 100644 index 0000000..f7d5801 --- /dev/null +++ b/src/hooks/use-realtime-record.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; + +import { usePocketBase } from "@/app/PocketBaseClientProvider"; + +type UseRealtimeRecordOptions = { + collection: string; + recordId: string | null | undefined; + fetcher: () => Promise; + queryKey?: readonly unknown[]; + enabled?: boolean; +}; + +/** + * Subscribes to realtime events for a single PB record (by id) and invalidates + * the matching TanStack Query cache entry on every change. Disabled when + * `recordId` is nullish. + */ +export function useRealtimeRecord({ + collection, + recordId, + fetcher, + queryKey, + enabled = true, +}: UseRealtimeRecordOptions) { + const pb = usePocketBase(); + const qc = useQueryClient(); + const key = useMemo( + () => queryKey ?? [collection, recordId], + [queryKey, collection, recordId], + ); + + const isEnabled = enabled && !!recordId; + const query = useQuery({ + queryKey: key, + queryFn: fetcher, + enabled: isEnabled, + }); + + useEffect(() => { + if (!isEnabled || !recordId) return; + let unsub: (() => void) | null = null; + let cancelled = false; + + pb.collection(collection) + .subscribe(recordId, () => { + qc.invalidateQueries({ queryKey: key }); + }) + .then((fn) => { + if (cancelled) fn(); + else unsub = fn; + }) + .catch((error) => { + console.error( + `[useRealtimeRecord] subscribe(${collection}/${recordId}) failed`, + error, + ); + }); + + return () => { + cancelled = true; + if (unsub) unsub(); + }; + }, [pb, qc, collection, recordId, key, isEnabled]); + + return query; +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..4f5b0c7 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,77 @@ +export type ApiError = { + error: string; + status: number; + issues?: unknown; +}; + +export class ApiRequestError extends Error { + readonly status: number; + readonly issues?: unknown; + + constructor(error: ApiError) { + super(error.error); + this.name = "ApiRequestError"; + this.status = error.status; + this.issues = error.issues; + } +} + +async function readError(res: Response): Promise { + let message = res.statusText || "Request failed"; + let issues: unknown; + try { + const body = (await res.json()) as { + error?: string; + issues?: unknown; + }; + if (body?.error) message = body.error; + if (body?.issues !== undefined) issues = body.issues; + } catch { + // ignore non-JSON error bodies + } + return { error: message, status: res.status, issues }; +} + +type JsonBody = Record | unknown[]; + +type ApiOptions = { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: JsonBody | FormData; + signal?: AbortSignal; +}; + +/** + * Small fetch wrapper for our /api/** routes. Sends credentials, parses JSON + * responses, and throws ApiRequestError (preserving status + issues) on non-OK. + */ +export async function apiFetch( + path: string, + options: ApiOptions = {}, +): Promise { + const { method = "GET", body, signal } = options; + + const init: RequestInit = { + method, + credentials: "include", + signal, + }; + + if (body instanceof FormData) { + init.body = body; + } else if (body !== undefined) { + init.body = JSON.stringify(body); + init.headers = { "content-type": "application/json" }; + } + + const res = await fetch(path, init); + + if (!res.ok) { + throw new ApiRequestError(await readError(res)); + } + + if (res.status === 204) { + return undefined as T; + } + + return (await res.json()) as T; +} diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts new file mode 100644 index 0000000..538edc8 --- /dev/null +++ b/src/lib/api/auth.ts @@ -0,0 +1,50 @@ +import { apiFetch } from "@/lib/api-client"; + +export type SessionUser = { + id: string; + email: string; + name: string; + role: "admin" | "user"; +}; + +export async function getCurrentUser(): Promise { + const { user } = await apiFetch<{ user: SessionUser | null }>("/api/auth/me"); + return user; +} + +export async function login(input: { + email: string; + password: string; +}): Promise { + const { user } = await apiFetch<{ user: SessionUser }>("/api/auth/login", { + method: "POST", + body: { email: input.email, password: input.password }, + }); + return user; +} + +export async function logout(): Promise { + await apiFetch("/api/auth/logout", { method: "POST" }); +} + +export async function checkFirstRun(): Promise { + const { firstRun } = await apiFetch<{ firstRun: boolean }>( + "/api/auth/first-run", + ); + return firstRun; +} + +export async function createFirstAdmin(input: { + email: string; + name: string; + password: string; +}): Promise { + const { user } = await apiFetch<{ user: SessionUser }>( + "/api/auth/first-admin", + { + method: "POST", + body: input, + }, + ); + return user; +} diff --git a/src/lib/api/categories.ts b/src/lib/api/categories.ts new file mode 100644 index 0000000..19b0a04 --- /dev/null +++ b/src/lib/api/categories.ts @@ -0,0 +1,31 @@ +import { apiFetch } from "@/lib/api-client"; + +export type Category = { + id: string; + name: string; + prefix: string | null; + description: string | null; + color: string; + createdAt: number; + updatedAt: number; +}; + +export async function listCategories(): Promise { + const { categories } = await apiFetch<{ categories: Category[] }>( + "/api/categories", + ); + return categories; +} + +export async function createCategory(input: { + name: string; + color: string; + prefix?: string | null; + description?: string | null; +}): Promise { + const { category } = await apiFetch<{ category: Category }>( + "/api/categories", + { method: "POST", body: input }, + ); + return category; +} From a28b73df3bb731ab0ed6ef691941453cd47ca4de Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:56:47 +0200 Subject: [PATCH 17/37] Swap auth shell onto PocketBase endpoints - server-auth.ts reads the pb_auth cookie and calls resolveSession + checkFirstRun against PB instead of fetchQuery to Convex - LoginForm posts to /api/auth/login via the new api-client, seeds the TanStack Query cache with the signed-in user, and redirects to /dashboard. No more ConvexAuthProvider hooks - SetupForm posts to /api/auth/first-admin; the admin auto-signs in and hydrates both CURRENT_USER_QUERY_KEY and the first-run flag - Root layout wraps children in PocketBaseClientProvider (outside Convex) so new-code components can use TanStack Query while legacy components still get the Convex client Tests updated to mock @/lib/api/auth instead of @convex-dev/auth. Main suite: 440/440 still green; PB: 204/204. --- .../components/auth/login-form.test.tsx | 104 ++++++++++-------- .../components/auth/setup-form.test.tsx | 62 +++++------ src/app/layout.tsx | 21 ++-- src/components/auth/login-form.tsx | 44 ++++---- src/components/auth/setup-form.tsx | 53 +++------ src/lib/server-auth.ts | 60 +++------- 6 files changed, 155 insertions(+), 189 deletions(-) diff --git a/src/__tests__/components/auth/login-form.test.tsx b/src/__tests__/components/auth/login-form.test.tsx index 8c12f76..ec364ab 100644 --- a/src/__tests__/components/auth/login-form.test.tsx +++ b/src/__tests__/components/auth/login-form.test.tsx @@ -1,16 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const replaceMock = vi.fn(); -const signInMock = vi.fn(); - -const authState = { - isAuthenticated: false, - isLoading: false, -}; - -let firstRunValue: boolean | undefined = false; +const getCurrentUserMock = vi.fn(); +const loginMock = vi.fn(); +const checkFirstRunMock = vi.fn(); vi.mock("next/link", () => ({ default: ({ @@ -28,54 +24,56 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: replaceMock }), })); -vi.mock("@convex-dev/auth/react", () => ({ - useAuthActions: () => ({ signIn: signInMock, signOut: vi.fn() }), - useAuthToken: () => null, -})); - -vi.mock("convex/react", () => ({ - useConvexAuth: () => authState, - useQuery: () => firstRunValue, +vi.mock("@/lib/api/auth", () => ({ + getCurrentUser: () => getCurrentUserMock(), + login: (input: unknown) => loginMock(input), + checkFirstRun: () => checkFirstRunMock(), })); import { LoginForm } from "@/components/auth/login-form"; +function renderWithClient() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return { + qc, + ...render( + + + , + ), + }; +} + describe("LoginForm", () => { beforeEach(() => { replaceMock.mockReset(); - signInMock.mockReset(); - authState.isAuthenticated = false; - authState.isLoading = false; - firstRunValue = false; + getCurrentUserMock.mockReset(); + loginMock.mockReset(); + checkFirstRunMock.mockReset(); + getCurrentUserMock.mockResolvedValue(null); + checkFirstRunMock.mockResolvedValue(false); }); - it("renders email/password fields and submit button", () => { - render(); - - expect(screen.getByLabelText("Email")).toBeInTheDocument(); + it("renders email/password fields and submit button", async () => { + renderWithClient(); + expect(await screen.findByLabelText("Email")).toBeInTheDocument(); expect(screen.getByLabelText("Password")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument(); }); - it("disables submit while auth state is loading", () => { - authState.isLoading = true; - render(); - - expect(screen.getByRole("button", { name: "Sign in" })).toBeDisabled(); - }); - it("shows error on invalid credentials", async () => { const user = userEvent.setup(); - signInMock.mockRejectedValueOnce(new Error("InvalidSecret")); + loginMock.mockRejectedValueOnce(new Error("Invalid email or password")); - render(); + renderWithClient(); - await user.type(screen.getByLabelText("Email"), "ADMIN@Example.com"); + await user.type(screen.getByLabelText("Email"), "admin@example.com"); await user.type(screen.getByLabelText("Password"), "bad-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); - expect(signInMock).toHaveBeenCalledWith("password", { - flow: "signIn", + expect(loginMock).toHaveBeenCalledWith({ email: "admin@example.com", password: "bad-password", }); @@ -84,18 +82,36 @@ describe("LoginForm", () => { ); }); - it("shows invalid credentials when signIn returns no session", async () => { + it("redirects to /dashboard on successful login", async () => { const user = userEvent.setup(); - signInMock.mockResolvedValueOnce({ signingIn: false }); + loginMock.mockResolvedValueOnce({ + id: "u1", + email: "admin@example.com", + name: "Admin", + role: "admin", + }); - render(); + renderWithClient(); await user.type(screen.getByLabelText("Email"), "admin@example.com"); - await user.type(screen.getByLabelText("Password"), "bad-password"); + await user.type(screen.getByLabelText("Password"), "correct-password"); await user.click(screen.getByRole("button", { name: "Sign in" })); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Invalid email or password", - ); + await vi.waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/dashboard"); + }); + }); + + it("redirects to /dashboard when already signed in", async () => { + getCurrentUserMock.mockResolvedValue({ + id: "u1", + email: "admin@example.com", + name: "Admin", + role: "admin", + }); + renderWithClient(); + await vi.waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/dashboard"); + }); }); }); diff --git a/src/__tests__/components/auth/setup-form.test.tsx b/src/__tests__/components/auth/setup-form.test.tsx index 4d54811..2046c4b 100644 --- a/src/__tests__/components/auth/setup-form.test.tsx +++ b/src/__tests__/components/auth/setup-form.test.tsx @@ -1,49 +1,48 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const replaceMock = vi.fn(); -const signInMock = vi.fn(); +const checkFirstRunMock = vi.fn(); const createFirstAdminMock = vi.fn(); -const authState = { - isAuthenticated: false, - isLoading: false, -}; - -let firstRunValue: boolean | undefined = true; - vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: replaceMock }), })); -vi.mock("@convex-dev/auth/react", () => ({ - useAuthActions: () => ({ signIn: signInMock, signOut: vi.fn() }), - useAuthToken: () => null, -})); - -vi.mock("convex/react", () => ({ - useConvexAuth: () => authState, - useQuery: () => firstRunValue, - useAction: () => createFirstAdminMock, +vi.mock("@/lib/api/auth", () => ({ + checkFirstRun: () => checkFirstRunMock(), + createFirstAdmin: (input: unknown) => createFirstAdminMock(input), })); import { SetupForm } from "@/components/auth/setup-form"; +function renderWithClient() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return { + qc, + ...render( + + + , + ), + }; +} + describe("SetupForm", () => { beforeEach(() => { replaceMock.mockReset(); - signInMock.mockReset(); + checkFirstRunMock.mockReset(); createFirstAdminMock.mockReset(); - authState.isAuthenticated = false; - authState.isLoading = false; - firstRunValue = true; + checkFirstRunMock.mockResolvedValue(true); }); - it("renders all setup fields", () => { - render(); - - expect(screen.getByLabelText("Full name")).toBeInTheDocument(); + it("renders all setup fields", async () => { + renderWithClient(); + expect(await screen.findByLabelText("Full name")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument(); expect(screen.getByLabelText("Password")).toBeInTheDocument(); expect(screen.getByLabelText("Confirm password")).toBeInTheDocument(); @@ -54,7 +53,8 @@ describe("SetupForm", () => { it("validates password mismatch before submitting", async () => { const user = userEvent.setup(); - render(); + renderWithClient(); + await screen.findByLabelText("Full name"); await user.type(screen.getByLabelText("Full name"), "Alex Admin"); await user.type(screen.getByLabelText("Email"), "alex@example.com"); @@ -70,10 +70,10 @@ describe("SetupForm", () => { ); }); - it("redirects to login if setup is already complete", () => { - firstRunValue = false; - render(); - + it("redirects to login if setup is already complete", async () => { + checkFirstRunMock.mockResolvedValue(false); + renderWithClient(); + await screen.findByRole("button", { name: "Create admin account" }); expect(replaceMock).toHaveBeenCalledWith("/login"); }); }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5ecd779..54b0f18 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ConvexClientProvider } from "@/app/ConvexClientProvider"; +import { PocketBaseClientProvider } from "@/app/PocketBaseClientProvider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -32,6 +33,8 @@ export default async function RootLayout({ }>) { const convexUrl = process.env.CONVEX_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL; + const pocketbaseUrl = + process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090"; if (!convexUrl) { throw new Error( @@ -44,14 +47,16 @@ export default async function RootLayout({ - - - - {children} - - - - + + + + + {children} + + + + + ); diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index b538962..2fca797 100644 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -1,25 +1,28 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useAuthActions, useAuthToken } from "@convex-dev/auth/react"; -import { useConvexAuth } from "convex/react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { AuthPanel } from "@/components/auth/auth-panel"; import { getLoginErrorMessage } from "@/components/auth/auth-error-messages"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { AuthPanel } from "@/components/auth/auth-panel"; -import { setAuthTokenCookie } from "@/lib/auth-token-cookie"; -import { api } from "@/lib/convex-api"; -import { useQuery } from "convex/react"; +import { CURRENT_USER_QUERY_KEY, useCurrentUser } from "@/hooks/use-current-user"; +import { checkFirstRun, login } from "@/lib/api/auth"; +import { useQuery } from "@tanstack/react-query"; export function LoginForm() { const router = useRouter(); - const { signIn } = useAuthActions(); - const authToken = useAuthToken(); - const { isAuthenticated, isLoading } = useConvexAuth(); - const firstRun = useQuery(api.users.checkFirstRun, {}); + const qc = useQueryClient(); + const { data: currentUser, isLoading } = useCurrentUser(); + const { data: firstRun } = useQuery({ + queryKey: ["auth", "first-run"], + queryFn: checkFirstRun, + staleTime: 60_000, + }); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -27,11 +30,10 @@ export function LoginForm() { const [error, setError] = useState(null); useEffect(() => { - if (!isLoading && isAuthenticated && authToken) { - setAuthTokenCookie(authToken); + if (!isLoading && currentUser) { router.replace("/dashboard"); } - }, [authToken, isAuthenticated, isLoading, router]); + }, [currentUser, isLoading, router]); async function onSubmit(event: React.FormEvent) { event.preventDefault(); @@ -39,15 +41,9 @@ export function LoginForm() { setError(null); try { - const result = await signIn("password", { - flow: "signIn", - email: email.trim().toLowerCase(), - password, - }); - - if (!result.signingIn) { - setError("Invalid email or password"); - } + const user = await login({ email, password }); + qc.setQueryData(CURRENT_USER_QUERY_KEY, user); + router.replace("/dashboard"); } catch (caught) { setError(getLoginErrorMessage(caught)); } finally { diff --git a/src/components/auth/setup-form.tsx b/src/components/auth/setup-form.tsx index 15252c1..c32e757 100644 --- a/src/components/auth/setup-form.tsx +++ b/src/components/auth/setup-form.tsx @@ -1,24 +1,25 @@ "use client"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useAuthActions, useAuthToken } from "@convex-dev/auth/react"; -import { useAction, useConvexAuth, useQuery } from "convex/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + import { AuthPanel } from "@/components/auth/auth-panel"; import { getSetupErrorMessage } from "@/components/auth/auth-error-messages"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { setAuthTokenCookie } from "@/lib/auth-token-cookie"; -import { api } from "@/lib/convex-api"; +import { CURRENT_USER_QUERY_KEY } from "@/hooks/use-current-user"; +import { checkFirstRun, createFirstAdmin } from "@/lib/api/auth"; export function SetupForm() { const router = useRouter(); - const { signIn } = useAuthActions(); - const authToken = useAuthToken(); - const { isAuthenticated, isLoading } = useConvexAuth(); - const firstRun = useQuery(api.users.checkFirstRun, {}); - const createFirstAdmin = useAction(api.users.createFirstAdmin); + const qc = useQueryClient(); + const { data: firstRun, isLoading } = useQuery({ + queryKey: ["auth", "first-run"], + queryFn: checkFirstRun, + staleTime: 60_000, + }); const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -27,13 +28,6 @@ export function SetupForm() { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!isLoading && isAuthenticated && authToken) { - setAuthTokenCookie(authToken); - router.replace("/dashboard"); - } - }, [authToken, isAuthenticated, isLoading, router]); - useEffect(() => { if (firstRun === false) { router.replace("/login"); @@ -52,23 +46,10 @@ export function SetupForm() { setSubmitting(true); try { - await createFirstAdmin({ - email, - name, - password, - }); - - const result = await signIn("password", { - flow: "signIn", - email: email.trim().toLowerCase(), - password, - }); - - if (!result.signingIn) { - setError( - "Setup completed, but sign-in did not finish. Try signing in.", - ); - } + const user = await createFirstAdmin({ email, name, password }); + qc.setQueryData(CURRENT_USER_QUERY_KEY, user); + qc.setQueryData(["auth", "first-run"], false); + router.replace("/dashboard"); } catch (caught) { setError(getSetupErrorMessage(caught)); } finally { @@ -76,7 +57,7 @@ export function SetupForm() { } } - if (firstRun === undefined) { + if (isLoading) { return ( { - const convexUrl = getConvexUrl(); + const token = await readPbToken(); - const [firstRun, isAuthenticated] = await Promise.all([ - fetchQuery(api.users.checkFirstRun, {}, { url: convexUrl }), - getServerIsAuthenticated(), + const [user, ctx] = await Promise.all([ + resolveSession(token), + createAdminCtx(), ]); + const firstRun = await checkFirstRun(ctx); return { firstRun, - isAuthenticated, + isAuthenticated: user !== null, }; } From 90f187a25e5efccf4d910c17f35eccf5dd81532c Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 10:58:43 +0200 Subject: [PATCH 18/37] Add typed client wrappers for every /api/** resource One file per resource under src/lib/api/. Each wraps the new /api/** routes with typed request and response shapes so the frontend refactor doesn't have to repeat fetch+JSON+error plumbing. Added: tags, locations, custom-fields, app-settings, service-providers, service-groups (+ service-group-fields), label-templates, users, assets, search, dashboard, service-schedules, service-records, attachments (+ service-record-attachments + storage-usage). All use the shared apiFetch / ApiRequestError from src/lib/api-client.ts. No components swap over yet; these are the primitives the remaining ~60-component rewrite will consume. --- src/lib/api/app-settings.ts | 41 +++++++ src/lib/api/assets.ts | 192 +++++++++++++++++++++++++++++++ src/lib/api/attachments.ts | 109 ++++++++++++++++++ src/lib/api/custom-fields.ts | 83 +++++++++++++ src/lib/api/dashboard.ts | 31 +++++ src/lib/api/label-templates.ts | 82 +++++++++++++ src/lib/api/locations.ts | 60 ++++++++++ src/lib/api/search.ts | 20 ++++ src/lib/api/service-groups.ts | 138 ++++++++++++++++++++++ src/lib/api/service-providers.ts | 65 +++++++++++ src/lib/api/service-records.ts | 121 +++++++++++++++++++ src/lib/api/service-schedules.ts | 100 ++++++++++++++++ src/lib/api/tags.ts | 40 +++++++ src/lib/api/users.ts | 49 ++++++++ 14 files changed, 1131 insertions(+) create mode 100644 src/lib/api/app-settings.ts create mode 100644 src/lib/api/assets.ts create mode 100644 src/lib/api/attachments.ts create mode 100644 src/lib/api/custom-fields.ts create mode 100644 src/lib/api/dashboard.ts create mode 100644 src/lib/api/label-templates.ts create mode 100644 src/lib/api/locations.ts create mode 100644 src/lib/api/search.ts create mode 100644 src/lib/api/service-groups.ts create mode 100644 src/lib/api/service-providers.ts create mode 100644 src/lib/api/service-records.ts create mode 100644 src/lib/api/service-schedules.ts create mode 100644 src/lib/api/tags.ts create mode 100644 src/lib/api/users.ts diff --git a/src/lib/api/app-settings.ts b/src/lib/api/app-settings.ts new file mode 100644 index 0000000..9ad6f68 --- /dev/null +++ b/src/lib/api/app-settings.ts @@ -0,0 +1,41 @@ +import { apiFetch } from "@/lib/api-client"; + +export const APP_DATE_FORMATS = [ + "DD-MM-YYYY", + "MM-DD-YYYY", + "YYYY-MM-DD", +] as const; +export type AppDateFormat = (typeof APP_DATE_FORMATS)[number]; + +export type AppSettings = { + dateFormat: AppDateFormat; + serviceSchedulingEnabled: boolean; + updatedAt: number | null; +}; + +export async function getAppSettings(): Promise { + const { settings } = await apiFetch<{ settings: AppSettings }>( + "/api/app-settings", + ); + return settings; +} + +export async function setDateFormat( + dateFormat: AppDateFormat, +): Promise { + const { settings } = await apiFetch<{ settings: AppSettings }>( + "/api/app-settings", + { method: "PATCH", body: { kind: "dateFormat", dateFormat } }, + ); + return settings; +} + +export async function setServiceSchedulingEnabled( + enabled: boolean, +): Promise { + const { settings } = await apiFetch<{ settings: AppSettings }>( + "/api/app-settings", + { method: "PATCH", body: { kind: "serviceScheduling", enabled } }, + ); + return settings; +} diff --git a/src/lib/api/assets.ts b/src/lib/api/assets.ts new file mode 100644 index 0000000..b56c4a3 --- /dev/null +++ b/src/lib/api/assets.ts @@ -0,0 +1,192 @@ +import { apiFetch } from "@/lib/api-client"; + +export const ASSET_STATUSES = [ + "active", + "in_storage", + "under_repair", + "retired", + "disposed", +] as const; +export type AssetStatus = (typeof ASSET_STATUSES)[number]; + +export type AssetCustomFieldValue = string | number | boolean | null; + +export type AssetListItem = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryId: string | null; + categoryName: string | null; + categoryColor: string | null; + locationId: string | null; + locationPath: string | null; + serviceGroupId: string | null; + notes: string | null; + tagIds: string[]; + tagNames: string[]; + createdAt: number; + updatedAt: number; +}; + +export type AssetDetail = { + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryId: string | null; + locationId: string | null; + serviceGroupId: string | null; + notes: string | null; + customFieldValues: Record; + createdBy: string; + updatedBy: string; + createdAt: number; + updatedAt: number; + category: { + id: string; + name: string; + prefix: string | null; + color: string; + } | null; + location: { + id: string; + name: string; + parentId: string | null; + path: string; + } | null; + serviceGroup: { id: string; name: string } | null; + tags: Array<{ id: string; name: string; color: string }>; +}; + +export type AssetTagPreview = { + assetTag: string; + prefix: string; + nextNumber: number; +}; + +export type ListAssetsParams = { + categoryId?: string | null; + locationId?: string | null; + status?: AssetStatus; + tagIds?: string[]; + search?: string; + sortBy?: "createdAt" | "name" | "assetTag" | "status"; + sortDirection?: "asc" | "desc"; +}; + +type CreateAssetInput = { + name: string; + categoryId?: string | null; + locationId?: string | null; + serviceGroupId?: string | null; + status?: AssetStatus; + notes?: string | null; + customFieldValues?: Record; + tagIds?: string[]; +}; + +type UpdateAssetInput = Partial; + +function buildAssetsQuery(params: ListAssetsParams): string { + const qs = new URLSearchParams(); + if (params.categoryId !== undefined && params.categoryId !== null) { + qs.set("categoryId", params.categoryId); + } + if (params.locationId !== undefined && params.locationId !== null) { + qs.set("locationId", params.locationId); + } + if (params.status !== undefined) qs.set("status", params.status); + if (params.search) qs.set("search", params.search); + if (params.sortBy) qs.set("sortBy", params.sortBy); + if (params.sortDirection) qs.set("sortDirection", params.sortDirection); + for (const id of params.tagIds ?? []) qs.append("tagId", id); + const q = qs.toString(); + return q ? `?${q}` : ""; +} + +export async function listAssets( + params: ListAssetsParams = {}, +): Promise { + const { assets } = await apiFetch<{ assets: AssetListItem[] }>( + `/api/assets${buildAssetsQuery(params)}`, + ); + return assets; +} + +export async function getAsset( + assetId: string, +): Promise { + const { asset } = await apiFetch<{ asset: AssetDetail | null }>( + `/api/assets/${assetId}`, + ); + return asset; +} + +export async function createAsset( + input: CreateAssetInput, +): Promise<{ assetId: string }> { + return apiFetch<{ assetId: string }>("/api/assets", { + method: "POST", + body: input, + }); +} + +export async function updateAsset( + assetId: string, + input: UpdateAssetInput, +): Promise { + await apiFetch(`/api/assets/${assetId}`, { + method: "PATCH", + body: input, + }); +} + +export async function updateAssetStatus( + assetId: string, + status: AssetStatus, +): Promise { + await apiFetch(`/api/assets/${assetId}/status`, { + method: "PATCH", + body: { status }, + }); +} + +export async function deleteAsset(assetId: string): Promise { + await apiFetch(`/api/assets/${assetId}`, { method: "DELETE" }); +} + +export async function previewAssetTag( + categoryId: string | null, +): Promise { + const qs = categoryId ? `?categoryId=${encodeURIComponent(categoryId)}` : ""; + const { preview } = await apiFetch<{ preview: AssetTagPreview }>( + `/api/assets/preview-tag${qs}`, + ); + return preview; +} + +export async function setAssetTags( + assetId: string, + tagIds: string[], +): Promise { + await apiFetch(`/api/assets/${assetId}/tags`, { + method: "PUT", + body: { tagIds }, + }); +} + +export type AssetTag = { + id: string; + name: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +export async function getAssetTags(assetId: string): Promise { + const { tags } = await apiFetch<{ tags: AssetTag[] }>( + `/api/assets/${assetId}/tags`, + ); + return tags; +} diff --git a/src/lib/api/attachments.ts b/src/lib/api/attachments.ts new file mode 100644 index 0000000..d87abb3 --- /dev/null +++ b/src/lib/api/attachments.ts @@ -0,0 +1,109 @@ +import { apiFetch } from "@/lib/api-client"; + +export type AttachmentView = { + id: string; + assetId: string; + fileName: string; + fileType: string; + fileExtension: string; + fileKind: "image" | "pdf" | "office"; + fileSizeOriginal: number; + fileSizeOptimized: number | null; + status: "pending" | "processing" | "ready" | "failed"; + optimizationAttempts: number; + optimizationError: string | null; + uploadedBy: string; + uploadedAt: number; + updatedAt: number; + url: string | null; +}; + +export async function listAttachments( + assetId: string, +): Promise { + const { attachments } = await apiFetch<{ attachments: AttachmentView[] }>( + `/api/attachments?assetId=${encodeURIComponent(assetId)}`, + ); + return attachments; +} + +export async function uploadAttachment( + assetId: string, + file: File, +): Promise<{ attachmentId: string }> { + const form = new FormData(); + form.append("assetId", assetId); + form.append("file", file); + return apiFetch<{ attachmentId: string }>("/api/attachments", { + method: "POST", + body: form, + }); +} + +export async function deleteAttachment(attachmentId: string): Promise { + await apiFetch(`/api/attachments/${attachmentId}`, { method: "DELETE" }); +} + +export async function retryAttachment(attachmentId: string): Promise { + await apiFetch(`/api/attachments/${attachmentId}/optimize`, { + method: "POST", + }); +} + +export type ServiceRecordAttachmentView = { + id: string; + serviceRecordId: string; + fileName: string; + fileType: string; + fileExtension: string; + fileKind: "image" | "pdf" | "office"; + fileSize: number; + uploadedBy: string; + uploadedAt: number; + updatedAt: number; + url: string | null; +}; + +export async function listServiceRecordAttachments( + serviceRecordId: string, +): Promise { + const { attachments } = await apiFetch<{ + attachments: ServiceRecordAttachmentView[]; + }>( + `/api/service-record-attachments?serviceRecordId=${encodeURIComponent( + serviceRecordId, + )}`, + ); + return attachments; +} + +export async function uploadServiceRecordAttachment( + serviceRecordId: string, + file: File, +): Promise<{ attachmentId: string }> { + const form = new FormData(); + form.append("serviceRecordId", serviceRecordId); + form.append("file", file); + return apiFetch<{ attachmentId: string }>("/api/service-record-attachments", { + method: "POST", + body: form, + }); +} + +export async function deleteServiceRecordAttachment( + attachmentId: string, +): Promise { + await apiFetch(`/api/service-record-attachments/${attachmentId}`, { + method: "DELETE", + }); +} + +export async function getStorageUsage(): Promise<{ + usedBytes: number; + limitBytes: number | null; +}> { + const { usage } = await apiFetch<{ + usage: { usedBytes: number; limitBytes: number | null }; + }>("/api/storage-usage"); + return usage; +} diff --git a/src/lib/api/custom-fields.ts b/src/lib/api/custom-fields.ts new file mode 100644 index 0000000..36ed9a3 --- /dev/null +++ b/src/lib/api/custom-fields.ts @@ -0,0 +1,83 @@ +import { apiFetch } from "@/lib/api-client"; + +export const CUSTOM_FIELD_TYPES = [ + "text", + "number", + "date", + "dropdown", + "checkbox", + "url", + "currency", +] as const; +export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number]; + +export type CustomFieldDefinition = { + id: string; + name: string; + fieldType: CustomFieldType; + options: string[]; + required: boolean; + sortOrder: number; + usageCount: number; + createdAt: number; + updatedAt: number; +}; + +type CreateInput = { + name: string; + fieldType: CustomFieldType; + options?: string[]; + required: boolean; +}; + +type UpdateInput = { + name: string; + fieldType: CustomFieldType; + options?: string[]; + required: boolean; +}; + +export async function listCustomFields(): Promise { + const { fields } = await apiFetch<{ fields: CustomFieldDefinition[] }>( + "/api/custom-fields", + ); + return fields; +} + +export async function createCustomField( + input: CreateInput, +): Promise { + const { field } = await apiFetch<{ field: CustomFieldDefinition }>( + "/api/custom-fields", + { method: "POST", body: input }, + ); + return field; +} + +export async function updateCustomField( + fieldDefinitionId: string, + input: UpdateInput, +): Promise { + const { field } = await apiFetch<{ field: CustomFieldDefinition }>( + `/api/custom-fields/${fieldDefinitionId}`, + { method: "PATCH", body: input }, + ); + return field; +} + +export async function deleteCustomField( + fieldDefinitionId: string, +): Promise { + await apiFetch(`/api/custom-fields/${fieldDefinitionId}`, { + method: "DELETE", + }); +} + +export async function reorderCustomFields( + fieldDefinitionIds: string[], +): Promise { + await apiFetch("/api/custom-fields/reorder", { + method: "POST", + body: { fieldDefinitionIds }, + }); +} diff --git a/src/lib/api/dashboard.ts b/src/lib/api/dashboard.ts new file mode 100644 index 0000000..8982b8a --- /dev/null +++ b/src/lib/api/dashboard.ts @@ -0,0 +1,31 @@ +import { apiFetch } from "@/lib/api-client"; +import type { AssetStatus } from "./assets"; + +export type DashboardOverview = { + counts: Array<{ status: AssetStatus; count: number }>; + recentAssets: Array<{ + id: string; + name: string; + assetTag: string; + status: AssetStatus; + categoryName: string | null; + categoryColor: string | null; + locationPath: string | null; + createdAt: number; + }>; + upcomingServices: Array<{ + scheduleId: string; + assetId: string; + assetName: string; + assetTag: string; + nextServiceDate: string; + }>; + serviceSchedulingEnabled: boolean; +}; + +export async function getDashboardOverview(): Promise { + const { overview } = await apiFetch<{ overview: DashboardOverview }>( + "/api/dashboard", + ); + return overview; +} diff --git a/src/lib/api/label-templates.ts b/src/lib/api/label-templates.ts new file mode 100644 index 0000000..87ac09d --- /dev/null +++ b/src/lib/api/label-templates.ts @@ -0,0 +1,82 @@ +import { apiFetch } from "@/lib/api-client"; + +export type LabelTemplateElement = Record; + +export type LabelTemplate = { + id: string; + name: string; + widthMm: number; + heightMm: number; + elements: LabelTemplateElement[]; + isDefault: boolean; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +type TemplateInput = { + name: string; + widthMm: number; + heightMm: number; + elements: LabelTemplateElement[]; + isDefault: boolean; +}; + +export async function listLabelTemplates(): Promise { + const { templates } = await apiFetch<{ templates: LabelTemplate[] }>( + "/api/label-templates", + ); + return templates; +} + +export async function getDefaultLabelTemplate(): Promise { + const { template } = await apiFetch<{ template: LabelTemplate | null }>( + "/api/label-templates?variant=default", + ); + return template; +} + +export async function getLabelTemplate( + templateId: string, +): Promise { + const { template } = await apiFetch<{ template: LabelTemplate | null }>( + `/api/label-templates/${templateId}`, + ); + return template; +} + +export async function createLabelTemplate( + input: TemplateInput, +): Promise { + const { template } = await apiFetch<{ template: LabelTemplate }>( + "/api/label-templates", + { method: "POST", body: input }, + ); + return template; +} + +export async function updateLabelTemplate( + templateId: string, + input: TemplateInput, +): Promise { + const { template } = await apiFetch<{ template: LabelTemplate }>( + `/api/label-templates/${templateId}`, + { method: "PATCH", body: input }, + ); + return template; +} + +export async function deleteLabelTemplate( + templateId: string, +): Promise { + await apiFetch(`/api/label-templates/${templateId}`, { method: "DELETE" }); +} + +export async function setDefaultLabelTemplate( + templateId: string, +): Promise { + await apiFetch(`/api/label-templates/${templateId}/default`, { + method: "POST", + }); +} diff --git a/src/lib/api/locations.ts b/src/lib/api/locations.ts new file mode 100644 index 0000000..265659e --- /dev/null +++ b/src/lib/api/locations.ts @@ -0,0 +1,60 @@ +import { apiFetch } from "@/lib/api-client"; + +export type Location = { + id: string; + name: string; + parentId: string | null; + description: string | null; + path: string; + createdAt: number; + updatedAt: number; +}; + +export async function listLocations(): Promise { + const { locations } = await apiFetch<{ locations: Location[] }>( + "/api/locations", + ); + return locations; +} + +export async function createLocation(input: { + name: string; + parentId?: string | null; + description?: string | null; +}): Promise { + const { location } = await apiFetch<{ location: Location }>( + "/api/locations", + { method: "POST", body: input }, + ); + return location; +} + +export async function updateLocation( + locationId: string, + input: { + name: string; + parentId?: string | null; + description?: string | null; + }, +): Promise { + const { location } = await apiFetch<{ location: Location }>( + `/api/locations/${locationId}`, + { method: "PATCH", body: input }, + ); + return location; +} + +export async function moveLocation( + locationId: string, + parentId: string | null, +): Promise { + const { location } = await apiFetch<{ location: Location }>( + `/api/locations/${locationId}/move`, + { method: "POST", body: { parentId } }, + ); + return location; +} + +export async function deleteLocation(locationId: string): Promise { + await apiFetch(`/api/locations/${locationId}`, { method: "DELETE" }); +} diff --git a/src/lib/api/search.ts b/src/lib/api/search.ts new file mode 100644 index 0000000..27e6042 --- /dev/null +++ b/src/lib/api/search.ts @@ -0,0 +1,20 @@ +import { apiFetch } from "@/lib/api-client"; + +export type SearchResult = { + id: string; + name: string; + assetTag: string; + status: string; + categoryId: string | null; + categoryName: string | null; + categoryColor: string | null; + locationPath: string | null; + score: number; +}; + +export async function searchAssets(query: string): Promise { + const { results } = await apiFetch<{ results: SearchResult[] }>( + `/api/search?q=${encodeURIComponent(query)}`, + ); + return results; +} diff --git a/src/lib/api/service-groups.ts b/src/lib/api/service-groups.ts new file mode 100644 index 0000000..4c23e3b --- /dev/null +++ b/src/lib/api/service-groups.ts @@ -0,0 +1,138 @@ +import { apiFetch } from "@/lib/api-client"; + +export type ServiceGroupSummary = { + id: string; + name: string; + description: string | null; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; + assetCount: number; + fieldCount: number; +}; + +export type AssignableGroup = { id: string; name: string }; + +export type ServiceGroupField = { + id: string; + groupId: string; + label: string; + fieldType: "text" | "textarea" | "number" | "date" | "checkbox" | "select"; + required: boolean; + options: string[]; + sortOrder: number; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +type GroupInput = { + name: string; + description?: string | null; +}; + +type FieldInput = { + label: string; + fieldType: ServiceGroupField["fieldType"]; + required: boolean; + options?: string[]; +}; + +export async function listServiceGroups(): Promise { + const { groups } = await apiFetch<{ groups: ServiceGroupSummary[] }>( + "/api/service-groups", + ); + return groups; +} + +export async function listAssignableServiceGroups(): Promise< + AssignableGroup[] +> { + const { groups } = await apiFetch<{ groups: AssignableGroup[] }>( + "/api/service-groups?variant=assignable", + ); + return groups; +} + +export async function getServiceGroup( + groupId: string, +): Promise { + const { group } = await apiFetch<{ group: ServiceGroupSummary | null }>( + `/api/service-groups/${groupId}`, + ); + return group; +} + +export async function createServiceGroup( + input: GroupInput, +): Promise { + const { group } = await apiFetch<{ group: ServiceGroupSummary }>( + "/api/service-groups", + { method: "POST", body: input }, + ); + return group; +} + +export async function updateServiceGroup( + groupId: string, + input: GroupInput, +): Promise { + const { group } = await apiFetch<{ group: ServiceGroupSummary }>( + `/api/service-groups/${groupId}`, + { method: "PATCH", body: input }, + ); + return group; +} + +export async function deleteServiceGroup(groupId: string): Promise { + await apiFetch(`/api/service-groups/${groupId}`, { method: "DELETE" }); +} + +export async function listServiceGroupFields( + groupId: string, +): Promise { + const { fields } = await apiFetch<{ fields: ServiceGroupField[] }>( + `/api/service-groups/${groupId}/fields`, + ); + return fields; +} + +export async function createServiceGroupField( + groupId: string, + input: FieldInput, +): Promise { + const { field } = await apiFetch<{ field: ServiceGroupField }>( + `/api/service-groups/${groupId}/fields`, + { method: "POST", body: input }, + ); + return field; +} + +export async function reorderServiceGroupFields( + groupId: string, + fieldIds: string[], +): Promise { + await apiFetch(`/api/service-groups/${groupId}/fields/reorder`, { + method: "POST", + body: { fieldIds }, + }); +} + +export async function updateServiceGroupField( + fieldId: string, + input: FieldInput, +): Promise { + const { field } = await apiFetch<{ field: ServiceGroupField }>( + `/api/service-group-fields/${fieldId}`, + { method: "PATCH", body: input }, + ); + return field; +} + +export async function deleteServiceGroupField( + fieldId: string, +): Promise { + await apiFetch(`/api/service-group-fields/${fieldId}`, { method: "DELETE" }); +} diff --git a/src/lib/api/service-providers.ts b/src/lib/api/service-providers.ts new file mode 100644 index 0000000..c85f47e --- /dev/null +++ b/src/lib/api/service-providers.ts @@ -0,0 +1,65 @@ +import { apiFetch } from "@/lib/api-client"; + +export type ServiceProvider = { + id: string; + name: string; + contactEmail: string | null; + contactPhone: string | null; + notes: string | null; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +export type ServiceProviderOption = { id: string; name: string }; + +type CreateInput = { + name: string; + contactEmail?: string | null; + contactPhone?: string | null; + notes?: string | null; +}; + +export async function listServiceProviders(): Promise { + const { providers } = await apiFetch<{ providers: ServiceProvider[] }>( + "/api/service-providers", + ); + return providers; +} + +export async function listServiceProviderOptions(): Promise< + ServiceProviderOption[] +> { + const { providers } = await apiFetch<{ providers: ServiceProviderOption[] }>( + "/api/service-providers?variant=options", + ); + return providers; +} + +export async function createServiceProvider( + input: CreateInput, +): Promise { + const { provider } = await apiFetch<{ provider: ServiceProvider }>( + "/api/service-providers", + { method: "POST", body: input }, + ); + return provider; +} + +export async function updateServiceProvider( + providerId: string, + input: CreateInput, +): Promise { + const { provider } = await apiFetch<{ provider: ServiceProvider }>( + `/api/service-providers/${providerId}`, + { method: "PATCH", body: input }, + ); + return provider; +} + +export async function deleteServiceProvider( + providerId: string, +): Promise { + await apiFetch(`/api/service-providers/${providerId}`, { method: "DELETE" }); +} diff --git a/src/lib/api/service-records.ts b/src/lib/api/service-records.ts new file mode 100644 index 0000000..f767bcd --- /dev/null +++ b/src/lib/api/service-records.ts @@ -0,0 +1,121 @@ +import { apiFetch } from "@/lib/api-client"; + +export type ServiceRecordValue = string | number | boolean | null; + +export type ServiceRecordFieldDefinition = { + id: string; + label: string; + fieldType: "text" | "textarea" | "number" | "date" | "checkbox" | "select"; + required: boolean; + options: string[]; + sortOrder: number; +}; + +export type ServiceRecordForm = { + assetId: string; + assetName: string; + assetTag: string; + serviceGroupId: string | null; + serviceGroupName: string | null; + fields: ServiceRecordFieldDefinition[]; +}; + +export type ServiceRecord = { + id: string; + assetId: string; + serviceGroupId: string | null; + serviceGroupName: string | null; + scheduleId: string | null; + scheduledForDate: string | null; + serviceDate: string | null; + description: string | null; + cost: number | null; + providerId: string | null; + providerName: string | null; + completedBy: string; + completedAt: number; + createdAt: number; + updatedAt: number; + valueEntries: Array<{ + fieldId: string; + label: string; + fieldType: string; + value: ServiceRecordValue; + }>; +}; + +type CreateInput = { + assetId: string; + serviceDate: string; + description: string; + cost?: number | null; + providerId?: string | null; + values?: Record; +}; + +type UpdateInput = { + serviceDate: string; + description: string; + cost?: number | null; + providerId?: string | null; + values?: Record; +}; + +type CompleteInput = { + scheduleId: string; + serviceDate: string; + description: string; + cost?: number | null; + providerId?: string | null; + values?: Record; +}; + +export async function getServiceRecordForm( + assetId: string, +): Promise { + const { form } = await apiFetch<{ form: ServiceRecordForm }>( + `/api/service-records/form/${assetId}`, + ); + return form; +} + +export async function listAssetServiceRecords( + assetId: string, +): Promise { + const { records } = await apiFetch<{ records: ServiceRecord[] }>( + `/api/service-records/by-asset/${assetId}`, + ); + return records; +} + +export async function createServiceRecord( + input: CreateInput, +): Promise<{ recordId: string }> { + return apiFetch<{ recordId: string }>("/api/service-records", { + method: "POST", + body: input, + }); +} + +export async function updateServiceRecord( + recordId: string, + input: UpdateInput, +): Promise { + await apiFetch(`/api/service-records/${recordId}`, { + method: "PATCH", + body: input, + }); +} + +export async function deleteServiceRecord(recordId: string): Promise { + await apiFetch(`/api/service-records/${recordId}`, { method: "DELETE" }); +} + +export async function completeScheduledService( + input: CompleteInput, +): Promise<{ recordId: string; nextServiceDate: string }> { + return apiFetch<{ recordId: string; nextServiceDate: string }>( + "/api/service-records/complete-scheduled", + { method: "POST", body: input }, + ); +} diff --git a/src/lib/api/service-schedules.ts b/src/lib/api/service-schedules.ts new file mode 100644 index 0000000..43ac73e --- /dev/null +++ b/src/lib/api/service-schedules.ts @@ -0,0 +1,100 @@ +import { apiFetch } from "@/lib/api-client"; + +import type { AssetStatus } from "./assets"; + +export const SERVICE_INTERVAL_UNITS = [ + "days", + "weeks", + "months", + "years", +] as const; +export type ServiceIntervalUnit = (typeof SERVICE_INTERVAL_UNITS)[number]; + +export type ServiceSchedule = { + id: string; + assetId: string; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; + reminderStartDate: string; + createdAt: number; + updatedAt: number; + createdBy: string; + updatedBy: string; +}; + +export type ScheduledAsset = { + scheduleId: string; + assetId: string; + assetName: string; + assetTag: string; + assetStatus: AssetStatus; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; + reminderStartDate: string; +}; + +type UpsertInput = { + assetId: string; + nextServiceDate: string; + intervalValue: number; + intervalUnit: ServiceIntervalUnit; + reminderLeadValue: number; + reminderLeadUnit: ServiceIntervalUnit; +}; + +export async function listScheduledAssets(): Promise { + const { schedules } = await apiFetch<{ schedules: ScheduledAsset[] }>( + "/api/service-schedules", + ); + return schedules; +} + +export async function upsertSchedule( + input: UpsertInput, +): Promise { + const { schedule } = await apiFetch<{ schedule: ServiceSchedule }>( + "/api/service-schedules", + { method: "PUT", body: input }, + ); + return schedule; +} + +export async function getScheduleByAssetId( + assetId: string, +): Promise { + const { schedule } = await apiFetch<{ schedule: ServiceSchedule | null }>( + `/api/service-schedules/by-asset/${assetId}`, + ); + return schedule; +} + +export async function deleteSchedule(assetId: string): Promise { + await apiFetch(`/api/service-schedules/by-asset/${assetId}`, { + method: "DELETE", + }); +} + +export async function listCalendarMonth( + year: number, + month: number, +): Promise { + const { schedules } = await apiFetch<{ schedules: ScheduledAsset[] }>( + `/api/service-schedules/calendar?year=${year}&month=${month}`, + ); + return schedules; +} + +export async function listUpcomingServices( + days: number, +): Promise { + const { schedules } = await apiFetch<{ schedules: ScheduledAsset[] }>( + `/api/service-schedules/upcoming?days=${days}`, + ); + return schedules; +} diff --git a/src/lib/api/tags.ts b/src/lib/api/tags.ts new file mode 100644 index 0000000..2dffb28 --- /dev/null +++ b/src/lib/api/tags.ts @@ -0,0 +1,40 @@ +import { apiFetch } from "@/lib/api-client"; + +export type Tag = { + id: string; + name: string; + color: string; + createdAt: number; + updatedAt: number; +}; + +export async function listTags(): Promise { + const { tags } = await apiFetch<{ tags: Tag[] }>("/api/tags"); + return tags; +} + +export async function createTag(input: { + name: string; + color: string; +}): Promise { + const { tag } = await apiFetch<{ tag: Tag }>("/api/tags", { + method: "POST", + body: input, + }); + return tag; +} + +export async function updateTag( + tagId: string, + input: { name: string; color: string }, +): Promise { + const { tag } = await apiFetch<{ tag: Tag }>(`/api/tags/${tagId}`, { + method: "PATCH", + body: input, + }); + return tag; +} + +export async function deleteTag(tagId: string): Promise { + await apiFetch(`/api/tags/${tagId}`, { method: "DELETE" }); +} diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts new file mode 100644 index 0000000..1f07c2c --- /dev/null +++ b/src/lib/api/users.ts @@ -0,0 +1,49 @@ +import { apiFetch } from "@/lib/api-client"; + +export type User = { + id: string; + email: string; + name: string; + role: "admin" | "user"; + createdBy: string | null; + createdAt: number; +}; + +export async function listUsers(): Promise { + const { users } = await apiFetch<{ users: User[] }>("/api/users"); + return users; +} + +export async function createUser(input: { + email: string; + name: string; + password: string; + role: "admin" | "user"; +}): Promise { + const { user } = await apiFetch<{ user: User }>("/api/users", { + method: "POST", + body: input, + }); + return user; +} + +export async function updateUserRole( + userId: string, + role: "admin" | "user", +): Promise { + const { user } = await apiFetch<{ user: User }>(`/api/users/${userId}/role`, { + method: "PATCH", + body: { role }, + }); + return user; +} + +export async function changePassword(input: { + currentPassword: string; + newPassword: string; +}): Promise { + await apiFetch("/api/users/me/password", { + method: "POST", + body: input, + }); +} From a5f3719746d2f215dbdf8b9f0eddbf7a9d0f6352 Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 11:02:31 +0200 Subject: [PATCH 19/37] Convert categories + tags pages to TanStack Query + /api/** - Fill in the missing updateCategory and deleteCategory PB domain functions (delete-in-use guard matches Convex semantics) and wire them into /api/categories/[id] (PATCH, DELETE). +5 tests, PB suite now 209 / 21. - Expose updateCategory / deleteCategory on the categories API client. - categories-page-client + tags-page-client rebuilt on TanStack Query hooks (useCurrentUser, listCategories, useMutation onSuccess invalidation). No more Convex react hooks in either file. - TaxonomyManager now consumes `id` instead of `_id` (PB convention). - Test suites mock @/lib/api/{categories,tags,auth} directly and wrap in a fresh QueryClient per test. Main suite: 440/440 green; typecheck clean; lint 0 errors. --- .../categories-page-client.test.tsx | 109 +++++++++--------- .../components/tags/tags-page-client.test.tsx | 104 ++++++++--------- src/app/api/categories/[id]/route.ts | 30 +++++ src/components/catalog/taxonomy-manager.tsx | 20 ++-- .../categories/categories-page-client.tsx | 60 +++++++--- src/components/tags/tags-page-client.tsx | 48 +++++--- src/lib/api/categories.ts | 20 ++++ .../domain/__tests__/categories.test.ts | 83 ++++++++++++- src/server/domain/categories.ts | 87 +++++++++++++- 9 files changed, 407 insertions(+), 154 deletions(-) create mode 100644 src/app/api/categories/[id]/route.ts diff --git a/src/__tests__/components/categories/categories-page-client.test.tsx b/src/__tests__/components/categories/categories-page-client.test.tsx index 9a6a161..0bdc540 100644 --- a/src/__tests__/components/categories/categories-page-client.test.tsx +++ b/src/__tests__/components/categories/categories-page-client.test.tsx @@ -1,12 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const mockUseQuery = vi.fn(); -const mockUseMutation = vi.fn(); +const listCategoriesMock = vi.fn(); +const getCurrentUserMock = vi.fn(); + +vi.mock("@/lib/api/categories", () => ({ + listCategories: () => listCategoriesMock(), + createCategory: vi.fn(), + updateCategory: vi.fn(), + deleteCategory: vi.fn(), +})); -vi.mock("convex/react", () => ({ - useQuery: (...args: unknown[]) => mockUseQuery(...args), - useMutation: (...args: unknown[]) => mockUseMutation(...args), +vi.mock("@/lib/api/auth", () => ({ + getCurrentUser: () => getCurrentUserMock(), })); vi.mock("next/link", () => ({ @@ -22,68 +29,60 @@ vi.mock("next/link", () => ({ })); vi.mock("sonner", () => ({ - toast: { - error: vi.fn(), - success: vi.fn(), - }, + toast: { error: vi.fn(), success: vi.fn() }, })); import { CategoriesPageClient } from "@/components/categories/categories-page-client"; +function renderWithClient() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + describe("CategoriesPageClient", () => { beforeEach(() => { - mockUseQuery.mockReset(); - mockUseMutation.mockReset(); - mockUseMutation.mockReturnValue(vi.fn()); + listCategoriesMock.mockReset(); + getCurrentUserMock.mockReset(); + getCurrentUserMock.mockResolvedValue({ + id: "u1", + email: "a@x.com", + name: "Admin", + role: "admin", + }); }); - it("shows loading state when data is undefined", () => { - mockUseQuery.mockReturnValue(undefined); - - render(); - + it("shows loading state while queries are in flight", () => { + listCategoriesMock.mockImplementation(() => new Promise(() => {})); + renderWithClient(); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); - it("renders category list when data is loaded", () => { - let callIndex = 0; - mockUseQuery.mockImplementation(() => { - callIndex++; - // First call: getCurrentUser, second call: listCategories - if (callIndex % 2 === 1) { - return { _id: "user1" as never, role: "admin" }; - } - return [ - { - _id: "cat1" as never, - name: "Laptops", - color: "#EA580C", - prefix: "LAP", - description: "Portable computers", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - }); - - render(); - + it("renders category list when data is loaded", async () => { + listCategoriesMock.mockResolvedValue([ + { + id: "cat1", + name: "Laptops", + color: "#EA580C", + prefix: "LAP", + description: "Portable computers", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ]); + renderWithClient(); + expect(await screen.findByText("Laptops")).toBeInTheDocument(); expect(screen.getByText("Categories")).toBeInTheDocument(); - expect(screen.getByText("Laptops")).toBeInTheDocument(); }); - it("shows empty state when no categories exist", () => { - let callIndex = 0; - mockUseQuery.mockImplementation(() => { - callIndex++; - if (callIndex % 2 === 1) { - return { _id: "user1" as never, role: "admin" }; - } - return []; - }); - - render(); - - expect(screen.getByText("No categories yet.")).toBeInTheDocument(); + it("shows empty state when no categories exist", async () => { + listCategoriesMock.mockResolvedValue([]); + renderWithClient(); + expect(await screen.findByText("No categories yet.")).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/tags/tags-page-client.test.tsx b/src/__tests__/components/tags/tags-page-client.test.tsx index 0853c2c..3a2c34d 100644 --- a/src/__tests__/components/tags/tags-page-client.test.tsx +++ b/src/__tests__/components/tags/tags-page-client.test.tsx @@ -1,12 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const mockUseQuery = vi.fn(); -const mockUseMutation = vi.fn(); +const listTagsMock = vi.fn(); +const getCurrentUserMock = vi.fn(); + +vi.mock("@/lib/api/tags", () => ({ + listTags: () => listTagsMock(), + createTag: vi.fn(), + updateTag: vi.fn(), + deleteTag: vi.fn(), +})); -vi.mock("convex/react", () => ({ - useQuery: (...args: unknown[]) => mockUseQuery(...args), - useMutation: (...args: unknown[]) => mockUseMutation(...args), +vi.mock("@/lib/api/auth", () => ({ + getCurrentUser: () => getCurrentUserMock(), })); vi.mock("next/link", () => ({ @@ -22,65 +29,58 @@ vi.mock("next/link", () => ({ })); vi.mock("sonner", () => ({ - toast: { - error: vi.fn(), - success: vi.fn(), - }, + toast: { error: vi.fn(), success: vi.fn() }, })); import { TagsPageClient } from "@/components/tags/tags-page-client"; +function renderWithClient() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + describe("TagsPageClient", () => { beforeEach(() => { - mockUseQuery.mockReset(); - mockUseMutation.mockReset(); - mockUseMutation.mockReturnValue(vi.fn()); + listTagsMock.mockReset(); + getCurrentUserMock.mockReset(); + getCurrentUserMock.mockResolvedValue({ + id: "u1", + email: "a@x.com", + name: "Admin", + role: "admin", + }); }); - it("shows loading state when data is undefined", () => { - mockUseQuery.mockReturnValue(undefined); - - render(); - + it("shows loading state while queries are in flight", () => { + listTagsMock.mockImplementation(() => new Promise(() => {})); + renderWithClient(); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); - it("renders tag list when data is loaded", () => { - let callIndex = 0; - mockUseQuery.mockImplementation(() => { - callIndex++; - if (callIndex % 2 === 1) { - return { _id: "user1" as never, role: "admin" }; - } - return [ - { - _id: "tag1" as never, - name: "Fragile", - color: "#DC2626", - createdAt: Date.now(), - updatedAt: Date.now(), - }, - ]; - }); - - render(); - + it("renders tag list when data is loaded", async () => { + listTagsMock.mockResolvedValue([ + { + id: "tag1", + name: "Fragile", + color: "#DC2626", + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ]); + renderWithClient(); + expect(await screen.findByText("Fragile")).toBeInTheDocument(); expect(screen.getByText("Tags")).toBeInTheDocument(); - expect(screen.getByText("Fragile")).toBeInTheDocument(); }); - it("shows empty state when no tags exist", () => { - let callIndex = 0; - mockUseQuery.mockImplementation(() => { - callIndex++; - if (callIndex % 2 === 1) { - return { _id: "user1" as never, role: "admin" }; - } - return []; - }); - - render(); - - expect(screen.getByText("No tags yet.")).toBeInTheDocument(); + it("shows empty state when no tags exist", async () => { + listTagsMock.mockResolvedValue([]); + renderWithClient(); + expect(await screen.findByText("No tags yet.")).toBeInTheDocument(); }); }); diff --git a/src/app/api/categories/[id]/route.ts b/src/app/api/categories/[id]/route.ts new file mode 100644 index 0000000..15631a7 --- /dev/null +++ b/src/app/api/categories/[id]/route.ts @@ -0,0 +1,30 @@ +import { parseJsonBody, withAdmin } from "@/server/auth/route"; +import { + UpdateCategoryInput, + deleteCategory, + updateCategory, +} from "@/server/domain/categories"; + +const ClientUpdateCategory = UpdateCategoryInput.omit({ categoryId: true }); + +export const PATCH = withAdmin( + "api/categories/[id]", + async (req, session, _user, { params }) => { + const { id } = await params; + const body = ClientUpdateCategory.parse(await parseJsonBody(req)); + const category = await updateCategory(session.ctx, { + ...body, + categoryId: id, + }); + return { category }; + }, +); + +export const DELETE = withAdmin( + "api/categories/[id]", + async (_req, session, _user, { params }) => { + const { id } = await params; + await deleteCategory(session.ctx, id); + return { ok: true }; + }, +); diff --git a/src/components/catalog/taxonomy-manager.tsx b/src/components/catalog/taxonomy-manager.tsx index cfc3ab6..5a8c61c 100644 --- a/src/components/catalog/taxonomy-manager.tsx +++ b/src/components/catalog/taxonomy-manager.tsx @@ -26,7 +26,7 @@ export type TaxonomyFormValues = { }; type TaxonomyRowBase = { - _id: string; + id: string; name: string; color: string; createdAt: number; @@ -96,7 +96,7 @@ export function TaxonomyManager({ const [deleting, setDeleting] = useState(false); const rowsById = useMemo( - () => new Map(rows.map((row) => [row._id, row])), + () => new Map(rows.map((row) => [row.id, row])), [rows], ); const activeDeleteRow = deleteId ? (rowsById.get(deleteId) ?? null) : null; @@ -220,12 +220,12 @@ export function TaxonomyManager({ const catRow = row as CategoryRow; return (

- openEdit(row._id)}> + openEdit(row.id)}> Edit setDeleteId(row._id)} + onClick={() => setDeleteId(row.id)} > Delete @@ -281,11 +281,11 @@ export function TaxonomyManager({
{rows.map((row) => (
openEdit(row._id)} + onClick={() => openEdit(row.id)} aria-label={`Edit ${row.name}`} > @@ -307,7 +307,7 @@ export function TaxonomyManager({
- + {/* Tabbed content */}
@@ -177,7 +177,7 @@ export function AssetDetail({
{asset.tags.map((tag) => ( - +
diff --git a/src/components/assets/asset-edit-page-client.tsx b/src/components/assets/asset-edit-page-client.tsx index e77ff62..036e8a3 100644 --- a/src/components/assets/asset-edit-page-client.tsx +++ b/src/components/assets/asset-edit-page-client.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { AttachmentsPanel } from "@/components/attachments/attachments-panel"; import { @@ -20,8 +20,18 @@ import type { } from "@/components/assets/types"; import type { FieldDefinition } from "@/components/fields/types"; import { Button } from "@/components/ui/button"; -import type { Id } from "@/lib/convex-api"; -import { api } from "@/lib/convex-api"; +import { getAppSettings } from "@/lib/api/app-settings"; +import { + getAsset, + getAssetFilterOptions, + updateAsset, +} from "@/lib/api/assets"; +import { listCustomFields } from "@/lib/api/custom-fields"; +import { + deleteSchedule, + getScheduleByAssetId, + upsertSchedule, +} from "@/lib/api/service-schedules"; function toFormValues(asset: AssetDetail): AssetFormValues { return { @@ -32,23 +42,34 @@ function toFormValues(asset: AssetDetail): AssetFormValues { status: asset.status, notes: asset.notes ?? "", customFieldValues: asset.customFieldValues, - tagIds: asset.tags.map((tag) => tag._id), + tagIds: asset.tags.map((tag) => tag.id), }; } -export function AssetEditPageClient({ assetId }: { assetId: Id<"assets"> }) { +export function AssetEditPageClient({ assetId }: { assetId: string }) { const router = useRouter(); - const updateAsset = useMutation(api.assets.updateAsset); - const upsertSchedule = useMutation(api.serviceSchedules.upsertSchedule); - const deleteSchedule = useMutation(api.serviceSchedules.deleteSchedule); - - const asset = useQuery(api.assets.getAsset, { assetId }); - const filterOptions = useQuery(api.assets.getAssetFilterOptions, {}); - const fieldDefinitions = useQuery(api.customFields.listFieldDefinitions, {}); - const existingSchedule = useQuery(api.serviceSchedules.getScheduleByAssetId, { - assetId, + const queryClient = useQueryClient(); + + const assetQuery = useQuery({ + queryKey: ["assets", "detail", assetId], + queryFn: () => getAsset(assetId), + }); + const filterOptionsQuery = useQuery({ + queryKey: ["assets", "filter-options"], + queryFn: getAssetFilterOptions, + }); + const fieldsQuery = useQuery({ + queryKey: ["custom-fields", "list"], + queryFn: listCustomFields, + }); + const scheduleQuery = useQuery({ + queryKey: ["service-schedules", "by-asset", assetId], + queryFn: () => getScheduleByAssetId(assetId), + }); + const appSettingsQuery = useQuery({ + queryKey: ["app-settings"], + queryFn: getAppSettings, }); - const appSettings = useQuery(api.appSettings.getAppSettings, {}); const [submitting, setSubmitting] = useState(false); const [serviceScheduleDraft, setServiceScheduleDraft] = @@ -56,37 +77,35 @@ export function AssetEditPageClient({ assetId }: { assetId: Id<"assets"> }) { const [scheduleSeeded, setScheduleSeeded] = useState(false); const loading = - asset === undefined || - filterOptions === undefined || - fieldDefinitions === undefined || - existingSchedule === undefined || - appSettings === undefined; + assetQuery.isPending || + filterOptionsQuery.isPending || + fieldsQuery.isPending || + scheduleQuery.isPending || + appSettingsQuery.isPending; - const options = (filterOptions ?? { + const options: AssetFilterOptions = filterOptionsQuery.data ?? { categories: [], locations: [], tags: [], serviceGroups: [], - }) as AssetFilterOptions; + }; const customFieldDefinitions = useMemo( - () => (fieldDefinitions ?? []) as unknown as FieldDefinition[], - [fieldDefinitions], + () => (fieldsQuery.data ?? []) as unknown as FieldDefinition[], + [fieldsQuery.data], ); - const detail = (asset ?? null) as AssetDetail | null; + const detail = (assetQuery.data ?? null) as AssetDetail | null; const initialValues = useMemo( () => (detail ? toFormValues(detail) : null), [detail], ); const serviceSchedulingEnabled = - appSettings?.serviceSchedulingEnabled ?? true; + appSettingsQuery.data?.serviceSchedulingEnabled ?? true; + const existingSchedule = scheduleQuery.data ?? null; - // One-time seed: populate the service schedule draft from the async query - // result. The `scheduleSeeded` flag ensures we only set the draft once so - // subsequent user edits are not overwritten by reactive query updates. useEffect(() => { - if (existingSchedule === undefined || scheduleSeeded) { + if (scheduleQuery.isPending || scheduleSeeded) { return; } @@ -104,7 +123,12 @@ export function AssetEditPageClient({ assetId }: { assetId: Id<"assets"> }) { reminderLeadUnit: existingSchedule.reminderLeadUnit, }); setScheduleSeeded(true); - }, [existingSchedule, scheduleSeeded]); + }, [existingSchedule, scheduleQuery.isPending, scheduleSeeded]); + + const updateMutation = useMutation({ + mutationFn: (input: Parameters[1]) => + updateAsset(assetId, input), + }); async function handleSubmit(values: AssetFormValues) { const parsedSchedule = serviceSchedulingEnabled @@ -119,8 +143,7 @@ export function AssetEditPageClient({ assetId }: { assetId: Id<"assets"> }) { setSubmitting(true); try { const promises: Promise[] = [ - updateAsset({ - assetId, + updateMutation.mutateAsync({ name: values.name, categoryId: values.categoryId, locationId: values.locationId, @@ -145,12 +168,20 @@ export function AssetEditPageClient({ assetId }: { assetId: Id<"assets"> }) { }), ); } else if (existingSchedule) { - promises.push(deleteSchedule({ assetId })); + promises.push(deleteSchedule(assetId)); } } await Promise.all(promises); + void queryClient.invalidateQueries({ + queryKey: ["assets", "detail", assetId], + }); + void queryClient.invalidateQueries({ queryKey: ["assets", "list"] }); + void queryClient.invalidateQueries({ + queryKey: ["service-schedules", "by-asset", assetId], + }); + toast.success("Asset updated"); router.push(`/assets/${assetId}`); } catch (error) { diff --git a/src/components/assets/asset-filters.tsx b/src/components/assets/asset-filters.tsx index 00be55c..e5c98c3 100644 --- a/src/components/assets/asset-filters.tsx +++ b/src/components/assets/asset-filters.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import { Check, ChevronDown, Filter, Search, X } from "lucide-react"; -import type { Id } from "@/lib/convex-api"; import { ASSET_STATUS_LABELS, ASSET_STATUS_OPTIONS, @@ -44,17 +43,17 @@ import { import { cn } from "@/lib/utils"; export type AssetFiltersState = { - categoryId: Id<"categories"> | null; + categoryId: string | null; status: AssetStatus | null; - locationId: Id<"locations"> | null; - tagIds: Id<"tags">[]; + locationId: string | null; + tagIds: string[]; }; -function removeTagId(tagIds: Id<"tags">[], tagId: Id<"tags">) { +function removeTagId(tagIds: string[], tagId: string) { return tagIds.filter((candidate) => candidate !== tagId); } -function addTagId(tagIds: Id<"tags">[], tagId: Id<"tags">) { +function addTagId(tagIds: string[], tagId: string) { if (tagIds.includes(tagId)) { return tagIds; } @@ -72,8 +71,8 @@ function TagsPicker({ id?: string; labelledBy?: string; tags: AssetFilterOptions["tags"]; - selectedIds: Id<"tags">[]; - onChange: (tagIds: Id<"tags">[]) => void; + selectedIds: string[]; + onChange: (tagIds: string[]) => void; }) { const [open, setOpen] = useState(false); const selectedCount = selectedIds.length; @@ -96,7 +95,7 @@ function TagsPicker({ {selectedCount === 0 ? "All tags" : selectedCount === 1 - ? (tags.find((t) => t._id === selectedIds[0])?.name ?? "1 tag") + ? (tags.find((t) => t.id === selectedIds[0])?.name ?? "1 tag") : `${selectedCount} tags`} @@ -112,16 +111,16 @@ function TagsPicker({ No tags found. {tags.map((tag) => { - const checked = selectedIds.includes(tag._id); + const checked = selectedIds.includes(tag.id); return ( onChange( checked - ? removeTagId(selectedIds, tag._id) - : addTagId(selectedIds, tag._id), + ? removeTagId(selectedIds, tag.id) + : addTagId(selectedIds, tag.id), ) } > @@ -263,8 +262,7 @@ export function AssetFilters({ onValueChange={(value) => onFiltersChange({ ...filters, - categoryId: - value === "__all__" ? null : (value as Id<"categories">), + categoryId: value === "__all__" ? null : value, }) } > @@ -274,7 +272,7 @@ export function AssetFilters({ All categories {options.categories.map((category) => ( - + {category.name} ))} @@ -316,7 +314,7 @@ export function AssetFilters({ {filters.categoryId ? ( Category:{" "} - {options.categories.find((c) => c._id === filters.categoryId) + {options.categories.find((c) => c.id === filters.categoryId) ?.name ?? "Unknown"}
diff --git a/src/components/settings/regional-settings-section.tsx b/src/components/settings/regional-settings-section.tsx index b7d7042..3323387 100644 --- a/src/components/settings/regional-settings-section.tsx +++ b/src/components/settings/regional-settings-section.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Loader2 } from "lucide-react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -18,49 +18,48 @@ import { formatDateFromTimestamp, type AppDateFormat, } from "@/lib/date-format"; -import { getConvexErrorCode, getConvexUiMessage } from "@/lib/convex-errors"; -import { api } from "@/lib/convex-api"; +import { ApiRequestError } from "@/lib/api-client"; +import { getAppSettings, setDateFormat } from "@/lib/api/app-settings"; function mapSettingsError(error: unknown, fallback: string) { - const code = getConvexErrorCode(error); - if (code === "FORBIDDEN") { - return "Only admins can update settings."; - } - if (code === "INVALID_DATE_FORMAT") { - return "Select one of the supported date formats."; + if (error instanceof ApiRequestError) { + if (error.status === 403) { + return "Only admins can update settings."; + } + return error.message || fallback; } - return getConvexUiMessage(error, fallback); + return fallback; } export function RegionalSettingsSection() { - const settings = useQuery(api.appSettings.getAppSettings, {}); - const updateDateFormat = useMutation(api.appSettings.updateDateFormat); + const queryClient = useQueryClient(); + const settingsQuery = useQuery({ + queryKey: ["app-settings"], + queryFn: getAppSettings, + }); + + const mutation = useMutation({ + mutationFn: (format: AppDateFormat) => setDateFormat(format), + onSuccess: () => { + setSelectedFormat(null); + toast.success("Date format updated"); + void queryClient.invalidateQueries({ queryKey: ["app-settings"] }); + }, + onError: (error) => { + toast.error(mapSettingsError(error, "Unable to update date format")); + }, + }); const [selectedFormat, setSelectedFormat] = useState( null, ); - const [saving, setSaving] = useState(false); const persistedFormat = - (settings?.dateFormat as AppDateFormat | undefined) ?? + (settingsQuery.data?.dateFormat as AppDateFormat | undefined) ?? DEFAULT_APP_DATE_FORMAT; const effectiveFormat = selectedFormat ?? persistedFormat; - const hasChanges = - settings !== undefined && effectiveFormat !== persistedFormat; - - async function handleSave() { - setSaving(true); - try { - await updateDateFormat({ dateFormat: effectiveFormat }); - setSelectedFormat(null); - toast.success("Date format updated"); - } catch (error) { - toast.error(mapSettingsError(error, "Unable to update date format")); - } finally { - setSaving(false); - } - } + !settingsQuery.isPending && effectiveFormat !== persistedFormat; return (
@@ -71,7 +70,7 @@ export function RegionalSettingsSection() {

- {settings === undefined ? ( + {settingsQuery.isPending ? (
Loading preferences...
@@ -84,7 +83,7 @@ export function RegionalSettingsSection() { onValueChange={(value) => setSelectedFormat(value as AppDateFormat) } - disabled={saving} + disabled={mutation.isPending} > @@ -109,12 +108,12 @@ export function RegionalSettingsSection() { )}
- {settings.updatedAt ? ( + {settingsQuery.data?.updatedAt ? (
Last updated:{" "} {formatDateFromTimestamp( - settings.updatedAt, - settings.dateFormat as AppDateFormat, + settingsQuery.data.updatedAt, + settingsQuery.data.dateFormat as AppDateFormat, )}
) : ( @@ -125,10 +124,12 @@ export function RegionalSettingsSection() { diff --git a/src/components/settings/settings-page-client.tsx b/src/components/settings/settings-page-client.tsx index 1ff64ad..a84b367 100644 --- a/src/components/settings/settings-page-client.tsx +++ b/src/components/settings/settings-page-client.tsx @@ -1,16 +1,15 @@ "use client"; -import { useQuery } from "convex/react"; import { FeaturesSection } from "@/components/settings/features-section"; import { PasswordChangeSection } from "@/components/settings/password-change-section"; import { RegionalSettingsSection } from "@/components/settings/regional-settings-section"; import { UserManagementSection } from "@/components/settings/user-management-section"; -import { api } from "@/lib/convex-api"; +import { useCurrentUser } from "@/hooks/use-current-user"; export function SettingsPageClient() { - const currentUser = useQuery(api.users.getCurrentUser, {}); + const { data: currentUser, isPending } = useCurrentUser(); - if (currentUser === undefined) { + if (isPending) { return (
@@ -36,7 +35,7 @@ export function SettingsPageClient() { <> - + ) : (
diff --git a/src/components/settings/user-management-section.tsx b/src/components/settings/user-management-section.tsx index 0ebffe7..42c5e85 100644 --- a/src/components/settings/user-management-section.tsx +++ b/src/components/settings/user-management-section.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Loader2, Plus } from "lucide-react"; -import { useAction, useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { CrudModal } from "@/components/crud/modal"; import { Button } from "@/components/ui/button"; @@ -14,8 +14,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { api, type Id } from "@/lib/convex-api"; -import { getConvexUiMessage } from "@/lib/convex-errors"; +import { ApiRequestError } from "@/lib/api-client"; +import { createUser, listUsers, updateUserRole } from "@/lib/api/users"; import { formatDateFromTimestamp, type AppDateFormat } from "@/lib/date-format"; import { useAppDateFormat } from "@/lib/use-app-date-format"; @@ -38,7 +38,13 @@ function formatCreatedDate(timestamp: number, format: AppDateFormat) { } function normalizeErrorMessage(error: unknown, fallback: string) { - return getConvexUiMessage(error, fallback); + if (error instanceof ApiRequestError) { + return error.message || fallback; + } + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; } function normalizeRoleUpdateErrorMessage(error: unknown) { @@ -54,25 +60,57 @@ function normalizeRoleUpdateErrorMessage(error: unknown) { export function UserManagementSection({ currentUserId, }: { - currentUserId: Id<"users">; + currentUserId: string; }) { const dateFormat = useAppDateFormat(); - const users = useQuery(api.users.listUsers, {}); - const createUser = useAction(api.users.createUser); - const updateUserRole = useMutation(api.users.updateUserRole); + const queryClient = useQueryClient(); + + const usersQuery = useQuery({ + queryKey: ["users"], + queryFn: listUsers, + }); + + const createMutation = useMutation({ + mutationFn: createUser, + onSuccess: () => { + toast.success("User created"); + setCreateForm(INITIAL_CREATE_USER_FORM); + setIsDialogOpen(false); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (error) => { + toast.error(normalizeErrorMessage(error, "Unable to create user")); + }, + }); + + const updateRoleMutation = useMutation({ + mutationFn: ({ + userId, + role, + }: { + userId: string; + role: "admin" | "user"; + }) => updateUserRole(userId, role), + onSuccess: () => { + toast.success("User role updated"); + void queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (error) => { + toast.error(normalizeRoleUpdateErrorMessage(error)); + }, + }); const [isDialogOpen, setIsDialogOpen] = useState(false); const [createForm, setCreateForm] = useState( INITIAL_CREATE_USER_FORM, ); - const [creating, setCreating] = useState(false); const [roleEdits, setRoleEdits] = useState>( {}, ); const [savingRoleUserId, setSavingRoleUserId] = useState(null); - const rows = useMemo(() => users ?? [], [users]); + const rows = useMemo(() => usersQuery.data ?? [], [usersQuery.data]); const adminCount = useMemo( () => rows.filter((user) => user.role === "admin").length, [rows], @@ -83,7 +121,7 @@ export function UserManagementSection({ } function closeDialog() { - if (creating) { + if (createMutation.isPending) { return; } setIsDialogOpen(false); @@ -98,27 +136,16 @@ export function UserManagementSection({ return; } - setCreating(true); - try { - await createUser({ - email: createForm.email, - name: createForm.name, - password: createForm.password, - role: createForm.role, - }); - toast.success("User created"); - setCreateForm(INITIAL_CREATE_USER_FORM); - setIsDialogOpen(false); - } catch (error) { - const message = normalizeErrorMessage(error, "Unable to create user"); - toast.error(message); - } finally { - setCreating(false); - } + createMutation.mutate({ + email: createForm.email, + name: createForm.name, + password: createForm.password, + role: createForm.role, + }); } async function handleSaveRole( - userId: Id<"users">, + userId: string, currentRole: "admin" | "user", ) { const nextRole = roleEdits[userId] ?? currentRole; @@ -127,9 +154,9 @@ export function UserManagementSection({ } if (currentRole === "admin" && nextRole !== "admin" && adminCount <= 1) { - const message = - "You can't remove the last admin. Promote another user to admin first."; - toast.error(message); + toast.error( + "You can't remove the last admin. Promote another user to admin first.", + ); setRoleEdits((prev) => { const next = { ...prev }; delete next[userId]; @@ -140,16 +167,12 @@ export function UserManagementSection({ setSavingRoleUserId(userId); try { - await updateUserRole({ userId, role: nextRole }); - toast.success("User role updated"); + await updateRoleMutation.mutateAsync({ userId, role: nextRole }); setRoleEdits((prev) => { const next = { ...prev }; delete next[userId]; return next; }); - } catch (error) { - const message = normalizeRoleUpdateErrorMessage(error); - toast.error(message); } finally { setSavingRoleUserId(null); } @@ -195,7 +218,7 @@ export function UserManagementSection({ - {rows === undefined ? null : rows.length === 0 ? ( + {usersQuery.isPending ? null : rows.length === 0 ? ( ) : ( rows.map((user) => { - const pendingRole = roleEdits[user._id] ?? user.role; + const pendingRole = roleEdits[user.id] ?? user.role; const roleChanged = pendingRole !== user.role; - const isSaving = savingRoleUserId === user._id; + const isSaving = savingRoleUserId === user.id; return ( - +
{user.name}
- {user._id === currentUserId ? ( + {user.id === currentUserId ? (
You
) : null} @@ -226,7 +249,7 @@ export function UserManagementSection({ onValueChange={(value) => setRoleEdits((prev) => ({ ...prev, - [user._id]: value as "admin" | "user", + [user.id]: value as "admin" | "user", })) } disabled={isSaving} @@ -253,7 +276,7 @@ export function UserManagementSection({ size="sm" className="cursor-pointer" disabled={!roleChanged || isSaving} - onClick={() => void handleSaveRole(user._id, user.role)} + onClick={() => void handleSaveRole(user.id, user.role)} > {isSaving ? ( @@ -281,7 +304,7 @@ export function UserManagementSection({ variant="outline" className="cursor-pointer" onClick={closeDialog} - disabled={creating} + disabled={createMutation.isPending} > Cancel @@ -289,10 +312,12 @@ export function UserManagementSection({ form="create-user-form" type="submit" className="cursor-pointer" - disabled={creating} + disabled={createMutation.isPending} > - {creating ? : null} - {creating ? "Creating..." : "Create user"} + {createMutation.isPending ? ( + + ) : null} + {createMutation.isPending ? "Creating..." : "Create user"} } From 60aca2fc63aac58f0f1db232a7077faf38fd263a Mon Sep 17 00:00:00 2001 From: Sauer Date: Mon, 20 Apr 2026 14:32:41 +0200 Subject: [PATCH 28/37] Port labels UI to PocketBase + TanStack Query --- .../components/labels/label-preview.test.tsx | 2 +- .../labels/label-print-page-client.test.tsx | 156 +++++++-------- .../labels/labels-page-client.test.tsx | 178 ++++++++++-------- .../labels/template-designer.test.tsx | 45 +++-- src/app/api/assets/for-labels/route.ts | 9 + src/app/api/assets/label-preview/route.ts | 10 + .../label-templates/ensure-defaults/route.ts | 10 + src/app/api/label-templates/url-base/route.ts | 9 + src/components/labels/element-properties.tsx | 6 +- src/components/labels/helpers.ts | 12 +- src/components/labels/label-preview.tsx | 2 +- .../labels/label-print-page-client.tsx | 148 +++++++++------ src/components/labels/label-print.tsx | 2 +- src/components/labels/labels-page-client.tsx | 90 +++++---- src/components/labels/template-canvas.tsx | 2 +- src/components/labels/template-designer.tsx | 103 ++++++---- src/components/labels/types.ts | 15 +- src/lib/api/assets.ts | 29 +++ src/lib/api/label-templates.ts | 16 ++ src/server/domain/assets.ts | 79 ++++++++ 20 files changed, 596 insertions(+), 327 deletions(-) create mode 100644 src/app/api/assets/for-labels/route.ts create mode 100644 src/app/api/assets/label-preview/route.ts create mode 100644 src/app/api/label-templates/ensure-defaults/route.ts create mode 100644 src/app/api/label-templates/url-base/route.ts diff --git a/src/__tests__/components/labels/label-preview.test.tsx b/src/__tests__/components/labels/label-preview.test.tsx index e6db9a3..3761d89 100644 --- a/src/__tests__/components/labels/label-preview.test.tsx +++ b/src/__tests__/components/labels/label-preview.test.tsx @@ -64,7 +64,7 @@ const template: Pick = { }; const asset: LabelPreviewAsset = { - _id: "asset-1" as never, + id: "asset-1", name: "Main winch", assetTag: "WIN-001", categoryName: "Deck gear", diff --git a/src/__tests__/components/labels/label-print-page-client.test.tsx b/src/__tests__/components/labels/label-print-page-client.test.tsx index 84aacf6..e10e132 100644 --- a/src/__tests__/components/labels/label-print-page-client.test.tsx +++ b/src/__tests__/components/labels/label-print-page-client.test.tsx @@ -1,16 +1,34 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; -import { getFunctionName } from "convex/server"; -import { LabelPrintPageClient } from "@/components/labels/label-print-page-client"; -const mockUseQuery = vi.fn(); -const mockUseMutation = vi.fn(); let currentSearchParams = new URLSearchParams("assetId=asset-1"); let mockBarcodeState: "loading" | "ready" | "error" = "loading"; -vi.mock("convex/react", () => ({ - useQuery: (...args: unknown[]) => mockUseQuery(...args), - useMutation: (...args: unknown[]) => mockUseMutation(...args), +const listLabelTemplatesMock = vi.fn(); +const getDefaultLabelTemplateMock = vi.fn(); +const getLabelUrlBaseMock = vi.fn(); +const getAssetsForLabelsMock = vi.fn(); +const listCustomFieldsMock = vi.fn(); +const getCurrentUserMock = vi.fn(); + +vi.mock("@/lib/api/label-templates", () => ({ + listLabelTemplates: () => listLabelTemplatesMock(), + getDefaultLabelTemplate: () => getDefaultLabelTemplateMock(), + getLabelUrlBase: () => getLabelUrlBaseMock(), + ensureDefaultLabelTemplates: vi.fn().mockResolvedValue({ seeded: false }), +})); + +vi.mock("@/lib/api/assets", () => ({ + getAssetsForLabels: (ids: string[]) => getAssetsForLabelsMock(ids), +})); + +vi.mock("@/lib/api/custom-fields", () => ({ + listCustomFields: () => listCustomFieldsMock(), +})); + +vi.mock("@/lib/api/auth", () => ({ + getCurrentUser: () => getCurrentUserMock(), })); vi.mock("next/navigation", () => ({ @@ -20,86 +38,74 @@ vi.mock("next/navigation", () => ({ vi.mock("@/components/labels/label-print", () => ({ LabelPrint: () => (
-
+
), })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { LabelPrintPageClient } from "@/components/labels/label-print-page-client"; + +function renderWithClient(ui: React.ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const result = render( + {ui}, + ); + return { + ...result, + rerender: (next: React.ReactElement) => + result.rerender( + {next}, + ), + }; +} + +const sampleTemplate = { + id: "template-1", + name: "Thermal 57x32 mm", + widthMm: 57, + heightMm: 32, + elements: [], + isDefault: true, + createdAt: 1, + updatedAt: 1, + createdBy: "user-1", + updatedBy: "user-1", +}; + describe("LabelPrintPageClient", () => { beforeEach(() => { mockBarcodeState = "loading"; currentSearchParams = new URLSearchParams("assetId=asset-1"); - mockUseQuery.mockReset(); - mockUseMutation.mockReset(); - mockUseMutation.mockReturnValue(vi.fn()); - - mockUseQuery.mockImplementation((reference: unknown) => { - const functionName = getFunctionName(reference as never); - - if (functionName === "users:getCurrentUser") { - return { _id: "user-1", role: "admin" }; - } - if (functionName === "labelTemplates:listTemplates") { - return [ - { - _id: "template-1", - _creationTime: 1, - name: "Thermal 57x32 mm", - widthMm: 57, - heightMm: 32, - elements: [], - isDefault: true, - createdAt: 1, - updatedAt: 1, - createdBy: "user-1", - updatedBy: "user-1", - }, - ]; - } - if (functionName === "labelTemplates:getDefaultTemplate") { - return { - _id: "template-1", - _creationTime: 1, - name: "Thermal 57x32 mm", - widthMm: 57, - heightMm: 32, - elements: [], - isDefault: true, - createdAt: 1, - updatedAt: 1, - createdBy: "user-1", - updatedBy: "user-1", - }; - } - if (functionName === "labelTemplates:getLabelUrlBase") { - return "http://localhost:3000"; - } - if (functionName === "customFields:listFieldDefinitions") { - return []; - } - if (functionName === "assets:getAssetsForLabels") { - return [ - { - _id: "asset-1", - name: "Main winch", - assetTag: "WIN-001", - categoryName: null, - locationPath: null, - notes: null, - customFieldValues: {}, - }, - ]; - } - - return undefined; + + getCurrentUserMock.mockResolvedValue({ + id: "user-1", + email: "a@x.com", + name: "Admin", + role: "admin", }); + listLabelTemplatesMock.mockResolvedValue([sampleTemplate]); + getDefaultLabelTemplateMock.mockResolvedValue(sampleTemplate); + getLabelUrlBaseMock.mockResolvedValue("http://localhost:3000"); + listCustomFieldsMock.mockResolvedValue([]); + getAssetsForLabelsMock.mockResolvedValue([ + { + id: "asset-1", + name: "Main winch", + assetTag: "WIN-001", + categoryName: null, + locationPath: null, + notes: null, + customFieldValues: {}, + }, + ]); }); it("disables printing until barcode rendering is ready", async () => { - const { rerender } = render(); + const { rerender } = renderWithClient(); await waitFor(() => { expect(screen.getByRole("button", { name: "Print now" })).toBeDisabled(); diff --git a/src/__tests__/components/labels/labels-page-client.test.tsx b/src/__tests__/components/labels/labels-page-client.test.tsx index c76e7b9..7f73a68 100644 --- a/src/__tests__/components/labels/labels-page-client.test.tsx +++ b/src/__tests__/components/labels/labels-page-client.test.tsx @@ -1,26 +1,42 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { getFunctionName } from "convex/server"; -import { LabelsPageClient } from "@/components/labels/labels-page-client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; + +const listLabelTemplatesMock = vi.fn(); +const getLabelUrlBaseMock = vi.fn(); +const getLabelPreviewAssetMock = vi.fn(); +const listCustomFieldsMock = vi.fn(); +const getCurrentUserMock = vi.fn(); +const ensureMock = vi.fn(); + +vi.mock("@/lib/api/label-templates", () => ({ + listLabelTemplates: () => listLabelTemplatesMock(), + getLabelUrlBase: () => getLabelUrlBaseMock(), + ensureDefaultLabelTemplates: () => ensureMock(), +})); -const mockUseQuery = vi.fn(); -const mockUseMutation = vi.fn(); +vi.mock("@/lib/api/assets", () => ({ + getLabelPreviewAsset: () => getLabelPreviewAssetMock(), +})); + +vi.mock("@/lib/api/custom-fields", () => ({ + listCustomFields: () => listCustomFieldsMock(), +})); -vi.mock("convex/react", () => ({ - useQuery: (...args: unknown[]) => mockUseQuery(...args), - useMutation: (...args: unknown[]) => mockUseMutation(...args), +vi.mock("@/lib/api/auth", () => ({ + getCurrentUser: () => getCurrentUserMock(), })); vi.mock("@/components/labels/template-designer", () => ({ TemplateDesigner: (props: { - templates: Array<{ _id: string; name: string }>; + templates: Array<{ id: string; name: string }>; currentUserRole: string | null; }) => (
{props.templates.length} - {props.currentUserRole} + {props.currentUserRole ?? "null"} {props.templates.map((t) => ( - + {t.name} ))} @@ -28,16 +44,38 @@ vi.mock("@/components/labels/template-designer", () => ({ ), })); +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { LabelsPageClient } from "@/components/labels/labels-page-client"; + +function renderWithClient(ui: React.ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({ui}); +} + describe("LabelsPageClient", () => { beforeEach(() => { - mockUseQuery.mockReset(); - mockUseMutation.mockReset(); - mockUseMutation.mockReturnValue(vi.fn()); + listLabelTemplatesMock.mockReset(); + getLabelUrlBaseMock.mockReset(); + getLabelPreviewAssetMock.mockReset(); + listCustomFieldsMock.mockReset(); + getCurrentUserMock.mockReset(); + ensureMock.mockReset(); + ensureMock.mockResolvedValue({ seeded: false }); }); it("shows loading state while queries are pending", () => { - mockUseQuery.mockReturnValue(undefined); - render(); + listLabelTemplatesMock.mockImplementation(() => new Promise(() => {})); + getLabelUrlBaseMock.mockImplementation(() => new Promise(() => {})); + getLabelPreviewAssetMock.mockImplementation(() => new Promise(() => {})); + listCustomFieldsMock.mockImplementation(() => new Promise(() => {})); + getCurrentUserMock.mockImplementation(() => new Promise(() => {})); + + renderWithClient(); expect(screen.getByText("Loading label designer...")).toBeInTheDocument(); expect( @@ -45,80 +83,58 @@ describe("LabelsPageClient", () => { ).not.toBeInTheDocument(); }); - it("renders TemplateDesigner once all queries resolve", () => { - mockUseQuery.mockImplementation((reference: unknown) => { - const functionName = getFunctionName(reference as never); - - if (functionName === "users:getCurrentUser") { - return { _id: "user-1", role: "admin" }; - } - if (functionName === "labelTemplates:listTemplates") { - return [ - { - _id: "template-1", - _creationTime: 1, - name: "Thermal 57x32 mm", - widthMm: 57, - heightMm: 32, - elements: [], - isDefault: true, - createdAt: 1, - updatedAt: 1, - createdBy: "user-1", - updatedBy: "user-1", - }, - ]; - } - if (functionName === "labelTemplates:getLabelUrlBase") { - return "http://localhost:3000"; - } - if (functionName === "assets:getLabelPreviewAsset") { - return null; - } - if (functionName === "customFields:listFieldDefinitions") { - return []; - } - - return undefined; + it("renders TemplateDesigner once all queries resolve", async () => { + getCurrentUserMock.mockResolvedValue({ + id: "user-1", + email: "a@x.com", + name: "Admin", + role: "admin", + }); + listLabelTemplatesMock.mockResolvedValue([ + { + id: "template-1", + name: "Thermal 57x32 mm", + widthMm: 57, + heightMm: 32, + elements: [], + isDefault: true, + createdAt: 1, + updatedAt: 1, + createdBy: "user-1", + updatedBy: "user-1", + }, + ]); + getLabelUrlBaseMock.mockResolvedValue("http://localhost:3000"); + getLabelPreviewAssetMock.mockResolvedValue(null); + listCustomFieldsMock.mockResolvedValue([]); + + renderWithClient(); + + await waitFor(() => { + expect(screen.getByTestId("mock-template-designer")).toBeInTheDocument(); }); - - render(); - - expect( - screen.queryByText("Loading label designer..."), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("mock-template-designer")).toBeInTheDocument(); expect(screen.getByTestId("template-count")).toHaveTextContent("1"); expect(screen.getByTestId("template-template-1")).toHaveTextContent( "Thermal 57x32 mm", ); }); - it("passes the current user role to TemplateDesigner", () => { - mockUseQuery.mockImplementation((reference: unknown) => { - const functionName = getFunctionName(reference as never); - - if (functionName === "users:getCurrentUser") { - return { _id: "user-1", role: "user" }; - } - if (functionName === "labelTemplates:listTemplates") { - return []; - } - if (functionName === "labelTemplates:getLabelUrlBase") { - return "http://localhost:3000"; - } - if (functionName === "assets:getLabelPreviewAsset") { - return null; - } - if (functionName === "customFields:listFieldDefinitions") { - return []; - } - - return undefined; + it("passes the current user role to TemplateDesigner", async () => { + getCurrentUserMock.mockResolvedValue({ + id: "user-1", + email: "u@x.com", + name: "User", + role: "user", }); + listLabelTemplatesMock.mockResolvedValue([]); + getLabelUrlBaseMock.mockResolvedValue("http://localhost:3000"); + getLabelPreviewAssetMock.mockResolvedValue(null); + listCustomFieldsMock.mockResolvedValue([]); - render(); + renderWithClient(); - expect(screen.getByTestId("user-role")).toHaveTextContent("user"); + await waitFor(() => { + expect(screen.getByTestId("user-role")).toHaveTextContent("user"); + }); }); }); diff --git a/src/__tests__/components/labels/template-designer.test.tsx b/src/__tests__/components/labels/template-designer.test.tsx index 15e0cb9..55cbd6f 100644 --- a/src/__tests__/components/labels/template-designer.test.tsx +++ b/src/__tests__/components/labels/template-designer.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { TemplateDesigner } from "@/components/labels/template-designer"; import type { LabelPreviewAsset, @@ -8,10 +8,10 @@ import type { } from "@/components/labels/types"; import type { FieldDefinition } from "@/components/fields/types"; -const mockUseMutation = vi.fn(); - -vi.mock("convex/react", () => ({ - useMutation: (...args: unknown[]) => mockUseMutation(...args), +vi.mock("@/lib/api/label-templates", () => ({ + createLabelTemplate: vi.fn(), + updateLabelTemplate: vi.fn(), + deleteLabelTemplate: vi.fn(), })); vi.mock("@/components/labels/template-canvas", () => ({ @@ -62,8 +62,7 @@ vi.mock("@/components/crud/confirm-dialog", () => ({ })); const sampleTemplate: LabelTemplate = { - _id: "template-1" as never, - _creationTime: 1, + id: "template-1", name: "Thermal 57x32 mm", widthMm: 57, heightMm: 32, @@ -83,12 +82,12 @@ const sampleTemplate: LabelTemplate = { isDefault: true, createdAt: 1, updatedAt: 1, - createdBy: "user-1" as never, - updatedBy: "user-1" as never, + createdBy: "user-1", + updatedBy: "user-1", }; const sampleAsset: LabelPreviewAsset = { - _id: "asset-1" as never, + id: "asset-1", name: "Main winch", assetTag: "WIN-001", categoryName: "Deck gear", @@ -99,14 +98,29 @@ const sampleAsset: LabelPreviewAsset = { const sampleFieldDefs: FieldDefinition[] = []; +function renderWithClient(ui: React.ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const result = render( + {ui}, + ); + return { + ...result, + rerender: (next: React.ReactElement) => + result.rerender( + {next}, + ), + }; +} + describe("TemplateDesigner", () => { beforeEach(() => { - mockUseMutation.mockReset(); - mockUseMutation.mockReturnValue(vi.fn()); + vi.clearAllMocks(); }); it("renders the template list, canvas, toolbar, and properties panel", () => { - render( + renderWithClient( { expect(screen.getByTestId("mock-element-toolbar")).toBeInTheDocument(); expect(screen.getByTestId("mock-label-preview")).toBeInTheDocument(); - // Template name appears in both the list and the canvas mock expect(screen.getAllByText("Thermal 57x32 mm").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("57 x 32 mm")).toBeInTheDocument(); }); it("shows empty state when no templates exist", () => { - render( + renderWithClient( { }); it("shows the New button for admin users and hides it for regular users", () => { - const { rerender } = render( + const { rerender } = renderWithClient( { + const url = new URL(req.url); + const assetIds = url.searchParams.getAll("assetId"); + const assets = await getAssetsForLabels(session.ctx, assetIds); + return { assets }; +}); diff --git a/src/app/api/assets/label-preview/route.ts b/src/app/api/assets/label-preview/route.ts new file mode 100644 index 0000000..fc2c388 --- /dev/null +++ b/src/app/api/assets/label-preview/route.ts @@ -0,0 +1,10 @@ +import { withUser } from "@/server/auth/route"; +import { getLabelPreviewAsset } from "@/server/domain/assets"; + +export const GET = withUser( + "api/assets/label-preview", + async (_req, session) => { + const asset = await getLabelPreviewAsset(session.ctx); + return { asset }; + }, +); diff --git a/src/app/api/label-templates/ensure-defaults/route.ts b/src/app/api/label-templates/ensure-defaults/route.ts new file mode 100644 index 0000000..b4336ce --- /dev/null +++ b/src/app/api/label-templates/ensure-defaults/route.ts @@ -0,0 +1,10 @@ +import { withAdmin } from "@/server/auth/route"; +import { ensureDefaultTemplates } from "@/server/domain/labelTemplates"; + +export const POST = withAdmin( + "api/label-templates/ensure-defaults", + async (_req, session, user) => { + const result = await ensureDefaultTemplates(session.ctx, user.id); + return result; + }, +); diff --git a/src/app/api/label-templates/url-base/route.ts b/src/app/api/label-templates/url-base/route.ts new file mode 100644 index 0000000..72b61ad --- /dev/null +++ b/src/app/api/label-templates/url-base/route.ts @@ -0,0 +1,9 @@ +import { withUser } from "@/server/auth/route"; +import { getLabelUrlBase } from "@/server/domain/labelTemplates"; + +export const GET = withUser( + "api/label-templates/url-base", + async () => { + return { base: getLabelUrlBase() }; + }, +); diff --git a/src/components/labels/element-properties.tsx b/src/components/labels/element-properties.tsx index bc5ff73..c2f704a 100644 --- a/src/components/labels/element-properties.tsx +++ b/src/components/labels/element-properties.tsx @@ -68,7 +68,7 @@ export function ElementProperties({

Template

Saved as{" "} - {template._id ? "an existing template" : "a new template draft"}. + {template.id ? "an existing template" : "a new template draft"}.

@@ -204,7 +204,7 @@ export function ElementProperties({ > {saving ? "Saving..." - : template._id + : template.id ? "Save changes" : "Save template"} @@ -217,7 +217,7 @@ export function ElementProperties({ > New template - {template._id ? ( + {template.id ? ( @@ -210,7 +236,7 @@ export function ServiceGroupsList() { variant="ghost" size="icon-sm" className="cursor-pointer text-destructive" - onClick={() => setDeleteGroupId(group._id)} + onClick={() => setDeleteGroupId(group.id)} aria-label={`Delete ${group.name}`} > @@ -228,7 +254,7 @@ export function ServiceGroupsList() {
{ void handleDelete(); }} onClose={() => { - if (!deleting) { + if (!deleteMutation.isPending) { setDeleteGroupId(null); } }} diff --git a/src/components/services/service-history.tsx b/src/components/services/service-history.tsx index 1339902..20dfd70 100644 --- a/src/components/services/service-history.tsx +++ b/src/components/services/service-history.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CalendarClock, Pencil, Trash2, Wrench } from "lucide-react"; import { toast } from "sonner"; import { ConfirmDialog } from "@/components/crud/confirm-dialog"; @@ -12,8 +12,12 @@ import { ServiceRecordForm } from "@/components/services/service-record-form"; import type { ServiceRecord } from "@/components/services/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import type { Id } from "@/lib/convex-api"; -import { api } from "@/lib/convex-api"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { + deleteServiceRecord, + listAssetServiceRecords, +} from "@/lib/api/service-records"; +import { getScheduleByAssetId } from "@/lib/api/service-schedules"; import { formatDateFromIsoDateOnly, formatDateFromTimestamp, @@ -31,42 +35,54 @@ function formatCost(value: number | null) { }).format(value); } -export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { +export function ServiceHistory({ assetId }: { assetId: string }) { const dateFormat = useAppDateFormat(); - const currentUser = useQuery(api.users.getCurrentUser, {}); - const schedule = useQuery(api.serviceSchedules.getScheduleByAssetId, { - assetId, + const queryClient = useQueryClient(); + const { data: currentUser } = useCurrentUser(); + + const scheduleQuery = useQuery({ + queryKey: ["service-schedules", "by-asset", assetId], + queryFn: () => getScheduleByAssetId(assetId), + }); + const recordsQuery = useQuery({ + queryKey: ["service-records", "by-asset", assetId], + queryFn: () => listAssetServiceRecords(assetId), + }); + + const deleteMutation = useMutation({ + mutationFn: (recordId: string) => deleteServiceRecord(recordId), }); - const rows = useQuery(api.serviceRecords.listAssetRecords, { assetId }); - const deleteRecord = useMutation(api.serviceRecords.deleteRecord); const [createOpen, setCreateOpen] = useState(false); const [editRecord, setEditRecord] = useState(null); - const [deleteRecordId, setDeleteRecordId] = - useState | null>(null); - const [deleting, setDeleting] = useState(false); + const [deleteRecordId, setDeleteRecordId] = useState(null); - const records = useMemo(() => (rows ?? []) as ServiceRecord[], [rows]); + const records = useMemo( + () => (recordsQuery.data ?? []) as ServiceRecord[], + [recordsQuery.data], + ); async function handleDeleteRecord() { if (!deleteRecordId) { return; } - setDeleting(true); try { - await deleteRecord({ recordId: deleteRecordId }); + await deleteMutation.mutateAsync(deleteRecordId); toast.success("Service record deleted"); setDeleteRecordId(null); + void queryClient.invalidateQueries({ + queryKey: ["service-records", "by-asset", assetId], + }); } catch (error) { toast.error( getConvexUiErrorMessage(error, "Unable to delete service record"), ); - } finally { - setDeleting(false); } } + const schedule = scheduleQuery.data ?? null; + return (
@@ -116,7 +132,7 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { ) : null}
- {rows === undefined || currentUser === undefined ? ( + {recordsQuery.isPending ? (
Loading service history...
@@ -128,11 +144,11 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { records.map((record) => { const canMutate = currentUser?.role === "admin" || - currentUser?._id === record.completedBy; + currentUser?.id === record.completedBy; return (
@@ -205,7 +221,7 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { className="cursor-pointer" onClick={(event) => { event.preventDefault(); - setDeleteRecordId(record._id); + setDeleteRecordId(record.id); }} > @@ -222,7 +238,7 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) {
{record.valueEntries.map((entry) => (

@@ -250,7 +266,7 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { Service reports and proof of work for this record.

- +
@@ -271,6 +287,7 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { key={`create-${assetId}`} assetId={assetId} mode="create" + onSubmitted={() => setCreateOpen(false)} /> ) : null} @@ -283,7 +300,7 @@ export function ServiceHistory({ assetId }: { assetId: Id<"assets"> }) { > {editRecord ? ( }) { title="Delete service record" description="Delete this service record and all of its attachments?" confirmLabel="Delete record" - busy={deleting} + busy={deleteMutation.isPending} onConfirm={handleDeleteRecord} onClose={() => setDeleteRecordId(null)} /> diff --git a/src/components/services/service-providers-page-client.tsx b/src/components/services/service-providers-page-client.tsx index 9fa656d..0badc28 100644 --- a/src/components/services/service-providers-page-client.tsx +++ b/src/components/services/service-providers-page-client.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { ConfirmDialog } from "@/components/crud/confirm-dialog"; @@ -12,8 +12,13 @@ import type { ServiceProvider } from "@/components/services/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import type { Id } from "@/lib/convex-api"; -import { api } from "@/lib/convex-api"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { + createServiceProvider, + deleteServiceProvider, + listServiceProviders, + updateServiceProvider, +} from "@/lib/api/service-providers"; type ProviderFormState = { name: string; @@ -34,25 +39,34 @@ function createInitialForm( } export function ServiceProvidersPageClient() { - const currentUser = useQuery(api.users.getCurrentUser, {}); - const providersQuery = useQuery(api.serviceProviders.listProviders, {}); + const queryClient = useQueryClient(); + const { data: currentUser, isPending: currentUserPending } = useCurrentUser(); + const providersQuery = useQuery({ + queryKey: ["service-providers"], + queryFn: listServiceProviders, + }); - const createProvider = useMutation(api.serviceProviders.createProvider); - const updateProvider = useMutation(api.serviceProviders.updateProvider); - const deleteProvider = useMutation(api.serviceProviders.deleteProvider); + const createMutation = useMutation({ mutationFn: createServiceProvider }); + const updateMutation = useMutation({ + mutationFn: ({ + providerId, + input, + }: { + providerId: string; + input: Parameters[1]; + }) => updateServiceProvider(providerId, input), + }); + const deleteMutation = useMutation({ mutationFn: deleteServiceProvider }); const [editingProvider, setEditingProvider] = useState(null); const [modalOpen, setModalOpen] = useState(false); - const [deleteProviderId, setDeleteProviderId] = - useState | null>(null); - const [submitting, setSubmitting] = useState(false); - const [deleting, setDeleting] = useState(false); + const [deleteProviderId, setDeleteProviderId] = useState(null); const [form, setForm] = useState(createInitialForm()); const providers = useMemo( - () => (providersQuery ?? []) as ServiceProvider[], - [providersQuery], + () => (providersQuery.data ?? []) as ServiceProvider[], + [providersQuery.data], ); const canManage = currentUser?.role === "admin"; @@ -70,27 +84,25 @@ export function ServiceProvidersPageClient() { async function handleSubmit(event: React.FormEvent) { event.preventDefault(); - setSubmitting(true); try { if (editingProvider) { - await updateProvider({ - providerId: editingProvider._id, - ...form, + await updateMutation.mutateAsync({ + providerId: editingProvider.id, + input: form, }); toast.success("Service provider updated"); } else { - await createProvider(form); + await createMutation.mutateAsync(form); toast.success("Service provider created"); } setModalOpen(false); setEditingProvider(null); setForm(createInitialForm()); + void queryClient.invalidateQueries({ queryKey: ["service-providers"] }); } catch (error) { toast.error( getConvexUiErrorMessage(error, "Unable to save service provider"), ); - } finally { - setSubmitting(false); } } @@ -99,21 +111,21 @@ export function ServiceProvidersPageClient() { return; } - setDeleting(true); try { - await deleteProvider({ providerId: deleteProviderId }); + await deleteMutation.mutateAsync(deleteProviderId); toast.success("Service provider deleted"); setDeleteProviderId(null); + void queryClient.invalidateQueries({ queryKey: ["service-providers"] }); } catch (error) { toast.error( getConvexUiErrorMessage(error, "Unable to delete service provider"), ); - } finally { - setDeleting(false); } } - if (currentUser === undefined || providersQuery === undefined) { + const submitting = createMutation.isPending || updateMutation.isPending; + + if (currentUserPending || providersQuery.isPending) { return (
@@ -144,7 +156,7 @@ export function ServiceProvidersPageClient() {
{providers.map((provider) => (
@@ -181,7 +193,7 @@ export function ServiceProvidersPageClient() { variant="outline" size="sm" className="cursor-pointer" - onClick={() => setDeleteProviderId(provider._id)} + onClick={() => setDeleteProviderId(provider.id)} > Delete @@ -297,7 +309,7 @@ export function ServiceProvidersPageClient() { title="Delete provider" description="Delete this service provider?" confirmLabel="Delete provider" - busy={deleting} + busy={deleteMutation.isPending} onConfirm={handleDelete} onClose={() => setDeleteProviderId(null)} /> diff --git a/src/components/services/service-record-attachments.tsx b/src/components/services/service-record-attachments.tsx index 08fc377..7238217 100644 --- a/src/components/services/service-record-attachments.tsx +++ b/src/components/services/service-record-attachments.tsx @@ -2,13 +2,16 @@ import { useMemo, useRef, useState } from "react"; import { Loader2, Paperclip, Trash2, Upload } from "lucide-react"; -import { useMutation, useQuery } from "convex/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { api } from "@/lib/convex-api"; -import type { Id } from "@/lib/convex-api"; import type { ServiceRecordAttachment } from "@/components/services/types"; import { getConvexUiErrorMessage } from "@/components/crud/error-messages"; +import { + deleteServiceRecordAttachment, + listServiceRecordAttachments, + uploadServiceRecordAttachment, +} from "@/lib/api/attachments"; const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; @@ -48,54 +51,37 @@ function formatFileSize(size: number) { return `${Math.max(1, Math.round(size / 1024))} KB`; } -async function uploadFile(uploadUrl: string, file: File) { - const response = await fetch(uploadUrl, { - method: "POST", - headers: { - "Content-Type": file.type || "application/octet-stream", - }, - body: file, - }); - - if (!response.ok) { - throw new Error("Upload failed"); - } - - const payload = (await response.json()) as { storageId?: string }; - if (!payload.storageId) { - throw new Error("Upload did not return a storage id"); - } - - return payload.storageId as Id<"_storage">; -} - export function ServiceRecordAttachments({ serviceRecordId, }: { - serviceRecordId: Id<"serviceRecords">; + serviceRecordId: string; }) { + const queryClient = useQueryClient(); const inputRef = useRef(null); - const rows = useQuery(api.serviceRecordAttachments.listAttachments, { - serviceRecordId, + + const attachmentsQuery = useQuery({ + queryKey: ["service-record-attachments", serviceRecordId], + queryFn: () => listServiceRecordAttachments(serviceRecordId), + }); + + const uploadMutation = useMutation({ + mutationFn: (file: File) => + uploadServiceRecordAttachment(serviceRecordId, file), + }); + const deleteMutation = useMutation({ + mutationFn: (attachmentId: string) => + deleteServiceRecordAttachment(attachmentId), }); - const generateUploadUrl = useMutation( - api.serviceRecordAttachments.generateUploadUrl, - ); - const createAttachment = useMutation( - api.serviceRecordAttachments.createAttachment, - ); - const deleteAttachment = useMutation( - api.serviceRecordAttachments.deleteAttachment, - ); const attachments = useMemo( - () => (rows ?? []) as ServiceRecordAttachment[], - [rows], + () => (attachmentsQuery.data ?? []) as ServiceRecordAttachment[], + [attachmentsQuery.data], ); const [uploading, setUploading] = useState(false); - const [deletingAttachmentId, setDeletingAttachmentId] = - useState | null>(null); + const [deletingAttachmentId, setDeletingAttachmentId] = useState< + string | null + >(null); async function handleFiles(inputFiles: FileList | null) { if (!inputFiles || inputFiles.length === 0) { @@ -118,21 +104,13 @@ export function ServiceRecordAttachments({ setUploading(true); try { - await Promise.all( - files.map(async (file) => { - const { uploadUrl } = await generateUploadUrl({}); - const storageId = await uploadFile(uploadUrl, file); - await createAttachment({ - serviceRecordId, - storageId, - fileName: file.name, - fileType: file.type || null, - }); - }), - ); + await Promise.all(files.map((file) => uploadMutation.mutateAsync(file))); toast.success( files.length === 1 ? "Attachment uploaded" : "Attachments uploaded", ); + void queryClient.invalidateQueries({ + queryKey: ["service-record-attachments", serviceRecordId], + }); } catch (error) { toast.error( getConvexUiErrorMessage(error, "Unable to upload attachment"), @@ -172,7 +150,7 @@ export function ServiceRecordAttachments({ Add attachment - {rows === undefined ? ( + {attachmentsQuery.isPending ? (
Loading attachments...
@@ -184,7 +162,7 @@ export function ServiceRecordAttachments({
{attachments.map((attachment) => (
@@ -215,12 +193,18 @@ export function ServiceRecordAttachments({ variant="ghost" size="icon-sm" className="cursor-pointer text-destructive" - disabled={deletingAttachmentId === attachment._id} + disabled={deletingAttachmentId === attachment.id} onClick={async () => { - setDeletingAttachmentId(attachment._id); + setDeletingAttachmentId(attachment.id); try { - await deleteAttachment({ attachmentId: attachment._id }); + await deleteMutation.mutateAsync(attachment.id); toast.success("Attachment deleted"); + void queryClient.invalidateQueries({ + queryKey: [ + "service-record-attachments", + serviceRecordId, + ], + }); } catch (error) { toast.error( getConvexUiErrorMessage( @@ -234,7 +218,7 @@ export function ServiceRecordAttachments({ }} aria-label={`Delete ${attachment.fileName}`} > - {deletingAttachmentId === attachment._id ? ( + {deletingAttachmentId === attachment.id ? ( ) : ( diff --git a/src/components/services/service-record-dynamic-form.tsx b/src/components/services/service-record-dynamic-form.tsx index c7e4dd5..28f50a1 100644 --- a/src/components/services/service-record-dynamic-form.tsx +++ b/src/components/services/service-record-dynamic-form.tsx @@ -1,15 +1,14 @@ "use client"; import { ServiceRecordForm } from "@/components/services/service-record-form"; -import type { Id } from "@/lib/convex-api"; export function ServiceRecordDynamicForm({ assetId, onCreated, }: { - assetId: Id<"assets">; + assetId: string; scheduledForDate?: string | null; - onCreated?: (recordId: Id<"serviceRecords">) => void; + onCreated?: (recordId: string) => void; }) { return ( ; + assetId: string; mode?: "create" | "edit" | "complete"; record?: ServiceRecord | null; - scheduleId?: Id<"serviceSchedules"> | null; + scheduleId?: string | null; submitLabel?: string; onSubmitted?: (result: { - recordId: Id<"serviceRecords">; + recordId: string; nextServiceDate?: string | null; }) => void; }; @@ -92,23 +97,30 @@ export function ServiceRecordForm({ onSubmitted, }: ServiceRecordFormProps) { const today = useTodayIsoDate(); - const formDefinitionQuery = useQuery( - api.serviceRecords.getRecordFormDefinition, - { - assetId, - recordId: record?._id, - }, - ); - const providerOptionsQuery = useQuery( - api.serviceProviders.listProviderOptions, - {}, - ); - - const createRecord = useMutation(api.serviceRecords.createRecord); - const updateRecord = useMutation(api.serviceRecords.updateRecord); - const completeScheduledService = useMutation( - api.serviceRecords.completeScheduledService, - ); + const queryClient = useQueryClient(); + + const formDefinitionQuery = useQuery({ + queryKey: ["service-records", "form", assetId, record?.id ?? null], + queryFn: () => getServiceRecordForm(assetId, record?.id), + }); + const providerOptionsQuery = useQuery({ + queryKey: ["service-providers", "options"], + queryFn: listServiceProviderOptions, + }); + + const createMutation = useMutation({ mutationFn: createServiceRecord }); + const updateMutation = useMutation({ + mutationFn: ({ + recordId, + input, + }: { + recordId: string; + input: Parameters[1]; + }) => updateServiceRecord(recordId, input), + }); + const completeMutation = useMutation({ + mutationFn: completeScheduledService, + }); const [serviceDate, setServiceDate] = useState(record?.serviceDate ?? today); const [description, setDescription] = useState(record?.description ?? ""); @@ -117,22 +129,22 @@ export function ServiceRecordForm({ ? "" : String(record.cost), ); - const [providerId, setProviderId] = useState | null>( + const [providerId, setProviderId] = useState( record?.providerId ?? null, ); const [values, setValues] = useState>( buildInitialValues(record), ); - const [submitting, setSubmitting] = useState(false); - const [currentRecordId, setCurrentRecordId] = - useState | null>(record?._id ?? null); + const [currentRecordId, setCurrentRecordId] = useState( + record?.id ?? null, + ); - const formDefinition = formDefinitionQuery as + const formDefinition = formDefinitionQuery.data as | ServiceRecordFormDefinition | undefined; const providerOptions = useMemo( - () => (providerOptionsQuery ?? []) as ServiceProviderOption[], - [providerOptionsQuery], + () => (providerOptionsQuery.data ?? []) as ServiceProviderOption[], + [providerOptionsQuery.data], ); const cost = useMemo(() => { @@ -143,6 +155,11 @@ export function ServiceRecordForm({ return Number.isFinite(parsed) ? parsed : Number.NaN; }, [costInput]); + const submitting = + createMutation.isPending || + updateMutation.isPending || + completeMutation.isPending; + const isSaved = currentRecordId !== null && mode !== "edit"; const effectiveSubmitLabel = submitLabel ?? @@ -169,7 +186,7 @@ export function ServiceRecordForm({ if (!field.required) { return true; } - return isFieldValid(field, values[field._id]); + return isFieldValid(field, values[field.id]); }); }, [ cost, @@ -187,24 +204,28 @@ export function ServiceRecordForm({ return; } - setSubmitting(true); try { if (mode === "edit" && record) { - await updateRecord({ - recordId: record._id, - serviceDate, - description, - cost, - providerId, - values, + await updateMutation.mutateAsync({ + recordId: record.id, + input: { + serviceDate, + description, + cost, + providerId, + values, + }, }); toast.success("Service record updated"); - onSubmitted?.({ recordId: record._id, nextServiceDate: null }); + onSubmitted?.({ recordId: record.id, nextServiceDate: null }); + void queryClient.invalidateQueries({ + queryKey: ["service-records", "by-asset", assetId], + }); return; } if (mode === "complete" && scheduleId) { - const result = await completeScheduledService({ + const result = await completeMutation.mutateAsync({ scheduleId, serviceDate, description, @@ -215,10 +236,16 @@ export function ServiceRecordForm({ setCurrentRecordId(result.recordId); toast.success("Service completed"); onSubmitted?.(result); + void queryClient.invalidateQueries({ + queryKey: ["service-records", "by-asset", assetId], + }); + void queryClient.invalidateQueries({ + queryKey: ["service-schedules"], + }); return; } - const result = await createRecord({ + const result = await createMutation.mutateAsync({ assetId, serviceDate, description, @@ -229,16 +256,17 @@ export function ServiceRecordForm({ setCurrentRecordId(result.recordId); toast.success("Service record logged"); onSubmitted?.({ recordId: result.recordId, nextServiceDate: null }); + void queryClient.invalidateQueries({ + queryKey: ["service-records", "by-asset", assetId], + }); } catch (error) { toast.error( getConvexUiErrorMessage(error, "Unable to save service record"), ); - } finally { - setSubmitting(false); } } - if (formDefinitionQuery === undefined || providerOptionsQuery === undefined) { + if (formDefinitionQuery.isPending || providerOptionsQuery.isPending) { return (
Loading service record form... @@ -311,11 +339,7 @@ export function ServiceRecordForm({ setValues((prev) => ({ ...prev, - [field._id]: event.target.checked, + [field.id]: event.target.checked, })) } disabled={isSaved || submitting} @@ -494,7 +518,7 @@ export function ServiceRecordForm({ if (field.fieldType === "select") { return ( -
+