From e58b150ab213f7b773d1677b06539fb1c8d803e9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 18:15:53 +0200 Subject: [PATCH] docs(specs): reference-data edit/delete lifecycle (#30 + #36) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-05-reference-data-edit-delete-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md diff --git a/docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md b/docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md new file mode 100644 index 0000000..b1e30a5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md @@ -0,0 +1,220 @@ +# Reference-Data Edit/Delete Lifecycle — Design + +**Date:** 2026-06-05 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issues:** #30 (vocabularies/terms/authorities edit+delete), #36 (field-definitions edit+delete). + +## Context + +The admin reference-data surface currently exposes **create + list only** for all four +entity kinds: + +- Vocabularies & terms — `GET/POST /api/admin/vocabularies`, + `GET/POST /api/admin/vocabularies/{id}/terms` +- Authorities — `GET/POST /api/admin/authorities?kind=` +- Field definitions — `GET/POST /api/admin/field-definitions` + +There is no way to rename a vocabulary, correct a term's labels/URI, fix a misspelled +authority, edit a field definition's labels/group/required flag, or delete any of them. +In a cataloguing system this is a real operational gap: reference data accrues mistakes +that today can only be fixed by direct DB edits. This milestone completes the CRUD +lifecycle — backend endpoints **and** frontend UI — for all four kinds. + +### The integrity constraint that shapes everything + +Object flexible-field values live in a **JSONB column on `object`** (migration +`0005_object_fields.sql`: `ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT +'{}'`), keyed by field-definition `key`, with `term`/`authority` references stored as +**UUID strings inside that JSON**. There is **no foreign key** from object data to +terms/authorities/field-definitions. Consequences: + +- Deleting a **term, authority, or field-definition** is *not* blocked or cascaded by + the database — it would silently leave dangling UUIDs / orphaned keys in objects. Any + "is it referenced?" guard must be an explicit JSONB scan we write. +- Deleting a **vocabulary** *is* already protected: `term.vocabulary_id` and + `field_definition.vocabulary_id` are `REFERENCES vocabulary (id) ON DELETE RESTRICT` + (migrations `0002`, `0004`). So a non-empty/bound vocabulary delete fails at the FK + level — we catch that and surface a clean 409, rather than invent a check. +- `term_label`, `authority_label`, `field_definition_label` are all `ON DELETE CASCADE` + from their parent, so deleting a term/authority/field-def cleans up its own labels. + +### Decisions settled in brainstorming + +1. **Scope:** cohesive — backend **and** frontend edit/delete for all four kinds in one + milestone (no backend endpoints shipping without UI). +2. **Delete policy for referenced entities:** **block with 409 + count.** Never silently + alter catalogue data. The curator must clear/reassign the referencing object fields + first. (Cascade-scrub was rejected as too destructive / no undo.) +3. **Field-definition immutability:** `key`, `data_type`, and binding + (`vocabulary_id`/`authority_kind`) are **immutable**; `labels`, `group_key`, + `required` are editable. +4. **`required` toggled on:** allowed; governs validation on *future* object writes only + — no retroactive scan or block of existing objects. +5. **Vocabulary `key` rename:** allowed (cosmetic — terms and field-definitions bind by + vocabulary UUID, not key). +6. **Frontend affordance:** **in-place edit + `AlertDialog` delete** (Option A) — reuse + the existing two-pane form panes and the installed AlertDialog; no new shadcn deps. +7. **Storybook:** add co-located stories for components created or meaningfully changed + where it makes sense (forms, editable rows, dialogs) — not every trivial change. + +## Backend + +All endpoints are gated by `EditCatalogue` and audited: every mutation runs the +insert/update/delete **and** `audit::record(&mut *conn, &NewAuditEvent { actor, action, +entity_type, entity_id, .. })` in **one transaction**, mirroring the #21 create-audit +pattern (`AuditActor::User(auth.user.id.to_uuid())`). Audit actions: `Updated` / +`Deleted`. + +### Endpoints + +**Vocabularies / terms** (`crates/api/src/admin_vocab.rs`) +- `PATCH /api/admin/vocabularies/{id}` — rename `key` only. +- `DELETE /api/admin/vocabularies/{id}` — allowed only when the vocabulary has no terms + and is bound by no field-definition. Enforced by the existing FK `RESTRICT`; the + resulting `sqlx` FK error is mapped to **409** with a reason, not a 500. +- `PATCH /api/admin/vocabularies/{id}/terms/{term_id}` — edit labels + `external_uri`. +- `DELETE /api/admin/vocabularies/{id}/terms/{term_id}` — **409 + count** if any object + references the term; otherwise delete (its `term_label` rows cascade). + +**Authorities** (`crates/api/src/admin_authorities.rs`) +- `PATCH /api/admin/authorities/{id}` — edit labels + `external_uri`. `kind` is + **immutable** (field bindings filter by kind). +- `DELETE /api/admin/authorities/{id}` — **409 + count** if referenced; otherwise delete + (`authority_label` cascades). + +**Field definitions** (`crates/api/src/` field-definition handler) +- `PATCH /api/admin/field-definitions/{key}` — edit labels, `group_key`, `required`. The + PATCH body exposes **only** those three fields, so `key`/`data_type`/binding are + **structurally immutable** (they cannot be sent — no runtime reject needed). Invalid + values (empty label, empty `group_key` — both have `CHECK <> ''` constraints) return + **422**, consistent with the create path. +- `DELETE /api/admin/field-definitions/{key}` — **409 + count** if any object stores that + field key; otherwise delete (label cascades). + +### Referenced-checks (JSONB scans) + +- **Term / authority referenced:** count objects whose `fields` JSONB contains the UUID + as a value. Use a jsonpath value-match, scoped to the field keys whose definition is + `term`/`authority`-typed for that vocabulary/kind, to avoid false positives. Returns a + count (and the implementation may also collect a small sample of object numbers for the + UI message). +- **Field-definition referenced:** count objects where `fields ? ''` (JSONB + key-exists operator) — simple and indexable. + +### DB layer (`crates/db/src/{vocab,authority,fields}.rs`) + +New functions, each taking `actor: AuditActor` and a `&mut PgConnection` and writing its +audit entry atomically (mirroring the existing create functions): + +- `vocab::rename_vocabulary`, `vocab::delete_vocabulary`, `vocab::update_term`, + `vocab::delete_term`, `vocab::count_objects_referencing_term` +- `authority::update_authority`, `authority::delete_authority`, + `authority::count_objects_referencing_authority` +- `fields::update_field_definition`, `fields::delete_field_definition`, + `fields::count_objects_using_field` + +`delete_vocabulary` maps the FK-restrict error to a typed "in use" error so the handler +can return 409. The three `count_*` helpers are read-only (no audit, no actor). + +### API error shape + +Reuse the existing typed-error conventions: +- **409 (referenced / in-use):** JSON body with the blocking count (e.g. + `{ "code": "in_use", "count": N }`) so the UI can render "used by N objects". +- **422 (invalid value):** the existing field-error shape + (`{ field, code }`, e.g. empty label / empty `group_key`), consistent with + `set_fields`' `FieldErrorView` and the create path. (Immutability is enforced + structurally by the PATCH DTO, not by a runtime 422.) +- **404:** entity not found. + +OpenAPI is regenerated so `web/src/api/schema.d.ts` picks up the new endpoints and DTOs. + +## Frontend + +**Affordance: in-place edit + `AlertDialog` delete**, per screen layout. Deletes +everywhere use the installed `AlertDialog`, mirroring the existing `DeleteObjectDialog`. + +### Per-screen + +**Fields** (`web/src/fields/`, two-pane `/fields`) +- Selecting a row in the left `FieldList` turns the right pane (`FieldForm`) into an + **edit form**: `labels` / `group_key` / `required` editable; `key` / `data_type` / + binding shown **disabled**. A "New field" button resets the pane to create mode. +- Each list row gets a delete affordance → `AlertDialog` confirm. + +**Vocabularies** (`web/src/vocab/`, two-pane `/vocabularies/:id`) +- Left `VocabularyList` rows: rename-`key` (inline edit) + delete. +- Right-pane (`VocabularyTerms`) term rows: edit (labels/URI) + delete. + +**Authorities** (`web/src/authorities/`, single-pane `/authorities/:kind`) +- Each authority row: inline-expand edit (labels/URI) + delete. + +### Hooks (`web/src/api/queries.ts`) + +Add, mirroring `useUpdateObject`/`useDeleteObject` and invalidating the correct list +query keys: `useRenameVocabulary`, `useDeleteVocabulary`, `useUpdateTerm`, +`useDeleteTerm`, `useUpdateAuthority`, `useDeleteAuthority`, `useUpdateFieldDefinition`, +`useDeleteFieldDefinition`. The **409 (referenced)** and **422 (immutable)** responses +parse into the existing `HttpError`/`FieldRejection` style. + +### Delete-blocked UX + +On a 409 the `AlertDialog` **stays open** and shows the blocking reason — +`t("actions.inUse", { count })` → e.g. *"Used by 7 objects — clear those fields first"* — +instead of closing. + +### i18n (`web/src/i18n/{en,sv}.json`) + +New action keys under `vocab.*` / `authorities.*` / `fields.*` plus a shared `actions.*` +namespace where it makes sense: `edit`, `delete`, `rename`, `save`, `cancel`, `inUse` +(count-interpolated). **en/sv parity** required. + +### Storybook + +Add co-located `*.stories.tsx` for the components that gain meaningful states: the +field-definition edit form, the editable term/authority row, and the delete-confirm +dialog wrapper. (Skip trivially-changed components.) + +## Testing + +**Backend** (existing infra: compose Postgres on host 5442, Meili on 7700; +`#[sqlx::test]` provisions its own DB; mirror `crates/api/tests/admin_catalog.rs`): +- **db:** update/delete per entity; each `count_objects_referencing_*` returns the right + count; delete blocked when referenced; `delete_vocabulary` FK-restrict → typed in-use + error; an audit row (`Updated`/`Deleted`, correct actor) per mutation. +- **api:** each `PATCH`/`DELETE` — `EditCatalogue` required (401/403 without), happy path + (200/204), **409 + count** when referenced, **422** on an invalid value (empty + `group_key`/label), **404** when missing. (Immutables are absent from the PATCH DTO, so + there is no "immutable changed" path to test.) +- OpenAPI regenerated. + +**Frontend** (Vitest + RTL + MSW, `onUnhandledRequest: "error"`): +- Mutation hooks invalidate the correct keys; a 409 parses into the typed error. +- Per screen: edit form populates and saves; delete confirms via `AlertDialog`; a 409 + keeps the dialog open showing "used by N"; field-def edit shows key/type/binding + disabled. +- en/sv key-parity check (existing test). +- Storybook stories for the meaningfully-changed components run green under the + addon-vitest project. + +## Acceptance criteria + +1. Update + delete endpoints for vocabulary (rename), term, authority, field-definition — + all `EditCatalogue`, all audited. +2. Referenced term/authority/field-def delete → **409 + count**; vocabulary delete → + **409** when it has terms or is bound. +3. Field-def `key`/`data_type`/binding immutable (absent from the PATCH DTO — cannot be + changed); `labels`/`group`/`required` editable; `required` not retroactively enforced. +4. In-place edit + `AlertDialog` delete on all three screens; a blocked delete shows + "used by N" without closing. +5. Storybook stories added for the meaningfully-changed components. +6. en/sv parity; no `any`/`eslint-disable`/`@ts-ignore`; codename ban; bundle ≤150 KB + gz; cargo + web typecheck/lint/test/build green; OpenAPI regenerated. + +## Out of scope / follow-ups + +- A "find/replace a reference across objects" bulk-reassign tool (would let a curator + clear references blocking a delete in one action) — file if the 409 friction is felt. +- Surfacing the referencing objects as clickable links in the 409 message (v1 shows a + count + optional sample, not a live list). +- Audit-entry coalescing for multi-step edits (#13, separate).