From f25a8ff463438e3ab32d08cb34460b5c9d181068 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Mon, 14 Sep 2026 23:01:28 -0400 Subject: [PATCH 1/2] Phase 2: every read path on Postgres; E2E on a production build Catalogue, projects, resources and maintenance history read Postgres through src/lib/data/; catalog.ts and projects.ts keep their exports and cache tags over the new modules. No mock-catalogue fallback: PGlite demo seed without DATABASE_URL, errors propagate otherwise. Legacy /tools/ links redirect to the slug. Health probes Postgres. Chat manuals and unit history come from Postgres. The build runs db:migrate first. Docs say Postgres is the source of truth and that writes stay on Notion until Phase 3. A temporary bridge translates Postgres ids back to Notion page ids for the three writes still on Notion. Tool and project pages are no longer prerendered at build, since that needed a database. E2E runs against `next build && next start` with one retry: under `next dev` parallel first compiles rewrote the layout chunk mid-download and four tests failed every run; what remains is a rare client-router race that Playwright reports as flaky. Details in the spec's Phase 2 amendment. Built by a workflow of eight agents (Opus for the core, capabilities and integration; Sonnet for projects, health, redirect, config, docs, review). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NP2xC6APXbSucoE4n5mtoj --- AGENTS.md | 24 +- CLAUDE.md | 6 +- docs/handover.md | 123 +-- .../2026-09-14-v5-data-platform-design.md | 14 + v5/AGENTS.md | 68 +- v5/TESTING.md | 44 +- v5/e2e/corrections.spec.ts | 11 +- v5/e2e/gallery.spec.ts | 10 +- v5/e2e/projects.spec.ts | 7 +- v5/e2e/tool-detail.spec.ts | 26 +- v5/evals/fixtures.ts | 19 +- v5/evals/harness.test.ts | 6 +- v5/evals/run.eval.ts | 25 +- v5/next.config.ts | 14 + v5/package.json | 2 +- v5/playwright.config.ts | 42 +- v5/scripts/generate-qr-labels.test.ts | 44 +- v5/scripts/generate-qr-labels.ts | 63 +- v5/src/app/api/chat/rate-limit.route.test.ts | 26 +- v5/src/app/api/chat/route.test.ts | 251 +++--- v5/src/app/api/chat/route.ts | 40 +- v5/src/app/api/flags/route.test.ts | 22 +- v5/src/app/api/health/route.test.ts | 228 ++---- v5/src/app/api/health/route.ts | 92 +-- v5/src/app/api/mcp/route.test.ts | 101 ++- v5/src/app/api/projects/route.test.ts | 53 +- v5/src/app/api/projects/route.ts | 10 +- v5/src/app/tools/[id]/page.tsx | 47 +- v5/src/components/DemoDataBanner.test.tsx | 40 +- v5/src/components/DemoDataBanner.tsx | 25 +- v5/src/lib/cache.ts | 6 +- v5/src/lib/capabilities/catalog.test.ts | 163 ++++ v5/src/lib/capabilities/flags.test.ts | 63 +- v5/src/lib/capabilities/flags.ts | 33 +- v5/src/lib/capabilities/helpers.test.ts | 242 ++++++ v5/src/lib/capabilities/helpers.ts | 42 +- v5/src/lib/capabilities/intake.test.ts | 4 +- v5/src/lib/capabilities/maintenance.test.ts | 49 +- v5/src/lib/capabilities/maintenance.ts | 15 +- v5/src/lib/capabilities/types.ts | 2 +- v5/src/lib/capabilities/units.test.ts | 209 +++++ v5/src/lib/catalog.test.ts | 605 ++++----------- v5/src/lib/catalog.ts | 265 +------ v5/src/lib/data/catalog.test.ts | 732 ++++++++++++++++++ v5/src/lib/data/catalog.ts | 476 ++++++++++++ v5/src/lib/data/maintenance.test.ts | 193 +++++ v5/src/lib/data/maintenance.ts | 149 ++++ v5/src/lib/data/notion-ids.test.ts | 140 ++++ v5/src/lib/data/notion-ids.ts | 92 +++ v5/src/lib/data/projects.test.ts | 197 +++++ v5/src/lib/data/projects.ts | 132 ++++ v5/src/lib/data/resources.test.ts | 141 ++++ v5/src/lib/data/resources.ts | 123 +++ v5/src/lib/data/uuid.ts | 18 + v5/src/lib/db/demo-seed.test.ts | 21 +- v5/src/lib/db/demo-seed.ts | 13 + v5/src/lib/legacy-id.test.ts | 53 ++ v5/src/lib/legacy-id.ts | 56 ++ v5/src/lib/projects.test.ts | 391 ++-------- v5/src/lib/projects.ts | 90 +-- v5/test/README.md | 74 +- 61 files changed, 4510 insertions(+), 1762 deletions(-) create mode 100644 v5/src/lib/capabilities/catalog.test.ts create mode 100644 v5/src/lib/capabilities/helpers.test.ts create mode 100644 v5/src/lib/capabilities/units.test.ts create mode 100644 v5/src/lib/data/catalog.test.ts create mode 100644 v5/src/lib/data/catalog.ts create mode 100644 v5/src/lib/data/maintenance.test.ts create mode 100644 v5/src/lib/data/maintenance.ts create mode 100644 v5/src/lib/data/notion-ids.test.ts create mode 100644 v5/src/lib/data/notion-ids.ts create mode 100644 v5/src/lib/data/projects.test.ts create mode 100644 v5/src/lib/data/projects.ts create mode 100644 v5/src/lib/data/resources.test.ts create mode 100644 v5/src/lib/data/resources.ts create mode 100644 v5/src/lib/data/uuid.ts create mode 100644 v5/src/lib/legacy-id.test.ts create mode 100644 v5/src/lib/legacy-id.ts diff --git a/AGENTS.md b/AGENTS.md index a1c641a..852f127 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,15 +15,15 @@ accepted ISAM 2026 demo paper. > [!IMPORTANT] > **This repository is in maintenance mode.** v5 is the live Cornell Tech -> deployment and stays on Notion. Active development continues in a separate -> repository — see below. Changes here should be fixes and operational -> improvements, not new architecture. +> deployment. Active development continues in a separate repository — see +> below. Changes here should be fixes and operational improvements, not new +> architecture. ## Repository layout — two apps | Path | Version | Status | Data layer | |---|---|---|---| -| `v5/` | v5 | **Live — work here** | Notion (`NOTION_DB_*`) | +| `v5/` | v5 | **Live — work here** | Postgres (`DATABASE_URL`); Notion read only by the one-time import | | `src/` (root) | v4 | Legacy, frozen | AirTable (`AIRTABLE_TABLE_*`) | **Default to `v5/` unless a task explicitly names v4.** The root app is retained @@ -62,7 +62,7 @@ Run from `v5/`: ```bash npm run dev # dev server (:3000) -npm run build # production build +npm run build # runs db:migrate, then production build npm run lint # eslint npm run typecheck # tsc --noEmit npm test # vitest (unit + integration + component) @@ -70,8 +70,9 @@ npm run test:e2e # playwright (needs: npx playwright install chromium) npm run test:all # lint + typecheck + vitest + playwright ``` -`npm run test:all` must pass before any merge. It needs no credentials — the -mock catalog and MSW cover every external service. +`npm run test:all` must pass before any merge. It needs no credentials — an +in-process PGlite database (seeded with demo data) covers Postgres and MSW +covers every other external service. ## Conventions @@ -90,8 +91,11 @@ mock catalog and MSW cover every external service. the default Node runtime. - The in-memory rate limiter is a per-process singleton and resets on cold start. Upstash backs it only when **both** `UPSTASH_REDIS_REST_*` vars are set. -- The mock-catalog fallback triggers when **any** Notion env var is missing *or* a - fetch throws. Great for tests; means a misconfigured deploy fails soft and - silently serves mock data. Check the logs before concluding the catalog is empty. +- `DATABASE_URL` unset serves an in-process PGlite database seeded with demo + data — great for tests and a fresh clone, but check that it's actually set + in a real deploy before concluding the catalog is empty. Unlike the old + Notion fallback, a **configured but unreachable** Postgres never falls back + to demo data — it fails toward stale cache or an explicit error state + (constitution Article 4). - Scripts under `v5/scripts/` run via `node --experimental-strip-types`. They are migration and maintenance tools, not part of the app build. diff --git a/CLAUDE.md b/CLAUDE.md index 7bdc9be..7f3751b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,9 @@ one place stops the two documents from drifting apart. Two things to know before anything else: -1. **The active app is `v5/`** (Notion-backed), not the root `src/` tree (v4, - AirTable, frozen). When working in `v5/`, also read - [`v5/AGENTS.md`](./v5/AGENTS.md). +1. **The active app is `v5/`** (Postgres-backed, with Notion read only by the + one-time import), not the root `src/` tree (v4, AirTable, frozen). When + working in `v5/`, also read [`v5/AGENTS.md`](./v5/AGENTS.md). 2. **This repository is in maintenance mode.** v5 is the live Cornell Tech deployment. Next-generation development happens in a separate repository — see `AGENTS.md`. diff --git a/docs/handover.md b/docs/handover.md index 676826c..a96b648 100644 --- a/docs/handover.md +++ b/docs/handover.md @@ -13,19 +13,24 @@ ## 1. What this system is, in operational terms A website that lists the lab's machines and answers questions about them. The data lives in -**Notion** — seven databases that staff edit directly. The website reads from Notion and -adds an AI assistant on top. +**Postgres** (Neon, provisioned through Vercel) and the website reads from there, with an AI +assistant on top. **The website no longer reads Notion.** Notion was the source of truth +before this phase; its seven databases were read once, by a developer, to move everything +into Postgres, and are not read again. A one-way mirror back into an admin's own Notion +workspace is planned for a later phase. -**Editing the catalogue means editing Notion.** There is no admin login and no dashboard, -by design. If a machine's description is wrong, you fix it in Notion, and the website -catches up within minutes. +**There is no admin login or dashboard yet, so there is currently no way to edit the +catalogue from the website.** Editing the old Notion databases no longer has any effect — +the website stopped reading them. Until inventory editing ships on the website, a wrong +record needs a developer **[dev]** to fix directly in Postgres. Three moving parts, and it is worth knowing which is which when something is wrong: | Part | What it is | Who provides it | |---|---|---| | **The website** | The app itself | Hosted on Vercel | -| **The data** | Seven databases | Notion workspace | +| **The data** | Tools, units, categories, locations, resources, tickets, projects | Neon Postgres (provisioned through Vercel) | +| **Files** | Tool images, manuals, project photos | Vercel Blob | | **The assistant** | The AI | Anthropic API (Claude) | --- @@ -38,14 +43,15 @@ owner. | Thing | Where | Who owns it | Notes | |---|---|---|---| | Vercel project | vercel.com | ⬜ **TBD** | Hosting, deploys, env vars, logs | -| Notion workspace | notion.so | ⬜ **TBD** | The seven databases | -| Notion integration token | Notion settings | ⬜ **TBD** | `NOTION_API_KEY` | +| Neon Postgres | Vercel → Storage → Marketplace | ⬜ **TBD** | **The catalogue's source of truth.** Provisioned through the Vercel Marketplace; sets `DATABASE_URL` automatically | +| Notion workspace | notion.so | ⬜ **TBD** | The seven original databases. No longer read by the website — kept for reference until a one-way mirror is set up | +| Notion integration token | Notion settings | ⬜ **TBD** | `NOTION_API_KEY`. Needed only to re-run the one-time import; not required for the website to run | | `AI_GATEWAY_API_KEY` | Vercel → AI Gateway | ⬜ **TBD** | **The intended production path.** Model spend on the hosting invoice, with a platform spend limit | | Anthropic API key | console.anthropic.com | ⬜ **TBD** | **Fallback.** Costs money per use. Keep it — it is the lever back if the gateway fails | | GitHub repository | github.com | ⬜ **TBD** | The code | | Domain / DNS | ⬜ | ⬜ **TBD** | | | `ADMIN_REVALIDATE_SECRET` | Vercel env vars | ⬜ **TBD** | Forces the site to refresh | -| Vercel Blob store | Vercel → Storage | ⬜ **TBD** | Holds the nightly Notion backup. Sets `BLOB_READ_WRITE_TOKEN`. **Contains student PII — keep private** | +| Vercel Blob store | Vercel → Storage | ⬜ **TBD** | Holds tool images, manuals and project photos (public) alongside maintenance photos and the nightly backup (private). Sets `BLOB_READ_WRITE_TOKEN`. **The private files contain student PII — keep those private** | | `CRON_SECRET` | Vercel env vars | ⬜ **TBD** | Lets the nightly backup cron prove it is Vercel (§3) | > [!WARNING] @@ -96,20 +102,29 @@ for what remains to verify before production traffic goes through it. ### Add a machine to the catalogue -1. In Notion, add a row to the **Tools** database. -2. Fill in at least: name, description, category, location. -3. Add units in the **Units** database, linked to the tool — one row per physical machine, - with serial and status. -4. Add manuals or SOPs to **Resources**, linked to the tool. -5. Tick **published**. Until you do, it will not appear on the site. -6. Wait a few minutes, or force a refresh (§4). - -**The assistant picks it up automatically.** No deploy, no developer. +> [!WARNING] +> **This no longer works.** The steps below edited Notion, which the website read. The +> website now reads Postgres and does not read Notion at all, and the on-website editor that +> replaces this is not built yet. Adding a machine today needs a developer **[dev]** to +> insert it directly in Postgres. Once inventory editing ships on the website, this section +> will be rewritten to match it. +> +> ~~1. In Notion, add a row to the **Tools** database.~~ +> ~~2. Fill in at least: name, description, category, location.~~ +> ~~3. Add units in the **Units** database, linked to the tool — one row per physical machine, +> with serial and status.~~ +> ~~4. Add manuals or SOPs to **Resources**, linked to the tool.~~ +> ~~5. Tick **published**. Until you do, it will not appear on the site.~~ +> ~~6. Wait a few minutes, or force a refresh (§4).~~ ### Mark a machine out of service -Change the unit's **status** in the Units database to `Under Maintenance` or `Out of -Service`. The site shows it as offline, and the assistant stops recommending it. +> [!WARNING] +> **This no longer works** — editing Notion has no effect on the website (see above). +> ~~Change the unit's **status** in the Units database to `Under Maintenance` or `Out of +> Service`. The site shows it as offline, and the assistant stops recommending it.~~ Until +> inventory editing ships on the website, this needs a developer **[dev]** to change directly +> in Postgres. ### Handle a maintenance ticket @@ -125,10 +140,12 @@ it and how often, and write that down here: ### Fix something the assistant got wrong -Almost always a data problem, not an AI problem. The assistant answers from Notion, so a -wrong answer usually means a wrong or empty field. Fix the record; the answer changes. +Almost always a data problem, not an AI problem. The assistant answers from Postgres, so a +wrong answer usually means a wrong or empty field there — which, until inventory editing +ships on the website, needs a developer **[dev]** to fix directly. Editing the old Notion +record does nothing; the website does not read it. -If it is wrong *and* the Notion record is right, that is a real bug — see §6. +If it is wrong *and* the underlying record is right, that is a real bug — see §6. ### Change branding, colours, or the assistant's name @@ -185,7 +202,8 @@ There is no automated restore, on purpose — it is far more work than the failu ## 4. Forcing the site to refresh -The site caches Notion data for a few minutes. To make a change appear immediately: +The site caches catalogue data from Postgres for a few minutes. To make a change appear +immediately: ``` POST https:///api/admin/revalidate @@ -204,7 +222,8 @@ effect. |---|---|---| | Anthropic console | **Spend.** Set a limit and an alert. | Weekly, at minimum | | Vercel dashboard | Failed deploys, function errors | When something looks wrong | -| Vercel logs | `Falling back to mock catalog` | Whenever the catalogue looks odd | +| Vercel logs | `DbUnavailableError` (Postgres unreachable) | Whenever the catalogue looks odd | +| Vercel → Storage | The Neon database is reachable | Whenever the catalogue looks odd | | Vercel → Cron Jobs | The nightly backup ran green | Monthly — see §3 | | Notion: Maintenance_Logs | Open tickets | Per §3 | @@ -217,25 +236,33 @@ recoverable; an unbounded bill is not. ### The site shows machines the lab doesn't own -**Most likely cause, and it is not obvious.** The app falls back to a built-in demo -catalogue whenever it cannot reach Notion — a missing or misspelled environment variable, an -expired integration token, or a database that stopped being shared with the integration. +**Now much less likely, and it no longer fails silently.** Before this phase, the app fell +back to a built-in demo catalogue whenever it could not reach Notion, with no visible error. +That is no longer how a database problem shows up: -The site does **not** show an error. It looks perfectly healthy while serving fictional -equipment. +- If `DATABASE_URL` is missing from the Vercel project — a genuine misconfiguration — the + site serves the same small built-in demo catalogue used in development and testing, and + shows a banner saying so. +- If `DATABASE_URL` is set but Postgres cannot be reached, the site keeps serving whatever it + last cached rather than switching to demo data, and any page that isn't cached shows an + error instead of inventing machines. -1. Check the Vercel logs for `Falling back to mock catalog`. -2. Check every `NOTION_DB_*` variable and `NOTION_API_KEY` in Vercel. -3. In Notion, confirm each database is still shared with the integration. +1. Check the Vercel logs for `DbUnavailableError`. +2. Check `DATABASE_URL` is set in the Vercel project's environment variables. +3. Check the Neon dashboard (Vercel → Storage) — a suspended or deleted branch is the usual + cause. **[dev]** ### A machine is missing from the site -Check `published` is ticked in Notion. Then force a refresh (§4). +Check `published` is set on the tool in Postgres. Then force a refresh (§4). Until inventory +editing ships on the website, changing `published` needs a developer **[dev]**. -### A field is empty on the site but filled in Notion +### A field is empty on the site but filled in the old Notion databases -Someone probably renamed the property in Notion. The app matches property names and cannot -follow a rename. Compare against `v5/.env.example` and the other rows. **[dev]** if unclear. +The website reads Postgres, not Notion, so this means the one-time import either mapped that +field differently than expected or the field has since been edited in Postgres. **[dev]**: +compare the row in Postgres against the Notion page it was imported from +(`notion_page_id` on the row) to see where the two diverge. ### The assistant is down or erroring @@ -279,10 +306,14 @@ The live app is `v5/`. The root `src/` directory is the old v4 app and is not us Nothing about Cornell is hardcoded. -1. Duplicate the seven Notion databases into the new workspace. -2. Create a Notion integration and share all seven databases with it. -3. Deploy the repo to Vercel. -4. Set `NOTION_API_KEY`, the seven `NOTION_DB_*` IDs, and `ANTHROPIC_API_KEY`. +1. Deploy the repo to Vercel. +2. Add Neon Postgres and a Blob store to the project through the Vercel Marketplace / + Storage tab — this sets `DATABASE_URL` and `BLOB_READ_WRITE_TOKEN` automatically. Run + `npm run db:migrate` once to apply the schema. +3. Set `ANTHROPIC_API_KEY` (or `AI_GATEWAY_API_KEY`, §2). +4. **Only if migrating an existing Notion-based catalogue:** set `NOTION_API_KEY` and the + seven `NOTION_DB_*` IDs, and run `npm run import:notion` once against the new database. A + brand-new lab with no existing data skips this step entirely. 5. Override the `NEXT_PUBLIC_*` branding variables. 6. Replace the logo in `v5/public/`. @@ -293,9 +324,13 @@ Full variable list with explanations: `v5/.env.example`. ## 9. Known limitations — say these out loud at handover - **No sign-in yet.** The assistant is open to anyone with the URL. Specced, not built. -- **The fallback is silent** (§6). Fix specced in the operational-hardening spec. -- **No analytics.** Notion cannot aggregate, so there is no way to see which machines get - asked about most. This is the main reason a successor project exists. +- **No catalogue editing on the website yet.** Postgres is the source of truth, but the + admin inventory pages that let staff edit it there are a later phase (§6, §8). Until then, a + wrong or missing record needs a developer. +- **The old silent fallback is fixed.** A configured-but-unreachable database now fails + toward stale cached data or an explicit error, never toward invented equipment (§6). +- **No analytics.** There is no way to see which machines get asked about most. This is the + main reason a successor project exists. - **No backup beyond Notion's own version history.** Notion keeps page history; there is no separate export. Consider a periodic manual export of the databases. - **One person built this.** That is the risk this document exists to reduce. If something diff --git a/docs/specs/2026-09-14-v5-data-platform-design.md b/docs/specs/2026-09-14-v5-data-platform-design.md index 65d5c54..8577156 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1012,3 +1012,17 @@ Appended per [`DRIFT.md`](DRIFT.md). Original text above is never edited — the - **`db:migrate` is not yet part of the Vercel build.** Phase 2 adds it to the build command when Neon exists; until then a build needs no database. **Status.** Accepted. Each is a detail the spec left to implementation. + +### 2026-09-15 — Phase 2 built, with as-built details + +**What changed.** Phase 2 (§9) is implemented: every read path is on Postgres through `src/lib/data/` (catalogue, projects, resources, maintenance history), `src/lib/catalog.ts` and `src/lib/projects.ts` keep their exports and their `"use cache"` tags over the new modules, the health route probes Postgres, `/tools/` redirects to the slug, the QR-label script reads Postgres, and the repo docs say Postgres. `npm run build` now runs `db:migrate` first. Details the spec did not name: + +- **A bridge from Postgres ids to Notion page ids, for the writes that are still on Notion.** Every id the app hands around is now a Postgres uuid, but a correction, a maintenance ticket and a project submission still create Notion pages whose relations address Notion *page* ids, and Notion refuses a relation it cannot find. `src/lib/data/notion-ids.ts` reads the page id the import recorded on `tools` and `units`; a row that has none (created after the import, or the demo seed) is written *without* the relation and logged, never with an id Notion would reject. The module leaves with those writes in Phase 3. +- **Tool and project pages are no longer prerendered at build.** `generateStaticParams` needed a reachable database during `next build`, which §3.2 says a build must not need, and Cache Components rejects an empty list. The pages render on first request and are then served from the `catalog` / `projects` cache tags. Two visible consequences: an unknown slug and a legacy-id redirect are resolved inside the dynamic part of a partially prerendered page, so the HTTP status is 200 and the browser performs the not-found render or the redirect; printed QR labels still land on the right page. A real 404 status and a real 308 are a follow-up, not a Phase 2 goal. +- **E2E runs against a production build, with one retry.** Under `next dev`, Playwright's fourteen parallel workers triggered first compiles of several routes at once and Turbopack rewrote the root layout's client chunk mid-response (`ERR_CONTENT_LENGTH_MISMATCH`), so pages intermittently never hydrated and four tests failed on every run. The Playwright web server is now `npm run build && next start` with `DATABASE_URL` and the Notion env blanked, which also exercises the bundled server with PGlite — the same configuration a Vercel preview without a database runs. That leaves about one test execution in a hundred failing, always the same shape: a `Link` click or the locale switch whose request is sent and answered within milliseconds (the trace shows the RSC fetch completing) but whose result the App Router never commits — the URL or the rendered locale stays put for the full assertion timeout. The server is not the bottleneck (56 concurrent requests answer in ~150 ms), no console error is logged, and a hydration-marker wait made no difference, so it is filed as a client-router race under load rather than an app defect. `retries: 1` keeps the gate green and Playwright reports such a test as *flaky*, so the count stays visible instead of hidden. Worth revisiting on the next Next.js upgrade. +- **`/api/health` reports `database: "ok" | "unreachable" | "demo"`** and `catalog: "live" | "demo"` in place of the Notion fields; a demo substrate is `status: "ok"`, an unreachable database `degraded`, and no error text ever reaches the body. +- **Cache tags.** Every catalogue read is tagged `catalog` only; §3.9's `tool:` tag on detail reads waits for the write paths that would invalidate it, which is also where the choice of key (slug or uuid) belongs. +- **`src/lib/data/uuid.ts`** guards every id lookup so free text from a tool call never reaches Postgres as a bad uuid cast. `listResources()` has no production caller yet; the chat route uses `listResourcesForTool()`. +- **Retired but not deleted:** `src/components/mock-catalog.ts` (only the offline eval fixtures still pin its data), the Notion read helpers in `src/lib/notion.ts`, `test/fixtures/catalog.ts`, and the four Notion maintenance scripts §3.10 lists. Each deletion is proposed separately (constitution working agreements). + +**Status.** Accepted. The bridge and the prerender change are the two a reader should know about before Phase 3. diff --git a/v5/AGENTS.md b/v5/AGENTS.md index e2b8f56..fe0b927 100644 --- a/v5/AGENTS.md +++ b/v5/AGENTS.md @@ -6,8 +6,8 @@ active application. Everything below is scoped to the `v5/` directory. > [!IMPORTANT] > The **root `CLAUDE.md` describes v4** — an AirTable-backed app with > `AIRTABLE_TABLE_*` env vars. **That does not apply to v5.** v5's data layer is -> **Notion** (`NOTION_DB_*`). When working in `v5/`, follow this file, not the -> root v4 doc. +> **Postgres** (`DATABASE_URL`), not Notion and not AirTable. When working in +> `v5/`, follow this file, not the root v4 doc. ## What this is @@ -24,23 +24,54 @@ the catalog to external agents. White-labelled via env vars. - **MCP:** `@modelcontextprotocol/sdk` (HTTP JSON-RPC server at `/api/mcp`). - **Validation:** `zod`. **Search:** `match-sorter` (fuzzy, ranked). -## Data layer — Notion (not AirTable) - -Seven Notion databases, IDs supplied via env (`NOTION_DB_TOOLS`, `_CATEGORIES`, -`_LOCATIONS`, `_UNITS`, `_RESOURCES`, `_MAINTENANCE_LOGS`, `_FLAGS`) plus -`NOTION_API_KEY`. See `.env.example` for the full list and defaults. - -- `src/lib/notion.ts` — server-only Notion REST client: page→record parsers, pagination, 429 retry, `createMaintenanceLog`. Tolerant of snake_case **and** Title-case property names. -- `src/lib/catalog.ts` — orchestration: fetch + join + derive `MakerLabTool`s, cached with `cacheTag("catalog")` / `cacheLife("minutes")`. -- **Mock-catalog fallback (important):** `getCatalogTools()` / `getCatalogTool(id)` return the built-in `src/components/mock-catalog.ts` data whenever `hasNotionCatalogEnv()` is false (**any** Notion env var missing) **or** a fetch throws. So the app — and the whole E2E/test suite — runs with no Notion creds. +## Data layer — Postgres (not Notion, not AirTable) + +**Postgres is the source of truth (constitution Article 7).** Neon Postgres, +provisioned through the Vercel Marketplace, reached through Drizzle ORM. +`DATABASE_URL` set → Neon. `DATABASE_URL` unset → an in-process PGlite +database, migrated and seeded with demo data on first use (two tools: "Form +4", "Trotec Speedy 400"), so a fresh clone, local dev, and the whole test +suite run with no credentials and no network. See `.env.example` for the full +variable list. + +- `src/lib/db/client.ts` — the one entry point: `getDb()` (a Drizzle handle), + `dataSubstrate()` (`"neon" | "pglite-demo"`), `pingDb()` (throws + `DbUnavailableError`), `resetDbForTests()`. +- `src/lib/db/schema/index.ts` — tables and vocabulary constants (stored + snake_case, e.g. `in_use`; display text is derived, never stored). +- `src/lib/catalog.ts` — orchestration: fetch + join + derive `MakerLabTool`s + from Postgres, cached with `cacheTag("catalog")` / `cacheLife("minutes")`. +- `src/lib/data/*.ts` — the query modules underneath: `catalog.ts`, + `projects.ts`, `maintenance.ts`, `resources.ts`, plus `uuid.ts` (the shape + guard every untrusted id passes before it reaches a uuid column) and + `notion-ids.ts`. Relative imports with `.ts` extensions, no `@/` alias, no + `"server-only"` — `scripts/` loads them under plain Node. +- **Notion is read only by the one-time import** (`npm run import:notion`). + No request path reads Notion. A one-way mirror (app → an admin's own Notion + workspace) is a later phase, not built yet. +- **The three writes still on Notion address pages, not rows.** A correction, + a maintenance ticket and a project submission create Notion pages whose + `relation` properties need *Notion page ids*, while everything the app hands + around is now a Postgres uuid. `src/lib/data/notion-ids.ts` translates + through `notion_page_id`, and a row that has none is written without the + relation rather than with an id Notion would reject — a ticket a human has + to link by hand beats a ticket that never arrived (Article 4). It goes away + with those writes in Phase 3. +- **Files** (tool images, manuals, project photos) live in **Vercel Blob**, + not Notion attachments — see `next.config.ts`'s `images.remotePatterns`. +- **Failing toward stale, not wrong (Article 4).** `DATABASE_URL` unset serves + the PGlite demo seed with `DemoDataBanner` shown. `DATABASE_URL` set but + unreachable never falls back to demo or invented data — cached pages keep + serving and an uncached read renders the error state. ## Key files | Path | Purpose | |---|---| | `src/lib/site-config.ts` | White-label branding (env-driven, all have defaults) | -| `src/lib/notion.ts` | Notion API client (server-only) | -| `src/lib/catalog.ts` | Catalog orchestration + cache + mock fallback | +| `src/lib/db/client.ts` | `getDb()`, `dataSubstrate()`, `pingDb()` — the one entry point to Postgres/PGlite | +| `src/lib/notion.ts` | Notion API client — write paths still on Notion until Phase 3 (tickets, corrections, project submission); no request path reads it | +| `src/lib/catalog.ts` | Catalog orchestration + cache, reading Postgres | | `src/lib/rate-limit.ts` | In-memory (or Upstash) sliding-window limiter | | `src/lib/types.ts` / `src/components/catalog-types.ts` | Notion record types / resolved view types | | `src/app/api/chat/route.ts` | Claude chat: streaming, tools (`get_unit_details`, `report_issue`, `web_fetch`), PDF manual attach | @@ -62,8 +93,8 @@ Seven Notion databases, IDs supplied via env (`NOTION_DB_TOOLS`, `_CATEGORIES`, ## Testing -Comprehensive, **fully-mocked** suite (no live Notion/Anthropic/Redis). Run -everything with one command: +Comprehensive, **fully-mocked** suite (no live Notion/Anthropic/Redis/Postgres — +Article 3). Run everything with one command: ```bash npm run test:all # lint + typecheck + vitest + playwright @@ -74,7 +105,10 @@ Or individually: `npm test` (Vitest: unit + integration + component), first), `npm run test:coverage`. - **Vitest** (jsdom) + React Testing Library + MSW; **Playwright** for E2E. -- E2E boots its own server on **port 3100** with `NOTION_*` unset (mock catalog) and intercepts `/api/chat` — it never touches your `:3000` dev server or real services. +- Catalogue reads run against an in-process PGlite database seeded with demo + data (`getCatalogTools()` needs no Notion env at all); write paths still on + Notion (§ above) are covered with `vi.stubEnv` + MSW as before. +- E2E boots its own server on **port 3100** with `DATABASE_URL` unset (PGlite demo catalog) and intercepts `/api/chat` — it never touches your `:3000` dev server or real services. - Tests are colocated (`*.test.ts(x)` next to source); shared harness in `test/`. - **Read these before writing tests:** `TESTING.md` (runbook), `test/README.md` (harness internals + the `streamText`-capture and env-stubbing patterns), and `docs/specs/2026-05-29-v5-test-suite-design.md` (design + coverage matrix). The harness deps/scripts are already wired — don't hand-edit `package.json` for them. @@ -82,7 +116,7 @@ first), `npm run test:coverage`. ```bash npm run dev # dev server (:3000) -npm run build # production build +npm run build # runs db:migrate, then production build npm run lint # eslint npm run typecheck # tsc --noEmit npm run test:all # full test suite diff --git a/v5/TESTING.md b/v5/TESTING.md index 43bced1..2babbef 100644 --- a/v5/TESTING.md +++ b/v5/TESTING.md @@ -9,15 +9,15 @@ import paths), see [`test/README.md`](./test/README.md). ## Overview The suite has four layers, all **fully mocked — no live services**. Tests never -hit Notion, Anthropic, or Upstash; there are no API keys and no network cost. -Everything is deterministic and offline. +hit Postgres, Notion, Anthropic, or Upstash; there are no API keys and no +network cost. Everything is deterministic and offline. | Layer | What | Where it lives | Runner | |---|---|---|---| | Unit | `src/lib`, `src/i18n` pure logic | colocated `*.test.ts` next to source | Vitest | | Integration | API routes (`/api/*`) with HTTP + module mocks | colocated `route.test.ts` next to the route | Vitest | | Component | React UI via React Testing Library | colocated `*.test.tsx` next to the component | Vitest | -| E2E | Full app against the mock catalog | `e2e/*.spec.ts` | Playwright | +| E2E | Full app against the PGlite demo catalog | `e2e/*.spec.ts` | Playwright | Shared infra lives in `test/` (MSW server + handlers, fixtures, mocks, the RTL `render` helper). Tests import from there; **don't edit `package.json` or run @@ -58,16 +58,21 @@ Playwright boots its own dev server (see E2E notes below), so no separate `test/mocks/next-cache.ts`. - **`server-only`** is aliased to an empty stub (`test/mocks/server-only.ts`) in `vitest.config.ts`, so `rate-limit.ts` and its importers load under Vitest. -- **Mock-catalog fallback rule.** `getCatalogTools()` / `getCatalogTool(id)` - return the built-in `src/components/mock-catalog.ts` data when - `hasNotionCatalogEnv()` is false — i.e. **any** of the 8 Notion env vars - (`NOTION_API_KEY` + the 7 `NOTION_DB_*`) is missing — and also on any thrown - error during fetch. So: - - **Mock path** (default in tests): leave `NOTION_*` unset → mock data, no MSW - needed. - - **Real Notion path**: `vi.stubEnv` all 8 vars (set `NOTION_DB_*` to the - `DB_IDS` sentinels from `test/msw/handlers.ts`) → MSW serves - `api.notion.com`. See the `stubNotionEnv()` helper in `test/README.md`. +- **The PGlite rule.** `getCatalogTools()` / `getCatalogTool(id)` read from an + in-process **PGlite** database — real Postgres, compiled to WebAssembly, + migrated and seeded with demo data (two tools: `form-4`, + `trotec-speedy-400`) — whenever `DATABASE_URL` is unset, which is the + default in every test. So: + - **Demo-seed path** (default in tests): leave `DATABASE_URL` unset → the + seeded PGlite database, no MSW needed, no Notion env at all. Put + `// @vitest-environment node` at the top of any file that touches PGlite. + - **An isolated database**: `createPgliteDb()` from `src/lib/db/pglite.ts` + returns a fresh instance for a test that seeds its own rows. + - **Write paths still on Notion this phase** (tickets, corrections, project + submission, uploads, intake): `vi.stubEnv` all 8 Notion vars (set + `NOTION_DB_*` to the `DB_IDS` sentinels from `test/msw/handlers.ts`) → MSW + serves `api.notion.com`. See the `stubNotionEnv()` helper in + `test/README.md`. ## How to add a test @@ -147,18 +152,17 @@ directly. Also mock `@ai-sdk/anthropic`. The full verified snippet is in ## E2E notes -- Playwright's `webServer` boots `npx next dev -p 3100` with all `NOTION_*` vars - set to `""`, so `hasNotionCatalogEnv()` is false and the app serves the - built-in mock catalog (`src/components/mock-catalog.ts`) regardless of your +- Playwright's `webServer` boots `npx next dev -p 3100` with `DATABASE_URL` + unset, so the app serves the seeded PGlite demo database regardless of your dev shell's environment. `testDir` is `./e2e`; `baseURL` is `http://localhost:3100`. - `/api/chat` is intercepted **inside each spec** at the network layer via `page.route()` returning a UI-message stream chunk — no real Anthropic call. - `reuseExistingServer: false` — Playwright **always** boots its own fresh - mock-backed server on the dedicated port 3100. This means E2E never collides - with (or accidentally reuses) a `next dev` you have running on the default - port 3000 against real Notion, so results are deterministic no matter what you - have running locally. Port 3000 is left untouched. + PGlite-backed server on the dedicated port 3100. This means E2E never + collides with (or accidentally reuses) a `next dev` you have running on the + default port 3000 against a real database, so results are deterministic no + matter what you have running locally. Port 3000 is left untouched. ## Coverage diff --git a/v5/e2e/corrections.spec.ts b/v5/e2e/corrections.spec.ts index 31d9b44..dadd94c 100644 --- a/v5/e2e/corrections.spec.ts +++ b/v5/e2e/corrections.spec.ts @@ -10,11 +10,12 @@ import { test, expect } from "@playwright/test"; // intercepts that request with page.route() instead — the same technique // chat.spec.ts uses — so nothing leaves the machine. // -// Mock catalog: "Form 4" is slug `form-4`, Notion id `tool-form-4` -// (src/components/mock-catalog.ts). Strings are `flag.*` in messages/en.json. +// Demo catalogue: "Form 4" is slug `form-4`; its id is the Postgres uuid the +// seed assigned (src/lib/db/demo-seed.ts). Strings are `flag.*` in +// messages/en.json. const FLAG_ENDPOINT = "**/api/flags"; -const TOOL_ID = "tool-form-4"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; test.describe("Report a correction", () => { test("the control is reachable from a tool detail page", async ({ page }) => { @@ -102,9 +103,9 @@ test.describe("Report a correction", () => { ).toHaveCount(0); // The report identifies the specific field, not just the tool (spec §2), and - // carries the tool's Notion id rather than its slug. + // carries the tool's database id rather than its slug. expect(submitted).toMatchObject({ - tool_id: TOOL_ID, + tool_id: expect.stringMatching(UUID), field_flagged: "description", issue_description: "The build volume says 220 mm, but it is 256 mm.", }); diff --git a/v5/e2e/gallery.spec.ts b/v5/e2e/gallery.spec.ts index cc71e35..ed833f7 100644 --- a/v5/e2e/gallery.spec.ts +++ b/v5/e2e/gallery.spec.ts @@ -1,11 +1,11 @@ import { test, expect } from "@playwright/test"; -// The app boots with NOTION_* unset (see playwright.config.ts webServer.env), -// so getCatalogTools() serves the built-in mock catalog -// (src/components/mock-catalog.ts): "Form 4" and "Trotec Speedy 400". +// The app boots with DATABASE_URL and NOTION_* unset (see playwright.config.ts +// webServer.env), so getCatalogTools() serves the PGlite demo seed +// (src/lib/db/demo-seed.ts): "Form 4" and "Trotec Speedy 400". test.describe("Gallery", () => { - test("loads at / and shows the mock-catalog tools", async ({ page }) => { + test("loads at / and shows the demo-catalogue tools", async ({ page }) => { await page.goto("/"); // Gallery heading from messages/en.json gallery.title => "TOOLS // MACHINES". @@ -34,7 +34,7 @@ test.describe("Gallery", () => { test("shows the catalog status strip count", async ({ page }) => { await page.goto("/"); // GlobalChrome status strip renders status.toolsInInventory: - // "{count} TOOLS IN INVENTORY" with count=2 (mock catalog has 2 tools). + // "{count} TOOLS IN INVENTORY" with count=2 (the demo seed has 2 tools). await expect(page.getByText("2 TOOLS IN INVENTORY")).toBeVisible(); }); }); diff --git a/v5/e2e/projects.spec.ts b/v5/e2e/projects.spec.ts index beb43a3..7822c01 100644 --- a/v5/e2e/projects.spec.ts +++ b/v5/e2e/projects.spec.ts @@ -105,7 +105,7 @@ test.describe("Project submission form", () => { page.getByRole("textbox", { name: "Link (optional)" }) ).toBeVisible(); - // Tools-used chips come from the mock catalog via getCatalogTools(). + // Tools-used chips come from the demo catalogue via getCatalogTools(). await expect( page.getByRole("button", { name: "Form 4", exact: true }) ).toBeVisible(); @@ -223,7 +223,10 @@ test.describe("Project submission form", () => { const payload = bodies[0] as Record; expect(payload.title).toBe("Parametric stool"); expect(payload.author).toBe("Ada Lovelace"); - expect(payload.tools).toEqual(["tool-trotec-speedy-400"]); + // The chosen tool travels as its database id (a uuid), not its slug. + expect(payload.tools).toEqual([ + expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + ]); expect(payload).not.toHaveProperty("published"); }); diff --git a/v5/e2e/tool-detail.spec.ts b/v5/e2e/tool-detail.spec.ts index e00dda9..b8485e3 100644 --- a/v5/e2e/tool-detail.spec.ts +++ b/v5/e2e/tool-detail.spec.ts @@ -1,7 +1,9 @@ import { test, expect } from "@playwright/test"; -// Mock catalog: Form 4 (slug "form-4") has one unit "Form 4 // A" (serial -// ML-F4-001) and two resource links (Form 4 SOP / Resin handling safety). +// Demo seed: Form 4 (slug "form-4") has one unit "Form 4 // A" (serial +// ML-F4-001), two resource links (Form 4 SOP / Resin handling safety), and a +// `notionPageId` of 1f2e3d4c-5b6a-4789-8abc-def012345678 (src/lib/db/demo-seed.ts) +// used below to exercise the legacy `/tools/` redirect (spec Goal 2). test.describe("Tool detail", () => { test("deep-link /tools/form-4 shows name, units and resources", async ({ @@ -47,4 +49,24 @@ test.describe("Tool detail", () => { // Trotec unit serial. await expect(page.getByText("ML-LSR-400").first()).toBeVisible(); }); + + test("a legacy Notion-id link redirects permanently to the slug, preserving ?src=qr", async ({ + page, + }) => { + // Undashed, as it appears in a printed QR code — lands on the current slug. + await page.goto("/tools/1f2e3d4c5b6a47898abcdef012345678"); + await expect(page).toHaveURL(/\/tools\/form-4$/); + await expect( + page.getByRole("heading", { name: "Form 4", level: 1 }) + ).toBeVisible(); + + // Dashed, with the QR marker — the marker survives the redirect. + await page.goto( + "/tools/1f2e3d4c-5b6a-4789-8abc-def012345678?src=qr" + ); + await expect(page).toHaveURL(/\/tools\/form-4\?src=qr$/); + await expect( + page.getByRole("heading", { name: "Form 4", level: 1 }) + ).toBeVisible(); + }); }); diff --git a/v5/evals/fixtures.ts b/v5/evals/fixtures.ts index 8b2d6a8..e23b84b 100644 --- a/v5/evals/fixtures.ts +++ b/v5/evals/fixtures.ts @@ -2,13 +2,16 @@ import { mockTools } from "@/components/mock-catalog"; import type { MakerLabTool } from "@/components/catalog-types"; /** - * Eval fixtures (design spec §3). The eval suite runs with every `NOTION_*` - * variable unset, so `getCatalogTools()` serves `src/components/mock-catalog.ts` - * — a fixed, two-machine catalog. This module pins that same data into the - * shape the assertions need, so an assertion can name exact values instead of - * guessing what the live catalog happens to contain today. + * Eval fixtures (design spec §3). The eval suite runs with `DATABASE_URL` + * unset, so `getCatalogTools()` serves the PGlite demo seed — a fixed, + * two-machine catalog — and `run.eval.ts` builds the fixture from that same + * catalogue, so an assertion can name exact values and can never police a + * machine the model was not given. * - * Everything here is pure data derived from the mock catalog plus two small + * {@link evalFixture} is the offline default the assertion unit tests use: the + * same two machines, pinned as literal data so those tests need no database. + * + * Everything here is pure data derived from a catalog plus two small * hand-maintained lists: * * - {@link EXTRA_ALIASES} — other names a catalog machine legitimately goes by @@ -198,7 +201,7 @@ function toFixtureTool(tool: MakerLabTool): EvalFixtureTool { }; } -/** Build the fixture from a catalog (defaults to the mock catalog). */ +/** Build the fixture from a catalog (defaults to the pinned two machines). */ export function buildFixture(tools: MakerLabTool[] = mockTools): EvalFixture { const fixtureTools = tools.map(toFixtureTool); @@ -213,5 +216,5 @@ export function buildFixture(tools: MakerLabTool[] = mockTools): EvalFixture { }; } -/** The fixture the runner uses: the mock catalog, pinned. */ +/** The offline fixture the assertion tests use: the two machines, pinned. */ export const evalFixture: EvalFixture = buildFixture(); diff --git a/v5/evals/harness.test.ts b/v5/evals/harness.test.ts index 35c60a5..0209dba 100644 --- a/v5/evals/harness.test.ts +++ b/v5/evals/harness.test.ts @@ -6,9 +6,11 @@ import { composeCase, stubWrites } from "./harness"; vi.mock("next/cache", () => nextCacheMock()); -// Blank the Notion contract so the catalog is the mock catalog no matter what -// the developer has in their shell — the same rail `npm run eval` applies. +// Blank `DATABASE_URL` so the catalog is the demo seed, and the Notion contract +// so no write path can reach Notion, no matter what the developer has in their +// shell — the same rails `npm run eval` applies. beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); for (const key of getNotionEnvContract()) vi.stubEnv(key, ""); }); diff --git a/v5/evals/run.eval.ts b/v5/evals/run.eval.ts index f732d01..d3bdaa9 100644 --- a/v5/evals/run.eval.ts +++ b/v5/evals/run.eval.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { anthropic } from "@ai-sdk/anthropic"; import { generateText, stepCountIs, type LanguageModel } from "ai"; -import { hasNotionCatalogEnv } from "@/lib/catalog"; +import { getCatalogTools, isDemoCatalog } from "@/lib/catalog"; import { CHAT_MODEL_ID, GATEWAY_CHAT_MODEL_ID, @@ -12,7 +12,7 @@ import { } from "@/lib/model"; import { getNotionEnvContract } from "@/lib/notion"; import { loadCases, type EvalCase } from "./cases"; -import { evalFixture } from "./fixtures"; +import { buildFixture } from "./fixtures"; import { composeCase } from "./harness"; import { formatReport, runSuite, type CaseExecution } from "./runner"; @@ -30,8 +30,9 @@ import { formatReport, runSuite, type CaseExecution } from "./runner"; * eval that tested a reimplementation of the prompt would test nothing. * * Safety rails, in order: - * - every `NOTION_*` variable is blanked, so the catalog is the fixed mock - * catalog and no Notion request is possible; + * - `DATABASE_URL` and every `NOTION_*` variable are blanked, so the catalog + * is the fixed demo seed in an in-process PGlite database (spec §3.2) and + * neither a real database nor Notion can be reached; * - every `write` capability tool is stubbed, so an eval can never create a * Notion record even if the model decides to call one; * - the provider-native `web_search` / `web_fetch` tools the chat route adds @@ -79,11 +80,13 @@ async function executeCase(evalCase: EvalCase): Promise { describe("agent evals", () => { beforeAll(() => { - // Blank the Notion contract so `getCatalogTools()` serves the mock catalog - // regardless of what the developer has in their shell. + // Blank `DATABASE_URL` so `getCatalogTools()` serves the demo seed, and the + // Notion contract so the write paths cannot reach Notion either, regardless + // of what the developer has in their shell. + vi.stubEnv("DATABASE_URL", ""); for (const key of getNotionEnvContract()) vi.stubEnv(key, ""); - if (hasNotionCatalogEnv()) { - throw new Error("refusing to run: the Notion environment is still configured"); + if (!isDemoCatalog()) { + throw new Error("refusing to run: the catalog is a real database, not the demo seed"); } if (!process.env.ANTHROPIC_API_KEY && !process.env.AI_GATEWAY_API_KEY) { throw new Error( @@ -96,7 +99,11 @@ describe("agent evals", () => { const cases = loadCases(); console.info(`Running ${cases.length} eval cases against ${MODEL_LABEL}…`); - const report = await runSuite(cases, executeCase, evalFixture, { + // Built from the catalogue the harness actually composes with, so an + // assertion can never police a machine or a manual the model was not given. + const fixture = buildFixture(await getCatalogTools()); + + const report = await runSuite(cases, executeCase, fixture, { onCase: (result) => console.info(` ${result.status.toUpperCase()} ${result.id}`), }); diff --git a/v5/next.config.ts b/v5/next.config.ts index 7c730ce..03fdd00 100644 --- a/v5/next.config.ts +++ b/v5/next.config.ts @@ -5,6 +5,13 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); const nextConfig: NextConfig = { cacheComponents: true, + // PGlite ships its WASM build and its extension tarballs as files it locates + // with `import.meta.url`. Bundled into the server output those become + // `/_next/static/media/...` URLs that nothing can read from disk, so a build + // or a request without `DATABASE_URL` dies on "Extension bundle not found". + // Kept external, it loads from node_modules and the demo database works in a + // built app exactly as it does under `next dev` (spec §3.2). + serverExternalPackages: ["@electric-sql/pglite"], images: { localPatterns: [ { @@ -18,6 +25,10 @@ const nextConfig: NextConfig = { }, ], remotePatterns: [ + { + protocol: "https", + hostname: "*.public.blob.vercel-storage.com", + }, { protocol: "https", hostname: "prod-files-secure.s3.us-west-2.amazonaws.com", @@ -34,6 +45,9 @@ const nextConfig: NextConfig = { protocol: "https", hostname: "v5.airtableusercontent.com", }, + // The Notion/S3/Airtable patterns above can go once every image has been + // re-imported to Vercel Blob; until then, rows imported before the switch + // may still reference them. ], minimumCacheTTL: 3600, }, diff --git a/v5/package.json b/v5/package.json index 2fde6ec..8e7bd83 100644 --- a/v5/package.json +++ b/v5/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "npm run db:migrate && next build", "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", diff --git a/v5/playwright.config.ts b/v5/playwright.config.ts index a2d2d11..d31f250 100644 --- a/v5/playwright.config.ts +++ b/v5/playwright.config.ts @@ -1,22 +1,35 @@ import { defineConfig, devices } from "@playwright/test"; -// E2E runs against the app booted with NO Notion env vars, so getCatalogTools() -// serves the built-in mock catalog (src/components/mock-catalog.ts). The -// /api/chat call is intercepted at the network layer inside each spec via +// E2E runs against a PRODUCTION build (`next build && next start`) booted with +// no `DATABASE_URL` and no Notion env, so the catalogue is the in-process +// PGlite database seeded with the two demo tools (src/lib/db/demo-seed.ts). +// The /api/chat call is intercepted at the network layer inside each spec via // page.route(). No real external services are touched. // +// Why not `next dev`: with parallel workers, the first request to each route +// compiled it on demand and Turbopack rewrote the root layout's client chunk +// while another worker was downloading it (ERR_CONTENT_LENGTH_MISMATCH), so +// pages intermittently never hydrated (data platform spec 2026-09-14, Phase 2 +// amendment). A production server has no compiler in the loop, and it is the +// same bundle a Vercel preview without a database runs. +// // NOTE FOR THE E2E AGENT: run `npx playwright install chromium` once before the // first `npm run test:e2e` — the foundation harness does not install browsers. export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, + // One retry everywhere, not only in CI. With fourteen workers on a large + // machine a handful of interactions a few hundred milliseconds after a + // navigation still occasionally miss (about one test execution in a hundred + // as of Phase 2); a retry keeps the gate green and Playwright reports the + // test as "flaky" rather than hiding it, so the count stays visible. + retries: 1, reporter: process.env.CI ? "github" : "list", use: { // Dedicated test port (not 3000) so the suite never collides with — or - // accidentally reuses — a `next dev` you have running locally against real - // Notion. The webServer below always boots its own mock-backed instance. + // accidentally reuses — a `next dev` you have running locally against a + // real database. The webServer below always boots its own demo instance. baseURL: "http://localhost:3100", trace: "on-first-retry", }, @@ -27,17 +40,22 @@ export default defineConfig({ }, ], webServer: { - command: "npx next dev -p 3100", + // `npm run build` runs `db:migrate` first, which is a no-op with the + // blanked DATABASE_URL below, then `next build`. + command: "npm run build && npx next start -p 3100", url: "http://localhost:3100", - // Always boot a fresh mock-backed server; never reuse whatever is on the + // Always boot a fresh demo-backed server; never reuse whatever is on the // port. Keeps E2E deterministic regardless of the local dev environment. reuseExistingServer: false, - timeout: 120_000, - // Unset Notion env so the app serves the mock catalog. Spreading - // process.env first, then overwriting with "" makes hasNotionCatalogEnv() - // return false regardless of the dev shell's environment. + // The build is most of this budget; `next start` itself is seconds. + timeout: 300_000, + // Blank `DATABASE_URL` so the catalogue is the demo seed, and the Notion + // env so no write path can reach Notion. Spreading process.env first, then + // overwriting with "" wins over both the dev shell and `.env.local`, which + // Next will not override for a key that is already present. env: { ...process.env, + DATABASE_URL: "", NOTION_API_KEY: "", NOTION_DB_TOOLS: "", NOTION_DB_CATEGORIES: "", diff --git a/v5/scripts/generate-qr-labels.test.ts b/v5/scripts/generate-qr-labels.test.ts index f056c6d..dc32e93 100644 --- a/v5/scripts/generate-qr-labels.test.ts +++ b/v5/scripts/generate-qr-labels.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { resolve } from "node:path"; import { buildLabelSheet, @@ -5,6 +6,7 @@ import { deriveLabels, formatLabelLocation, loadSheetStrings, + loadSources, MIN_QR_MM, parseArgs, pickSheetStrings, @@ -13,7 +15,6 @@ import { type LabelSheetStrings, type QrLabelSource, } from "./generate-qr-labels.ts"; -import { mockTools } from "@/components/mock-catalog"; import enMessages from "../messages/en.json"; const MESSAGES_DIR = resolve(process.cwd(), "messages"); @@ -173,27 +174,31 @@ describe("renderLabelSheet", () => { }); }); -describe("buildLabelSheet (integration, mock catalog)", () => { - it("produces one label per tool in the mock catalog", async () => { - const html = await buildLabelSheet(mockTools, { +// The whole script, end to end, over the catalogue it reads in production — +// the demo seed standing in for a real database (spec §3.2). +describe("buildLabelSheet (integration, demo catalogue)", () => { + it("produces one label per tool in the catalogue", async () => { + const sources = await loadSources(); + const html = await buildLabelSheet(sources, { baseUrl: BASE, strings: STRINGS, encodeQr: fakeEncoder, }); const labels = html.match(/class="qr-label"/g) || []; - expect(labels).toHaveLength(mockTools.length); - expect(mockTools.length).toBeGreaterThan(0); + expect(labels).toHaveLength(sources.length); + expect(sources.length).toBeGreaterThan(0); }); it("encodes each tool's own ?src=qr URL and prints its name", async () => { - const html = await buildLabelSheet(mockTools, { + const sources = await loadSources(); + const html = await buildLabelSheet(sources, { baseUrl: BASE, strings: STRINGS, encodeQr: fakeEncoder, }); - for (const tool of mockTools) { + for (const tool of sources) { expect(html).toContain(`${BASE}/tools/${tool.slug}?src=qr`); expect(html).toContain(tool.name); } @@ -201,8 +206,9 @@ describe("buildLabelSheet (integration, mock catalog)", () => { }); it("drops unpublished tools from the sheet", async () => { + const sources = await loadSources(); const html = await buildLabelSheet( - [...mockTools.map((tool) => ({ ...tool, published: false })), source()], + [...sources.map((tool) => ({ ...tool, published: false })), source()], { baseUrl: BASE, strings: STRINGS, encodeQr: fakeEncoder } ); @@ -211,6 +217,26 @@ describe("buildLabelSheet (integration, mock catalog)", () => { }); }); +describe("loadSources (Postgres, PGlite demo seed)", () => { + it("reads published, non-archived tools ordered by name", async () => { + const sources = await loadSources(); + + expect(sources.map((source) => source.slug)).toEqual(["form-4", "trotec-speedy-400"]); + expect(sources.every((source) => source.published)).toBe(true); + + const form4 = sources.find((source) => source.slug === "form-4"); + expect(form4).toMatchObject({ name: "Form 4", location: "MakerLab", zone: "Resin Bench" }); + }); + + it("produces working label URLs end to end from Postgres", async () => { + const sources = await loadSources(); + expect(deriveLabels(sources, BASE).map((label) => label.url)).toEqual([ + "https://tools.example.edu/tools/form-4?src=qr", + "https://tools.example.edu/tools/trotec-speedy-400?src=qr", + ]); + }); +}); + describe("sheet strings", () => { it("picks the qr namespace out of a locale catalog", () => { const strings = pickSheetStrings(enMessages); diff --git a/v5/scripts/generate-qr-labels.ts b/v5/scripts/generate-qr-labels.ts index bcbdcb5..5d31c21 100644 --- a/v5/scripts/generate-qr-labels.ts +++ b/v5/scripts/generate-qr-labels.ts @@ -15,11 +15,15 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { and, asc, eq, isNull } from "drizzle-orm"; import { getDirection, isSupportedLocale, DEFAULT_LOCALE } from "../src/i18n/config.ts"; +import { getDb } from "../src/lib/db/client.ts"; +import { locations, tools } from "../src/lib/db/schema/index.ts"; /** * The minimum a label needs: something to route to, something to print. Both - * the resolved catalog (`MakerLabTool`) and raw Notion metadata satisfy it. + * the resolved catalog (`MakerLabTool`) and a raw Postgres `tools` row (joined + * to its location) satisfy it. */ export interface QrLabelSource { id: string; @@ -338,34 +342,37 @@ export function parseArgs(argv: string[]): CliOptions { } /** - * Catalog source. With Notion configured we read the published Tools DB - * directly — `src/lib/catalog.ts` caches through `next/cache`, which only - * works inside a Next runtime. Without credentials we fall back to the mock - * catalog, so staff can dry-run the layout before wiring anything up. + * Catalog source: published, non-archived tools straight from Postgres + * (spec §3.10 — old labels still resolve because `notion_page_id` isn't + * touched here, and new ones are printed from `slug`). `getDb()` resolves to + * the PGlite demo seed when `DATABASE_URL` is unset, so staff can dry-run the + * layout before a real database is wired up. */ -async function loadSources(): Promise { - const { getNotionEnvContract, fetchAllTools, fetchAllLocations, resolveTools } = - await import("../src/lib/notion.ts"); - - if (!getNotionEnvContract().every((key) => Boolean(process.env[key]))) { - console.warn("Notion env not configured — generating labels from the mock catalog."); - const { mockTools } = await import("../src/components/mock-catalog.ts"); - return mockTools; - } - - const [tools, locations] = await Promise.all([fetchAllTools(), fetchAllLocations()]); - // `fetchAllTools` already filters to published records; keep the flag so - // `deriveLabels` stays the single place that decides. - const publishedById = new Map(tools.map((tool) => [tool.id, tool.fields.published])); - - return resolveTools(tools, [], locations).map((tool) => ({ - id: tool.id, - // Notion-backed tool pages route by page id (catalog.ts sets slug = id). - slug: tool.id, - name: tool.name, - location: tool.location_room, - zone: tool.location_zone, - published: publishedById.get(tool.id), +export async function loadSources(): Promise { + const db = await getDb(); + + const rows = await db + .select({ + id: tools.id, + slug: tools.slug, + name: tools.name, + room: locations.room, + zone: locations.zone, + }) + .from(tools) + .leftJoin(locations, eq(tools.locationId, locations.id)) + .where(and(eq(tools.published, true), isNull(tools.archivedAt))) + .orderBy(asc(tools.name)); + + return rows.map((row) => ({ + id: row.id, + slug: row.slug, + name: row.name, + location: row.room, + zone: row.zone, + // The query already filters to published tools; the flag stays true so + // `deriveLabels` remains the single place that decides. + published: true, })); } diff --git a/v5/src/app/api/chat/rate-limit.route.test.ts b/v5/src/app/api/chat/rate-limit.route.test.ts index 3f1bc50..5266036 100644 --- a/v5/src/app/api/chat/rate-limit.route.test.ts +++ b/v5/src/app/api/chat/rate-limit.route.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node /** * `/api/chat` at and over the tiered ceiling (auth design spec §8, §10). * @@ -6,13 +7,15 @@ * an anonymous visitor and a signed-in student hitting the same endpoint from * the same IP get different allowances, and the refusal offers a way forward. * - * The model is still stubbed at the `streamText` boundary — no network (Art. 3). + * The model is still stubbed at the `streamText` boundary, and the catalogue + * comes from the demo-seeded PGlite database — no network (Art. 3). */ import { SESSION_COOKIE_NAME, createSessionPayload, signSession, } from "@/lib/auth/session-cookie"; +import { resetDbForTests } from "@/lib/db/client"; const AUTH_SECRET = "chat-ceiling-test-secret"; @@ -42,19 +45,9 @@ vi.mock("ai", async (importOriginal) => { }; }); -// Notion is never reached (NOTION_* unset → mock catalog), but catalog.ts -// imports it, so stub the module surface it uses. -vi.mock("@/lib/notion", () => ({ - fetchMaintenanceLogsByUnit: vi.fn(async () => []), - createMaintenanceLog: vi.fn(), - fetchAllResources: vi.fn(async () => []), - getNotionEnvContract: () => ["NOTION_API_KEY"], - fetchAllTools: vi.fn(async () => []), - fetchAllCategories: vi.fn(async () => []), - fetchAllLocations: vi.fn(async () => []), - fetchAllUnits: vi.fn(async () => []), - resolveTools: vi.fn(() => []), -})); +// Notion is not stubbed here: no request path in this suite reaches it. The +// catalogue reads Postgres, and the one Notion write left in the capability +// layer (`report_issue`, spec Phase 3) is never called from a ceiling test. vi.mock("next/cache", () => ({ cacheTag: vi.fn(), @@ -97,6 +90,11 @@ function chatRequest({ ip, cookie }: { ip: string; cookie?: string }): Request { beforeEach(() => { vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("DATABASE_URL", ""); +}); + +afterAll(() => { + resetDbForTests(); }); describe("POST /api/chat — anonymous ceiling", () => { diff --git a/v5/src/app/api/chat/route.test.ts b/v5/src/app/api/chat/route.test.ts index d56e5c1..1c0a0c3 100644 --- a/v5/src/app/api/chat/route.test.ts +++ b/v5/src/app/api/chat/route.test.ts @@ -1,7 +1,7 @@ +// @vitest-environment node /* eslint-disable @typescript-eslint/no-explicit-any -- this suite inspects the loosely-typed { system, messages, tools } object captured from the mocked streamText call; precise typing here would add noise without value. */ -import type { MaintenanceLogRecord, ResourceRecord } from "@/lib/types"; // ── Captured streamText args ───────────────────────────────────────── // streamText is mocked so we can inspect the { system, messages, tools } @@ -46,16 +46,9 @@ vi.mock("ai", async (importOriginal) => { const mocks = vi.hoisted(() => ({ rateLimitAsync: vi.fn(), checkRateLimit: vi.fn(), - fetchMaintenanceLogsByUnit: vi.fn(), createMaintenanceLog: vi.fn(), - fetchAllResources: vi.fn(), })); -const { - checkRateLimit, - fetchMaintenanceLogsByUnit, - createMaintenanceLog, - fetchAllResources, -} = mocks; +const { checkRateLimit, createMaintenanceLog } = mocks; // ── Mock the rate limiter (default: allowed, set in beforeEach) ────── // The route calls the identity-keyed `checkRateLimit`; the rest of the module @@ -71,29 +64,14 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => { }; }); -// ── Mock Notion calls used by the route's tools / manual collection ── -vi.mock("@/lib/notion", () => ({ - fetchMaintenanceLogsByUnit: mocks.fetchMaintenanceLogsByUnit, - createMaintenanceLog: mocks.createMaintenanceLog, - fetchAllResources: mocks.fetchAllResources, - // catalog.ts also imports from notion.ts, but with NOTION_* env unset the - // catalog uses the mock fallback and never touches these, so stub the rest. - getNotionEnvContract: () => [ - "NOTION_API_KEY", - "NOTION_DB_TOOLS", - "NOTION_DB_CATEGORIES", - "NOTION_DB_LOCATIONS", - "NOTION_DB_UNITS", - "NOTION_DB_RESOURCES", - "NOTION_DB_MAINTENANCE_LOGS", - "NOTION_DB_FLAGS", - ], - fetchAllTools: vi.fn(async () => []), - fetchAllCategories: vi.fn(async () => []), - fetchAllLocations: vi.fn(async () => []), - fetchAllUnits: vi.fn(async () => []), - resolveTools: vi.fn(() => []), -})); +// ── Mock the one Notion call left in the route's tools ─────────────── +// `report_issue` still files its ticket in Notion (spec §9, Phase 3). Every +// other export stays real: nothing else in this request path reads Notion, and +// a full replacement would have to track every import in the capability layer. +vi.mock("@/lib/notion", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createMaintenanceLog: mocks.createMaintenanceLog }; +}); // next/cache is imported by catalog.ts ("use cache" / cacheTag / cacheLife). vi.mock("next/cache", () => ({ @@ -102,29 +80,75 @@ vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), })); +import { eq, inArray } from "drizzle-orm"; import { POST } from "@/app/api/chat/route"; +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { DEMO_FORM_4_UNIT_NOTION_PAGE_ID } from "@/lib/db/demo-seed"; +import { + attachments, + maintenanceLogs, + resources, + tools as toolsTable, + units, +} from "@/lib/db/schema/index"; import { SESSION_COOKIE_NAME, createSessionPayload, signSession, } from "@/lib/auth/session-cookie"; -// A mock-catalog resource fixture builder (mirrors ResourceRecord shape). -function resource( - partial: Partial & { id?: string } -): ResourceRecord { - const { id = "res-1", ...fields } = partial; - return { - id, - createdTime: "2024-01-01T00:00:00.000Z", - lastEditedTime: "2024-01-01T00:00:00.000Z", - fields: { - title: "Manual", - tool: ["tool-form-4"], - published: true, - ...fields, - }, - } as ResourceRecord; +/** + * The catalogue, the units and the resources all come from the demo-seeded + * PGlite database `getDb()` hands out with `DATABASE_URL` unset — the two + * tools `Form 4` (unit `Form 4 // A`) and `Trotec Speedy 400`. The seed carries + * no maintenance logs and no PDF resources; the tests that need them write + * them and clean up after themselves. + */ + +/** Resource rows this file inserted, removed after each test. */ +const insertedResourceIds: string[] = []; + +async function toolId(slug: string): Promise { + const db = await getDb(); + const [row] = await db + .select({ id: toolsTable.id }) + .from(toolsTable) + .where(eq(toolsTable.slug, slug)); + return row.id; +} + +async function unitId(label: string): Promise { + const db = await getDb(); + const [row] = await db + .select({ id: units.id }) + .from(units) + .where(eq(units.unitLabel, label)); + return row.id; +} + +/** Add resources to a seeded tool (default: the Form 4). */ +async function addResources( + rows: Array<{ title: string; url?: string | null; published?: boolean; slug?: string }> +): Promise { + const db = await getDb(); + const values = await Promise.all( + rows.map(async ({ slug = "form-4", ...row }) => ({ + toolId: await toolId(slug), + type: "Manual", + ...row, + })) + ); + const created = await db.insert(resources).values(values).returning({ id: resources.id }); + insertedResourceIds.push(...created.map((row) => row.id)); +} + +async function addMaintenanceLogs( + label: string, + rows: Array> +): Promise { + const db = await getDb(); + const id = await unitId(label); + await db.insert(maintenanceLogs).values(rows.map((row) => ({ title: "Issue", unitId: id, ...row }))); } function chatRequest( @@ -150,6 +174,7 @@ const userMessage = (text: string) => ({ beforeEach(() => { captured.args = undefined; + vi.stubEnv("DATABASE_URL", ""); // Undo any `vi.stubGlobal("fetch", …)` from a prior PDF test (the shared // setup file does not call vi.unstubAllGlobals). vi.unstubAllGlobals(); @@ -161,13 +186,25 @@ beforeEach(() => { retryAfterSeconds: 3600, role: "student", }); - fetchMaintenanceLogsByUnit.mockReset(); - fetchMaintenanceLogsByUnit.mockResolvedValue([]); - fetchAllResources.mockReset(); - fetchAllResources.mockResolvedValue([]); createMaintenanceLog.mockReset(); }); +afterEach(async () => { + const db = await getDb(); + await db.delete(maintenanceLogs); + // The seed carries no attachments, so clearing the table only drops what a + // test wrote. + await db.delete(attachments); + if (insertedResourceIds.length > 0) { + await db.delete(resources).where(inArray(resources.id, insertedResourceIds)); + insertedResourceIds.length = 0; + } +}); + +afterAll(() => { + resetDbForTests(); +}); + describe("POST /api/chat — rate limiting", () => { it("returns 429 with Retry-After and never calls streamText when denied", async () => { checkRateLimit.mockResolvedValueOnce({ @@ -248,7 +285,7 @@ describe("get_unit_details.execute", () => { unit_label: "no-such-unit", }); expect(result.found).toBe(false); - // Sample list drawn from the mock catalog's units. + // Sample list drawn from the seeded catalogue's units. expect(result.message).toMatch(/Form 4 \/\/ A|Trotec Speedy 400/); }); @@ -265,21 +302,18 @@ describe("get_unit_details.execute", () => { expect(result.maintenance_logs).toEqual([]); }); - it("surfaces maintenance logs, sliced to 10", async () => { - const logs: MaintenanceLogRecord[] = Array.from({ length: 12 }, (_, i) => ({ - id: `log-${i}`, - createdTime: "2024-01-01T00:00:00.000Z", - lastEditedTime: "2024-01-01T00:00:00.000Z", - fields: { + it("surfaces maintenance logs, capped at 10 and newest first", async () => { + await addMaintenanceLogs( + "Form 4 // A", + Array.from({ length: 12 }, (_, i) => ({ title: `Issue ${i}`, - type: "Issue Report", - priority: "Medium", - status: "Open", - date_reported: "2024-09-01", + type: "issue_report", + priority: "medium", + status: "open", description: `desc ${i}`, - }, - })) as MaintenanceLogRecord[]; - fetchMaintenanceLogsByUnit.mockResolvedValueOnce(logs); + dateReported: `2024-09-${String(i + 1).padStart(2, "0")}`, + })) + ); const tools = await getTools(); const result = await tools.get_unit_details.execute({ @@ -288,8 +322,9 @@ describe("get_unit_details.execute", () => { expect(result.found).toBe(true); expect(result.maintenance_logs).toHaveLength(10); + // Stored snake_case, shown in the words the assistant has always seen. expect(result.maintenance_logs[0]).toMatchObject({ - title: "Issue 0", + title: "Issue 11", type: "Issue Report", priority: "Medium", status: "Open", @@ -346,13 +381,14 @@ describe("report_issue.execute", () => { priority: "High", }); + // The caller is told the Postgres uuid the catalogue resolved; the ticket, + // still filed in Notion, carries the unit's imported Notion page id — the + // only id that database's `unit` relation can address. + const form4A = await unitId("Form 4 // A"); expect(result.success).toBe(true); - expect(result.unit_resolved).toEqual({ - id: "unit-form-4-a", - label: "Form 4 // A", - }); + expect(result.unit_resolved).toEqual({ id: form4A, label: "Form 4 // A" }); expect(createMaintenanceLog).toHaveBeenCalledWith( - expect.objectContaining({ unit: ["unit-form-4-a"] }) + expect.objectContaining({ unit: [DEMO_FORM_4_UNIT_NOTION_PAGE_ID] }) ); }); @@ -451,11 +487,11 @@ describe("PDF manual collection (focused tool)", () => { } it("attaches small PDFs to the first user message and caps at 3", async () => { - fetchAllResources.mockResolvedValueOnce([ - resource({ id: "r1", title: "Manual 1", url: "https://x.test/m1.pdf" }), - resource({ id: "r2", title: "Manual 2", url: "https://x.test/m2.pdf" }), - resource({ id: "r3", title: "Manual 3", url: "https://x.test/m3.pdf" }), - resource({ id: "r4", title: "Manual 4", url: "https://x.test/m4.pdf" }), + await addResources([ + { title: "Manual 1", url: "https://x.test/m1.pdf" }, + { title: "Manual 2", url: "https://x.test/m2.pdf" }, + { title: "Manual 3", url: "https://x.test/m3.pdf" }, + { title: "Manual 4", url: "https://x.test/m4.pdf" }, ]); const fetchMock = vi.fn(async () => okPdf(PDF_SMALL)); @@ -482,12 +518,12 @@ describe("PDF manual collection (focused tool)", () => { }); it("skips PDFs that are too large or return non-ok, and skips non-PDF resources", async () => { - fetchAllResources.mockResolvedValueOnce([ - resource({ id: "r1", title: "Good", url: "https://x.test/good.pdf" }), - resource({ id: "r2", title: "Huge", url: "https://x.test/huge.pdf" }), - resource({ id: "r3", title: "Dead", url: "https://x.test/dead.pdf" }), + await addResources([ + { title: "Good", url: "https://x.test/good.pdf" }, + { title: "Huge", url: "https://x.test/huge.pdf" }, + { title: "Dead", url: "https://x.test/dead.pdf" }, // Non-PDF url → skipped entirely (never fetched). - resource({ id: "r4", title: "Webpage", url: "https://x.test/page.html" }), + { title: "Webpage", url: "https://x.test/page.html" }, ]); const fetchMock = vi.fn(async (url: string) => { @@ -525,19 +561,9 @@ describe("PDF manual collection (focused tool)", () => { }); it("ignores unpublished resources and resources for other tools", async () => { - fetchAllResources.mockResolvedValueOnce([ - resource({ - id: "r1", - title: "Unpublished", - url: "https://x.test/u.pdf", - published: false, - }), - resource({ - id: "r2", - title: "OtherTool", - url: "https://x.test/o.pdf", - tool: ["tool-trotec-speedy-400"], - }), + await addResources([ + { title: "Unpublished", url: "https://x.test/u.pdf", published: false }, + { title: "OtherTool", url: "https://x.test/o.pdf", slug: "trotec-speedy-400" }, ]); const fetchMock = vi.fn(async () => okPdf(PDF_SMALL)); vi.stubGlobal("fetch", fetchMock); @@ -550,16 +576,43 @@ describe("PDF manual collection (focused tool)", () => { expect(captured.args.system).not.toContain("Available manuals"); }); + it("attaches a resource's uploaded file when the resource itself has no url", async () => { + await addResources([{ title: "Scanned manual", url: null }]); + const db = await getDb(); + const [scanned] = await db + .select({ id: resources.id }) + .from(resources) + .where(eq(resources.title, "Scanned manual")); + await db.insert(attachments).values({ + ownerType: "resource", + ownerId: scanned.id, + blobPathname: "resources/scanned-abc123.pdf", + access: "public", + publicUrl: "https://blob.test/resources/scanned-abc123.pdf", + }); + + const fetchMock = vi.fn(async () => okPdf(PDF_SMALL)); + vi.stubGlobal("fetch", fetchMock); + + await POST( + chatRequest({ messages: [userMessage("help")], toolId: "form-4" }) + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://blob.test/resources/scanned-abc123.pdf", + expect.anything() + ); + expect(captured.args.system).toContain("Scanned manual"); + }); + it("does not run manual collection when no toolId is provided", async () => { - fetchAllResources.mockResolvedValueOnce([ - resource({ id: "r1", title: "Manual 1", url: "https://x.test/m1.pdf" }), - ]); + await addResources([{ title: "Manual 1", url: "https://x.test/m1.pdf" }]); const fetchMock = vi.fn(async () => okPdf(PDF_SMALL)); vi.stubGlobal("fetch", fetchMock); await POST(chatRequest({ messages: [userMessage("hi")] })); - expect(fetchAllResources).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); + expect(captured.args.system).not.toContain("Available manuals"); }); }); diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts index 67f741d..0c983c6 100644 --- a/v5/src/app/api/chat/route.ts +++ b/v5/src/app/api/chat/route.ts @@ -13,9 +13,11 @@ import { type UserModelMessage, } from "ai"; import { getCatalogTool, getCatalogTools } from "../../../lib/catalog"; -import { fetchAllResources } from "../../../lib/notion"; +import { + listResourcesForTool, + type ToolResource, +} from "../../../lib/data/resources"; import type { MakerLabTool } from "../../../components/catalog-types"; -import type { ResourceRecord } from "../../../lib/types"; import { checkRateLimit, type RateLimitDecision } from "../../../lib/rate-limit"; import { resolveIdentity } from "../../../lib/auth/identity"; import { siteConfig } from "../../../lib/site-config"; @@ -50,7 +52,7 @@ interface ChatRequest { export async function POST(req: Request) { // Who is asking, then how much they are allowed — both before any expensive - // work (Notion fetch / model call). Anonymous visitors get a small allowance + // work (catalogue read / model call). Anonymous visitors get a small allowance // keyed by hashed IP; signed-in callers get a generous one keyed by user id // (auth design spec §8). const identity = await resolveIdentity(req); @@ -372,18 +374,15 @@ function uniqueHosts(urls: string[]): string[] { return [...set]; } -function isPdfUrl(url: string | undefined): boolean { +function isPdfUrl(url: string | null | undefined): boolean { if (!url) return false; const cleaned = url.split("?")[0].toLowerCase(); return cleaned.endsWith(".pdf"); } -function pickPdfUrl(resource: ResourceRecord): string | null { - if (isPdfUrl(resource.fields.url)) return resource.fields.url ?? null; - const file = (resource.fields.files || []).find( - (f) => isPdfUrl(f.filename) || isPdfUrl(f.url) - ); - return file?.url || null; +function pickPdfUrl(resource: ToolResource): string | null { + if (isPdfUrl(resource.url)) return resource.url; + return resource.fileUrls.find(isPdfUrl) ?? null; } /** @@ -429,38 +428,35 @@ async function fetchPdfAsBase64( async function collectToolManuals( toolId: string ): Promise<{ manuals: AttachedManual[]; skipped: number }> { - let resources: ResourceRecord[]; + // Only the focused tool's resources are read (spec §3.10, Article 4's "load + // context lazily") — the whole resource table used to come back from Notion + // just to be filtered down to one tool's rows here. + let forTool: ToolResource[]; try { - resources = await fetchAllResources(); + forTool = await listResourcesForTool(toolId); } catch (err) { console.warn("[chat] failed to load resources for manuals", err); return { manuals: [], skipped: 0 }; } - const forTool = resources.filter( - (r) => r.fields.published !== false && (r.fields.tool || []).includes(toolId) - ); - const manuals: AttachedManual[] = []; let skipped = 0; try { for (const r of forTool) { const url = pickPdfUrl(r); if (!url) { - if (r.fields.url) { - console.info( - `[chat] skipping non-PDF resource: ${r.fields.title} (${r.fields.url})` - ); + if (r.url) { + console.info(`[chat] skipping non-PDF resource: ${r.title} (${r.url})`); } continue; } if (manuals.length >= MAX_PDFS_PER_CHAT) { console.info( - `[chat] PDF cap reached (${MAX_PDFS_PER_CHAT}); skipping: ${r.fields.title}` + `[chat] PDF cap reached (${MAX_PDFS_PER_CHAT}); skipping: ${r.title}` ); continue; } - const title = r.fields.title || "Manual"; + const title = r.title || "Manual"; const data = await fetchPdfAsBase64(title, url); if (!data) { skipped += 1; diff --git a/v5/src/app/api/flags/route.test.ts b/v5/src/app/api/flags/route.test.ts index fddc1b0..4ce6754 100644 --- a/v5/src/app/api/flags/route.test.ts +++ b/v5/src/app/api/flags/route.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { http, HttpResponse } from "msw"; import { server } from "../../../../test/msw/server"; import { DB_IDS } from "../../../../test/msw/handlers"; @@ -6,10 +7,27 @@ import { nextCacheMock } from "../../../../test/mocks/next-cache"; // The route reaches the catalog (for tool resolution) which imports next/cache. vi.mock("next/cache", () => nextCacheMock()); +import { getCatalogTools } from "@/lib/catalog"; +import { resetDbForTests } from "@/lib/db/client"; +import { DEMO_FORM_4_NOTION_PAGE_ID } from "@/lib/db/demo-seed"; import { POST } from "./route"; const NOTION = "https://api.notion.com/v1"; -const FORM_4_ID = "tool-form-4"; + +// The route resolves the flagged tool against the demo-seeded PGlite database +// (`DATABASE_URL` unset). Its id is a Postgres uuid minted at seed time, so it +// is looked up rather than hard-coded. +let FORM_4_ID = ""; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + const tools = await getCatalogTools(); + FORM_4_ID = tools.find((tool) => tool.slug === "form-4")?.id ?? ""; +}); + +afterAll(() => { + resetDbForTests(); +}); // The in-memory limiter is a per-process singleton keyed by IP, so each test // gets its own IP to avoid bleeding a spent window into the next one. @@ -79,6 +97,8 @@ describe("POST /api/flags", () => { expect(creates[0].properties).toMatchObject({ status: { select: { name: "New" } }, field_flagged: { select: { name: "location" } }, + // The relation addresses the tool's Notion page, not its Postgres id. + tool: { relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }] }, }); }); diff --git a/v5/src/app/api/health/route.test.ts b/v5/src/app/api/health/route.test.ts index 1d367d0..ef6bb9b 100644 --- a/v5/src/app/api/health/route.test.ts +++ b/v5/src/app/api/health/route.test.ts @@ -1,40 +1,18 @@ -import { http, HttpResponse } from "msw"; - -import { server } from "../../../../test/msw/server"; -import { DB_IDS } from "../../../../test/msw/handlers"; +// @vitest-environment node +// `nextCacheMock` is imported first on purpose: `vi.mock` is hoisted above +// every import, and its factory can only reach a module imported before the +// one it replaces. import { nextCacheMock } from "../../../../test/mocks/next-cache"; vi.mock("next/cache", () => nextCacheMock()); import { cacheLife } from "next/cache"; -import { mockTools } from "../../../components/mock-catalog"; import { HEALTH_CACHE } from "../../../lib/cache"; +import { resetDbForTests } from "../../../lib/db/client"; import { GET } from "./route"; // ── Helpers ───────────────────────────────────────────────────────── -function stubNotionEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); - vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); - vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); - vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); - vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); - vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); - vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); -} - -function unsetNotionEnv() { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_TOOLS", ""); - vi.stubEnv("NOTION_DB_CATEGORIES", ""); - vi.stubEnv("NOTION_DB_LOCATIONS", ""); - vi.stubEnv("NOTION_DB_UNITS", ""); - vi.stubEnv("NOTION_DB_RESOURCES", ""); - vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", ""); - vi.stubEnv("NOTION_DB_FLAGS", ""); -} - // The in-memory limiter is a per-process singleton keyed by IP, so every test // gets its own address rather than eating a shared window. let ipCounter = 0; @@ -45,196 +23,130 @@ function makeRequest(ip = `10.0.0.${++ipCounter}`) { } beforeEach(() => { - // The degraded paths log the real reason server-side; keep it out of the + // Force the demo substrate regardless of the host shell's own env, matching + // the rest of the catalogue test suite (see src/lib/catalog.test.ts). + vi.stubEnv("DATABASE_URL", ""); + // The degraded path logs the real reason server-side; keep it out of the // test output (and prove nothing about it reaches the response body below). vi.spyOn(console, "warn").mockImplementation(() => {}); }); -// ── Healthy ───────────────────────────────────────────────────────── +afterEach(() => { + resetDbForTests(); +}); -describe("GET /api/health — healthy", () => { - it("returns 200 with status ok, notion ok, and a live catalog", async () => { - stubNotionEnv(); +// ── Healthy (demo substrate) ──────────────────────────────────────── +// +// `DATABASE_URL` unset is the default in every test, and it is a normal, +// expected state — not a failure — so it reports `status: "ok"` with +// `database: "demo"`, distinct from a real Postgres that is actually down. +describe("GET /api/health — healthy (demo substrate)", () => { + it("returns 200 with status ok, database demo, and the seeded tool count", async () => { const res = await GET(makeRequest()); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe("ok"); - expect(body.notion).toBe("ok"); - expect(body.catalog).toBe("live"); - expect(body.toolCount).toBeGreaterThan(0); + expect(body.database).toBe("demo"); + expect(body.catalog).toBe("demo"); + expect(body.toolCount).toBe(2); // the seeded demo catalogue: form-4, trotec-speedy-400 expect(Number.isNaN(Date.parse(body.checkedAt))).toBe(false); }); - it("probes Notion with a single page_size:1 query against the Tools database", async () => { - stubNotionEnv(); - - const probes: Array<{ db: string; body: unknown }> = []; - server.use( - http.post( - `https://api.notion.com/v1/databases/${DB_IDS.tools}/query`, - async ({ request }) => { - probes.push({ db: DB_IDS.tools, body: await request.json() }); - return HttpResponse.json({ results: [], has_more: false, next_cursor: null }); - } - ) - ); - - await GET(makeRequest()); - - // First hit is the health probe itself; anything after is the catalog read. - expect(probes.length).toBeGreaterThan(0); - expect(probes[0].body).toEqual({ page_size: 1 }); - }); - it("caches the probe on the 30s health profile", async () => { - stubNotionEnv(); - await GET(makeRequest()); expect(vi.mocked(cacheLife)).toHaveBeenCalledWith(HEALTH_CACHE); }); it("does not let a client or CDN store the verdict", async () => { - stubNotionEnv(); - const res = await GET(makeRequest()); expect(res.headers.get("cache-control")).toBe("no-store"); }); }); -// ── Unconfigured ──────────────────────────────────────────────────── - -describe("GET /api/health — unconfigured", () => { - it("returns 503 with notion unconfigured when the Notion env is empty", async () => { - unsetNotionEnv(); - - const res = await GET(makeRequest()); - - // The status code is the point: an uptime monitor alerts on 503, and would - // never notice a 200 carrying "degraded". - expect(res.status).toBe(503); - const body = await res.json(); - expect(body.status).toBe("degraded"); - expect(body.notion).toBe("unconfigured"); - expect(body.catalog).toBe("mock"); - expect(body.toolCount).toBe(mockTools.length); - }); - - it("returns 503 when a single NOTION_DB_* variable is dropped", async () => { - stubNotionEnv(); - vi.stubEnv("NOTION_DB_UNITS", ""); - - const res = await GET(makeRequest()); +// ── Unreachable (a configured database that pingDb cannot reach) ──── +// +// `dataSubstrate()` and `pingDb()` are mocked directly rather than pointed at +// a real broken connection string, matching the `src/lib/catalog.test.ts` +// "a database failure" pattern: `vi.doMock` + a fresh dynamic `import()`, so +// only this one route instance sees the failure and every other test keeps +// the real module and its memoised PGlite handle. - expect(res.status).toBe(503); - const body = await res.json(); - expect(body.notion).toBe("unconfigured"); +describe("GET /api/health — unreachable", () => { + afterEach(() => { + vi.doUnmock("../../../lib/db/client"); + vi.resetModules(); }); -}); -// ── Unreachable ───────────────────────────────────────────────────── + it("returns 503 with database unreachable, degraded status, no live catalog, and no invented count", async () => { + vi.resetModules(); + vi.doMock("../../../lib/db/client", () => ({ + dataSubstrate: () => "neon", + pingDb: () => Promise.reject(new Error("connection terminated unexpectedly")), + })); + const { GET: GETWithBrokenDb } = await import("./route"); -describe("GET /api/health — unreachable", () => { - it("returns 503 with notion unreachable when the Notion query throws", async () => { - stubNotionEnv(); - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", () => HttpResponse.error()) - ); - - const res = await GET(makeRequest()); + const res = await GETWithBrokenDb(makeRequest()); expect(res.status).toBe(503); const body = await res.json(); expect(body.status).toBe("degraded"); - expect(body.notion).toBe("unreachable"); - expect(body.catalog).toBe("mock"); - expect(body.toolCount).toBe(mockTools.length); - }); - - it("returns 503 with notion unreachable when Notion answers non-2xx", async () => { - stubNotionEnv(); - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", () => - HttpResponse.json({ object: "error", code: "unauthorized" }, { status: 401 }) - ) - ); - - const res = await GET(makeRequest()); - - expect(res.status).toBe(503); - expect((await res.json()).notion).toBe("unreachable"); + expect(body.database).toBe("unreachable"); + expect(body.catalog).toBe("demo"); + // Article 4: never invented data — a Postgres this probe just proved down + // is never asked for a tool count. + expect(body.toolCount).toBe(0); }); }); // ── Disclosure ────────────────────────────────────────────────────── // -// A health endpoint that names the missing variable or echoes the Notion error -// hands an attacker the configuration. The body is three enum words, a count, -// and a timestamp — nothing else, on any path. +// A health endpoint that echoes a driver error or connection string hands an +// attacker the configuration. The body is three enum words, a count, and a +// timestamp — nothing else, on any path. describe("GET /api/health — leaks nothing", () => { - const FORBIDDEN = [ - "NOTION_", - "notion.com", - "secret_test", - "Bearer", - DB_IDS.tools, - DB_IDS.units, - ]; - - it("names no env var, Notion id, or error text when unconfigured", async () => { - unsetNotionEnv(); - - const raw = await (await GET(makeRequest())).text(); - - for (const needle of FORBIDDEN) expect(raw).not.toContain(needle); - }); - - it("does not echo the Notion error body when unreachable", async () => { - stubNotionEnv(); - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", () => - HttpResponse.json( - { - object: "error", - code: "unauthorized", - message: "API token is invalid: secret_test against db-tools", - }, - { status: 401 } - ) - ) - ); - - const raw = await (await GET(makeRequest())).text(); - - for (const needle of FORBIDDEN) expect(raw).not.toContain(needle); - expect(raw).not.toContain("API token is invalid"); - expect(raw).not.toContain("unauthorized"); + afterEach(() => { + vi.doUnmock("../../../lib/db/client"); + vi.resetModules(); }); it("returns exactly the documented fields and nothing more", async () => { - unsetNotionEnv(); - const body = await (await GET(makeRequest())).json(); expect(Object.keys(body).sort()).toEqual([ "catalog", "checkedAt", - "notion", + "database", "status", "toolCount", ]); }); + + it("does not echo the driver error or connection string when unreachable", async () => { + const SENSITIVE = "postgres://admin:hunter2@db.internal.example.com/prod"; + vi.resetModules(); + vi.doMock("../../../lib/db/client", () => ({ + dataSubstrate: () => "neon", + pingDb: () => Promise.reject(new Error(`could not connect to ${SENSITIVE}: ECONNREFUSED`)), + })); + const { GET: GETWithBrokenDb } = await import("./route"); + + const raw = await (await GETWithBrokenDb(makeRequest())).text(); + + for (const needle of [SENSITIVE, "hunter2", "ECONNREFUSED", "postgres://"]) { + expect(raw).not.toContain(needle); + } + }); }); // ── Rate limiting ─────────────────────────────────────────────────── describe("GET /api/health — rate limiting", () => { - it("429s a caller that hammers it, before touching Notion", async () => { - unsetNotionEnv(); + it("429s a caller that hammers it, before touching the database", async () => { const ip = "10.9.9.9"; let last = await GET(makeRequest(ip)); diff --git a/v5/src/app/api/health/route.ts b/v5/src/app/api/health/route.ts index a0c28b7..602accc 100644 --- a/v5/src/app/api/health/route.ts +++ b/v5/src/app/api/health/route.ts @@ -1,90 +1,84 @@ import { cacheLife } from "next/cache"; import { HEALTH_CACHE } from "../../../lib/cache"; import { getCatalogTools } from "../../../lib/catalog"; -import { getNotionEnvContract } from "../../../lib/notion"; +import { dataSubstrate, pingDb } from "../../../lib/db/client"; import { rateLimitAsync } from "../../../lib/rate-limit"; import { resolveIdentity } from "../../../lib/auth/identity"; -import { mockTools } from "../../../components/mock-catalog"; // `runtime` cannot be set when nextConfig.cacheComponents is enabled. // Default Node.js runtime is used. -const NOTION_API_URL = "https://api.notion.com/v1"; -const NOTION_VERSION = "2022-06-28"; const PROBE_TIMEOUT_MS = 5_000; -type NotionHealth = "ok" | "unreachable" | "unconfigured"; +type DatabaseHealth = "ok" | "unreachable" | "demo"; interface HealthReport { status: "ok" | "degraded"; - notion: NotionHealth; - catalog: "live" | "mock"; + database: DatabaseHealth; + catalog: "live" | "demo"; toolCount: number; checkedAt: string; } /** - * One minimal, uncached Notion call — `page_size: 1` against the Tools database. - * It deliberately does *not* go through `catalog.ts`, whose day-long cache would - * happily keep answering through an outage (which is the point of that cache and - * the reason this probe exists). + * `pingDb()` carries no signal of its own (spec §3.2), and a hung connection + * must not hang this endpoint — race it against a plain timer instead. + */ +function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Health probe timed out")), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * One `select 1` against Postgres — or `"demo"` with no round trip at all when + * `DATABASE_URL` is unset, since that runs the app on the in-process PGlite + * demo seed (spec §3.10) rather than on nothing. Demo is a known, expected + * substrate, not a failure, so it gets its own word instead of collapsing into + * `"ok"` or `"unreachable"`. * - * Nothing about the failure escapes this function: the caller gets one of three - * words. A health endpoint that reports `NOTION_DB_TOOLS is missing` or echoes a - * Notion error body hands an attacker the configuration, so the detail is logged - * server-side and nowhere else. + * Nothing about a real failure escapes this function beyond one of three + * words — a health endpoint that echoes a connection string or driver error + * hands an attacker the configuration, so the detail is logged server-side + * and nowhere else. */ -async function probeNotion(): Promise { - const configured = getNotionEnvContract().every((key) => Boolean(process.env[key])); - if (!configured) return "unconfigured"; +async function probeDatabase(): Promise { + if (dataSubstrate() === "pglite-demo") return "demo"; try { - const res = await fetch( - `${NOTION_API_URL}/databases/${process.env.NOTION_DB_TOOLS}/query`, - { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.NOTION_API_KEY}`, - "Content-Type": "application/json", - "Notion-Version": NOTION_VERSION, - }, - body: JSON.stringify({ page_size: 1 }), - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - } - ); - - if (!res.ok) { - console.warn("Health probe: Notion query failed with status", res.status); - return "unreachable"; - } + await withTimeout(pingDb(), PROBE_TIMEOUT_MS); return "ok"; } catch (error) { - console.warn("Health probe: Notion unreachable:", error); + console.warn("Health probe: database unreachable:", error); return "unreachable"; } } /** - * Cached for ~30s so a monitor polling every minute costs at most one Notion - * call per 30s and the endpoint cannot be used to hammer Notion. `checkedAt` is + * Cached for ~30s so a monitor polling every minute costs at most one probe + * per 30s and the endpoint cannot be used to hammer Postgres. `checkedAt` is * captured with the cached value, so it reports when the probe actually ran. * - * The tool count comes from the catalog only when Notion answered; otherwise the - * catalog is on its mock fallback and counting it would mean five more failing - * Notion calls to learn something already known. + * The tool count comes from the catalogue whenever there is one to safely + * read: on the demo seed `getCatalogTools()` reads the local PGlite instance, + * which costs nothing extra to ask. A configured-but-unreachable Postgres is + * the one case that skips it — counting from a database this same probe just + * proved was down would mean either inventing a number or making the failing + * call twice, and Article 4 rules out the former. */ async function checkHealth(): Promise { "use cache"; cacheLife(HEALTH_CACHE); - const notion = await probeNotion(); - const live = notion === "ok"; - const toolCount = live ? (await getCatalogTools()).length : mockTools.length; + const database = await probeDatabase(); + const toolCount = database === "unreachable" ? 0 : (await getCatalogTools()).length; return { - status: live ? "ok" : "degraded", - notion, - catalog: live ? "live" : "mock", + status: database === "unreachable" ? "degraded" : "ok", + database, + catalog: database === "ok" ? "live" : "demo", toolCount, checkedAt: new Date().toISOString(), }; @@ -102,7 +96,7 @@ async function checkHealth(): Promise { * around. */ export async function GET(req: Request) { - // Rate limit before the probe — cheap as it is, it still touches Notion. + // Rate limit before the probe — cheap as it is, it still touches Postgres. const identity = await resolveIdentity(req); const { allowed } = await rateLimitAsync(`health:${identity.rateLimitKey}`, { limit: 30, diff --git a/v5/src/app/api/mcp/route.test.ts b/v5/src/app/api/mcp/route.test.ts index 5cb397b..fa72b54 100644 --- a/v5/src/app/api/mcp/route.test.ts +++ b/v5/src/app/api/mcp/route.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node /** * Integration tests for the MCP API route (`src/app/api/mcp/route.ts`). * @@ -7,10 +8,10 @@ * the route's `POST` handler and assert the JSON responses. * - `enableJsonResponse: true` in the route means the transport returns a single * JSON object (not an SSE stream), so `await res.json()` works directly. - * - Catalog comes from the mock-catalog fallback (no NOTION_* env set), so tool - * results are deterministic and offline. - * - `@/lib/notion`'s `fetchMaintenanceLogsByUnit` is mocked so the maintenance - * tools are deterministic without any network. + * - Catalog and maintenance history come from the demo-seeded PGlite database + * `getDb()` hands out with `DATABASE_URL` unset, so results are deterministic + * and offline. The seed has no maintenance logs; the tests that need them + * write them. * * SDK quirk discovered: the WebStandardStreamableHTTPServerTransport is created * with `sessionIdGenerator: undefined` (stateless mode). In that mode the @@ -23,27 +24,30 @@ * each method statelessly. */ +// `nextCacheMock` is imported first on purpose: `vi.mock` is hoisted above every +// import, and its factory can only reach a module imported before the one it +// replaces. `catalog.ts` imports `cacheTag`/`cacheLife`, which only work inside +// the Next build. +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { eq } from "drizzle-orm"; import { POST, GET } from "@/app/api/mcp/route"; +import { getDb, resetDbForTests } from "@/lib/db/client"; +import { maintenanceLogs, units } from "@/lib/db/schema/index"; -// Partial-mock @/lib/notion: override ONLY `fetchMaintenanceLogsByUnit` and keep -// every other export real. (catalog.ts also imports from this module — e.g. -// getNotionEnvContract, fetchAllTools — so a full replacement would break the -// catalog's mock-fallback path.) The catalog itself still comes from the -// mock-catalog because NOTION_* env stays unset. -vi.mock("@/lib/notion", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchMaintenanceLogsByUnit: vi.fn(async () => []), - }; -}); - -import { fetchMaintenanceLogsByUnit } from "@/lib/notion"; - -const fetchLogsMock = vi.mocked(fetchMaintenanceLogsByUnit); +vi.mock("next/cache", () => nextCacheMock()); const MCP_URL = "http://localhost/api/mcp"; +/** The uuid of a seeded unit — what the capability resolves a label to. */ +async function unitId(label: string): Promise { + const db = await getDb(); + const [row] = await db + .select({ id: units.id }) + .from(units) + .where(eq(units.unitLabel, label)); + return row.id; +} + /** Build a JSON-RPC POST Request with the Accept header the transport requires. */ function rpcRequest( body: unknown, @@ -89,8 +93,18 @@ async function callTool(name: string, args: Record = {}) { } beforeEach(() => { - fetchLogsMock.mockReset(); - fetchLogsMock.mockResolvedValue([]); + vi.stubEnv("DATABASE_URL", ""); +}); + +afterEach(async () => { + // The seeded catalogue rows are read, never written; only the logs a test + // wrote need clearing. + const db = await getDb(); + await db.delete(maintenanceLogs); +}); + +afterAll(() => { + resetDbForTests(); }); // ── GET handler ────────────────────────────────────────────────────── @@ -223,7 +237,7 @@ describe("tools/call: list_tools", () => { it("returns a structured summary of the whole catalog", async () => { const { json } = await callTool("list_tools"); const parsed = JSON.parse(resultText(json)); - // mock-catalog has 2 tools (Form 4, Trotec Speedy 400). + // The demo seed is 2 tools (Form 4, Trotec Speedy 400). expect(parsed.count).toBe(2); const names = parsed.tools.map((t: { name: string }) => t.name); expect(names).toContain("Form 4"); @@ -301,7 +315,7 @@ describe("tools/call: get_tool_details", () => { // ── tools/call: get_unit_details ───────────────────────────────────── describe("tools/call: get_unit_details", () => { - it("returns JSON for a real mock unit label", async () => { + it("returns JSON for a seeded unit label", async () => { const { json } = await callTool("get_unit_details", { unit_label: "Form 4 // A" }); const text = resultText(json); const parsed = JSON.parse(text); @@ -324,37 +338,44 @@ describe("tools/call: get_unit_details", () => { describe("tools/call: get_maintenance_history", () => { it("returns a JSON list of logs when the unit has maintenance history", async () => { - fetchLogsMock.mockResolvedValue([ + const db = await getDb(); + const form4A = await unitId("Form 4 // A"); + await db.insert(maintenanceLogs).values([ + { + title: "Resin tank cloudy", + unitId: form4A, + type: "repair", + priority: "high", + status: "open", + dateReported: "2024-09-01", + description: "The resin tank film is clouded and needs replacement.", + }, + // A log on another unit, to prove the tool reads by unit rather than + // returning everything. { - id: "log-1", - createdTime: "2024-09-01T10:00:00.000Z", - lastEditedTime: "2024-09-01T10:00:00.000Z", - fields: { - title: "Resin tank cloudy", - type: "Repair", - priority: "High", - status: "Open", - date_reported: "2024-09-01", - description: "The resin tank film is clouded and needs replacement.", - }, + title: "Lens dirty", + unitId: await unitId("Trotec Speedy 400"), + dateReported: "2024-09-02", }, - ] as Awaited>); + ]); const { json } = await callTool("get_maintenance_history", { unit_label: "Form 4 // A", }); const text = resultText(json); const parsed = JSON.parse(text); + expect(parsed.unit_id).toBe(form4A); expect(parsed.unit_label).toBe("Form 4 // A"); expect(parsed.maintenance_logs).toHaveLength(1); expect(parsed.maintenance_logs[0].title).toBe("Resin tank cloudy"); + // Stored `repair` / `high` / `open`, shown in the words the assistant has + // always been given. + expect(parsed.maintenance_logs[0].type).toBe("Repair"); expect(parsed.maintenance_logs[0].priority).toBe("High"); - // Confirm the fetch was driven with the matched unit's id. - expect(fetchLogsMock).toHaveBeenCalledWith("unit-form-4-a"); + expect(parsed.maintenance_logs[0].status).toBe("Open"); }); it("returns an empty log list when the unit has no logs", async () => { - fetchLogsMock.mockResolvedValue([]); const { json } = await callTool("get_maintenance_history", { unit_label: "Form 4 // A", }); diff --git a/v5/src/app/api/projects/route.test.ts b/v5/src/app/api/projects/route.test.ts index 2f9c367..c9a79a6 100644 --- a/v5/src/app/api/projects/route.test.ts +++ b/v5/src/app/api/projects/route.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { http, HttpResponse } from "msw"; import { server } from "../../../../test/msw/server"; @@ -6,11 +7,38 @@ import { createSessionPayload, signSession, } from "@/lib/auth/session-cookie"; +import { nextCacheMock } from "../../../../test/mocks/next-cache"; + +// The route translates catalogue ids to Notion page ids through Postgres, and +// that module's neighbours import cacheTag/cacheLife. +vi.mock("next/cache", () => nextCacheMock()); + +import { getCatalogTools } from "@/lib/catalog"; +import { resetDbForTests } from "@/lib/db/client"; +import { DEMO_FORM_4_NOTION_PAGE_ID } from "@/lib/db/demo-seed"; const NOTION = "https://api.notion.com/v1"; const PROJECTS_DB = "db-projects"; const AUTH_SECRET = "projects-route-test-secret"; +// `tools_used` is a Notion relation, but the form now submits catalogue ids — +// Postgres uuids minted at seed time. The demo seed is the substrate here +// (`DATABASE_URL` unset): the Form 4 came from Notion and has a page id, the +// Trotec did not and has none. +let FORM_4_ID = ""; +let TROTEC_ID = ""; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + const tools = await getCatalogTools(); + FORM_4_ID = tools.find((tool) => tool.slug === "form-4")?.id ?? ""; + TROTEC_ID = tools.find((tool) => tool.slug === "trotec-speedy-400")?.id ?? ""; +}); + +afterAll(() => { + resetDbForTests(); +}); + // ── Helpers ───────────────────────────────────────────────────────── // The in-memory limiter is a per-process singleton keyed by `projects:` — @@ -68,7 +96,7 @@ function validPayload(overrides: Record = {}) { title: "Lamp from scrap plywood", author: "Ada Lovelace", body: "Cut on the laser, glued, sanded.", - tools: ["tool-form-4"], + tools: [FORM_4_ID], materials: ["Plywood"], photos: [{ id: "file-upload-1", name: "cover.png" }], ...overrides, @@ -167,7 +195,10 @@ describe("POST /api/projects (drafts by default)", () => { await post( submitRequest( - validPayload({ link: "https://example.com/lamp", tools: ["tool-form-4", "tool-2"] }) + validPayload({ + link: "https://example.com/lamp", + tools: [FORM_4_ID, TROTEC_ID], + }) ) ); @@ -179,8 +210,10 @@ describe("POST /api/projects (drafts by default)", () => { rich_text: [{ text: { content: "Ada Lovelace" } }], }); expect(properties.link).toEqual({ url: "https://example.com/lamp" }); + // Each catalogue id is translated to the tool's Notion page; the Trotec has + // no imported page, so it drops out rather than failing the whole write. expect(properties.tools_used).toEqual({ - relation: [{ id: "tool-form-4" }, { id: "tool-2" }], + relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], }); expect(properties.materials).toEqual({ multi_select: [{ name: "Plywood" }] }); // Photos go in as file_upload references from /api/upload-notion. @@ -439,14 +472,16 @@ describe("POST /api/projects (validation)", () => { it("accepts exactly 20 tools", async () => { stubProjectsEnv(); const calls = captureNotionCreate(); - const tools = Array.from({ length: 20 }, (_, i) => `tool-${i}`); + const tools = Array.from({ length: 20 }, () => crypto.randomUUID()); const res = await post(submitRequest(validPayload({ tools }))); + // The cap is what is under test: twenty is not one too many. None of these + // ids is a real tool, so nothing survives the Notion-page translation and + // the submission is filed without a relation rather than refused. expect(res.status).toBe(201); - expect( - (calls[0].properties?.tools_used as { relation: unknown[] }).relation - ).toHaveLength(20); + expect(calls).toHaveLength(1); + expect(calls[0].properties?.tools_used).toBeUndefined(); }); it("ignores non-string entries in tools and materials", async () => { @@ -455,13 +490,13 @@ describe("POST /api/projects (validation)", () => { const res = await post( submitRequest( - validPayload({ tools: ["tool-form-4", 7, null], materials: [{}, "Plywood"] }) + validPayload({ tools: [FORM_4_ID, 7, null], materials: [{}, "Plywood"] }) ) ); expect(res.status).toBe(201); expect(calls[0].properties?.tools_used).toEqual({ - relation: [{ id: "tool-form-4" }], + relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], }); expect(calls[0].properties?.materials).toEqual({ multi_select: [{ name: "Plywood" }], diff --git a/v5/src/app/api/projects/route.ts b/v5/src/app/api/projects/route.ts index 1222f98..1aefd9a 100644 --- a/v5/src/app/api/projects/route.ts +++ b/v5/src/app/api/projects/route.ts @@ -4,6 +4,7 @@ import { hasProjectsEnv, type ProjectWriteFields, } from "../../../lib/notion"; +import { notionPageIdsForTools } from "../../../lib/data/notion-ids"; import type { ProjectRecord } from "../../../lib/types"; import { rateLimitAsync } from "../../../lib/rate-limit"; import { resolveIdentity } from "../../../lib/auth/identity"; @@ -144,6 +145,13 @@ export async function POST(req: NextRequest) { ); } + // `tools` arrives as catalogue ids, which are Postgres uuids since the read + // path moved (spec §3.10), while `tools_used` is a Notion relation. Translate + // through `tools.notion_page_id` and drop what does not resolve: Notion + // rejects the whole page for one unknown relation id, and losing a student's + // whole write-up over a tool link is the wrong way to fail (Article 4). + const toolPageIds = await notionPageIdsForTools(tools); + try { const record = await submitProject({ title, @@ -154,7 +162,7 @@ export async function POST(req: NextRequest) { // no session, no email, submission still succeeds. author_email: identity.email || undefined, link: link || undefined, - tools_used: tools, + tools_used: toolPageIds, materials, photo_uploads: photoUploads, }); diff --git a/v5/src/app/tools/[id]/page.tsx b/v5/src/app/tools/[id]/page.tsx index 953eda3..6d8215d 100644 --- a/v5/src/app/tools/[id]/page.tsx +++ b/v5/src/app/tools/[id]/page.tsx @@ -1,30 +1,61 @@ import { Suspense } from "react"; -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import { QrArrivalNotice } from "./QrArrivalNotice"; import { DetailShell } from "../../../components/DetailShell"; import { FlagButton } from "../../../components/FlagButton"; -import { getCatalogTool, getCatalogTools } from "../../../lib/catalog"; +import { getCatalogTool } from "../../../lib/catalog"; +import { findToolByNotionPageId } from "../../../lib/data/catalog"; +import { isLegacyNotionId } from "../../../lib/legacy-id"; import { getProjectsForTool } from "../../../lib/projects"; interface ToolDetailPageProps { params: Promise<{ id: string; }>; + searchParams: Promise>; } -export async function generateStaticParams() { - const tools = await getCatalogTools(); +// No `generateStaticParams`: enumerating the slugs would make `next build` +// depend on the database, which the client is explicitly designed not to +// require — `DATABASE_URL` may be unset, Neon may be asleep, and PGlite cannot +// run inside the bundled build (spec §3.2). Tool pages render on first request +// instead and are then served from the cache, which is where they came from +// anyway: every catalogue read is `"use cache"` + `cacheTag("catalog")`, so a +// later write invalidates them by tag rather than waiting for the next build +// (spec §3.9). `/projects/[id]` skips it for the same reason. - return tools.map((tool) => ({ - id: tool.slug, - })); +/** + * Re-encodes the incoming query string for a redirect target. `?src=qr` is + * the case that matters (`QrArrivalNotice` reads it on whatever page it lands + * on), but nothing here is specific to that one key — a legacy link keeps + * whatever it was carrying. + */ +function preserveQueryString(searchParams: Record): string { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(searchParams)) { + if (value === undefined) continue; + for (const v of Array.isArray(value) ? value : [value]) qs.append(key, v); + } + const serialized = qs.toString(); + return serialized ? `?${serialized}` : ""; } -export default async function ToolDetailPage({ params }: ToolDetailPageProps) { +export default async function ToolDetailPage({ params, searchParams }: ToolDetailPageProps) { const { id } = await params; const tool = await getCatalogTool(id); if (!tool) { + // Printed QR labels and old links encode a Notion page id rather than a + // slug (spec Goal 2). A match redirects permanently to the tool's current + // slug; anything else — a stale id, a typo, a slug that never existed — + // 404s exactly as it did before this check existed. + if (isLegacyNotionId(id)) { + const match = await findToolByNotionPageId(id); + if (match) { + const query = preserveQueryString(await searchParams); + permanentRedirect(`/tools/${match.slug}${query}`); + } + } notFound(); } diff --git a/v5/src/components/DemoDataBanner.test.tsx b/v5/src/components/DemoDataBanner.test.tsx index deb079f..b95bd9a 100644 --- a/v5/src/components/DemoDataBanner.test.tsx +++ b/v5/src/components/DemoDataBanner.test.tsx @@ -1,7 +1,12 @@ +import { nextCacheMock } from "../../test/mocks/next-cache"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { DemoDataBanner } from "./DemoDataBanner"; +// The banner reaches `isDemoCatalog` through `lib/catalog`, which imports +// `next/cache` at module load. +vi.mock("next/cache", () => nextCacheMock()); + // The banner asks next-intl for copy on the server; the message content is not // what is under test here, only whether the banner appears at all. vi.mock("next-intl/server", () => ({ @@ -14,21 +19,6 @@ vi.mock("next-intl/server", () => ({ }, })); -const NOTION_ENV = [ - "NOTION_API_KEY", - "NOTION_DB_TOOLS", - "NOTION_DB_CATEGORIES", - "NOTION_DB_LOCATIONS", - "NOTION_DB_UNITS", - "NOTION_DB_RESOURCES", - "NOTION_DB_MAINTENANCE_LOGS", - "NOTION_DB_FLAGS", -]; - -function configureNotion() { - for (const key of NOTION_ENV) vi.stubEnv(key, "configured"); -} - async function renderBanner() { render(await DemoDataBanner()); } @@ -37,7 +27,8 @@ describe("DemoDataBanner", () => { beforeEach(() => vi.unstubAllEnvs()); afterEach(() => vi.unstubAllEnvs()); - it("warns when Notion is not configured at all", async () => { + it("warns when there is no database and the catalogue is the demo seed", async () => { + vi.stubEnv("DATABASE_URL", ""); await renderBanner(); const banner = screen.getByRole("status"); expect(banner).toHaveTextContent("Demo data"); @@ -46,19 +37,18 @@ describe("DemoDataBanner", () => { expect(banner.textContent).toMatch(/inventory/); }); - it("stays out of the way when the catalogue is real", async () => { - configureNotion(); + it("stays out of the way when a real database is configured", async () => { + vi.stubEnv("DATABASE_URL", "postgres://user:pass@example.neon.tech/db"); await renderBanner(); expect(screen.queryByRole("status")).toBeNull(); }); - // The failure that actually reaches production is not "Notion is unset" — it is - // one variable dropped during a deploy. The catalogue silently falls back to - // invented equipment, so the banner has to fire on a partial contract too. - it.each(NOTION_ENV)("warns when only %s is missing", async (missing) => { - configureNotion(); - vi.stubEnv(missing, ""); + // A configured-but-unreachable database is a different failure: the catalogue + // throws rather than serving sample data, so the banner must not claim the + // page is a demo. It keys on the substrate alone. + it("does not fire for a database that is merely unhealthy", async () => { + vi.stubEnv("DATABASE_URL", "postgres://user:pass@unreachable.invalid/db"); await renderBanner(); - expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.queryByRole("status")).toBeNull(); }); }); diff --git a/v5/src/components/DemoDataBanner.tsx b/v5/src/components/DemoDataBanner.tsx index 6916523..a2791a8 100644 --- a/v5/src/components/DemoDataBanner.tsx +++ b/v5/src/components/DemoDataBanner.tsx @@ -1,27 +1,26 @@ import { getTranslations } from "next-intl/server"; -import { hasNotionCatalogEnv } from "../lib/catalog"; +import { isDemoCatalog } from "../lib/catalog"; import { siteConfig } from "../lib/site-config"; /** - * Says out loud that the catalogue is built-in demo data rather than the lab's - * real inventory (operational-hardening spec §6). + * Says out loud that the catalogue is the built-in demo seed rather than the + * lab's real inventory (operational-hardening spec §6). * - * The fallback itself is deliberate and load-bearing: it is why `npm run dev` - * and the whole test suite work with no credentials. What was dangerous was - * that it happened *silently* — a deploy missing one `NOTION_DB_*` variable - * served invented equipment and looked perfectly healthy, and the only way to - * find out was to recognise machines the lab does not own. + * The seed itself is deliberate and load-bearing: an unset `DATABASE_URL` runs + * the app on an in-process Postgres, which is why `npm run dev` and the whole + * test suite work with no credentials. What was dangerous was that it happened + * *silently* — a deploy missing the variable served equipment the lab does not + * own and looked perfectly healthy. * * `/api/health` reports the same state as a 503 for monitors. This is the half * a person standing in front of the page can see. * - * Keyed on the env contract rather than on a fallback that already happened, - * because a server component renders before any catalogue fetch it does not - * itself perform — and a missing variable is the case that actually reaches - * production. A transient fetch failure is caught by the health endpoint. + * Keyed on the substrate, not on a failure that already happened: a configured + * database that is unreachable is a different case, and one the catalogue + * surfaces by failing rather than by quietly showing sample data (Article 4). */ export async function DemoDataBanner() { - if (hasNotionCatalogEnv()) return null; + if (!isDemoCatalog()) return null; const t = await getTranslations("demoBanner"); diff --git a/v5/src/lib/cache.ts b/v5/src/lib/cache.ts index f5a7056..ab887e6 100644 --- a/v5/src/lib/cache.ts +++ b/v5/src/lib/cache.ts @@ -13,9 +13,9 @@ */ /** - * Catalog reads (`fetchFullCatalog`). Long by design — a Notion outage keeps - * serving cached data rather than falling back to the mock catalog, which is - * "fail toward stale, not toward wrong". `/api/health` probes Notion directly so + * Catalog reads (`getCatalogTools`). Long by design — a database outage keeps + * serving cached data rather than falling back to sample data, which is "fail + * toward stale, not toward wrong". `/api/health` probes Postgres directly so * the outage is still visible. */ export const CATALOG_CACHE = { diff --git a/v5/src/lib/capabilities/catalog.test.ts b/v5/src/lib/capabilities/catalog.test.ts new file mode 100644 index 0000000..4f7633e --- /dev/null +++ b/v5/src/lib/capabilities/catalog.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment node +// `nextCacheMock` is imported first on purpose: `vi.mock` is hoisted above +// every import, and its factory can only reach a module imported before the one +// it replaces. +import { nextCacheMock } from "../../../test/mocks/next-cache"; +import { getCatalogTools } from "../catalog"; +import { resetDbForTests } from "../db/client"; +import { catalog } from "./catalog"; +import type { CapabilityCtx, CapabilityTool } from "./types"; + +vi.mock("next/cache", () => nextCacheMock()); + +/** + * The `catalog` capability against the demo-seeded PGlite database — the same + * two tools (`form-4`, `trotec-speedy-400`) the mock catalogue used to supply, + * now real rows. Nothing here writes, so the seed is read as-is. + */ + +const ctx: CapabilityCtx = {}; + +function tool(name: string): CapabilityTool { + const found = catalog.tools.find((t) => t.name === name); + if (!found) throw new Error(`No such tool: ${name}`); + return found; +} + +beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); +}); + +afterAll(() => { + resetDbForTests(); +}); + +// ── list_tools ───────────────────────────────────────────────────── + +describe("list_tools", () => { + it("lists the whole catalogue with a one-line summary each", async () => { + const result = (await tool("list_tools").run({}, ctx)) as { + count: number; + tools: { name: string; slug: string; summary: string }[]; + }; + + expect(result.count).toBe(2); + expect(result.tools.map((t) => t.slug)).toEqual(["form-4", "trotec-speedy-400"]); + expect(result.tools[0].summary).toContain("Form 4"); + expect(result.tools[0].summary).toContain("training: Intermediate"); + }); + + it("narrows by category and by location, on a partial match", async () => { + const byCategory = (await tool("list_tools").run({ category: "laser" }, ctx)) as { + tools: { name: string }[]; + }; + expect(byCategory.tools.map((t) => t.name)).toEqual(["Trotec Speedy 400"]); + + const byLocation = (await tool("list_tools").run({ location: "Resin" }, ctx)) as { + tools: { name: string }[]; + }; + expect(byLocation.tools.map((t) => t.name)).toEqual(["Form 4"]); + }); +}); + +// ── search_tools ─────────────────────────────────────────────────── + +describe("search_tools", () => { + it("matches on materials as well as names and descriptions", async () => { + const result = (await tool("search_tools").run({ query: "acrylic" }, ctx)) as { + count: number; + tools: { name: string; short_description: string }[]; + }; + + expect(result.count).toBe(1); + expect(result.tools[0].name).toBe("Trotec Speedy 400"); + expect(result.tools[0].short_description).toMatch(/laser/i); + }); + + it("returns an empty result set rather than a guess on a miss", async () => { + const result = (await tool("search_tools").run( + { query: "nonexistent-widget-xyz" }, + ctx + )) as { query: string; count: number; tools: unknown[] }; + + expect(result).toMatchObject({ query: "nonexistent-widget-xyz", count: 0, tools: [] }); + }); +}); + +// ── get_tool_details ─────────────────────────────────────────────── + +describe("get_tool_details", () => { + it("resolves a tool by slug and returns its full record", async () => { + const result = (await tool("get_tool_details").run( + { id_or_name: "form-4" }, + ctx + )) as Record; + + expect(result).toMatchObject({ + found: true, + slug: "form-4", + name: "Form 4", + category: "3D Printing", + category_sub: "Resin", + location: "MakerLab", + zone: "Resin Bench", + training_level: "Intermediate", + status: "In Use", + detail_page: "/tools/form-4", + }); + expect(result.links).not.toEqual([]); + expect(result.units).toHaveLength(1); + }); + + it("resolves a tool by the Postgres id it hands back in `list_tools`", async () => { + // The round trip that matters: the model reads an id out of one tool result + // and passes it to another. Ids are uuids now, not Notion page ids. + const [form4] = await getCatalogTools(); + const result = (await tool("get_tool_details").run( + { id_or_name: form4.id }, + ctx + )) as { found: boolean; slug: string }; + + expect(result).toMatchObject({ found: true, slug: "form-4" }); + }); + + it("falls back to a partial name when the value is neither id nor slug", async () => { + const result = (await tool("get_tool_details").run( + { id_or_name: "Trotec" }, + ctx + )) as { found: boolean; slug: string }; + + expect(result).toMatchObject({ found: true, slug: "trotec-speedy-400" }); + }); + + it("says so plainly when there is no such tool", async () => { + const result = (await tool("get_tool_details").run( + { id_or_name: "no-such-tool-id" }, + ctx + )) as { found: boolean; message: string }; + + expect(result.found).toBe(false); + expect(result.message).toMatch(/Tool not found: no-such-tool-id/); + }); +}); + +// ── Prompt fragment ──────────────────────────────────────────────── + +describe("promptFragment", () => { + it("lists every catalogue tool with the slug the linking rule tells the model to use", async () => { + const tools = await getCatalogTools(); + const fragment = catalog.promptFragment?.({ tools }) ?? ""; + + expect(fragment).toContain("## MakerLab catalog (2 tools)"); + expect(fragment).toContain("**Form 4** — slug: `form-4`"); + expect(fragment).toContain("units: Form 4 // A [In Use]"); + }); + + it("names the focused tool and tells the model not to link it", async () => { + const tools = await getCatalogTools(); + const fragment = catalog.promptFragment?.({ tools, focusedTool: tools[0] }) ?? ""; + + expect(fragment).toContain("Active tool context"); + expect(fragment).toContain("already on its page"); + }); +}); diff --git a/v5/src/lib/capabilities/flags.test.ts b/v5/src/lib/capabilities/flags.test.ts index 0098fd7..5b7d99b 100644 --- a/v5/src/lib/capabilities/flags.test.ts +++ b/v5/src/lib/capabilities/flags.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { http, HttpResponse } from "msw"; import { server } from "../../../test/msw/server"; import { DB_IDS } from "../../../test/msw/handlers"; @@ -6,6 +7,9 @@ import { nextCacheMock } from "../../../test/mocks/next-cache"; // `catalog.ts` (pulled in for tool resolution) imports cacheTag/cacheLife. vi.mock("next/cache", () => nextCacheMock()); +import { getCatalogTools } from "../catalog"; +import { resetDbForTests } from "../db/client"; +import { DEMO_FORM_4_NOTION_PAGE_ID } from "../db/demo-seed"; import { FLAG_FIELDS, MAX_FLAG_TEXT, @@ -19,11 +23,27 @@ import { const NOTION = "https://api.notion.com/v1"; -// The mock catalog is served whenever the Notion catalog env is incomplete; its -// first tool is the Form 4. Stubbing only the two flag vars keeps reads on the -// mock path while the write path is fully configured. -const FORM_4_ID = "tool-form-4"; +// Reads run against the demo-seeded PGlite database (`DATABASE_URL` unset), +// which holds the same two tools the mock catalogue used to. The Form 4's +// catalogue id is a Postgres uuid minted at seed time, so it is resolved rather +// than hard-coded; the Flags relation needs its *Notion page* id instead. const FORM_4_NAME = "Form 4"; +let FORM_4_ID = ""; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + const tools = await getCatalogTools(); + FORM_4_ID = tools.find((tool) => tool.slug === "form-4")?.id ?? ""; +}); + +afterAll(() => { + resetDbForTests(); +}); + +/** The Form 4 as `buildFlagFields` receives it, with its Notion page by default. */ +function flaggedTool(notionPageId: string | null = DEMO_FORM_4_NOTION_PAGE_ID) { + return { id: FORM_4_ID, name: FORM_4_NAME, notionPageId }; +} function stubFlagsEnv() { vi.stubEnv("NOTION_API_KEY", "secret_test"); @@ -166,27 +186,34 @@ describe("parseCorrectionReport", () => { }); describe("buildFlagFields", () => { - const tool = { id: FORM_4_ID, name: FORM_4_NAME }; - it("generates the title as '' and opens the row as New", () => { - const fields = buildFlagFields(report({ field_flagged: "safety_info" }), tool); + const fields = buildFlagFields(report({ field_flagged: "safety_info" }), flaggedTool()); expect(fields.title).toBe(`${FORM_4_NAME} — safety_info`); expect(fields.status).toBe("New"); - expect(fields.tool).toEqual([FORM_4_ID]); + // The relation addresses the Notion page, never the Postgres id. + expect(fields.tool).toEqual([DEMO_FORM_4_NOTION_PAGE_ID]); + }); + + it("omits the relation for a tool that never came from Notion", () => { + // Notion refuses a relation to a page it cannot find, and the correction + // itself is worth more than the link (Article 4). + const fields = buildFlagFields(report(), flaggedTool(null)); + expect(fields.tool).toBeUndefined(); + expect(fields.title).toBe(`${FORM_4_NAME} — materials`); }); it("omits the optional fields when they were not supplied", () => { - const fields = buildFlagFields(report(), tool); + const fields = buildFlagFields(report(), flaggedTool()); expect(fields.suggested_fix).toBeUndefined(); expect(fields.reporter).toBeUndefined(); expect(fields.reporter_email).toBeUndefined(); }); it("writes reporter_email only for a signed-in reporter", () => { - const anonymous = buildFlagFields(report(), tool); + const anonymous = buildFlagFields(report(), flaggedTool()); expect(anonymous.reporter_email).toBeUndefined(); - const signedIn = buildFlagFields(report(), tool, { + const signedIn = buildFlagFields(report(), flaggedTool(), { name: "Ada", email: "ada@example.edu", }); @@ -195,7 +222,7 @@ describe("buildFlagFields", () => { }); it("prefers a self-declared name over the session name", () => { - const fields = buildFlagFields(report({ reporter: "Grace" }), tool, { + const fields = buildFlagFields(report({ reporter: "Grace" }), flaggedTool(), { name: "Ada", email: "ada@example.edu", }); @@ -230,18 +257,20 @@ describe("submitCorrection", () => { expect(creates[0].properties).toMatchObject({ status: { select: { name: "New" } }, field_flagged: { select: { name: "materials" } }, - tool: { relation: [{ id: FORM_4_ID }] }, + tool: { relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }] }, }); }); - it("resolves the tool by slug as well as by page id", async () => { + it("resolves the tool by slug as well as by catalogue id", async () => { stubFlagsEnv(); const creates = capturePageCreates(); const result = await submitCorrection(report({ tool_id: "form-4" })); expect(result.ok).toBe(true); - expect(creates[0].properties.tool).toEqual({ relation: [{ id: FORM_4_ID }] }); + expect(creates[0].properties.tool).toEqual({ + relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], + }); }); it("returns unknown_tool without writing anything", async () => { @@ -317,7 +346,9 @@ describe("report_correction capability tool", () => { )) as { success: boolean }; expect(result.success).toBe(true); - expect(creates[0].properties.tool).toEqual({ relation: [{ id: FORM_4_ID }] }); + expect(creates[0].properties.tool).toEqual({ + relation: [{ id: DEMO_FORM_4_NOTION_PAGE_ID }], + }); }); it("rejects an empty description without calling Notion", async () => { diff --git a/v5/src/lib/capabilities/flags.ts b/v5/src/lib/capabilities/flags.ts index 1689c17..7ad36f2 100644 --- a/v5/src/lib/capabilities/flags.ts +++ b/v5/src/lib/capabilities/flags.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { getCatalogTools } from "../catalog"; +import { notionPageIdForTool } from "../data/notion-ids"; import type { FlagFields, FlaggedField } from "../types"; import type { Capability, CapabilityCtx, CapabilityTool } from "./types"; @@ -153,17 +154,22 @@ export function parseCorrectionReport( */ export function buildFlagFields( report: CorrectionReport, - tool: { id: string; name: string }, + tool: FlaggedTool, identity?: ReporterIdentity ): FlagWriteFields { const fields: FlagWriteFields = { title: `${tool.name} — ${report.field_flagged}`, - tool: [tool.id], field_flagged: report.field_flagged, issue_description: report.issue_description, status: "New", }; + // The `tool` relation addresses a Notion page, and `tool.id` is a Postgres + // uuid since the read path moved (spec §3.10). A tool with no imported page + // is filed without the relation rather than with an id Notion would reject: + // a correction staff have to match up by title beats one that never arrived. + if (tool.notionPageId) fields.tool = [tool.notionPageId]; + if (report.suggested_fix) fields.suggested_fix = report.suggested_fix; const reporter = report.reporter || identity?.name; @@ -247,11 +253,28 @@ async function createFlagPage(fields: FlagWriteFields): Promise { // ── Submission (shared by both surfaces) ─────────────────────────── -/** Resolve by Notion page id or slug — surfaces disagree about which they hold. */ -async function findTool(toolId: string): Promise<{ id: string; name: string } | null> { +/** + * The tool a report is about, with the Notion page behind it. + * + * `id` is the catalogue id (a Postgres uuid); `notionPageId` is what the Flags + * relation needs, and is null for a tool that never came from Notion. + */ +export interface FlaggedTool { + id: string; + name: string; + notionPageId?: string | null; +} + +/** Resolve by catalogue id or slug — surfaces disagree about which they hold. */ +async function findTool(toolId: string): Promise { const tools = await getCatalogTools(); const match = tools.find((tool) => tool.id === toolId || tool.slug === toolId); - return match ? { id: match.id, name: match.name } : null; + if (!match) return null; + return { + id: match.id, + name: match.name, + notionPageId: await notionPageIdForTool(match.id), + }; } /** diff --git a/v5/src/lib/capabilities/helpers.test.ts b/v5/src/lib/capabilities/helpers.test.ts new file mode 100644 index 0000000..32564a7 --- /dev/null +++ b/v5/src/lib/capabilities/helpers.test.ts @@ -0,0 +1,242 @@ +// @vitest-environment node +import { eq } from "drizzle-orm"; +import { getDb, resetDbForTests } from "../db/client"; +import { maintenanceLogs, units as unitsTable } from "../db/schema/index"; +import type { MakerLabTool } from "../../components/catalog-types"; +import { + buildUnitLookup, + findTool, + findUnit, + recentMaintenance, + summarizeTool, +} from "./helpers"; + +/** + * The shared capability helpers: the pure lookups on fixtures, and + * `recentMaintenance` against the demo-seeded PGlite database `getDb()` hands + * out when `DATABASE_URL` is unset. + */ + +// ── Fixtures ──────────────────────────────────────────────────────── + +function makeTool(overrides: Partial = {}): MakerLabTool { + return { + id: "2a6f0d1e-0000-4000-8000-000000000001", + slug: "form-4", + name: "Form 4", + category: "3D Printing", + categorySub: "Resin", + location: "MakerLab", + zone: "Resin Bench", + trainingLevel: "Intermediate", + trainingLabel: "Resin handling training required.", + status: "In Use", + shortDescription: "A resin printer.", + description: "A production-grade resin printer.", + imageSrc: "/tool-images/Form%204.png", + ppe: ["Nitrile gloves"], + materials: ["Standard resin"], + tags: ["Resin"], + emergencyStop: null, + useRestrictions: null, + mapId: null, + notes: null, + links: [], + units: [ + { + id: "3b7f0d1e-0000-4000-8000-000000000001", + name: "Form 4 // A", + serial: "ML-F4-001", + status: "In Use", + condition: "Excellent", + location: "Resin Bench", + dateAcquired: "2024-08-12", + }, + ], + ...overrides, + }; +} + +const trotec = makeTool({ + id: "2a6f0d1e-0000-4000-8000-000000000002", + slug: "trotec-speedy-400", + name: "Trotec Speedy 400", + category: "Laser", + categorySub: "CO2", + location: "Laser Room", + zone: "Laser Bay", + status: "Available", + units: [ + { + id: "3b7f0d1e-0000-4000-8000-000000000002", + name: "Trotec Speedy 400", + serial: "ML-LSR-400", + status: "Available", + condition: "Good", + location: "Laser Bay", + dateAcquired: "2022-04-03", + }, + ], +}); + +const catalog = [makeTool(), trotec]; + +// ── Unit lookup ───────────────────────────────────────────────────── + +describe("buildUnitLookup", () => { + it("flattens every tool's units, carrying the tool they belong to", () => { + const lookup = buildUnitLookup(catalog); + expect(lookup).toHaveLength(2); + expect(lookup[0]).toMatchObject({ + label: "Form 4 // A", + toolName: "Form 4", + toolSlug: "form-4", + status: "In Use", + condition: "Excellent", + serial: "ML-F4-001", + }); + }); + + it("is empty for a catalogue whose tools have no units", () => { + expect(buildUnitLookup([makeTool({ units: [] })])).toEqual([]); + }); +}); + +describe("findUnit", () => { + const lookup = buildUnitLookup(catalog); + + it("prefers an exact, case-insensitive label match", () => { + expect(findUnit(lookup, "form 4 // a")?.label).toBe("Form 4 // A"); + }); + + it("falls back to the first substring match", () => { + expect(findUnit(lookup, "trotec")?.label).toBe("Trotec Speedy 400"); + }); + + it("returns null for an unknown or empty label", () => { + expect(findUnit(lookup, "no-such-unit")).toBeNull(); + expect(findUnit(lookup, " ")).toBeNull(); + }); +}); + +// ── Tool lookup / summaries ───────────────────────────────────────── + +describe("findTool", () => { + it("resolves by id, by slug, by exact name, then by partial name", () => { + expect(findTool(catalog, catalog[0].id)?.slug).toBe("form-4"); + expect(findTool(catalog, "trotec-speedy-400")?.name).toBe("Trotec Speedy 400"); + expect(findTool(catalog, "form 4")?.slug).toBe("form-4"); + expect(findTool(catalog, "speedy")?.slug).toBe("trotec-speedy-400"); + }); + + it("returns null for an unknown or empty needle", () => { + expect(findTool(catalog, "no-such-tool")).toBeNull(); + expect(findTool(catalog, "")).toBeNull(); + }); +}); + +describe("summarizeTool", () => { + it("packs the fields a list result needs onto one line", () => { + expect(summarizeTool(catalog[0])).toBe( + `Form 4 | id: ${catalog[0].id} | 3D Printing > Resin | MakerLab / Resin Bench | training: Intermediate | status: In Use` + ); + }); +}); + +// ── recentMaintenance (Postgres) ──────────────────────────────────── + +describe("recentMaintenance", () => { + beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); + }); + + afterEach(async () => { + const db = await getDb(); + await db.delete(maintenanceLogs); + }); + + afterAll(() => { + resetDbForTests(); + }); + + async function seededUnitId(label: string): Promise { + const db = await getDb(); + const [row] = await db + .select({ id: unitsTable.id }) + .from(unitsTable) + .where(eq(unitsTable.unitLabel, label)); + return row.id; + } + + it("flattens logs to the shape both chat and MCP have always returned", async () => { + const id = await seededUnitId("Form 4 // A"); + const db = await getDb(); + await db.insert(maintenanceLogs).values({ + title: "Resin tank cloudy", + unitId: id, + type: "issue_report", + priority: "high", + status: "open", + description: "The tank film is clouded.", + resolution: "Not yet.", + dateReported: "2024-09-01", + reportedByName: "Ada Lovelace", + reportedByEmail: "ada@cornell.edu", + }); + + const [entry] = await recentMaintenance(id); + + // Exactly six keys — the tool descriptions and prompt fragments say the + // model gets type, priority, status, date and description, and nothing in + // the richer query module leaks past this boundary. + expect(entry).toEqual({ + title: "Resin tank cloudy", + type: "Issue Report", + priority: "High", + status: "Open", + date_reported: "2024-09-01", + description: "The tank film is clouded.", + }); + }); + + it("caps at the 10 most recent", async () => { + const id = await seededUnitId("Form 4 // A"); + const db = await getDb(); + await db.insert(maintenanceLogs).values( + Array.from({ length: 12 }, (_, i) => ({ + title: `Issue ${i}`, + unitId: id, + dateReported: `2024-09-${String(i + 1).padStart(2, "0")}`, + })) + ); + + const entries = await recentMaintenance(id); + expect(entries).toHaveLength(10); + expect(entries[0].title).toBe("Issue 11"); + }); + + it("resolves to [] for a unit with no history", async () => { + expect(await recentMaintenance(await seededUnitId("Trotec Speedy 400"))).toEqual([]); + }); + + it("resolves to [] and logs rather than failing the tool call when the read throws", async () => { + // Replace the query module for this test only, so an unreachable database + // is simulated without one going near the network (Article 3). + vi.resetModules(); + vi.doMock("../data/maintenance", () => ({ + listMaintenanceHistoryForUnit: vi.fn(async () => { + throw new Error("The database is unavailable."); + }), + })); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { recentMaintenance: withFailingDb } = await import("./helpers"); + // The tool still answers, and the failure is visible in the logs rather + // than swallowed silently (Article 4). + expect(await withFailingDb("3b7f0d1e-0000-4000-8000-000000000001")).toEqual([]); + expect(warn).toHaveBeenCalled(); + + vi.doUnmock("../data/maintenance"); + vi.resetModules(); + }); +}); diff --git a/v5/src/lib/capabilities/helpers.ts b/v5/src/lib/capabilities/helpers.ts index 07d76c6..8f60102 100644 --- a/v5/src/lib/capabilities/helpers.ts +++ b/v5/src/lib/capabilities/helpers.ts @@ -1,4 +1,4 @@ -import { fetchMaintenanceLogsByUnit } from "../notion"; +import { listMaintenanceHistoryForUnit } from "../data/maintenance"; import type { MakerLabTool, MakerLabUnit, @@ -10,8 +10,9 @@ import type { * (`app/api/chat/route.ts`) and the MCP route (`app/api/mcp/route.ts`); they are * collapsed here so both adapters resolve units and summarize tools identically. * - * Pure data transforms plus one thin Notion read wrapper — server-only via the - * `../notion` import. + * Pure data transforms plus one thin Postgres read wrapper (`../data/maintenance`, + * spec §3.10 — the maintenance read left Notion in Phase 2; filing a ticket has + * not). */ // ── Unit lookup ──────────────────────────────────────────────────── @@ -68,7 +69,7 @@ export function findUnit( // ── Tool lookup / summaries ──────────────────────────────────────── -/** Resolve a tool by Notion page id, slug, exact name, or partial name. */ +/** Resolve a tool by id, slug, exact name, or partial name. */ export function findTool( tools: MakerLabTool[], idOrName: string @@ -109,21 +110,34 @@ export interface MaintenanceEntry { description: string; } +/** The model sees a recap, not an archive — and the read is bounded to match. */ +const MAX_MAINTENANCE_ENTRIES = 10; + /** * Fetch the most recent maintenance logs for a unit (cap 10), flattened to the - * shape both chat and MCP return. Best-effort: resolves to [] on failure. + * shape both chat and MCP return — unchanged from when these rows lived in + * Notion, so the tool descriptions and prompt fragments still describe what the + * model gets. The query module carries more (resolution, resolved date, + * reporter name); this is the subset the assistant has always been given. + * + * Best-effort: resolves to [] on failure, as it always has. A failure here is + * odd — resolving the unit already read the same database — so it is logged + * rather than swallowed silently (Article 4). */ export function recentMaintenance(unitId: string): Promise { - return fetchMaintenanceLogsByUnit(unitId) - .catch(() => []) + return listMaintenanceHistoryForUnit(unitId, { limit: MAX_MAINTENANCE_ENTRIES }) + .catch((err) => { + console.warn("[capabilities] maintenance history unavailable", unitId, err); + return []; + }) .then((logs) => - logs.slice(0, 10).map((log) => ({ - title: log.fields.title, - type: log.fields.type || "", - priority: log.fields.priority || "", - status: log.fields.status || "", - date_reported: log.fields.date_reported || "", - description: log.fields.description || "", + logs.map((log) => ({ + title: log.title, + type: log.type, + priority: log.priority, + status: log.status, + date_reported: log.dateReported, + description: log.description, })) ); } diff --git a/v5/src/lib/capabilities/intake.test.ts b/v5/src/lib/capabilities/intake.test.ts index da8e1f7..df4dbce 100644 --- a/v5/src/lib/capabilities/intake.test.ts +++ b/v5/src/lib/capabilities/intake.test.ts @@ -13,7 +13,7 @@ vi.mock("next/cache", () => nextCacheMock()); /** * Intake ↔ confidence wiring (confidence spec phases 2, 4 and 5). The catalog - * read runs against the built-in mock catalog (no `NOTION_*` env is stubbed, so + * read runs against the PGlite demo seed (no `DATABASE_URL`, so * `getCatalogTools()` never touches the network) and every candidate here * carries no resources, so no link verification fetch is made either. * @@ -45,7 +45,7 @@ function toolByName(name: string): CapabilityTool { return found; } -/** A candidate that matches nothing in the mock catalog. */ +/** A candidate that matches nothing in the demo catalogue. */ function candidate(over: Partial = {}): ToolCandidate { return { name: "Zorbex Filament Extruder 9000", diff --git a/v5/src/lib/capabilities/maintenance.test.ts b/v5/src/lib/capabilities/maintenance.test.ts index 9d97525..2fcecde 100644 --- a/v5/src/lib/capabilities/maintenance.test.ts +++ b/v5/src/lib/capabilities/maintenance.test.ts @@ -1,13 +1,17 @@ +// @vitest-environment node import { nextCacheMock } from "../../../test/mocks/next-cache"; +import { getCatalogTools } from "../catalog"; +import { resetDbForTests } from "../db/client"; +import { DEMO_FORM_4_UNIT_NOTION_PAGE_ID } from "../db/demo-seed"; import type { CapabilityCtx } from "./types"; import type { Identity } from "../auth/identity"; // `catalog.ts` (pulled in to resolve unit labels) imports cacheTag/cacheLife. vi.mock("next/cache", () => nextCacheMock()); -// Only the write is mocked; everything else in notion.ts stays real. With the -// NOTION_* env unset the catalog serves the mock catalog, so no test here needs -// a network call to resolve "Form 4 // A" (Article 3). +// Only the write is mocked; everything else in notion.ts stays real. Units are +// resolved against the demo-seeded PGlite database (`DATABASE_URL` unset), so +// no test here needs a network call to resolve "Form 4 // A" (Article 3). const mocks = vi.hoisted(() => ({ createMaintenanceLog: vi.fn() })); vi.mock("../notion", async (importOriginal) => { const actual = await importOriginal(); @@ -66,10 +70,23 @@ function ticket(id = "ticket-1") { } beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); createMaintenanceLog.mockReset(); createMaintenanceLog.mockResolvedValue(ticket()); }); +afterAll(() => { + resetDbForTests(); +}); + +/** The seeded unit behind a label, as the capability resolves it. */ +async function seededUnit(toolName: string) { + const tools = await getCatalogTools(); + const tool = tools.find((t) => t.name === toolName); + if (!tool?.units[0]) throw new Error(`No seeded unit for ${toolName}`); + return tool.units[0]; +} + describe("report_issue — verified authorship", () => { it("records the session's name and email when the student is signed in", async () => { const ctx: CapabilityCtx = { identity: signedIn() }; @@ -133,17 +150,39 @@ describe("report_issue — verified authorship", () => { }); it("still links a resolved unit and reports the ticket id", async () => { + const unit = await seededUnit("Form 4"); + const result = (await reportIssue.run( issue({ unit_label: "Form 4 // A", priority: "High" }), { identity: signedIn() } )) as { success: boolean; ticket_id?: string; unit_resolved?: unknown }; expect(result.ticket_id).toBe("ticket-1"); + // The caller is told the catalogue id — a Postgres uuid, the one every + // other capability accepts back. expect(result.unit_resolved).toEqual({ - id: "unit-form-4-a", + id: unit.id, label: "Form 4 // A", }); - expect(writtenFields().unit).toEqual(["unit-form-4-a"]); + // The Notion relation is the imported page id, not the uuid: Notion rejects + // a relation to a page it cannot find. + expect(writtenFields().unit).toEqual([DEMO_FORM_4_UNIT_NOTION_PAGE_ID]); + }); + + it("files the ticket unlinked when the unit never came from Notion", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const unit = await seededUnit("Trotec Speedy 400"); + + const result = (await reportIssue.run( + issue({ unit_label: "Trotec Speedy 400" }), + {} + )) as { success: boolean; ticket_id?: string; unit_resolved?: unknown }; + + // The resolution still happened and is reported; only the relation is gone. + expect(result.success).toBe(true); + expect(result.unit_resolved).toEqual({ id: unit.id, label: "Trotec Speedy 400" }); + expect(writtenFields().unit).toBeUndefined(); + expect(warn).toHaveBeenCalled(); }); }); diff --git a/v5/src/lib/capabilities/maintenance.ts b/v5/src/lib/capabilities/maintenance.ts index ea27987..bbde95b 100644 --- a/v5/src/lib/capabilities/maintenance.ts +++ b/v5/src/lib/capabilities/maintenance.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { getCatalogTools } from "../catalog"; +import { notionPageIdForUnit } from "../data/notion-ids"; import { createMaintenanceLog } from "../notion"; import type { MaintenanceLogFields, MaintenanceLogRecord } from "../types"; import { buildUnitLookup, findUnit } from "./helpers"; @@ -88,6 +89,18 @@ const reportIssue: CapabilityTool = { const tools = await getCatalogTools(); const unitLookup = buildUnitLookup(tools); const match = unit_label ? findUnit(unitLookup, unit_label) : null; + // The `unit` relation addresses a Notion page; `match.id` is the Postgres + // uuid the catalogue now hands out (spec §3.10). A unit with no imported + // page is linked in the description-by-title sense only — the ticket is + // filed either way, because Notion rejects a relation to a page it cannot + // find and a rejected write loses the student's report (Article 4). + const unitPageId = match ? await notionPageIdForUnit(match.id) : null; + if (match && !unitPageId) { + console.warn( + "[maintenance] no Notion page for unit — filing the ticket unlinked", + match.label + ); + } try { const record = await createTicket({ title, @@ -102,7 +115,7 @@ const reportIssue: CapabilityTool = { // Server-resolved only. There is no input field for this, and there is // deliberately no path that would let one exist. reporter_email: ctx.identity?.email || undefined, - unit: match ? [match.id] : undefined, + unit: unitPageId ? [unitPageId] : undefined, date_reported: new Date().toISOString().split("T")[0], photo_uploads: photo_uploads?.length ? photo_uploads : undefined, }); diff --git a/v5/src/lib/capabilities/types.ts b/v5/src/lib/capabilities/types.ts index 4774a75..fa9d6fc 100644 --- a/v5/src/lib/capabilities/types.ts +++ b/v5/src/lib/capabilities/types.ts @@ -61,7 +61,7 @@ export interface CapabilityCtx { attachments?: UploadedImage[]; /** Response/UI locale, e.g. "en" / "es". */ locale?: string; - /** Notion page id of the tool the user is currently viewing, if any. */ + /** Catalogue id (a Postgres uuid) of the tool the user is viewing, if any. */ focusedToolId?: string; /** * Who is making this request, resolved **server-side** from the session diff --git a/v5/src/lib/capabilities/units.test.ts b/v5/src/lib/capabilities/units.test.ts new file mode 100644 index 0000000..b08f289 --- /dev/null +++ b/v5/src/lib/capabilities/units.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment node +// `nextCacheMock` is imported first on purpose: `vi.mock` is hoisted above +// every import, and its factory can only reach a module imported before the one +// it replaces. +import { nextCacheMock } from "../../../test/mocks/next-cache"; +import { eq } from "drizzle-orm"; +import { getDb, resetDbForTests } from "../db/client"; +import { maintenanceLogs, units as unitsTable } from "../db/schema/index"; +import type { CapabilityCtx, CapabilityTool } from "./types"; +import { units } from "./units"; + +vi.mock("next/cache", () => nextCacheMock()); + +/** + * The `units` capability against the demo-seeded PGlite database `getDb()` + * hands out when `DATABASE_URL` is unset — the substrate the whole suite runs + * on. The seed's two units are `Form 4 // A` and `Trotec Speedy 400`; the + * maintenance logs each test needs are written here, since the seed has none. + */ + +const ctx: CapabilityCtx = {}; + +function tool(name: string): CapabilityTool { + const found = units.tools.find((t) => t.name === name); + if (!found) throw new Error(`No such tool: ${name}`); + return found; +} + +async function unitId(label: string): Promise { + const db = await getDb(); + const [row] = await db + .select({ id: unitsTable.id }) + .from(unitsTable) + .where(eq(unitsTable.unitLabel, label)); + return row.id; +} + +/** Write a log against a seeded unit. Stored values are snake_case (spec §4.1). */ +async function logIssue(label: string, values: Partial) { + const db = await getDb(); + await db.insert(maintenanceLogs).values({ + title: "Issue", + unitId: await unitId(label), + ...values, + }); +} + +beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); +}); + +afterEach(async () => { + // These tests write; the catalogue rows they read are the seed's and are left + // alone, so only the logs need clearing between tests. + const db = await getDb(); + await db.delete(maintenanceLogs); +}); + +afterAll(() => { + resetDbForTests(); +}); + +// ── get_unit_details ─────────────────────────────────────────────── + +describe("get_unit_details", () => { + it("resolves a seeded unit and derives its status and condition", async () => { + const result = (await tool("get_unit_details").run( + { unit_label: "Form 4 // A" }, + ctx + )) as Record; + + expect(result).toMatchObject({ + found: true, + unit_label: "Form 4 // A", + tool_name: "Form 4", + tool_slug: "form-4", + // Stored `in_use` / `excellent`, shown as the display forms the view + // models have always carried. + status: "In Use", + condition: "Excellent", + serial: "ML-F4-001", + date_acquired: "2024-08-12", + detail_page: "/tools/form-4", + maintenance_logs: [], + }); + expect(result.unit_id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("matches a unit by a case-insensitive substring of its label", async () => { + const result = (await tool("get_unit_details").run( + { unit_label: "trotec" }, + ctx + )) as Record; + expect(result).toMatchObject({ found: true, unit_label: "Trotec Speedy 400" }); + }); + + it("returns { found:false } with sample labels for an unknown unit", async () => { + const result = (await tool("get_unit_details").run( + { unit_label: "no-such-unit" }, + ctx + )) as { found: boolean; message: string }; + + expect(result.found).toBe(false); + expect(result.message).toMatch(/Form 4 \/\/ A|Trotec Speedy 400/); + }); + + it("surfaces the unit's maintenance history in display form", async () => { + await logIssue("Form 4 // A", { + title: "Resin tank cloudy", + type: "issue_report", + priority: "high", + status: "in_progress", + description: "The tank film is clouded.", + dateReported: "2024-09-01", + }); + + const result = (await tool("get_unit_details").run( + { unit_label: "Form 4 // A" }, + ctx + )) as { maintenance_logs: Record[] }; + + expect(result.maintenance_logs).toEqual([ + { + title: "Resin tank cloudy", + type: "Issue Report", + priority: "High", + status: "In Progress", + date_reported: "2024-09-01", + description: "The tank film is clouded.", + }, + ]); + }); + + it("reads another unit's logs as that unit's, not this one's", async () => { + await logIssue("Trotec Speedy 400", { title: "Lens dirty" }); + + const result = (await tool("get_unit_details").run( + { unit_label: "Form 4 // A" }, + ctx + )) as { maintenance_logs: unknown[] }; + expect(result.maintenance_logs).toEqual([]); + }); +}); + +// ── get_maintenance_history ──────────────────────────────────────── + +describe("get_maintenance_history", () => { + it("returns the unit's logs, newest first and capped at 10", async () => { + for (let i = 0; i < 12; i += 1) { + await logIssue("Form 4 // A", { + title: `Issue ${i}`, + dateReported: `2024-09-${String(i + 1).padStart(2, "0")}`, + }); + } + + const result = (await tool("get_maintenance_history").run( + { unit_label: "Form 4 // A" }, + ctx + )) as { found: boolean; maintenance_logs: { title: string }[] }; + + expect(result.found).toBe(true); + expect(result.maintenance_logs).toHaveLength(10); + expect(result.maintenance_logs[0].title).toBe("Issue 11"); + }); + + it("never puts the reporter's email in front of the model (spec §8)", async () => { + await logIssue("Form 4 // A", { + title: "Resin leak", + reportedByName: "Ada Lovelace", + reportedByEmail: "ada@cornell.edu", + }); + + const result = await tool("get_maintenance_history").run( + { unit_label: "Form 4 // A" }, + ctx + ); + expect(JSON.stringify(result)).not.toContain("cornell.edu"); + }); + + it("returns an empty history for a unit with no logs", async () => { + const result = (await tool("get_maintenance_history").run( + { unit_label: "Trotec Speedy 400" }, + ctx + )) as { found: boolean; maintenance_logs: unknown[] }; + + expect(result.found).toBe(true); + expect(result.maintenance_logs).toEqual([]); + }); + + it("returns { found:false } for an unknown unit", async () => { + const result = (await tool("get_maintenance_history").run( + { unit_label: "no-such-unit-999" }, + ctx + )) as { found: boolean; message: string }; + + expect(result.found).toBe(false); + expect(result.message).toMatch(/No unit found matching "no-such-unit-999"/); + }); +}); + +// ── Prompt fragment ──────────────────────────────────────────────── + +describe("promptFragment", () => { + it("still describes both tools, which is what they do", () => { + const fragment = units.promptFragment?.({ tools: [] }) ?? ""; + expect(fragment).toContain("get_unit_details"); + expect(fragment).toContain("get_maintenance_history"); + }); +}); diff --git a/v5/src/lib/catalog.test.ts b/v5/src/lib/catalog.test.ts index 323e9dc..2d7a2f7 100644 --- a/v5/src/lib/catalog.test.ts +++ b/v5/src/lib/catalog.test.ts @@ -1,524 +1,187 @@ -import { http, HttpResponse } from "msw"; - -import { server } from "../../test/msw/server"; -import { DB_IDS } from "../../test/msw/handlers"; -import { - categoriesPage, - locationsPage, - notionQueryResponse, - resourcesPage, - toolsPage, - unitsPage, - selectProp, - relationProp, - titleProp, - richTextProp, - checkboxProp, - multiSelectProp, - filesProp, - externalFile, - hostedFile, - urlProp, - STALE_IMAGE_URL, - FRESH_IMAGE_URL, - type NotionPageFixture, -} from "../../test/fixtures/notion"; +// @vitest-environment node +// `nextCacheMock` is imported first on purpose: `vi.mock` is hoisted above +// every import, and its factory can only reach a module imported before the one +// it replaces. import { nextCacheMock } from "../../test/mocks/next-cache"; +import { cacheLife, cacheTag } from "next/cache"; +import { CATALOG_CACHE } from "./cache"; +import { getCatalogStats, getCatalogTool, getCatalogTools, isDemoCatalog } from "./catalog"; +import { getDb, resetDbForTests } from "./db/client"; +import { tools } from "./db/schema/index"; vi.mock("next/cache", () => nextCacheMock()); -// ── Helpers ───────────────────────────────────────────────────────── - -function stubNotionEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); - vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); - vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); - vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); - vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); - vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); - vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); -} - -function unsetNotionEnv() { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_TOOLS", ""); - vi.stubEnv("NOTION_DB_CATEGORIES", ""); - vi.stubEnv("NOTION_DB_LOCATIONS", ""); - vi.stubEnv("NOTION_DB_UNITS", ""); - vi.stubEnv("NOTION_DB_RESOURCES", ""); - vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", ""); - vi.stubEnv("NOTION_DB_FLAGS", ""); -} - -// Route the standard catalog DBs to a custom set of pages. tools / categories / -// locations / units / resources can each be overridden; anything else returns -// empty. Used to drive status + training-level derivation through the real path. -function routeCatalog(opts: { - tools?: NotionPageFixture[]; - categories?: NotionPageFixture[]; - locations?: NotionPageFixture[]; - units?: NotionPageFixture[]; - resources?: NotionPageFixture[]; -}) { - const byId: Record = { - [DB_IDS.tools]: opts.tools ?? [toolsPage], - [DB_IDS.categories]: opts.categories ?? [categoriesPage], - [DB_IDS.locations]: opts.locations ?? [locationsPage], - [DB_IDS.units]: opts.units ?? [unitsPage], - [DB_IDS.resources]: opts.resources ?? [resourcesPage], - }; - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", ({ params }) => { - const id = params.id as string; - return HttpResponse.json(notionQueryResponse(byId[id] ?? [])); - }) - ); -} - -// Reset modules + re-import so catalog.ts re-reads process.env at module load. -async function importCatalog() { - vi.resetModules(); - return import("@/lib/catalog"); -} - -// ── hasNotionCatalogEnv ───────────────────────────────────────────── - -describe("hasNotionCatalogEnv", () => { - it("returns true when all NOTION_* vars are set", async () => { - stubNotionEnv(); - const { hasNotionCatalogEnv } = await importCatalog(); - expect(hasNotionCatalogEnv()).toBe(true); - }); - - it("returns false when any NOTION_* var is missing", async () => { - stubNotionEnv(); - vi.stubEnv("NOTION_DB_UNITS", ""); - const { hasNotionCatalogEnv } = await importCatalog(); - expect(hasNotionCatalogEnv()).toBe(false); - }); +/** + * The catalogue against the demo-seeded PGlite database `getDb()` hands out + * when `DATABASE_URL` is unset — the same substrate the test suite, E2E and a + * fresh clone run on. The seed is two tools, so these assertions are about the + * wiring and the derivation, not about the lab's real inventory. + */ - it("returns false when NOTION_API_KEY is missing", async () => { - stubNotionEnv(); - vi.stubEnv("NOTION_API_KEY", ""); - const { hasNotionCatalogEnv } = await importCatalog(); - expect(hasNotionCatalogEnv()).toBe(false); - }); +beforeEach(() => { + vi.stubEnv("DATABASE_URL", ""); }); -// ── getCatalogTools — mock fallback ───────────────────────────────── - -describe("getCatalogTools (mock fallback)", () => { - it("returns the built-in mockTools when Notion env is unset", async () => { - unsetNotionEnv(); - const catalog = await importCatalog(); - const { mockTools } = await import("@/components/mock-catalog"); - - const tools = await catalog.getCatalogTools(); - expect(tools).toBe(mockTools); - expect(tools.map((t) => t.slug)).toEqual(["form-4", "trotec-speedy-400"]); - }); +// One in-process database for the file: nothing here writes, so there is +// nothing to isolate between tests, and a fresh PGlite per test would pay for +// the migrations every time. +afterAll(() => { + resetDbForTests(); }); -// ── getCatalogTools — real Notion path ────────────────────────────── - -describe("getCatalogTools (Notion path)", () => { - it("resolves tools from Notion fixtures", async () => { - stubNotionEnv(); - const { getCatalogTools } = await importCatalog(); +// ── getCatalogTools ───────────────────────────────────────────────── - const tools = await getCatalogTools(); - expect(tools).toHaveLength(1); - - const [tool] = tools; - expect(tool.id).toBe("tool-1"); - expect(tool.slug).toBe("tool-1"); - expect(tool.name).toBe("Form 4"); - // category resolved via relation -> categoriesPage - expect(tool.category).toBe("3D Printing"); - expect(tool.categorySub).toBe("Resin"); - // location resolved via relation -> locationsPage - expect(tool.location).toBe("MakerLab"); - expect(tool.zone).toBe("Resin Bench"); - expect(tool.materials).toEqual(["Standard resin", "Tough resin"]); - expect(tool.ppe).toEqual(["Nitrile gloves", "Safety glasses"]); - // The first image_attachment is the stale airtable host; pickFreshImageUrl - // only inspects the *first* attachment's own url candidates and rejects it, - // so image_url is null and toMakerLabTool falls back to the local path. - expect(tool.imageSrc).toBe("/tool-images/Form%204.png"); - // mapId from resolved location.id - expect(tool.mapId).toBe("ML-RESIN-01"); +describe("getCatalogTools", () => { + it("returns the seeded catalogue ordered by name", async () => { + const catalog = await getCatalogTools(); + expect(catalog.map((tool) => tool.slug)).toEqual(["form-4", "trotec-speedy-400"]); }); - it("keeps a fresh first-attachment image url and drops a stale one", async () => { - stubNotionEnv(); - const freshFirst: NotionPageFixture = { - ...toolsPage, - properties: { - ...toolsPage.properties, - image_attachments: filesProp([externalFile("fresh.png", FRESH_IMAGE_URL)]), - }, - }; - const staleFirst: NotionPageFixture = { - ...toolsPage, - properties: { - ...toolsPage.properties, - image_attachments: filesProp([externalFile("stale.png", STALE_IMAGE_URL)]), - }, - }; - - routeCatalog({ tools: [freshFirst] }); - const fresh = await importCatalog(); - expect((await fresh.getCatalogTools())[0].imageSrc).toBe(FRESH_IMAGE_URL); - - routeCatalog({ tools: [staleFirst] }); - const stale = await importCatalog(); - // stale first-attachment rejected -> local fallback - expect((await stale.getCatalogTools())[0].imageSrc).toBe("/tool-images/Form%204.png"); + it("resolves a tool's category, location and map tag through the joins", async () => { + const [form4] = await getCatalogTools(); + expect(form4).toMatchObject({ + name: "Form 4", + category: "3D Printing", + categorySub: "Resin", + location: "MakerLab", + zone: "Resin Bench", + mapId: "ML-RESIN-01", + }); + expect(form4.id).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("derives status from the units' stored snake_case values", async () => { + const [form4, trotec] = await getCatalogTools(); + // The Form 4's one unit is `in_use`; the Trotec's is `available`, so its + // status falls through to the tool's training gate. + expect(form4.status).toBe("In Use"); + expect(form4.units[0]).toMatchObject({ + name: "Form 4 // A", + serial: "ML-F4-001", + status: "In Use", + condition: "Excellent", + location: "Resin Bench", + dateAcquired: "2024-08-12", + }); + expect(trotec.status).toBe("Training Required"); + expect(trotec.units[0]).toMatchObject({ status: "Available", condition: "Good" }); }); - it("attaches the unit grouped by tool id", async () => { - stubNotionEnv(); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.units).toHaveLength(1); - expect(tool.units[0].name).toBe("Form 4 #1"); - expect(tool.units[0].serial).toBe("ML-F4-001"); + it("derives the training level from the restrictions and tags", async () => { + const [form4, trotec] = await getCatalogTools(); + expect(form4.trainingLevel).toBe("Intermediate"); + expect(form4.trainingLabel).toBe("Resin handling training required before first print."); + // "Authorized" is one of the Trotec's tags. + expect(trotec.trainingLevel).toBe("Advanced"); }); - it("falls back to mockTools and warns when the fetch errors (500)", async () => { - stubNotionEnv(); - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", () => - HttpResponse.json({ message: "boom" }, { status: 500 }) - ) - ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const catalog = await importCatalog(); - const { mockTools } = await import("@/components/mock-catalog"); - - const tools = await catalog.getCatalogTools(); - expect(tools).toBe(mockTools); - expect(warn).toHaveBeenCalledWith( - "Falling back to mock catalog:", - expect.anything() - ); + it("falls back to the bundled photo when no attachment carries one", async () => { + const [form4, trotec] = await getCatalogTools(); + expect(form4.imageSrc).toBe("/tool-images/Form%204.png"); + expect(trotec.imageSrc).toBe("/tool-images/Trotec%20Speedy%20400.png"); }); -}); - -// ── getCatalogTool(id) ────────────────────────────────────────────── - -describe("getCatalogTool", () => { - it("returns the tool found via the Notion path by id", async () => { - stubNotionEnv(); - const { getCatalogTool } = await importCatalog(); - const tool = await getCatalogTool("tool-1"); - expect(tool).not.toBeNull(); - expect(tool?.name).toBe("Form 4"); - expect(tool?.units).toHaveLength(1); + it("attaches each tool's resource links", async () => { + const [form4] = await getCatalogTools(); + expect(form4.links.map((link) => link.label)).toEqual([ + "Form 4 SOP", + "Resin handling safety", + ]); + expect(form4.links[0].kind).toBe("SOP"); }); - it("returns null when the id is not found via the Notion path", async () => { - stubNotionEnv(); - const { getCatalogTool } = await importCatalog(); + it("leaves a draft or archived tool out of the catalogue", async () => { + const db = await getDb(); + await db + .insert(tools) + .values([ + { slug: "draft-tool", name: "Draft tool", published: false }, + { slug: "archived-tool", name: "Archived tool", published: true, archivedAt: new Date() }, + ]) + .onConflictDoNothing(); - const tool = await getCatalogTool("does-not-exist"); - expect(tool).toBeNull(); + const slugs = (await getCatalogTools()).map((tool) => tool.slug); + expect(slugs).not.toContain("draft-tool"); + expect(slugs).not.toContain("archived-tool"); }); +}); - it("resolves a tool by slug from the mock catalog when env is unset", async () => { - unsetNotionEnv(); - const { getCatalogTool } = await importCatalog(); +// ── getCatalogTool ────────────────────────────────────────────────── +describe("getCatalogTool", () => { + it("resolves a slug", async () => { const tool = await getCatalogTool("trotec-speedy-400"); expect(tool?.name).toBe("Trotec Speedy 400"); }); - it("returns null for an unknown slug in the mock fallback", async () => { - unsetNotionEnv(); - const { getCatalogTool } = await importCatalog(); + it("resolves the Postgres uuid capabilities pass back as tool.id", async () => { + const [form4] = await getCatalogTools(); + const tool = await getCatalogTool(form4.id); + expect(tool?.slug).toBe("form-4"); + }); + it("returns null for an unknown slug or uuid", async () => { expect(await getCatalogTool("nope")).toBeNull(); + expect(await getCatalogTool("11111111-2222-3333-4444-555555555555")).toBeNull(); }); }); // ── getCatalogStats ───────────────────────────────────────────────── describe("getCatalogStats", () => { - it("counts tools in inventory and reports lab hours (mock path)", async () => { - unsetNotionEnv(); - const { getCatalogStats } = await importCatalog(); - + it("counts the published tools and reports lab hours", async () => { const stats = await getCatalogStats(); expect(stats.toolsInInventory).toBe(2); expect(stats.labHours).toBe("LAB OPEN 9AM-9PM"); }); - - it("counts tools resolved from the Notion path", async () => { - stubNotionEnv(); - const { getCatalogStats } = await importCatalog(); - - const stats = await getCatalogStats(); - expect(stats.toolsInInventory).toBe(1); - }); -}); - -// ── Status derivation ─────────────────────────────────────────────── - -describe("status derivation (via Notion path)", () => { - const toolNoTraining: NotionPageFixture = { - ...toolsPage, - properties: { ...toolsPage.properties, training_required: checkboxProp(false) }, - }; - - function unit(id: string, status: string): NotionPageFixture { - return { - object: "page", - id, - created_time: "2024-08-12T10:00:00.000Z", - last_edited_time: "2024-08-12T10:00:00.000Z", - properties: { - unit_label: titleProp(`Unit ${id}`), - tool: relationProp(["tool-1"]), - status: selectProp(status), - condition: selectProp("Good"), - }, - }; - } - - it('derives "In Use" when any unit is In Use', async () => { - stubNotionEnv(); - routeCatalog({ units: [unit("u1", "Available"), unit("u2", "In Use")] }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.status).toBe("In Use"); - }); - - it('derives "Offline" when all units are Offline (Out of Service)', async () => { - stubNotionEnv(); - routeCatalog({ units: [unit("u1", "Out of Service"), unit("u2", "Retired")] }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.status).toBe("Offline"); - }); - - it('derives "Training Required" when training_required and no units gate it', async () => { - stubNotionEnv(); - // toolsPage has training_required: true; give it no units so unit-based - // status does not apply. - routeCatalog({ tools: [toolsPage], units: [] }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.status).toBe("Training Required"); - }); - - it('derives "Available" when no training required and no gating units', async () => { - stubNotionEnv(); - routeCatalog({ tools: [toolNoTraining], units: [] }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.status).toBe("Available"); - }); }); -// ── Training level / label derivation ─────────────────────────────── - -describe("training level + label derivation (via Notion path)", () => { - function toolWith(props: Record): NotionPageFixture { - return { ...toolsPage, properties: { ...toolsPage.properties, ...props } }; - } - - it('derives "Advanced" when restrictions mention "authorized"', async () => { - stubNotionEnv(); - routeCatalog({ - tools: [toolWith({ use_restrictions: richTextProp("Authorized users only.") })], - units: [], - }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.trainingLevel).toBe("Advanced"); - // training_required true + use_restrictions set -> label is the restriction - expect(tool.trainingLabel).toBe("Authorized users only."); - }); +// ── Caching and the substrate ─────────────────────────────────────── - it('derives "Advanced" when a tag contains "advanced"', async () => { - stubNotionEnv(); - routeCatalog({ - tools: [ - toolWith({ - use_restrictions: richTextProp(""), - tags: multiSelectProp(["Advanced", "Laser"]), - }), - ], - units: [], - }); - const { getCatalogTools } = await importCatalog(); +describe("caching", () => { + it("tags every read `catalog` and gives it the long catalogue lifetime", async () => { + vi.mocked(cacheTag).mockClear(); + vi.mocked(cacheLife).mockClear(); - const [tool] = await getCatalogTools(); - expect(tool.trainingLevel).toBe("Advanced"); - }); + await getCatalogTools(); + await getCatalogTool("form-4"); + await getCatalogStats(); - it('derives "Intermediate" when training_required and no advanced keyword', async () => { - stubNotionEnv(); - // toolsPage: training_required true, restrictions "Resin handling..." (no - // advanced/authorized keyword), tags Resin/SLA. - routeCatalog({ tools: [toolsPage], units: [] }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.trainingLevel).toBe("Intermediate"); - expect(tool.trainingLabel).toBe("Resin handling training required."); - }); - - it('derives "Beginner" when no training required and no keywords', async () => { - stubNotionEnv(); - routeCatalog({ - tools: [ - toolWith({ - training_required: checkboxProp(false), - use_restrictions: richTextProp(""), - tags: multiSelectProp(["Resin"]), - }), - ], - units: [], - }); - const { getCatalogTools } = await importCatalog(); - - const [tool] = await getCatalogTools(); - expect(tool.trainingLevel).toBe("Beginner"); - expect(tool.trainingLabel).toBe("Beginner orientation"); + expect(vi.mocked(cacheTag).mock.calls).toEqual([["catalog"], ["catalog"], ["catalog"]]); + expect(vi.mocked(cacheLife)).toHaveBeenCalledWith(CATALOG_CACHE); }); }); -// ── resourceLinks ─────────────────────────────────────────────────── - -describe("resourceLinks (via Notion path)", () => { - it("emits a url link plus a link per file attachment", async () => { - stubNotionEnv(); - // Default resourcesPage: url + 2 files (external pdf + hosted png). - const { getCatalogTool } = await importCatalog(); - - const tool = await getCatalogTool("tool-1"); - expect(tool).not.toBeNull(); - - const links = tool!.links; - // 1 url link + 2 file links - expect(links).toHaveLength(3); - - const urlLink = links.find((l) => l.href === "https://example.com/form4-sop"); - expect(urlLink).toMatchObject({ label: "Form 4 SOP", kind: "SOP" }); - - const pdfLink = links.find((l) => l.href === "https://example.com/form4-manual.pdf"); - expect(pdfLink).toBeDefined(); - const pngLink = links.find((l) => l.href === "https://files.notion.so/safety.png"); - expect(pngLink).toBeDefined(); - }); - - it("includes a resource even when published === false (gated by its tool's visibility)", async () => { - // Resources are created as drafts alongside the tool; tool-level publishing - // already gates the catalog, so a tool's links show with it regardless of - // the resource's own published flag. - stubNotionEnv(); - const draftResource: NotionPageFixture = { - object: "page", - id: "res-draft", - created_time: "2024-08-12T10:00:00.000Z", - last_edited_time: "2024-08-12T10:00:00.000Z", - properties: { - title: titleProp("Draft SOP"), - tool: relationProp(["tool-1"]), - type: selectProp("SOP"), - url: urlProp("https://example.com/draft-sop"), - files: filesProp([]), - published: checkboxProp(false), - }, - }; - routeCatalog({ resources: [draftResource] }); - const { getCatalogTool } = await importCatalog(); - - const tool = await getCatalogTool("tool-1"); - expect(tool!.links).toHaveLength(1); - expect(tool!.links[0].href).toBe("https://example.com/draft-sop"); - }); - - it("emits only file links when a resource has files but no url", async () => { - stubNotionEnv(); - const filesOnly: NotionPageFixture = { - object: "page", - id: "res-files-only", - created_time: "2024-08-12T10:00:00.000Z", - last_edited_time: "2024-08-12T10:00:00.000Z", - properties: { - title: titleProp("Manual"), - tool: relationProp(["tool-1"]), - type: selectProp("Manual"), - url: urlProp(null), - files: filesProp([ - externalFile("a.pdf", "https://example.com/a.pdf"), - hostedFile("b.pdf", "https://files.notion.so/b.pdf"), - ]), - published: checkboxProp(true), - }, - }; - routeCatalog({ resources: [filesOnly] }); - const { getCatalogTool } = await importCatalog(); - - const tool = await getCatalogTool("tool-1"); - expect(tool!.links).toHaveLength(2); - expect(tool!.links.every((l) => l.href.endsWith(".pdf"))).toBe(true); +describe("isDemoCatalog", () => { + it("is true on the PGlite demo seed and false once a database is configured", () => { + expect(isDemoCatalog()).toBe(true); + vi.stubEnv("DATABASE_URL", "postgres://user:pass@example.neon.tech/db"); + expect(isDemoCatalog()).toBe(false); }); }); -// ── units / resources grouping ────────────────────────────────────── - -describe("units + resources grouping by tool id", () => { - it("only attaches units/resources whose relation points at the tool", async () => { - stubNotionEnv(); - - const otherUnit: NotionPageFixture = { - object: "page", - id: "unit-other", - created_time: "2024-08-12T10:00:00.000Z", - last_edited_time: "2024-08-12T10:00:00.000Z", - properties: { - unit_label: titleProp("Other unit"), - tool: relationProp(["tool-2"]), - status: selectProp("Available"), - condition: selectProp("Good"), - }, - }; - const otherResource: NotionPageFixture = { - object: "page", - id: "res-other", - created_time: "2024-08-12T10:00:00.000Z", - last_edited_time: "2024-08-12T10:00:00.000Z", - properties: { - title: titleProp("Other SOP"), - tool: relationProp(["tool-2"]), - type: selectProp("SOP"), - url: urlProp("https://example.com/other"), - files: filesProp([]), - published: checkboxProp(true), - }, - }; - - routeCatalog({ - units: [unitsPage, otherUnit], - resources: [resourcesPage, otherResource], - }); - const { getCatalogTool } = await importCatalog(); - - const tool = await getCatalogTool("tool-1"); - // only the unit/resource pointing at tool-1 are attached - expect(tool!.units).toHaveLength(1); - expect(tool!.units[0].id).toBe("unit-1"); - expect(tool!.links.some((l) => l.href.includes("/other"))).toBe(false); +// ── Failing toward stale, never toward invented data ──────────────── + +describe("a database failure", () => { + afterEach(() => { + vi.doUnmock("./data/catalog"); + vi.resetModules(); + }); + + // Article 4: the old code caught a Notion failure and served the mock + // catalogue, so a broken deploy looked healthy and showed equipment the lab + // does not own. The error has to reach the page instead. + it("propagates instead of falling back to sample data", async () => { + vi.resetModules(); + vi.doMock("./data/catalog", () => ({ + listCatalogTools: () => Promise.reject(new Error("the database is unavailable")), + findToolByIdOrSlug: () => Promise.reject(new Error("the database is unavailable")), + countPublishedTools: () => Promise.reject(new Error("the database is unavailable")), + })); + const catalog = await import("./catalog"); + + await expect(catalog.getCatalogTools()).rejects.toThrow("the database is unavailable"); + await expect(catalog.getCatalogTool("form-4")).rejects.toThrow("the database is unavailable"); + await expect(catalog.getCatalogStats()).rejects.toThrow("the database is unavailable"); }); }); diff --git a/v5/src/lib/catalog.ts b/v5/src/lib/catalog.ts index ce4034d..56de094 100644 --- a/v5/src/lib/catalog.ts +++ b/v5/src/lib/catalog.ts @@ -1,246 +1,59 @@ import { cacheLife, cacheTag } from "next/cache"; import { CATALOG_CACHE } from "./cache"; import { - fetchAllCategories, - fetchAllLocations, - fetchAllResources, - fetchAllTools, - fetchAllUnits, - getNotionEnvContract, - resolveTools, -} from "./notion"; -import type { - CategoryRecord, - LocationRecord, - ResourceRecord, - ToolRecord, - ToolWithMeta, - UnitRecord, -} from "./types"; -import type { - CatalogStats, - MakerLabTool, - MakerLabUnit, - ToolStatus, -} from "../components/catalog-types"; -import { getToolBySlug, mockTools } from "../components/mock-catalog"; - -interface FullCatalog { - tools: ToolRecord[]; - categories: CategoryRecord[]; - locations: LocationRecord[]; - units: UnitRecord[]; - resources: ResourceRecord[]; -} - -export function hasNotionCatalogEnv(): boolean { - return getNotionEnvContract().every((key) => Boolean(process.env[key])); + countPublishedTools, + findToolByIdOrSlug, + listCatalogTools, +} from "./data/catalog"; +import { dataSubstrate } from "./db/client"; +import type { CatalogStats, MakerLabTool } from "../components/catalog-types"; + +/** + * The catalogue the app reads (spec §3.9, §3.10). + * + * Three cached entry points over `src/lib/data/catalog.ts`, which holds the SQL + * and the row→view mapping. Everything is tagged `catalog`, so a write in a + * later phase invalidates the whole catalogue with one `revalidateTag` rather + * than waiting out a revalidation window (Article 4). + * + * There is deliberately no fallback. A Postgres failure propagates and the page + * renders its error state: cached pages keep serving, and nothing ever invents + * equipment the lab does not own. The demo seed is the substrate when + * `DATABASE_URL` is unset, never a rescue when a configured database is down. + */ + +/** True when the catalogue is the built-in demo seed rather than a real database. */ +export function isDemoCatalog(): boolean { + return dataSubstrate() === "pglite-demo"; } -async function fetchFullCatalog(): Promise { +export async function getCatalogTools(): Promise { "use cache"; cacheTag("catalog"); cacheLife(CATALOG_CACHE); - const [tools, categories, locations, units, resources] = await Promise.all([ - fetchAllTools(), - fetchAllCategories(), - fetchAllLocations(), - fetchAllUnits(), - fetchAllResources(), - ]); - return { tools, categories, locations, units, resources }; -} - -function groupUnitsByTool(units: UnitRecord[]): Map { - const map = new Map(); - for (const unit of units) { - for (const toolId of unit.fields.tool || []) { - const list = map.get(toolId); - if (list) list.push(unit); - else map.set(toolId, [unit]); - } - } - return map; -} - -function groupResourcesByTool(resources: ResourceRecord[]): Map { - const map = new Map(); - for (const resource of resources) { - // Resources are gated by their parent tool's visibility, not their own - // `published` flag: the catalog only surfaces published tools, so an - // unpublished tool already hides its resources. Filtering resources here too - // meant intake-created links (created as drafts alongside the tool) stayed - // invisible even after staff published the tool — so we show them with it. - for (const toolId of resource.fields.tool || []) { - const list = map.get(toolId); - if (list) list.push(resource); - else map.set(toolId, [resource]); - } - } - return map; -} - -export async function getCatalogTools(): Promise { - if (!hasNotionCatalogEnv()) return mockTools; - - try { - const catalog = await fetchFullCatalog(); - const resolved = resolveTools(catalog.tools, catalog.categories, catalog.locations); - const unitsByTool = groupUnitsByTool(catalog.units); - const resourcesByTool = groupResourcesByTool(catalog.resources); - - return resolved.map((tool) => - toMakerLabTool(tool, unitsByTool.get(tool.id) || [], resourcesByTool.get(tool.id) || []) - ); - } catch (error) { - console.warn("Falling back to mock catalog:", error); - return mockTools; - } + return listCatalogTools(); } export async function getCatalogStats(): Promise { - const tools = await getCatalogTools(); + "use cache"; + cacheTag("catalog"); + cacheLife(CATALOG_CACHE); return { - toolsInInventory: tools.length, + toolsInInventory: await countPublishedTools(), labHours: "LAB OPEN 9AM-9PM", }; } -export async function getCatalogTool(id: string): Promise { - if (!hasNotionCatalogEnv()) { - return getToolBySlug(id) || null; - } - - try { - const catalog = await fetchFullCatalog(); - const raw = catalog.tools.find((tool) => tool.id === id); - if (!raw) return null; - - const [resolved] = resolveTools([raw], catalog.categories, catalog.locations); - if (!resolved) return null; - - const units = groupUnitsByTool(catalog.units).get(id) || []; - const resources = groupResourcesByTool(catalog.resources).get(id) || []; - return toMakerLabTool(resolved, units, resources); - } catch (error) { - console.warn("Falling back to mock tool:", error); - return getToolBySlug(id) || null; - } -} - -function toMakerLabTool( - tool: ToolWithMeta, - units: UnitRecord[], - resources: ResourceRecord[] = [] -): MakerLabTool { - const mappedUnits = units.map((unit) => toMakerLabUnit(unit, tool)); - const status = deriveStatus(tool, mappedUnits); - - return { - id: tool.id, - slug: tool.id, - name: tool.name, - category: tool.category_group, - categorySub: tool.category_sub, - location: tool.location_room, - zone: tool.location_zone, - trainingLevel: deriveTrainingLevel(tool), - trainingLabel: deriveTrainingLabel(tool), - status, - shortDescription: tool.description || "Catalog record pending description.", - description: tool.description || "This tool record is available in the MakerLab catalog.", - imageSrc: tool.image_url || localToolImage(tool.name), - ppe: tool.ppe_required.length ? tool.ppe_required : ["Check posted lab guidance"], - materials: tool.materials, - tags: tool.tags, - emergencyStop: tool.emergency_stop, - useRestrictions: tool.use_restrictions, - mapId: tool.map_tag, - notes: tool.notes, - links: resourceLinks(resources), - units: mappedUnits, - }; -} - -function resourceLinks(resources: ResourceRecord[]): MakerLabTool["links"] { - return resources.flatMap((resource) => { - const fields = resource.fields; - const base = { - kind: fields.type || "Resource", - description: fields.notes || undefined, - }; - const urlLinks = fields.url - ? [ - { - label: fields.title || fields.type || "Resource", - href: fields.url, - ...base, - }, - ] - : []; - const fileLinks = - fields.files?.map((file) => ({ - label: fields.title || file.filename || fields.type || "Resource", - href: file.url, - ...base, - })) || []; - - return [...urlLinks, ...fileLinks]; - }); -} - -function toMakerLabUnit(unit: UnitRecord, tool: ToolWithMeta): MakerLabUnit { - return { - id: unit.id, - name: unit.fields.unit_label || `${tool.name} // Unit`, - serial: unit.fields.serial_number || unit.fields.asset_tag || "Unlisted", - status: toToolStatus(unit.fields.status, false), - condition: toCondition(unit.fields.condition, unit.fields.status), - location: tool.location_zone || tool.location_room || "Unknown", - dateAcquired: unit.fields.date_acquired || null, - }; -} - -function deriveStatus(tool: ToolWithMeta, units: MakerLabUnit[]): ToolStatus { - if (units.some((unit) => unit.status === "In Use")) return "In Use"; - if (units.length > 0 && units.every((unit) => unit.status === "Offline")) return "Offline"; - return toToolStatus(undefined, tool.training_required); -} - -function toToolStatus(status: string | undefined, trainingRequired: boolean): ToolStatus { - if (status === "In Use") return "In Use"; - if (status === "Under Maintenance" || status === "Out of Service" || status === "Retired") { - return "Offline"; - } - if (trainingRequired) return "Training Required"; - return "Available"; -} - -function toCondition( - condition: string | undefined, - status: string | undefined -): MakerLabUnit["condition"] { - if (status === "Out of Service" || status === "Retired") return "Offline"; - if (condition === "Needs Repair" || status === "Under Maintenance") return "Service Soon"; - if (condition === "Excellent" || condition === "Good") return condition; - return "Good"; -} - -function deriveTrainingLevel(tool: ToolWithMeta): MakerLabTool["trainingLevel"] { - const restrictionText = `${tool.use_restrictions || ""} ${tool.tags.join(" ")}`.toLowerCase(); - if (restrictionText.includes("advanced") || restrictionText.includes("authorized")) return "Advanced"; - if (tool.training_required) return "Intermediate"; - return "Beginner"; -} - -function deriveTrainingLabel(tool: ToolWithMeta): string { - if (!tool.training_required) return "Beginner orientation"; - if (tool.use_restrictions) return tool.use_restrictions; - return `${deriveTrainingLevel(tool)} checkout required`; -} +/** + * One tool by slug, or by the Postgres uuid capabilities pass back as + * `tool.id`. Returns null when neither matches. + */ +export async function getCatalogTool(idOrSlug: string): Promise { + "use cache"; + cacheTag("catalog"); + cacheLife(CATALOG_CACHE); -function localToolImage(name: string): string { - return `/tool-images/${encodeURIComponent(name.replace(/\//g, "_"))}.png`; + return findToolByIdOrSlug(idOrSlug); } diff --git a/v5/src/lib/data/catalog.test.ts b/v5/src/lib/data/catalog.test.ts new file mode 100644 index 0000000..2b8cb8f --- /dev/null +++ b/v5/src/lib/data/catalog.test.ts @@ -0,0 +1,732 @@ +// @vitest-environment node +import { createPgliteDb } from "../db/pglite"; +import { + attachments, + categories, + locations, + resources, + tools, + units, + UNIT_CONDITION, + UNIT_STATUS, +} from "../db/schema/index"; +import type { Db } from "../db/types"; +import { + countPublishedTools, + deriveTrainingLabel, + deriveTrainingLevel, + findToolByIdOrSlug, + findToolByNotionPageId, + findToolBySlug, + indexAttachments, + listCatalogTools, + localToolImage, + resourceLinks, + toCondition, + toMakerLabUnit, + toToolStatus, + toolImageSrc, + type AttachmentRow, + type ResourceRow, + type ToolRow, + type UnitRow, +} from "./catalog"; + +/** + * The catalogue read path, against a real (in-process) Postgres with rows this + * file inserts — and the row→view mapping on its own, where the derivation + * rules are easiest to exercise exhaustively. + */ + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + // Every test builds the catalogue it needs. Deleting tools cascades to their + // units and resources; attachments are polymorphic and have no foreign key. + await db.delete(attachments); + await db.delete(tools); + await db.delete(categories); + await db.delete(locations); +}); + +// ── Fixtures ──────────────────────────────────────────────────────── + +type ToolValues = typeof tools.$inferInsert; +type UnitValues = typeof units.$inferInsert; +type ResourceValues = typeof resources.$inferInsert; + +async function insertCategory(name: string, group: string | null): Promise { + const [row] = await db.insert(categories).values({ name, group }).returning({ id: categories.id }); + return row.id; +} + +async function insertLocation( + room: string, + zone: string, + mapTag: string | null +): Promise { + const [row] = await db + .insert(locations) + .values({ room, zone, mapTag }) + .returning({ id: locations.id }); + return row.id; +} + +async function insertTool(values: ToolValues): Promise { + const [row] = await db + .insert(tools) + .values({ published: true, ...values }) + .returning({ id: tools.id }); + return row.id; +} + +async function insertUnit(values: UnitValues): Promise { + const [row] = await db.insert(units).values(values).returning({ id: units.id }); + return row.id; +} + +async function insertResource(values: ResourceValues): Promise { + const [row] = await db.insert(resources).values(values).returning({ id: resources.id }); + return row.id; +} + +async function insertAttachment(values: { + ownerType: string; + ownerId: string; + position?: number; + access?: string; + publicUrl?: string | null; + originalFilename?: string | null; +}): Promise { + await db.insert(attachments).values({ + ownerType: values.ownerType, + ownerId: values.ownerId, + position: values.position ?? 0, + blobPathname: `blob/${values.ownerId}/${values.position ?? 0}`, + access: values.access ?? "public", + publicUrl: values.publicUrl === undefined ? "https://blob.example.com/photo.png" : values.publicUrl, + originalFilename: values.originalFilename ?? null, + }); +} + +/** A published tool with a category, a location, one unit and one resource. */ +async function seedForm4(overrides: Partial = {}): Promise { + const categoryId = await insertCategory("Resin", "3D Printing"); + const locationId = await insertLocation("MakerLab", "Resin Bench", "ML-RESIN-01"); + const toolId = await insertTool({ + slug: "form-4", + name: "Form 4", + description: "A production-grade resin printer.", + categoryId, + locationId, + materials: ["Standard resin"], + ppeRequired: ["Nitrile gloves"], + tags: ["Resin", "SLA"], + trainingRequired: true, + useRestrictions: "Resin handling training required.", + emergencyStop: "Lift the lid.", + notes: "Ventilation must be running.", + ...overrides, + }); + await insertUnit({ + toolId, + unitLabel: "Form 4 // A", + serialNumber: "ML-F4-001", + status: "in_use", + condition: "excellent", + dateAcquired: "2024-08-12", + }); + await insertResource({ toolId, title: "Form 4 SOP", type: "SOP", url: "https://example.com/sop" }); + return toolId; +} + +// ── listCatalogTools ──────────────────────────────────────────────── + +describe("listCatalogTools", () => { + it("maps a tool, its category, location, unit and resource into the view model", async () => { + await seedForm4(); + + const [tool] = await listCatalogTools({ db }); + expect(tool).toMatchObject({ + slug: "form-4", + name: "Form 4", + category: "3D Printing", + categorySub: "Resin", + location: "MakerLab", + zone: "Resin Bench", + mapId: "ML-RESIN-01", + status: "In Use", + trainingLevel: "Intermediate", + trainingLabel: "Resin handling training required.", + shortDescription: "A production-grade resin printer.", + materials: ["Standard resin"], + ppe: ["Nitrile gloves"], + emergencyStop: "Lift the lid.", + notes: "Ventilation must be running.", + }); + expect(tool.id).toMatch(/^[0-9a-f-]{36}$/); + expect(tool.units).toEqual([ + { + id: expect.any(String), + name: "Form 4 // A", + serial: "ML-F4-001", + status: "In Use", + condition: "Excellent", + location: "Resin Bench", + dateAcquired: "2024-08-12", + }, + ]); + expect(tool.links).toEqual([ + { + label: "Form 4 SOP", + href: "https://example.com/sop", + kind: "SOP", + description: undefined, + }, + ]); + }); + + it("shows published tools only, never drafts or archived ones, ordered by name", async () => { + await insertTool({ slug: "zeta", name: "Zeta mill" }); + await insertTool({ slug: "alpha", name: "Alpha saw" }); + await insertTool({ slug: "draft", name: "Draft router", published: false }); + await insertTool({ slug: "gone", name: "Archived lathe", archivedAt: new Date() }); + + const names = (await listCatalogTools({ db })).map((tool) => tool.name); + expect(names).toEqual(["Alpha saw", "Zeta mill"]); + }); + + it("includes drafts when the caller asks, but never an archived tool", async () => { + await insertTool({ slug: "draft", name: "Draft router", published: false }); + await insertTool({ slug: "gone", name: "Archived lathe", archivedAt: new Date() }); + + const names = (await listCatalogTools({ db, includeDrafts: true })).map((tool) => tool.name); + expect(names).toEqual(["Draft router"]); + }); + + it("falls back to honest placeholders when a tool has no category, location or copy", async () => { + await insertTool({ slug: "bare", name: "Bare tool" }); + + const [tool] = await listCatalogTools({ db }); + expect(tool).toMatchObject({ + category: "Uncategorized", + categorySub: "Other", + location: "Unknown", + zone: "Unknown", + mapId: null, + status: "Available", + trainingLevel: "Beginner", + trainingLabel: "Beginner orientation", + shortDescription: "Catalog record pending description.", + ppe: ["Check posted lab guidance"], + imageSrc: "/tool-images/Bare%20tool.png", + units: [], + links: [], + }); + }); + + it("keeps each tool's units and resources to itself", async () => { + const form4 = await seedForm4(); + const other = await insertTool({ slug: "other", name: "Other tool" }); + await insertUnit({ toolId: other, unitLabel: "Other // A", status: "available" }); + await insertResource({ toolId: other, title: "Other SOP", url: "https://example.com/other" }); + + const bySlug = new Map((await listCatalogTools({ db })).map((tool) => [tool.slug, tool])); + expect(bySlug.get("form-4")?.units.map((unit) => unit.name)).toEqual(["Form 4 // A"]); + expect(bySlug.get("form-4")?.links.map((link) => link.href)).toEqual([ + "https://example.com/sop", + ]); + expect(bySlug.get("other")?.units.map((unit) => unit.name)).toEqual(["Other // A"]); + expect(form4).not.toBe(other); + }); +}); + +// ── Images and links from attachments ─────────────────────────────── + +describe("attachments", () => { + it("uses the lowest-position public photo as the tool image", async () => { + const toolId = await seedForm4(); + await insertAttachment({ + ownerType: "tool", + ownerId: toolId, + position: 2, + publicUrl: "https://blob.example.com/third.png", + }); + await insertAttachment({ + ownerType: "tool", + ownerId: toolId, + position: 1, + publicUrl: "https://blob.example.com/first.png", + }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.imageSrc).toBe("https://blob.example.com/first.png"); + }); + + it("skips a private photo and falls back to the bundled image", async () => { + const toolId = await seedForm4(); + await insertAttachment({ + ownerType: "tool", + ownerId: toolId, + access: "private", + publicUrl: "https://blob.example.com/private.png", + }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.imageSrc).toBe("/tool-images/Form%204.png"); + }); + + it("emits a link per resource file alongside the resource url", async () => { + const toolId = await insertTool({ slug: "laser", name: "Laser" }); + const resourceId = await insertResource({ + toolId, + title: "Manual", + type: "Manual", + url: "https://example.com/manual", + notes: "Read section 4 first.", + }); + await insertAttachment({ + ownerType: "resource", + ownerId: resourceId, + position: 0, + publicUrl: "https://blob.example.com/manual.pdf", + originalFilename: "manual.pdf", + }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.links).toEqual([ + { + label: "Manual", + href: "https://example.com/manual", + kind: "Manual", + description: "Read section 4 first.", + }, + { + label: "Manual", + href: "https://blob.example.com/manual.pdf", + kind: "Manual", + description: "Read section 4 first.", + }, + ]); + }); + + it("shows an unpublished resource, because its tool's visibility already gates it", async () => { + const toolId = await insertTool({ slug: "laser", name: "Laser" }); + await insertResource({ + toolId, + title: "Draft SOP", + url: "https://example.com/draft", + published: false, + }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.links.map((link) => link.href)).toEqual(["https://example.com/draft"]); + }); + + it("ignores attachments owned by another row entirely", async () => { + const toolId = await seedForm4(); + const other = await insertTool({ slug: "other", name: "Other tool" }); + await insertAttachment({ + ownerType: "tool", + ownerId: other, + publicUrl: "https://blob.example.com/other.png", + }); + + const bySlug = new Map((await listCatalogTools({ db })).map((tool) => [tool.slug, tool])); + expect(bySlug.get("form-4")?.imageSrc).toBe("/tool-images/Form%204.png"); + expect(bySlug.get("other")?.imageSrc).toBe("https://blob.example.com/other.png"); + expect(toolId).not.toBe(other); + }); +}); + +// ── Status and condition, from the stored vocabulary ──────────────── + +describe("unit status and condition from stored values", () => { + it.each([ + ["available", "Available"], + ["in_use", "In Use"], + ["under_maintenance", "Offline"], + ["out_of_service", "Offline"], + ["retired", "Offline"], + ] as const)("reads unit status %s as %s", async (stored, shown) => { + const toolId = await insertTool({ slug: "s", name: "Status tool" }); + await insertUnit({ toolId, unitLabel: "A", status: stored, condition: "good" }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.units[0].status).toBe(shown); + }); + + it.each([ + ["excellent", "Excellent"], + ["good", "Good"], + ["fair", "Good"], + ["new", "Good"], + ["needs_repair", "Service Soon"], + [null, "Good"], + ] as const)("reads unit condition %s as %s", async (stored, shown) => { + const toolId = await insertTool({ slug: "c", name: "Condition tool" }); + await insertUnit({ toolId, unitLabel: "A", status: "available", condition: stored }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.units[0].condition).toBe(shown); + }); + + it("covers every value the schema allows", () => { + // A vocabulary that grows without this module growing with it would show + // the new value as "Available" / "Good" without anyone noticing. + expect([...UNIT_STATUS]).toEqual([ + "available", + "in_use", + "under_maintenance", + "out_of_service", + "retired", + ]); + expect([...UNIT_CONDITION]).toEqual(["excellent", "good", "fair", "needs_repair", "new"]); + }); + + it("lets maintenance and retirement override the stored condition", async () => { + const toolId = await insertTool({ slug: "m", name: "Maintenance tool" }); + await insertUnit({ + toolId, + unitLabel: "A", + status: "under_maintenance", + condition: "excellent", + }); + await insertUnit({ toolId, unitLabel: "B", status: "retired", condition: "good" }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.units.map((unit) => unit.condition)).toEqual(["Service Soon", "Offline"]); + }); + + it("falls back to the asset tag, then to Unlisted, for a serial", async () => { + const toolId = await insertTool({ slug: "sn", name: "Serial tool" }); + await insertUnit({ toolId, unitLabel: "A", assetTag: "ASSET-9", status: "available" }); + await insertUnit({ toolId, unitLabel: "B", status: "available" }); + + const [tool] = await listCatalogTools({ db }); + expect(tool.units.map((unit) => unit.serial)).toEqual(["ASSET-9", "Unlisted"]); + }); +}); + +describe("tool status derivation", () => { + it("is In Use when any unit is in use, whatever the others say", async () => { + const toolId = await insertTool({ slug: "t", name: "Tool" }); + await insertUnit({ toolId, unitLabel: "A", status: "available" }); + await insertUnit({ toolId, unitLabel: "B", status: "in_use" }); + + expect((await listCatalogTools({ db }))[0].status).toBe("In Use"); + }); + + it("is Offline when every unit is offline", async () => { + const toolId = await insertTool({ slug: "t", name: "Tool" }); + await insertUnit({ toolId, unitLabel: "A", status: "out_of_service" }); + await insertUnit({ toolId, unitLabel: "B", status: "retired" }); + + expect((await listCatalogTools({ db }))[0].status).toBe("Offline"); + }); + + it("is Training Required when training is required and no unit gates it", async () => { + await insertTool({ slug: "t", name: "Tool", trainingRequired: true }); + + expect((await listCatalogTools({ db }))[0].status).toBe("Training Required"); + }); + + it("is Available when nothing gates it", async () => { + const toolId = await insertTool({ slug: "t", name: "Tool" }); + await insertUnit({ toolId, unitLabel: "A", status: "available" }); + + expect((await listCatalogTools({ db }))[0].status).toBe("Available"); + }); +}); + +// ── Lookups ───────────────────────────────────────────────────────── + +describe("findToolBySlug", () => { + it("resolves a published tool and returns null for an unknown slug", async () => { + await seedForm4(); + + expect((await findToolBySlug("form-4", { db }))?.name).toBe("Form 4"); + expect(await findToolBySlug("nope", { db })).toBeNull(); + }); + + it("hides a draft unless the caller passes includeDrafts", async () => { + await insertTool({ slug: "draft", name: "Draft router", published: false }); + + expect(await findToolBySlug("draft", { db })).toBeNull(); + expect((await findToolBySlug("draft", { db, includeDrafts: true }))?.name).toBe("Draft router"); + }); + + it("hides an archived tool even with includeDrafts", async () => { + await insertTool({ slug: "gone", name: "Archived lathe", archivedAt: new Date() }); + + expect(await findToolBySlug("gone", { db, includeDrafts: true })).toBeNull(); + }); +}); + +describe("findToolByIdOrSlug", () => { + it("resolves the Postgres uuid capabilities pass back as tool.id", async () => { + const toolId = await seedForm4(); + + expect((await findToolByIdOrSlug(toolId, { db }))?.slug).toBe("form-4"); + }); + + it("resolves a slug", async () => { + await seedForm4(); + + expect((await findToolByIdOrSlug("form-4", { db }))?.name).toBe("Form 4"); + }); + + it("returns null for an unknown uuid and for free text, without a bad-cast error", async () => { + await seedForm4(); + + expect(await findToolByIdOrSlug("11111111-2222-3333-4444-555555555555", { db })).toBeNull(); + expect(await findToolByIdOrSlug("not a tool at all", { db })).toBeNull(); + }); +}); + +describe("findToolByNotionPageId", () => { + const dashed = "0f5e2c1a-4b6d-4e8f-9a0b-1c2d3e4f5a6b"; + const compact = "0f5e2c1a4b6d4e8f9a0b1c2d3e4f5a6b"; + + it("matches the stored id whether the link carries dashes or not", async () => { + await insertTool({ slug: "form-4", name: "Form 4", notionPageId: dashed }); + + expect(await findToolByNotionPageId(dashed, { db })).toEqual({ slug: "form-4" }); + expect(await findToolByNotionPageId(compact, { db })).toEqual({ slug: "form-4" }); + expect(await findToolByNotionPageId(compact.toUpperCase(), { db })).toEqual({ slug: "form-4" }); + }); + + it("matches an undashed stored id too, since the import is not the only writer", async () => { + await insertTool({ slug: "form-4", name: "Form 4", notionPageId: compact }); + + expect(await findToolByNotionPageId(dashed, { db })).toEqual({ slug: "form-4" }); + }); + + it("returns null for an unknown id and for a slug", async () => { + await seedForm4(); + + expect(await findToolByNotionPageId(dashed, { db })).toBeNull(); + expect(await findToolByNotionPageId("form-4", { db })).toBeNull(); + }); +}); + +describe("countPublishedTools", () => { + it("counts published, non-archived tools only", async () => { + await insertTool({ slug: "a", name: "A" }); + await insertTool({ slug: "b", name: "B" }); + await insertTool({ slug: "c", name: "C", published: false }); + await insertTool({ slug: "d", name: "D", archivedAt: new Date() }); + + expect(await countPublishedTools({ db })).toBe(2); + }); + + it("is 0 on an empty catalogue", async () => { + expect(await countPublishedTools({ db })).toBe(0); + }); +}); + +// ── The mapping functions on their own ────────────────────────────── + +const toolRow: ToolRow = { + id: "tool-id", + slug: "form-4", + name: "Form 4", + description: null, + materials: [], + ppeRequired: [], + tags: [], + trainingRequired: false, + useRestrictions: null, + emergencyStop: null, + notes: null, + categoryName: null, + categoryGroup: null, + room: "MakerLab", + zone: "Resin Bench", + mapTag: null, +}; + +const unitRow: UnitRow = { + id: "unit-id", + toolId: "tool-id", + unitLabel: "Form 4 // A", + serialNumber: null, + assetTag: null, + status: "available", + condition: null, + dateAcquired: null, +}; + +function file(overrides: Partial = {}): AttachmentRow { + return { + ownerType: "tool", + ownerId: "tool-id", + position: 0, + access: "public", + publicUrl: "https://blob.example.com/a.png", + originalFilename: null, + ...overrides, + }; +} + +describe("toToolStatus", () => { + it("maps stored statuses, then falls through to the training gate", () => { + expect(toToolStatus("in_use", false)).toBe("In Use"); + expect(toToolStatus("under_maintenance", false)).toBe("Offline"); + expect(toToolStatus("out_of_service", false)).toBe("Offline"); + expect(toToolStatus("retired", false)).toBe("Offline"); + expect(toToolStatus("available", true)).toBe("Training Required"); + expect(toToolStatus("available", false)).toBe("Available"); + expect(toToolStatus(undefined, true)).toBe("Training Required"); + expect(toToolStatus(null, false)).toBe("Available"); + }); + + it("does not accept the Title-case values Notion used to store", () => { + expect(toToolStatus("In Use", false)).toBe("Available"); + expect(toToolStatus("Out of Service", false)).toBe("Available"); + }); +}); + +describe("toCondition", () => { + it("lets the status override the condition", () => { + expect(toCondition("excellent", "out_of_service")).toBe("Offline"); + expect(toCondition("excellent", "retired")).toBe("Offline"); + expect(toCondition("excellent", "under_maintenance")).toBe("Service Soon"); + }); + + it("maps every stored condition", () => { + expect(toCondition("excellent", "available")).toBe("Excellent"); + expect(toCondition("good", "available")).toBe("Good"); + expect(toCondition("fair", "available")).toBe("Good"); + expect(toCondition("new", "available")).toBe("Good"); + expect(toCondition("needs_repair", "available")).toBe("Service Soon"); + expect(toCondition(null, "available")).toBe("Good"); + }); +}); + +describe("toMakerLabUnit", () => { + it("names an unlabelled unit after its tool and locates it by zone", () => { + const unit = toMakerLabUnit({ ...unitRow, unitLabel: "" }, toolRow); + expect(unit.name).toBe("Form 4 // Unit"); + expect(unit.location).toBe("Resin Bench"); + }); + + it("falls back to the room, then to Unknown, when there is no zone", () => { + expect(toMakerLabUnit(unitRow, { ...toolRow, zone: null }).location).toBe("MakerLab"); + expect(toMakerLabUnit(unitRow, { ...toolRow, zone: null, room: null }).location).toBe("Unknown"); + }); +}); + +describe("deriveTrainingLevel / deriveTrainingLabel", () => { + it("reads Advanced out of the restrictions or the tags", () => { + expect( + deriveTrainingLevel({ ...toolRow, useRestrictions: "Authorized users only." }) + ).toBe("Advanced"); + expect(deriveTrainingLevel({ ...toolRow, tags: ["Advanced", "Laser"] })).toBe("Advanced"); + }); + + it("is Intermediate when training is required and nothing says advanced", () => { + expect(deriveTrainingLevel({ ...toolRow, trainingRequired: true })).toBe("Intermediate"); + }); + + it("is Beginner otherwise", () => { + expect(deriveTrainingLevel(toolRow)).toBe("Beginner"); + expect(deriveTrainingLabel(toolRow)).toBe("Beginner orientation"); + }); + + it("prefers the restriction text as the label, then names the level", () => { + expect( + deriveTrainingLabel({ + ...toolRow, + trainingRequired: true, + useRestrictions: "Resin handling training required.", + }) + ).toBe("Resin handling training required."); + expect(deriveTrainingLabel({ ...toolRow, trainingRequired: true })).toBe( + "Intermediate checkout required" + ); + }); +}); + +describe("indexAttachments / toolImageSrc", () => { + it("groups by owner and sorts by position, whatever order the rows arrive in", () => { + const index = indexAttachments([ + file({ position: 2, publicUrl: "c.png" }), + file({ position: 0, publicUrl: "a.png" }), + file({ ownerType: "resource", ownerId: "res-id", publicUrl: "r.png" }), + file({ position: 1, publicUrl: "b.png" }), + ]); + + expect(index.get("tool:tool-id")?.map((row) => row.publicUrl)).toEqual([ + "a.png", + "b.png", + "c.png", + ]); + expect(index.get("resource:res-id")?.map((row) => row.publicUrl)).toEqual(["r.png"]); + }); + + it("drops an attachment that is not owned by anything yet", () => { + expect(indexAttachments([file({ ownerType: null, ownerId: null })]).size).toBe(0); + }); + + it("falls back to the bundled photo when there is no public file", () => { + expect(toolImageSrc(toolRow, [])).toBe("/tool-images/Form%204.png"); + expect(toolImageSrc(toolRow, [file({ access: "private" })])).toBe("/tool-images/Form%204.png"); + expect(toolImageSrc(toolRow, [file({ publicUrl: null })])).toBe("/tool-images/Form%204.png"); + }); +}); + +describe("resourceLinks", () => { + const resourceRow: ResourceRow = { + id: "res-id", + toolId: "tool-id", + title: "Form 4 SOP", + type: "SOP", + url: null, + notes: null, + }; + + it("emits nothing for a resource with neither a url nor a file", () => { + expect(resourceLinks([resourceRow])).toEqual([]); + }); + + it("labels a file link with the resource title, then the filename, then the type", () => { + const files = indexAttachments([ + file({ ownerType: "resource", ownerId: "res-id", originalFilename: "sop.pdf" }), + ]); + + expect(resourceLinks([resourceRow], files)[0].label).toBe("Form 4 SOP"); + expect(resourceLinks([{ ...resourceRow, title: "" }], files)[0].label).toBe("sop.pdf"); + expect( + resourceLinks( + [{ ...resourceRow, title: "" }], + indexAttachments([file({ ownerType: "resource", ownerId: "res-id" })]) + )[0].label + ).toBe("SOP"); + }); + + it("never links a private file", () => { + const files = indexAttachments([ + file({ ownerType: "resource", ownerId: "res-id", access: "private" }), + ]); + expect(resourceLinks([resourceRow], files)).toEqual([]); + }); + + it("calls an untyped resource a Resource", () => { + const links = resourceLinks([ + { ...resourceRow, title: "", type: null, url: "https://example.com/x" }, + ]); + expect(links[0]).toMatchObject({ label: "Resource", kind: "Resource" }); + }); +}); + +describe("localToolImage", () => { + it("encodes the name and keeps a slash out of the path", () => { + expect(localToolImage("Form 4")).toBe("/tool-images/Form%204.png"); + expect(localToolImage("Bantam/Othermill")).toBe("/tool-images/Bantam_Othermill.png"); + }); +}); diff --git a/v5/src/lib/data/catalog.ts b/v5/src/lib/data/catalog.ts new file mode 100644 index 0000000..757a0ab --- /dev/null +++ b/v5/src/lib/data/catalog.ts @@ -0,0 +1,476 @@ +import { and, asc, count, eq, inArray, isNotNull, isNull, or, sql, type SQL } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { + attachments, + categories, + locations, + resources, + tools, + units, +} from "../db/schema/index.ts"; +import type { Db } from "../db/types.ts"; +import { compactNotionId } from "../legacy-id.ts"; +import { isUuid } from "./uuid.ts"; +import type { MakerLabTool, MakerLabUnit, ToolStatus } from "../../components/catalog-types.ts"; + +/** + * The catalogue read path on Postgres (spec §3.10, §4.13). + * + * `src/lib/catalog.ts` is the cached wrapper the app calls; this module is the + * SQL underneath it and the row→view mapping, exported so the derivation rules + * can be tested without a database. The view models are unchanged — every + * display string (`"In Use"`, `"Service Soon"`, `"Uncategorized"`) is derived + * from the stored snake_case vocabulary exactly as `catalog.ts` derived it from + * Notion's Title-case select options. + * + * A catalogue read is four statements whatever the size of the catalogue — + * tools with their category and location, then units, resources and + * attachments in bulk — never one per tool. + * + * Relative imports with `.ts` extensions and no `@/` alias, and no + * `"server-only"`: `scripts/` loads this module under plain Node. + */ + +/** A `tools` row joined to its category and location. */ +export interface ToolRow { + id: string; + slug: string; + name: string; + description: string | null; + materials: string[]; + ppeRequired: string[]; + tags: string[]; + trainingRequired: boolean; + useRestrictions: string | null; + emergencyStop: string | null; + notes: string | null; + categoryName: string | null; + categoryGroup: string | null; + room: string | null; + zone: string | null; + mapTag: string | null; +} + +/** A `units` row belonging to one tool. */ +export interface UnitRow { + id: string; + toolId: string | null; + unitLabel: string; + serialNumber: string | null; + assetTag: string | null; + status: string; + condition: string | null; + dateAcquired: string | null; +} + +/** A `resources` row belonging to one tool. */ +export interface ResourceRow { + id: string; + toolId: string | null; + title: string; + type: string | null; + url: string | null; + notes: string | null; +} + +/** An `attachments` row: a file in Blob and the row it hangs off. */ +export interface AttachmentRow { + ownerType: string | null; + ownerId: string | null; + position: number; + access: string; + publicUrl: string | null; + originalFilename: string | null; +} + +/** Attachments grouped by `:`, each list in position order. */ +export type AttachmentIndex = Map; + +export interface CatalogQueryOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; + /** Include unpublished tools. Off by default: the catalogue is published-only. */ + includeDrafts?: boolean; +} + +// ── Queries ───────────────────────────────────────────────────────── + +/** Every published, non-archived tool, ordered by name. */ +export async function listCatalogTools( + options: CatalogQueryOptions = {} +): Promise { + const db = await resolveDb(options); + return loadTools(db, and(...visibility(options))); +} + +/** One tool by slug, or null. Published only unless `includeDrafts` is passed. */ +export async function findToolBySlug( + slug: string, + options: CatalogQueryOptions = {} +): Promise { + const db = await resolveDb(options); + const [tool] = await loadTools(db, and(eq(tools.slug, slug), ...visibility(options))); + return tool ?? null; +} + +/** + * One tool by slug or by Postgres uuid, or null. Capabilities hand `tool.id` + * straight back (`get_tool_details`), and the tool route passes a slug, so both + * resolve here in a single statement. A value that is neither a uuid nor a + * known slug returns null rather than reaching Postgres as a bad uuid cast. + */ +export async function findToolByIdOrSlug( + idOrSlug: string, + options: CatalogQueryOptions = {} +): Promise { + const db = await resolveDb(options); + const match = isUuid(idOrSlug) + ? or(eq(tools.slug, idOrSlug), eq(tools.id, idOrSlug)) + : eq(tools.slug, idOrSlug); + const [tool] = await loadTools(db, and(match, ...visibility(options))); + return tool ?? null; +} + +/** + * The slug behind a legacy `/tools/` link, or null (spec + * Goal 2). Dashes and case are normalised on both sides, because the id in a + * printed QR label is undashed and the stored one is not. + * + * Unfiltered by `published` and `archived_at` on purpose: an old link should + * land on the tool's current page and let that page decide what to show, + * rather than silently 404 at the redirect. + */ +export async function findToolByNotionPageId( + id: string, + options: CatalogQueryOptions = {} +): Promise<{ slug: string } | null> { + const compact = compactNotionId(id); + if (!compact) return null; + + const db = await resolveDb(options); + const [row] = await db + .select({ slug: tools.slug }) + .from(tools) + .where(sql`lower(replace(${tools.notionPageId}, '-', '')) = ${compact}`) + .limit(1); + return row ?? null; +} + +/** How many tools the catalogue shows — `getCatalogStats().toolsInInventory`. */ +export async function countPublishedTools(options: CatalogQueryOptions = {}): Promise { + const db = await resolveDb(options); + const [row] = await db + .select({ value: count() }) + .from(tools) + .where(and(...visibility(options))); + return row?.value ?? 0; +} + +async function resolveDb(options: CatalogQueryOptions): Promise { + return options.db ?? (await getDb()); +} + +/** Archived tools are never shown; drafts only when the caller asks. */ +function visibility(options: CatalogQueryOptions): SQL[] { + const clauses = [isNull(tools.archivedAt)]; + if (!options.includeDrafts) clauses.push(eq(tools.published, true)); + return clauses; +} + +/** + * Tools matching `where`, with everything they display. Four statements: the + * tools themselves, their units, their resources, and the attachments owned by + * either — so the cost of a catalogue page does not grow with the tool count. + */ +async function loadTools(db: Db, where: SQL | undefined): Promise { + const toolRows: ToolRow[] = await db + .select({ + id: tools.id, + slug: tools.slug, + name: tools.name, + description: tools.description, + materials: tools.materials, + ppeRequired: tools.ppeRequired, + tags: tools.tags, + trainingRequired: tools.trainingRequired, + useRestrictions: tools.useRestrictions, + emergencyStop: tools.emergencyStop, + notes: tools.notes, + categoryName: categories.name, + categoryGroup: categories.group, + room: locations.room, + zone: locations.zone, + mapTag: locations.mapTag, + }) + .from(tools) + .leftJoin(categories, eq(tools.categoryId, categories.id)) + .leftJoin(locations, eq(tools.locationId, locations.id)) + .where(where) + .orderBy(asc(tools.name)); + + if (toolRows.length === 0) return []; + const toolIds = toolRows.map((tool) => tool.id); + + const unitRows: UnitRow[] = await db + .select({ + id: units.id, + toolId: units.toolId, + unitLabel: units.unitLabel, + serialNumber: units.serialNumber, + assetTag: units.assetTag, + status: units.status, + condition: units.condition, + dateAcquired: units.dateAcquired, + }) + .from(units) + .where(inArray(units.toolId, toolIds)) + .orderBy(asc(units.unitLabel), asc(units.id)); + + // Deliberately not filtered by `resources.published`: resources are created + // as drafts alongside their tool, and tool-level publishing already gates the + // catalogue. Filtering here too kept intake-created links invisible after + // staff published the tool. + const resourceRows: ResourceRow[] = await db + .select({ + id: resources.id, + toolId: resources.toolId, + title: resources.title, + type: resources.type, + url: resources.url, + notes: resources.notes, + }) + .from(resources) + .where(inArray(resources.toolId, toolIds)) + .orderBy(asc(resources.createdAt), asc(resources.title), asc(resources.id)); + + const files = indexAttachments( + await selectAttachments( + db, + toolIds, + resourceRows.map((resource) => resource.id) + ) + ); + const unitsByTool = groupByTool(unitRows); + const resourcesByTool = groupByTool(resourceRows); + + return toolRows.map((tool) => + toMakerLabTool(tool, unitsByTool.get(tool.id) ?? [], resourcesByTool.get(tool.id) ?? [], files) + ); +} + +/** Every attachment owned by one of these tools or resources, in position order. */ +async function selectAttachments( + db: Db, + toolIds: string[], + resourceIds: string[] +): Promise { + const ownedByTool = toolIds.length + ? and(eq(attachments.ownerType, "tool"), inArray(attachments.ownerId, toolIds)) + : undefined; + const ownedByResource = resourceIds.length + ? and(eq(attachments.ownerType, "resource"), inArray(attachments.ownerId, resourceIds)) + : undefined; + if (!ownedByTool && !ownedByResource) return []; + + return db + .select({ + ownerType: attachments.ownerType, + ownerId: attachments.ownerId, + position: attachments.position, + access: attachments.access, + publicUrl: attachments.publicUrl, + originalFilename: attachments.originalFilename, + }) + .from(attachments) + .where(and(or(ownedByTool, ownedByResource), isNotNull(attachments.publicUrl))) + .orderBy(asc(attachments.position), asc(attachments.id)); +} + +function groupByTool(rows: T[]): Map { + const map = new Map(); + for (const row of rows) { + if (!row.toolId) continue; + const list = map.get(row.toolId); + if (list) list.push(row); + else map.set(row.toolId, [row]); + } + return map; +} + +// ── Row → view model ──────────────────────────────────────────────── + +export function indexAttachments(rows: AttachmentRow[]): AttachmentIndex { + const index: AttachmentIndex = new Map(); + for (const row of rows) { + if (!row.ownerType || !row.ownerId) continue; + const key = attachmentKey(row.ownerType, row.ownerId); + const list = index.get(key); + if (list) list.push(row); + else index.set(key, [row]); + } + // Sorted here rather than trusted from the query, so the first attachment is + // the lowest-position one however the rows arrived. + for (const list of index.values()) list.sort((a, b) => a.position - b.position); + return index; +} + +export function attachmentsFor( + index: AttachmentIndex, + ownerType: string, + ownerId: string +): AttachmentRow[] { + return index.get(attachmentKey(ownerType, ownerId)) ?? []; +} + +function attachmentKey(ownerType: string, ownerId: string): string { + return `${ownerType}:${ownerId}`; +} + +export function toMakerLabTool( + tool: ToolRow, + unitRows: UnitRow[], + resourceRows: ResourceRow[] = [], + files: AttachmentIndex = new Map() +): MakerLabTool { + const mappedUnits = unitRows.map((unit) => toMakerLabUnit(unit, tool)); + const status = deriveStatus(tool, mappedUnits); + + return { + id: tool.id, + slug: tool.slug, + name: tool.name, + category: tool.categoryGroup || "Uncategorized", + categorySub: tool.categoryName || "Other", + location: tool.room || "Unknown", + zone: tool.zone || "Unknown", + trainingLevel: deriveTrainingLevel(tool), + trainingLabel: deriveTrainingLabel(tool), + status, + shortDescription: tool.description || "Catalog record pending description.", + description: tool.description || "This tool record is available in the MakerLab catalog.", + imageSrc: toolImageSrc(tool, attachmentsFor(files, "tool", tool.id)), + ppe: tool.ppeRequired.length ? tool.ppeRequired : ["Check posted lab guidance"], + materials: tool.materials, + tags: tool.tags, + emergencyStop: tool.emergencyStop, + useRestrictions: tool.useRestrictions, + mapId: tool.mapTag, + notes: tool.notes, + links: resourceLinks(resourceRows, files), + units: mappedUnits, + }; +} + +/** The tool's first public photo in Blob, or the bundled photo named after it. */ +export function toolImageSrc(tool: Pick, files: AttachmentRow[]): string { + const image = files.find((file) => file.access === "public" && file.publicUrl); + return image?.publicUrl || localToolImage(tool.name); +} + +/** A link per resource url, plus one per file the resource owns. */ +export function resourceLinks( + resourceRows: ResourceRow[], + files: AttachmentIndex = new Map() +): MakerLabTool["links"] { + return resourceRows.flatMap((resource) => { + const base = { + kind: resource.type || "Resource", + description: resource.notes || undefined, + }; + const urlLinks = resource.url + ? [ + { + label: resource.title || resource.type || "Resource", + href: resource.url, + ...base, + }, + ] + : []; + // A private file has no public URL to link to, and would not be one a + // visitor is allowed to open even if it did. + const fileLinks = attachmentsFor(files, "resource", resource.id).flatMap((file) => + file.access === "public" && file.publicUrl + ? [ + { + label: resource.title || file.originalFilename || resource.type || "Resource", + href: file.publicUrl, + ...base, + }, + ] + : [] + ); + + return [...urlLinks, ...fileLinks]; + }); +} + +export function toMakerLabUnit( + unit: UnitRow, + tool: Pick +): MakerLabUnit { + return { + id: unit.id, + name: unit.unitLabel || `${tool.name} // Unit`, + serial: unit.serialNumber || unit.assetTag || "Unlisted", + status: toToolStatus(unit.status, false), + condition: toCondition(unit.condition, unit.status), + location: tool.zone || tool.room || "Unknown", + dateAcquired: unit.dateAcquired || null, + }; +} + +export function deriveStatus( + tool: Pick, + units: MakerLabUnit[] +): ToolStatus { + if (units.some((unit) => unit.status === "In Use")) return "In Use"; + if (units.length > 0 && units.every((unit) => unit.status === "Offline")) return "Offline"; + return toToolStatus(undefined, tool.trainingRequired); +} + +export function toToolStatus( + status: string | null | undefined, + trainingRequired: boolean +): ToolStatus { + if (status === "in_use") return "In Use"; + if (status === "under_maintenance" || status === "out_of_service" || status === "retired") { + return "Offline"; + } + if (trainingRequired) return "Training Required"; + return "Available"; +} + +export function toCondition( + condition: string | null | undefined, + status: string | null | undefined +): MakerLabUnit["condition"] { + if (status === "out_of_service" || status === "retired") return "Offline"; + if (condition === "needs_repair" || status === "under_maintenance") return "Service Soon"; + if (condition === "excellent") return "Excellent"; + // `fair`, `new` and an unknown condition all read as Good, as they did when + // the same four display values came from Notion's select options. + return "Good"; +} + +export function deriveTrainingLevel( + tool: Pick +): MakerLabTool["trainingLevel"] { + const restrictionText = `${tool.useRestrictions || ""} ${tool.tags.join(" ")}`.toLowerCase(); + if (restrictionText.includes("advanced") || restrictionText.includes("authorized")) { + return "Advanced"; + } + if (tool.trainingRequired) return "Intermediate"; + return "Beginner"; +} + +export function deriveTrainingLabel( + tool: Pick +): string { + if (!tool.trainingRequired) return "Beginner orientation"; + if (tool.useRestrictions) return tool.useRestrictions; + return `${deriveTrainingLevel(tool)} checkout required`; +} + +export function localToolImage(name: string): string { + return `/tool-images/${encodeURIComponent(name.replace(/\//g, "_"))}.png`; +} diff --git a/v5/src/lib/data/maintenance.test.ts b/v5/src/lib/data/maintenance.test.ts new file mode 100644 index 0000000..e998cf4 --- /dev/null +++ b/v5/src/lib/data/maintenance.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment node +import { createPgliteDb } from "../db/pglite"; +import { maintenanceLogs, tools, units } from "../db/schema/index"; +import type { Db } from "../db/types"; +import { + listMaintenanceHistoryForUnit, + toDisplayLabel, + toMaintenanceHistoryEntry, +} from "./maintenance"; + +/** + * Maintenance history against a real (in-process) Postgres, plus the + * stored-value → display-string translation on its own. + */ + +let db: Db; +let unitId: string; +let otherUnitId: string; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(maintenanceLogs); + // Deleting the tools cascades to their units. + await db.delete(tools); + + const [form4] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4", published: true }) + .returning({ id: tools.id }); + const [unitA, unitB] = await db + .insert(units) + .values([ + { toolId: form4.id, unitLabel: "Form 4 // A" }, + { toolId: form4.id, unitLabel: "Form 4 // B" }, + ]) + .returning({ id: units.id }); + unitId = unitA.id; + otherUnitId = unitB.id; +}); + +type LogValues = typeof maintenanceLogs.$inferInsert; + +async function insertLog(values: Partial = {}): Promise { + await db.insert(maintenanceLogs).values({ + title: "Resin tank cloudy", + unitId, + ...values, + }); +} + +// ── listMaintenanceHistoryForUnit ─────────────────────────────────── + +describe("listMaintenanceHistoryForUnit", () => { + it("returns the unit's logs with the display strings the assistant has always seen", async () => { + await insertLog({ + title: "Resin tank cloudy", + type: "issue_report", + priority: "high", + status: "in_progress", + description: "The resin tank film is clouded.", + resolution: "Tank swapped.", + dateReported: "2024-09-01", + dateResolved: "2024-09-03", + reportedByName: "Ada Lovelace", + }); + + const [entry] = await listMaintenanceHistoryForUnit(unitId, { db }); + + expect(entry).toMatchObject({ + title: "Resin tank cloudy", + type: "Issue Report", + priority: "High", + status: "In Progress", + description: "The resin tank film is clouded.", + resolution: "Tank swapped.", + dateReported: "2024-09-01", + dateResolved: "2024-09-03", + reportedByName: "Ada Lovelace", + }); + }); + + it("never returns the reporter's email (spec §8)", async () => { + await insertLog({ + reportedByName: "Ada Lovelace", + reportedByEmail: "ada@cornell.edu", + }); + + const [entry] = await listMaintenanceHistoryForUnit(unitId, { db }); + + expect(entry.reportedByName).toBe("Ada Lovelace"); + // Not "undefined" — the address must not be a key at all, so no caller can + // spread it into a prompt or a mirror payload. + expect(Object.keys(entry)).not.toContain("reportedByEmail"); + expect(JSON.stringify(entry)).not.toContain("cornell.edu"); + }); + + it("orders newest first and sorts an undated log last", async () => { + await insertLog({ title: "Oldest", dateReported: "2024-01-01" }); + await insertLog({ title: "Undated", dateReported: null }); + await insertLog({ title: "Newest", dateReported: "2024-09-01" }); + + const titles = (await listMaintenanceHistoryForUnit(unitId, { db })).map( + (log) => log.title + ); + expect(titles).toEqual(["Newest", "Oldest", "Undated"]); + }); + + it("reads only the requested unit's logs", async () => { + await insertLog({ title: "Mine" }); + await insertLog({ title: "Theirs", unitId: otherUnitId }); + + const titles = (await listMaintenanceHistoryForUnit(unitId, { db })).map( + (log) => log.title + ); + expect(titles).toEqual(["Mine"]); + }); + + it("bounds the read with `limit`, keeping the most recent", async () => { + for (let i = 0; i < 12; i += 1) { + await insertLog({ title: `Issue ${i}`, dateReported: `2024-09-${String(i + 1).padStart(2, "0")}` }); + } + + const logs = await listMaintenanceHistoryForUnit(unitId, { db, limit: 10 }); + expect(logs).toHaveLength(10); + expect(logs[0].title).toBe("Issue 11"); + }); + + it("is empty for a unit with no history", async () => { + expect(await listMaintenanceHistoryForUnit(unitId, { db })).toEqual([]); + }); + + it("is empty for anything that is not a uuid, without querying", async () => { + // A unit label that failed to resolve must not reach Postgres as a cast. + expect(await listMaintenanceHistoryForUnit("Form 4 // A", { db })).toEqual([]); + expect(await listMaintenanceHistoryForUnit("", { db })).toEqual([]); + }); +}); + +// ── Row → entry ───────────────────────────────────────────────────── + +describe("toMaintenanceHistoryEntry", () => { + it("turns every absent value into an empty string", () => { + expect( + toMaintenanceHistoryEntry({ + id: "log-1", + title: "Untriaged", + type: null, + priority: null, + status: null, + description: null, + resolution: null, + dateReported: null, + dateResolved: null, + reportedByName: null, + }) + ).toEqual({ + id: "log-1", + title: "Untriaged", + type: "", + priority: "", + status: "", + description: "", + resolution: "", + dateReported: "", + dateResolved: "", + reportedByName: "", + }); + }); +}); + +describe("toDisplayLabel", () => { + it("maps every stored maintenance value back to the words Notion showed", () => { + expect(toDisplayLabel("issue_report")).toBe("Issue Report"); + expect(toDisplayLabel("preventive_maintenance")).toBe("Preventive Maintenance"); + expect(toDisplayLabel("repair")).toBe("Repair"); + expect(toDisplayLabel("inspection")).toBe("Inspection"); + expect(toDisplayLabel("calibration")).toBe("Calibration"); + expect(toDisplayLabel("critical")).toBe("Critical"); + expect(toDisplayLabel("open")).toBe("Open"); + expect(toDisplayLabel("in_progress")).toBe("In Progress"); + expect(toDisplayLabel("resolved")).toBe("Resolved"); + expect(toDisplayLabel("closed")).toBe("Closed"); + }); + + it("returns an empty string for a missing value", () => { + expect(toDisplayLabel(null)).toBe(""); + expect(toDisplayLabel(undefined)).toBe(""); + expect(toDisplayLabel("")).toBe(""); + }); +}); diff --git a/v5/src/lib/data/maintenance.ts b/v5/src/lib/data/maintenance.ts new file mode 100644 index 0000000..d2b0d46 --- /dev/null +++ b/v5/src/lib/data/maintenance.ts @@ -0,0 +1,149 @@ +import { desc, eq, sql } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { maintenanceLogs } from "../db/schema/index.ts"; +import type { Db } from "../db/types.ts"; +import { isUuid } from "./uuid.ts"; + +/** + * Maintenance history reads on Postgres (spec §3.10, §4.8). + * + * This replaces `fetchMaintenanceLogsByUnit` from `src/lib/notion.ts` behind + * the `units` capability. Tickets are still *written* to Notion until Phase 3; + * only the read moved. + * + * Two rules shape what comes back: + * + * - **Display strings, not stored values.** The column holds `issue_report`; + * the assistant has always seen `"Issue Report"`, and the tool descriptions + * and prompt fragments say so. {@link toDisplayLabel} is the exact inverse of + * the import's `toStoredValue`, so a value that survived the import round + * trips back to the words Notion showed. + * - **Never the reporter's email.** `reported_by_email` is set only from a + * resolved session and must not enter a model prompt (spec §8, PII). It is + * not selected here at all, so no caller can leak it by accident. + * + * Relative imports with `.ts` extensions and no `@/` alias, and no + * `"server-only"`: `scripts/` loads these modules under plain Node. + */ + +/** One maintenance log, flattened and translated into display text. */ +export interface MaintenanceHistoryEntry { + id: string; + title: string; + /** `"Issue Report"`, `"Repair"`, … — empty when the log has none. */ + type: string; + /** `"Critical"`, `"High"`, … — empty when the log has none. */ + priority: string; + /** `"Open"`, `"In Progress"`, … — empty when the log has none. */ + status: string; + description: string; + resolution: string; + dateReported: string; + dateResolved: string; + /** The reporter's name. Deliberately never their email (spec §8). */ + reportedByName: string; +} + +export interface MaintenanceQueryOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; + /** How many of the most recent logs to read. */ + limit?: number; +} + +/** + * A history is for a person (or a model) to read, not an archive to page + * through, so a read is always bounded (Article 4). Callers that show fewer + * pass their own, smaller limit. + */ +const DEFAULT_LIMIT = 50; + +/** + * The most recent maintenance logs filed against one unit, newest first. + * + * Empty for a unit with no history, and for anything that is not a uuid — a + * unit label that failed to resolve returns nothing rather than reaching + * Postgres as a bad uuid cast. + */ +export async function listMaintenanceHistoryForUnit( + unitId: string, + options: MaintenanceQueryOptions = {} +): Promise { + if (!isUuid(unitId)) return []; + + const db = options.db ?? (await getDb()); + const rows = await db + .select({ + id: maintenanceLogs.id, + title: maintenanceLogs.title, + type: maintenanceLogs.type, + priority: maintenanceLogs.priority, + status: maintenanceLogs.status, + description: maintenanceLogs.description, + resolution: maintenanceLogs.resolution, + dateReported: maintenanceLogs.dateReported, + dateResolved: maintenanceLogs.dateResolved, + reportedByName: maintenanceLogs.reportedByName, + }) + .from(maintenanceLogs) + .where(eq(maintenanceLogs.unitId, unitId)) + // `date_reported` is nullable and most imported logs have one; a log with + // no date sorts last rather than ahead of everything, which is where a + // descending sort would otherwise put it. + .orderBy( + sql`${maintenanceLogs.dateReported} desc nulls last`, + desc(maintenanceLogs.createdAt) + ) + .limit(options.limit ?? DEFAULT_LIMIT); + + return rows.map(toMaintenanceHistoryEntry); +} + +/** The columns {@link listMaintenanceHistoryForUnit} selects, before translation. */ +export interface MaintenanceLogRow { + id: string; + title: string; + type: string | null; + priority: string | null; + status: string | null; + description: string | null; + resolution: string | null; + dateReported: string | null; + dateResolved: string | null; + reportedByName: string | null; +} + +/** + * Row → entry. Every absent value becomes `""` rather than null: these strings + * are read by a model, and an empty string reads as "not recorded" where a + * `null` reads as a value. + */ +export function toMaintenanceHistoryEntry(row: MaintenanceLogRow): MaintenanceHistoryEntry { + return { + id: row.id, + title: row.title, + type: toDisplayLabel(row.type), + priority: toDisplayLabel(row.priority), + status: toDisplayLabel(row.status), + description: row.description || "", + resolution: row.resolution || "", + dateReported: row.dateReported || "", + dateResolved: row.dateResolved || "", + reportedByName: row.reportedByName || "", + }; +} + +/** + * `in_progress` → `"In Progress"`. The inverse of the import's `toStoredValue`, + * which lower-cased Notion's option names and turned spaces into underscores, + * so every vocabulary value that came through the import maps back to the words + * the workspace used. + */ +export function toDisplayLabel(value: string | null | undefined): string { + if (!value) return ""; + return value + .split("_") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} diff --git a/v5/src/lib/data/notion-ids.test.ts b/v5/src/lib/data/notion-ids.test.ts new file mode 100644 index 0000000..43a2b78 --- /dev/null +++ b/v5/src/lib/data/notion-ids.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment node +import { createPgliteDb } from "../db/pglite"; +import { tools, units } from "../db/schema/index"; +import type { Db } from "../db/types"; +import { + notionPageIdForTool, + notionPageIdForUnit, + notionPageIdsForTools, +} from "./notion-ids"; +import { isUuid } from "./uuid"; + +/** + * The Postgres → Notion page id translation the three remaining Notion writes + * depend on, against a real (in-process) Postgres with rows this file inserts. + */ + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(tools); +}); + +/** One tool, with or without the page id the import would have recorded. */ +async function insertTool(slug: string, notionPageId: string | null): Promise { + const [row] = await db + .insert(tools) + .values({ slug, name: slug, notionPageId }) + .returning({ id: tools.id }); + return row.id; +} + +async function insertUnit(toolId: string, notionPageId: string | null): Promise { + const [row] = await db + .insert(units) + .values({ toolId, unitLabel: `${toolId} // A`, status: "available", notionPageId }) + .returning({ id: units.id }); + return row.id; +} + +// ── isUuid ────────────────────────────────────────────────────────── + +describe("isUuid", () => { + it("accepts a uuid in either case and rejects everything else", () => { + expect(isUuid("1f2e3d4c-5b6a-4789-8abc-def012345678")).toBe(true); + expect(isUuid("1F2E3D4C-5B6A-4789-8ABC-DEF012345678")).toBe(true); + expect(isUuid("form-4")).toBe(false); + expect(isUuid("Form 4 // A")).toBe(false); + expect(isUuid("1f2e3d4c5b6a47898abcdef012345678")).toBe(false); + expect(isUuid("")).toBe(false); + }); +}); + +// ── Tools ─────────────────────────────────────────────────────────── + +describe("notionPageIdForTool", () => { + it("returns the page the tool was imported from", async () => { + const id = await insertTool("form-4", "notion-tool-1"); + await expect(notionPageIdForTool(id, { db })).resolves.toBe("notion-tool-1"); + }); + + it("is null for a tool that never came from Notion", async () => { + const id = await insertTool("locally-made", null); + await expect(notionPageIdForTool(id, { db })).resolves.toBeNull(); + }); + + it("is null for an unknown uuid", async () => { + await expect(notionPageIdForTool(crypto.randomUUID(), { db })).resolves.toBeNull(); + }); + + it("is null for a slug or free text, without a bad-cast error", async () => { + // A surface may hand this a slug; reaching a uuid column with one throws. + await expect(notionPageIdForTool("form-4", { db })).resolves.toBeNull(); + await expect(notionPageIdForTool("", { db })).resolves.toBeNull(); + }); +}); + +// ── Units ─────────────────────────────────────────────────────────── + +describe("notionPageIdForUnit", () => { + it("returns the page the unit was imported from", async () => { + const toolId = await insertTool("form-4", "notion-tool-1"); + const unitId = await insertUnit(toolId, "notion-unit-1"); + + await expect(notionPageIdForUnit(unitId, { db })).resolves.toBe("notion-unit-1"); + }); + + it("is null for a unit with no page, so the ticket is filed unlinked", async () => { + const toolId = await insertTool("form-4", "notion-tool-1"); + const unitId = await insertUnit(toolId, null); + + await expect(notionPageIdForUnit(unitId, { db })).resolves.toBeNull(); + }); + + it("is null for a unit label that failed to resolve", async () => { + await expect(notionPageIdForUnit("Form 4 // A", { db })).resolves.toBeNull(); + }); +}); + +// ── Many tools ────────────────────────────────────────────────────── + +describe("notionPageIdsForTools", () => { + it("translates each id, in the order it was given", async () => { + const first = await insertTool("form-4", "notion-tool-1"); + const second = await insertTool("trotec", "notion-tool-2"); + + await expect(notionPageIdsForTools([second, first], { db })).resolves.toEqual([ + "notion-tool-2", + "notion-tool-1", + ]); + }); + + it("drops ids that do not resolve rather than passing them through", async () => { + // One unresolvable relation id fails the whole Notion page, which would + // lose the student's submission (Article 4). + const known = await insertTool("form-4", "notion-tool-1"); + const local = await insertTool("locally-made", null); + + await expect( + notionPageIdsForTools([known, local, crypto.randomUUID(), "form-4"], { db }) + ).resolves.toEqual(["notion-tool-1"]); + }); + + it("is empty for an empty list and for a list with no uuids, without querying", async () => { + await expect(notionPageIdsForTools([], { db })).resolves.toEqual([]); + await expect(notionPageIdsForTools(["form-4", "trotec"], { db })).resolves.toEqual([]); + }); + + it("keeps a repeated id as often as it was given", async () => { + const id = await insertTool("form-4", "notion-tool-1"); + + await expect(notionPageIdsForTools([id, id], { db })).resolves.toEqual([ + "notion-tool-1", + "notion-tool-1", + ]); + }); +}); diff --git a/v5/src/lib/data/notion-ids.ts b/v5/src/lib/data/notion-ids.ts new file mode 100644 index 0000000..5cfd6b1 --- /dev/null +++ b/v5/src/lib/data/notion-ids.ts @@ -0,0 +1,92 @@ +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { tools, units } from "../db/schema/index.ts"; +import type { Db } from "../db/types.ts"; +import { isUuid } from "./uuid.ts"; + +/** + * Postgres row → Notion page id (spec §3.10, §9 Phase 2). + * + * Reads moved to Postgres in Phase 2; three writes have not yet (a correction, + * a maintenance ticket, a project submission). Those still create Notion pages + * whose `relation` properties address Notion **page** ids, while every id the + * app now hands around — `tool.id`, `unit.id` — is a Postgres uuid. Notion + * rejects a relation to a page it cannot find, so without this translation the + * whole write fails and the student's report is lost. + * + * `tools.notion_page_id` and `units.notion_page_id` carry the mapping the + * import recorded (spec §4), and this module is the only place that reads it. + * A row with no page id — created after the import, or a demo-seed row — + * resolves to null, and the caller files the page *without* the relation: a + * ticket missing its unit link is recoverable by a human, a ticket Notion + * refused is not (Article 4, fail toward stale rather than toward nothing). + * + * This module moves out with the writes in Phase 3. + */ + +export interface NotionIdOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** The Notion page the tool was imported from, or null. */ +export async function notionPageIdForTool( + toolId: string, + options: NotionIdOptions = {} +): Promise { + if (!isUuid(toolId)) return null; + + const db = await resolveDb(options); + const [row] = await db + .select({ notionPageId: tools.notionPageId }) + .from(tools) + .where(eq(tools.id, toolId)) + .limit(1); + return row?.notionPageId ?? null; +} + +/** The Notion page the unit was imported from, or null. */ +export async function notionPageIdForUnit( + unitId: string, + options: NotionIdOptions = {} +): Promise { + if (!isUuid(unitId)) return null; + + const db = await resolveDb(options); + const [row] = await db + .select({ notionPageId: units.notionPageId }) + .from(units) + .where(eq(units.id, unitId)) + .limit(1); + return row?.notionPageId ?? null; +} + +/** + * The Notion pages behind a list of tool ids, in the order they were given. + * Ids that are not uuids, are unknown, or name a tool that never came from + * Notion are dropped rather than passed through — an unresolvable id would + * fail the entire write. + */ +export async function notionPageIdsForTools( + toolIds: string[], + options: NotionIdOptions = {} +): Promise { + const candidates = toolIds.filter(isUuid); + if (candidates.length === 0) return []; + + const db = await resolveDb(options); + const rows = await db + .select({ id: tools.id, notionPageId: tools.notionPageId }) + .from(tools) + .where(and(inArray(tools.id, candidates), isNotNull(tools.notionPageId))); + + const byId = new Map(rows.map((row) => [row.id, row.notionPageId])); + return toolIds.flatMap((id) => { + const pageId = byId.get(id); + return pageId ? [pageId] : []; + }); +} + +async function resolveDb(options: NotionIdOptions): Promise { + return options.db ?? (await getDb()); +} diff --git a/v5/src/lib/data/projects.test.ts b/v5/src/lib/data/projects.test.ts new file mode 100644 index 0000000..78daaaa --- /dev/null +++ b/v5/src/lib/data/projects.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment node +import { createPgliteDb } from "../db/pglite"; +import { attachments, projectTools, projects, tools } from "../db/schema/index"; +import type { Db } from "../db/types"; + +/** + * `src/lib/data/projects.ts` calls `getDb()` internally, like every other + * query module (spec §3.2 — one entry point, no `Proxy`, no db parameter to + * plumb through pages). Tests therefore need `getDb()` itself to resolve to + * an isolated, per-test PGlite instance rather than the memoised demo + * database — done by mocking `../db/client.ts`, the exact specifier the + * module under test imports, and pointing it at a fresh `createPgliteDb()` + * before each test. + */ +const dbHolder = vi.hoisted(() => ({ current: undefined as Db | undefined })); + +vi.mock("../db/client.ts", () => ({ + getDb: () => Promise.resolve(dbHolder.current), +})); + +import { findPublishedProject, listPublishedProjects, listPublishedProjectsForTool } from "./projects"; + +async function insertProject( + db: Db, + overrides: Partial = {} +): Promise { + const [row] = await db + .insert(projects) + .values({ + slug: `project-${crypto.randomUUID()}`, + title: "Untitled project", + body: "Body", + published: true, + ...overrides, + }) + .returning({ id: projects.id }); + return row.id; +} + +async function insertTool(db: Db, overrides: Partial = {}): Promise { + const [row] = await db + .insert(tools) + .values({ + slug: `tool-${crypto.randomUUID()}`, + name: "A tool", + published: true, + ...overrides, + }) + .returning({ id: tools.id }); + return row.id; +} + +async function linkTool(db: Db, projectId: string, toolId: string): Promise { + await db.insert(projectTools).values({ projectId, toolId }); +} + +async function addPhoto( + db: Db, + projectId: string, + overrides: Partial = {} +): Promise { + await db.insert(attachments).values({ + ownerType: "project", + ownerId: projectId, + blobPathname: `projects/${projectId}/${crypto.randomUUID()}.png`, + access: "public", + publicUrl: `https://blob.test/${crypto.randomUUID()}.png`, + position: 0, + ...overrides, + }); +} + +describe("src/lib/data/projects.ts", () => { + let db: Db; + + beforeEach(async () => { + db = await createPgliteDb(); + dbHolder.current = db; + }); + + describe("listPublishedProjects", () => { + it("returns only published projects, newest first", async () => { + await insertProject(db, { + title: "Older", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }); + await insertProject(db, { title: "Draft", published: false }); + await insertProject(db, { + title: "Newer", + createdAt: new Date("2026-02-01T00:00:00.000Z"), + }); + + const result = await listPublishedProjects(); + expect(result.map((project) => project.title)).toEqual(["Newer", "Older"]); + }); + + it("falls back to Anonymous when author_name is null, and reports the created_at ISO string", async () => { + const createdAt = new Date("2026-03-04T12:30:00.000Z"); + await insertProject(db, { title: "No byline", authorName: null, createdAt }); + + const [project] = await listPublishedProjects(); + expect(project.author).toBe("Anonymous"); + expect(project.date).toBe(createdAt.toISOString()); + }); + + it("uses the author_name when present", async () => { + await insertProject(db, { title: "With byline", authorName: "Ada Lovelace" }); + + const [project] = await listPublishedProjects(); + expect(project.author).toBe("Ada Lovelace"); + }); + + it("orders photos by position, and includes only public attachments owned by the project", async () => { + const projectId = await insertProject(db, { title: "Gallery" }); + await addPhoto(db, projectId, { position: 1, publicUrl: "https://blob.test/second.png" }); + await addPhoto(db, projectId, { position: 0, publicUrl: "https://blob.test/first.png" }); + // A private photo (e.g. a re-owned maintenance shot) must never surface. + await addPhoto(db, projectId, { position: 2, publicUrl: null, access: "private" }); + // A public attachment with no URL yet (upload recorded before the blob + // finished) is skipped rather than rendered as a broken image. + await addPhoto(db, projectId, { position: 3, publicUrl: null, access: "public" }); + + const [project] = await listPublishedProjects(); + expect(project.photos).toEqual(["https://blob.test/first.png", "https://blob.test/second.png"]); + }); + + it("resolves tool refs to {id,name,slug}, and drops refs to unpublished tools without dropping the project", async () => { + const projectId = await insertProject(db, { title: "Built with two" }); + const published = await insertTool(db, { name: "Form 4", slug: "form-4", published: true }); + const draft = await insertTool(db, { name: "Secret prototype", slug: "secret-prototype", published: false }); + await linkTool(db, projectId, published); + await linkTool(db, projectId, draft); + + const [project] = await listPublishedProjects(); + expect(project.tools).toEqual([{ id: published, name: "Form 4", slug: "form-4" }]); + }); + }); + + describe("findPublishedProject", () => { + it("finds a published project by its uuid id", async () => { + const id = await insertProject(db, { title: "By id" }); + + const project = await findPublishedProject(id); + expect(project?.id).toBe(id); + expect(project?.title).toBe("By id"); + }); + + it("finds a published project by its slug", async () => { + await insertProject(db, { title: "By slug", slug: "by-slug" }); + + const project = await findPublishedProject("by-slug"); + expect(project?.title).toBe("By slug"); + }); + + it("returns null for an unpublished project, whether looked up by id or by slug", async () => { + const id = await insertProject(db, { title: "Draft", slug: "draft-project", published: false }); + + expect(await findPublishedProject(id)).toBeNull(); + expect(await findPublishedProject("draft-project")).toBeNull(); + }); + + it("returns null for an id that does not exist", async () => { + expect(await findPublishedProject(crypto.randomUUID())).toBeNull(); + }); + + it("returns null for a slug that does not exist", async () => { + expect(await findPublishedProject("no-such-project")).toBeNull(); + }); + }); + + describe("listPublishedProjectsForTool", () => { + it("returns only published projects that reference the given tool", async () => { + const toolId = await insertTool(db, { name: "Trotec Speedy 400" }); + const otherToolId = await insertTool(db, { name: "Form 4" }); + const built = await insertProject(db, { title: "Laser box" }); + const other = await insertProject(db, { title: "Printed part" }); + const draft = await insertProject(db, { title: "Draft laser project", published: false }); + await linkTool(db, built, toolId); + await linkTool(db, other, otherToolId); + await linkTool(db, draft, toolId); + + const result = await listPublishedProjectsForTool(toolId); + expect(result.map((project) => project.title)).toEqual(["Laser box"]); + }); + + it("returns an empty array for a tool nothing was built with", async () => { + const toolId = await insertTool(db); + await insertProject(db, { title: "Unrelated" }); + + expect(await listPublishedProjectsForTool(toolId)).toEqual([]); + }); + + it("returns an empty array instead of throwing when the id is not uuid-shaped", async () => { + await expect(listPublishedProjectsForTool("not-a-uuid")).resolves.toEqual([]); + }); + }); +}); diff --git a/v5/src/lib/data/projects.ts b/v5/src/lib/data/projects.ts new file mode 100644 index 0000000..6ef7884 --- /dev/null +++ b/v5/src/lib/data/projects.ts @@ -0,0 +1,132 @@ +import { and, desc, eq, inArray } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { attachments, projectTools, projects, tools } from "../db/schema/index.ts"; +import type { Db } from "../db/types.ts"; +import { isUuid } from "./uuid.ts"; +import type { MakerLabProject, ProjectToolRef } from "../../components/catalog-types.ts"; + +/** + * Postgres reads for published projects (data platform design spec 2026-09-14 + * §3.10, §4.10, §4.13). This is the query module `src/lib/projects.ts` wraps + * in `"use cache"`; nothing here is cached itself, so every export is a plain + * round trip and safe to call as often as the caller needs a fresh answer. + * + * Imports are relative with `.ts` extensions and skip the `@/` alias, like + * everything under `src/lib/db/` and `src/lib/import/`: scripts load these + * modules under plain Node type-stripping, which cannot resolve either. + * + * A project's photos and tool refs are joined in eagerly rather than resolved + * lazily per view, because both list views (the gallery, "Built with this") + * need the whole set at once and Article 4 prefers one round trip of joins + * over N+1 queries. + */ + +type ProjectRow = typeof projects.$inferSelect; + +/** Every published project, newest first (spec §4.10). */ +export async function listPublishedProjects(): Promise { + const db = await getDb(); + const rows = await db + .select() + .from(projects) + .where(eq(projects.published, true)) + .orderBy(desc(projects.createdAt)); + return hydrateProjects(db, rows); +} + +/** + * A published project by its uuid id or its slug. Null for an unknown id, a + * slug that matches nothing, or a real row that is not published — a draft is + * never reachable by guessing its id. + */ +export async function findPublishedProject(idOrSlug: string): Promise { + const db = await getDb(); + const match = isUuid(idOrSlug) ? eq(projects.id, idOrSlug) : eq(projects.slug, idOrSlug); + const rows = await db + .select() + .from(projects) + .where(and(match, eq(projects.published, true))) + .limit(1); + const [hydrated] = await hydrateProjects(db, rows); + return hydrated ?? null; +} + +/** Published projects built with a given tool — the tool page's "Built with this" section. */ +export async function listPublishedProjectsForTool(toolId: string): Promise { + if (!isUuid(toolId)) return []; + + const db = await getDb(); + const rows = await db + .select({ project: projects }) + .from(projects) + .innerJoin(projectTools, eq(projectTools.projectId, projects.id)) + .where(and(eq(projectTools.toolId, toolId), eq(projects.published, true))) + .orderBy(desc(projects.createdAt)); + return hydrateProjects( + db, + rows.map((row) => row.project) + ); +} + +/** Attaches each row's photos and tool refs, then maps onto the view model. */ +async function hydrateProjects(db: Db, rows: ProjectRow[]): Promise { + if (rows.length === 0) return []; + + const ids = rows.map((row) => row.id); + const [photosByProject, toolsByProject] = await Promise.all([loadPhotos(db, ids), loadToolRefs(db, ids)]); + + return rows.map((row) => toMakerLabProject(row, photosByProject.get(row.id) ?? [], toolsByProject.get(row.id) ?? [])); +} + +/** Public attachment URLs owned by these projects, grouped by project id and ordered by `position`. */ +async function loadPhotos(db: Db, projectIds: string[]): Promise> { + const rows = await db + .select({ ownerId: attachments.ownerId, publicUrl: attachments.publicUrl }) + .from(attachments) + .where( + and(eq(attachments.ownerType, "project"), inArray(attachments.ownerId, projectIds), eq(attachments.access, "public")) + ) + .orderBy(attachments.position); + + const map = new Map(); + for (const row of rows) { + if (!row.ownerId || !row.publicUrl) continue; + const list = map.get(row.ownerId); + if (list) list.push(row.publicUrl); + else map.set(row.ownerId, [row.publicUrl]); + } + return map; +} + +/** `{id,name,slug}` for each project's *published* tools, grouped by project id. An archived/draft tool drops the ref, not the project. */ +async function loadToolRefs(db: Db, projectIds: string[]): Promise> { + const rows = await db + .select({ projectId: projectTools.projectId, id: tools.id, name: tools.name, slug: tools.slug }) + .from(projectTools) + .innerJoin(tools, eq(projectTools.toolId, tools.id)) + .where(and(inArray(projectTools.projectId, projectIds), eq(tools.published, true))) + .orderBy(tools.name); + + const map = new Map(); + for (const row of rows) { + const ref: ProjectToolRef = { id: row.id, name: row.name, slug: row.slug }; + const list = map.get(row.projectId); + if (list) list.push(ref); + else map.set(row.projectId, [ref]); + } + return map; +} + +function toMakerLabProject(row: ProjectRow, photos: string[], toolRefs: ProjectToolRef[]): MakerLabProject { + return { + id: row.id, + title: row.title, + author: row.authorName ?? "Anonymous", + body: row.body, + photos, + tools: toolRefs, + link: row.link, + materials: row.materials, + date: row.createdAt.toISOString(), + }; +} diff --git a/v5/src/lib/data/resources.test.ts b/v5/src/lib/data/resources.test.ts new file mode 100644 index 0000000..0b48efa --- /dev/null +++ b/v5/src/lib/data/resources.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment node +import { createPgliteDb } from "../db/pglite"; +import { attachments, resources, tools } from "../db/schema/index"; +import type { Db } from "../db/types"; +import { listResources, listResourcesForTool } from "./resources"; + +/** + * Resource reads against a real (in-process) Postgres — what the chat route + * needs to decide which of a tool's files are manuals worth attaching. + */ + +let db: Db; +let form4: string; +let trotec: string; + +beforeAll(async () => { + db = await createPgliteDb(); +}); + +beforeEach(async () => { + await db.delete(attachments); + // Deleting the tools cascades to their resources. + await db.delete(tools); + + const [a, b] = await db + .insert(tools) + .values([ + { slug: "form-4", name: "Form 4", published: true }, + { slug: "trotec-speedy-400", name: "Trotec Speedy 400", published: true }, + ]) + .returning({ id: tools.id }); + form4 = a.id; + trotec = b.id; +}); + +type ResourceValues = typeof resources.$inferInsert; +type AttachmentValues = typeof attachments.$inferInsert; + +async function insertResource(values: Partial = {}): Promise { + const [row] = await db + .insert(resources) + .values({ toolId: form4, title: "Form 4 manual", ...values }) + .returning({ id: resources.id }); + return row.id; +} + +async function attachFile(resourceId: string, values: Partial = {}) { + await db.insert(attachments).values({ + ownerType: "resource", + ownerId: resourceId, + blobPathname: "resources/manual.pdf", + access: "public", + publicUrl: "https://blob.test/manual.pdf", + ...values, + }); +} + +// ── listResourcesForTool ──────────────────────────────────────────── + +describe("listResourcesForTool", () => { + it("returns the tool's resources with their link and notes", async () => { + await insertResource({ + title: "Form 4 SOP", + type: "SOP", + url: "https://x.test/sop.pdf", + notes: "Read before the first print.", + }); + + expect(await listResourcesForTool(form4, { db })).toEqual([ + { + id: expect.any(String), + toolId: form4, + title: "Form 4 SOP", + type: "SOP", + url: "https://x.test/sop.pdf", + notes: "Read before the first print.", + fileUrls: [], + }, + ]); + }); + + it("carries a resource's public files, cover first", async () => { + const id = await insertResource({ title: "Manual", url: null }); + await attachFile(id, { position: 1, publicUrl: "https://blob.test/second.pdf" }); + await attachFile(id, { position: 0, publicUrl: "https://blob.test/first.pdf" }); + + const [resource] = await listResourcesForTool(form4, { db }); + expect(resource.fileUrls).toEqual([ + "https://blob.test/first.pdf", + "https://blob.test/second.pdf", + ]); + }); + + it("leaves out private files, which no visitor could open either", async () => { + const id = await insertResource({ title: "Incident photos", url: null }); + await attachFile(id, { access: "private", publicUrl: null }); + + const [resource] = await listResourcesForTool(form4, { db }); + expect(resource.fileUrls).toEqual([]); + }); + + it("leaves out unpublished resources — the assistant must not read what staff hid", async () => { + await insertResource({ title: "Draft SOP", published: false }); + await insertResource({ title: "Live SOP", published: true }); + + const titles = (await listResourcesForTool(form4, { db })).map((r) => r.title); + expect(titles).toEqual(["Live SOP"]); + }); + + it("returns only the named tool's resources, ordered by title", async () => { + await insertResource({ title: "Zebra chart" }); + await insertResource({ title: "Alpha guide" }); + await insertResource({ title: "Trotec SOP", toolId: trotec }); + + const titles = (await listResourcesForTool(form4, { db })).map((r) => r.title); + expect(titles).toEqual(["Alpha guide", "Zebra chart"]); + }); + + it("is empty for a tool with no resources, and for anything that is not a uuid", async () => { + expect(await listResourcesForTool(trotec, { db })).toEqual([]); + expect(await listResourcesForTool("form-4", { db })).toEqual([]); + }); +}); + +// ── listResources ─────────────────────────────────────────────────── + +describe("listResources", () => { + it("returns every published resource in the lab, by title", async () => { + await insertResource({ title: "Trotec SOP", toolId: trotec }); + await insertResource({ title: "Form 4 SOP" }); + await insertResource({ title: "Hidden", published: false }); + + const rows = await listResources({ db }); + expect(rows.map((r) => r.title)).toEqual(["Form 4 SOP", "Trotec SOP"]); + expect(rows.map((r) => r.toolId)).toEqual([form4, trotec]); + }); + + it("is empty when the lab has no resources", async () => { + expect(await listResources({ db })).toEqual([]); + }); +}); diff --git a/v5/src/lib/data/resources.ts b/v5/src/lib/data/resources.ts new file mode 100644 index 0000000..1d8e4a6 --- /dev/null +++ b/v5/src/lib/data/resources.ts @@ -0,0 +1,123 @@ +import { and, asc, eq, inArray, isNotNull, type SQL } from "drizzle-orm"; +import { getDb } from "../db/client.ts"; +import { attachments, resources } from "../db/schema/index.ts"; +import type { Db } from "../db/types.ts"; +import { isUuid } from "./uuid.ts"; + +/** + * Resource reads on Postgres (spec §3.10, §4.6, §4.7). + * + * This replaces `fetchAllResources` from `src/lib/notion.ts` behind the chat + * route's manual attachment: the route picks the focused tool's PDFs and hands + * their bytes to the model, so it needs each resource's link *and* the files + * hanging off it. + * + * **Unpublished resources are left out.** A resource staff unpublished is one + * they hid, and the assistant must not read its manual into a conversation. + * That is the rule the chat route has always applied; note it is deliberately + * *not* the rule in `./catalog.ts`, where a tool's links are gated by the + * tool's own visibility so intake-created drafts appear the moment staff + * publish the tool. + * + * Relative imports with `.ts` extensions and no `@/` alias, and no + * `"server-only"`: `scripts/` loads these modules under plain Node. + */ + +/** A resource and the public files attached to it. */ +export interface ToolResource { + id: string; + toolId: string | null; + title: string; + type: string | null; + url: string | null; + notes: string | null; + /** Public URLs of the resource's attachments, cover first. */ + fileUrls: string[]; +} + +export interface ResourceQueryOptions { + /** A handle to use instead of {@link getDb} — tests pass an isolated one. */ + db?: Db; +} + +/** Every published resource in the lab, by title — the order Notion sorted them in. */ +export async function listResources( + options: ResourceQueryOptions = {} +): Promise { + const db = options.db ?? (await getDb()); + return loadResources(db, eq(resources.published, true)); +} + +/** + * One tool's published resources, by title. Empty for a tool with none, and + * for anything that is not a uuid. + */ +export async function listResourcesForTool( + toolId: string, + options: ResourceQueryOptions = {} +): Promise { + if (!isUuid(toolId)) return []; + + const db = options.db ?? (await getDb()); + return loadResources( + db, + and(eq(resources.toolId, toolId), eq(resources.published, true)) + ); +} + +/** + * Two statements whatever the number of resources: the rows, then every + * attachment they own, keyed by resource id. + */ +async function loadResources(db: Db, where: SQL | undefined): Promise { + const rows = await db + .select({ + id: resources.id, + toolId: resources.toolId, + title: resources.title, + type: resources.type, + url: resources.url, + notes: resources.notes, + }) + .from(resources) + .where(where) + .orderBy(asc(resources.title), asc(resources.id)); + + if (rows.length === 0) return []; + + const filesByResource = await loadFileUrls( + db, + rows.map((row) => row.id) + ); + + return rows.map((row) => ({ ...row, fileUrls: filesByResource.get(row.id) ?? [] })); +} + +/** + * Public attachment URLs owned by these resources, grouped by resource id and + * ordered by `position`. A private file has no URL a visitor could open, and + * the model is given nothing a visitor could not read. + */ +async function loadFileUrls(db: Db, resourceIds: string[]): Promise> { + const rows = await db + .select({ ownerId: attachments.ownerId, publicUrl: attachments.publicUrl }) + .from(attachments) + .where( + and( + eq(attachments.ownerType, "resource"), + inArray(attachments.ownerId, resourceIds), + eq(attachments.access, "public"), + isNotNull(attachments.publicUrl) + ) + ) + .orderBy(asc(attachments.position), asc(attachments.id)); + + const map = new Map(); + for (const row of rows) { + if (!row.ownerId || !row.publicUrl) continue; + const list = map.get(row.ownerId); + if (list) list.push(row.publicUrl); + else map.set(row.ownerId, [row.publicUrl]); + } + return map; +} diff --git a/v5/src/lib/data/uuid.ts b/v5/src/lib/data/uuid.ts new file mode 100644 index 0000000..02f4d93 --- /dev/null +++ b/v5/src/lib/data/uuid.ts @@ -0,0 +1,18 @@ +/** + * The uuid shape guard every query module needs (spec §4). + * + * Postgres `uuid` columns reject anything that is not uuid-shaped with a cast + * error rather than an empty result, so a slug, a unit label or free text from + * a model must never reach one. Each lookup that takes an untrusted id checks + * here first and returns its own "not found" instead. + * + * Relative imports with `.ts` extensions and no `@/` alias: `scripts/` loads + * these modules under plain Node. + */ + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** True when `value` could address a uuid primary key. */ +export function isUuid(value: string): boolean { + return UUID.test(value); +} diff --git a/v5/src/lib/db/demo-seed.test.ts b/v5/src/lib/db/demo-seed.test.ts index b859ea3..0046966 100644 --- a/v5/src/lib/db/demo-seed.test.ts +++ b/v5/src/lib/db/demo-seed.test.ts @@ -1,6 +1,10 @@ // @vitest-environment node import { eq } from "drizzle-orm"; -import { seedDemo } from "./demo-seed"; +import { + DEMO_FORM_4_NOTION_PAGE_ID, + DEMO_FORM_4_UNIT_NOTION_PAGE_ID, + seedDemo, +} from "./demo-seed"; import { createPgliteDb } from "./pglite"; import { resources, tools, units } from "./schema/index"; @@ -13,9 +17,24 @@ describe("seedDemo", () => { expect(toolRows.every((row) => row.published)).toBe(true); const form4 = toolRows[0]; + // Legacy-QR redirect fixture (spec Goal 2): Form 4 carries a Notion page + // id so an old `/tools/` link can be tested end to end. + expect(form4.notionPageId).toBe(DEMO_FORM_4_NOTION_PAGE_ID); + expect(toolRows[1].notionPageId).toBeNull(); + const form4Units = await db.select().from(units).where(eq(units.toolId, form4.id)); expect(form4Units).toHaveLength(1); expect(form4Units[0]).toMatchObject({ unitLabel: "Form 4 // A", status: "in_use", condition: "excellent" }); + // The unit carries a page id too: the writes still on Notion relate rows by + // page id, and the Trotec's unit deliberately has none so both branches of + // that translation are reachable from the seed. + expect(form4Units[0].notionPageId).toBe(DEMO_FORM_4_UNIT_NOTION_PAGE_ID); + + const trotecUnits = await db + .select() + .from(units) + .where(eq(units.toolId, toolRows[1].id)); + expect(trotecUnits[0].notionPageId).toBeNull(); const form4Resources = await db.select().from(resources).where(eq(resources.toolId, form4.id)); expect(form4Resources.map((row) => row.title)).toEqual(["Form 4 SOP", "Resin handling safety"]); diff --git a/v5/src/lib/db/demo-seed.ts b/v5/src/lib/db/demo-seed.ts index 669f378..1ae1950 100644 --- a/v5/src/lib/db/demo-seed.ts +++ b/v5/src/lib/db/demo-seed.ts @@ -6,7 +6,18 @@ import type { Db } from "./types.ts"; * tools the mock catalogue has always shown, now as real rows so the same * query modules serve tests, E2E and a fresh clone. Idempotent — a database * that already has tools is left alone. + * + * Form 4 carries a `notionPageId`, exercising the legacy `/tools/` + * redirect (spec Goal 2) end to end without a real database. Its unit carries + * one too: the writes still on Notion until Phase 3 relate rows by page id, so + * an imported row and a purely local one (the Trotec, which has neither) are + * both worth having in the seed. */ +export const DEMO_FORM_4_NOTION_PAGE_ID = "1f2e3d4c-5b6a-4789-8abc-def012345678"; + +/** The Notion page behind the Form 4's one unit — the `unit` relation target. */ +export const DEMO_FORM_4_UNIT_NOTION_PAGE_ID = "2a3b4c5d-6e7f-4890-9abc-def012345678"; + export async function seedDemo(db: Db): Promise { const existing = await db.select({ id: tools.id }).from(tools).limit(1); if (existing.length > 0) return; @@ -46,6 +57,7 @@ export async function seedDemo(db: Db): Promise { emergencyStop: "Lift the lid to immediately halt the print and pause the build.", notes: "Always wear nitrile gloves when handling uncured resin. Ventilation must be running.", published: true, + notionPageId: DEMO_FORM_4_NOTION_PAGE_ID, }, { slug: "trotec-speedy-400", @@ -74,6 +86,7 @@ export async function seedDemo(db: Db): Promise { status: "in_use", condition: "excellent", dateAcquired: "2024-08-12", + notionPageId: DEMO_FORM_4_UNIT_NOTION_PAGE_ID, }, { toolId: trotec.id, diff --git a/v5/src/lib/legacy-id.test.ts b/v5/src/lib/legacy-id.test.ts new file mode 100644 index 0000000..9934e44 --- /dev/null +++ b/v5/src/lib/legacy-id.test.ts @@ -0,0 +1,53 @@ +import { compactNotionId, isLegacyNotionId, normaliseNotionId } from "./legacy-id"; + +const DASHED = "0f5e2c1a-4b6d-4e8f-9a0b-1c2d3e4f5a6b"; +const COMPACT = "0f5e2c1a4b6d4e8f9a0b1c2d3e4f5a6b"; + +describe("isLegacyNotionId", () => { + it("accepts a Notion page id with and without dashes, in either case", () => { + expect(isLegacyNotionId(DASHED)).toBe(true); + expect(isLegacyNotionId(COMPACT)).toBe(true); + expect(isLegacyNotionId(DASHED.toUpperCase())).toBe(true); + expect(isLegacyNotionId(COMPACT.toUpperCase())).toBe(true); + expect(isLegacyNotionId(` ${DASHED} `)).toBe(true); + }); + + it("rejects slugs, which is the whole point of the check", () => { + for (const slug of [ + "form-4", + "trotec-speedy-400", + "bantam-tools-desktop-cnc-milling-machine", + "0f5e2c1a", // a truncated id + `${COMPACT}0`, // 33 characters + "0f5e2c1a-4b6d-4e8f-9a0b-1c2d3e4f5a6z", // not hex + "0f5e2c1a4b6d-4e8f9a0b1c2d3e4f5a6b", // dashes in the wrong places + "", + ]) { + expect(isLegacyNotionId(slug)).toBe(false); + } + }); +}); + +describe("compactNotionId", () => { + it("reduces both spellings to the same comparison key", () => { + expect(compactNotionId(DASHED)).toBe(COMPACT); + expect(compactNotionId(COMPACT)).toBe(COMPACT); + expect(compactNotionId(DASHED.toUpperCase())).toBe(COMPACT); + }); + + it("returns null for a slug", () => { + expect(compactNotionId("form-4")).toBeNull(); + }); +}); + +describe("normaliseNotionId", () => { + it("returns the dashed lower-case form Notion's API uses", () => { + expect(normaliseNotionId(COMPACT)).toBe(DASHED); + expect(normaliseNotionId(DASHED)).toBe(DASHED); + expect(normaliseNotionId(COMPACT.toUpperCase())).toBe(DASHED); + }); + + it("returns null for a slug", () => { + expect(normaliseNotionId("trotec-speedy-400")).toBeNull(); + }); +}); diff --git a/v5/src/lib/legacy-id.ts b/v5/src/lib/legacy-id.ts new file mode 100644 index 0000000..949b94e --- /dev/null +++ b/v5/src/lib/legacy-id.ts @@ -0,0 +1,56 @@ +/** + * Legacy Notion page ids in `/tools/` URLs (spec Goal 2). + * + * Before the Postgres move a tool's URL key was its Notion page id, and those + * ids are printed on the QR labels stuck to the machines. `tools.notion_page_id` + * keeps the key so the tool route can redirect an old link to `/tools/`, + * and these helpers are the whole test for "is this path segment a legacy id or + * a slug?". + * + * Notion writes the same id two ways: dashed in API responses, undashed in the + * URLs people copy out of the app. Both are accepted, in either case; anything + * else — `form-4`, a partial id, a uuid with dashes in the wrong places — is a + * slug as far as this module is concerned. + * + * No `@/` alias and no `server-only`: `src/lib/data/catalog.ts` imports this, + * and the scripts load that module under plain Node type-stripping. + */ + +/** 32 hex characters, no dashes — the form Notion puts in a page URL. */ +const COMPACT = /^[0-9a-f]{32}$/i; + +/** The canonical 8-4-4-4-12 form Notion's API returns. */ +const DASHED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** True for a Notion page id, dashed or not. False for a slug like `form-4`. */ +export function isLegacyNotionId(value: string): boolean { + return compactNotionId(value) !== null; +} + +/** + * The id's 32 hex characters, lower-case and undashed, or null when `value` is + * not a Notion page id. This is the comparison key: a stored id is normalised + * the same way before it is matched, so dashes and case never decide a lookup. + */ +export function compactNotionId(value: string): string | null { + const trimmed = value.trim(); + if (COMPACT.test(trimmed)) return trimmed.toLowerCase(); + if (DASHED.test(trimmed)) return trimmed.replace(/-/g, "").toLowerCase(); + return null; +} + +/** + * The dashed lower-case form Notion's API returns (`8-4-4-4-12`), or null when + * `value` is not a Notion page id. + */ +export function normaliseNotionId(value: string): string | null { + const compact = compactNotionId(value); + if (!compact) return null; + return [ + compact.slice(0, 8), + compact.slice(8, 12), + compact.slice(12, 16), + compact.slice(16, 20), + compact.slice(20), + ].join("-"); +} diff --git a/v5/src/lib/projects.test.ts b/v5/src/lib/projects.test.ts index 4d81b41..9c17204 100644 --- a/v5/src/lib/projects.test.ts +++ b/v5/src/lib/projects.test.ts @@ -1,368 +1,113 @@ -import { http, HttpResponse } from "msw"; - -import { server } from "../../test/msw/server"; -import { - checkboxProp, - externalFile, - filesProp, - multiSelectProp, - notionQueryResponse, - relationProp, - richTextProp, - titleProp, - urlProp, - type NotionPageFixture, -} from "../../test/fixtures/notion"; import { nextCacheMock } from "../../test/mocks/next-cache"; vi.mock("next/cache", () => nextCacheMock()); -// ── Helpers ───────────────────────────────────────────────────────── - -const PROJECTS_DB = "db-projects"; - -// The Projects DB is independent of the catalog env contract: it needs only -// NOTION_API_KEY + NOTION_DB_PROJECTS. The catalog DB ids are deliberately left -// blank so `getCatalogTools()` resolves tool relations against the built-in -// mock catalog (tool-form-4 / tool-trotec-speedy-400) with no network call. -function stubProjectsEnv() { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_PROJECTS", PROJECTS_DB); - vi.stubEnv("NOTION_DB_TOOLS", ""); - vi.stubEnv("NOTION_DB_CATEGORIES", ""); - vi.stubEnv("NOTION_DB_LOCATIONS", ""); - vi.stubEnv("NOTION_DB_UNITS", ""); - vi.stubEnv("NOTION_DB_RESOURCES", ""); - vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", ""); - vi.stubEnv("NOTION_DB_FLAGS", ""); -} - -const TS = "2024-08-12T10:00:00.000Z"; - -// A raw Projects page. Any property can be overridden — or omitted entirely, to -// exercise the "property absent from the database" path. -function projectPage( - id: string, - properties: Record, - createdTime = TS -): NotionPageFixture { +// `src/lib/projects.ts` is now a thin `"use cache"` wrapper over +// `src/lib/data/projects.ts` (spec §3.10); the mapping and filtering it used +// to do live there and are covered by `src/lib/data/projects.test.ts` +// instead. This file only has to prove the wrapper: it delegates with the +// right arguments, tags every read `projects`, and — Article 4 — never turns +// a database failure into an invented empty result. +const dataProjects = vi.hoisted(() => ({ + listPublishedProjects: vi.fn(), + findPublishedProject: vi.fn(), + listPublishedProjectsForTool: vi.fn(), +})); + +vi.mock("./data/projects", () => dataProjects); + +import { cacheTag } from "next/cache"; +import type { MakerLabProject } from "../components/catalog-types"; +import { getProject, getProjectsForTool, getPublishedProjects } from "./projects"; + +function project(overrides: Partial = {}): MakerLabProject { return { - object: "page", - id, - created_time: createdTime, - last_edited_time: createdTime, - properties, + id: "11111111-1111-1111-1111-111111111111", + title: "Lamp from scrap plywood", + author: "Ada Lovelace", + body: "## How I made it", + photos: ["https://blob.test/cover.png"], + tools: [{ id: "tool-1", name: "Form 4", slug: "form-4" }], + link: null, + materials: ["Plywood"], + date: "2026-01-01T00:00:00.000Z", + ...overrides, }; } -const fullProject = projectPage("project-1", { - title: titleProp("Lamp from scrap plywood"), - author: richTextProp("Ada Lovelace"), - body: richTextProp("## How I made it\nCut on the laser, glued, sanded."), - photos: filesProp([ - externalFile("cover.png", "https://files.notion.so/cover.png"), - externalFile("detail.png", "https://files.notion.so/detail.png"), - ]), - // tool-form-4 exists in the mock catalog; tool-deleted does not. - tools_used: relationProp(["tool-form-4", "tool-deleted"]), - link: urlProp("https://example.com/lamp"), - materials: multiSelectProp(["Plywood", "PLA"]), - published: checkboxProp(true), -}); - -// Route every Notion DB query to the supplied project pages. `projects.ts` only -// ever queries the Projects DB (the catalog comes from the mock fallback), so a -// single override is enough. -function routeProjects(pages: NotionPageFixture[]) { - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", ({ params }) => - HttpResponse.json( - notionQueryResponse(params.id === PROJECTS_DB ? pages : []) - ) - ) - ); -} - -// projects.ts reads process.env through function calls (`hasProjectsEnv`), but -// re-importing per test also gives each test a fresh `"use cache"` boundary. -async function importProjects() { - vi.resetModules(); - return import("@/lib/projects"); -} - -// ── toMakerLabProject mapping ─────────────────────────────────────── - -describe("getPublishedProjects (mapping)", () => { - it("maps a Notion project record onto MakerLabProject", async () => { - stubProjectsEnv(); - routeProjects([fullProject]); - const { getPublishedProjects } = await importProjects(); - - const [project] = await getPublishedProjects(); - - expect(project.id).toBe("project-1"); - expect(project.title).toBe("Lamp from scrap plywood"); - expect(project.author).toBe("Ada Lovelace"); - expect(project.body).toContain("How I made it"); - expect(project.photos).toEqual([ - "https://files.notion.so/cover.png", - "https://files.notion.so/detail.png", - ]); - expect(project.link).toBe("https://example.com/lamp"); - expect(project.materials).toEqual(["Plywood", "PLA"]); - expect(project.date).toBe(TS); - }); - - it("falls back to Anonymous when the author is empty", async () => { - stubProjectsEnv(); - routeProjects([ - projectPage("project-anon", { - title: titleProp("Anonymous build"), - author: richTextProp(""), - body: richTextProp("No name on this one."), - published: checkboxProp(true), - }), - ]); - const { getPublishedProjects } = await importProjects(); - - const [project] = await getPublishedProjects(); - expect(project.author).toBe("Anonymous"); - }); - - it("falls back to Anonymous when the author property is absent entirely", async () => { - stubProjectsEnv(); - routeProjects([ - projectPage("project-no-author", { - title: titleProp("Untitled author"), - body: richTextProp("Body only."), - published: checkboxProp(true), - }), - ]); - const { getPublishedProjects } = await importProjects(); - - const [project] = await getPublishedProjects(); - expect(project.author).toBe("Anonymous"); - }); - - it("returns empty photos/tools/materials when those properties are absent", async () => { - stubProjectsEnv(); - routeProjects([ - projectPage("project-bare", { - title: titleProp("Bare project"), - author: richTextProp("Grace Hopper"), - body: richTextProp("Just words."), - published: checkboxProp(true), - }), - ]); - const { getPublishedProjects } = await importProjects(); - - const [project] = await getPublishedProjects(); - expect(project.photos).toEqual([]); - expect(project.tools).toEqual([]); - expect(project.materials).toEqual([]); - expect(project.link).toBeNull(); - // No `date` property — falls back to the page's created_time. - expect(project.date).toBe(TS); - }); - - it("drops tool ids that no longer resolve instead of rendering them broken", async () => { - stubProjectsEnv(); - routeProjects([fullProject]); - const { getPublishedProjects } = await importProjects(); - - const [project] = await getPublishedProjects(); - - // tool-deleted is gone from the catalog; only the resolvable ref survives. - expect(project.tools).toEqual([ - { id: "tool-form-4", name: "Form 4", slug: "form-4" }, - ]); - }); - - it("resolves tool refs with the slug the tool page is routed by", async () => { - stubProjectsEnv(); - routeProjects([ - projectPage("project-two-tools", { - title: titleProp("Two machines"), - author: richTextProp("Ada"), - body: richTextProp("Body"), - tools_used: relationProp(["tool-trotec-speedy-400", "tool-form-4"]), - published: checkboxProp(true), - }), - ]); - const { getPublishedProjects } = await importProjects(); - - const [project] = await getPublishedProjects(); - expect(project.tools.map((tool) => tool.slug)).toEqual([ - "trotec-speedy-400", - "form-4", - ]); - }); +beforeEach(() => { + dataProjects.listPublishedProjects.mockReset(); + dataProjects.findPublishedProject.mockReset(); + dataProjects.listPublishedProjectsForTool.mockReset(); }); -// ── published filtering ───────────────────────────────────────────── +describe("getPublishedProjects", () => { + it("returns what the data layer resolves", async () => { + dataProjects.listPublishedProjects.mockResolvedValue([project()]); -describe("getPublishedProjects (publish gate)", () => { - it("excludes records whose published checkbox is unticked", async () => { - stubProjectsEnv(); - routeProjects([ - fullProject, - projectPage("project-draft", { - title: titleProp("Draft build"), - author: richTextProp("Someone"), - body: richTextProp("Not reviewed yet."), - published: checkboxProp(false), - }), - ]); - const { getPublishedProjects } = await importProjects(); - - const projects = await getPublishedProjects(); - expect(projects.map((project) => project.id)).toEqual(["project-1"]); + expect(await getPublishedProjects()).toEqual([project()]); + expect(dataProjects.listPublishedProjects).toHaveBeenCalledTimes(1); }); - it("excludes records with no published property at all", async () => { - stubProjectsEnv(); - routeProjects([ - projectPage("project-unset", { - title: titleProp("No checkbox"), - author: richTextProp("Someone"), - body: richTextProp("Body"), - }), - ]); - const { getPublishedProjects } = await importProjects(); + it("tags the read `projects`, so publishing or unpublishing one can invalidate it", async () => { + dataProjects.listPublishedProjects.mockResolvedValue([]); - expect(await getPublishedProjects()).toEqual([]); + await getPublishedProjects(); + expect(vi.mocked(cacheTag)).toHaveBeenCalledWith("projects"); }); -}); - -// ── no env / failure paths ────────────────────────────────────────── -describe("getPublishedProjects (no Projects DB)", () => { - it("returns an empty array without throwing or calling Notion", async () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_PROJECTS", ""); - const { getPublishedProjects } = await importProjects(); + it("lets a database failure propagate instead of returning an invented empty gallery", async () => { + dataProjects.listPublishedProjects.mockRejectedValue(new Error("db unavailable")); - // MSW's onUnhandledRequest:"error" would fail this test on any outbound - // call, so reaching the assertion proves nothing was fetched. - await expect(getPublishedProjects()).resolves.toEqual([]); - }); - - it("returns an empty array when NOTION_DB_PROJECTS alone is missing", async () => { - vi.stubEnv("NOTION_API_KEY", "secret_test"); - vi.stubEnv("NOTION_DB_PROJECTS", ""); - const { getPublishedProjects } = await importProjects(); - - await expect(getPublishedProjects()).resolves.toEqual([]); - }); - - it("fails toward empty (not toward a thrown page) when Notion errors", async () => { - stubProjectsEnv(); - server.use( - http.post("https://api.notion.com/v1/databases/:id/query", () => - HttpResponse.json( - { object: "error", status: 500, message: "internal" }, - { status: 500 } - ) - ) - ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { getPublishedProjects } = await importProjects(); - - await expect(getPublishedProjects()).resolves.toEqual([]); - // The failure has to be visible in the logs (Article 4). - expect(warn).toHaveBeenCalled(); + await expect(getPublishedProjects()).rejects.toThrow("db unavailable"); }); }); -// ── getProject ────────────────────────────────────────────────────── - describe("getProject", () => { - it("returns the published project for a known id", async () => { - stubProjectsEnv(); - routeProjects([fullProject]); - const { getProject } = await importProjects(); + it("delegates to findPublishedProject with the id or slug it was given", async () => { + dataProjects.findPublishedProject.mockResolvedValue(project()); - const project = await getProject("project-1"); - expect(project?.title).toBe("Lamp from scrap plywood"); + expect(await getProject("lamp-from-scrap-plywood")).toEqual(project()); + expect(dataProjects.findPublishedProject).toHaveBeenCalledWith("lamp-from-scrap-plywood"); }); - it("returns null for an unknown id", async () => { - stubProjectsEnv(); - routeProjects([fullProject]); - const { getProject } = await importProjects(); + it("returns null when the data layer finds nothing published under that id or slug", async () => { + dataProjects.findPublishedProject.mockResolvedValue(null); expect(await getProject("no-such-project")).toBeNull(); }); - it("returns null for an unpublished project reachable by direct id", async () => { - stubProjectsEnv(); - routeProjects([ - fullProject, - projectPage("project-draft", { - title: titleProp("Draft build"), - author: richTextProp("Someone"), - body: richTextProp("Not reviewed yet."), - published: checkboxProp(false), - }), - ]); - const { getProject } = await importProjects(); + it("tags the read `projects`", async () => { + dataProjects.findPublishedProject.mockResolvedValue(null); - // Guessing the id of an unpublished submission must not surface it. - expect(await getProject("project-draft")).toBeNull(); + await getProject("project-1"); + expect(vi.mocked(cacheTag)).toHaveBeenCalledWith("projects"); }); - it("returns null when no Projects DB is configured", async () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_PROJECTS", ""); - const { getProject } = await importProjects(); + it("lets a database failure propagate", async () => { + dataProjects.findPublishedProject.mockRejectedValue(new Error("db unavailable")); - expect(await getProject("project-1")).toBeNull(); + await expect(getProject("project-1")).rejects.toThrow("db unavailable"); }); }); -// ── getProjectsForTool (the "Built with this" join) ────────────────── - describe("getProjectsForTool", () => { - it("returns only the projects that reference the tool", async () => { - stubProjectsEnv(); - routeProjects([ - fullProject, - projectPage("project-laser", { - title: titleProp("Laser box"), - author: richTextProp("Someone"), - body: richTextProp("Body"), - tools_used: relationProp(["tool-trotec-speedy-400"]), - published: checkboxProp(true), - }), - ]); - const { getProjectsForTool } = await importProjects(); - - const forPrinter = await getProjectsForTool("tool-form-4"); - expect(forPrinter.map((project) => project.id)).toEqual(["project-1"]); - }); - - it("returns an empty array for a tool nothing was built with", async () => { - stubProjectsEnv(); - routeProjects([fullProject]); - const { getProjectsForTool } = await importProjects(); + it("delegates to listPublishedProjectsForTool with the given tool id", async () => { + dataProjects.listPublishedProjectsForTool.mockResolvedValue([project()]); - expect(await getProjectsForTool("tool-trotec-speedy-400")).toEqual([]); + expect(await getProjectsForTool("tool-1")).toEqual([project()]); + expect(dataProjects.listPublishedProjectsForTool).toHaveBeenCalledWith("tool-1"); }); - it("returns an empty array — never throws — for a deleted tool id", async () => { - stubProjectsEnv(); - routeProjects([fullProject]); - const { getProjectsForTool } = await importProjects(); + it("returns an empty array when nothing was built with the tool", async () => { + dataProjects.listPublishedProjectsForTool.mockResolvedValue([]); - // A project referencing a deleted tool must not 500 the tool page. - expect(await getProjectsForTool("tool-deleted")).toEqual([]); + expect(await getProjectsForTool("tool-1")).toEqual([]); }); - it("returns an empty array when no Projects DB is configured", async () => { - vi.stubEnv("NOTION_API_KEY", ""); - vi.stubEnv("NOTION_DB_PROJECTS", ""); - const { getProjectsForTool } = await importProjects(); + it("lets a database failure propagate", async () => { + dataProjects.listPublishedProjectsForTool.mockRejectedValue(new Error("db unavailable")); - expect(await getProjectsForTool("tool-form-4")).toEqual([]); + await expect(getProjectsForTool("tool-1")).rejects.toThrow("db unavailable"); }); }); diff --git a/v5/src/lib/projects.ts b/v5/src/lib/projects.ts index 4361568..f71b16d 100644 --- a/v5/src/lib/projects.ts +++ b/v5/src/lib/projects.ts @@ -1,84 +1,50 @@ import { cacheLife, cacheTag } from "next/cache"; -import { fetchAllProjects, hasProjectsEnv } from "./notion"; -import type { ProjectRecord } from "./types"; -import type { MakerLabProject, ProjectToolRef } from "../components/catalog-types"; -import { getCatalogTools } from "./catalog"; +import { findPublishedProject, listPublishedProjects, listPublishedProjectsForTool } from "./data/projects"; +import type { MakerLabProject } from "../components/catalog-types"; -// Cached, published-only view of every project. Behind `"use cache"` with the -// `projects` tag so the admin revalidate endpoint can bust it on publish. -async function fetchPublishedProjects(): Promise { +// Cached, published-only reads over `src/lib/data/projects.ts`. Every export +// below shares the `projects` cache tag (spec §3.9), so publishing, +// unpublishing or editing a project in `/admin/projects` busts every one of +// these with a single `revalidateTag("projects")` / `updateTag("projects")` +// call. A thrown database read is not caught here: it surfaces as a stale +// cached page (Article 4, fail toward stale), never an invented empty result. + +async function fetchPublishedProjects(): Promise { "use cache"; cacheTag("projects"); cacheLife("minutes"); - return fetchAllProjects({ publishedOnly: true }); + return listPublishedProjects(); } -async function toMakerLabProjects( - records: ProjectRecord[] -): Promise { - // Resolve tool relation ids → {id,name,slug} via the catalog (slug === id). - const tools = await getCatalogTools(); - const toolMap = new Map( - tools.map((tool) => [tool.id, { id: tool.id, name: tool.name, slug: tool.slug }]) - ); +async function fetchProject(idOrSlug: string): Promise { + "use cache"; + cacheTag("projects"); + cacheLife("minutes"); - return records.map((record) => toMakerLabProject(record, toolMap)); + return findPublishedProject(idOrSlug); } -function toMakerLabProject( - record: ProjectRecord, - toolMap: Map -): MakerLabProject { - const fields = record.fields; - return { - id: record.id, - title: fields.title, - author: fields.author || "Anonymous", - body: fields.body || "", - // Notion file URLs are signed and expire; surfaced as-is for the short - // cacheLife window (mirrors the catalog image approach). - photos: (fields.photos || []).map((photo) => photo.url).filter(Boolean), - tools: (fields.tools_used || []) - .map((id) => toolMap.get(id)) - .filter((ref): ref is ProjectToolRef => Boolean(ref)), - link: fields.link || null, - materials: fields.materials || [], - date: fields.date || record.createdTime || null, - }; +async function fetchProjectsForTool(toolId: string): Promise { + "use cache"; + cacheTag("projects"); + cacheLife("minutes"); + + return listPublishedProjectsForTool(toolId); } export async function getPublishedProjects(): Promise { - if (!hasProjectsEnv()) return []; - - try { - const records = await fetchPublishedProjects(); - return toMakerLabProjects(records); - } catch (error) { - console.warn("Failed to load projects:", error); - return []; - } + return fetchPublishedProjects(); } -export async function getProject(id: string): Promise { - if (!hasProjectsEnv()) return null; - - // Resolve from the cached published set so unpublished projects never surface - // via the detail route either. - const projects = await getPublishedProjects(); - return projects.find((project) => project.id === id) || null; +export async function getProject(idOrSlug: string): Promise { + return fetchProject(idOrSlug); } /** * Published projects that reference a given tool id (the "Built with this" - * section on the tool detail). Derived by scanning each project's - * `tools_used`. Returns [] when no projects DB is configured. + * section on the tool detail). */ -export async function getProjectsForTool( - toolId: string -): Promise { - const projects = await getPublishedProjects(); - return projects.filter((project) => - project.tools.some((tool) => tool.id === toolId) - ); +export async function getProjectsForTool(toolId: string): Promise { + return fetchProjectsForTool(toolId); } diff --git a/v5/test/README.md b/v5/test/README.md index e77ffa8..afa1a07 100644 --- a/v5/test/README.md +++ b/v5/test/README.md @@ -17,7 +17,7 @@ your `*.test.ts(x)` files and import from here. | `test/mocks/next-cache.ts` | `nextCacheMock()` factory for `vi.mock("next/cache", …)` | | `test/mocks/server-only.ts` | empty stub aliased for `import "server-only"` | | `test/utils/render.tsx` | RTL `render` wrapped in `NextIntlClientProvider` + `userEvent` | -| `playwright.config.ts` | E2E config; dev server boots with Notion env unset | +| `playwright.config.ts` | E2E config; dev server boots with `DATABASE_URL` unset (PGlite demo seed) | ## Scripts @@ -59,18 +59,62 @@ import { render, screen, userEvent } from "../../test/utils/render"; --- -## The mock-catalog fallback rule (read this first) +## The PGlite rule (read this first) + +Catalogue reads run against Postgres, and the test suite never needs Notion +env for them. `getCatalogTools()` / `getCatalogTool(id)` read from **PGlite** +— an in-process Postgres, migrated and seeded with demo data (two tools: +`form-4`, `trotec-speedy-400`) — whenever `DATABASE_URL` is unset, which is +the default in every test. No MSW, no `vi.stubEnv` for Notion, no network. + +- **The seeded demo database** (most catalogue tests): leave `DATABASE_URL` + unset and call `getDb()` from `../../src/lib/db/client` — it lazily creates + and memoises one PGlite instance per test file. Put `resetDbForTests()` in + an `afterEach` if a test stubs `DATABASE_URL` to something else. +- **An isolated database** (schema tests, or a test that needs to seed its own + rows without touching the shared demo data): call `createPgliteDb()` from + `../../src/lib/db/pglite` directly — it returns a fresh, empty (or + custom-seeded via its `seed` option) instance, migrated the same way Neon + is. +- **`// @vitest-environment node` is required** at the top of any file that + creates or touches a PGlite database — jsdom (the suite's default + environment) doesn't have what PGlite's WASM build needs. -`getCatalogTools()` / `getCatalogTool(id)` return the built-in mock catalog when -`hasNotionCatalogEnv()` is false — i.e. **any** of the 8 Notion env vars -(`NOTION_API_KEY` + the 7 `NOTION_DB_*`) is missing — **and** on any thrown -error during the Notion fetch. +```ts +// @vitest-environment node +import { createPgliteDb } from "../../src/lib/db/pglite"; +import type { Db } from "../../src/lib/db/types"; + +let db: Db; + +beforeAll(async () => { + db = await createPgliteDb(); +}); +``` + +```ts +// @vitest-environment node +import { getDb, resetDbForTests } from "../../src/lib/db/client"; + +afterEach(() => { + resetDbForTests(); +}); + +it("reads the seeded demo tools", async () => { + vi.stubEnv("DATABASE_URL", ""); + const db = await getDb(); + // ... +}); +``` -- **Mock path** (default in tests): leave Notion env unset. Catalog comes from - `src/components/mock-catalog.ts`. No MSW needed. -- **Real Notion path**: `vi.stubEnv` all 8 vars (set the `NOTION_DB_*` ones to - the `DB_IDS` sentinels so the default handlers route correctly), then let MSW - serve `api.notion.com`. +`vi.unstubAllEnvs()` runs automatically after every test (setup file). + +**Write paths are still on Notion in this phase** (maintenance tickets, +corrections, project submission, uploads, intake) — see +`docs/specs/2026-09-14-v5-data-platform-design.md` §9. Testing those still +uses the real-Notion MSW path: `vi.stubEnv` all 8 Notion vars (set the +`NOTION_DB_*` ones to the `DB_IDS` sentinels so the default handlers route +correctly), then let MSW serve `api.notion.com`. ```ts import { DB_IDS } from "../../test/msw/handlers"; @@ -87,8 +131,6 @@ function stubNotionEnv() { } ``` -`vi.unstubAllEnvs()` runs automatically after every test (setup file). - --- ## Env stubbing for module-load-time reads @@ -233,7 +275,7 @@ it("wires system + tools and runs a captured tool.execute", async () => { expect(captured.args.tools).toHaveProperty("report_issue"); expect(captured.args.tools).toHaveProperty("web_fetch"); - // Invoke a tool.execute directly (mock-catalog path — no Notion env): + // Invoke a tool.execute directly (PGlite demo seed — DATABASE_URL unset): const miss = await captured.args.tools.get_unit_details.execute({ unit_label: "no-such-unit", }); @@ -251,5 +293,5 @@ Notes: - To test `report_issue.execute` filing a ticket, stub the Notion env and let MSW's `POST /pages` handler respond (returns `id: "created-page-1"`), then assert `result.success === true` and `result.ticket_id`. -- For `get_unit_details` / `report_issue` against the **mock catalog** (no env), - the catalog units come from `mock-catalog.ts` (`Form 4 // A`, `Trotec Speedy 400`). +- For `get_unit_details` against the **PGlite demo seed** (`DATABASE_URL` + unset), the catalog units are `Form 4 // A` and `Trotec Speedy 400`. From 79c8cd58ef4d54ad29225d25ade121e42740feb8 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Mon, 14 Sep 2026 23:27:09 -0400 Subject: [PATCH 2/2] Sample project in the demo seed; sign-in says why it went nowhere The demo seed publishes one sample project (a laser-cut lamp built with both demo tools, two bundled photos, materials, an outside link) and gives the Trotec a photo attachment, since its bundled image is not named after the tool. Project URLs use the slug. Messages fall back to English per locale (Article 6 as amended), and the first English-only strings are the header's sign-in notices: a click that cannot start sign-in now says whether this deployment has none configured or the request failed, in a popover that fades after six seconds and never shifts the layout. The Add control takes the same chrome as Report instead of the browser default. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NP2xC6APXbSucoE4n5mtoj --- .../2026-09-14-v5-data-platform-design.md | 3 + v5/.gitignore | 2 + v5/e2e/projects.spec.ts | 37 ++++--- v5/messages/en.json | 2 + v5/next.config.ts | 4 + .../sample-projects/laser-cut-lamp-lit.png | Bin 0 -> 116167 bytes .../sample-projects/laser-cut-lamp-parts.png | Bin 0 -> 68130 bytes v5/src/app/projects/page.tsx | 2 +- v5/src/components/DetailShell.tsx | 2 +- v5/src/components/PrimaryNav.test.tsx | 54 ++++++++++- v5/src/components/PrimaryNav.tsx | 62 +++++++++--- v5/src/components/ProjectDetail.test.tsx | 1 + v5/src/components/catalog-types.ts | 2 + v5/src/i18n/messages.test.ts | 43 +++++++++ v5/src/i18n/messages.ts | 27 ++++++ v5/src/i18n/request.ts | 11 ++- v5/src/lib/auth/sign-in-client.test.ts | 16 ++- v5/src/lib/auth/sign-in-client.ts | 23 +++-- v5/src/lib/catalog.test.ts | 4 +- v5/src/lib/data/projects.ts | 1 + v5/src/lib/db/demo-seed.test.ts | 44 ++++++++- v5/src/lib/db/demo-seed.ts | 91 +++++++++++++++++- v5/src/lib/projects.test.ts | 1 + v5/src/styles/globals.css | 35 +++++++ 24 files changed, 408 insertions(+), 59 deletions(-) create mode 100644 v5/public/sample-projects/laser-cut-lamp-lit.png create mode 100644 v5/public/sample-projects/laser-cut-lamp-parts.png create mode 100644 v5/src/i18n/messages.test.ts create mode 100644 v5/src/i18n/messages.ts diff --git a/docs/specs/2026-09-14-v5-data-platform-design.md b/docs/specs/2026-09-14-v5-data-platform-design.md index 8577156..c9cfa90 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1024,5 +1024,8 @@ Appended per [`DRIFT.md`](DRIFT.md). Original text above is never edited — the - **Cache tags.** Every catalogue read is tagged `catalog` only; §3.9's `tool:` tag on detail reads waits for the write paths that would invalidate it, which is also where the choice of key (slug or uuid) belongs. - **`src/lib/data/uuid.ts`** guards every id lookup so free text from a tool call never reaches Postgres as a bad uuid cast. `listResources()` has no production caller yet; the chat route uses `listResourcesForTool()`. - **Retired but not deleted:** `src/components/mock-catalog.ts` (only the offline eval fixtures still pin its data), the Notion read helpers in `src/lib/notion.ts`, `test/fixtures/catalog.ts`, and the four Notion maintenance scripts §3.10 lists. Each deletion is proposed separately (constitution working agreements). +- **English fallback for messages is in** (§6, Article 6 as amended): `src/i18n/request.ts` lays each locale file over `en.json`, so a string added only to English renders in English elsewhere. The first such strings are the sign-in notices in the header, which now say why a click on *Sign in* went nowhere instead of doing nothing (this deployment has no sign-in configured, or the request failed). +- **Project URLs use the slug.** `MakerLabProject` gains `slug`, the gallery and a tool's "built with this" link to `/projects/`, and the project page accepts either the slug or the uuid. +- **The demo seed has one published sample project** (a laser-cut lamp built with both demo tools, with two bundled photos under `public/sample-projects/`, materials and an outside link) and gives the Trotec a photo attachment, since its bundled image file is not named after the tool. The gallery, the project page and "built with this" are therefore exercised end to end without a database. **Status.** Accepted. The bridge and the prerender change are the two a reader should know about before Phase 3. diff --git a/v5/.gitignore b/v5/.gitignore index ed3d3e8..a354760 100644 --- a/v5/.gitignore +++ b/v5/.gitignore @@ -10,3 +10,5 @@ tsconfig.tsbuildinfo /playwright-report/ /blob-report/ /playwright/.cache/ +.vercel +.env* diff --git a/v5/e2e/projects.spec.ts b/v5/e2e/projects.spec.ts index 7822c01..c5a09ba 100644 --- a/v5/e2e/projects.spec.ts +++ b/v5/e2e/projects.spec.ts @@ -1,11 +1,10 @@ import { test, expect } from "@playwright/test"; -// The app boots with NOTION_* unset (see playwright.config.ts webServer.env), -// so `hasProjectsEnv()` is false: `getPublishedProjects()` returns [] without -// ever calling Notion, and `POST /api/projects` answers 503 "not configured". -// That is the correct production behaviour for a lab that has not created the -// Projects database yet — so the gallery here is asserted as an *intentional* -// empty state, not a broken one. +// The app boots with no DATABASE_URL and NOTION_* unset (see +// playwright.config.ts webServer.env), so the gallery reads the PGlite demo +// seed — one published sample project, "Laser-cut plywood lamp" +// (src/lib/db/demo-seed.ts) — and `POST /api/projects`, still a Notion write +// until Phase 3, would answer 503 "not configured". // // The submit path is therefore exercised with `page.route("**/api/projects")` // standing in for the route handler, exactly as chat.spec.ts stands in for @@ -31,9 +30,7 @@ async function fillRequiredFields(page: import("@playwright/test").Page) { } test.describe("Projects gallery", () => { - test("renders an intentional empty state with no Projects database", async ({ - page, - }) => { + test("renders the sample project and opens its page", async ({ page }) => { await page.goto("/projects"); // projects.title => "STUDENT PROJECTS". @@ -41,23 +38,31 @@ test.describe("Projects gallery", () => { page.getByRole("heading", { name: "STUDENT PROJECTS", level: 1 }) ).toBeVisible(); - // The lede describes the gallery rather than apologising for it, and its - // {institution} placeholder resolved. + // The lede describes the gallery, and its {institution} placeholder resolved. await expect( page.getByText(/Builds, experiments, and course outcomes from/i) ).toBeVisible(); - // projects.empty — reads as "nothing published yet", not as an error. No - // failure language, and the invitation to submit is still offered. - await expect( - page.getByText("No projects published yet. Be the first to share your build.") - ).toBeVisible(); + // The demo seed's one published project is listed, with no failure language. + const card = page.getByRole("link").filter({ hasText: "Laser-cut plywood lamp" }).first(); + await expect(card).toBeVisible(); await expect( page.getByText(/error|failed|unavailable|something went wrong/i) ).toHaveCount(0); // Whole-page check: no locale placeholder rendered literally anywhere. await expect(page.locator("body")).not.toContainText(PLACEHOLDER_LEAK); + + // Its page shows the write-up, the outside link and the tools it was built with. + await card.click(); + await expect(page).toHaveURL(/\/projects\/laser-cut-plywood-lamp$/); + await expect( + page.getByRole("heading", { name: "Laser-cut plywood lamp", level: 1 }) + ).toBeVisible(); + await expect( + page.getByRole("link", { name: /wikipedia/i }).first() + ).toHaveAttribute("href", /en\.wikipedia\.org\/wiki\/Laser_cutting/); + await expect(page.getByRole("link", { name: "Trotec Speedy 400" })).toBeVisible(); }); test("the empty state's call to action reaches the submit form", async ({ diff --git a/v5/messages/en.json b/v5/messages/en.json index 57d88c6..8429f3c 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -18,6 +18,8 @@ "addSeed": "I'd like to add new equipment to the inventory.", "signIn": "SIGN IN", "signInAria": "Sign in with your {institution} account", + "signInUnconfigured": "Sign-in isn't set up on this deployment yet.", + "signInFailed": "Couldn't start sign-in. Try again.", "signOut": "SIGN OUT", "signedInAria": "Signed in as {name}" }, diff --git a/v5/next.config.ts b/v5/next.config.ts index 03fdd00..0ba2390 100644 --- a/v5/next.config.ts +++ b/v5/next.config.ts @@ -17,6 +17,10 @@ const nextConfig: NextConfig = { { pathname: "/tool-images/**", }, + { + // Photos for the demo seed's sample project (src/lib/db/demo-seed.ts). + pathname: "/sample-projects/**", + }, { pathname: "/makerlab-logo-transparent.png", }, diff --git a/v5/public/sample-projects/laser-cut-lamp-lit.png b/v5/public/sample-projects/laser-cut-lamp-lit.png new file mode 100644 index 0000000000000000000000000000000000000000..9896bfb1f34ca88dd6a445f775898a1ec714e5b5 GIT binary patch literal 116167 zcmX_ncU;o@|Ggq2ARr1VA}Tlt%?yxAtyG3HEHE`H>xzoj6%Zka0?3yWK1+C z-ogT`x3I7xLP5YUS0#u<3(L0h7!EUG%fmO3lWK4QIx#nC9x$ElgnmK{%uXk^Kv-$Q8A%SV3y*&a=Ni~Rm#^U7Djf{Eds_iO%EeF^Q~`}NO(_4c2(SATXYy&Cw> zdf12UnlHT`*Zxwj&-ir6ZRgFa@5?^SJ^FEP%d<pqYitJM+WGT;H+~7d8ZhBx_e_4~?;RhC?$6A=Ucr4DG4Os{$Cbga zdwXxVbo>y%T`O?f>3a0p$G%4id8>PG*uGvL`1&O7`i!#8T|ejkIhE9XooV%pR<0|f7(tv^z8Zg>2Ke= zbAJ7>oOr+2bVCCAw|v>$ZFhGbP5gJ4^Y8m3V}EFmd##^3{XEh3`M*z3{o4BU+cSxy zcFwO-?>$mFwP@wr8+$(ei~RgE%ITxW*vp;wx^}W~zbw;=NBF-j27XFaokvcq zcebbfhUExdV@6v{}a6%vLM`0 z+DTDYSS1owQ3Yq;9F1OgJms`#-9cTeXdA_HGi&k}Cs zp=-hQLA;d^I*jM|a}al7#@z)mWeFVbk0b)h`W$H=;)PrY@lU3d%a0`r|9oEStDdlG zm$l0vz}QO|7lAMHV5lkNhaogU09?06L$rC#BrdfmS^Vt6m$2+4@9C|%S&cD@Z9=u1 z?Uh>`{x%b{7l@eVeNf!v9p7QdoHIHb$cxCec9}jZHpUCNbqw{(v>SZ1zU+ zWWO$a{!z?J1?_~&T26?@oD~y0 zxu%t~03)r}4Mhnk&?S;aF{Q)`!z8k-=pPnU)h5k(_TbjvE8eZmxzYYCGM0yBa;oNu zT1XPah~OfZi8J1eek753sHipMGSn=D(=JI>EN_n$ z?X51 znurpJvnz58kB&=tiGexowynO$J~k27D;tH&%JiTS?VgpdAaa`AbOFiggdyJCpRyud0CyDW}HmABc?{k zdoH#0r(7s&6s$#{t@m|nG$A$;wl*Bvx~6%<;OXAXKV$R1Q)6m5d3jI;J6e-)01^C3 zp5p&gAL2w_BKPTt!rbY>VU~@HPfKd8Y7M=w4R5?K*Xo#0YMFP{l%Z7Wr%OafYfL@6 zkZ_UEQ79?qIfAO&Z?Iw(6}je+banGuMX}kUiv+IX(uO*!zyFk;YDg4b`7`};x6B90 zAEW_`B9FM@<}?dm9ftQ`hDh&G`e`*y4%H*!G-#_s%JF0CSHC&G-*MHH3SP8dq)x)Y zQ-!C%8Nw#&aEz9wwT_w*^l&5bO^o17wd1-TqW63?2|u zE_oiyqhS3Yg$NspaR>C>vgJ<~L;`0^_JeI11h$E75p~)t#`Re-27y54=U<=RuI>XK z{l0K<1pVy+LJwEEOst2++;6EiNVYbNfO!Da-l0dP)VJ;1Crob?mI%Zt-{l?@!G zA-6HeW(`Y¨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 literal 0 HcmV?d00001 diff --git a/v5/src/app/projects/page.tsx b/v5/src/app/projects/page.tsx index 4d0b583..ad5ca51 100644 --- a/v5/src/app/projects/page.tsx +++ b/v5/src/app/projects/page.tsx @@ -36,7 +36,7 @@ export default async function ProjectsPage() { projects.map((project) => (