e58b150ab2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
12 KiB
Markdown
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).
|