From 4056bfe8e98a3f6b6ecba51cec5bc306431531ec Mon Sep 17 00:00:00 2001 From: Isaac S Date: Tue, 22 Sep 2026 19:40:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Phase=205:=20the=20admin=20surface=20writes?= =?UTF-8?q?=20=E2=80=94=20inventory,=20the=20editor,=20three=20queues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin gains the surfaces the lab runs on (spec §5.3, §5.6): the review table with its needs-attention flags, a tool editor offered both from a row and as a full-screen sheet over a machine's own page, and the maintenance, corrections and project-moderation queues. The editor's concurrency token is a string, not a Date — `extract(epoch from updated_at)::text`, minted and compared by the same expression. Postgres has microseconds and a JS Date has milliseconds, so a Date comparison matches nothing on Neon while matching forever on PGlite: every save a bogus conflict in production and no test anywhere to say so. A unit, resource or photo write touches its tool in the same transaction, so the tool is the token for the whole panel, and a refused child write rolls that touch back rather than spending every open panel's token over a write that never happened. Every server action checks its own permission through one shared gate — a server action is a POST endpoint with a generated name, reachable without the page that offers the control — and each queue's test proves it is its own permission by granting only the adjacent ones. An audit write that fails after its change committed stays a warning on a success, through the channel /admin/users already had rather than a second one. Closing the seams between the four parts: - Migration 0004 writes the three `user.id` foreign keys the plan asked for and no part wrote — tools.last_reviewed_by, maintenance_logs.assigned_to_user_id, projects.published_by — all `on delete set null`, sharing one helper with actorColumns(). Phase 5 is the first code to write two of them, so until now an id naming no row went in without complaint. - .admin-shell now supplies the --td-* tokens its .td-* utilities read. Those tokens were scoped to .tool-detail, and an unresolvable var() computes to `unset` rather than falling back: the amber "saved, but the audit log did not record it" line was rendering the colour of body text. - A resource's lost PDF raises files_not_attached instead of reporting itself as a lost photo; the queues' refusal codes are declared once rather than twice; markToolReviewed names its permission the same way its siblings do. E2E covers the flow the phase exists for: a SuperMaker at a 390px viewport marks a unit out of service from the machine's own page and the public tool page stops saying it is available. That last step is the cache-tag seam nothing smaller could reach — the test was confirmed red by pointing invalidateCatalog() at the wrong tag, where it failed with the page still reading "Available". Gate from v5/ with every environment variable unset: lint 0 errors (3 pre-existing warnings), typecheck clean, 137 files / 1711 tests, 67 Playwright tests, spec:coverage 73 items / 0 undocumented, and a build in which /tools/[id] and all five /admin routes are still Partial Prerender. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE --- .../2026-09-14-v5-data-platform-design.md | 155 ++ v5/AGENTS.md | 132 +- v5/e2e/admin-inventory.spec.ts | 98 + v5/e2e/admin-queues.spec.ts | 150 ++ v5/e2e/tool-editor.spec.ts | 201 ++ v5/messages/en.json | 272 +- v5/src/app/admin/corrections/action-result.ts | 33 + v5/src/app/admin/corrections/actions.test.ts | 202 ++ v5/src/app/admin/corrections/actions.ts | 48 + v5/src/app/admin/corrections/page.tsx | 52 + v5/src/app/admin/inventory/action-result.ts | 67 + .../admin/inventory/actions.conflict.test.ts | 161 ++ v5/src/app/admin/inventory/actions.test.ts | 277 ++ v5/src/app/admin/inventory/actions.ts | 114 + v5/src/app/admin/inventory/page.tsx | 118 + .../app/admin/inventory/photo-actions.test.ts | 207 ++ v5/src/app/admin/inventory/photo-actions.ts | 60 + .../admin/inventory/resource-actions.test.ts | 235 ++ .../app/admin/inventory/resource-actions.ts | 70 + .../app/admin/inventory/tool-write-context.ts | 76 + .../app/admin/inventory/unit-actions.test.ts | 210 ++ v5/src/app/admin/inventory/unit-actions.ts | 67 + v5/src/app/admin/maintenance/action-result.ts | 65 + v5/src/app/admin/maintenance/actions.test.ts | 200 ++ v5/src/app/admin/maintenance/actions.ts | 53 + v5/src/app/admin/maintenance/page.tsx | 64 + v5/src/app/admin/page.tsx | 49 +- v5/src/app/admin/projects/action-result.ts | 33 + v5/src/app/admin/projects/actions.test.ts | 209 ++ v5/src/app/admin/projects/actions.ts | 73 + v5/src/app/admin/projects/page.tsx | 53 + v5/src/app/admin/users/action-result.ts | 38 +- v5/src/app/admin/users/actions.ts | 138 +- v5/src/app/api/admin/revalidate/route.ts | 12 +- v5/src/app/tools/[id]/DraftToolView.test.tsx | 110 + v5/src/app/tools/[id]/DraftToolView.tsx | 38 + .../app/tools/[id]/EditToolControl.test.tsx | 84 + v5/src/app/tools/[id]/EditToolControl.tsx | 74 + v5/src/app/tools/[id]/page.tsx | 70 +- .../components/admin/CorrectionControls.tsx | 74 + .../admin/CorrectionsQueue.test.tsx | 116 + v5/src/components/admin/CorrectionsQueue.tsx | 128 + .../admin/InventoryFilters.test.tsx | 199 ++ v5/src/components/admin/InventoryFilters.tsx | 248 ++ .../components/admin/InventoryTable.test.tsx | 138 + v5/src/components/admin/InventoryTable.tsx | 189 ++ .../admin/MaintenanceQueue.test.tsx | 188 ++ v5/src/components/admin/MaintenanceQueue.tsx | 139 + v5/src/components/admin/PhotoEditor.test.tsx | 109 + v5/src/components/admin/PhotoEditor.tsx | 151 ++ v5/src/components/admin/ProjectQueue.test.tsx | 115 + v5/src/components/admin/ProjectQueue.tsx | 142 + v5/src/components/admin/PublishToggle.tsx | 64 + .../components/admin/ResourcesEditor.test.tsx | 135 + v5/src/components/admin/ResourcesEditor.tsx | 188 ++ v5/src/components/admin/RowStatus.tsx | 49 + v5/src/components/admin/TicketControls.tsx | 172 ++ .../components/admin/ToolEditorPanel.test.tsx | 413 +++ v5/src/components/admin/ToolEditorPanel.tsx | 430 +++ .../components/admin/ToolFieldsForm.test.tsx | 113 + v5/src/components/admin/ToolFieldsForm.tsx | 296 +++ .../admin/ToolStateControls.test.tsx | 100 + v5/src/components/admin/ToolStateControls.tsx | 120 + v5/src/components/admin/UnitsEditor.test.tsx | 108 + v5/src/components/admin/UnitsEditor.tsx | 229 ++ .../components/admin/UnlinkedUnits.test.tsx | 49 + v5/src/components/admin/UnlinkedUnits.tsx | 55 + .../admin/inventory-filters.test.ts | 131 + v5/src/components/admin/inventory-filters.ts | 130 + .../components/admin/tool-editor-actions.ts | 86 + v5/src/components/admin/upload-file.ts | 82 + v5/src/components/admin/use-row-action.ts | 89 + v5/src/lib/admin/action-gate.test.ts | 88 + v5/src/lib/admin/action-gate.ts | 55 + v5/src/lib/admin/action-result.ts | 39 + v5/src/lib/admin/audit-warning.test.ts | 67 + v5/src/lib/admin/audit-warning.ts | 63 + v5/src/lib/admin/queue-write.ts | 93 + v5/src/lib/data/attachments.test.ts | 93 + v5/src/lib/data/attachments.ts | 114 +- v5/src/lib/data/feedback.test.ts | 109 +- v5/src/lib/data/feedback.ts | 163 +- v5/src/lib/data/inventory.test.ts | 344 +++ v5/src/lib/data/inventory.ts | 361 +++ v5/src/lib/data/maintenance.test.ts | 180 ++ v5/src/lib/data/maintenance.ts | 258 +- v5/src/lib/data/pg-errors.ts | 29 + v5/src/lib/data/projects.test.ts | 115 + v5/src/lib/data/projects.ts | 169 +- v5/src/lib/data/rank.test.ts | 68 + v5/src/lib/data/rank.ts | 33 + v5/src/lib/data/resources.test.ts | 119 +- v5/src/lib/data/resources.ts | 221 ++ v5/src/lib/data/revision.test.ts | 136 + v5/src/lib/data/revision.ts | 70 + v5/src/lib/data/taxonomy.test.ts | 64 + v5/src/lib/data/taxonomy.ts | 74 + v5/src/lib/data/tool-editor.test.ts | 116 + v5/src/lib/data/tool-editor.ts | 86 + v5/src/lib/data/tools.test.ts | 278 ++ v5/src/lib/data/tools.ts | 374 +++ v5/src/lib/data/units.test.ts | 225 ++ v5/src/lib/data/units.ts | 262 ++ v5/src/lib/data/users.test.ts | 32 +- v5/src/lib/data/users.ts | 33 +- v5/src/lib/data/write-result.ts | 41 + v5/src/lib/db/demo-seed.test.ts | 23 + v5/src/lib/db/demo-seed.ts | 127 +- .../db/migrations/0004_user_foreign_keys.sql | 24 + .../lib/db/migrations/meta/0004_snapshot.json | 2318 +++++++++++++++++ v5/src/lib/db/migrations/meta/_journal.json | 9 +- v5/src/lib/db/schema/helpers.ts | 20 + v5/src/lib/db/schema/maintenance.ts | 4 +- v5/src/lib/db/schema/projects.ts | 4 +- v5/src/lib/db/schema/tools.ts | 4 +- v5/src/lib/db/schema/user-references.test.ts | 144 + v5/src/lib/inventory/photo-edits.test.ts | 189 ++ v5/src/lib/inventory/photo-edits.ts | 143 + v5/src/lib/inventory/resource-edits.test.ts | 134 + v5/src/lib/inventory/resource-edits.ts | 110 + v5/src/lib/inventory/result.ts | 64 + v5/src/lib/inventory/tool-edits.test.ts | 86 + v5/src/lib/inventory/tool-edits.ts | 53 + v5/src/lib/inventory/tool-state.audit.test.ts | 134 + v5/src/lib/inventory/tool-state.test.ts | 187 ++ v5/src/lib/inventory/tool-state.ts | 166 ++ v5/src/lib/inventory/tool-transaction.ts | 91 + v5/src/lib/inventory/unit-edits.test.ts | 175 ++ v5/src/lib/inventory/unit-edits.ts | 89 + v5/src/lib/revalidate.test.ts | 39 + v5/src/lib/revalidate.ts | 50 + v5/src/styles/globals.css | 778 ++++++ 132 files changed, 18877 insertions(+), 203 deletions(-) create mode 100644 v5/e2e/admin-inventory.spec.ts create mode 100644 v5/e2e/admin-queues.spec.ts create mode 100644 v5/e2e/tool-editor.spec.ts create mode 100644 v5/src/app/admin/corrections/action-result.ts create mode 100644 v5/src/app/admin/corrections/actions.test.ts create mode 100644 v5/src/app/admin/corrections/actions.ts create mode 100644 v5/src/app/admin/corrections/page.tsx create mode 100644 v5/src/app/admin/inventory/action-result.ts create mode 100644 v5/src/app/admin/inventory/actions.conflict.test.ts create mode 100644 v5/src/app/admin/inventory/actions.test.ts create mode 100644 v5/src/app/admin/inventory/actions.ts create mode 100644 v5/src/app/admin/inventory/page.tsx create mode 100644 v5/src/app/admin/inventory/photo-actions.test.ts create mode 100644 v5/src/app/admin/inventory/photo-actions.ts create mode 100644 v5/src/app/admin/inventory/resource-actions.test.ts create mode 100644 v5/src/app/admin/inventory/resource-actions.ts create mode 100644 v5/src/app/admin/inventory/tool-write-context.ts create mode 100644 v5/src/app/admin/inventory/unit-actions.test.ts create mode 100644 v5/src/app/admin/inventory/unit-actions.ts create mode 100644 v5/src/app/admin/maintenance/action-result.ts create mode 100644 v5/src/app/admin/maintenance/actions.test.ts create mode 100644 v5/src/app/admin/maintenance/actions.ts create mode 100644 v5/src/app/admin/maintenance/page.tsx create mode 100644 v5/src/app/admin/projects/action-result.ts create mode 100644 v5/src/app/admin/projects/actions.test.ts create mode 100644 v5/src/app/admin/projects/actions.ts create mode 100644 v5/src/app/admin/projects/page.tsx create mode 100644 v5/src/app/tools/[id]/DraftToolView.test.tsx create mode 100644 v5/src/app/tools/[id]/DraftToolView.tsx create mode 100644 v5/src/app/tools/[id]/EditToolControl.test.tsx create mode 100644 v5/src/app/tools/[id]/EditToolControl.tsx create mode 100644 v5/src/components/admin/CorrectionControls.tsx create mode 100644 v5/src/components/admin/CorrectionsQueue.test.tsx create mode 100644 v5/src/components/admin/CorrectionsQueue.tsx create mode 100644 v5/src/components/admin/InventoryFilters.test.tsx create mode 100644 v5/src/components/admin/InventoryFilters.tsx create mode 100644 v5/src/components/admin/InventoryTable.test.tsx create mode 100644 v5/src/components/admin/InventoryTable.tsx create mode 100644 v5/src/components/admin/MaintenanceQueue.test.tsx create mode 100644 v5/src/components/admin/MaintenanceQueue.tsx create mode 100644 v5/src/components/admin/PhotoEditor.test.tsx create mode 100644 v5/src/components/admin/PhotoEditor.tsx create mode 100644 v5/src/components/admin/ProjectQueue.test.tsx create mode 100644 v5/src/components/admin/ProjectQueue.tsx create mode 100644 v5/src/components/admin/PublishToggle.tsx create mode 100644 v5/src/components/admin/ResourcesEditor.test.tsx create mode 100644 v5/src/components/admin/ResourcesEditor.tsx create mode 100644 v5/src/components/admin/RowStatus.tsx create mode 100644 v5/src/components/admin/TicketControls.tsx create mode 100644 v5/src/components/admin/ToolEditorPanel.test.tsx create mode 100644 v5/src/components/admin/ToolEditorPanel.tsx create mode 100644 v5/src/components/admin/ToolFieldsForm.test.tsx create mode 100644 v5/src/components/admin/ToolFieldsForm.tsx create mode 100644 v5/src/components/admin/ToolStateControls.test.tsx create mode 100644 v5/src/components/admin/ToolStateControls.tsx create mode 100644 v5/src/components/admin/UnitsEditor.test.tsx create mode 100644 v5/src/components/admin/UnitsEditor.tsx create mode 100644 v5/src/components/admin/UnlinkedUnits.test.tsx create mode 100644 v5/src/components/admin/UnlinkedUnits.tsx create mode 100644 v5/src/components/admin/inventory-filters.test.ts create mode 100644 v5/src/components/admin/inventory-filters.ts create mode 100644 v5/src/components/admin/tool-editor-actions.ts create mode 100644 v5/src/components/admin/upload-file.ts create mode 100644 v5/src/components/admin/use-row-action.ts create mode 100644 v5/src/lib/admin/action-gate.test.ts create mode 100644 v5/src/lib/admin/action-gate.ts create mode 100644 v5/src/lib/admin/action-result.ts create mode 100644 v5/src/lib/admin/audit-warning.test.ts create mode 100644 v5/src/lib/admin/audit-warning.ts create mode 100644 v5/src/lib/admin/queue-write.ts create mode 100644 v5/src/lib/data/inventory.test.ts create mode 100644 v5/src/lib/data/inventory.ts create mode 100644 v5/src/lib/data/pg-errors.ts create mode 100644 v5/src/lib/data/rank.test.ts create mode 100644 v5/src/lib/data/rank.ts create mode 100644 v5/src/lib/data/revision.test.ts create mode 100644 v5/src/lib/data/revision.ts create mode 100644 v5/src/lib/data/taxonomy.test.ts create mode 100644 v5/src/lib/data/taxonomy.ts create mode 100644 v5/src/lib/data/tool-editor.test.ts create mode 100644 v5/src/lib/data/tool-editor.ts create mode 100644 v5/src/lib/data/tools.test.ts create mode 100644 v5/src/lib/data/tools.ts create mode 100644 v5/src/lib/data/units.test.ts create mode 100644 v5/src/lib/data/units.ts create mode 100644 v5/src/lib/data/write-result.ts create mode 100644 v5/src/lib/db/migrations/0004_user_foreign_keys.sql create mode 100644 v5/src/lib/db/migrations/meta/0004_snapshot.json create mode 100644 v5/src/lib/db/schema/user-references.test.ts create mode 100644 v5/src/lib/inventory/photo-edits.test.ts create mode 100644 v5/src/lib/inventory/photo-edits.ts create mode 100644 v5/src/lib/inventory/resource-edits.test.ts create mode 100644 v5/src/lib/inventory/resource-edits.ts create mode 100644 v5/src/lib/inventory/result.ts create mode 100644 v5/src/lib/inventory/tool-edits.test.ts create mode 100644 v5/src/lib/inventory/tool-edits.ts create mode 100644 v5/src/lib/inventory/tool-state.audit.test.ts create mode 100644 v5/src/lib/inventory/tool-state.test.ts create mode 100644 v5/src/lib/inventory/tool-state.ts create mode 100644 v5/src/lib/inventory/tool-transaction.ts create mode 100644 v5/src/lib/inventory/unit-edits.test.ts create mode 100644 v5/src/lib/inventory/unit-edits.ts create mode 100644 v5/src/lib/revalidate.test.ts create mode 100644 v5/src/lib/revalidate.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 35f3bc8..c670771 100644 --- a/docs/specs/2026-09-14-v5-data-platform-design.md +++ b/docs/specs/2026-09-14-v5-data-platform-design.md @@ -1361,3 +1361,158 @@ permission, so an anonymous caller can make the server read up to 18 MB before i stored and no URL is returned, and the 15/min-per-IP limiter is what bounds it. **Status.** Accepted. §3.4 is no longer open. + +### 2026-09-22 — Phase 5 part 4 built (the three queues), with as-built details + +**What changed.** §5.6 is implemented, and with it the Phase 5 row of §9 that made the lab +runnable on this app rather than on Notion. Three pages, each gated on its own permission and each +refusing in words rather than a 404: `/admin/maintenance` (`maintenance.manage`), +`/admin/corrections` (`feedback.manage`) and `/admin/projects` (`projects.moderate`). `/admin` +now lists exactly the surfaces the viewer's permissions open, from one table rather than a stack of +conditionals, so a page added without an entry is simply unreachable. + +**All three tables needed new read paths, which the brief did not expect.** `feedback` was +insert-only — there was no read of the table anywhere in the app. `maintenance.ts` read one unit's +history and nothing else. Every read in `projects.ts` filtered `published = true`. So +`listMaintenanceQueue`, `listFeedbackQueue` and `listProjectsForModeration` are genuinely new, and +the last of them is the only read in that module that can see an unpublished row. + +**The queues do not carry a revision token, and that is a decision rather than an omission.** The +tool editor's optimistic concurrency exists because its panel is a form full of typing that a +conflict would discard. A queue control is a single click on a single field; there is nothing to +lose to a concurrent write but a value somebody can set again, and a conflict dialogue on a queue +somebody is trying to clear in ten minutes costs more than it protects. `runQueueWrite` +(`src/lib/admin/queue-write.ts`) is therefore the queue counterpart of the editor's +`tool-write-context.ts` and deliberately not the same helper. + +**Only the project queue audits, and only it invalidates.** Publishing decides what the public +gallery shows, so `setPublished` writes `project.published` / `project.unpublished` and calls +`invalidateProjects()`. A lost audit event there is still `{ ok: true, warning: +"audit_unavailable" }` — the row has already changed, and the island answers a refusal by restoring +the previous value, which would leave the page asserting an unpublished project over a database +that has published it. Maintenance and correction edits write no events at all: §4.11 scopes the +trail to security-relevant actions and says ordinary edits are not logged, and nothing cached reads +either table. + +**`published_at` and `published_by` describe the current publication, not the history**, so +unpublishing clears both. `audit_events` is the history, and it is append-only by construction. The +alternative — leaving a stamp on a row that is not published — gives the columns two meanings, and +the one a reader would guess is the wrong one. + +**`date_resolved` is computed in SQL, from `labToday()`**, as `coalesce(date_resolved, )` +when a ticket reaches `resolved` or `closed`, and null when it is reopened. §4.8's rule about +`LAB_TIMEZONE` is not pedantic: a ticket closed at nine on a Tuesday evening in New York is +Wednesday in UTC, and a day staff would not find it under. The `coalesce` keeps the first +resolution date rather than moving it every time somebody edits the note afterwards. + +**One read selects a reporter's email now, and exactly one.** `listMaintenanceQueue` and +`listFeedbackQueue` carry `reported_by_email` / `reporter_email`, because the first thing an admin +does with a confusing ticket is ask the person who filed it, and a queue that shows a name they +cannot reach sends them back to their inbox to guess. `listMaintenanceHistoryForUnit` still does +not select the column at all — its rows reach a model prompt and the Notion mirror (§8). Which +function a caller picks is the whole of that decision, which is why they are two functions rather +than one with a flag, and why `maintenance.ts`'s module docstring now says so. + +**Each queue's test proves the action checks *its own* permission.** No role holds `tools.edit` +without also holding `feedback.manage`, so a test signed in as an `admin` cannot tell a correct +gate from one that checks the wrong declaration. Each `actions.test.ts` therefore mocks `can()` for +one case, grants `tools.edit` and `tools.publish` only, and asserts the endpoint refuses — then +grants the surface's own permission and asserts it succeeds. Every other test in those files runs +against the real `can()`. + +**Ordering comes from the vocabulary constants, with one reversal.** `MAINTENANCE_STATUS` and +`FEEDBACK_STATUS` are declared in the order work moves through them, so ranking by index is the +queue order and a value added later sorts where it was declared. `MAINTENANCE_PRIORITY` is declared +*ascending* in severity, so it is reversed — ranking it as declared put the low-priority tickets at +the top, which a test caught. + +**The demo seed gained one row per queue** — an open ticket against the Trotec's unit, a correction +about the Form 4's materials, and an unpublished project — so each page has something real to show +and `e2e/admin-queues.spec.ts` has something to assert on. It deliberately did **not** gain a draft +tool, which the plan asked for: `e2e/admin-inventory.spec.ts` asserts that both seeded tools are +published and that `?state=draft` therefore empties the table. + +**Still outstanding from the Phase 5 plan.** Migration `0004`, which was to add the `user.id` +foreign keys on `tools.last_reviewed_by` (§4.4), `projects.published_by` (§4.10) and +`maintenance_logs.assigned_to_user_id` (§4.8), was not written in any of the four parts. All three +columns are still bare `text` while the spec says they are foreign keys, and Phase 5 is the first +code to write two of them. Every existing row is null, so the ALTERs remain safe; it wants one +migration and a `db:generate` whose output is read before it is committed. *(Written at the +integration gate — see the amendment below.)* + +**The gate, with every environment variable unset:** `npm run lint` (0 errors, 3 pre-existing +warnings), `npm run typecheck`, `npx vitest run` — **136 files / 1707 tests**, `npx playwright +test` — **65 passed**, `npm run spec:coverage` — 73 items, 0 undocumented, and `npm run build`, +which still reports `/tools/[id]` as Partial Prerender. + +**Status.** Accepted. + +### 2026-09-22 — Phase 5 integrated, four seams closed and migration `0004` written + +Four agents built Phase 5 in parallel — the write layer, the review table, the editor panel, the +three queues — and each ran the gate green on its own. This amendment records what only showed up +when the four were read together, and what the gate found. + +**Migration `0004` exists.** The three `user.id` foreign keys the plan asked for and no part +wrote — `tools.last_reviewed_by` (§4.4), `maintenance_logs.assigned_to_user_id` (§4.8) and +`projects.published_by` (§4.10) — are in `src/lib/db/migrations/0004_user_foreign_keys.sql`, three +`ALTER TABLE … ADD CONSTRAINT` statements and nothing else (the generated SQL was read before it +was committed, which is what that instruction was for). All three are `on delete set null`, so +removing a person never removes the work: the review date, the assignee's name snapshot and +`published_at` all survive the account that made them, and `audit_events` holds who. The three +columns now share one helper, `userReference()` in `schema/helpers.ts`, beside the `actorColumns()` +that already did this — a fourth spelling of the same foreign key was the thing to avoid. +`schema/user-references.test.ts` asserts both halves against PGlite: the key refuses an id naming +nobody, and a deleted account nulls the column rather than taking the row with it. + +**The `/admin` pages were rendering with an unresolvable palette.** Every admin surface uses the +tool-detail utilities — `.td-panel`, `.td-eyebrow`, `.td-empty`, `.td-prose` — and +`.admin-row-status.is-warning` reads `--td-warning`. Those *rules* are global; their *tokens* are +scoped to `.tool-detail`. An unresolvable `var()` does not fall back to anything: the declaration +computes to `unset`, so a panel's border came out `currentColor` and the amber warning line came +out the colour of body text — the one line on the page whose whole job is to be noticed, silently +not being noticeable. `.admin-shell` now supplies the tokens, mapped onto the global theme tokens +so light and dark follow, with `--td-warning` the only one needing a pair of its own. Part 2 found +this and correctly left it as out of its scope; it was nobody's part and belonged to the gate. + +**A resource's lost PDF no longer reports itself as a lost photo.** Part 3 made `addResource` +raise a warning on a file shortfall — right, since silently creating a manual with no manual is the +quiet lie Article 4 is about — but reused `photos_not_attached`, whose message is "some photos did +not attach". Told about a manual that sends somebody to the Photos section looking for a file that +was never there. `files_not_attached` is now a code of its own, because these codes *are* message +keys and the message is the only reason to raise one. + +**Two smaller drifts, each a value declared twice.** The three queue surfaces each spelled their +refusal codes once in an exported `…ActionError` alias and again inside `QueueActionResult<…>`; +they now derive from one `…WriteError` declaration, so the union a page renders and the union its +action answers cannot drift apart. And `markToolReviewed` said `withToolWrite("tools.edit", …)` +where every other edit on the surface says `withToolEdit(…)` — one action quietly gating on +another's permission is exactly what a second spelling buys. + +**The §10 scenario that needed a browser is covered.** `e2e/tool-editor.spec.ts` gained the flow +this phase exists for: a SuperMaker at a 390px viewport opens the sheet on a machine's own page, +marks its unit out of service in one gesture, and **the public tool page stops saying the machine +is available**. That last step is a seam no unit test could reach — the catalogue is cached behind +`cacheTag("catalog")`, the write busts it from `src/lib/revalidate.ts`, and a tag that did not +match would fail silently: no error, no failing test, a student walking to a dead machine. The test +was verified to have teeth by pointing `invalidateCatalog()` at the wrong tag, at which point it +failed with the page still reading "Available". It is `serial`, it touches the Trotec's unit status +(a field no other spec reads), and it puts the value back; the suite's one database and its +parallel workers leave no other honest way to write in E2E until the seed carries a row nothing +asserts on. + +**The gate, run from `v5/` with every environment variable unset:** `npm run test:all` — lint 0 +errors and the same 3 pre-existing warnings, `tsc --noEmit` clean, **137 files / 1711 tests** +passed, **67 Playwright tests** passed with none flaky; `npm run spec:coverage` — 73 items, 0 +undocumented; `npm run build` — succeeds, `/tools/[id]` and all five `/admin` routes still Partial +Prerender. + +**Still not built, and deliberately.** `markReviewed` writes no audit event: §4.11 scopes the trail +to security-relevant actions and `AUDIT_ACTIONS` has no `tool.reviewed`, so adding one is a spec +change rather than an implementation detail. `projects.author_user_id`, `feedback.reporter_user_id` +and `maintenance_logs.reported_by_user_id` are still bare `text` — §4.10 calls the first of them a +foreign key, and `0004` deliberately covered only the three the Phase 5 plan named. And the demo +seed still has no draft or archived tool, so no E2E exercises publish, archive or restore in a +browser; each is covered against PGlite in `src/app/admin/inventory/actions.test.ts`. + +**Status.** Accepted. diff --git a/v5/AGENTS.md b/v5/AGENTS.md index f7dae9d..389f1f1 100644 --- a/v5/AGENTS.md +++ b/v5/AGENTS.md @@ -138,6 +138,14 @@ approval. Do not mint one. - **`created_by` / `updated_by` now reference `user.id`** (`on delete set null`), the foreign keys Phase 1 deferred. A write whose author is not a row is refused — correct, because in production that id comes from a session. +- **So do the three other columns that name a person**, as of migration `0004`: + `tools.last_reviewed_by`, `maintenance_logs.assigned_to_user_id` and + `projects.published_by`, which Phase 5 is the first code to write. They share + `userReference()` in `src/lib/db/schema/helpers.ts` with `actorColumns()` — a + fourth spelling of the same foreign key is the thing to avoid. All are + `on delete set null`: removing a person must never remove the work, which is + why `last_reviewed_at`, `assigned_to_name` and `published_at` are worth + keeping beside them. They are what is left when the account goes. ## The admin surface (`/admin`) @@ -172,7 +180,70 @@ Phase 5 extends both. The shape it sets: value and show `admin.warnings.` in `.admin-row-status.is-warning`. **Never answer `{ ok: false }` for a write that landed** — both islands respond to a refusal by restoring the previous value, which would then assert - a state the database does not hold. Phase 5's admin writes should reuse this. + a state the database does not hold. Phase 5 reuses this rather than repeating + it: `record` and `warn` now live in `src/lib/admin/audit-warning.ts`, and the + codes every admin surface shares in `src/lib/admin/action-result.ts`. +- **`/admin/inventory` is the review table, and it is not the catalogue.** It + lists every tool including drafts and archived ones, with the flags a review + runs on — no photo, no manual, open tickets, never reviewed — computed in SQL + by `src/lib/data/inventory.ts` in five statements whatever the size of the + inventory. An *archived* tool carries no flags: archiving is one of the three + outcomes of a review, so settled equipment stays out of the queue. Units that + belong to no tool come back as their own list rather than being attached to a + guessed tool. +- **The filters are client-side and in the URL, both on purpose.** The server + renders every row and `InventoryFilters` narrows them in the browser (the + `GalleryShell` idiom), so a facet costs no round trip; it then writes the + filters back with `history.replaceState`, so "every tool with no manual" is a + link somebody can send and the Back button still points where the reviewer + came from. `inventory-filters.ts` is the directive-free sibling both the page + and the island import — it owns which values a URL may carry, and drops any + it does not offer. An empty table names the filter that emptied it; "no + results" on its own tells a reviewer nothing (§6). +- **Editing inventory is optimistic, and its token is a string.** The tool + editor reads a revision when it opens and hands it back with the save; the + write happens only if `tools.updated_at` has not moved. The token is + `extract(epoch from updated_at)::text`, computed and compared by Postgres, + **never a JavaScript `Date`** — `now()` has microsecond resolution and a + `Date` has milliseconds, so a `Date` comparison matches nothing on Neon while + matching forever on PGlite. See `src/lib/data/revision.ts`. A unit, resource + or photo edit touches the tool row in the same transaction, so the tool is the + token for the whole panel; a *refused* child write rolls that touch back. +- **The tool editor is one panel offered from two places.** + `ToolEditorPanel` opens as a side panel from a row of the review table and as + a **full-screen sheet over a tool's own page** (§5.3(b)) — the phone-first + case, because a SuperMaker marking a printer out of service is standing next + to the machine. `EditToolControl` is what offers it there: it asks + `/api/identity` *after mount*, like `AdminLink`, so the cached tool page stays + cached for everyone who is not staff. +- **A conflict never costs anybody their typing.** The panel keeps the unsaved + edits, says so inline (never a modal — §6), and **Reload** fetches the newer + version and shows the other person's value beside every field they disagree + on, with a control that takes it. The fields form is *rebased*, never + synchronised: nothing copies fresh server values over a box somebody is + typing in, so the panel remounts it with a new `key` after a save it knows + landed. It also sends **only the fields that changed**, because a patch + carrying every field would overwrite an edit the revision check cannot see. +- **Every editor action is gated by `authorizeAdminAction`** + (`src/lib/admin/action-gate.ts`): identity, then the limiter, then the one + permission it needs — the sequence `/admin/users` established, now shared and + parameterised. Editing is `tools.edit`; publish, unpublish, archive and + restore are `tools.publish`. Both are `admin` today, so the split costs + nothing and makes "SuperMakers may add tools but not publish them" a one-line + change. `canPublish` hides those four controls; hiding is presentation. +- **Drafts are reachable at their slug only with `catalog.view_drafts`, and the + refusal is a 404.** `getCatalogTool` is `"use cache"` and published-only and + cannot see the caller, so a miss renders `DraftToolView` inside its own + Suspense boundary — an async child that reads headers, checks the permission + and calls `notFound()` for everyone else. Keeping that read inside the + boundary is what leaves every *published* tool page prerenderable under + `cacheComponents` (`npm run build` is the check); a different-looking refusal + would confirm the draft exists. +- **With no `BLOB_READ_WRITE_TOKEN` the panel says photos cannot be added and + stays usable for everything else.** `POST /api/uploads` answers 503, the + Photos and Resources sections show that sentence, and reordering, removal and + every text field keep working — a deployment with no Blob store is still one + where a wrong description is worth fixing (Article 4). - **Two things cannot be undone, so they cannot be done.** An address in `AUTH_SUPER_ADMIN_EMAILS` cannot be demoted or banned, and the last unbanned `super_admin` cannot be demoted. The table disables those rows with @@ -195,6 +266,41 @@ Phase 5 extends both. The shape it sets: `sign_in_required` to an anonymous caller, the byline comes from the session and the typed-name field is gone. Browsing, chatting and reporting a problem are all still anonymous. +- **The three queues are what the lab actually runs on** (§5.6). + `/admin/maintenance` (`maintenance.manage`), `/admin/corrections` + (`feedback.manage`) and `/admin/projects` (`projects.moderate`) are the + surfaces that replace working tickets in Notion. Each is a card list rather + than a table, because every row carries prose somebody typed; each puts the + open work on the page and folds the settled work behind a disclosure, because + the person using these has twenty tickets and ten minutes; and each one's + control **saves on the click**, with `useRowAction` giving all of them the + same contract (optimistic, a refusal restores, a warning keeps). The shared + preamble is `src/lib/admin/queue-write.ts` — gate, write, record, refresh, + each step only as far as the last one earned. +- **Each queue checks its own permission, and a test proves it is its own.** No + role holds `tools.edit` without `feedback.manage`, so each `actions.test.ts` + mocks `can()` for one case and asserts the endpoint is refused to a caller + holding the *adjacent* permission. That is the only way to catch an action + that gates on the wrong declaration. +- **Only the project one audits, and only it invalidates.** Publishing decides + what the public gallery shows, so it writes `project.published` / + `project.unpublished` and calls `invalidateProjects()` — and a lost audit + event is still `{ ok: true, warning: "audit_unavailable" }`, never a failure. + Maintenance and correction edits are ordinary edits, which §4.11 says are + deliberately not logged, and nothing cached reads either table. +- **A correction is one click from the field it corrects.** The row links to + the tool's own page — where the field is shown and where `EditToolControl` + opens the editor — not to `/admin/inventory`, which would land the reviewer + in a table they then have to search. +- **`published_at` / `published_by` describe the current publication, not the + history**, so unpublishing clears both. The history is `audit_events`. +- **One read selects a reporter's email, on purpose.** `listMaintenanceQueue` + and `listFeedbackQueue` carry `reported_by_email` / `reporter_email`, because + the first thing an admin does with a confusing ticket is ask the person who + filed it. `listMaintenanceHistoryForUnit` still does not select it at all — + its rows reach a model prompt and the mirror (§8). Which function a caller + picks is the whole of that decision, which is why they are two functions and + not one with a flag. ## Key files @@ -203,7 +309,19 @@ Phase 5 extends both. The shape it sets: | `src/lib/site-config.ts` | White-label branding (env-driven, all have defaults) | | `src/lib/db/client.ts` | `getDb()`, `dataSubstrate()`, `pingDb()` — the one entry point to Postgres/PGlite | | `src/lib/notion.ts` | Notion API client — read by the one-time import and by intake's `create_tool` (Phase 6); no other write and no request path reads it | -| `src/lib/data/attachments.ts` | `attachments` rows: create, claim onto an owner, list orphans, delete | +| `src/lib/data/attachments.ts` | `attachments` rows: create, claim onto an owner, reorder, release, list orphans, delete | +| `src/lib/data/revision.ts` | The editor's concurrency token — `extract(epoch from updated_at)::text`, **never a `Date`** (read the docstring before touching a conflict check) | +| `src/lib/data/tools.ts` / `units.ts` | Row-level inventory writes, every one revision-checked. Tools are archived, never deleted | +| `src/lib/data/inventory.ts` | The `/admin/inventory` read — every tool, its state and its needs-attention flags, plus the units that belong to no tool | +| `src/lib/data/taxonomy.ts` | `listCategories()` / `listLocations()` — the two option lists an editing surface needs, the only place these tables are read whole | +| `src/lib/inventory/*` | Those writes composed with cache invalidation and the audit trail — the layer `/admin/inventory`'s server actions call | +| `src/lib/admin/audit-warning.ts` | `record` / `warn` — the shared "a lost audit event is a warning on a success" channel | +| `src/lib/admin/action-gate.ts` | `authorizeAdminAction(permission)` — identity, limiter, permission: the preamble every admin server action runs | +| `src/lib/data/tool-editor.ts` | The editor panel's read — one tool with its units, resources and photos, drafts and retired rows included | +| `src/app/admin/inventory/actions.ts` + `unit-`/`resource-`/`photo-actions.ts` | The editor's server actions, one module per section, each checking its own permission | +| `src/components/admin/ToolEditorPanel.tsx` | The editor itself: the revision token, the conflict, and the five sections beside it | +| `src/app/tools/[id]/EditToolControl.tsx` / `DraftToolView.tsx` | Edit mode on a tool page (phone-first), and drafts at their slug for `catalog.view_drafts` | +| `src/lib/revalidate.ts` | `invalidateCatalog()` / `invalidateProjects()` — the one home for the cache tag strings | | `src/lib/blob.ts` | The Blob seam — `put` (private backups, fixed pathname) and `putUpload` (random pathname, caller's access) | | `src/lib/cron/backup.ts`, `src/lib/cron/cleanup.ts` | The nightly Postgres export and the orphaned-upload sweep | | `src/lib/cron/backup-policy.ts` | What the nightly export holds back — `session` / `verification` skipped, `account` tokens blanked. A backup is data, not credentials | @@ -215,8 +333,13 @@ Phase 5 extends both. The shape it sets: | `src/lib/auth/super-admins.ts` | `AUTH_SUPER_ADMIN_EMAILS`, the lock-out floor | | `src/lib/auth/floor-role.ts` | `reconcileSuperAdminFloor` — writes the floor's role and lifts its ban onto the row, because the admin plugin reads the row and not `can()` | | `src/app/admin/layout.tsx` | The `/admin` front door — signed in? holds an admin permission? | +| `src/app/admin/inventory/page.tsx` | The review table (`tools.edit`), uncached, filtered from the URL | | `src/app/admin/users/actions.ts` | `setUserRole` / `setUserBanned` — the app's first server actions | | `src/lib/data/users.ts` | The `/admin/users` roster, read straight from Postgres | +| `src/lib/admin/queue-write.ts` | `runQueueWrite` — the gate/write/record/refresh preamble the three §5.6 queues share | +| `src/app/admin/maintenance/`, `corrections/`, `projects/` | The three queues: one page, one result module and one action apiece | +| `src/components/admin/use-row-action.ts` | What every queue control does around its action — optimistic, refusal restores, warning keeps | +| `src/components/admin/MaintenanceQueue.tsx` / `CorrectionsQueue.tsx` / `ProjectQueue.tsx` | The three card lists, each with its own small island | | `src/lib/data/audit.ts` | `audit_events` — insert and select, never update or delete | | `src/lib/db/schema/auth.ts` | Better Auth's four tables; property keys are its field names | | `src/lib/types.ts` / `src/components/catalog-types.ts` | Notion record types / resolved view types | @@ -234,6 +357,11 @@ Phase 5 extends both. The shape it sets: - Server components by default; add `"use client"` only when needed. - Server-only modules import `"server-only"` (e.g. `rate-limit.ts`). - Theme/brand via **CSS variables** (`--primary`, `--background`, …) — `[data-theme="light|dark"]` on ``, never hardcoded colors. +- **The `.td-*` utilities are global; their `--td-*` tokens are not.** They are + declared on `.tool-detail`, and `.admin-shell` supplies its own mapped onto + the global theme tokens. Using a `.td-*` class anywhere else means supplying + the tokens there too: an unresolvable `var()` does not fall back, it computes + to `unset`, so the rule fails *silently and wrongly* rather than visibly. - All branding strings come from `siteConfig` (`@/lib/site-config`). - Every API route is **rate-limited by identity** before expensive work — user id when signed in, hashed IP when not. - Authorization is **always** `can(subject, permission)` from `src/lib/auth/permissions.ts`. Never compare role names, and never gate inside a capability tool's `run()`. diff --git a/v5/e2e/admin-inventory.spec.ts b/v5/e2e/admin-inventory.spec.ts new file mode 100644 index 0000000..281ab04 --- /dev/null +++ b/v5/e2e/admin-inventory.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from "@playwright/test"; + +import { DEMO_ACCOUNTS } from "../src/lib/db/demo-seed"; +import { signIn } from "./utils/session"; + +/** + * `/admin/inventory`, end to end (data platform design spec §5.3(a), §6). + * + * This part of Phase 5 builds the review table and its filters; the editor + * panel that opens from a row is the next part, and its scenarios land here + * beside these. What is checked now is the page's own contract: who may open + * it, that it lists the inventory rather than the catalogue, that a filtered + * view is a link, and that an empty table says which filter emptied it. + * + * Nothing here writes, so these tests share the demo database happily with + * every other spec. + */ + +test.describe("/admin/inventory — who may open it", () => { + test("an anonymous visitor is told to sign in, not 404ed", async ({ page }) => { + await page.goto("/admin/inventory"); + + await expect( + page.getByRole("heading", { name: "You are not signed in", level: 1 }) + ).toBeVisible(); + await expect(page.getByRole("table")).toHaveCount(0); + }); + + test("an ordinary student is refused, and told why", async ({ page, context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + await page.goto("/admin/inventory"); + + await expect( + page.getByRole("heading", { name: /do not have access/i, level: 1 }) + ).toBeVisible(); + await expect(page.getByRole("table")).toHaveCount(0); + }); + + test("a SuperMaker holds tools.edit, so the review table opens for them", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/admin"); + + // The index lists exactly what this account's permissions open. + await page.getByRole("link", { name: "Inventory" }).click(); + + // Headroom, the way `admin-users.spec.ts` gives its saves some: this is + // often the first request this server sees for the route, and the table + // streams in behind the layout's Suspense boundary. + await expect(page.getByRole("heading", { name: "Inventory", level: 2 })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("row", { name: /Form 4/ })).toBeVisible(); + await expect(page.getByRole("row", { name: /Trotec Speedy 400/ })).toBeVisible(); + }); +}); + +test.describe("/admin/inventory — filtering", () => { + test.beforeEach(async ({ context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + }); + + test("a filtered view arrives filtered when its link is opened", async ({ page }) => { + await page.goto("/admin/inventory?state=draft"); + + // The demo seed's two tools are both published, so this filter empties the + // table — and the page says which filter did it (§6, States). + await expect(page.getByRole("combobox", { name: "State" })).toHaveValue("draft"); + await expect(page.getByRole("table")).toHaveCount(0); + await expect(page.getByText(/State: Draft/)).toBeVisible(); + }); + + test("changing a filter puts it in the URL, so the view can be sent to somebody", async ({ + page, + }) => { + await page.goto("/admin/inventory"); + await expect(page.getByText("Showing 2 of 2")).toBeVisible(); + + await page.getByRole("combobox", { name: "Needs attention" }).selectOption("never_reviewed"); + + await expect(page).toHaveURL(/\?attention=never_reviewed$/); + // Nothing in the demo seed has ever been reviewed, so both rows stay. + await expect(page.getByText("Showing 2 of 2")).toBeVisible(); + await expect(page.getByRole("row", { name: /Form 4/ })).toBeVisible(); + }); + + test("clearing the filters empties the query string too", async ({ page }) => { + await page.goto("/admin/inventory?attention=no_photo"); + + await page.getByRole("button", { name: "Clear filters" }).click(); + + await expect(page).toHaveURL(/\/admin\/inventory$/); + await expect(page.getByRole("combobox", { name: "Needs attention" })).toHaveValue(""); + }); +}); diff --git a/v5/e2e/admin-queues.spec.ts b/v5/e2e/admin-queues.spec.ts new file mode 100644 index 0000000..4a04bc6 --- /dev/null +++ b/v5/e2e/admin-queues.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from "@playwright/test"; + +import { DEMO_ACCOUNTS } from "../src/lib/db/demo-seed"; +import { signIn } from "./utils/session"; + +/** + * The three queues (data platform design spec §5.6, §9). + * + * The demo seed carries one row for each of them — an open ticket against the + * Trotec, a correction about the Form 4's materials, and a project waiting for + * a decision — so each page here has something real to show. + * + * **Only one test writes**, and it writes a field nothing else in the suite + * reads: the ticket's priority. Every spec shares one PGlite database and the + * workers run in parallel, so publishing the waiting project (which would put + * it in the gallery `projects.spec.ts` counts) or resolving the ticket is left + * to each surface's own `actions.test.ts`, which runs against rows it seeds + * itself. What only a browser can check is the round trip: a server action + * handed down to a client island as a prop, called from a real click, landing + * in Postgres. + */ + +test.describe("who may open each queue", () => { + test("a student is refused all three, and told so rather than 404ed", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + + for (const path of ["/admin/maintenance", "/admin/corrections", "/admin/projects"]) { + await page.goto(path); + await expect( + page.getByRole("heading", { name: /do not have access/i, level: 1 }) + ).toBeVisible(); + } + }); + + test("the admin index lists exactly what this account's permissions open", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/admin"); + + // Scoped to the index's own list: the header's navigation has a Projects + // link of its own, and it means the public gallery. + const surfaces = page.getByRole("list", { name: "Admin pages your account can open" }); + await expect(surfaces.getByRole("link", { name: "Maintenance" })).toBeVisible({ + timeout: 15_000, + }); + await expect(surfaces.getByRole("link", { name: "Corrections" })).toBeVisible(); + await expect(surfaces.getByRole("link", { name: "Projects" })).toBeVisible(); + // A SuperMaker does not hold `users.manage`, so the roster is not offered. + await expect(surfaces.getByRole("link", { name: "People" })).toHaveCount(0); + // Every lede on this page is shared with the page it names, so none of them + // takes an argument — a next-intl placeholder rendered without one renders + // literally (Article 6). + await expect(page.locator("body")).not.toContainText("{"); + }); +}); + +test.describe("the queues have the lab's work in them", () => { + test.beforeEach(async ({ context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + }); + + test("the maintenance queue shows the open ticket and the machine it is about", async ({ + page, + }) => { + await page.goto("/admin/maintenance"); + + await expect(page.getByRole("heading", { name: "Laser bed out of focus" })).toBeVisible({ + timeout: 15_000, + }); + // The unit lives on its tool's page, which is where the link goes. + await expect(page.getByRole("link", { name: "Trotec Speedy 400" })).toHaveAttribute( + "href", + "/tools/trotec-speedy-400" + ); + await expect(page.getByRole("combobox", { name: /^Status for/ })).toHaveValue("open"); + }); + + test("a correction is one click from the field it corrects", async ({ page }) => { + await page.goto("/admin/corrections"); + + await expect(page.getByText(/missing Rigid 10K/)).toBeVisible({ timeout: 15_000 }); + await page.getByRole("link", { name: "Form 4" }).click(); + + // The tool's own page: the field is on it, and so is the editor for + // anybody holding `tools.edit` (§5.3(b)). + await expect(page).toHaveURL(/\/tools\/form-4$/); + await expect(page.getByRole("heading", { name: "Form 4", level: 1 })).toBeVisible(); + }); + + test("the moderation queue shows the whole submission, since it has no page yet", async ({ + page, + }) => { + await page.goto("/admin/projects"); + + await expect(page.getByRole("heading", { name: "Resin dice tower" })).toBeVisible({ + timeout: 15_000, + }); + // Everything a visitor would see, because a visitor cannot see it at all. + await expect(page.getByText(/printed in three parts/)).toBeVisible(); + await expect(page.getByText(/Not in the gallery yet/)).toBeVisible(); + await expect(page.getByRole("button", { name: /^Publish Resin dice tower$/ })).toBeVisible(); + }); +}); + +test.describe("working a ticket", () => { + // One database, parallel workers: the two halves of this have to happen in + // order, and they are the only tests in the suite that touch this field. + test.describe.configure({ mode: "serial" }); + + test("a priority set from the queue survives a reload", async ({ page, context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/admin/maintenance"); + + const priority = page.getByRole("combobox", { name: /^Priority for/ }); + await expect(priority).toHaveValue("high", { timeout: 15_000 }); + + await priority.selectOption("critical"); + // The control confirms from the action's own answer rather than waiting for + // the revalidation behind it — see `use-row-action.ts`. + await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + timeout: 15_000, + }); + + await page.reload(); + await expect(page.getByRole("combobox", { name: /^Priority for/ })).toHaveValue("critical", { + timeout: 15_000, + }); + }); + + test("and is put back, so the seed reads the way the other specs expect", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/admin/maintenance"); + + await page.getByRole("combobox", { name: /^Priority for/ }).selectOption("high"); + await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/v5/e2e/tool-editor.spec.ts b/v5/e2e/tool-editor.spec.ts new file mode 100644 index 0000000..c0b6192 --- /dev/null +++ b/v5/e2e/tool-editor.spec.ts @@ -0,0 +1,201 @@ +import { test, expect } from "@playwright/test"; + +import { DEMO_ACCOUNTS } from "../src/lib/db/demo-seed"; +import { signIn } from "./utils/session"; + +/** + * The tool editor, opened from both surfaces (data platform design spec + * §5.3(3), §5.3(b), §6). + * + * What these check is the thing only a browser can: that the panel opens on + * both surfaces, that its server actions survive the trip through a client + * island as props, and that the control is invisible to everyone who may not + * edit. + * + * **Exactly one describe here writes**, and it is the §10 scenario the whole + * phase is for: a SuperMaker standing at a machine takes it out of service and + * the public page says so. Every spec in this suite shares one PGlite database + * and the workers run in parallel, so that block is `serial`, it touches the + * **Trotec's** unit status — a field no other spec reads, on the tool + * `qr-arrival.spec.ts` does *not* compare two renderings of — and it puts the + * value back when it is done. It never clicks "Looks good": `admin-inventory` + * asserts that nothing in the seed has ever been reviewed. + */ + +test.describe("the editor on /admin/inventory", () => { + test("opens from a row, and reads the tool for itself", async ({ page, context, baseURL }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/admin/inventory"); + + await page + .getByRole("row", { name: /Form 4/ }) + .getByRole("button", { name: "Edit" }) + .click(); + + // The panel mints its own revision on open rather than trusting the page — + // so the fields appearing at all is the load action having answered. + const panel = page.getByRole("complementary", { name: "Editing Form 4" }); + await expect(panel).toBeVisible({ timeout: 15_000 }); + await expect(panel.getByLabel("Name")).toHaveValue("Form 4"); + await expect(panel.getByRole("button", { name: "Looks good" })).toBeVisible(); + + await panel.getByRole("button", { name: "Close" }).click(); + await expect(panel).toHaveCount(0); + // Closing puts the reviewer back on the table they came from. + await expect(page.getByRole("row", { name: /Form 4/ })).toBeVisible(); + }); +}); + +test.describe("edit mode on a tool page", () => { + // Phone-first, because that is where this control is used: somebody standing + // next to the machine with one hand free (§5.3(b)). The viewport alone rather + // than a device preset: a preset changes `defaultBrowserType`, which + // Playwright refuses inside a describe because it forces a second worker. + test.use({ viewport: { width: 390, height: 844 }, isMobile: false }); + + test("a SuperMaker gets a full-screen sheet over the tool's own page", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + await page.goto("/tools/form-4"); + + // The control asks `/api/identity` after mount, so it arrives a beat after + // the page — which is what keeps the page cached for everyone else. + await page.getByRole("button", { name: "Edit this tool" }).click({ timeout: 15_000 }); + + const sheet = page.getByRole("complementary", { name: "Editing Form 4" }); + await expect(sheet).toBeVisible({ timeout: 15_000 }); + await expect(sheet.getByRole("heading", { name: "Units" })).toBeVisible(); + + // Full width at a phone viewport: a panel beside something is not a thing a + // phone has room for. + const width = await sheet.evaluate((node) => node.getBoundingClientRect().width); + const viewport = page.viewportSize(); + expect(width).toBeGreaterThanOrEqual((viewport?.width ?? 0) - 1); + }); + + test("a student sees no way in, and an anonymous visitor sees none either", async ({ + page, + context, + baseURL, + }) => { + await page.goto("/tools/form-4"); + await expect(page.getByRole("button", { name: "Edit this tool" })).toHaveCount(0); + + await signIn(context, DEMO_ACCOUNTS.user, baseURL); + await page.goto("/tools/form-4"); + await expect(page.getByRole("heading", { name: "Form 4", level: 1 })).toBeVisible(); + await expect(page.getByRole("button", { name: "Edit this tool" })).toHaveCount(0); + }); +}); + +/** + * Spec §10, scenario 4 — the flow this phase exists for. + * + * A laser is down. The person who found that out is standing next to it with a + * phone, and three things have to hold for the lab to trust this app over a + * whiteboard: the editor has to be reachable from the machine's own page, the + * write has to land through a client island's prop-passed server action, and + * **the public page has to stop saying the machine is available**. That last + * one is the seam nothing else could test: the catalogue is cached for minutes + * behind `cacheTag("catalog")`, the write busts it with `invalidateCatalog()` + * from a different module, and a tag that did not match would fail silently — + * no error, no failing unit test, just a student walking to a dead machine. + */ +test.describe("taking a machine out of service, from a phone", () => { + // Two halves of one act, and the second is the cleanup the rest of the suite + // depends on. Serial, so the restore cannot be the one that runs first. + test.describe.configure({ mode: "serial" }); + + // The viewport this is actually done at. Not a device preset: a preset sets + // `defaultBrowserType`, which Playwright refuses inside a describe. + test.use({ viewport: { width: 390, height: 844 }, isMobile: false }); + + const TOOL = "/tools/trotec-speedy-400"; + const UNIT_ROW = /ML-LSR-400/; + + /** + * The unit's row in the public page's Physical Machines table. + * + * Scoped to that table rather than to the page: the serial is printed twice + * on a tool page — once here and once in the specifications table as the map + * id a QR label carries — and only one of them has a status beside it. + */ + function publicUnitRow(page: import("@playwright/test").Page) { + return page + .getByRole("table") + .filter({ has: page.getByRole("columnheader", { name: "Condition" }) }) + .getByRole("row", { name: UNIT_ROW }); + } + + /** Open the sheet over the tool's own page and hand back its status select. */ + async function openUnitStatus(page: import("@playwright/test").Page) { + await page.goto(TOOL); + // The control asks `/api/identity` after mount — which is exactly what + // keeps this page cached for the students who are not staff. + await page.getByRole("button", { name: "Edit this tool" }).click({ timeout: 15_000 }); + + const sheet = page.getByRole("complementary", { name: "Editing Trotec Speedy 400" }); + await expect(sheet).toBeVisible({ timeout: 15_000 }); + + return { + sheet, + status: sheet + .getByRole("listitem", { name: "Trotec Speedy 400" }) + .getByRole("combobox", { name: "Status" }), + }; + } + + test("the public page stops saying the laser is available", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + + // Where the catalogue starts: one unit, available, and a tool chip to match. + await page.goto(TOOL); + await expect(publicUnitRow(page)).toContainText("Available", { timeout: 15_000 }); + + const { sheet, status } = await openUnitStatus(page); + await expect(status).toHaveValue("available"); + + // One gesture, no Save button: the reason this section's selects write on + // change is the person holding the phone has one hand free. + await status.selectOption("out_of_service"); + await expect(sheet.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + timeout: 15_000, + }); + // The panel re-read its children with the revision the write returned, so + // the select it hands back is the database's answer, not the click's. + await expect(status).toHaveValue("out_of_service", { timeout: 15_000 }); + + // The point of the whole exercise. A fresh request for the public page: the + // cache tag the write invalidated is the one the page reads under. + await page.goto(TOOL); + const row = publicUnitRow(page); + await expect(row).toContainText("Offline", { timeout: 15_000 }); + await expect(row).not.toContainText("Available"); + // And the tool itself, because its only machine is down (`deriveStatus`). + await expect(page.getByText("Offline").first()).toBeVisible(); + }); + + test("and the laser is put back, so the seed reads the way the suite expects", async ({ + page, + context, + baseURL, + }) => { + await signIn(context, DEMO_ACCOUNTS.admin, baseURL); + + const { sheet, status } = await openUnitStatus(page); + await status.selectOption("available"); + await expect(sheet.getByRole("status").filter({ hasText: "Saved" })).toBeVisible({ + timeout: 15_000, + }); + + await page.goto(TOOL); + await expect(publicUnitRow(page)).toContainText("Available", { timeout: 15_000 }); + }); +}); diff --git a/v5/messages/en.json b/v5/messages/en.json index d51ebcc..d293e88 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -289,8 +289,17 @@ "forbiddenTitle": "You do not have access to this page", "forbiddenBody": "Your account is signed in, but it does not hold the permission this page needs. Ask a super admin if you think it should.", "indexTitle": "Admin surfaces", + "indexListLabel": "Admin pages your account can open", "indexNothingYet": "Nothing here is open to your account yet.", - "indexMoreComing": "Inventory, intake, maintenance and corrections arrive in later phases.", + "indexMoreComing": "Intake and the Notion mirror arrive in later phases.", + "inventoryTitle": "Inventory", + "inventoryLede": "Every tool the lab owns — drafts and archived records included. Needs attention is where a review starts.", + "maintenanceTitle": "Maintenance", + "maintenanceLede": "Every ticket anyone has filed, open ones first. Change the status, hand it to somebody, and write down what fixed it.", + "correctionsTitle": "Corrections", + "correctionsLede": "What people have told us is wrong in the catalog, and the tool each report is about.", + "projectsTitle": "Projects", + "projectsLede": "Student submissions waiting for a decision. Nothing reaches the gallery until somebody publishes it.", "usersTitle": "People", "usersLede": "Everyone who has signed in. A role change takes effect on that person's next request.", "tableLabel": "People and their roles", @@ -314,6 +323,162 @@ "admin": "SuperMaker", "super_admin": "Director" }, + "inventory": { + "tableLabel": "Tools, their state and what each one is missing", + "columnPhoto": "Photo", + "columnTool": "Tool", + "columnCategory": "Category", + "columnLocation": "Location", + "columnUnits": "Units", + "columnState": "State", + "columnReviewed": "Last reviewed", + "columnUpdated": "Last updated", + "noPhoto": "No photo", + "noUnits": "None", + "never": "Never", + "notRecorded": "Not recorded", + "openPublicPage": "Open {name}", + "state": { + "published": "Published", + "draft": "Draft", + "archived": "Archived" + }, + "unitStatus": { + "available": "Available", + "in_use": "In use", + "under_maintenance": "Under maintenance", + "out_of_service": "Out of service", + "retired": "Retired" + }, + "attentionLabel": "Needs attention", + "flags": { + "no_photo": "No photo", + "no_manual": "No manual", + "open_tickets": "Open tickets", + "never_reviewed": "Never reviewed" + }, + "openTicketsWithCount": "{count} open", + "filtersLabel": "Filter the inventory", + "filterSearch": "Search", + "filterSearchPlaceholder": "Name, category or location", + "filterState": "State", + "filterCategory": "Category", + "filterLocation": "Location", + "filterAttention": "Needs attention", + "filterAny": "Any", + "attentionAny": "Anything flagged", + "uncategorized": "Uncategorized", + "unplaced": "No location", + "clearFilters": "Clear filters", + "showing": "Showing {shown} of {total}", + "filterSummaryPart": "{label}: {value}", + "emptyInventory": "No tools yet. Import the workspace, or add equipment from the chat, and every record appears here — drafts included.", + "emptyFiltered": "No tools match {filters}. Clear that filter to see the rest of the inventory.", + "unlinkedTitle": "Units with no tool", + "unlinkedLede": "These machines are recorded but belong to no tool, so nobody can find them in the catalog. Open a tool and add the unit there.", + "unlinkedSerial": "Serial {serial}", + "unlinkedAssetTag": "Asset tag {assetTag}", + "unlinkedUnidentified": "No serial or asset tag recorded", + "editor": { + "panelLabel": "Editing {name}", + "loading": "Loading this tool…", + "close": "Close", + "editThisTool": "Edit this tool", + "saving": "Saving…", + "saved": "Saved", + "reload": "Reload their version", + "sectionFields": "Details", + "sectionUnits": "Units", + "sectionResources": "Manuals and links", + "sectionPhotos": "Photos", + "fieldName": "Name", + "fieldDescription": "Description", + "fieldCategory": "Category", + "fieldLocation": "Location", + "field_materials": "Materials", + "field_ppeRequired": "PPE required", + "field_tags": "Tags", + "fieldTrainingRequired": "Training required before use", + "fieldUseRestrictions": "Use restrictions", + "fieldEmergencyStop": "Emergency stop", + "fieldNotes": "Notes", + "listPlaceholder": "Separate with commas", + "noneSelected": "Not set", + "saveFields": "Save details", + "theirValue": "Their version: {value}", + "useTheirs": "Use theirs", + "empty": "(empty)", + "yes": "Yes", + "no": "No", + "noUnits": "No units recorded. Add the machines this tool covers so tickets and QR labels can name one.", + "addUnit": "Add unit", + "addUnitLabel": "New unit", + "addUnitPlaceholder": "How the desk asks for it, e.g. Form 4 #2", + "unitLabel": "Label", + "unitSerial": "Serial number", + "unitAssetTag": "Asset tag", + "unitDateAcquired": "Acquired", + "unitStatus": "Status", + "unitCondition": "Condition", + "unitConditionUnknown": "Not known", + "saveUnit": "Save unit", + "retireUnit": "Retire", + "deleteUnit": "Delete", + "unitStatusOption": { + "available": "Available", + "in_use": "In use", + "under_maintenance": "Under maintenance", + "out_of_service": "Out of service", + "retired": "Retired" + }, + "unitConditionOption": { + "excellent": "Excellent", + "good": "Good", + "fair": "Fair", + "needs_repair": "Needs repair", + "new": "New" + }, + "noResources": "No manuals or links yet. Add the one a student would need before using this.", + "resourceTitle": "Title", + "resourceType": "Kind", + "resourceTypePlaceholder": "Manual, SOP, guide…", + "resourceUrl": "Link", + "resourceFileField": "PDF", + "resourceFile": "Open the file", + "resourceHidden": "Hidden", + "addResource": "Add", + "hideResource": "Hide", + "showResource": "Show", + "removeResource": "Remove", + "noPhotos": "No photos. The gallery shows a placeholder until somebody takes one.", + "addPhotos": "Add photos", + "coverPhoto": "Cover", + "movePhotoEarlier": "Move earlier", + "movePhotoLater": "Move later", + "removePhoto": "Remove", + "uploading": "Uploading…", + "uploadErrors": { + "unavailable": "Photos and files cannot be added right now — this deployment has no file storage configured. Everything else here still saves.", + "not_permitted": "Your account cannot upload this kind of file.", + "too_large": "That file is too large.", + "wrong_type": "That kind of file cannot be uploaded here. Photos take images; manuals take a PDF.", + "failed": "That upload did not finish. Try again." + }, + "looksGood": "Looks good", + "publish": "Publish", + "unpublish": "Unpublish", + "archive": "Archive", + "restore": "Restore", + "lastReviewed": "Last reviewed {date}", + "neverReviewed": "Never reviewed", + "stateName": { + "published": "Published", + "draft": "Draft", + "archived": "Archived" + } + }, + "edit": "Edit" + }, "errors": { "not_signed_in": "You are not signed in any more. Sign in and try again.", "not_permitted": "Your account does not hold the permission this needs.", @@ -323,10 +488,113 @@ "protected_floor": "This address is protected in the deployment's settings and cannot be demoted or banned.", "last_super_admin": "This is the last director. Promote somebody else first, or nobody could undo it.", "self_ban": "You cannot ban yourself.", + "conflict": "Somebody else changed this while you had it open, so nothing was saved. Your edits are still here — reload to see their version first.", + "not_found": "That no longer exists. Somebody may have removed it while this was open.", + "invalid_field": "One of these values is not one this field accepts. Check it and try again.", + "duplicate_serial": "This tool already has a unit with that serial number.", + "unit_has_history": "This unit has maintenance history, so it cannot be deleted. Retire it instead.", "failed": "That did not save. Nothing was changed." }, "warnings": { - "audit_unavailable": "Saved, but this change could not be written to the audit log. Tell whoever runs the deployment." + "audit_unavailable": "Saved, but this change could not be written to the audit log. Tell whoever runs the deployment.", + "photos_not_attached": "Saved, but some photos did not attach — they may have expired. Upload them again.", + "files_not_attached": "Saved, but the file did not attach — it may have expired. Add the resource's file again." + }, + "maintenance": { + "queueLabel": "Open maintenance tickets", + "empty": "No tickets have been filed. A problem reported from a tool page or from the chat opens one here.", + "emptyOpen": "Nothing is open. Every ticket that has been filed is resolved or closed.", + "settledToggle": "Show {count} resolved and closed", + "noTool": "No tool recorded", + "reportedBy": "Reported by {name}", + "reportedAnonymously": "Reported anonymously", + "reportedOn": "Reported {date}", + "resolvedOn": "Resolved {date}", + "fieldStatus": "Status", + "fieldPriority": "Priority", + "fieldAssignee": "Assigned to", + "fieldResolution": "Resolution", + "resolutionPlaceholder": "What fixed it, for whoever hits this next.", + "saveResolution": "Save resolution", + "unassigned": "Nobody", + "noPriority": "Not set", + "statusFor": "Status for {title}", + "priorityFor": "Priority for {title}", + "assigneeFor": "Assigned to, for {title}", + "resolutionFor": "Resolution for {title}", + "status": { + "open": "Open", + "in_progress": "In progress", + "resolved": "Resolved", + "closed": "Closed" + }, + "priority": { + "low": "Low", + "medium": "Medium", + "high": "High", + "critical": "Critical" + }, + "type": { + "issue_report": "Issue report", + "preventive_maintenance": "Preventive maintenance", + "repair": "Repair", + "inspection": "Inspection", + "calibration": "Calibration" + } + }, + "corrections": { + "queueLabel": "Corrections waiting to be handled", + "empty": "No corrections have been reported. The “Report a problem” control on a tool page opens one here.", + "emptyWaiting": "Nothing is waiting. Every correction has been reviewed, fixed or dismissed.", + "handledToggle": "Show {count} already handled", + "noTool": "No tool matched", + "noField": "No field named", + "suggestedFix": "Suggested fix:", + "reportedBy": "Reported by {name}", + "reportedAnonymously": "Reported anonymously", + "reportedOn": "Reported {date}", + "statusFor": "Status for the correction about {tool}", + "status": { + "new": "Waiting", + "reviewed": "Reviewed", + "fixed": "Fixed", + "dismissed": "Dismissed" + }, + "setStatus": { + "new": "Reopen", + "reviewed": "Mark reviewed", + "fixed": "Mark fixed", + "dismissed": "Dismiss" + }, + "fields": { + "description": "Description", + "image": "Photo", + "name": "Name", + "category": "Category", + "location": "Location", + "materials": "Materials", + "safety_info": "Safety info" + } + }, + "projects": { + "queueLabel": "Projects waiting for a decision", + "empty": "No projects have been submitted yet. A signed-in student sharing one puts it here first.", + "emptyWaiting": "Nothing is waiting. Every submission has been decided on.", + "publishedToggle": "Show {count} already published", + "by": "By {name}", + "byAnonymous": "No author recorded", + "submittedOn": "Submitted {date}", + "notPublicYet": "Not in the gallery yet — everything a visitor would see is below.", + "openInGallery": "Open in the gallery", + "photosFor": "Photos submitted with {title}", + "noPhotos": "No photos were submitted.", + "materials": "Materials:", + "statePublished": "Published", + "stateWaiting": "Waiting", + "publish": "Publish", + "unpublish": "Unpublish", + "publishFor": "Publish {title}", + "unpublishFor": "Unpublish {title}" } } } diff --git a/v5/src/app/admin/corrections/action-result.ts b/v5/src/app/admin/corrections/action-result.ts new file mode 100644 index 0000000..744a31b --- /dev/null +++ b/v5/src/app/admin/corrections/action-result.ts @@ -0,0 +1,33 @@ +import type { AdminGateError } from "../../../lib/admin/action-result"; +import type { QueueActionResult } from "../../../lib/admin/queue-write"; + +/** + * What `/admin/corrections`' server action answers, and where it lives. + * + * Directive-free for the reason every admin surface's result module is: a + * `"use server"` module may export only async functions, and the client island + * has to render these codes without importing the endpoint to get at its shape. + */ + +/** The page this action belongs to, and the path it refreshes. */ +export const CORRECTIONS_PATH = "/admin/corrections"; + +/** + * Why a correction did not change. Each has an `admin.errors.` message. + * + * - `not_found` — the correction is gone, or never existed. + * - `invalid_field` — a status outside `FEEDBACK_STATUS`, refused before + * Postgres rejects the whole statement with a message no page can render. + */ +export type CorrectionWriteError = "not_found" | "invalid_field"; + +export type CorrectionActionError = AdminGateError | CorrectionWriteError; + +/** One declaration of the codes, two shapes built from it — see `/admin/maintenance`. */ +export type CorrectionActionResult = QueueActionResult; + +/** The shape `CorrectionsQueue` hands its island, and the page hands the queue. */ +export type SetCorrectionStatusAction = (input: { + feedbackId: string; + status: string; +}) => Promise; diff --git a/v5/src/app/admin/corrections/actions.test.ts b/v5/src/app/admin/corrections/actions.test.ts new file mode 100644 index 0000000..cb75c13 --- /dev/null +++ b/v5/src/app/admin/corrections/actions.test.ts @@ -0,0 +1,202 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +/** + * Withhold or grant one permission, to prove the action asks for its own. + * + * `admin` holds `tools.edit` and `feedback.manage` together, so the only way to + * test that this endpoint checks the second rather than the first is to take + * the declaration out of the picture for one test — and this is the surface + * where it matters most, because fixing the catalogue and triaging the report + * about it are so obviously adjacent. Null means the real `can()`. + */ +const override = vi.hoisted(() => ({ permissions: null as Set | null })); + +vi.mock("../../../lib/auth/permissions", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + can: (subject: Parameters[0], permission: string) => + override.permissions + ? override.permissions.has(permission) + : actual.can(subject, permission as Parameters[1]), + }; +}); + +/** A database that refuses the write itself, rather than declining it. */ +const writes = vi.hoisted(() => ({ failing: false })); + +vi.mock("../../../lib/data/feedback", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateFeedbackStatus: async (...args: Parameters) => { + if (writes.failing) throw new Error("connection terminated unexpectedly"); + return actual.updateFeedbackStatus(...args); + }, + }; +}); + +import { eq } from "drizzle-orm"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { feedback, session, tools, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { setCorrectionStatus } from "./actions"; + +/** + * `/admin/corrections`' one endpoint (spec §5.6, §8). + * + * Called directly, with no page: a server action is a POST endpoint with a + * generated name, so the gate is the only thing in front of the write. The + * write is covered in `src/lib/data/feedback.test.ts`; this is about who may + * call it, and that a refusal is a value rather than an exception. + */ + +const AUTH_SECRET = "admin-corrections-test-secret"; + +let db: Db; +let correctionId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + override.permissions = null; + writes.failing = false; + + db = await getDb(); + await db.delete(feedback); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [tool] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4" }) + .returning({ id: tools.id }); + const [correction] = await db + .insert(feedback) + .values({ + toolId: tool.id, + fieldFlagged: "materials", + issueDescription: "The resin list is missing Rigid 10K.", + status: "new", + }) + .returning({ id: feedback.id }); + correctionId = correction.id; +}); + +afterEach(() => { + override.permissions = null; + writes.failing = false; + resetAuthForTests(); + resetDbForTests(); +}); + +async function storedCorrection() { + const [row] = await db.select().from(feedback).where(eq(feedback.id, correctionId)); + return row; +} + +async function asSuperMaker(email = "niti@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +it("refuses an anonymous caller, and changes nothing", async () => { + setMockHeaders(); + + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "fixed" })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect((await storedCorrection()).status).toBe("new"); +}); + +it("refuses a student, who may report a correction but not close one", async () => { + const student = await signInAsNew({ email: "casey@cornell.edu", role: "user" }); + setMockHeaders({ cookie: student.cookie }); + + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "dismissed" })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect((await storedCorrection()).status).toBe("new"); +}); + +it("refuses a caller who holds tools.edit but not feedback.manage", async () => { + await asSuperMaker(); + + override.permissions = new Set(["tools.edit", "tools.publish"]); + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "fixed" })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect((await storedCorrection()).status).toBe("new"); + + override.permissions = new Set(["feedback.manage"]); + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "fixed" })).toEqual({ + ok: true, + }); +}); + +it("marks a correction fixed, stamps who did it and refreshes the queue", async () => { + const signedIn = await asSuperMaker(); + const { revalidatePath } = await import("next/cache"); + + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "fixed" })).toEqual({ + ok: true, + }); + + const row = await storedCorrection(); + expect(row.status).toBe("fixed"); + expect(row.updatedBy).toBe(signedIn.user.id); + // And nothing else about the report is touched: triaging is not editing. + expect(row.issueDescription).toBe("The resin list is missing Rigid 10K."); + expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith("/admin/corrections"); +}); + +it("lets a dismissal be undone, because it was a judgement", async () => { + await asSuperMaker(); + await setCorrectionStatus({ feedbackId: correctionId, status: "dismissed" }); + + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "new" })).toEqual({ + ok: true, + }); + expect((await storedCorrection()).status).toBe("new"); +}); + +it("passes the write layer's refusals through as codes, not exceptions", async () => { + await asSuperMaker(); + + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "resolved" })).toEqual({ + ok: false, + error: "invalid_field", + }); + expect(await setCorrectionStatus({ feedbackId: crypto.randomUUID(), status: "fixed" })).toEqual({ + ok: false, + error: "not_found", + }); + expect((await storedCorrection()).status).toBe("new"); +}); + +it("answers `failed` rather than throwing when the database is unreachable", async () => { + await asSuperMaker(); + writes.failing = true; + + // The data layer throws because its callers have to tell "we declined" from + // "we do not know". A server action may not: a throw reaches the browser as a + // digest and an error boundary rather than as a sentence beside the control. + expect(await setCorrectionStatus({ feedbackId: correctionId, status: "fixed" })).toEqual({ + ok: false, + error: "failed", + }); +}); diff --git a/v5/src/app/admin/corrections/actions.ts b/v5/src/app/admin/corrections/actions.ts new file mode 100644 index 0000000..9c764e3 --- /dev/null +++ b/v5/src/app/admin/corrections/actions.ts @@ -0,0 +1,48 @@ +"use server"; + +import { runQueueWrite } from "../../../lib/admin/queue-write"; +import { updateFeedbackStatus } from "../../../lib/data/feedback"; +import { CORRECTIONS_PATH, type CorrectionActionResult } from "./action-result"; + +/** + * Triaging a correction (spec §5.6, §4.9, §8). + * + * **It checks `feedback.manage` for itself**, and that is not the same + * permission as `/admin/inventory`'s: a server action is a POST endpoint with a + * generated name, so a caller holding `tools.edit` and nothing else is refused + * here even though they could fix the field the correction is about. The two + * are separate declarations in `permissions.ts` precisely so that can be true. + * + * **The status is the whole write.** A correction is a message somebody sent, + * not a record to edit; changing the catalogue happens in the tool editor, + * which the queue links to. That separation is also what keeps §8's promise + * that a student's report is inert — there is no path from the sentence to the + * field, only from the sentence to a person. + * + * **No audit event** (§4.11: security-relevant actions only, and this is an + * ordinary edit) and **no cache invalidation** — nothing cached reads the + * `feedback` table. + */ + +/** Names this surface in the console line a failure leaves behind. */ +const SURFACE = "admin/corrections"; + +/** + * Mark one correction `reviewed`, `fixed` or `dismissed` — or back to `new`. + * + * Going backwards is allowed on purpose: dismissing a correction is a + * judgement, and the reviewer who made it in a hurry should be able to undo it + * without a database console. + */ +export async function setCorrectionStatus(input: { + feedbackId: string; + status: string; +}): Promise { + return runQueueWrite({ + permission: "feedback.manage", + path: CORRECTIONS_PATH, + surface: SURFACE, + write: (identity) => + updateFeedbackStatus(input.feedbackId, input.status, { actorUserId: identity.userId }), + }); +} diff --git a/v5/src/app/admin/corrections/page.tsx b/v5/src/app/admin/corrections/page.tsx new file mode 100644 index 0000000..e0f054b --- /dev/null +++ b/v5/src/app/admin/corrections/page.tsx @@ -0,0 +1,52 @@ +import { getTranslations } from "next-intl/server"; +import { AdminNotice } from "../../../components/admin/AdminNotice"; +import { CorrectionsQueue } from "../../../components/admin/CorrectionsQueue"; +import { resolveIdentityFromHeaders } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { listFeedbackQueue } from "../../../lib/data/feedback"; +import { siteConfig } from "../../../lib/site-config"; +import { setCorrectionStatus } from "./actions"; + +/** + * `/admin/corrections` — what people have told us is wrong (spec §5.6, §6). + * + * Requires `feedback.manage`, which is its own permission and not + * `tools.edit`: reading what somebody reported and deciding what to do about + * it is a different job from editing the record, even though the same person + * usually does both. The refusal is said, never 404ed. + * + * **Nothing here is cached**, for the reason the other queues are not: a + * correction somebody has already handled must not still be sitting at the top + * of the list. + * + * The action travels down as a prop and re-checks its own permission — a server + * action is a POST endpoint reachable without this page (§8). + */ + +export const metadata = { + title: `Corrections — ${siteConfig.name}`, +}; + +export default async function AdminCorrectionsPage() { + const t = await getTranslations("admin"); + const identity = await resolveIdentityFromHeaders(); + + if (!can(identity, "feedback.manage")) return ; + + const corrections = await listFeedbackQueue(); + + return ( +
+
+

