Files
biggus-dickus/docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md
2026-06-05 18:15:53 +02:00

221 lines
12 KiB
Markdown

# 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 ? '<key>'` (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).