{t("eyebrow")}

+

{t("correctionsTitle")}

+ {/* No placeholder in this string: `/admin/page.tsx` renders the same + key without arguments, and a next-intl placeholder with no argument + renders literally (Article 6 — this has been a real bug here). */} +

{t("correctionsLede")}

+
+ + +
+ ); +} diff --git a/v5/src/app/admin/inventory/action-result.ts b/v5/src/app/admin/inventory/action-result.ts new file mode 100644 index 0000000..c0c7af8 --- /dev/null +++ b/v5/src/app/admin/inventory/action-result.ts @@ -0,0 +1,67 @@ +import type { AdminGateError } from "../../../lib/admin/action-result"; +import type { CategoryOption, LocationOption } from "../../../lib/data/taxonomy"; +import type { ToolEditorData } from "../../../lib/data/tool-editor"; +import type { Revision } from "../../../lib/data/revision"; +import type { + InventoryWriteError, + InventoryWriteWarning, +} from "../../../lib/inventory/result"; + +/** + * What the tool editor's server actions answer, and where they live. + * + * Its own module with no directive, for the reason + * `app/admin/users/action-result.ts` is one: a `"use server"` module may export + * **only async functions**, because every export becomes a callable endpoint. + * A path constant and a result type cannot live there — and the panel, which is + * a client island, must be able to render these codes without importing the + * endpoints to get at their shape. + */ + +/** The page these actions belong to, and the path they invalidate. */ +export const INVENTORY_PATH = "/admin/inventory"; + +/** + * Why an action did nothing. Every code has an `admin.errors.` message. + * + * Two halves, and they come from two places on purpose: + * + * - {@link AdminGateError} — not signed in, not permitted, over the ceiling, or + * "it did not land". Shared with every admin surface, so a refusal reads the + * same wherever it happens. + * - {@link InventoryWriteError} — `conflict`, `not_found`, `invalid_field`, + * `duplicate_serial`, `unit_has_history`. The write layer's own vocabulary, + * passed straight through rather than re-spelled here: this boundary + * translates nothing, so a code cannot drift between the two modules. + * + * **`conflict` is the one that is not an apology.** It means somebody else + * changed this tool while the panel was open and *nothing was written*, so the + * panel keeps the unsaved edits and offers a reload (§5.3(4)). + */ +export type InventoryActionError = AdminGateError | InventoryWriteError; + +/** A change that landed with less than the full guarantee behind it. */ +export type InventoryActionWarning = InventoryWriteWarning; + +/** + * The shape every write on this surface answers with. + * + * **A success always carries the new revision**, because the panel stays open: + * the token it handed in is spent, and without the next one its following save + * would report a conflict against itself. + */ +export type InventoryActionResult = + | ({ ok: true; revision: Revision; warning?: InventoryActionWarning } & T) + | { ok: false; error: InventoryActionError }; + +/** Everything the panel needs to render itself, once it is allowed to. */ +export interface ToolEditorPayload extends ToolEditorData { + /** The two option lists the fields form selects from (§4.3). */ + categories: CategoryOption[]; + locations: LocationOption[]; +} + +/** What opening (or reloading) the panel answers. */ +export type LoadToolEditorResult = + | { ok: true; editor: ToolEditorPayload } + | { ok: false; error: InventoryActionError }; diff --git a/v5/src/app/admin/inventory/actions.conflict.test.ts b/v5/src/app/admin/inventory/actions.conflict.test.ts new file mode 100644 index 0000000..8958c79 --- /dev/null +++ b/v5/src/app/admin/inventory/actions.conflict.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +import { eq, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { session, tools, units, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { loadToolForEditor, saveTool } from "./actions"; +import { addUnit } from "./unit-actions"; + +/** + * Two panels, one tool (spec §5.3(4)). + * + * The scenario the whole revision token exists for: somebody opens the editor, + * somebody else saves, and then the first person saves. **Nothing of the second + * writer's work may be lost, and the first person must be told** — never a + * silent overwrite, and never a partial one. + * + * Its own file because every test in it stages the same race, and because the + * staging is the fiddly part: PGlite's `now()` is millisecond-resolution, so + * "somebody else" landing in the same millisecond as the token would leave the + * token still matching. Each test moves `updated_at` explicitly with the + * trigger disabled rather than racing the clock. + */ + +const AUTH_SECRET = "inventory-conflict-test-secret"; + +let db: Db; +let toolId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + vi.mocked(revalidatePath).mockClear(); + + db = await getDb(); + await db.delete(units); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [row] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4", description: "before" }) + .returning({ id: tools.id }); + toolId = row.id; + + const maker = await signInAsNew({ email: "maker@cornell.edu", role: "admin" }); + setMockHeaders({ cookie: maker.cookie }); +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +/** The other editor saves, at a timestamp this one cannot share. */ +async function somebodyElseSaves(description: string) { + await db.execute(sql`alter table tools disable trigger tools_set_updated_at`); + await db.execute( + sql`update tools set description = ${description}, updated_at = updated_at + interval '1 second'` + ); + await db.execute(sql`alter table tools enable trigger tools_set_updated_at`); +} + +async function toolRow() { + return (await db.select().from(tools).where(eq(tools.id, toolId)))[0]; +} + +it("refuses the second save, keeps the other writer's value, and busts no cache", async () => { + const opened = await loadToolForEditor("form-4"); + if (!opened.ok) throw new Error("expected the panel to open"); + + await somebodyElseSaves("somebody else"); + + const result = await saveTool({ + toolId, + expectedRevision: opened.editor.tool.revision, + patch: { description: "mine" }, + }); + + expect(result).toEqual({ ok: false, error: "conflict" }); + // Their work survives intact — a conflict is never a silent overwrite, and + // the panel still holds "mine" to offer back to the person who typed it. + expect((await toolRow()).description).toBe("somebody else"); + expect(revalidatePath).not.toHaveBeenCalled(); +}); + +it("refuses a child write on a stale token too, and adds nothing", async () => { + const opened = await loadToolForEditor("form-4"); + if (!opened.ok) throw new Error("expected the panel to open"); + + await somebodyElseSaves("somebody else"); + + // A unit write touches its tool in the same transaction, so it is checked + // against the same token — and a refusal rolls that touch back. + expect( + await addUnit({ + toolId, + expectedRevision: opened.editor.tool.revision, + unit: { unitLabel: "Form 4 #2" }, + }) + ).toEqual({ ok: false, error: "conflict" }); + + expect(await db.select().from(units)).toEqual([]); +}); + +it("lets the same person save again once they reload into the newer version", async () => { + const opened = await loadToolForEditor("form-4"); + if (!opened.ok) throw new Error("expected the panel to open"); + + await somebodyElseSaves("somebody else"); + expect( + ( + await saveTool({ + toolId, + expectedRevision: opened.editor.tool.revision, + patch: { description: "mine" }, + }) + ).ok + ).toBe(false); + + // What the panel's Reload button does: read the tool again, take the new + // token, and keep the unsaved text the person typed. + const reloaded = await loadToolForEditor("form-4"); + if (!reloaded.ok) throw new Error("expected the panel to reload"); + expect(reloaded.editor.tool.description).toBe("somebody else"); + + const saved = await saveTool({ + toolId, + expectedRevision: reloaded.editor.tool.revision, + patch: { description: "mine" }, + }); + + expect(saved.ok).toBe(true); + expect((await toolRow()).description).toBe("mine"); +}); + +it("tells a deleted tool apart from a conflict, because the panel says something else", async () => { + const opened = await loadToolForEditor("form-4"); + if (!opened.ok) throw new Error("expected the panel to open"); + + await db.delete(tools).where(eq(tools.id, toolId)); + + expect( + await saveTool({ + toolId, + expectedRevision: opened.editor.tool.revision, + patch: { description: "mine" }, + }) + ).toEqual({ ok: false, error: "not_found" }); +}); diff --git a/v5/src/app/admin/inventory/actions.test.ts b/v5/src/app/admin/inventory/actions.test.ts new file mode 100644 index 0000000..b589897 --- /dev/null +++ b/v5/src/app/admin/inventory/actions.test.ts @@ -0,0 +1,277 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { listAuditEvents } from "../../../lib/data/audit"; +import { readToolRevision } from "../../../lib/data/tools"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { auditEvents, session, tools, units, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { + archive, + loadToolForEditor, + markToolReviewed, + publish, + restore, + saveTool, + unpublish, +} from "./actions"; + +/** + * The tool editor's server actions, called **directly** — which is the whole + * point (spec §8). A server action is a POST endpoint with a generated name: + * reaching it needs no page, no panel and no control, so the gate on each + * action is the only thing standing in front of the write. + * + * Everything is real here except the two Next modules that need a request + * scope: PGlite, real session rows, the real audit table. No network, no + * Google, no `DATABASE_URL` (Article 3). + */ + +const AUTH_SECRET = "inventory-actions-test-secret"; + +let db: Db; +let toolId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + vi.mocked(revalidatePath).mockClear(); + + db = await getDb(); + await db.delete(auditEvents); + await db.delete(units); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [row] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4", description: "before", published: false }) + .returning({ id: tools.id }); + toolId = row.id; +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +/** Sign in as a SuperMaker and point `next/headers` at their cookie. */ +async function asSuperMaker(email = "maker@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin", name: "Luis" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +async function asStudent(email = "student@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "user" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +async function revision(): Promise { + return (await readToolRevision(toolId))!; +} + +async function toolRow() { + return (await db.select().from(tools).where(eq(tools.id, toolId)))[0]; +} + +// ── Who may call these at all (§8) ────────────────────────────────── + +describe("the permission gate", () => { + it("refuses an anonymous caller on every action, and writes nothing", async () => { + setMockHeaders(); + const input = { toolId, expectedRevision: await revision() }; + + expect(await loadToolForEditor("form-4")).toEqual({ ok: false, error: "not_signed_in" }); + expect(await saveTool({ ...input, patch: { description: "after" } })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await publish(input)).toEqual({ ok: false, error: "not_signed_in" }); + expect(await archive(input)).toEqual({ ok: false, error: "not_signed_in" }); + expect(await markToolReviewed(input)).toEqual({ ok: false, error: "not_signed_in" }); + + const row = await toolRow(); + expect(row.description).toBe("before"); + expect(row.published).toBe(false); + expect(row.archivedAt).toBeNull(); + }); + + it("refuses a signed-in student, who holds neither permission", async () => { + await asStudent(); + const input = { toolId, expectedRevision: await revision() }; + + // Told apart from `not_signed_in`: signing in again would not help. + expect(await loadToolForEditor("form-4")).toEqual({ ok: false, error: "not_permitted" }); + expect(await saveTool({ ...input, patch: { name: "Renamed" } })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect(await publish(input)).toEqual({ ok: false, error: "not_permitted" }); + expect((await toolRow()).name).toBe("Form 4"); + }); + + it("never lets a refused caller read a draft through the editor", async () => { + // The load is a reader and still a POST endpoint: it sees drafts, archived + // tools and unpublished resources, so it gates itself like every write. + await asStudent(); + expect(await loadToolForEditor("form-4")).toEqual({ ok: false, error: "not_permitted" }); + }); +}); + +// ── Opening the panel (§5.3(3)) ───────────────────────────────────── + +describe("loadToolForEditor", () => { + it("returns the draft, its children and the taxonomy the form selects from", async () => { + await asSuperMaker(); + await db.insert(units).values({ toolId, unitLabel: "Form 4 #1" }); + + const result = await loadToolForEditor("form-4"); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.editor.tool.name).toBe("Form 4"); + expect(result.editor.units.map((unit) => unit.unitLabel)).toEqual(["Form 4 #1"]); + expect(Array.isArray(result.editor.categories)).toBe(true); + expect(Array.isArray(result.editor.locations)).toBe(true); + }); + + it("mints a token that a save made straight afterwards accepts", async () => { + await asSuperMaker(); + const result = await loadToolForEditor("form-4"); + if (!result.ok) throw new Error("expected the panel to open"); + + const saved = await saveTool({ + toolId, + expectedRevision: result.editor.tool.revision, + patch: { description: "after" }, + }); + expect(saved.ok).toBe(true); + expect((await toolRow()).description).toBe("after"); + }); + + it("says not_found for a slug nobody owns", async () => { + await asSuperMaker(); + expect(await loadToolForEditor("no-such-tool")).toEqual({ ok: false, error: "not_found" }); + }); +}); + +// ── Saving (§5.3(4)) ──────────────────────────────────────────────── + +describe("saveTool", () => { + it("writes the patch, stamps the author and hands back a fresh token", async () => { + const maker = await asSuperMaker(); + const before = await revision(); + + const result = await saveTool({ + toolId, + expectedRevision: before, + patch: { description: "after" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // The token it came in with is spent; without a new one the panel's next + // save would conflict with itself. + expect(result.revision).not.toBe(before); + + const row = await toolRow(); + expect(row.description).toBe("after"); + expect(row.updatedBy).toBe(maker.user.id); + expect(revalidatePath).toHaveBeenCalledWith("/admin/inventory"); + }); + + it("refuses a value the catalogue could not render, and changes nothing", async () => { + await asSuperMaker(); + expect( + await saveTool({ toolId, expectedRevision: await revision(), patch: { name: " " } }) + ).toEqual({ ok: false, error: "invalid_field" }); + expect((await toolRow()).name).toBe("Form 4"); + expect(revalidatePath).not.toHaveBeenCalled(); + }); +}); + +// ── State changes (§5.3(5), §4.11) ────────────────────────────────── + +describe("the state changes", () => { + it("publishes, and records who did it", async () => { + const maker = await asSuperMaker(); + + const result = await publish({ toolId, expectedRevision: await revision() }); + expect(result.ok).toBe(true); + expect((await toolRow()).published).toBe(true); + + const events = await listAuditEvents(); + expect(events).toHaveLength(1); + expect(events[0].action).toBe("tool.published"); + expect(events[0].subjectId).toBe(toolId); + expect(events[0].actorUserId).toBe(maker.user.id); + }); + + it("unpublishes, and records that separately", async () => { + await asSuperMaker(); + await publish({ toolId, expectedRevision: await revision() }); + + await unpublish({ toolId, expectedRevision: await revision() }); + expect((await toolRow()).published).toBe(false); + expect((await listAuditEvents()).map((event) => event.action)).toEqual([ + "tool.unpublished", + "tool.published", + ]); + }); + + it("archives without deleting, and restores as the same action with a flag", async () => { + await asSuperMaker(); + + await archive({ toolId, expectedRevision: await revision() }); + expect((await toolRow()).archivedAt).not.toBeNull(); + + await restore({ toolId, expectedRevision: await revision() }); + expect((await toolRow()).archivedAt).toBeNull(); + + // `AUDIT_ACTIONS` has no `tool.restored` (§4.11), so a restore is + // `tool.archived` with `archived: false` — the shape a lifted ban uses. + const events = await listAuditEvents(); + expect(events.map((event) => event.action)).toEqual(["tool.archived", "tool.archived"]); + expect(events.map((event) => (event.detail as { archived: boolean }).archived)).toEqual([ + false, + true, + ]); + }); + + it("marks a tool reviewed with both columns, and records nothing", async () => { + const maker = await asSuperMaker(); + + expect((await markToolReviewed({ toolId, expectedRevision: await revision() })).ok).toBe(true); + + const row = await toolRow(); + expect(row.lastReviewedAt).not.toBeNull(); + expect(row.lastReviewedBy).toBe(maker.user.id); + // A review is an ordinary edit, and §4.11 says those are not logged. + expect(await listAuditEvents()).toEqual([]); + }); + + it("refuses a state change on a tool that is gone, and records nothing", async () => { + await asSuperMaker(); + const stale = await revision(); + await db.delete(tools).where(eq(tools.id, toolId)); + + expect(await publish({ toolId, expectedRevision: stale })).toEqual({ + ok: false, + error: "not_found", + }); + expect(await listAuditEvents()).toEqual([]); + }); +}); diff --git a/v5/src/app/admin/inventory/actions.ts b/v5/src/app/admin/inventory/actions.ts new file mode 100644 index 0000000..bc67e58 --- /dev/null +++ b/v5/src/app/admin/inventory/actions.ts @@ -0,0 +1,114 @@ +"use server"; + +import { authorizeAdminAction } from "../../../lib/admin/action-gate"; +import { listCategories, listLocations } from "../../../lib/data/taxonomy"; +import { loadToolEditor } from "../../../lib/data/tool-editor"; +import type { ToolPatch } from "../../../lib/data/tools"; +import { saveToolFields } from "../../../lib/inventory/tool-edits"; +import { + archiveTool, + markReviewed, + publishTool, + restoreTool, + unpublishTool, +} from "../../../lib/inventory/tool-state"; +import { type InventoryActionResult, type LoadToolEditorResult } from "./action-result"; +import { withToolEdit, withToolWrite, type ToolWriteInput } from "./tool-write-context"; + +/** + * The tool editor's own writes (spec §5.3(3)–(5), §8). + * + * **Every one of these checks its own permission**, because a server action is + * a POST endpoint with a generated name: it is reachable without the panel that + * offers the control, so the panel is evidence of nothing. `authorizeAdminAction` + * is the shared preamble — identity, limiter, permission, in that order. + * + * **Two permissions, not one.** Editing is `tools.edit`; publishing, + * unpublishing, archiving and restoring are `tools.publish`. Both are held by + * `admin` today, so nothing changes behaviourally — but the declaration's own + * stated future ("SuperMakers may add tools but not publish them") is now a + * one-line change in `permissions.ts` rather than a rewrite of this file. + * + * **A refusal is a value; a conflict is a refusal.** `{ ok: false, error }` + * reaches the panel as a message it can render, and `conflict` specifically + * means *nothing was written* — the panel keeps what the person typed and + * offers a reload (§5.3(4)). A thrown error would reach the browser as a digest + * and an error boundary, which loses the unsaved edits this phase exists to + * protect. + * + * **And a change that landed minus its audit event is a success with a + * warning.** That channel lives in `src/lib/admin/audit-warning.ts` and is + * applied by `src/lib/inventory/`; nothing here answers `{ ok: false }` for a + * write that is in the database. + * + * The child sections — units, resources, photos — are in their own modules + * beside this one, so no module grows past one job. + */ + +/** + * Open the panel: everything about one tool, plus the token its saves carry. + * + * **A reader, and still a POST endpoint**, so it checks `tools.edit` for + * itself — this read sees drafts, archived tools, unpublished resources and + * retired units, none of which the catalogue shows anybody. + * + * It is also what guarantees the revision is minted **when the panel opens** + * rather than when the page was rendered or cached. A token read from a page + * that has been sitting in a tab since this morning would make every save a + * conflict; one read from a cached HTML page would make the check meaningless. + */ +export async function loadToolForEditor(idOrSlug: string): Promise { + const gate = await authorizeAdminAction("tools.edit"); + if (!gate.ok) return gate; + + const editor = await loadToolEditor(idOrSlug); + if (!editor) return { ok: false, error: "not_found" }; + + // The two option lists the fields form needs. Read here rather than in + // `loadToolEditor`, which is about one tool: these belong to the whole lab. + const [categories, locations] = await Promise.all([listCategories(), listLocations()]); + + return { ok: true, editor: { ...editor, categories, locations } }; +} + +/** Save the editor's fields. `tools.edit`. */ +export async function saveTool( + input: ToolWriteInput & { patch: ToolPatch } +): Promise { + return withToolEdit(input, (context) => + saveToolFields({ ...context, patch: input.patch }) + ); +} + +/** + * **Looks good** — the review's one-click mark (§5.3(3)). `tools.edit`. + * + * Deliberately not `tools.publish`: saying a record is accurate is the review + * itself, and it is the same act as fixing a field. + */ +export async function markToolReviewed(input: ToolWriteInput): Promise { + // `withToolEdit`, not `withToolWrite("tools.edit", …)`: naming the permission + // here as well as there is a second place for it to be wrong, and the two + // would not disagree loudly — one action quietly gating on the other's. + return withToolEdit(input, markReviewed); +} + +/** Publish a tool: the draft becomes catalogue (Article 5). `tools.publish`. */ +export async function publish(input: ToolWriteInput): Promise { + return withToolWrite("tools.publish", input, publishTool); +} + +/** Unpublish: it stops being catalogue without losing anything. `tools.publish`. */ +export async function unpublish(input: ToolWriteInput): Promise { + return withToolWrite("tools.publish", input, unpublishTool); +} + +/** Archive: the machine is gone, its history is not. Never a delete. */ +export async function archive(input: ToolWriteInput): Promise { + return withToolWrite("tools.publish", input, archiveTool); +} + +/** Restore an archived tool. Recorded as `tool.archived` with `archived: false`. */ +export async function restore(input: ToolWriteInput): Promise { + return withToolWrite("tools.publish", input, restoreTool); +} diff --git a/v5/src/app/admin/inventory/page.tsx b/v5/src/app/admin/inventory/page.tsx new file mode 100644 index 0000000..b61971f --- /dev/null +++ b/v5/src/app/admin/inventory/page.tsx @@ -0,0 +1,118 @@ +import { getTranslations } from "next-intl/server"; +import { AdminNotice } from "../../../components/admin/AdminNotice"; +import { InventoryFilters } from "../../../components/admin/InventoryFilters"; +import { UnlinkedUnits } from "../../../components/admin/UnlinkedUnits"; +import { + parseInventoryFilters, + type SearchParams, +} from "../../../components/admin/inventory-filters"; +import { resolveIdentityFromHeaders } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { listInventoryRows, listUnlinkedUnits } from "../../../lib/data/inventory"; +import { siteConfig } from "../../../lib/site-config"; +import type { ToolEditorActions } from "../../../components/admin/tool-editor-actions"; +import { + archive, + loadToolForEditor, + markToolReviewed, + publish, + restore, + saveTool, + unpublish, +} from "./actions"; +import { attachPhotos, removePhoto, reorderPhotos } from "./photo-actions"; +import { addResource, editResource, removeResource } from "./resource-actions"; +import { addUnit, deleteUnit, editUnit, retireUnit } from "./unit-actions"; + +/** + * `/admin/inventory` — the review table (spec §5.3(a), §6). + * + * Requires `tools.edit`. The layout above answered the coarse question and let + * anyone holding an admin permission through; the exact refusal happens here + * and is *said*, the way `/admin/users` says it. A 404 would claim the page + * does not exist, which is a lie told to somebody who is signed in. + * + * **Nothing here is cached.** A review table is a picture of what is true right + * now — the one thing it must not do is show a row somebody already fixed. The + * identity read makes this subtree dynamic anyway, and the filters are read + * from the URL, which is dynamic for the same reason. + * + * The rows are read whole and filtered in the browser (see `InventoryFilters`), + * so changing a facet costs nothing and the URL stays linkable. + * + * **The editor's actions travel down as props.** A client island that imported + * them would drag `next/headers`, the limiter and `server-only` into the + * browser bundle and stop being testable; and the same panel is offered from a + * tool's own page, which has no access to this one. Each action re-checks its + * own permission regardless — handing one down is not a grant (§8). + */ + +/** + * The bundle `ToolEditorPanel` receives. Built here rather than exported from + * `actions.ts`, because a `"use server"` module may export only async + * functions. + */ +const EDITOR_ACTIONS: ToolEditorActions = { + load: loadToolForEditor, + save: saveTool, + markReviewed: markToolReviewed, + publish, + unpublish, + archive, + restore, + addUnit, + editUnit, + retireUnit, + deleteUnit, + addResource, + editResource, + removeResource, + attachPhotos, + reorderPhotos, + removePhoto, +}; + +export const metadata = { + title: `Inventory — ${siteConfig.name}`, +}; + +export default async function AdminInventoryPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const t = await getTranslations("admin"); + const identity = await resolveIdentityFromHeaders(); + + if (!can(identity, "tools.edit")) return ; + + const [params, rows, unlinked] = await Promise.all([ + searchParams, + listInventoryRows(), + listUnlinkedUnits(), + ]); + + return ( +
+
+

{t("eyebrow")}

+

{t("inventoryTitle")}

+ {/* No placeholder in this string: `/admin/page.tsx` renders the same + key without arguments, and a next-intl placeholder with no argument + renders literally (Article 6 — this has been a real bug here). */} +

{t("inventoryLede")}

+
+ + + + +
+ ); +} diff --git a/v5/src/app/admin/inventory/photo-actions.test.ts b/v5/src/app/admin/inventory/photo-actions.test.ts new file mode 100644 index 0000000..3fb619c --- /dev/null +++ b/v5/src/app/admin/inventory/photo-actions.test.ts @@ -0,0 +1,207 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +import { asc, eq } from "drizzle-orm"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { readToolRevision } from "../../../lib/data/tools"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { attachments, session, tools, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { attachPhotos, removePhoto, reorderPhotos } from "./photo-actions"; + +/** + * The Photos section's endpoints (spec §5.3(3), §4.7). + * + * **Position 0 is the cover**, so ordering is the feature these three exist to + * get right. Blob is never called here — the upload already happened at + * `POST /api/uploads` and left an unowned row; these actions only ever move + * rows around, which is why they work with no `BLOB_READ_WRITE_TOKEN`. + */ + +const AUTH_SECRET = "photo-actions-test-secret"; + +let db: Db; +let toolId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + + db = await getDb(); + await db.delete(attachments); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [row] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4" }) + .returning({ id: tools.id }); + toolId = row.id; +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +async function asSuperMaker(email = "maker@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +async function revision(): Promise { + return (await readToolRevision(toolId))!; +} + +/** An unowned upload row, exactly as `POST /api/uploads` leaves one. */ +async function seedUpload(name: string): Promise { + const [row] = await db + .insert(attachments) + .values({ + blobPathname: `uploads/tool/${name}.jpg`, + access: "public", + publicUrl: `https://blob.test/${name}.jpg`, + contentType: "image/jpeg", + originalFilename: `${name}.jpg`, + }) + .returning({ id: attachments.id }); + return row.id; +} + +/** This tool's photos, cover first. */ +async function order(): Promise { + const rows = await db + .select({ id: attachments.id, filename: attachments.originalFilename }) + .from(attachments) + .where(eq(attachments.ownerId, toolId)) + .orderBy(asc(attachments.position)); + return rows.map((row) => row.filename ?? row.id); +} + +it("refuses an anonymous caller on all three, and claims nothing", async () => { + setMockHeaders(); + const upload = await seedUpload("front"); + const input = { toolId, expectedRevision: await revision() }; + + expect(await attachPhotos({ ...input, attachmentIds: [upload] })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await reorderPhotos({ ...input, orderedIds: [upload] })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await removePhoto({ ...input, attachmentId: upload })).toEqual({ + ok: false, + error: "not_signed_in", + }); + + expect(await order()).toEqual([]); +}); + +it("appends new photos rather than making the newest one the cover", async () => { + await asSuperMaker(); + const first = await attachPhotos({ + toolId, + expectedRevision: await revision(), + attachmentIds: [await seedUpload("front")], + }); + expect(first.ok).toBe(true); + + await attachPhotos({ + toolId, + expectedRevision: await revision(), + attachmentIds: [await seedUpload("back")], + }); + + // `claimAttachments` numbers from zero, which on a tool that already has + // photos would quietly replace the cover with the newest upload. + expect(await order()).toEqual(["front.jpg", "back.jpg"]); +}); + +it("says how many stuck, and warns when some did not", async () => { + await asSuperMaker(); + + const result = await attachPhotos({ + toolId, + expectedRevision: await revision(), + // One real upload and one the daily cron already swept — what a panel left + // open overnight sends. + attachmentIds: [await seedUpload("front"), crypto.randomUUID()], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect([result.photosSubmitted, result.photosAttached]).toEqual([2, 1]); + expect(result.warning).toBe("photos_not_attached"); +}); + +it("reorders, which is also how the cover is chosen", async () => { + await asSuperMaker(); + const front = await seedUpload("front"); + const back = await seedUpload("back"); + await attachPhotos({ + toolId, + expectedRevision: await revision(), + attachmentIds: [front, back], + }); + + const result = await reorderPhotos({ + toolId, + expectedRevision: await revision(), + orderedIds: [back, front], + }); + + expect(result.ok).toBe(true); + expect(await order()).toEqual(["back.jpg", "front.jpg"]); +}); + +it("removes a photo and promotes the next one to cover", async () => { + await asSuperMaker(); + const front = await seedUpload("front"); + const back = await seedUpload("back"); + await attachPhotos({ + toolId, + expectedRevision: await revision(), + attachmentIds: [front, back], + }); + + expect( + (await removePhoto({ toolId, expectedRevision: await revision(), attachmentId: front })).ok + ).toBe(true); + + expect(await order()).toEqual(["back.jpg"]); + // Released, not deleted: the bytes go to the daily sweep, and the row is the + // only thing that had to change for the photo to leave the page. + const [row] = await db.select().from(attachments).where(eq(attachments.id, front)); + expect(row.ownerId).toBeNull(); +}); + +it("will not remove a photo that belongs to another tool", async () => { + await asSuperMaker(); + const [other] = await db + .insert(tools) + .values({ slug: "trotec", name: "Trotec Speedy 400" }) + .returning({ id: tools.id }); + const theirs = await seedUpload("theirs"); + await db + .update(attachments) + .set({ ownerType: "tool", ownerId: other.id, position: 0 }) + .where(eq(attachments.id, theirs)); + + expect( + await removePhoto({ toolId, expectedRevision: await revision(), attachmentId: theirs }) + ).toEqual({ ok: false, error: "not_found" }); + + const [row] = await db.select().from(attachments).where(eq(attachments.id, theirs)); + expect(row.ownerId).toBe(other.id); +}); diff --git a/v5/src/app/admin/inventory/photo-actions.ts b/v5/src/app/admin/inventory/photo-actions.ts new file mode 100644 index 0000000..94ea43e --- /dev/null +++ b/v5/src/app/admin/inventory/photo-actions.ts @@ -0,0 +1,60 @@ +"use server"; + +import { + attachPhotos as attachPhotosWrite, + removePhoto as removePhotoWrite, + reorderPhotos as reorderPhotosWrite, + type PhotoCountPayload, + type PhotoOrderPayload, +} from "../../../lib/inventory/photo-edits"; +import type { InventoryActionResult } from "./action-result"; +import { withToolEdit, type ToolWriteInput } from "./tool-write-context"; + +/** + * The Photos section of the tool editor — attach, reorder, remove + * (spec §5.3(3), §4.7). + * + * **Uploading happens before any of this.** The panel posts the file to + * `POST /api/uploads` with `kind: "tool"` (public, `tools.add`), which returns + * an `attachments` id owned by nothing; `attachPhotos` claims those ids. With + * no `BLOB_READ_WRITE_TOKEN` that route answers 503 and the panel says photos + * cannot be added right now — these actions are unreachable rather than broken, + * and everything else in the panel still works (Article 4). + * + * **Position 0 is the cover** (§4.7), so "make this the cover" and "reorder" + * are one operation and there is only one of them. + */ + +/** + * Attach uploaded photos, after the ones the tool already has. + * + * `photosSubmitted` / `photosAttached` come back and a shortfall raises + * `photos_not_attached`: a claim only takes *unowned* rows, and the daily cron + * sweeps uploads after 24 hours. Saying "attached" over either would be the + * quiet lie Article 4 forbids. + */ +export async function attachPhotos( + input: ToolWriteInput & { attachmentIds: readonly string[] } +): Promise> { + return withToolEdit(input, (context) => attachPhotosWrite(context, input.attachmentIds)); +} + +/** Set the order; the first is the cover. Ids from another tool move nothing. */ +export async function reorderPhotos( + input: ToolWriteInput & { orderedIds: readonly string[] } +): Promise> { + return withToolEdit(input, (context) => reorderPhotosWrite(context, input.orderedIds)); +} + +/** + * Remove a photo from the tool. + * + * The row is released and the bytes are left to the daily sweep; the photo + * leaves the page the moment this commits, which is what "remove" means to the + * person clicking it. Renumbering promotes the next photo to cover. + */ +export async function removePhoto( + input: ToolWriteInput & { attachmentId: string } +): Promise> { + return withToolEdit(input, (context) => removePhotoWrite(context, input.attachmentId)); +} diff --git a/v5/src/app/admin/inventory/resource-actions.test.ts b/v5/src/app/admin/inventory/resource-actions.test.ts new file mode 100644 index 0000000..af938cb --- /dev/null +++ b/v5/src/app/admin/inventory/resource-actions.test.ts @@ -0,0 +1,235 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +import { eq } from "drizzle-orm"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { readToolRevision } from "../../../lib/data/tools"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { attachments, resources, session, tools, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { addResource, editResource, removeResource } from "./resource-actions"; + +/** + * The Resources section's endpoints (spec §5.3(3), §4.6). + * + * The upload itself is `POST /api/uploads`, which has its own tests; what these + * actions do with the id it hands back is the part that belongs here. Nothing + * in this file touches Blob, which is why it all runs with + * `BLOB_READ_WRITE_TOKEN` unset. + */ + +const AUTH_SECRET = "resource-actions-test-secret"; + +let db: Db; +let toolId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + + db = await getDb(); + await db.delete(attachments); + await db.delete(resources); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [row] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4" }) + .returning({ id: tools.id }); + toolId = row.id; +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +async function asSuperMaker(email = "maker@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +async function revision(): Promise { + return (await readToolRevision(toolId))!; +} + +/** An unowned upload row, exactly as `POST /api/uploads` leaves one. */ +async function seedUpload(): Promise { + const [row] = await db + .insert(attachments) + .values({ + blobPathname: "uploads/resource/manual.pdf", + access: "public", + publicUrl: "https://blob.test/manual.pdf", + contentType: "application/pdf", + }) + .returning({ id: attachments.id }); + return row.id; +} + +it("refuses an anonymous caller on all three, and writes nothing", async () => { + setMockHeaders(); + const input = { toolId, expectedRevision: await revision() }; + + expect(await addResource({ ...input, resource: { title: "Manual" } })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect( + await editResource({ ...input, resourceId: crypto.randomUUID(), patch: { title: "x" } }) + ).toEqual({ ok: false, error: "not_signed_in" }); + expect(await removeResource({ ...input, resourceId: crypto.randomUUID() })).toEqual({ + ok: false, + error: "not_signed_in", + }); + + expect(await db.select().from(resources)).toEqual([]); +}); + +it("adds a link, and refuses one that is not a link", async () => { + await asSuperMaker(); + + const added = await addResource({ + toolId, + expectedRevision: await revision(), + resource: { title: "Formlabs support", type: "guide", url: "https://support.formlabs.com" }, + }); + expect(added.ok).toBe(true); + + // A bare `example.com` renders as a relative link and sends the reader to a + // page on this site that does not exist. + expect( + await addResource({ + toolId, + expectedRevision: await revision(), + resource: { title: "Typo", url: "support.formlabs.com" }, + }) + ).toEqual({ ok: false, error: "invalid_field" }); + + expect(await db.select().from(resources)).toHaveLength(1); +}); + +it("claims an uploaded PDF onto the resource it was uploaded for", async () => { + await asSuperMaker(); + const attachmentId = await seedUpload(); + + const added = await addResource({ + toolId, + expectedRevision: await revision(), + resource: { title: "Form 4 manual", type: "manual" }, + fileAttachmentIds: [attachmentId], + }); + + expect(added.ok).toBe(true); + if (!added.ok) return; + expect(added.filesSubmitted).toBe(1); + expect(added.filesAttached).toBe(1); + + const [file] = await db.select().from(attachments).where(eq(attachments.id, attachmentId)); + expect(file.ownerType).toBe("resource"); + expect(file.ownerId).toBe(added.resourceId); +}); + +it("says so when the file did not stick, and keeps the resource anyway", async () => { + await asSuperMaker(); + + // The id of an upload the daily cron already swept — what a panel left open + // overnight sends. Thanking somebody for a manual nobody has is the quiet lie + // Article 4 forbids; losing the link because its PDF expired is worse still. + const added = await addResource({ + toolId, + expectedRevision: await revision(), + resource: { title: "Form 4 manual" }, + fileAttachmentIds: [crypto.randomUUID()], + }); + + expect(added.ok).toBe(true); + if (!added.ok) return; + expect([added.filesSubmitted, added.filesAttached]).toEqual([1, 0]); + expect(added.warning).toBe("files_not_attached"); + expect(await db.select().from(resources)).toHaveLength(1); +}); + +it("edits a resource, including unpublishing it", async () => { + await asSuperMaker(); + const added = await addResource({ + toolId, + expectedRevision: await revision(), + resource: { title: "Old SOP" }, + }); + if (!added.ok) throw new Error("expected the resource to be added"); + + expect( + ( + await editResource({ + toolId, + expectedRevision: await revision(), + resourceId: added.resourceId, + patch: { published: false, notes: "Superseded" }, + }) + ).ok + ).toBe(true); + + const [row] = await db.select().from(resources); + expect(row.published).toBe(false); + expect(row.notes).toBe("Superseded"); +}); + +it("removes a resource and releases its files to the sweep", async () => { + await asSuperMaker(); + const attachmentId = await seedUpload(); + const added = await addResource({ + toolId, + expectedRevision: await revision(), + resource: { title: "Form 4 manual" }, + fileAttachmentIds: [attachmentId], + }); + if (!added.ok) throw new Error("expected the resource to be added"); + + expect( + ( + await removeResource({ + toolId, + expectedRevision: await revision(), + resourceId: added.resourceId, + }) + ).ok + ).toBe(true); + + expect(await db.select().from(resources)).toEqual([]); + // Released rather than deleted: a file still owned by a row that no longer + // exists is invisible to every read *and* to the orphan sweep. + const [file] = await db.select().from(attachments).where(eq(attachments.id, attachmentId)); + expect(file.ownerId).toBeNull(); +}); + +it("will not touch a resource belonging to another tool", async () => { + await asSuperMaker(); + const [other] = await db + .insert(tools) + .values({ slug: "trotec", name: "Trotec Speedy 400" }) + .returning({ id: tools.id }); + const [theirs] = await db + .insert(resources) + .values({ toolId: other.id, title: "Laser manual" }) + .returning({ id: resources.id }); + + expect( + await removeResource({ + toolId, + expectedRevision: await revision(), + resourceId: theirs.id, + }) + ).toEqual({ ok: false, error: "not_found" }); + expect(await db.select().from(resources)).toHaveLength(1); +}); diff --git a/v5/src/app/admin/inventory/resource-actions.ts b/v5/src/app/admin/inventory/resource-actions.ts new file mode 100644 index 0000000..0f3655a --- /dev/null +++ b/v5/src/app/admin/inventory/resource-actions.ts @@ -0,0 +1,70 @@ +"use server"; + +import type { NewResource, ResourcePatch } from "../../../lib/data/resources"; +import { + addResource as addResourceWrite, + editResource as editResourceWrite, + removeResource as removeResourceWrite, + type ResourceCreatePayload, + type ResourceWritePayload, +} from "../../../lib/inventory/resource-edits"; +import type { InventoryActionResult } from "./action-result"; +import { withToolEdit, type ToolWriteInput } from "./tool-write-context"; + +/** + * The Resources section of the tool editor — manuals, SOPs and links + * (spec §5.3(3), §4.6). + * + * **The upload is not here.** A PDF reaches the app through + * `POST /api/uploads` with `kind: "resource"`, which writes the blob and an + * *unowned* `attachments` row and hands back its id; `addResource` claims that + * id onto the new resource. Nothing in this module talks to Blob, which is why + * every action here works with `BLOB_READ_WRITE_TOKEN` unset — the panel simply + * cannot offer the file half. + * + * Each action gates itself on `tools.edit` and carries the panel's revision + * token, like every other write on this surface. + */ + +/** + * Add a manual, SOP or link. + * + * `filesSubmitted` / `filesAttached` come back and a shortfall raises + * `files_not_attached` — the file-shaped sibling of the photo warning, because + * the code is a message key and "some photos did not attach" is the wrong + * sentence about a manual. A claim only takes *unowned* rows and the daily cron + * sweeps uploads after 24 hours, so a panel left open overnight submits ids + * nobody can claim any more. The resource is still created — losing the link + * because its PDF expired would be the worse failure. + */ +export async function addResource( + input: ToolWriteInput & { + resource: NewResource; + fileAttachmentIds?: readonly string[]; + } +): Promise> { + return withToolEdit(input, (context) => + addResourceWrite(context, input.resource, input.fileAttachmentIds ?? []) + ); +} + +/** Edit a resource — title, type, link, notes, or whether it is published. */ +export async function editResource( + input: ToolWriteInput & { resourceId: string; patch: ResourcePatch } +): Promise> { + return withToolEdit(input, (context) => + editResourceWrite(context, input.resourceId, input.patch) + ); +} + +/** + * Remove a resource. + * + * Genuinely deleted, unlike a tool: a resource is a link or a file, not a + * record anything refers to. Its files are released in the same transaction. + */ +export async function removeResource( + input: ToolWriteInput & { resourceId: string } +): Promise> { + return withToolEdit(input, (context) => removeResourceWrite(context, input.resourceId)); +} diff --git a/v5/src/app/admin/inventory/tool-write-context.ts b/v5/src/app/admin/inventory/tool-write-context.ts new file mode 100644 index 0000000..e9c548e --- /dev/null +++ b/v5/src/app/admin/inventory/tool-write-context.ts @@ -0,0 +1,76 @@ +import { revalidatePath } from "next/cache"; +import { authorizeAdminAction } from "../../../lib/admin/action-gate"; +import type { Permission } from "../../../lib/auth/permissions"; +import type { Revision } from "../../../lib/data/revision"; +import type { InventoryWriteResult } from "../../../lib/inventory/result"; +import { INVENTORY_PATH, type InventoryActionResult } from "./action-result"; + +/** + * The preamble every tool-editor write shares (spec §5.3, §8). + * + * Whatever a panel control changes — a field, a unit, a resource, a photo, the + * tool's state — the action behind it is the same three moves: check its own + * permission, build the write context out of the caller's identity and the + * token the panel holds, and refresh the review table only if the write landed. + * This module owns those moves so `actions.ts` and the three child-section + * modules beside it hold nothing but the writes they are named after. + * + * It is deliberately **not** a `"use server"` module: nothing here is an + * endpoint, and a module with that directive may export only async functions. + */ + +/** What every write on this surface is told by the panel. */ +export interface ToolWriteInput { + toolId: string; + /** The token the panel received when it opened. */ + expectedRevision: Revision; +} + +/** The context the `src/lib/inventory/` writes take, once the gate has run. */ +export interface ToolWriteActor extends ToolWriteInput { + actorUserId: string | null; +} + +/** + * Gate on `permission`, then run one write against the panel's token. + * + * The result is passed back unchanged, warning and all. **A warning rides on + * `ok: true` and must keep doing so**: the row changed, and answering + * `{ ok: false }` would make the panel restore the previous value and assert a + * state the database no longer holds (§4.11, Article 4). + */ +export async function withToolWrite( + permission: Permission, + input: ToolWriteInput, + write: (context: ToolWriteActor) => Promise> +): Promise> { + const gate = await authorizeAdminAction(permission); + if (!gate.ok) return gate; + + const result = await write({ + toolId: input.toolId, + expectedRevision: input.expectedRevision, + actorUserId: gate.identity.userId, + }); + + // Only on a success: a refused write changed nothing, and re-rendering the + // review table for it buys a page of queries for no reason. The catalogue's + // own invalidation happened inside `src/lib/inventory/`, which is the layer + // that knows whether the transaction committed. + if (result.ok) revalidatePath(INVENTORY_PATH); + return result; +} + +/** + * The same, for the child sections, which are all `tools.edit`. + * + * Every one of them is an ordinary edit to the record — including taking a + * machine out of service, however consequential that feels standing next to the + * printer. Publishing is the one that is not, and it names its own permission. + */ +export function withToolEdit( + input: ToolWriteInput, + write: (context: ToolWriteActor) => Promise> +): Promise> { + return withToolWrite("tools.edit", input, write); +} diff --git a/v5/src/app/admin/inventory/unit-actions.test.ts b/v5/src/app/admin/inventory/unit-actions.test.ts new file mode 100644 index 0000000..d7a40c1 --- /dev/null +++ b/v5/src/app/admin/inventory/unit-actions.test.ts @@ -0,0 +1,210 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +import { revalidatePath } from "next/cache"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { readToolRevision } from "../../../lib/data/tools"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { maintenanceLogs, session, tools, units, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { addUnit, deleteUnit, editUnit, retireUnit } from "./unit-actions"; + +/** + * The Units section's endpoints (spec §5.3(3), §4.5). + * + * Called directly, with no panel: each one is a POST endpoint and has to check + * `tools.edit` for itself. The writes underneath are covered in + * `src/lib/inventory/unit-edits.test.ts`; what is under test here is the gate, + * the token, and that each refusal comes back as a code the panel can render. + */ + +const AUTH_SECRET = "unit-actions-test-secret"; + +let db: Db; +let toolId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + vi.mocked(revalidatePath).mockClear(); + + db = await getDb(); + await db.delete(maintenanceLogs); + await db.delete(units); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [row] = await db + .insert(tools) + .values({ slug: "form-4", name: "Form 4" }) + .returning({ id: tools.id }); + toolId = row.id; +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +async function asSuperMaker(email = "maker@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +async function revision(): Promise { + return (await readToolRevision(toolId))!; +} + +/** Add a unit as a signed-in SuperMaker and return its id. */ +async function seedUnit(label: string, serialNumber?: string): Promise { + const result = await addUnit({ + toolId, + expectedRevision: await revision(), + unit: { unitLabel: label, ...(serialNumber ? { serialNumber } : {}) }, + }); + if (!result.ok) throw new Error(`expected the unit to be added: ${result.error}`); + return result.unitId; +} + +it("refuses every unit action from an anonymous caller, and adds nothing", async () => { + setMockHeaders(); + const input = { toolId, expectedRevision: await revision() }; + + expect(await addUnit({ ...input, unit: { unitLabel: "Form 4 #1" } })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await editUnit({ ...input, unitId: crypto.randomUUID(), patch: {} })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await retireUnit({ ...input, unitId: crypto.randomUUID() })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect(await deleteUnit({ ...input, unitId: crypto.randomUUID() })).toEqual({ + ok: false, + error: "not_signed_in", + }); + + expect(await db.select().from(units)).toEqual([]); +}); + +it("refuses a student, who may browse the catalogue and nothing else", async () => { + const student = await signInAsNew({ email: "student@cornell.edu", role: "user" }); + setMockHeaders({ cookie: student.cookie }); + + expect( + await addUnit({ + toolId, + expectedRevision: await revision(), + unit: { unitLabel: "Form 4 #1" }, + }) + ).toEqual({ ok: false, error: "not_permitted" }); + expect(await db.select().from(units)).toEqual([]); +}); + +it("adds a unit, moves the tool's token with it and refreshes the table", async () => { + await asSuperMaker(); + const before = await revision(); + + const result = await addUnit({ + toolId, + expectedRevision: before, + unit: { unitLabel: "Form 4 #1", status: "available" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // The tool is the token for the whole panel, so a unit-only edit has to move + // it — otherwise a second editor's stale token would still match. + expect(result.revision).not.toBe(before); + expect(revalidatePath).toHaveBeenCalledWith("/admin/inventory"); +}); + +it("edits the fields the panel offers", async () => { + await asSuperMaker(); + const unitId = await seedUnit("Form 4 #1"); + + const result = await editUnit({ + toolId, + expectedRevision: await revision(), + unitId, + patch: { serialNumber: "FL-0042", condition: "good", dateAcquired: "2025-01-15" }, + }); + + expect(result.ok).toBe(true); + const [row] = await db.select().from(units); + expect(row.serialNumber).toBe("FL-0042"); + expect(row.condition).toBe("good"); + expect(row.dateAcquired).toBe("2025-01-15"); +}); + +it("refuses a status outside the vocabulary before Postgres sees it", async () => { + await asSuperMaker(); + const unitId = await seedUnit("Form 4 #1"); + + // `units_status_check` would refuse the whole statement with a message no + // page can render; `invalid_field` is one the panel can put next to the field. + expect( + await editUnit({ + toolId, + expectedRevision: await revision(), + unitId, + patch: { status: "on fire" }, + }) + ).toEqual({ ok: false, error: "invalid_field" }); +}); + +it("names a duplicate serial rather than leaking the constraint", async () => { + await asSuperMaker(); + await seedUnit("Form 4 #1", "FL-0042"); + const second = await seedUnit("Form 4 #2"); + + expect( + await editUnit({ + toolId, + expectedRevision: await revision(), + unitId: second, + patch: { serialNumber: "fl-0042" }, + }) + ).toEqual({ ok: false, error: "duplicate_serial" }); +}); + +it("retires a unit rather than deleting it once it has history", async () => { + await asSuperMaker(); + const unitId = await seedUnit("Form 4 #1"); + await db.insert(maintenanceLogs).values({ + toolId, + unitId, + title: "Tank cloudy", + status: "open", + }); + + // `maintenance_logs.unit_id` is `on delete set null`, so Postgres would allow + // the delete and quietly detach the ticket. The refusal is the feature. + expect(await deleteUnit({ toolId, expectedRevision: await revision(), unitId })).toEqual({ + ok: false, + error: "unit_has_history", + }); + + expect((await retireUnit({ toolId, expectedRevision: await revision(), unitId })).ok).toBe(true); + expect((await db.select().from(units))[0].status).toBe("retired"); +}); + +it("deletes a unit that nothing refers to", async () => { + await asSuperMaker(); + const unitId = await seedUnit("Form 4 #1"); + + expect((await deleteUnit({ toolId, expectedRevision: await revision(), unitId })).ok).toBe(true); + expect(await db.select().from(units)).toEqual([]); +}); diff --git a/v5/src/app/admin/inventory/unit-actions.ts b/v5/src/app/admin/inventory/unit-actions.ts new file mode 100644 index 0000000..632639e --- /dev/null +++ b/v5/src/app/admin/inventory/unit-actions.ts @@ -0,0 +1,67 @@ +"use server"; + +import type { NewUnit, UnitPatch } from "../../../lib/data/units"; +import { + addUnit as addUnitWrite, + editUnit as editUnitWrite, + removeUnit as removeUnitWrite, + retireUnitForTool, + type UnitWritePayload, +} from "../../../lib/inventory/unit-edits"; +import type { InventoryActionResult } from "./action-result"; +import { withToolEdit, type ToolWriteInput } from "./tool-write-context"; + +/** + * The Units section of the tool editor (spec §5.3(3), §4.5). + * + * Its own module because the panel fires these one at a time, without a full + * save: adding a machine, marking one out of service and correcting a serial + * number are three separate acts, and the person doing them is usually standing + * in front of the machine. + * + * **Each one gates itself on `tools.edit`** — a server action is a POST + * endpoint reachable without the panel — and each carries the panel's revision + * token, so a unit write refuses if somebody else has touched this tool since + * the panel opened. The write itself touches the tool in the same transaction + * (see `src/lib/inventory/tool-transaction.ts`), which is what makes the tool's + * token mean something for a unit-only edit. + * + * **No audit events**: a unit going out of service is an ordinary edit (§4.11). + */ + +/** Add a unit. A duplicate serial refuses with `duplicate_serial`; §4.5. */ +export async function addUnit( + input: ToolWriteInput & { unit: NewUnit } +): Promise> { + return withToolEdit(input, (context) => addUnitWrite(context, input.unit)); +} + +/** Edit label, serial, asset tag, status, condition, date acquired or notes. */ +export async function editUnit( + input: ToolWriteInput & { unitId: string; patch: UnitPatch } +): Promise> { + return withToolEdit(input, (context) => editUnitWrite(context, input.unitId, input.patch)); +} + +/** + * Retire a unit — the answer for a machine that is gone but has a history, + * which is most of them (§5.3 "Deleting"). + */ +export async function retireUnit( + input: ToolWriteInput & { unitId: string } +): Promise> { + return withToolEdit(input, (context) => retireUnitForTool(context, input.unitId)); +} + +/** + * Delete a unit, which usually refuses. + * + * `unit_has_history` is the refusal the panel turns into "retire it instead": + * `maintenance_logs.unit_id` is `on delete set null`, so Postgres would let the + * delete through and quietly detach every ticket filed against the machine. + */ +export async function deleteUnit( + input: ToolWriteInput & { unitId: string } +): Promise> { + return withToolEdit(input, (context) => removeUnitWrite(context, input.unitId)); +} diff --git a/v5/src/app/admin/maintenance/action-result.ts b/v5/src/app/admin/maintenance/action-result.ts new file mode 100644 index 0000000..61c1f2d --- /dev/null +++ b/v5/src/app/admin/maintenance/action-result.ts @@ -0,0 +1,65 @@ +import type { AdminGateError } from "../../../lib/admin/action-result"; +import type { QueueActionResult } from "../../../lib/admin/queue-write"; + +/** + * What `/admin/maintenance`'s server action answers, and where it lives. + * + * Its own module with no directive, for the reason every admin surface has + * one: a `"use server"` module may export **only async functions**, because + * every export becomes a callable endpoint — a path constant and a result type + * cannot live there. And `TicketControls` is a client island that has to render + * these codes without pulling `next/headers` and the limiter into its graph. + */ + +/** The page this action belongs to, and the path it refreshes. */ +export const MAINTENANCE_PATH = "/admin/maintenance"; + +/** + * Why a ticket did not change. Every code has an `admin.errors.` message. + * + * {@link AdminGateError} is the shared half — not signed in, not permitted, + * over the ceiling, or "it did not land". The two below are this surface's own, + * passed through from `updateMaintenanceLog` rather than re-spelled, so a code + * cannot drift between the two modules: + * + * - `not_found` — the ticket is gone, or never existed. + * - `invalid_field` — a status or priority outside its vocabulary. Refused + * before Postgres sees it, whose CHECK constraint would reject the whole + * statement with a message no page can render. + */ +export type MaintenanceWriteError = "not_found" | "invalid_field"; + +export type MaintenanceActionError = AdminGateError | MaintenanceWriteError; + +/** + * Built from {@link MaintenanceWriteError} rather than from the codes again: + * spelling them twice is how the union the page renders and the union the + * action answers drift apart, and nothing would fail until a reviewer saw a + * blank line where a refusal should be. + */ +export type MaintenanceActionResult = QueueActionResult; + +/** + * What the controls may change about a ticket (spec §5.6). + * + * A patch, not a record: only the keys the control sent are written, so + * assigning a ticket cannot blank the resolution somebody typed a moment + * earlier from another screen. + */ +export interface TicketPatch { + /** One of `MAINTENANCE_STATUS`. */ + status?: string; + /** One of `MAINTENANCE_PRIORITY`, or null to clear it. */ + priority?: string | null; + /** `user.id`, or null to unassign. */ + assignedToUserId?: string | null; + /** The assignee's name, stored beside the id as the snapshot §4.8 wants. */ + assignedToName?: string | null; + resolution?: string | null; +} + +/** The shape `MaintenanceQueue` hands its island, and the page hands the queue. */ +export type UpdateTicketAction = (input: { + logId: string; + patch: TicketPatch; +}) => Promise; diff --git a/v5/src/app/admin/maintenance/actions.test.ts b/v5/src/app/admin/maintenance/actions.test.ts new file mode 100644 index 0000000..8155f6f --- /dev/null +++ b/v5/src/app/admin/maintenance/actions.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +/** + * Withhold or grant one permission, to prove the action asks for its own. + * + * No role in `permissions.ts` holds `tools.edit` without also holding + * `maintenance.manage`, so the only way to test "this endpoint checks the + * permission it needs, not one that happens to travel with it" is to take the + * declaration out of the picture for one test. Null means the real `can()`, so + * every other test in this file runs against the genuine article. + */ +const override = vi.hoisted(() => ({ permissions: null as Set | null })); + +vi.mock("../../../lib/auth/permissions", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + can: (subject: Parameters[0], permission: string) => + override.permissions + ? override.permissions.has(permission) + : actual.can(subject, permission as Parameters[1]), + }; +}); + +import { eq } from "drizzle-orm"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { maintenanceLogs, session, tools, units, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { updateTicket } from "./actions"; + +/** + * `/admin/maintenance`'s one endpoint (spec §5.6, §8). + * + * Called directly, with no page — which is the point: a server action is a POST + * endpoint with a generated name, reachable by anybody who can read the page + * source, so the gate is the only thing standing in front of the write. The + * write itself is covered in `src/lib/data/maintenance.test.ts`; what is under + * test here is who may call it, and that every refusal comes back as a code the + * island can render rather than as an exception. + */ + +const AUTH_SECRET = "admin-maintenance-test-secret"; + +let db: Db; +let ticketId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + vi.stubEnv("LAB_TIMEZONE", "America/New_York"); + resetAuthForTests(); + override.permissions = null; + + db = await getDb(); + await db.delete(maintenanceLogs); + await db.delete(units); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + const [tool] = await db + .insert(tools) + .values({ slug: "trotec", name: "Trotec Speedy 400" }) + .returning({ id: tools.id }); + const [ticket] = await db + .insert(maintenanceLogs) + .values({ + title: "Laser bed out of focus", + status: "open", + priority: "high", + toolId: tool.id, + reportedByName: "Casey", + }) + .returning({ id: maintenanceLogs.id }); + ticketId = ticket.id; +}); + +afterEach(() => { + override.permissions = null; + resetAuthForTests(); + resetDbForTests(); +}); + +async function storedTicket() { + const [row] = await db.select().from(maintenanceLogs).where(eq(maintenanceLogs.id, ticketId)); + return row; +} + +async function asSuperMaker(email = "niti@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +it("refuses an anonymous caller, and changes nothing", async () => { + setMockHeaders(); + + expect(await updateTicket({ logId: ticketId, patch: { status: "resolved" } })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect((await storedTicket()).status).toBe("open"); +}); + +it("refuses a student, who may file a ticket but not work one", async () => { + const student = await signInAsNew({ email: "casey@cornell.edu", role: "user" }); + setMockHeaders({ cookie: student.cookie }); + + expect(await updateTicket({ logId: ticketId, patch: { status: "closed" } })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect((await storedTicket()).status).toBe("open"); +}); + +it("checks maintenance.manage, not a permission that happens to travel with it", async () => { + await asSuperMaker(); + + // Somebody who may edit the catalogue but was never given the tickets. + override.permissions = new Set(["tools.edit", "tools.publish"]); + expect(await updateTicket({ logId: ticketId, patch: { status: "resolved" } })).toEqual({ + ok: false, + error: "not_permitted", + }); + + override.permissions = new Set(["maintenance.manage"]); + expect(await updateTicket({ logId: ticketId, patch: { status: "resolved" } })).toEqual({ + ok: true, + }); +}); + +it("moves a ticket, stamps who moved it and refreshes the queue", async () => { + const signedIn = await asSuperMaker(); + const { revalidatePath } = await import("next/cache"); + + expect(await updateTicket({ logId: ticketId, patch: { status: "in_progress" } })).toEqual({ + ok: true, + }); + + const row = await storedTicket(); + expect(row.status).toBe("in_progress"); + expect(row.updatedBy).toBe(signedIn.user.id); + expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith("/admin/maintenance"); +}); + +it("assigns a ticket, keeping the name beside the id", async () => { + await asSuperMaker(); + const assignee = await signInAsNew({ email: "luis@cornell.edu", role: "admin" }); + + expect( + await updateTicket({ + logId: ticketId, + patch: { assignedToUserId: assignee.user.id, assignedToName: "Luis" }, + }) + ).toEqual({ ok: true }); + + const row = await storedTicket(); + expect(row.assignedToUserId).toBe(assignee.user.id); + expect(row.assignedToName).toBe("Luis"); +}); + +it("passes the write layer's refusals through as codes, not exceptions", async () => { + await asSuperMaker(); + + expect(await updateTicket({ logId: ticketId, patch: { status: "spicy" } })).toEqual({ + ok: false, + error: "invalid_field", + }); + expect(await updateTicket({ logId: crypto.randomUUID(), patch: { status: "open" } })).toEqual({ + ok: false, + error: "not_found", + }); + // Still open: a refused write is a write that did not happen. + expect((await storedTicket()).status).toBe("open"); +}); + +it("refuses once the caller is over the ceiling, without touching the row", async () => { + await asSuperMaker(); + + // `ADMIN_ACTION_TIER` is 120 a minute per person (§8). The 121st is refused + // — and the limiter runs before the permission check, so an anonymous + // prodder spends their own key rather than finding that refusals are free. + for (let attempt = 0; attempt < 120; attempt += 1) { + await updateTicket({ logId: ticketId, patch: { status: "open" } }); + } + + expect(await updateTicket({ logId: ticketId, patch: { status: "closed" } })).toEqual({ + ok: false, + error: "rate_limited", + }); + expect((await storedTicket()).status).toBe("open"); +}); diff --git a/v5/src/app/admin/maintenance/actions.ts b/v5/src/app/admin/maintenance/actions.ts new file mode 100644 index 0000000..172eaae --- /dev/null +++ b/v5/src/app/admin/maintenance/actions.ts @@ -0,0 +1,53 @@ +"use server"; + +import { runQueueWrite } from "../../../lib/admin/queue-write"; +import { updateMaintenanceLog } from "../../../lib/data/maintenance"; +import { + MAINTENANCE_PATH, + type MaintenanceActionResult, + type TicketPatch, +} from "./action-result"; + +/** + * Working a ticket (spec §5.6, §4.8, §8). + * + * **It checks `maintenance.manage` for itself.** A server action is a POST + * endpoint with a generated name, reachable without the page that offers the + * control, so the page's own gate is evidence of nothing — and this is the + * permission this surface needs, not `tools.edit`, which a SuperMaker might + * hold without ever having been given the tickets. + * + * **No audit event, deliberately.** §4.11 scopes the trail to security-relevant + * actions and says ordinary edits are not logged; moving a ticket to "in + * progress" is the most ordinary edit in the lab. `maintenance_logs` carries + * `updated_by` and `updated_at`, which is the record this change earns. + * + * **And no cache invalidation.** Nothing cached reads a ticket: the catalogue + * shows unit *status*, which is the tool editor's field and a different write. + * Busting the catalogue here would cost a full re-read every time somebody + * ticked a box. + */ + +/** Names this surface in the console line a failure leaves behind. */ +const SURFACE = "admin/maintenance"; + +/** + * Change one ticket's status, priority, assignee or resolution. + * + * One action rather than four, because the controls are four views of one row + * and the page saves each of them the moment it changes — a queue somebody has + * ten minutes for cannot afford a Save button per field. The patch shape is + * what keeps that safe: an unsent key is not written. + */ +export async function updateTicket(input: { + logId: string; + patch: TicketPatch; +}): Promise { + return runQueueWrite({ + permission: "maintenance.manage", + path: MAINTENANCE_PATH, + surface: SURFACE, + write: (identity) => + updateMaintenanceLog(input.logId, input.patch, { actorUserId: identity.userId }), + }); +} diff --git a/v5/src/app/admin/maintenance/page.tsx b/v5/src/app/admin/maintenance/page.tsx new file mode 100644 index 0000000..0a4683a --- /dev/null +++ b/v5/src/app/admin/maintenance/page.tsx @@ -0,0 +1,64 @@ +import { getTranslations } from "next-intl/server"; +import { AdminNotice } from "../../../components/admin/AdminNotice"; +import { MaintenanceQueue } from "../../../components/admin/MaintenanceQueue"; +import { resolveIdentityFromHeaders } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { listMaintenanceQueue } from "../../../lib/data/maintenance"; +import { listAssignableStaff } from "../../../lib/data/users"; +import { siteConfig } from "../../../lib/site-config"; +import { updateTicket } from "./actions"; + +/** + * `/admin/maintenance` — the ticket queue (spec §5.6, §6). + * + * Requires `maintenance.manage`. The layout above answered the coarse question + * and let anyone holding an admin permission through; the exact refusal happens + * here and is *said*, the way `/admin/users` and `/admin/inventory` say it. A + * 404 would claim the page does not exist, which is a lie told to somebody who + * is signed in. + * + * **Nothing here is cached.** A queue is a picture of what is open right now, + * and the one thing it must not do is show a ticket somebody already closed. + * The identity read makes this subtree dynamic anyway, and the action calls + * `revalidatePath` for the same reason. + * + * **The action travels down as a prop.** A client island that imported it would + * drag `next/headers`, the limiter and `server-only` into the browser bundle + * and stop being testable. Handing it down is not a grant — it checks + * `maintenance.manage` itself, because it is a POST endpoint reachable without + * this page (§8). + */ + +export const metadata = { + title: `Maintenance — ${siteConfig.name}`, +}; + +export default async function AdminMaintenancePage() { + const t = await getTranslations("admin"); + const identity = await resolveIdentityFromHeaders(); + + if (!can(identity, "maintenance.manage")) return ; + + // The roster read is the assignee list, not an authorization input: assigning + // a ticket grants nobody anything (see `listAssignableStaff`). + const [tickets, staff] = await Promise.all([listMaintenanceQueue(), listAssignableStaff()]); + + return ( +
+
+

{t("eyebrow")}

+

{t("maintenanceTitle")}

+ {/* No placeholder in this string: `/admin/page.tsx` renders the same + key without arguments, and a next-intl placeholder with no argument + renders literally (Article 6 — this has been a real bug here). */} +

{t("maintenanceLede")}

+
+ + ({ id: person.id, name: person.name }))} + action={updateTicket} + /> +
+ ); +} diff --git a/v5/src/app/admin/page.tsx b/v5/src/app/admin/page.tsx index b121104..ae743a3 100644 --- a/v5/src/app/admin/page.tsx +++ b/v5/src/app/admin/page.tsx @@ -1,38 +1,59 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { resolveIdentityFromHeaders } from "../../lib/auth/identity"; -import { can } from "../../lib/auth/permissions"; +import { can, type Permission } from "../../lib/auth/permissions"; /** * `/admin` — the index the header's `AdminLink` points at. * - * Phase 4 builds one surface, so this is a short list rather than the - * `AdminHome` of spec §6 (counts, the intake queue, open tickets, mirror - * status) — those need the tables and queues later phases add. What it must do - * now is be honest: a SuperMaker who holds `tools.edit` but not `users.manage` - * reaches this page, sees nothing they can open yet, and is told that, instead - * of following a link into a refusal. + * This is still a short list rather than the `AdminHome` of spec §6 (counts, + * the intake queue, open tickets, mirror status) — those need the tables Phase + * 6 adds. What it must do now is be honest: it lists exactly the surfaces the + * viewer's own permissions open, so nobody follows a link into a refusal, and + * a SuperMaker who holds `tools.edit` but not `users.manage` sees the inventory + * and not the roster. * * The layout above has already established that this person may see an admin * surface at all. */ +/** + * Every admin page, with the permission that opens it and the two message keys + * that name it. + * + * A list rather than a stack of conditionals, because it is now long enough + * that a page added without an entry here would simply be unreachable — and + * because each permission is checked exactly once, against the same `can()` + * the page itself calls. The ledes are shared with each page's own header, so + * none of them takes an argument: a next-intl placeholder rendered without one + * renders literally, which has been a real bug here (Article 6). + */ +const SURFACES: ReadonlyArray<{ href: string; permission: Permission; key: string }> = [ + { href: "/admin/inventory", permission: "tools.edit", key: "inventory" }, + { href: "/admin/maintenance", permission: "maintenance.manage", key: "maintenance" }, + { href: "/admin/corrections", permission: "feedback.manage", key: "corrections" }, + { href: "/admin/projects", permission: "projects.moderate", key: "projects" }, + { href: "/admin/users", permission: "users.manage", key: "users" }, +]; + export default async function AdminHomePage() { const t = await getTranslations("admin"); const identity = await resolveIdentityFromHeaders(); - const manageUsers = can(identity, "users.manage"); + const open = SURFACES.filter((surface) => can(identity, surface.permission)); return (

{t("eyebrow")}

{t("indexTitle")}

- {manageUsers ? ( -
    -
  • - {t("usersTitle")} - {t("usersLede")} -
  • + {open.length > 0 ? ( +
      + {open.map((surface) => ( +
    • + {t(`${surface.key}Title`)} + {t(`${surface.key}Lede`)} +
    • + ))}
    ) : (

    {t("indexNothingYet")}

    diff --git a/v5/src/app/admin/projects/action-result.ts b/v5/src/app/admin/projects/action-result.ts new file mode 100644 index 0000000..68a2255 --- /dev/null +++ b/v5/src/app/admin/projects/action-result.ts @@ -0,0 +1,33 @@ +import type { AdminGateError } from "../../../lib/admin/action-result"; +import type { QueueActionResult } from "../../../lib/admin/queue-write"; + +/** + * What `/admin/projects`' server action answers, and where it lives. + * + * Directive-free for the reason every admin surface's result module is: a + * `"use server"` module may export only async functions, and the client island + * has to render these codes without importing the endpoint to get at its shape. + */ + +/** The page this action belongs to, and the path it refreshes. */ +export const ADMIN_PROJECTS_PATH = "/admin/projects"; + +/** + * Why a project did not change. + * + * Only one code of its own: publishing takes no input but a boolean, so there + * is no field to be invalid. `not_found` means the submission is gone — + * somebody deleted it, or the page has been open since before it was. + */ +export type ProjectWriteError = "not_found"; + +export type ProjectActionError = AdminGateError | ProjectWriteError; + +/** One declaration of the codes, two shapes built from it — see `/admin/maintenance`. */ +export type ProjectActionResult = QueueActionResult; + +/** The shape `ProjectQueue` hands its island, and the page hands the queue. */ +export type SetProjectPublishedAction = (input: { + projectId: string; + published: boolean; +}) => Promise; diff --git a/v5/src/app/admin/projects/actions.test.ts b/v5/src/app/admin/projects/actions.test.ts new file mode 100644 index 0000000..88c2766 --- /dev/null +++ b/v5/src/app/admin/projects/actions.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment node +import { nextCacheMock } from "../../../../test/mocks/next-cache"; +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/cache", () => nextCacheMock()); +vi.mock("next/headers", () => nextHeadersMock()); + +/** Withhold or grant one permission — see `admin/corrections/actions.test.ts`. */ +const override = vi.hoisted(() => ({ permissions: null as Set | null })); + +vi.mock("../../../lib/auth/permissions", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + can: (subject: Parameters[0], permission: string) => + override.permissions + ? override.permissions.has(permission) + : actual.can(subject, permission as Parameters[1]), + }; +}); + +/** + * A database that answers the *second* statement with an error. + * + * The audit insert happens after the row has already changed, so this is not an + * exotic failure: they are two statements, and either can fail on its own. + */ +const audit = vi.hoisted(() => ({ failing: false })); + +vi.mock("../../../lib/data/audit", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordAuditEvent: async (event: Parameters[0]) => { + if (audit.failing) throw new Error("connection terminated unexpectedly"); + return actual.recordAuditEvent(event); + }, + }; +}); + +import { eq } from "drizzle-orm"; +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { auditEvents, projects, session, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { PROJECTS_TAG } from "../../../lib/revalidate"; +import { signInAsNew } from "../../../../test/utils/session"; +import { setPublished } from "./actions"; + +/** + * The moderation gate's endpoint (spec §5.6, §4.10, §4.11, Article 5). + * + * Called directly, with no page: a server action is a POST endpoint with a + * generated name, and this one decides what the public gallery shows — so the + * gate in front of it is the whole of Article 5's "publishing takes a person + * with the permission, in the app". + */ + +const AUTH_SECRET = "admin-projects-test-secret"; + +let db: Db; +let projectId: string; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + override.permissions = null; + audit.failing = false; + + db = await getDb(); + await db.delete(auditEvents); + await db.delete(projects); + await db.delete(session); + await db.delete(user); + + const [row] = await db + .insert(projects) + .values({ + slug: "resin-dice-tower", + title: "Resin dice tower", + body: "A dice tower.", + published: false, + }) + .returning({ id: projects.id }); + projectId = row.id; +}); + +afterEach(() => { + override.permissions = null; + audit.failing = false; + resetAuthForTests(); + resetDbForTests(); +}); + +async function storedProject() { + const [row] = await db.select().from(projects).where(eq(projects.id, projectId)); + return row; +} + +async function asSuperMaker(email = "niti@cornell.edu") { + const signedIn = await signInAsNew({ email, role: "admin" }); + setMockHeaders({ cookie: signedIn.cookie }); + return signedIn; +} + +it("refuses an anonymous caller, and the submission stays out of the gallery", async () => { + setMockHeaders(); + + expect(await setPublished({ projectId, published: true })).toEqual({ + ok: false, + error: "not_signed_in", + }); + expect((await storedProject()).published).toBe(false); +}); + +it("refuses a student, including the one who submitted it", async () => { + const student = await signInAsNew({ email: "casey@cornell.edu", role: "user" }); + setMockHeaders({ cookie: student.cookie }); + + expect(await setPublished({ projectId, published: true })).toEqual({ + ok: false, + error: "not_permitted", + }); + expect((await storedProject()).published).toBe(false); +}); + +it("checks projects.moderate, not tools.publish", async () => { + await asSuperMaker(); + + // Publishing a machine and publishing somebody's write-up are different + // jobs, and `permissions.ts` declares them separately so they can diverge. + override.permissions = new Set(["tools.publish", "tools.edit"]); + expect(await setPublished({ projectId, published: true })).toEqual({ + ok: false, + error: "not_permitted", + }); + + override.permissions = new Set(["projects.moderate"]); + expect(await setPublished({ projectId, published: true })).toEqual({ ok: true }); +}); + +it("publishes, stamps the row, records the event and busts the gallery cache", async () => { + const signedIn = await asSuperMaker(); + const { revalidatePath, revalidateTag } = await import("next/cache"); + + expect(await setPublished({ projectId, published: true })).toEqual({ ok: true }); + + const row = await storedProject(); + expect(row.published).toBe(true); + expect(row.publishedBy).toBe(signedIn.user.id); + expect(row.publishedAt).toBeInstanceOf(Date); + + const [event] = await db.select().from(auditEvents); + expect(event).toMatchObject({ + action: "project.published", + subjectType: "project", + subjectId: projectId, + actorUserId: signedIn.user.id, + }); + + // Unlike a submission, which deliberately invalidates nothing: this write is + // the one that changes which rows the cached gallery should hold (§3.9). + expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(PROJECTS_TAG, "minutes"); + expect(vi.mocked(revalidatePath)).toHaveBeenCalledWith("/admin/projects"); +}); + +it("unpublishes as its own audited event, clearing the stamps", async () => { + await asSuperMaker(); + await setPublished({ projectId, published: true }); + + expect(await setPublished({ projectId, published: false })).toEqual({ ok: true }); + + const row = await storedProject(); + expect(row.published).toBe(false); + expect(row.publishedAt).toBeNull(); + expect(row.publishedBy).toBeNull(); + + const actions = (await db.select().from(auditEvents)).map((event) => event.action); + expect(actions).toContain("project.published"); + expect(actions).toContain("project.unpublished"); +}); + +it("answers not_found for an unknown id, and publishes nothing", async () => { + await asSuperMaker(); + + expect(await setPublished({ projectId: crypto.randomUUID(), published: true })).toEqual({ + ok: false, + error: "not_found", + }); + expect((await storedProject()).published).toBe(false); +}); + +it("keeps the publish and warns when the audit event cannot be written", async () => { + await asSuperMaker(); + audit.failing = true; + + // **Never `{ ok: false }` for a write that landed.** The island answers a + // refusal by restoring the previous value, which would leave the page saying + // the project is unpublished over a database that has published it (§4.11). + expect(await setPublished({ projectId, published: true })).toEqual({ + ok: true, + warning: "audit_unavailable", + }); + + expect((await storedProject()).published).toBe(true); + expect(await db.select().from(auditEvents)).toEqual([]); +}); diff --git a/v5/src/app/admin/projects/actions.ts b/v5/src/app/admin/projects/actions.ts new file mode 100644 index 0000000..7c1f0d4 --- /dev/null +++ b/v5/src/app/admin/projects/actions.ts @@ -0,0 +1,73 @@ +"use server"; + +import { record } from "../../../lib/admin/audit-warning"; +import { runQueueWrite } from "../../../lib/admin/queue-write"; +import { setProjectPublished } from "../../../lib/data/projects"; +import { invalidateProjects } from "../../../lib/revalidate"; +import { ADMIN_PROJECTS_PATH, type ProjectActionResult } from "./action-result"; + +/** + * The moderation gate (spec §5.6, §4.10, Article 5). + * + * This is the one queue action that is *not* an ordinary edit, and it shows in + * all three ways: + * + * - **It is audited.** `project.published` and `project.unpublished` are in + * `AUDIT_ACTIONS` (§4.11) because deciding what the public gallery shows is + * exactly the kind of act the trail exists for. A lost audit event is still a + * **warning on a success**, never a failure: the row has already changed by + * the time the event is written, and answering `{ ok: false }` would make the + * island restore the previous value and assert a state the database no longer + * holds (Article 4). That channel is `src/lib/admin/audit-warning.ts`, shared + * with `/admin/users` and the tool editor. + * - **It invalidates.** `invalidateProjects()` — the cached gallery is + * published-only, and this write is the only thing that changes which rows + * that means. `createProjectSubmission` deliberately invalidates nothing and + * says why; do not copy that reasoning here, it is the opposite case. + * - **It checks `projects.moderate`**, which is its own permission and not + * `tools.publish`. Publishing a machine and publishing somebody's write-up + * are different jobs, and the declaration already says so. + */ + +/** Names this surface in the console line a missing audit event leaves behind. */ +const SURFACE = "admin/projects"; + +/** + * Publish or unpublish one submission. + * + * Both directions through one action, because they are one decision made twice: + * "this belongs in the gallery" and "on reflection it does not". The audit + * vocabulary has a separate action for each, so the trail reads as two events + * rather than one with a flag — unlike `tool.archived`, which has no + * counterpart to pair with. + */ +export async function setPublished(input: { + projectId: string; + published: boolean; +}): Promise { + return runQueueWrite({ + permission: "projects.moderate", + path: ADMIN_PROJECTS_PATH, + surface: SURFACE, + write: (identity) => + setProjectPublished(input.projectId, input.published, { actorUserId: identity.userId }), + afterCommit: async (identity) => { + const recorded = await record( + { + actorUserId: identity.userId, + action: input.published ? "project.published" : "project.unpublished", + subjectType: "project", + subjectId: input.projectId, + }, + SURFACE + ); + + // After the commit and after the event, never before either: a rolled-back + // write has nothing to show, and busting the gallery for it would cost a + // full re-read for free. + invalidateProjects(); + + return recorded ? undefined : "audit_unavailable"; + }, + }); +} diff --git a/v5/src/app/admin/projects/page.tsx b/v5/src/app/admin/projects/page.tsx new file mode 100644 index 0000000..cdb9348 --- /dev/null +++ b/v5/src/app/admin/projects/page.tsx @@ -0,0 +1,53 @@ +import { getTranslations } from "next-intl/server"; +import { AdminNotice } from "../../../components/admin/AdminNotice"; +import { ProjectQueue } from "../../../components/admin/ProjectQueue"; +import { resolveIdentityFromHeaders } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { listProjectsForModeration } from "../../../lib/data/projects"; +import { siteConfig } from "../../../lib/site-config"; +import { setPublished } from "./actions"; + +/** + * `/admin/projects` — the moderation gate (spec §5.6, §5.5, Article 5). + * + * Requires `projects.moderate`. This is the page Article 5 names: a submission + * is written unpublished and stays invisible until a person with the permission + * says otherwise, in the app, and it is recorded. Nothing else in the codebase + * can publish a project — `createProjectSubmission` takes no `published` + * parameter at all. + * + * **Nothing here is cached**, and the action invalidates the *gallery's* cache + * when it lands, because publishing is precisely the event that changes what + * the cached gallery should show (§3.9). + * + * The action travels down as a prop and re-checks its own permission — a server + * action is a POST endpoint reachable without this page (§8). + */ + +export const metadata = { + title: `Projects — ${siteConfig.name}`, +}; + +export default async function AdminProjectsPage() { + const t = await getTranslations("admin"); + const identity = await resolveIdentityFromHeaders(); + + if (!can(identity, "projects.moderate")) return ; + + const projects = await listProjectsForModeration(); + + return ( +
    +
    +

    {t("eyebrow")}

    +

    {t("projectsTitle")}

    + {/* No placeholder in this string: `/admin/page.tsx` renders the same + key without arguments, and a next-intl placeholder with no argument + renders literally (Article 6 — this has been a real bug here). */} +

    {t("projectsLede")}

    +
    + + +
    + ); +} diff --git a/v5/src/app/admin/users/action-result.ts b/v5/src/app/admin/users/action-result.ts index 2208f48..5ce79e2 100644 --- a/v5/src/app/admin/users/action-result.ts +++ b/v5/src/app/admin/users/action-result.ts @@ -1,3 +1,4 @@ +import type { AdminActionWarning, AdminGateError } from "../../../lib/admin/action-result"; import type { Role } from "../../../lib/db/schema/vocabulary"; /** @@ -8,6 +9,9 @@ import type { Role } from "../../../lib/db/schema/vocabulary"; * callable endpoint. A shared constant and a result type therefore cannot live * there, and a client island that only needs the shape should not have to * import the endpoints to get it. + * + * The codes every admin surface shares live in `src/lib/admin/action-result.ts`; + * this module adds the ones only this page can answer. */ /** The page these actions belong to, and the path they invalidate. */ @@ -17,39 +21,31 @@ export const ADMIN_USERS_PATH = "/admin/users"; * Why an action did nothing. Every code has an `admin.errors.` message in * `messages/en.json`; the union is what keeps the two in step. * - * - `not_signed_in` / `not_permitted` — told apart on purpose: one is - * actionable and the other is not. + * The first four are {@link AdminGateError} — signed in, permitted, within the + * rate ceiling, and "the write did not land" — shared with every other admin + * surface so a refusal reads the same wherever it happens. The rest are this + * page's own: + * + * - `unknown_user` / `invalid_role` — the target or the value. * - `protected_floor` — the address is in `AUTH_SUPER_ADMIN_EMAILS`. * - `last_super_admin` — the change would leave nobody holding `users.manage`. * - `self_ban` — banning yourself; the plugin refuses it too. - * - `failed` — the write did not land. Deliberately opaque to the browser. */ export type AdminActionError = - | "not_signed_in" - | "not_permitted" - | "rate_limited" + | AdminGateError | "unknown_user" | "invalid_role" | "protected_floor" | "last_super_admin" - | "self_ban" - | "failed"; + | "self_ban"; /** - * A change that landed with less than the full guarantee behind it. - * - * - `audit_unavailable` — the row changed and `audit_events` did not record it. - * - * It rides on `ok: true` deliberately. The plugin's write commits in its own - * statement, and with the Neon HTTP driver the audit insert is a second request - * that can fail on its own; reporting that as a failure would make the island - * snap back to the previous value and leave the page asserting a role the - * database no longer holds — the one thing `RoleSelect` promises never to do. - * Reporting it as a plain success would leave a hole in the trail nobody was - * told about (spec §4.11, Article 4). So it is a success that says what is - * missing, and every code has an `admin.warnings.` message. + * Re-exported, not redefined: the islands on this page import their result + * shape from here and would otherwise need a second import to render a warning + * they already received. See `src/lib/admin/action-result.ts` for why a missing + * audit event rides on a success. */ -export type AdminActionWarning = "audit_unavailable"; +export type { AdminActionWarning }; export type AdminActionResult = | { ok: true; role?: Role; banned?: boolean; warning?: AdminActionWarning } diff --git a/v5/src/app/admin/users/actions.ts b/v5/src/app/admin/users/actions.ts index 883ccb9..9861511 100644 --- a/v5/src/app/admin/users/actions.ts +++ b/v5/src/app/admin/users/actions.ts @@ -2,15 +2,14 @@ import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; +import { authorizeAdminAction } from "../../../lib/admin/action-gate"; +import { AUDIT_WARNING, record, warn } from "../../../lib/admin/audit-warning"; import { getAuth } from "../../../lib/auth/config"; import { reconcileSuperAdminFloor } from "../../../lib/auth/floor-role"; -import { resolveIdentityFromHeaders, type Identity } from "../../../lib/auth/identity"; -import { can } from "../../../lib/auth/permissions"; +import { type Identity } from "../../../lib/auth/identity"; import { isSuperAdminFloor } from "../../../lib/auth/super-admins"; -import { recordAuditEvent, type NewAuditEvent } from "../../../lib/data/audit"; import { countUsersWithRole, findUserById, type UserRecord } from "../../../lib/data/users"; import { isOneOf, ROLES, type Role } from "../../../lib/db/schema/vocabulary"; -import { ADMIN_ACTION_TIER, rateLimitAsync } from "../../../lib/rate-limit"; import { ADMIN_USERS_PATH, type AdminActionError, @@ -41,9 +40,13 @@ import { * * **And a change that lands without its audit event is a success with a * warning, not a failure.** The two writes are two statements and only the - * first one is the change; see {@link record}. + * first one is the change; `record` and `warn` come from + * `src/lib/admin/audit-warning.ts`, which every admin surface shares. */ +/** Names this surface in the console line a missing audit event leaves behind. */ +const AUDIT_SURFACE = "admin/users"; + /** * Change one person's role. * @@ -92,15 +95,18 @@ export async function setUserRole(input: { return { ok: false, error: "failed" }; } - const recorded = await record({ - actorUserId: identity.userId, - action: "role.changed", - subjectType: "user", - subjectId: target.id, - // Both halves: "became an admin" is not answerable later without the - // "from", and that is the question an audit trail exists to answer. - detail: { from: target.role, to: role }, - }); + const recorded = await record( + { + actorUserId: identity.userId, + action: "role.changed", + subjectType: "user", + subjectId: target.id, + // Both halves: "became an admin" is not answerable later without the + // "from", and that is the question an audit trail exists to answer. + detail: { from: target.role, to: role }, + }, + AUDIT_SURFACE + ); revalidatePath(ADMIN_USERS_PATH); return { ok: true, role, ...warn(gateWarning, recorded) }; @@ -164,16 +170,19 @@ export async function setUserBanned(input: { return { ok: false, error: "failed" }; } - const recorded = await record({ - actorUserId: identity.userId, - action: "user.banned", - subjectType: "user", - subjectId: target.id, - // `AUDIT_ACTIONS` has no `user.unbanned` (spec §4.11), so lifting a ban is - // the same action with `banned: false`. The alternative is a vocabulary - // that drifts from the spec, which is worse than a flag in the detail. - detail: { banned: input.banned, ...(reason ? { reason } : {}) }, - }); + const recorded = await record( + { + actorUserId: identity.userId, + action: "user.banned", + subjectType: "user", + subjectId: target.id, + // `AUDIT_ACTIONS` has no `user.unbanned` (spec §4.11), so lifting a ban is + // the same action with `banned: false`. The alternative is a vocabulary + // that drifts from the spec, which is worse than a flag in the detail. + detail: { banned: input.banned, ...(reason ? { reason } : {}) }, + }, + AUDIT_SURFACE + ); revalidatePath(ADMIN_USERS_PATH); return { @@ -190,14 +199,11 @@ type Gate = | { ok: false; error: AdminActionError }; /** - * Resolve the caller, bound their attempts, and check `users.manage`. - * - * Bounded *before* the permission check and the queries behind it (Article 4, - * §8: 120/min per user), and keyed on `rateLimitKey` — the user id when signed - * in, a hashed IP when not — so an anonymous prodder cannot spend an admin's - * allowance. + * Resolve the caller, bound their attempts, check `users.manage` — and then + * do the one thing that is this page's alone. * - * The last step is the one that is not a refusal: the floor is written onto the + * The first three are {@link authorizeAdminAction}, shared with every other + * admin surface. The last step is the one that is not a refusal: the floor is written onto the * caller's own row before either action calls the plugin. `can()` honours the * floor and the plugin does not — see `lib/auth/floor-role.ts` — so without * this a floor address whose row says `user` reaches the page with every @@ -206,18 +212,12 @@ type Gate = * a no-op for everyone whose row already agrees. */ async function authorize(): Promise { - const identity = await resolveIdentityFromHeaders(); - - const { allowed } = await rateLimitAsync( - `admin-action:${identity.rateLimitKey}`, - ADMIN_ACTION_TIER - ); - if (!allowed) return { ok: false, error: "rate_limited" }; - - // Told apart on purpose: "sign in" is actionable and "you are not permitted" - // is not, and showing the wrong one of those is how a page feels broken. - if (identity.role === "anonymous") return { ok: false, error: "not_signed_in" }; - if (!can(identity, "users.manage")) return { ok: false, error: "not_permitted" }; + // Identity, limiter, then the permission — the sequence every admin action + // shares, which is why it lives in `src/lib/admin/action-gate.ts` now rather + // than here. Only the step below it is this page's own. + const gate = await authorizeAdminAction("users.manage"); + if (!gate.ok) return gate; + const { identity } = gate; let reconciliation; try { @@ -288,55 +288,3 @@ async function requestHeaders(): Promise { if (cookie) copy.set("cookie", cookie); return copy; } - -// ── Recording it ──────────────────────────────────────────────────── - -/** The one warning either action can carry. Named so the two cannot drift. */ -const AUDIT_WARNING: AdminActionWarning = "audit_unavailable"; - -/** - * Write the audit event, and say whether it landed. - * - * **The order is not negotiable and neither is the shape.** The event describes - * a change that has already committed — `auth.api.setRole` / `banUser` have - * returned — and `recordAuditEvent` throws on any database failure. With the - * Neon HTTP driver each statement is its own request, so a transient 5xx - * between the two is an ordinary outcome rather than an exotic one, and there - * is no transaction spanning them to roll back. - * - * Letting the throw propagate would reach the island as a rejected action, and - * both islands answer a rejection by restoring the previous value: the page - * would show the old role over a database holding the new one, which is exactly - * what `RoleSelect`'s comment says it never does. Swallowing it silently would - * leave a gap in the trail nobody was told about (spec §4.11). - * - * So the failure becomes a `warning` on a successful result: the row changed, - * the page says so, and it also says the change was not recorded. The console - * line is the operator's copy — it is the only place the event now exists. - */ -/** - * The warning half of a successful result, or nothing. - * - * Two audit writes can go missing on one action — the floor reconciliation's, - * before the action ran, and the action's own — and there is one warning for - * both, because the admin's question is the same either way: *did the trail - * record this?* Spread into the result so a success without a gap carries no - * `warning` key at all. - */ -function warn( - gateWarning: AdminActionWarning | undefined, - recorded = true -): { warning?: AdminActionWarning } { - const warning = gateWarning ?? (recorded ? undefined : AUDIT_WARNING); - return warning ? { warning } : {}; -} - -async function record(event: NewAuditEvent): Promise { - try { - await recordAuditEvent(event); - return true; - } catch (err) { - console.error("[admin/users] audit write failed after the change landed", err); - return false; - } -} diff --git a/v5/src/app/api/admin/revalidate/route.ts b/v5/src/app/api/admin/revalidate/route.ts index d419955..e455bdb 100644 --- a/v5/src/app/api/admin/revalidate/route.ts +++ b/v5/src/app/api/admin/revalidate/route.ts @@ -1,7 +1,7 @@ -import { revalidateTag } from "next/cache"; import { resolveIdentity } from "../../../../lib/auth/identity"; import { can } from "../../../../lib/auth/permissions"; import { rateLimitAsync } from "../../../../lib/rate-limit"; +import { ALL_TAGS, invalidateCatalog, invalidateProjects } from "../../../../lib/revalidate"; /** * `POST /api/admin/revalidate` — drop the cached catalog and projects so the @@ -29,9 +29,6 @@ import { rateLimitAsync } from "../../../../lib/rate-limit"; */ const REVALIDATE_TIER = { limit: 30, windowMs: 60_000 }; -/** Tags every cached read is stored under; one refresh has to clear them all. */ -const TAGS = ["catalog", "projects"] as const; - export async function POST(req: Request) { const identity = await resolveIdentity(req); @@ -70,6 +67,9 @@ export async function POST(req: Request) { } } - for (const tag of TAGS) revalidateTag(tag, "minutes"); - return Response.json({ ok: true, tags: [...TAGS] }); + // Both, through the same helpers every inventory write uses, so a tag can + // never be spelled one way here and another way there. + invalidateCatalog(); + invalidateProjects(); + return Response.json({ ok: true, tags: [...ALL_TAGS] }); } diff --git a/v5/src/app/tools/[id]/DraftToolView.test.tsx b/v5/src/app/tools/[id]/DraftToolView.test.tsx new file mode 100644 index 0000000..6f648e2 --- /dev/null +++ b/v5/src/app/tools/[id]/DraftToolView.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment node +import { nextHeadersMock, setMockHeaders } from "../../../../test/mocks/next-headers"; + +vi.mock("next/headers", () => nextHeadersMock()); + +// `notFound()` throws in Next; here it throws something this test can name, so +// "the visitor gets the 404 page" is an assertion rather than an absence. +class NotFound extends Error {} +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new NotFound("not found"); + }, +})); + +import { resetAuthForTests } from "../../../lib/auth/config"; +import { getDb, resetDbForTests } from "../../../lib/db/client"; +import { session, tools, user } from "../../../lib/db/schema/index"; +import type { Db } from "../../../lib/db/types"; +import { signInAsNew } from "../../../../test/utils/session"; +import { DraftToolView } from "./DraftToolView"; + +/** + * Drafts at their own slug (spec §5.3(b)). + * + * **The refusal has to look the same as a slug nobody owns.** A distinct + * message — a redirect, a "you may not see this" — would confirm that the draft + * exists, which is precisely what `catalog.view_drafts` is withholding. So both + * cases are asserted to be the same 404. + */ + +const AUTH_SECRET = "draft-tool-view-test-secret"; + +let db: Db; + +beforeEach(async () => { + vi.stubEnv("DATABASE_URL", ""); + vi.stubEnv("AUTH_SECRET", AUTH_SECRET); + vi.stubEnv("AUTH_SUPER_ADMIN_EMAILS", ""); + resetAuthForTests(); + + db = await getDb(); + await db.delete(tools); + await db.delete(session); + await db.delete(user); + + await db.insert(tools).values({ + slug: "form-4", + name: "Form 4", + description: "A resin printer", + published: false, + }); +}); + +afterEach(() => { + resetAuthForTests(); + resetDbForTests(); +}); + +/** What the page renders, or the refusal it threw. */ +async function view(idOrSlug: string): Promise<"not-found" | "rendered"> { + try { + await DraftToolView({ idOrSlug }); + return "rendered"; + } catch (err) { + if (err instanceof NotFound) return "not-found"; + throw err; + } +} + +it("404s for an anonymous visitor", async () => { + setMockHeaders(); + expect(await view("form-4")).toBe("not-found"); +}); + +it("404s for a signed-in student", async () => { + const student = await signInAsNew({ email: "student@cornell.edu", role: "user" }); + setMockHeaders({ cookie: student.cookie }); + + expect(await view("form-4")).toBe("not-found"); +}); + +it("renders the draft for somebody holding catalog.view_drafts", async () => { + const maker = await signInAsNew({ email: "maker@cornell.edu", role: "admin" }); + setMockHeaders({ cookie: maker.cookie }); + + expect(await view("form-4")).toBe("rendered"); +}); + +it("gives the same 404 for a slug nobody owns, whoever is asking", async () => { + const maker = await signInAsNew({ email: "maker2@cornell.edu", role: "admin" }); + setMockHeaders({ cookie: maker.cookie }); + expect(await view("no-such-tool")).toBe("not-found"); + + setMockHeaders(); + expect(await view("no-such-tool")).toBe("not-found"); +}); + +it("404s an archived tool even for staff — archived is gone, not hidden", async () => { + await db.insert(tools).values({ + slug: "old-laser", + name: "Old laser", + published: true, + archivedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + const maker = await signInAsNew({ email: "maker3@cornell.edu", role: "admin" }); + setMockHeaders({ cookie: maker.cookie }); + + // It is still in `/admin/inventory`, which is where it can be restored. + expect(await view("old-laser")).toBe("not-found"); +}); diff --git a/v5/src/app/tools/[id]/DraftToolView.tsx b/v5/src/app/tools/[id]/DraftToolView.tsx new file mode 100644 index 0000000..c3bcd3c --- /dev/null +++ b/v5/src/app/tools/[id]/DraftToolView.tsx @@ -0,0 +1,38 @@ +import { notFound } from "next/navigation"; +import { DetailShell } from "../../../components/DetailShell"; +import { resolveIdentityFromHeaders } from "../../../lib/auth/identity"; +import { can } from "../../../lib/auth/permissions"; +import { findToolByIdOrSlug } from "../../../lib/data/catalog"; + +/** + * A draft tool at its own slug, for the people allowed to see one + * (spec §5.3(b)). + * + * **Everyone else gets the 404 page**, and gets it for both reasons at once: a + * slug nobody owns and a draft somebody may not see are the same refusal here. + * A different-looking message for the second would confirm that the draft + * exists, which is the thing `catalog.view_drafts` is withholding. + * + * **It is a dynamic hole inside its own Suspense boundary, on purpose.** + * `getCatalogTool` is `"use cache"` and published-only, and it cannot take an + * identity — a cached read that varied by caller would serve one person's + * answer to the next. So the published page keeps its fast path untouched, and + * only the *miss* reads headers, inside a boundary. Doing it any higher would + * mark the whole tool page dynamic under `cacheComponents` and lose the cache + * for every visitor. `QrArrivalNotice` is the same shape above it. + * + * No projects are loaded: "Built with this" lists published projects, and a + * tool that is not in the catalogue has none worth a second query. + */ + +export async function DraftToolView({ idOrSlug }: { idOrSlug: string }) { + const identity = await resolveIdentityFromHeaders(); + if (!can(identity, "catalog.view_drafts")) notFound(); + + // Uncached and draft-inclusive — the opposite of `getCatalogTool` in both + // respects, which is why it is a separate read rather than a flag on that one. + const tool = await findToolByIdOrSlug(idOrSlug, { includeDrafts: true }); + if (!tool) notFound(); + + return ; +} diff --git a/v5/src/app/tools/[id]/EditToolControl.test.tsx b/v5/src/app/tools/[id]/EditToolControl.test.tsx new file mode 100644 index 0000000..ec40763 --- /dev/null +++ b/v5/src/app/tools/[id]/EditToolControl.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, userEvent, waitFor } from "../../../../test/utils/render"; +import { EditToolControl } from "./EditToolControl"; +import type { ToolEditorActions } from "../../../components/admin/tool-editor-actions"; +import type { ClientIdentity } from "../../../lib/auth/sign-in-client"; + +// `fetchIdentity`'s own behaviour is covered in `lib/auth/sign-in-client.test.ts`; +// here it is the seam that decides whether the control exists at all. +const fetchIdentity = vi.fn<() => Promise>(async () => null); +vi.mock("../../../lib/auth/sign-in-client", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchIdentity: () => fetchIdentity() }; +}); + +/** + * Edit mode on a tool's own page (spec §5.3(b)). + * + * **The control asks who is calling after mount**, which is what keeps the tool + * page cached for everybody else — the same contract `AdminLink` has. So the + * states worth pinning are the three where nothing should appear: nobody signed + * in, nobody answered yet, and a signed-in student. + */ + +const ACTIONS = { + load: vi.fn(async () => ({ ok: false as const, error: "not_found" as const })), +} as unknown as ToolEditorActions; + +function renderControl() { + render(); +} + +function editButton() { + return screen.queryByRole("button", { name: "Edit this tool" }); +} + +beforeEach(() => { + fetchIdentity.mockReset(); + fetchIdentity.mockResolvedValue(null); +}); + +it("shows nothing to an anonymous visitor", async () => { + fetchIdentity.mockResolvedValue({ role: "anonymous", name: null }); + renderControl(); + + await waitFor(() => expect(fetchIdentity).toHaveBeenCalled()); + expect(editButton()).not.toBeInTheDocument(); +}); + +it("shows nothing while the answer is still outstanding", () => { + // A control that flashes into existence and back out is worse than one that + // arrives a moment late. + fetchIdentity.mockReturnValue(new Promise(() => {})); + renderControl(); + expect(editButton()).not.toBeInTheDocument(); +}); + +it("shows nothing when the identity could not be asked", async () => { + // A 429 from the identity tier, or lab wifi. No evidence is not a permission. + fetchIdentity.mockResolvedValue(null); + renderControl(); + + await waitFor(() => expect(fetchIdentity).toHaveBeenCalled()); + expect(editButton()).not.toBeInTheDocument(); +}); + +it("shows nothing to a signed-in student", async () => { + fetchIdentity.mockResolvedValue({ role: "user", name: "Ada" }); + renderControl(); + + await waitFor(() => expect(fetchIdentity).toHaveBeenCalled()); + expect(editButton()).not.toBeInTheDocument(); +}); + +it("offers the editor to a SuperMaker, as a full-screen sheet", async () => { + fetchIdentity.mockResolvedValue({ role: "admin", name: "Luis" }); + renderControl(); + + const button = await screen.findByRole("button", { name: "Edit this tool" }); + await userEvent.click(button); + + // The panel opens on the slug, and reads for itself rather than trusting + // anything the cached page rendered. + await waitFor(() => expect(ACTIONS.load).toHaveBeenCalledWith("form-4")); + expect(screen.getByRole("complementary").className).toContain("is-sheet"); +}); diff --git a/v5/src/app/tools/[id]/EditToolControl.tsx b/v5/src/app/tools/[id]/EditToolControl.tsx new file mode 100644 index 0000000..e9d54ca --- /dev/null +++ b/v5/src/app/tools/[id]/EditToolControl.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ToolEditorPanel } from "../../../components/admin/ToolEditorPanel"; +import type { ToolEditorActions } from "../../../components/admin/tool-editor-actions"; +import { fetchIdentity, type ClientIdentity } from "../../../lib/auth/sign-in-client"; +import { can } from "../../../lib/auth/permissions"; + +/** + * Edit mode on a tool's own page (spec §5.3(b), §6). + * + * **This is the phone-first one.** A SuperMaker marking a printer out of + * service is standing next to the machine with one hand free, so the way in is + * a single control on the page they are already looking at — the one the QR + * label on the machine opens — and the editor arrives as a full-screen sheet + * rather than a panel beside something. + * + * **It asks who is calling after mount, and that is what keeps the page + * cached.** The tool page is statically shelled and served from the catalogue + * cache; reading the session during render would make it dynamic for every + * visitor to buy a button for a handful of staff. `AdminLink` and + * `RefreshCatalogButton` established the pattern, and `role` may be `undefined` + * for the same reason: until `/api/identity` answers there is nothing to show. + * + * **Hiding is presentation.** Every action behind the panel re-checks + * `tools.edit` for itself, because a control that is absent from the DOM is + * absent for exactly as long as nobody calls the endpoint directly (§8). + */ + +export interface EditToolControlProps { + /** The tool's slug — what the panel's own read looks it up by. */ + slug: string; + toolName: string; + actions: ToolEditorActions; +} + +export function EditToolControl({ slug, toolName, actions }: EditToolControlProps) { + const t = useTranslations("admin.inventory.editor"); + const [identity, setIdentity] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + void fetchIdentity(controller.signal).then((answer) => { + if (!controller.signal.aborted) setIdentity(answer); + }); + return () => controller.abort(); + }, []); + + // `identity` is null while the answer is outstanding *and* when it could not + // be asked — both mean "no evidence this person may edit", which is the same + // as a student as far as this control is concerned. + if (!can(identity, "tools.edit")) return null; + + return ( +
    + + + {open ? ( + setOpen(false)} + /> + ) : null} +
    + ); +} diff --git a/v5/src/app/tools/[id]/page.tsx b/v5/src/app/tools/[id]/page.tsx index 6d8215d..4eb8b36 100644 --- a/v5/src/app/tools/[id]/page.tsx +++ b/v5/src/app/tools/[id]/page.tsx @@ -1,5 +1,7 @@ import { Suspense } from "react"; -import { notFound, permanentRedirect } from "next/navigation"; +import { permanentRedirect } from "next/navigation"; +import { DraftToolView } from "./DraftToolView"; +import { EditToolControl } from "./EditToolControl"; import { QrArrivalNotice } from "./QrArrivalNotice"; import { DetailShell } from "../../../components/DetailShell"; import { FlagButton } from "../../../components/FlagButton"; @@ -7,6 +9,52 @@ import { getCatalogTool } from "../../../lib/catalog"; import { findToolByNotionPageId } from "../../../lib/data/catalog"; import { isLegacyNotionId } from "../../../lib/legacy-id"; import { getProjectsForTool } from "../../../lib/projects"; +import type { ToolEditorActions } from "../../../components/admin/tool-editor-actions"; +import { + archive, + loadToolForEditor, + markToolReviewed, + publish, + restore, + saveTool, + unpublish, +} from "../../admin/inventory/actions"; +import { attachPhotos, removePhoto, reorderPhotos } from "../../admin/inventory/photo-actions"; +import { + addResource, + editResource, + removeResource, +} from "../../admin/inventory/resource-actions"; +import { addUnit, deleteUnit, editUnit, retireUnit } from "../../admin/inventory/unit-actions"; + +/** + * The tool editor's actions, handed to the page's Edit control (spec §5.3(b)). + * + * The same endpoints `/admin/inventory` uses — one editor, one set of writes, + * one place each permission is checked. They travel as props because a client + * island that imported them would drag `next/headers` and the limiter into the + * browser bundle; handing them down is not a grant, since every one of them + * re-checks its own permission (§8). + */ +const EDITOR_ACTIONS: ToolEditorActions = { + load: loadToolForEditor, + save: saveTool, + markReviewed: markToolReviewed, + publish, + unpublish, + archive, + restore, + addUnit, + editUnit, + retireUnit, + deleteUnit, + addResource, + editResource, + removeResource, + attachPhotos, + reorderPhotos, + removePhoto, +}; interface ToolDetailPageProps { params: Promise<{ @@ -48,7 +96,7 @@ export default async function ToolDetailPage({ params, searchParams }: ToolDetai // 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. + // falls through to the draft check below. if (isLegacyNotionId(id)) { const match = await findToolByNotionPageId(id); if (match) { @@ -56,7 +104,19 @@ export default async function ToolDetailPage({ params, searchParams }: ToolDetai permanentRedirect(`/tools/${match.slug}${query}`); } } - notFound(); + + // The catalogue read is cached and published-only, so a miss is not yet a + // 404: it may be a draft, and somebody holding `catalog.view_drafts` is + // allowed to open it (§5.3(b)). The identity read happens inside this + // boundary and nowhere above it, which is what keeps every *published* + // tool page prerenderable. `DraftToolView` calls `notFound()` for everyone + // else — the same refusal a slug nobody owns gets, so neither answer + // reveals that a draft exists. + return ( + + + + ); } // "Built with this" — published projects referencing this tool (empty if no @@ -72,6 +132,10 @@ export default async function ToolDetailPage({ params, searchParams }: ToolDetai + {/* Edit mode, phone-first (§5.3(b)). Another dynamic hole of its own: + the control asks `/api/identity` after mount, so the shell above it + stays cached for the visitors who are not staff. */} + {/* Quiet footer control for reporting a wrong field (report-a-correction spec §6). Deliberately below the content, not competing with it. */} diff --git a/v5/src/components/admin/CorrectionControls.tsx b/v5/src/components/admin/CorrectionControls.tsx new file mode 100644 index 0000000..c6a8ae7 --- /dev/null +++ b/v5/src/components/admin/CorrectionControls.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FEEDBACK_STATUS } from "../../lib/db/schema/vocabulary"; +import type { SetCorrectionStatusAction } from "../../app/admin/corrections/action-result"; +import { RowStatus } from "./RowStatus"; +import { useRowAction } from "./use-row-action"; + +/** + * Triaging one correction (spec §5.6). + * + * **Buttons, not a select.** Every other row control in `/admin` picks from a + * vocabulary with a ` update({ query: event.target.value })} + /> + + + + + + + + + + +
    +

    + {t("showing", { shown: visible.length, total: rows.length })} +

    + {active ? ( + + ) : null} +
    + + + + + {actions && editing ? ( + setEditing(null)} + /> + ) : null} + + ); +} + +/** The active filters as one readable phrase, each part translated. */ +function describeFilters( + filters: InventoryFilterState, + t: (key: string, values?: Record) => string +): string { + const parts: string[] = []; + const part = (label: string, value: string) => + parts.push(t("filterSummaryPart", { label, value })); + + if (filters.query.trim()) part(t("filterSearch"), filters.query.trim()); + if (filters.state) part(t("filterState"), t(`state.${filters.state}`)); + if (filters.category) part(t("filterCategory"), filters.category); + if (filters.location) part(t("filterLocation"), filters.location); + if (filters.attention) { + part( + t("filterAttention"), + filters.attention === "any" ? t("attentionAny") : t(`flags.${filters.attention}`) + ); + } + return parts.join(", "); +} + +/** The non-null values, deduplicated and sorted — a facet's option list. */ +function unique(values: Array): string[] { + return Array.from(new Set(values.filter((value): value is string => Boolean(value)))).sort(); +} diff --git a/v5/src/components/admin/InventoryTable.test.tsx b/v5/src/components/admin/InventoryTable.test.tsx new file mode 100644 index 0000000..4046f34 --- /dev/null +++ b/v5/src/components/admin/InventoryTable.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, within } from "../../../test/utils/render"; +import { InventoryTable } from "./InventoryTable"; +import type { InventoryAttention, InventoryRow } from "../../lib/data/inventory"; + +/** + * The review table's rendering. + * + * `InventoryTable` has no `async` and no data access, which is exactly why it + * can be mounted here: everything it shows arrived as a prop. + */ + +/** Overrides where `attention` may name just the flags the case is about. */ +type RowOverrides = Partial> & { + attention?: Partial; +}; + +function row(overrides: RowOverrides = {}): InventoryRow { + const attention = { + noPhoto: false, + noManual: false, + openTickets: false, + neverReviewed: false, + ...overrides.attention, + }; + return { + id: "t-form-4", + slug: "form-4", + name: "Form 4", + photoUrl: "https://blob.test/form-4.jpg", + categoryName: "Resin Printing", + categoryGroup: "3D Printing", + room: "Bloomberg 061", + zone: "Resin Bay", + unitCount: 2, + worstUnitStatus: "available", + state: "published", + openTicketCount: 0, + lastReviewedAt: new Date("2026-06-01T09:00:00.000Z"), + updatedAt: new Date("2026-09-20T12:00:00.000Z"), + ...overrides, + attention, + needsAttention: Object.values(attention).some(Boolean), + }; +} + +function rowFor(name: string) { + return screen.getByRole("row", { name: new RegExp(name) }); +} + +describe("InventoryTable", () => { + it("shows each tool's category, location, units and dates", () => { + render(); + + const tableRow = rowFor("Form 4"); + expect(within(tableRow).getByText("Resin Printing")).toBeInTheDocument(); + expect(within(tableRow).getByText("Bloomberg 061")).toBeInTheDocument(); + expect(within(tableRow).getByText("2")).toBeInTheDocument(); + expect(within(tableRow).getByText("Available")).toBeInTheDocument(); + expect(within(tableRow).getByText("2026-06-01")).toBeInTheDocument(); + expect(within(tableRow).getByText("2026-09-20")).toBeInTheDocument(); + }); + + it("links the name at its public page", () => { + render(); + expect(screen.getByRole("link", { name: "Form 4" })).toHaveAttribute("href", "/tools/form-4"); + }); + + it("lists drafts and archived tools, each marked with its state", () => { + render( + + ); + + expect(within(rowFor("A Tool")).getByText("Draft")).toBeInTheDocument(); + expect(within(rowFor("B Tool")).getByText("Archived")).toBeInTheDocument(); + }); + + it("marks a missing photo instead of showing a stand-in image", () => { + render(); + + expect(screen.getByLabelText("No photo")).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "" })).not.toBeInTheDocument(); + }); + + it("says a tool has never been reviewed rather than leaving the cell blank", () => { + render(); + expect(screen.getByText("Never")).toBeInTheDocument(); + }); + + it("names every reason a row needs attention, with the ticket count", () => { + render( + + ); + + const flags = within(screen.getByRole("list", { name: "Needs attention" })); + expect(flags.getByText("No photo")).toBeInTheDocument(); + expect(flags.getByText("No manual")).toBeInTheDocument(); + expect(flags.getByText("Never reviewed")).toBeInTheDocument(); + expect(flags.getByText("3 open")).toBeInTheDocument(); + }); + + it("shows no attention list at all for a row with nothing wrong", () => { + render(); + expect(screen.queryByRole("list", { name: "Needs attention" })).not.toBeInTheDocument(); + }); + + it("says a tool has no units rather than showing a bare zero", () => { + render( + + ); + expect(screen.getByText("None")).toBeInTheDocument(); + }); + + it("renders the caller's empty message instead of a table", () => { + render(); + + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByText("No tools match state: Draft.")).toBeInTheDocument(); + }); +}); diff --git a/v5/src/components/admin/InventoryTable.tsx b/v5/src/components/admin/InventoryTable.tsx new file mode 100644 index 0000000..6be4303 --- /dev/null +++ b/v5/src/components/admin/InventoryTable.tsx @@ -0,0 +1,189 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import type { InventoryAttention, InventoryRow } from "../../lib/data/inventory"; + +/** + * The review table on `/admin/inventory` (spec §5.3(a)1). + * + * A component with no `async` and no data access of its own, for the reason + * `UsersTable` is: everything it needs is a prop, so a component test mounts it + * with the ordinary i18n wrapper, and the client island that filters the rows + * can render it directly. + * + * **It never invents a photo.** The catalogue falls back to a bundled image + * named after the tool, which is right for a visitor and wrong here: the whole + * point of the "no photo" flag is that somebody has to go and take one. A tool + * with no public attachment gets a marked empty frame instead. + * + * **Dates are ISO** — the same locale-neutral mono treatment the roster gives + * them. A review table is read by two or three people who compare stamps; a + * formatted date would also render differently on the server and the client. + */ + +export interface InventoryTableProps { + rows: InventoryRow[]; + /** + * Open the tool editor on this row, or undefined when the surface offers no + * editor. The table stays presentational either way: the panel's state + * belongs to the island that owns the rows, not to the markup showing them. + */ + onEdit?: (row: InventoryRow) => void; + /** + * What to say when there is nothing to show. The caller owns this string + * because only the filter console knows *why* the table is empty, and + * "no results" on its own tells a reviewer nothing (spec §6, States). + */ + emptyMessage: string; +} + +/** The flags that are plain booleans, in the order the badges read. */ +const PLAIN_FLAGS: ReadonlyArray> = [ + "noPhoto", + "noManual", + "neverReviewed", +]; + +const FLAG_KEYS: Record<(typeof PLAIN_FLAGS)[number], string> = { + noPhoto: "no_photo", + noManual: "no_manual", + neverReviewed: "never_reviewed", +}; + +export function InventoryTable({ rows, emptyMessage, onEdit }: InventoryTableProps) { + const t = useTranslations("admin.inventory"); + + if (rows.length === 0) { + return

    {emptyMessage}

    ; + } + + return ( +
    + + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + + + + + + + + + ))} + +
    {t("columnPhoto")}{t("columnTool")}{t("columnCategory")}{t("columnLocation")}{t("columnUnits")}{t("columnState")}{t("columnReviewed")}{t("columnUpdated")}
    + {row.photoUrl ? ( + + + + ) : ( + + + + )} + + {/* Two ways in from one cell: the name opens the public page, + which is where a reviewer checks their own work, and Edit + opens the panel over this table without losing the filters. */} + + {row.name} + + {onEdit ? ( + + ) : null} + + + {row.categoryName ?? {t("uncategorized")}} + {row.categoryGroup ? ( + {row.categoryGroup} + ) : null} + + {row.room ?? {t("unplaced")}} + {row.zone ? {row.zone} : null} + + {row.unitCount === 0 ? ( + {t("noUnits")} + ) : ( + <> + {row.unitCount} + {row.worstUnitStatus ? ( + + {t(`unitStatus.${row.worstUnitStatus}`)} + + ) : null} + + )} + + {t(`state.${row.state}`)} + + {row.lastReviewedAt ? isoDay(row.lastReviewedAt) : t("never")} + + {isoDay(row.updatedAt)} +
    +
    + ); +} + +/** + * Why this row is in the queue, said on the row rather than in a column of its + * own: a reviewer filtering by one flag still needs to see the other three, + * because "no photo and no manual" is one trip to the lab, not two. + */ +function AttentionBadges({ row }: { row: InventoryRow }) { + const t = useTranslations("admin.inventory"); + if (!row.needsAttention) return null; + + return ( +
      + {PLAIN_FLAGS.filter((flag) => row.attention[flag]).map((flag) => ( +
    • + {t(`flags.${FLAG_KEYS[flag]}`)} +
    • + ))} + {row.attention.openTickets ? ( +
    • + {t("openTicketsWithCount", { count: row.openTicketCount })} +
    • + ) : null} +
    + ); +} + +function isoDay(value: Date): string { + return value.toISOString().slice(0, 10); +} diff --git a/v5/src/components/admin/MaintenanceQueue.test.tsx b/v5/src/components/admin/MaintenanceQueue.test.tsx new file mode 100644 index 0000000..39f5d4e --- /dev/null +++ b/v5/src/components/admin/MaintenanceQueue.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, userEvent, within } from "../../../test/utils/render"; +import type { MaintenanceQueueEntry } from "../../lib/data/maintenance"; +import { MaintenanceQueue } from "./MaintenanceQueue"; + +/** + * The ticket queue's rendering and its one interactive card (spec §5.6, §6). + * + * `MaintenanceQueue` is a server component with no `async`, which is what lets + * it be mounted here; `TicketControls` is the client island inside it, and this + * file covers both together because the thing worth asserting — a refusal puts + * the status back — spans the two. + */ + +function ticket(overrides: Partial = {}): MaintenanceQueueEntry { + return { + id: "log-1", + title: "Laser bed out of focus", + description: "Cuts are not going through 3 mm ply.", + resolution: "", + type: "issue_report", + priority: "high", + status: "open", + toolId: "tool-1", + toolSlug: "trotec-speedy-400", + toolName: "Trotec Speedy 400", + unitId: "unit-1", + unitLabel: "Trotec // A", + reportedByName: "Casey Rivera", + reportedByEmail: "casey@cornell.edu", + assignedToUserId: null, + assignedToName: "", + dateReported: "2026-03-04", + dateResolved: "", + createdAt: new Date("2026-03-04T15:00:00.000Z"), + ...overrides, + }; +} + +const STAFF = [{ id: "u-niti", name: "Niti Parikh" }]; + +function renderQueue( + tickets: MaintenanceQueueEntry[], + result: Awaited[0]["action"]>> = { ok: true } +) { + const action = vi.fn(async () => result); + render(); + return { action }; +} + +describe("MaintenanceQueue", () => { + it("names what is missing when nothing has been filed", () => { + renderQueue([]); + // §6: an empty state names what would fill it, not "no results". + expect(screen.getByText(/No tickets have been filed/)).toBeInTheDocument(); + }); + + it("says so when everything filed is already settled", () => { + renderQueue([ticket({ status: "closed" })]); + expect(screen.getByText(/Nothing is open/)).toBeInTheDocument(); + }); + + it("shows the machine, the reporter and a way to reach them", () => { + renderQueue([ticket()]); + + // The unit lives on its tool's page — there is no page for a unit alone. + expect(screen.getByRole("link", { name: "Trotec Speedy 400" })).toHaveAttribute( + "href", + "/tools/trotec-speedy-400" + ); + expect(screen.getByText("Trotec // A")).toBeInTheDocument(); + expect(screen.getByText("Reported by Casey Rivera")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "casey@cornell.edu" })).toHaveAttribute( + "href", + "mailto:casey@cornell.edu" + ); + }); + + it("falls back to the snapshot name, and links nowhere, for a ticket with no tool", () => { + renderQueue([ticket({ toolId: null, toolSlug: null, toolName: "A printer we sold" })]); + + expect(screen.getByText("A printer we sold")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /printer/ })).not.toBeInTheDocument(); + }); + + it("keeps settled tickets one click away rather than gone", () => { + renderQueue([ticket(), ticket({ id: "log-2", title: "Old one", status: "resolved" })]); + + expect(screen.getByText("Show 1 resolved and closed")).toBeInTheDocument(); + // Present in the DOM behind the disclosure: reopening one is legitimate. + expect(screen.getByText("Old one")).toBeInTheDocument(); + }); + + it("saves a status the moment it changes, sending only that field", async () => { + const { action } = renderQueue([ticket()]); + + await userEvent.selectOptions( + screen.getByRole("combobox", { name: "Status for Laser bed out of focus" }), + "in_progress" + ); + + expect(action).toHaveBeenCalledWith({ + logId: "log-1", + // Only the field that changed: a patch carrying all three would overwrite + // whatever somebody else set from the next bench. + patch: { status: "in_progress" }, + }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + }); + + it("sends the assignee's name beside their id, as the snapshot column wants", async () => { + const { action } = renderQueue([ticket()]); + + await userEvent.selectOptions( + screen.getByRole("combobox", { name: "Assigned to, for Laser bed out of focus" }), + "u-niti" + ); + + expect(action).toHaveBeenCalledWith({ + logId: "log-1", + patch: { assignedToUserId: "u-niti", assignedToName: "Niti Parikh" }, + }); + }); + + it("restores the previous status when the server refuses, and says why", async () => { + renderQueue([ticket()], { ok: false, error: "not_permitted" }); + const status = screen.getByRole("combobox", { name: "Status for Laser bed out of focus" }); + + await userEvent.selectOptions(status, "closed"); + + // Nothing changed on the server, so nothing may claim to have changed here. + expect(await screen.findByText(/does not hold the permission/)).toBeInTheDocument(); + expect(status).toHaveValue("open"); + }); + + it("keeps the new value and warns when only the audit trail failed", async () => { + renderQueue([ticket()], { ok: true, warning: "audit_unavailable" }); + const status = screen.getByRole("combobox", { name: "Status for Laser bed out of focus" }); + + await userEvent.selectOptions(status, "resolved"); + + expect(await screen.findByText(/could not be written to the audit log/)).toBeInTheDocument(); + expect(status).toHaveValue("resolved"); + }); + + it("only offers to save a resolution once one has been typed", async () => { + const { action } = renderQueue([ticket()]); + const save = screen.getByRole("button", { name: "Save resolution" }); + expect(save).toBeDisabled(); + + await userEvent.type( + screen.getByRole("textbox", { name: "Resolution for Laser bed out of focus" }), + "Refocused." + ); + expect(save).toBeEnabled(); + await userEvent.click(save); + + expect(action).toHaveBeenCalledWith({ logId: "log-1", patch: { resolution: "Refocused." } }); + }); + + it("leaves a refused resolution in the box, because it is somebody's typing", async () => { + renderQueue([ticket()], { ok: false, error: "failed" }); + const box = screen.getByRole("textbox", { name: "Resolution for Laser bed out of focus" }); + + await userEvent.type(box, "Refocused."); + await userEvent.click(screen.getByRole("button", { name: "Save resolution" })); + + expect(await screen.findByText(/did not save/)).toBeInTheDocument(); + expect(box).toHaveValue("Refocused."); + }); + + it("disables the assignee control when nobody holds an admin role yet", () => { + const action = vi.fn(async () => ({ ok: true }) as const); + render(); + + expect( + screen.getByRole("combobox", { name: "Assigned to, for Laser bed out of focus" }) + ).toBeDisabled(); + }); + + it("shows the ticket the queue is ordered by first", () => { + renderQueue([ticket(), ticket({ id: "log-2", title: "Second" })]); + + const [first] = screen.getAllByRole("listitem"); + expect(within(first).getByRole("heading", { level: 3 })).toHaveTextContent( + "Laser bed out of focus" + ); + }); +}); diff --git a/v5/src/components/admin/MaintenanceQueue.tsx b/v5/src/components/admin/MaintenanceQueue.tsx new file mode 100644 index 0000000..cda3b07 --- /dev/null +++ b/v5/src/components/admin/MaintenanceQueue.tsx @@ -0,0 +1,139 @@ +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import type { MaintenanceQueueEntry } from "../../lib/data/maintenance"; +import type { UpdateTicketAction } from "../../app/admin/maintenance/action-result"; +import { TicketControls } from "./TicketControls"; + +/** + * The ticket queue on `/admin/maintenance` (spec §5.6). + * + * A server component with no `async` and no data access of its own, for the + * reason `UsersTable` and `InventoryTable` are: everything is a prop, so a + * component test mounts it with the ordinary i18n wrapper. + * + * **Cards, not a table.** A ticket carries a description somebody typed and a + * resolution somebody will type, and neither fits a cell. The card also puts + * the controls under the words they act on, which is what lets this be worked + * on a phone standing next to the machine. + * + * **Open work is the page; settled work is behind a disclosure.** Somebody with + * ten minutes wants the twenty tickets that are still open, in the order the + * read already put them — but "what did we do about the last one of these" is + * the question a resolution field exists to answer, so the closed ones are one + * click away rather than gone. + */ + +export interface MaintenanceQueueProps { + tickets: MaintenanceQueueEntry[]; + /** Who a ticket can be handed to — admin roles only (`listAssignableStaff`). */ + staff: ReadonlyArray<{ id: string; name: string }>; + action: UpdateTicketAction; +} + +/** The two statuses that mean "somebody still has to do something". */ +const OPEN_STATUSES = new Set(["open", "in_progress"]); + +export function MaintenanceQueue({ tickets, staff, action }: MaintenanceQueueProps) { + const t = useTranslations("admin.maintenance"); + + if (tickets.length === 0) { + // Spec §6: an empty state names what is missing and what would fill it. + return

    {t("empty")}

    ; + } + + const open = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status)); + const settled = tickets.filter((ticket) => !OPEN_STATUSES.has(ticket.status)); + + return ( +
    + {open.length === 0 ? ( +

    {t("emptyOpen")}

    + ) : ( +
      + {open.map((ticket) => ( + + ))} +
    + )} + + {settled.length > 0 ? ( +
    + {t("settledToggle", { count: settled.length })} +
      + {settled.map((ticket) => ( + + ))} +
    +
    + ) : null} +
    + ); +} + +function TicketCard({ + ticket, + staff, + action, +}: { + ticket: MaintenanceQueueEntry; + staff: ReadonlyArray<{ id: string; name: string }>; + action: UpdateTicketAction; +}) { + const t = useTranslations("admin.maintenance"); + + return ( +
  • +
    +

    {ticket.title}

    + {t(`status.${ticket.status}`)} + {ticket.priority ? ( + + {t(`priority.${ticket.priority}`)} + + ) : null} +
    + +

    + {/* The machine, and how to get to it. There is no page for a unit of + its own — a unit lives on its tool's page, which is also where the + editor opens for anybody holding `tools.edit` (§5.3(b)). */} + {ticket.toolSlug ? ( + {ticket.toolName} + ) : ( + {ticket.toolName || t("noTool")} + )} + {ticket.unitLabel ? {ticket.unitLabel} : null} + {ticket.type ? {t(`type.${ticket.type}`)} : null} +

    + +

    + {ticket.reportedByName ? ( + {t("reportedBy", { name: ticket.reportedByName })} + ) : ( + {t("reportedAnonymously")} + )} + {/* The one thing an admin does with a ticket they do not understand is + ask the person who filed it (§8 — this page and nowhere else). */} + {ticket.reportedByEmail ? ( + {ticket.reportedByEmail} + ) : null} + {/* ISO, locale-neutral, in the mono treatment every other stamp in + `/admin` gets — and identical on the server and the client. */} + + {t("reportedOn", { date: ticket.dateReported || isoDay(ticket.createdAt) })} + + {ticket.dateResolved ? ( + {t("resolvedOn", { date: ticket.dateResolved })} + ) : null} +

    + + {ticket.description ?

    {ticket.description}

    : null} + + +
  • + ); +} + +function isoDay(at: Date): string { + return at.toISOString().slice(0, 10); +} diff --git a/v5/src/components/admin/PhotoEditor.test.tsx b/v5/src/components/admin/PhotoEditor.test.tsx new file mode 100644 index 0000000..b61b498 --- /dev/null +++ b/v5/src/components/admin/PhotoEditor.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, userEvent, waitFor } from "../../../test/utils/render"; +import { PhotoEditor } from "./PhotoEditor"; +import type { EditorPhoto } from "../../lib/data/tool-editor"; + +/** + * The Photos section (spec §5.3(3), §4.7). + * + * Two properties, and the second is a requirement of this phase: **position 0 + * is the cover**, and **with no Blob store the section says photos cannot be + * added right now and stays usable for everything else** (Article 4). + * + * `fetch` is stubbed rather than reached — `POST /api/uploads` has its own + * tests, and nothing here may touch the network. + */ + +function photo(id: string, name: string): EditorPhoto { + return { id, url: `https://blob.test/${name}.jpg`, originalFilename: `${name}.jpg` }; +} + +function stubUploads(status: number, body: unknown = {}) { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function renderEditor(photos: EditorPhoto[] = []) { + const handlers = { + onAttach: vi.fn(), + onReorder: vi.fn(), + onRemove: vi.fn(), + }; + render(); + return handlers; +} + +/** A one-pixel file, since nothing here reads the bytes. */ +function file(name: string) { + return new File(["x"], name, { type: "image/jpeg" }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("names what is missing when there are no photos (§6, States)", () => { + renderEditor(); + expect(screen.getByText(/No photos/)).toBeInTheDocument(); +}); + +it("marks the first photo as the cover, and only the first", () => { + renderEditor([photo("a", "front"), photo("b", "back")]); + expect(screen.getAllByText("Cover")).toHaveLength(1); +}); + +it("reorders by moving a photo, which is also how a cover is chosen", async () => { + const handlers = renderEditor([photo("a", "front"), photo("b", "back")]); + + await userEvent.click(screen.getAllByRole("button", { name: "Move earlier" })[1]); + + expect(handlers.onReorder).toHaveBeenCalledWith(["b", "a"]); +}); + +it("cannot move the cover earlier or the last photo later", () => { + renderEditor([photo("a", "front"), photo("b", "back")]); + + expect(screen.getAllByRole("button", { name: "Move earlier" })[0]).toBeDisabled(); + expect(screen.getAllByRole("button", { name: "Move later" })[1]).toBeDisabled(); +}); + +it("uploads a chosen photo and hands the claim the id that came back", async () => { + stubUploads(200, { attachmentId: "att-1", previewUrl: "https://blob.test/a.jpg" }); + const handlers = renderEditor(); + + await userEvent.upload(screen.getByLabelText("Add photos"), file("front.jpg")); + + await waitFor(() => expect(handlers.onAttach).toHaveBeenCalledWith(["att-1"])); +}); + +it("says photos cannot be added when the deployment has no file storage", async () => { + // `POST /api/uploads` answers 503 with no `BLOB_READ_WRITE_TOKEN`. The panel + // must say so and stay usable — never invent an id (Article 4). + stubUploads(503, { code: "blob_not_configured" }); + const handlers = renderEditor([photo("a", "front"), photo("b", "back")]); + + await userEvent.upload(screen.getByLabelText("Add photos"), file("front.jpg")); + + expect( + await screen.findByText(/cannot be added right now/) + ).toBeInTheDocument(); + expect(handlers.onAttach).not.toHaveBeenCalled(); + // Everything that touches only rows still works. + expect(screen.getAllByRole("button", { name: "Remove" })[0]).toBeEnabled(); + expect(screen.getAllByRole("button", { name: "Move later" })[0]).toBeEnabled(); +}); + +it("reports an upload that failed without claiming anything", async () => { + stubUploads(502, { error: "Upload failed" }); + const handlers = renderEditor(); + + await userEvent.upload(screen.getByLabelText("Add photos"), file("front.jpg")); + + expect(await screen.findByText(/did not finish/)).toBeInTheDocument(); + expect(handlers.onAttach).not.toHaveBeenCalled(); +}); diff --git a/v5/src/components/admin/PhotoEditor.tsx b/v5/src/components/admin/PhotoEditor.tsx new file mode 100644 index 0000000..7c6dbd7 --- /dev/null +++ b/v5/src/components/admin/PhotoEditor.tsx @@ -0,0 +1,151 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import type { EditorPhoto } from "../../lib/data/tool-editor"; +import { uploadFile, type UploadFailure } from "./upload-file"; + +/** + * The Photos section of the tool editor (spec §5.3(3), §4.7). + * + * **Position 0 is the cover**, so "make this the cover" and "reorder" are the + * same operation: moving a photo to the front is how a cover is chosen, and + * there is no second concept to keep in step. + * + * **Move buttons rather than drag-and-drop.** The panel is used on a phone + * standing next to a machine, and a drag target inside a scrolling sheet is the + * control that fails there. Two buttons work with a thumb, a keyboard and a + * screen reader. + * + * **With no Blob store, adding goes away and the rest stays.** `POST /api/uploads` + * answers 503 when `BLOB_READ_WRITE_TOKEN` is unset; this says photos cannot be + * added right now and leaves reordering and removal — which touch only rows — + * working (Article 4). + */ + +export interface PhotoEditorProps { + photos: EditorPhoto[]; + pending: boolean; + onAttach: (attachmentIds: string[]) => void; + onReorder: (orderedIds: string[]) => void; + onRemove: (attachmentId: string) => void; +} + +export function PhotoEditor({ + photos, + pending, + onAttach, + onReorder, + onRemove, +}: PhotoEditorProps) { + const t = useTranslations("admin.inventory.editor"); + const [uploading, setUploading] = useState(0); + const [uploadError, setUploadError] = useState(null); + + async function handleFiles(list: FileList | null) { + if (!list || list.length === 0) return; + setUploadError(null); + + const files = Array.from(list); + setUploading(files.length); + + // Uploaded in parallel, claimed in one write: the claim is what decides the + // order, and one write means one revision to hand back to the panel. + const results = await Promise.all(files.map((file) => uploadFile(file, "tool"))); + setUploading(0); + + const attachmentIds = results + .filter((result) => result.ok) + .map((result) => (result.ok ? result.attachmentId : "")); + + // The first failure is the one reported: with no Blob store every file + // fails the same way, and five copies of that sentence is not five facts. + const failure = results.find((result) => !result.ok); + if (failure && !failure.ok) setUploadError(failure.reason); + + if (attachmentIds.length > 0) onAttach(attachmentIds); + } + + function move(index: number, delta: number) { + const next = [...photos.map((photo) => photo.id)]; + const target = index + delta; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + onReorder(next); + } + + return ( +
    + {photos.length === 0 ? ( +

    {t("noPhotos")}

    + ) : ( +
      + {photos.map((photo, index) => ( +
    • + + {photo.url ? ( + {photo.originalFilename + ) : null} + + + {index === 0 ? {t("coverPhoto")} : null} + +
      + + + +
      +
    • + ))} +
    + )} + + + +

    + {uploading > 0 ? t("uploading") : null} +

    + {uploadError ? ( +

    {t(`uploadErrors.${uploadError}`)}

    + ) : null} +
    + ); +} diff --git a/v5/src/components/admin/ProjectQueue.test.tsx b/v5/src/components/admin/ProjectQueue.test.tsx new file mode 100644 index 0000000..9d518fd --- /dev/null +++ b/v5/src/components/admin/ProjectQueue.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, userEvent } from "../../../test/utils/render"; +import type { ProjectModerationEntry } from "../../lib/data/projects"; +import { ProjectQueue } from "./ProjectQueue"; + +/** + * The moderation queue and its one control (spec §5.6, Article 5). + * + * The property that matters most here: a submission waiting for a decision has + * **no public page to preview**, so everything the moderator needs has to be on + * the card. A queue that showed only a title would be asking somebody to + * approve a heading. + */ + +function submission(overrides: Partial = {}): ProjectModerationEntry { + return { + id: "p-1", + slug: "resin-dice-tower", + title: "Resin dice tower", + authorName: "Casey Rivera", + authorUserId: "u-casey", + body: "A dice tower printed in three parts.", + link: "https://example.test/tower", + materials: ["Standard resin", "Felt"], + photos: ["https://blob.test/cover.png"], + published: false, + publishedAt: null, + createdAt: new Date("2026-03-05T18:00:00.000Z"), + ...overrides, + }; +} + +function renderQueue( + projects: ProjectModerationEntry[], + result: Awaited[0]["action"]>> = { ok: true } +) { + const action = vi.fn(async () => result); + render(); + return { action }; +} + +describe("ProjectQueue", () => { + it("names what is missing when nothing has been submitted", () => { + renderQueue([]); + expect(screen.getByText(/No projects have been submitted yet/)).toBeInTheDocument(); + }); + + it("says so when every submission has been decided on", () => { + renderQueue([submission({ published: true })]); + expect(screen.getByText(/Nothing is waiting/)).toBeInTheDocument(); + }); + + it("shows the whole submission, because there is no page to preview", () => { + renderQueue([submission()]); + + expect(screen.getByText("A dice tower printed in three parts.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "https://example.test/tower" })).toBeInTheDocument(); + expect(screen.getByText(/Standard resin, Felt/)).toBeInTheDocument(); + expect(screen.getByText("By Casey Rivera")).toBeInTheDocument(); + expect(screen.getByText(/Not in the gallery yet/)).toBeInTheDocument(); + expect(screen.getByRole("list", { name: /Photos submitted with Resin dice tower/ })).toBeInTheDocument(); + }); + + it("says a submission arrived without photos rather than showing an empty strip", () => { + renderQueue([submission({ photos: [] })]); + expect(screen.getByText("No photos were submitted.")).toBeInTheDocument(); + }); + + it("links a published project to the gallery, where it now is", () => { + renderQueue([submission({ published: true })]); + + expect(screen.getByRole("link", { name: "Open in the gallery" })).toHaveAttribute( + "href", + "/projects/resin-dice-tower" + ); + }); + + it("publishes in one click, and the button then offers the other direction", async () => { + const { action } = renderQueue([submission()]); + + await userEvent.click(screen.getByRole("button", { name: "Publish Resin dice tower" })); + + expect(action).toHaveBeenCalledWith({ projectId: "p-1", published: true }); + expect(await screen.findByText("Saved")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Unpublish Resin dice tower" }) + ).toBeInTheDocument(); + }); + + it("restores the previous state when the server refuses, and says why", async () => { + renderQueue([submission()], { ok: false, error: "not_permitted" }); + + await userEvent.click(screen.getByRole("button", { name: "Publish Resin dice tower" })); + + expect(await screen.findByText(/does not hold the permission/)).toBeInTheDocument(); + // Still the publish button: nothing was published, so nothing may say it was. + expect(screen.getByRole("button", { name: "Publish Resin dice tower" })).toBeInTheDocument(); + }); + + it("keeps the publish and warns when only the audit trail failed", async () => { + renderQueue([submission()], { ok: true, warning: "audit_unavailable" }); + + await userEvent.click(screen.getByRole("button", { name: "Publish Resin dice tower" })); + + // The project *is* published; the trail does not say so. Restoring the + // button would be the worse lie of the two (§4.11). + expect(await screen.findByText(/could not be written to the audit log/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Unpublish Resin dice tower" })).toBeInTheDocument(); + }); + + it("keeps published submissions reachable, because unpublishing is this page's job too", () => { + renderQueue([submission(), submission({ id: "p-2", slug: "lamp", published: true })]); + + expect(screen.getByText("Show 1 already published")).toBeInTheDocument(); + }); +}); diff --git a/v5/src/components/admin/ProjectQueue.tsx b/v5/src/components/admin/ProjectQueue.tsx new file mode 100644 index 0000000..f5329d3 --- /dev/null +++ b/v5/src/components/admin/ProjectQueue.tsx @@ -0,0 +1,142 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import type { ProjectModerationEntry } from "../../lib/data/projects"; +import type { SetProjectPublishedAction } from "../../app/admin/projects/action-result"; +import { PublishToggle } from "./PublishToggle"; + +/** + * The moderation queue on `/admin/projects` (spec §5.6, §5.5, Article 5). + * + * A server component with no `async`, everything in props — `UsersTable`'s + * shape, and what makes it mountable in a component test. + * + * **The card shows the whole submission, because there is nowhere else to see + * it.** `/projects/` is published-only by design, so a project waiting + * for a decision has no public page to preview — the moderator would be judging + * a title. Every card therefore carries the photos, the write-up, the link and + * the materials, which is exactly what the gallery would show if this were + * approved. + * + * **Waiting first, published folded away.** Unpublishing is the other half of + * the gate rather than a different job, so published submissions stay reachable + * behind a disclosure instead of disappearing from the page that governs them. + */ + +export interface ProjectQueueProps { + projects: ProjectModerationEntry[]; + action: SetProjectPublishedAction; +} + +export function ProjectQueue({ projects, action }: ProjectQueueProps) { + const t = useTranslations("admin.projects"); + + if (projects.length === 0) { + return

    {t("empty")}

    ; + } + + const waiting = projects.filter((project) => !project.published); + const published = projects.filter((project) => project.published); + + return ( +
    + {waiting.length === 0 ? ( +

    {t("emptyWaiting")}

    + ) : ( +
      + {waiting.map((project) => ( + + ))} +
    + )} + + {published.length > 0 ? ( +
    + {t("publishedToggle", { count: published.length })} +
      + {published.map((project) => ( + + ))} +
    +
    + ) : null} +
    + ); +} + +function ProjectCard({ + project, + action, +}: { + project: ProjectModerationEntry; + action: SetProjectPublishedAction; +}) { + const t = useTranslations("admin.projects"); + + return ( +
  • +
    +

    {project.title}

    +
    + +

    + + {project.authorName + ? t("by", { name: project.authorName }) + : t("byAnonymous")} + + + {t("submittedOn", { date: project.createdAt.toISOString().slice(0, 10) })} + + {project.published ? ( + // Published rows have a page; the ones being judged deliberately do + // not, which is why the card carries everything below. + {t("openInGallery")} + ) : ( + {t("notPublicYet")} + )} +

    + + {project.photos.length > 0 ? ( +
      + {project.photos.map((photo, index) => ( +
    • + {/* `unoptimized`, like the review table's thumbnails: these are + Blob URLs on an admin page nobody browses for pleasure, and + an optimizer pass per photo per moderation is a bill for + nothing (Article 4). */} + +
    • + ))} +
    + ) : ( +

    {t("noPhotos")}

    + )} + +

    {project.body}

    + + {project.link ? ( +

    + {/* Somebody else's URL, opened from an admin page: `noreferrer` as + well as `noopener`, the way every outbound link in the app is. */} + + {project.link} + +

    + ) : null} + + {project.materials.length > 0 ? ( +

    + {t("materials")} {project.materials.join(", ")} +

    + ) : null} + + +
  • + ); +} diff --git a/v5/src/components/admin/PublishToggle.tsx b/v5/src/components/admin/PublishToggle.tsx new file mode 100644 index 0000000..3849bb6 --- /dev/null +++ b/v5/src/components/admin/PublishToggle.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { SetProjectPublishedAction } from "../../app/admin/projects/action-result"; +import { RowStatus } from "./RowStatus"; +import { useRowAction } from "./use-row-action"; + +/** + * The moderation gate's one control (spec §5.6, Article 5). + * + * One button whose label says what it will do, not what the row is — the badge + * beside it says that. Two states, so a toggle is the honest shape; and it is + * the same button in both directions because taking something down is the same + * decision as putting it up, made again. + * + * Optimistic, with `useRowAction`'s contract behind it: a refusal restores the + * previous state, a warning keeps the new one. The warning matters here more + * than anywhere else on these three pages — this is the only queue action that + * writes an audit event, so `audit_unavailable` is a thing it can actually + * report, and it means *the project was published and the trail does not say + * so* (§4.11). Restoring the button for that would be a worse lie than the + * missing event. + * + * The action arrives as a prop and re-checks `projects.moderate` itself (§8). + */ + +export interface PublishToggleProps { + projectId: string; + title: string; + published: boolean; + action: SetProjectPublishedAction; +} + +export function PublishToggle({ projectId, title, published, action }: PublishToggleProps) { + const t = useTranslations("admin.projects"); + const row = useRowAction(published); + + const next = !row.value; + + return ( +
    + + {t(row.value ? "statePublished" : "stateWaiting")} + + + + + +
    + ); +} diff --git a/v5/src/components/admin/ResourcesEditor.test.tsx b/v5/src/components/admin/ResourcesEditor.test.tsx new file mode 100644 index 0000000..d64c744 --- /dev/null +++ b/v5/src/components/admin/ResourcesEditor.test.tsx @@ -0,0 +1,135 @@ +import { render, screen, userEvent, waitFor } from "../../../test/utils/render"; +import { ResourcesEditor } from "./ResourcesEditor"; +import type { EditorResource } from "../../lib/data/resources"; + +/** + * The Resources section — manuals, SOPs and links (spec §5.3(3), §4.6). + * + * The two things worth pinning: it shows **unpublished** resources, which no + * other surface does, and **the link half keeps working when there is nowhere + * to put a PDF** (Article 4). + */ + +function resource(overrides: Partial = {}): EditorResource { + return { + id: "res-1", + title: "Form 4 manual", + type: "manual", + url: "https://support.formlabs.com/form-4", + notes: null, + published: true, + fileUrls: [], + ...overrides, + }; +} + +function stubUploads(status: number, body: unknown = {}) { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + ) + ); +} + +function renderEditor(resources: EditorResource[] = []) { + const handlers = { + onAdd: vi.fn(), + onTogglePublished: vi.fn(), + onRemove: vi.fn(), + }; + render(); + return handlers; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("names what is missing when there is nothing to read (§6, States)", () => { + renderEditor(); + expect(screen.getByText(/No manuals or links yet/)).toBeInTheDocument(); +}); + +it("shows an unpublished resource and says it is hidden", () => { + renderEditor([resource({ published: false })]); + + // The editor is the one place in the app that shows one — which is the point + // of showing it: somebody came here to look at the manual they hid. + expect(screen.getByText("Form 4 manual")).toBeInTheDocument(); + expect(screen.getByText("Hidden")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show" })).toBeInTheDocument(); +}); + +it("hides a published resource without deleting it", async () => { + const handlers = renderEditor([resource()]); + + await userEvent.click(screen.getByRole("button", { name: "Hide" })); + + expect(handlers.onTogglePublished).toHaveBeenCalledWith("res-1", false); +}); + +it("adds a link with no file, and asks for no upload", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const handlers = renderEditor(); + + await userEvent.type(screen.getByLabelText("Title"), "Safety sheet"); + await userEvent.type(screen.getByLabelText("Link"), "https://example.edu/safety"); + await userEvent.click(screen.getByRole("button", { name: "Add" })); + + expect(handlers.onAdd).toHaveBeenCalledWith( + { title: "Safety sheet", type: null, url: "https://example.edu/safety" }, + [] + ); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it("uploads a PDF first and adds the resource with the id that came back", async () => { + stubUploads(200, { attachmentId: "att-9" }); + const handlers = renderEditor(); + + await userEvent.type(screen.getByLabelText("Title"), "Form 4 manual"); + await userEvent.upload( + screen.getByLabelText("PDF"), + new File(["%PDF"], "manual.pdf", { type: "application/pdf" }) + ); + await userEvent.click(screen.getByRole("button", { name: "Add" })); + + await waitFor(() => + expect(handlers.onAdd).toHaveBeenCalledWith( + { title: "Form 4 manual", type: null, url: null }, + ["att-9"] + ) + ); +}); + +it("adds nothing when the upload had nowhere to go, and says why", async () => { + stubUploads(503, { code: "blob_not_configured" }); + const handlers = renderEditor(); + + await userEvent.type(screen.getByLabelText("Title"), "Form 4 manual"); + await userEvent.upload( + screen.getByLabelText("PDF"), + new File(["%PDF"], "manual.pdf", { type: "application/pdf" }) + ); + await userEvent.click(screen.getByRole("button", { name: "Add" })); + + // A resource whose manual silently did not attach is the quiet lie Article 4 + // forbids, so nothing is created at all and the person keeps their typing. + expect(await screen.findByText(/cannot be added right now/)).toBeInTheDocument(); + expect(handlers.onAdd).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Title")).toHaveValue("Form 4 manual"); +}); + +it("removes a resource", async () => { + const handlers = renderEditor([resource()]); + + await userEvent.click(screen.getByRole("button", { name: "Remove" })); + + expect(handlers.onRemove).toHaveBeenCalledWith("res-1"); +}); diff --git a/v5/src/components/admin/ResourcesEditor.tsx b/v5/src/components/admin/ResourcesEditor.tsx new file mode 100644 index 0000000..47e737b --- /dev/null +++ b/v5/src/components/admin/ResourcesEditor.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import type { EditorResource, NewResource } from "../../lib/data/resources"; +import { uploadFile, type UploadFailure } from "./upload-file"; + +/** + * The Resources section of the tool editor — manuals, SOPs and links + * (spec §5.3(3), §4.6). + * + * A resource is a URL, an uploaded PDF, or both. **The PDF is uploaded before + * the resource exists**: `POST /api/uploads` writes the blob and an unowned + * `attachments` row, and the add claims its id. That is why the file is chosen + * here and the claim happens on the server (§3.3). + * + * **With no Blob store the file half goes away and the rest does not.** The + * upload route answers 503 and this section says so, keeps the link field + * working, and leaves every existing resource editable — a deployment without + * `BLOB_READ_WRITE_TOKEN` is still one where a wrong link is worth fixing + * (Article 4). + * + * **Unpublished resources are shown**, unlike everywhere else in the app: the + * editor is exactly where somebody goes to look at the manual they hid. + */ + +export interface ResourcesEditorProps { + resources: EditorResource[]; + pending: boolean; + onAdd: (resource: NewResource, fileAttachmentIds: string[]) => void; + onTogglePublished: (resourceId: string, published: boolean) => void; + onRemove: (resourceId: string) => void; +} + +export function ResourcesEditor({ + resources, + pending, + onAdd, + onTogglePublished, + onRemove, +}: ResourcesEditorProps) { + const t = useTranslations("admin.inventory.editor"); + + const [title, setTitle] = useState(""); + const [type, setType] = useState(""); + const [url, setUrl] = useState(""); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + async function handleAdd(event: React.FormEvent) { + event.preventDefault(); + const trimmed = title.trim(); + if (!trimmed) return; + setUploadError(null); + + // The file is uploaded first and the resource claims what actually landed: + // an id invented after a failed upload would be a manual nobody has. + let fileAttachmentIds: string[] = []; + if (file) { + setUploading(true); + const uploaded = await uploadFile(file, "resource"); + setUploading(false); + if (!uploaded.ok) { + setUploadError(uploaded.reason); + return; + } + fileAttachmentIds = [uploaded.attachmentId]; + } + + onAdd( + { title: trimmed, type: type.trim() || null, url: url.trim() || null }, + fileAttachmentIds + ); + setTitle(""); + setType(""); + setUrl(""); + setFile(null); + } + + return ( +
    + {resources.length === 0 ? ( +

    {t("noResources")}

    + ) : ( +
      + {resources.map((resource) => ( +
    • +
      + {resource.title} + {resource.type ? ( + {resource.type} + ) : null} + {!resource.published ? ( + {t("resourceHidden")} + ) : null} +
      + + {resource.url ? ( + + {resource.url} + + ) : null} + {resource.fileUrls.map((fileUrl) => ( + + {t("resourceFile")} + + ))} + +
      + + +
      +
    • + ))} +
    + )} + +
    + + + + + + + + + + + {uploadError ? ( +

    + {t(`uploadErrors.${uploadError}`)} +

    + ) : null} +
    +
    + ); +} diff --git a/v5/src/components/admin/RowStatus.tsx b/v5/src/components/admin/RowStatus.tsx new file mode 100644 index 0000000..2343371 --- /dev/null +++ b/v5/src/components/admin/RowStatus.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { AdminActionWarning } from "../../lib/admin/action-result"; + +/** + * The one line every admin row control speaks through (spec §6, §4.11). + * + * `RoleSelect` established the shape — a single `role="status"` region + * carrying saving, saved, the warning and the refusal, so a screen reader hears + * every outcome in the place it heard the last one — and the three queues have + * one of these per row. Extracted rather than copied a fourth time, because the + * *order* of these branches is the contract: a warning and an error are never + * shown together, and "Saved" never appears beside a reason the save did not + * happen. + * + * It renders codes, not sentences. `admin.errors.` and + * `admin.warnings.` are the shared message families every admin surface + * looks its refusals up in, so a code added to the gate is translated + * everywhere at once (Article 6). + */ + +export interface RowStatusProps { + pending: boolean; + /** True once a change has landed and nothing has qualified it. */ + saved: boolean; + /** An `admin.errors.` key, or null. */ + error: string | null; + /** An `admin.warnings.` key, or null. */ + warning: AdminActionWarning | null; +} + +export function RowStatus({ pending, saved, error, warning }: RowStatusProps) { + const t = useTranslations("admin"); + + return ( + + {pending ? t("saving") : null} + {!pending && saved && !error && !warning ? t("saved") : null} + {!pending && warning ? t(`warnings.${warning}`) : null} + {!pending && error ? t(`errors.${error}`) : null} + + ); +} diff --git a/v5/src/components/admin/TicketControls.tsx b/v5/src/components/admin/TicketControls.tsx new file mode 100644 index 0000000..6a3570b --- /dev/null +++ b/v5/src/components/admin/TicketControls.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import type { MaintenanceQueueEntry } from "../../lib/data/maintenance"; +import { MAINTENANCE_PRIORITY, MAINTENANCE_STATUS } from "../../lib/db/schema/vocabulary"; +import type { UpdateTicketAction } from "../../app/admin/maintenance/action-result"; +import { RowStatus } from "./RowStatus"; +import { useRowAction } from "./use-row-action"; + +/** + * The controls on one maintenance ticket (spec §5.6). + * + * **Everything but the resolution saves the moment it changes.** Status, + * priority and assignee are single clicks, and a queue of twenty tickets that + * asks for a Save after each of them is a queue nobody clears. The resolution + * is the exception because it is typing: it keeps a local draft and saves on + * its own button, so a half-written sentence is never posted by a stray click + * elsewhere on the card. + * + * **One draft, one status line.** The three selects share a single optimistic + * value — the editable state of the ticket — so a refusal restores all of it + * at once and every outcome is announced in one `role="status"` region, the + * shape `RoleSelect` set. Each control still sends only the field it changed, + * because a patch carrying all three would overwrite whatever somebody else + * set from the next bench. + * + * The action arrives as a **prop**. A client component importing `actions.ts` + * would drag `next/headers`, the limiter and `server-only` into its graph and + * stop being testable, and the page that renders this already has it. Handing + * it down is not a grant: the action checks `maintenance.manage` itself (§8). + */ + +/** The part of a ticket these controls own. */ +interface TicketDraft { + status: string; + priority: string | null; + assignedToUserId: string | null; +} + +export interface TicketControlsProps { + ticket: MaintenanceQueueEntry; + /** Who a ticket can be handed to — admin roles only, by name. */ + staff: ReadonlyArray<{ id: string; name: string }>; + action: UpdateTicketAction; +} + +export function TicketControls({ ticket, staff, action }: TicketControlsProps) { + const t = useTranslations("admin.maintenance"); + const draft = useRowAction({ + status: ticket.status, + priority: ticket.priority, + assignedToUserId: ticket.assignedToUserId, + }); + // The resolution is typed, so it lives outside the optimistic draft: nothing + // may copy a server value over a box somebody is writing in, and a refused + // save must leave the words where they are. + const [resolution, setResolution] = useState(ticket.resolution); + // What the server last confirmed, so the Save button can be dark until there + // is something to save. It moves only on a landed write — an optimistic move + // here would let a refused save look committed. + const [committed, setCommitted] = useState(ticket.resolution); + + const { value, pending } = draft; + + function save(next: TicketDraft, patch: Parameters[0]["patch"]) { + void draft.run(next, () => action({ logId: ticket.id, patch })); + } + + async function saveResolution() { + const landed = await draft.run(value, () => + action({ logId: ticket.id, patch: { resolution } }) + ); + if (landed) setCommitted(resolution); + } + + function assign(userId: string) { + const person = staff.find((member) => member.id === userId); + save( + { ...value, assignedToUserId: person?.id ?? null }, + // The name is stored beside the id as the snapshot §4.8 asks for, so the + // queue still says who has a ticket after that account is demoted. + { assignedToUserId: person?.id ?? null, assignedToName: person?.name ?? null } + ); + } + + return ( +
    + + + + + + +