From f6053068be7b849fabe2ba3ba81e30f9ff06f2a0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 5 Jun 2026 18:30:55 +0200 Subject: [PATCH] docs(plans): reference-data edit/delete lifecycle (#30 + #36) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-05-reference-data-edit-delete.md | 2298 +++++++++++++++++ 1 file changed, 2298 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-reference-data-edit-delete.md diff --git a/docs/superpowers/plans/2026-06-05-reference-data-edit-delete.md b/docs/superpowers/plans/2026-06-05-reference-data-edit-delete.md new file mode 100644 index 0000000..78e3b33 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-reference-data-edit-delete.md @@ -0,0 +1,2298 @@ +# Reference-Data Edit/Delete Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add update + delete across the admin reference-data surface — vocabularies (rename), terms, authorities, and field definitions — with backend endpoints (audited, referenced-entity-safe) and in-place frontend edit/delete UI. + +**Architecture:** Each backend task adds db-layer functions (mutation + atomic `audit::record`, plus a read-only referenced-count) and the matching axum handlers. Deletes that would orphan object data are blocked with **409 + count**; the count comes from a JSONB scan of `object.fields` (object flexible-field values are stored as a JSONB blob with no FK to reference data). The frontend mirrors the existing `useUpdateObject`/`useDeleteObject` hooks and the `DeleteObjectDialog`/`AlertDialog` pattern, editing in place (no modal dialogs, no new shadcn components). + +**Tech Stack:** Rust (axum 0.8, sqlx 0.8 + Postgres, utoipa 5, thiserror), React 19 + TypeScript + pnpm, TanStack Query v5, react-i18next, shadcn/ui (Base UI "base-nova"), Vitest + RTL + MSW, Storybook 10. + +**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; storage always UTC; **codename ban** (never write "biggus"/"dickus"). Spec: `docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md`. + +**Test infra:** compose Postgres on host **5442**, Meili on **7700**. Backend tests: +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \ +MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test ... +``` +`#[sqlx::test(migrations = "../db/migrations")]` provisions its own temp DB. Run `docker compose up -d` first and confirm `pg_isready`. Web tests: `cd web && pnpm test` (jsdom + storybook projects), `pnpm typecheck`, `pnpm lint`, `pnpm build`. + +--- + +## File Structure + +**Backend** +- `crates/db/src/lib.rs` — add `pub enum DeleteOutcome { Deleted, InUse { count: i64 }, NotFound }` (shared by all delete fns). +- `crates/db/src/vocab.rs` — add `update_term`, `delete_term`, `count_objects_referencing_term`, `rename_vocabulary`, `delete_vocabulary`. +- `crates/db/src/authority.rs` — add `update_authority`, `delete_authority`, `count_objects_referencing_authority`. +- `crates/db/src/fields.rs` — add audit wiring + `update_field_definition`, `delete_field_definition`, `count_objects_using_field`. +- `crates/api/src/admin_vocab.rs` — PATCH/DELETE term + PATCH/DELETE vocabulary handlers, DTOs, routes; shared `InUseView`. +- `crates/api/src/admin_authorities.rs` — PATCH/DELETE authority handler, DTO, routes. +- `crates/api/src/admin_objects.rs` — PATCH/DELETE field-definition handlers, DTOs, routes; define `pub(crate) struct InUseView`. +- `crates/api/src/openapi.rs` — register all new paths + schemas. +- Tests: `crates/db/tests/vocab.rs`, `crates/db/tests/authority.rs`, `crates/db/tests/fields.rs` (new), `crates/api/tests/admin_catalog.rs`. + +**Frontend** +- `web/src/api/schema.d.ts` — regenerated (Task 5). +- `web/src/api/queries.ts` — 8 mutation hooks + `InUseError` class. +- `web/src/i18n/en.json`, `web/src/i18n/sv.json` — action/in-use keys. +- `web/src/components/delete-confirm-dialog.tsx` (new) + `.stories.tsx` — generic delete confirm with in-use handling. +- `web/src/fields/field-form.tsx`, `field-list.tsx`, `fields-page.tsx` (+ stories) — edit mode + delete. +- `web/src/vocab/vocabulary-list.tsx`, `vocabulary-terms.tsx` + new `term-row.tsx` (+ stories) — rename + term edit/delete. +- `web/src/authorities/authorities-page.tsx` + new `authority-row.tsx` (+ stories) — edit/delete. + +**Shared db delete contract (defined in Task 1, used by all):** +```rust +/// Result of a delete that may be blocked by references from catalogue objects. +pub enum DeleteOutcome { + Deleted, + InUse { count: i64 }, + NotFound, +} +``` + +--- + +## Task 1: Term edit/delete (db + api) + +**Files:** +- Modify: `crates/db/src/lib.rs` (add `DeleteOutcome`) +- Modify: `crates/db/src/vocab.rs` +- Modify: `crates/api/src/admin_vocab.rs`, `crates/api/src/openapi.rs` +- Test: `crates/db/tests/vocab.rs`, `crates/api/tests/admin_catalog.rs` + +- [ ] **Step 1: Add the shared `DeleteOutcome` enum.** In `crates/db/src/lib.rs`, add near the top (after the existing `pub mod`/`pub use` lines): + +```rust +/// Result of a delete that catalogue-object references may block. +#[derive(Debug, PartialEq, Eq)] +pub enum DeleteOutcome { + /// The row was deleted. + Deleted, + /// Refused: `count` catalogue objects still reference it. + InUse { count: i64 }, + /// The row did not exist. + NotFound, +} +``` + +- [ ] **Step 2: Write failing db tests for term update/delete.** Append to `crates/db/tests/vocab.rs` (mirror the existing tests' setup — they build `Db`, create a vocabulary + term via `vocab::create_vocabulary`/`vocab::add_term` inside a tx with `AuditActor::System`). Reference how `set_object_fields` is used in `crates/db/tests/object_fields.rs` to attach a term value to an object. + +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn update_term_changes_labels_and_uri(pool: PgPool) { + use db::DeleteOutcome; + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") + .await + .unwrap(); + let term_id = vocab::add_term( + &mut tx, + AuditActor::System, + &NewTerm { + vocabulary_id: vocab.id, + external_uri: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Trä".into() }], + }, + ) + .await + .unwrap(); + + let existed = vocab::update_term( + &mut tx, + AuditActor::System, + term_id, + Some("https://example.org/wood"), + &[LocalizedLabel { lang: "sv".into(), label: "Träslag".into() }], + ) + .await + .unwrap(); + assert!(existed); + tx.commit().await.unwrap(); + + let term = vocab::term_by_id(db.pool(), term_id).await.unwrap().unwrap(); + assert_eq!(term.external_uri.as_deref(), Some("https://example.org/wood")); + assert_eq!(term.labels.len(), 1); + assert_eq!(term.labels[0].label, "Träslag"); + let _ = DeleteOutcome::Deleted; // import smoke +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) { + use db::{DeleteOutcome, catalog, fields}; + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material") + .await + .unwrap(); + let term_id = vocab::add_term( + &mut tx, + AuditActor::System, + &NewTerm { vocabulary_id: vocab.id, external_uri: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Trä".into() }] }, + ).await.unwrap(); + // A field definition of type `term` + an object using this term. + fields::create_field_definition(&mut tx, &NewFieldDefinition { + key: "material".into(), + field_type: domain::FieldType::Term { vocabulary_id: vocab.id }, + required: false, group_key: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Material".into() }], + }).await.unwrap(); + let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input()).await.unwrap(); + let mut map = serde_json::Map::new(); + map.insert("material".into(), serde_json::Value::String(term_id.to_string())); + catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map).await.unwrap(); + + // Referenced → blocked with a count of 1. + let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap(); + assert_eq!(blocked, DeleteOutcome::InUse { count: 1 }); + + // Clear the reference, then delete succeeds. + catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new()).await.unwrap(); + let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap(); + assert_eq!(ok, DeleteOutcome::Deleted); + assert!(vocab::term_by_id(&mut *tx, term_id).await.unwrap().is_none()); + + // Deleting again → NotFound. + let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap(); + assert_eq!(gone, DeleteOutcome::NotFound); +} +``` + +Add a small helper at the bottom of the test file if one does not already exist (copy field names from `crates/db/tests/object_fields.rs`'s object setup): +```rust +fn sample_object_input() -> domain::ObjectInput { + domain::ObjectInput { + object_number: "X.1".into(), + object_name: "Test".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: domain::Visibility::Draft, + } +} +``` +Ensure the test file's `use` includes `db::{vocab}`, `domain::{AuditActor, LocalizedLabel, NewTerm, NewFieldDefinition}`, `sqlx::PgPool`, and `db::Db`. + +- [ ] **Step 3: Run the tests to confirm they fail.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab +``` +Expected: FAIL — `update_term`/`delete_term` not found. + +- [ ] **Step 4: Implement the db functions.** In `crates/db/src/vocab.rs`, add to the `use` line `DeleteOutcome` is in the `db` crate root, so reference it as `crate::DeleteOutcome`. Add: + +```rust +/// Update a term's `external_uri` and labels (full replace), recording an `updated` +/// audit entry. Returns `false` if no such term. Pass a transaction connection. +pub async fn update_term( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + term_id: TermId, + external_uri: Option<&str>, + labels: &[LocalizedLabel], +) -> Result { + let updated = sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1") + .bind(term_id.to_uuid()) + .bind(external_uri) + .execute(&mut *conn) + .await? + .rows_affected(); + + if updated == 0 { + return Ok(false); + } + + sqlx::query("DELETE FROM term_label WHERE term_id = $1") + .bind(term_id.to_uuid()) + .execute(&mut *conn) + .await?; + + for label in labels { + sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)") + .bind(term_id.to_uuid()) + .bind(&label.lang) + .bind(&label.label) + .execute(&mut *conn) + .await?; + } + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Updated, + entity_type: TERM_ENTITY_TYPE.to_owned(), + entity_id: term_id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + + Ok(true) +} + +/// Count catalogue objects that reference `term_id` through a `term`-typed field. +pub async fn count_objects_referencing_term<'e, E>( + executor: E, + term_id: TermId, +) -> Result +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query_scalar( + "SELECT count(*) FROM object o WHERE EXISTS ( \ + SELECT 1 FROM field_definition fd \ + WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )", + ) + .bind(term_id.to_string()) + .fetch_one(executor) + .await +} + +/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a +/// `deleted` audit entry. Pass a transaction connection. +pub async fn delete_term( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + vocabulary_id: VocabularyId, + term_id: TermId, +) -> Result { + let exists = sqlx::query_scalar::<_, i32>( + "SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2", + ) + .bind(term_id.to_uuid()) + .bind(vocabulary_id.to_uuid()) + .fetch_optional(&mut *conn) + .await?; + + if exists.is_none() { + return Ok(crate::DeleteOutcome::NotFound); + } + + let count = count_objects_referencing_term(&mut *conn, term_id).await?; + if count > 0 { + return Ok(crate::DeleteOutcome::InUse { count }); + } + + sqlx::query("DELETE FROM term WHERE id = $1") + .bind(term_id.to_uuid()) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Deleted, + entity_type: TERM_ENTITY_TYPE.to_owned(), + entity_id: term_id.to_uuid(), + changes: Vec::new(), + }, + ) + .await?; + + Ok(crate::DeleteOutcome::Deleted) +} +``` + +- [ ] **Step 5: Run the db tests to confirm they pass.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab +``` +Expected: PASS. + +- [ ] **Step 6: Write failing api tests** in `crates/api/tests/admin_catalog.rs` (mirror `create_list_vocabulary_and_terms` for setup: `seed_user(Role::Editor)`, `build_app(state(pool))`, `login(...)`, then `oneshot` requests with the session cookie). Add a small PATCH/DELETE helper if none exists: + +```rust +async fn send(app: &axum::Router, cookie: &str, method: &str, uri: &str, body: Option<&str>) -> axum::http::Response { + let mut req = Request::builder().method(method).uri(uri).header(header::COOKIE, cookie); + if let Some(b) = body { + req = req.header(header::CONTENT_TYPE, "application/json"); + app.clone().oneshot(req.body(Body::from(b.to_owned())).unwrap()).await.unwrap() + } else { + app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap() + } +} +``` + +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn edit_and_delete_term(pool: PgPool) { + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + // create a vocabulary + term + let v = send(&app, &cookie, "POST", "/api/admin/vocabularies", + Some(r#"{"key":"material"}"#)).await; + let vid: serde_json::Value = serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vid = vid["id"].as_str().unwrap().to_owned(); + let t = send(&app, &cookie, "POST", &format!("/api/admin/vocabularies/{vid}/terms"), + Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#)).await; + let tid: serde_json::Value = serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let tid = tid["id"].as_str().unwrap().to_owned(); + + // PATCH the term + let patched = send(&app, &cookie, "PATCH", + &format!("/api/admin/vocabularies/{vid}/terms/{tid}"), + Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#)).await; + assert_eq!(patched.status(), StatusCode::NO_CONTENT); + + // DELETE the (unreferenced) term + let deleted = send(&app, &cookie, "DELETE", + &format!("/api/admin/vocabularies/{vid}/terms/{tid}"), None).await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + + // DELETE again → 404 + let again = send(&app, &cookie, "DELETE", + &format!("/api/admin/vocabularies/{vid}/terms/{tid}"), None).await; + assert_eq!(again.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn term_edit_delete_requires_auth(pool: PgPool) { + let app = build_app(state(pool)); + let r = app.clone().oneshot( + Request::builder().method("DELETE") + .uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000") + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(r.status(), StatusCode::UNAUTHORIZED); +} +``` + +- [ ] **Step 7: Run to confirm failure.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api --test admin_catalog edit_and_delete_term +``` +Expected: FAIL (routes 404 / method not allowed). + +- [ ] **Step 8: Implement the api handlers, DTOs, and routes.** In `crates/api/src/admin_vocab.rs`: + - Add imports: `axum::response::{IntoResponse, Response}`, `axum::routing` already imported via `get`. Add `domain::TermId`. Import the shared in-use view: `use crate::admin_objects::InUseView;` (defined in Task 4 / or define here if Task 4 not yet done — see note). + - Add DTOs: +```rust +#[derive(Deserialize, ToSchema)] +pub(crate) struct UpdateTermRequest { + pub external_uri: Option, + pub labels: Vec, +} +``` + - Add handlers: +```rust +#[utoipa::path( + patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}", + request_body = UpdateTermRequest, + params(("id" = String, Path, description = "Vocabulary id (UUID)"), + ("term_id" = String, Path, description = "Term id (UUID)")), + responses((status = 204), (status = 401), (status = 403), (status = 404)) +)] +pub(crate) async fn update_term( + auth: Authorized, + State(state): State, + Path((_id, term_id)): Path<(String, String)>, + Json(req): Json, +) -> Result { + let term_id = term_id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let labels: Vec = req.labels.into_iter() + .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect(); + + let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let existed = db::vocab::update_term( + &mut tx, AuditActor::User(auth.user.id.to_uuid()), + term_id, req.external_uri.as_deref(), &labels, + ).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) } +} + +#[utoipa::path( + delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}", + params(("id" = String, Path, description = "Vocabulary id (UUID)"), + ("term_id" = String, Path, description = "Term id (UUID)")), + responses((status = 204), (status = 401), (status = 403), (status = 404), + (status = 409, body = InUseView, description = "Referenced by catalogue objects")) +)] +pub(crate) async fn delete_term( + auth: Authorized, + State(state): State, + Path((id, term_id)): Path<(String, String)>, +) -> Response { + let (Ok(vocab_id), Ok(term_id)) = (id.parse::(), term_id.parse::()) else { + return StatusCode::NOT_FOUND.into_response(); + }; + + let Ok(mut tx) = state.db.pool().begin().await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let outcome = db::vocab::delete_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), vocab_id, term_id).await; + + match outcome { + Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(), + Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} +``` + - **Note on `InUseView`:** it is owned by Task 4 (`admin_objects.rs`). To keep tasks independently compilable, define it here in `admin_vocab.rs` instead and have Task 4 import it from here: +```rust +/// 409 body: how many catalogue objects still reference the entity. +#[derive(Serialize, ToSchema)] +pub(crate) struct InUseView { + pub count: i64, +} +``` +Then drop the `use crate::admin_objects::InUseView;` import above and reference the local `InUseView`. (Adjust the File Structure note accordingly: `InUseView` lives in `admin_vocab.rs`.) + - Extend `routes()`: +```rust +.route( + "/api/admin/vocabularies/{id}/terms/{term_id}", + axum::routing::patch(update_term).delete(delete_term), +) +``` + +- [ ] **Step 9: Register in OpenAPI.** In `crates/api/src/openapi.rs`, add to `paths(...)`: `admin_vocab::update_term,` and `admin_vocab::delete_term,`. Add to `components(schemas(...))`: `admin_vocab::UpdateTermRequest,` and `admin_vocab::InUseView,`. + +- [ ] **Step 10: Run api tests + fmt + clippy.** +``` +cargo +nightly fmt +cargo clippy -p api -p db --all-targets -- -D warnings +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db +``` +Expected: all PASS, clippy clean. + +- [ ] **Step 11: Commit.** +```bash +git add crates/db crates/api +git commit -m "feat: edit/delete terms — audited, blocked when referenced (#30)" +``` + +--- + +## Task 2: Vocabulary rename/delete (db + api) + +**Files:** +- Modify: `crates/db/src/vocab.rs`, `crates/api/src/admin_vocab.rs`, `crates/api/src/openapi.rs` +- Test: `crates/db/tests/vocab.rs`, `crates/api/tests/admin_catalog.rs` + +- [ ] **Step 1: Write failing db tests** in `crates/db/tests/vocab.rs`: +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn rename_vocabulary_changes_key(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old").await.unwrap(); + let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new").await.unwrap(); + assert!(existed); + tx.commit().await.unwrap(); + assert!(vocab::vocabulary_by_key(db.pool(), "new").await.unwrap().is_some()); + assert!(vocab::vocabulary_by_key(db.pool(), "old").await.unwrap().is_none()); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) { + use db::DeleteOutcome; + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material").await.unwrap(); + vocab::add_term(&mut tx, AuditActor::System, &NewTerm { + vocabulary_id: v.id, external_uri: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Trä".into() }], + }).await.unwrap(); + + let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id).await.unwrap(); + assert_eq!(blocked, DeleteOutcome::InUse { count: 1 }); + + // Empty vocabulary deletes cleanly. + let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty").await.unwrap(); + assert_eq!(vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id).await.unwrap(), + DeleteOutcome::Deleted); +} +``` + +- [ ] **Step 2: Run to confirm failure.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab rename_vocabulary delete_vocabulary +``` +Expected: FAIL. + +- [ ] **Step 3: Implement.** In `crates/db/src/vocab.rs`: +```rust +/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no +/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505). +pub async fn rename_vocabulary( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: VocabularyId, + key: &str, +) -> Result { + let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1") + .bind(id.to_uuid()) + .bind(key) + .execute(&mut *conn) + .await? + .rows_affected(); + + if updated == 0 { + return Ok(false); + } + + audit::record(&mut *conn, &NewAuditEvent { + actor, action: AuditAction::Updated, + entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), changes: Vec::new(), + }).await?; + + Ok(true) +} + +/// Delete a vocabulary unless it still has terms or is bound by a field definition +/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry. +pub async fn delete_vocabulary( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: VocabularyId, +) -> Result { + let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1") + .bind(id.to_uuid()) + .fetch_optional(&mut *conn) + .await?; + if exists.is_none() { + return Ok(crate::DeleteOutcome::NotFound); + } + + let count: i64 = sqlx::query_scalar( + "SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \ + + (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)", + ) + .bind(id.to_uuid()) + .fetch_one(&mut *conn) + .await?; + if count > 0 { + return Ok(crate::DeleteOutcome::InUse { count }); + } + + sqlx::query("DELETE FROM vocabulary WHERE id = $1") + .bind(id.to_uuid()) + .execute(&mut *conn) + .await?; + + audit::record(&mut *conn, &NewAuditEvent { + actor, action: AuditAction::Deleted, + entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), changes: Vec::new(), + }).await?; + + Ok(crate::DeleteOutcome::Deleted) +} +``` +Add `const VOCABULARY_ENTITY_TYPE: &str = "vocabulary";` already exists in this file (it does). + +- [ ] **Step 4: Run db tests — expect PASS.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab +``` + +- [ ] **Step 5: Write failing api tests** in `crates/api/tests/admin_catalog.rs` (reuse the `send` helper from Task 1): +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn rename_and_delete_vocabulary(pool: PgPool) { + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let v = send(&app, &cookie, "POST", "/api/admin/vocabularies", Some(r#"{"key":"old"}"#)).await; + let vid: serde_json::Value = serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vid = vid["id"].as_str().unwrap().to_owned(); + + let renamed = send(&app, &cookie, "PATCH", &format!("/api/admin/vocabularies/{vid}"), + Some(r#"{"key":"new"}"#)).await; + assert_eq!(renamed.status(), StatusCode::NO_CONTENT); + + let deleted = send(&app, &cookie, "DELETE", &format!("/api/admin/vocabularies/{vid}"), None).await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_vocabulary_with_terms_is_409(pool: PgPool) { + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + let v = send(&app, &cookie, "POST", "/api/admin/vocabularies", Some(r#"{"key":"material"}"#)).await; + let vid: serde_json::Value = serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let vid = vid["id"].as_str().unwrap().to_owned(); + send(&app, &cookie, "POST", &format!("/api/admin/vocabularies/{vid}/terms"), + Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#)).await; + + let blocked = send(&app, &cookie, "DELETE", &format!("/api/admin/vocabularies/{vid}"), None).await; + assert_eq!(blocked.status(), StatusCode::CONFLICT); + let body: serde_json::Value = serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(body["count"], 1); +} +``` + +- [ ] **Step 6: Implement handlers + routes** in `crates/api/src/admin_vocab.rs`: +```rust +#[derive(Deserialize, ToSchema)] +pub(crate) struct RenameVocabularyRequest { + pub key: String, +} + +#[utoipa::path( + patch, path = "/api/admin/vocabularies/{id}", + request_body = RenameVocabularyRequest, + params(("id" = String, Path, description = "Vocabulary id (UUID)")), + responses((status = 204), (status = 401), (status = 403), (status = 404), + (status = 409, description = "Key already in use")) +)] +pub(crate) async fn rename_vocabulary( + auth: Authorized, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let existed = db::vocab::rename_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id, &req.key) + .await + .map_err(|err| { + // Unique-key collision → 409. + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") { + StatusCode::CONFLICT + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) } +} + +#[utoipa::path( + delete, path = "/api/admin/vocabularies/{id}", + params(("id" = String, Path, description = "Vocabulary id (UUID)")), + responses((status = 204), (status = 401), (status = 403), (status = 404), + (status = 409, body = InUseView, description = "Has terms or is bound by a field")) +)] +pub(crate) async fn delete_vocabulary( + auth: Authorized, + State(state): State, + Path(id): Path, +) -> Response { + let Ok(id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response() }; + let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; + match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await { + Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(), + Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} +``` +Extend `routes()`: +```rust +.route( + "/api/admin/vocabularies/{id}", + axum::routing::patch(rename_vocabulary).delete(delete_vocabulary), +) +``` + +- [ ] **Step 7: Register in OpenAPI.** Add `admin_vocab::rename_vocabulary,` and `admin_vocab::delete_vocabulary,` to `paths(...)`, and `admin_vocab::RenameVocabularyRequest,` to schemas. + +- [ ] **Step 8: fmt, clippy, test.** +``` +cargo +nightly fmt && cargo clippy -p api -p db --all-targets -- -D warnings +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db +``` +Expected: all PASS. + +- [ ] **Step 9: Commit.** +```bash +git add crates/db crates/api +git commit -m "feat: rename + delete vocabularies, blocked when in use (#30)" +``` + +--- + +## Task 3: Authority edit/delete (db + api) + +**Files:** +- Modify: `crates/db/src/authority.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/openapi.rs` +- Test: `crates/db/tests/authority.rs`, `crates/api/tests/admin_catalog.rs` + +- [ ] **Step 1: Write failing db tests** in `crates/db/tests/authority.rs` (mirror existing setup: `authority::create_authority` in a tx). For the referenced case, create an `authority`-typed field definition + an object whose `fields` stores the authority id, via `fields::create_field_definition` + `catalog::set_object_fields` (see Task 1 Step 2 for the pattern; use `domain::FieldType::Authority { kind }`). +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn update_authority_changes_labels(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = authority::create_authority(&mut tx, AuditActor::System, &NewAuthority { + kind: AuthorityKind::Person, external_uri: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Anon".into() }], + }).await.unwrap(); + + let existed = authority::update_authority(&mut tx, AuditActor::System, id, + Some("https://viaf.org/1"), + &[LocalizedLabel { lang: "sv".into(), label: "Astrid".into() }]).await.unwrap(); + assert!(existed); + tx.commit().await.unwrap(); + let a = authority::authority_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1")); + assert_eq!(a.labels[0].label, "Astrid"); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_authority_blocks_when_referenced(pool: PgPool) { + use db::{DeleteOutcome, catalog, fields}; + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = authority::create_authority(&mut tx, AuditActor::System, &NewAuthority { + kind: AuthorityKind::Person, external_uri: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Astrid".into() }], + }).await.unwrap(); + fields::create_field_definition(&mut tx, &NewFieldDefinition { + key: "maker".into(), + field_type: domain::FieldType::Authority { kind: AuthorityKind::Person }, + required: false, group_key: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Tillverkare".into() }], + }).await.unwrap(); + let obj = catalog::create_object(&mut tx, AuditActor::System, &super::sample_object_input()).await.unwrap(); + let mut map = serde_json::Map::new(); + map.insert("maker".into(), serde_json::Value::String(id.to_string())); + catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map).await.unwrap(); + + assert_eq!(authority::delete_authority(&mut tx, AuditActor::System, id).await.unwrap(), + DeleteOutcome::InUse { count: 1 }); + catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new()).await.unwrap(); + assert_eq!(authority::delete_authority(&mut tx, AuditActor::System, id).await.unwrap(), + DeleteOutcome::Deleted); +} +``` +(If `sample_object_input()` lives in `vocab.rs` tests, duplicate the tiny helper into `authority.rs` tests rather than cross-referencing — test crates don't share modules. Define it locally.) + +- [ ] **Step 2: Run to confirm failure.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test authority +``` + +- [ ] **Step 3: Implement** in `crates/db/src/authority.rs` (the file already imports `AuditAction, AuditActor, NewAuditEvent`): +```rust +/// Update an authority's `external_uri` and labels (full replace), recording an +/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable. +pub async fn update_authority( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: AuthorityId, + external_uri: Option<&str>, + labels: &[LocalizedLabel], +) -> Result { + let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1") + .bind(id.to_uuid()) + .bind(external_uri) + .execute(&mut *conn) + .await? + .rows_affected(); + if updated == 0 { + return Ok(false); + } + + sqlx::query("DELETE FROM authority_label WHERE authority_id = $1") + .bind(id.to_uuid()) + .execute(&mut *conn) + .await?; + for label in labels { + sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)") + .bind(id.to_uuid()) + .bind(&label.lang) + .bind(&label.label) + .execute(&mut *conn) + .await?; + } + + audit::record(&mut *conn, &NewAuditEvent { + actor, action: AuditAction::Updated, + entity_type: AUTHORITY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), changes: Vec::new(), + }).await?; + Ok(true) +} + +/// Count catalogue objects referencing `id` through an `authority`-typed field. +pub async fn count_objects_referencing_authority<'e, E>( + executor: E, + id: AuthorityId, +) -> Result +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query_scalar( + "SELECT count(*) FROM object o WHERE EXISTS ( \ + SELECT 1 FROM field_definition fd \ + WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )", + ) + .bind(id.to_string()) + .fetch_one(executor) + .await +} + +/// Delete an authority (labels cascade) unless catalogue objects reference it, +/// recording a `deleted` audit entry. +pub async fn delete_authority( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + id: AuthorityId, +) -> Result { + let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1") + .bind(id.to_uuid()) + .fetch_optional(&mut *conn) + .await?; + if exists.is_none() { + return Ok(crate::DeleteOutcome::NotFound); + } + let count = count_objects_referencing_authority(&mut *conn, id).await?; + if count > 0 { + return Ok(crate::DeleteOutcome::InUse { count }); + } + sqlx::query("DELETE FROM authority WHERE id = $1") + .bind(id.to_uuid()) + .execute(&mut *conn) + .await?; + audit::record(&mut *conn, &NewAuditEvent { + actor, action: AuditAction::Deleted, + entity_type: AUTHORITY_ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), changes: Vec::new(), + }).await?; + Ok(crate::DeleteOutcome::Deleted) +} +``` + +- [ ] **Step 4: Run db tests — PASS.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test authority +``` + +- [ ] **Step 5: Write failing api tests** in `crates/api/tests/admin_catalog.rs`: +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn edit_and_delete_authority(pool: PgPool) { + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let a = send(&app, &cookie, "POST", "/api/admin/authorities", + Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#)).await; + let aid: serde_json::Value = serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let aid = aid["id"].as_str().unwrap().to_owned(); + + let patched = send(&app, &cookie, "PATCH", &format!("/api/admin/authorities/{aid}"), + Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#)).await; + assert_eq!(patched.status(), StatusCode::NO_CONTENT); + + let deleted = send(&app, &cookie, "DELETE", &format!("/api/admin/authorities/{aid}"), None).await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); +} +``` + +- [ ] **Step 6: Implement handlers + routes** in `crates/api/src/admin_authorities.rs`: + - Imports: add `AuthorityId` to the `domain` import, `axum::response::{IntoResponse, Response}`, and `use crate::admin_vocab::InUseView;`. + - DTO + handlers: +```rust +#[derive(Deserialize, ToSchema)] +pub(crate) struct UpdateAuthorityRequest { + pub external_uri: Option, + pub labels: Vec, +} + +#[utoipa::path( + patch, path = "/api/admin/authorities/{id}", + request_body = UpdateAuthorityRequest, + params(("id" = String, Path, description = "Authority id (UUID)")), + responses((status = 204), (status = 401), (status = 403), (status = 404)) +)] +pub(crate) async fn update_authority( + auth: Authorized, + State(state): State, + axum::extract::Path(id): axum::extract::Path, + Json(req): Json, +) -> Result { + let id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let labels: Vec = req.labels.into_iter() + .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect(); + let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let existed = db::authority::update_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), + id, req.external_uri.as_deref(), &labels) + .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) } +} + +#[utoipa::path( + delete, path = "/api/admin/authorities/{id}", + params(("id" = String, Path, description = "Authority id (UUID)")), + responses((status = 204), (status = 401), (status = 403), (status = 404), + (status = 409, body = InUseView)) +)] +pub(crate) async fn delete_authority( + auth: Authorized, + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> Response { + let Ok(id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response() }; + let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; + match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await { + Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(), + Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} +``` + - Extend `routes()`: +```rust +.route( + "/api/admin/authorities/{id}", + axum::routing::patch(update_authority).delete(delete_authority), +) +``` + +- [ ] **Step 7: Register in OpenAPI.** Add `admin_authorities::update_authority,` and `admin_authorities::delete_authority,` to `paths(...)`, and `admin_authorities::UpdateAuthorityRequest,` to schemas. + +- [ ] **Step 8: fmt, clippy, test.** +``` +cargo +nightly fmt && cargo clippy -p api -p db --all-targets -- -D warnings +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db +``` + +- [ ] **Step 9: Commit.** +```bash +git add crates/db crates/api +git commit -m "feat: edit/delete authorities, blocked when referenced (#30)" +``` + +--- + +## Task 4: Field-definition edit/delete (db + api) + +**Files:** +- Modify: `crates/db/src/fields.rs`, `crates/api/src/admin_objects.rs`, `crates/api/src/openapi.rs` +- Test: `crates/db/tests/fields.rs` (new), `crates/api/tests/admin_catalog.rs` + +- [ ] **Step 1: Create `crates/db/tests/fields.rs`** with failing tests (mirror the `Db::from_pool` + tx pattern; reuse a local `sample_object_input()` helper): +```rust +use db::{Db, DeleteOutcome, catalog, fields}; +use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; +use sqlx::PgPool; + +fn sample_object_input() -> domain::ObjectInput { + domain::ObjectInput { + object_number: "X.1".into(), object_name: "Test".into(), number_of_objects: 1, + brief_description: None, current_location: None, current_owner: None, + recorder: None, recording_date: None, visibility: domain::Visibility::Draft, + } +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn update_field_definition_edits_labels_group_required(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + fields::create_field_definition(&mut tx, &NewFieldDefinition { + key: "weight".into(), field_type: FieldType::Integer, required: false, group_key: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Vikt".into() }], + }).await.unwrap(); + + let existed = fields::update_field_definition(&mut tx, AuditActor::System, "weight", + true, Some("Mått"), + &[LocalizedLabel { lang: "sv".into(), label: "Vikt (g)".into() }]).await.unwrap(); + assert!(existed); + tx.commit().await.unwrap(); + + let def = fields::field_definition_by_key(db.pool(), "weight").await.unwrap().unwrap(); + assert!(def.required); + assert_eq!(def.group_key.as_deref(), Some("Mått")); + assert_eq!(def.labels[0].label, "Vikt (g)"); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + fields::create_field_definition(&mut tx, &NewFieldDefinition { + key: "weight".into(), field_type: FieldType::Integer, required: false, group_key: None, + labels: vec![LocalizedLabel { lang: "sv".into(), label: "Vikt".into() }], + }).await.unwrap(); + let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input()).await.unwrap(); + let mut map = serde_json::Map::new(); + map.insert("weight".into(), serde_json::Value::from(42)); + catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map).await.unwrap(); + + assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(), + DeleteOutcome::InUse { count: 1 }); + catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new()).await.unwrap(); + assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(), + DeleteOutcome::Deleted); + assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(), + DeleteOutcome::NotFound); +} +``` +(Confirm `domain::FieldType::Integer` is the correct variant name by reading `crates/domain/src/field_definition.rs`; adjust if the integer variant is spelled differently.) + +- [ ] **Step 2: Run to confirm failure.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test fields +``` + +- [ ] **Step 3: Implement** in `crates/db/src/fields.rs`. Add audit imports + entity const at the top: +```rust +use domain::{ + AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, + LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId, +}; +// ...existing `use sqlx::Row;` +use crate::audit; + +const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition"; +``` +Then add the functions: +```rust +/// Update a field definition's mutable attributes (labels, group, required); `key`, +/// `data_type`, and binding are immutable and untouched. Records an `updated` audit +/// entry. Returns `false` if no such key. Pass a transaction connection. +pub async fn update_field_definition( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + key: &str, + required: bool, + group_key: Option<&str>, + labels: &[LocalizedLabel], +) -> Result { + let id: Option = + sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1") + .bind(key) + .fetch_optional(&mut *conn) + .await?; + let Some(id) = id else { return Ok(false) }; + + sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1") + .bind(id) + .bind(required) + .bind(group_key) + .execute(&mut *conn) + .await?; + + sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1") + .bind(id) + .execute(&mut *conn) + .await?; + for label in labels { + sqlx::query( + "INSERT INTO field_definition_label (field_definition_id, lang, label) VALUES ($1, $2, $3)", + ) + .bind(id) + .bind(&label.lang) + .bind(&label.label) + .execute(&mut *conn) + .await?; + } + + audit::record(&mut *conn, &NewAuditEvent { + actor, action: AuditAction::Updated, + entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(), + entity_id: id, changes: Vec::new(), + }).await?; + Ok(true) +} + +/// Count catalogue objects that store a value under field `key`. +pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)") + .bind(key) + .fetch_one(executor) + .await +} + +/// Delete a field definition (labels cascade) unless catalogue objects use its key, +/// recording a `deleted` audit entry. +pub async fn delete_field_definition( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + key: &str, +) -> Result { + let id: Option = + sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1") + .bind(key) + .fetch_optional(&mut *conn) + .await?; + let Some(id) = id else { return Ok(crate::DeleteOutcome::NotFound) }; + + let count = count_objects_using_field(&mut *conn, key).await?; + if count > 0 { + return Ok(crate::DeleteOutcome::InUse { count }); + } + + sqlx::query("DELETE FROM field_definition WHERE id = $1") + .bind(id) + .execute(&mut *conn) + .await?; + audit::record(&mut *conn, &NewAuditEvent { + actor, action: AuditAction::Deleted, + entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(), + entity_id: id, changes: Vec::new(), + }).await?; + Ok(crate::DeleteOutcome::Deleted) +} +``` + +- [ ] **Step 4: Run db tests — PASS.** +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test fields +``` + +- [ ] **Step 5: Write failing api tests** in `crates/api/tests/admin_catalog.rs`: +```rust +#[sqlx::test(migrations = "../db/migrations")] +async fn edit_and_delete_field_definition(pool: PgPool) { + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + let app = build_app(state(pool)); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + send(&app, &cookie, "POST", "/api/admin/field-definitions", + Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#)).await; + + let patched = send(&app, &cookie, "PATCH", "/api/admin/field-definitions/weight", + Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#)).await; + assert_eq!(patched.status(), StatusCode::NO_CONTENT); + + let deleted = send(&app, &cookie, "DELETE", "/api/admin/field-definitions/weight", None).await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + + let again = send(&app, &cookie, "DELETE", "/api/admin/field-definitions/weight", None).await; + assert_eq!(again.status(), StatusCode::NOT_FOUND); +} +``` + +- [ ] **Step 6: Implement handlers + routes** in `crates/api/src/admin_objects.rs`. The file already has `actor(&auth.user)`, `Authorized`, `LabelView`, `LabelInput` (imported via `admin_vocab`). Add `axum::response::{IntoResponse, Response}` and `use crate::admin_vocab::InUseView;`. Add: +```rust +#[derive(serde::Deserialize, utoipa::ToSchema)] +pub(crate) struct UpdateFieldDefinitionRequest { + pub required: bool, + pub group: Option, + pub labels: Vec, +} + +#[utoipa::path( + patch, path = "/api/admin/field-definitions/{key}", + request_body = UpdateFieldDefinitionRequest, + params(("key" = String, Path, description = "Field key")), + responses((status = 204), (status = 401), (status = 403), (status = 404), + (status = 422, description = "Invalid value (e.g. empty label/group)")) +)] +pub(crate) async fn update_field_definition( + auth: Authorized, + State(state): State, + Path(key): Path, + Json(req): Json, +) -> Result { + let labels: Vec = req.labels.into_iter() + .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect(); + let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let existed = db::fields::update_field_definition( + &mut tx, actor(&auth.user), &key, req.required, req.group.as_deref(), &labels, + ).await.map_err(|err| { + // CHECK constraint (empty label / group_key) → 422. + if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23514") { + StatusCode::UNPROCESSABLE_ENTITY + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) } +} + +#[utoipa::path( + delete, path = "/api/admin/field-definitions/{key}", + params(("key" = String, Path, description = "Field key")), + responses((status = 204), (status = 401), (status = 403), (status = 404), + (status = 409, body = InUseView)) +)] +pub(crate) async fn delete_field_definition( + auth: Authorized, + State(state): State, + Path(key): Path, +) -> Response { + let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; + match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await { + Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }, + Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(), + Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} +``` +Find where field-definition routes are registered (search `field-definitions` in `admin_objects.rs`'s `routes()`), and extend that route: +```rust +.route( + "/api/admin/field-definitions/{key}", + axum::routing::patch(update_field_definition).delete(delete_field_definition), +) +``` +(If there is no existing `/{key}` route, add it as a new `.route(...)`.) + +- [ ] **Step 7: Register in OpenAPI.** Add `admin_objects::update_field_definition,` and `admin_objects::delete_field_definition,` to `paths(...)`, and `admin_objects::UpdateFieldDefinitionRequest,` to schemas. + +- [ ] **Step 8: fmt, clippy, full backend test.** +``` +cargo +nightly fmt && cargo clippy --workspace --all-targets -- -D warnings +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace +``` +Expected: all PASS, clippy clean. + +- [ ] **Step 9: Commit.** +```bash +git add crates/db crates/api +git commit -m "feat: edit/delete field definitions — audited, blocked when in use (#36)" +``` + +--- + +## Task 5: Regenerate the web API types + +**Files:** `web/src/api/schema.d.ts` (generated) + +- [ ] **Step 1: Start the stack and server.** Ensure compose is up, then run the server so `pnpm gen:api` can read `/api-docs/openapi.json`: +```bash +docker compose up -d # postgres + meilisearch +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \ +MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey \ +cargo run -p server & # background; wait until it logs "listening" +``` + +- [ ] **Step 2: Regenerate.** +```bash +cd web && pnpm gen:api +``` + +- [ ] **Step 3: Verify the new endpoints/DTOs landed.** +```bash +grep -c "InUseView" web/src/api/schema.d.ts # >= 1 +grep -c "UpdateTermRequest" web/src/api/schema.d.ts # >= 1 +grep -c "/api/admin/field-definitions/{key}" web/src/api/schema.d.ts # >= 1 +``` +Expected: each ≥ 1. Stop the background server afterward (`kill %1` or Ctrl-C). + +- [ ] **Step 4: typecheck (no app code changed yet, just the schema).** +```bash +cd web && pnpm typecheck +``` +Expected: PASS. + +- [ ] **Step 5: Commit.** +```bash +git add web/src/api/schema.d.ts +git commit -m "chore(web): regenerate API types for reference-data edit/delete" +``` + +--- + +## Task 6: Frontend data layer — mutation hooks + i18n + +**Files:** +- Modify: `web/src/api/queries.ts` +- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json` +- Test: `web/src/api/queries.test.ts` (new, or extend an existing queries test if present) + +- [ ] **Step 1: Add i18n keys.** In `web/src/i18n/en.json`, extend the existing `actions` object (currently `{ "edit", "delete", "confirmDelete" }`) and add reference-data confirm strings + the in-use message: +```jsonc +"actions": { + "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", + "confirmDelete": "Delete this object? This cannot be undone.", + "confirmDeleteTerm": "Delete this term? This cannot be undone.", + "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", + "confirmDeleteField": "Delete this field definition? This cannot be undone.", + "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", + "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." +}, +``` +In `web/src/i18n/sv.json`, mirror with Swedish (keep the same keys): +```jsonc +"actions": { + "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", + "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", + "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", + "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", + "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", + "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", + "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." +}, +``` + +- [ ] **Step 2: Write a failing test** for the in-use parsing, in `web/src/api/queries.test.ts` (mirror existing query tests' use of MSW handlers from `web/src/test/handlers.ts` and a `QueryClientProvider` + `renderHook` wrapper — copy the wrapper from any existing `*.test.tsx` that calls `renderHook`). Minimal: +```ts +import { describe, it, expect } from "vitest"; +import { InUseError } from "./queries"; + +describe("InUseError", () => { + it("carries the count", () => { + const e = new InUseError(7); + expect(e.count).toBe(7); + expect(e).toBeInstanceOf(Error); + }); +}); +``` + +- [ ] **Step 3: Run to confirm failure.** +```bash +cd web && pnpm test -- queries.test +``` +Expected: FAIL — `InUseError` not exported. + +- [ ] **Step 4: Implement hooks + `InUseError`** in `web/src/api/queries.ts`. Add the error class near `HttpError`/`FieldRejection`: +```ts +export class InUseError extends Error { + constructor(public readonly count: number) { + super(`in use: ${count}`); + this.name = "InUseError"; + } +} +``` +Add the 8 hooks (mirroring `useUpdateObject`/`useDeleteObject` and the create hooks). Use the existing `LabelInput` type alias: +```ts +export function useUpdateTerm() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ vocabularyId, termId, external_uri, labels }: + { vocabularyId: string; termId: string; external_uri: string | null; labels: LabelInput[] }) => { + const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", { + params: { path: { id: vocabularyId, term_id: termId } }, + body: { external_uri, labels }, + }); + if (response.status !== 204) throw new Error("update term failed"); + }, + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + }); +} + +export function useDeleteTerm() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => { + const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", { + params: { path: { id: vocabularyId, term_id: termId } }, + }); + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete term failed"); + }, + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + }); +} + +export function useRenameVocabulary() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, key }: { id: string; key: string }) => { + const { response } = await api.PATCH("/api/admin/vocabularies/{id}", { + params: { path: { id } }, body: { key }, + }); + if (response.status !== 204) throw new Error("rename failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + }); +} + +export function useDeleteVocabulary() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", { + params: { path: { id } }, + }); + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete vocabulary failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + }); +} + +export function useUpdateAuthority() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, external_uri, labels }: + { id: string; kind: string; external_uri: string | null; labels: LabelInput[] }) => { + const { response } = await api.PATCH("/api/admin/authorities/{id}", { + params: { path: { id } }, body: { external_uri, labels }, + }); + if (response.status !== 204) throw new Error("update authority failed"); + }, + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + }); +} + +export function useDeleteAuthority() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id }: { id: string; kind: string }) => { + const { error, response } = await api.DELETE("/api/admin/authorities/{id}", { + params: { path: { id } }, + }); + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete authority failed"); + }, + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + }); +} + +export function useUpdateFieldDefinition() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ key, required, group, labels }: + { key: string; required: boolean; group: string | null; labels: LabelInput[] }) => { + const { response } = await api.PATCH("/api/admin/field-definitions/{key}", { + params: { path: { key } }, body: { required, group, labels }, + }); + if (response.status !== 204) throw new Error("update field failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + }); +} + +export function useDeleteFieldDefinition() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (key: string) => { + const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", { + params: { path: { key } }, + }); + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new Error("delete field failed"); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + }); +} +``` + +- [ ] **Step 5: Run test, typecheck, lint.** +```bash +cd web && pnpm test -- queries.test && pnpm typecheck && pnpm lint +``` +Expected: PASS, no `any` lint errors (note the `(error as { count?: number })` cast is a *typed* cast, not `any`). + +- [ ] **Step 6: Commit.** +```bash +git add web/src/api/queries.ts web/src/api/queries.test.ts web/src/i18n/en.json web/src/i18n/sv.json +git commit -m "feat(web): mutation hooks + InUseError + i18n for reference-data edit/delete" +``` + +--- + +## Task 7: Generic delete-confirm dialog (component + story) + +**Files:** +- Create: `web/src/components/delete-confirm-dialog.tsx`, `web/src/components/delete-confirm-dialog.stories.tsx` +- Test: covered by the story `play` + reused in Tasks 8–10. + +This is a reusable component mirroring `DeleteObjectDialog` but parameterized and aware of `InUseError` (shows the in-use message and keeps the dialog open instead of navigating). + +- [ ] **Step 1: Write the component.** `web/src/components/delete-confirm-dialog.tsx`: +```tsx +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { InUseError } from "../api/queries"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; + +export function DeleteConfirmDialog({ + description, + onConfirm, + triggerLabel, +}: { + /** Confirmation prompt, e.g. t("actions.confirmDeleteTerm"). */ + description: string; + /** Performs the delete; may throw InUseError to surface the in-use count. */ + onConfirm: () => Promise; + /** Optional override for the trigger button text (defaults to actions.delete). */ + triggerLabel?: string; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(null); + + const confirm = async () => { + setMessage(null); + try { + await onConfirm(); + } catch (err) { + // Keep the dialog open; show the blocking reason. Never let the rejected + // mutation escape as an unhandled rejection. + setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected")); + return; + } + setOpen(false); + }; + + return ( + + + {triggerLabel ?? t("actions.delete")} + + } + /> + + {t("actions.delete")} + {description} + {message && ( +

+ {message} +

+ )} + + {t("form.cancel")} + {t("actions.delete")} + +
+
+ ); +} +``` + +- [ ] **Step 2: Write the story** `web/src/components/delete-confirm-dialog.stories.tsx` (mirror the format of `web/src/objects/visibility-badge.stories.tsx` — `@storybook/react-vite`, `expect` from `storybook/test`, `tags: ['ai-generated']`, single quotes, no semicolons): +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, fn } from 'storybook/test' + +import { DeleteConfirmDialog } from './delete-confirm-dialog' +import { InUseError } from '../api/queries' + +const meta = { + component: DeleteConfirmDialog, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Confirms: Story = { + args: { description: 'Delete this term? This cannot be undone.', onConfirm: fn() }, + play: async ({ canvas, args }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Delete' })) + // The dialog content renders in a portal; query the document body. + const confirm = await within(document.body).findByRole('button', { name: 'Delete' }) + await userEvent.click(confirm) + await expect(args.onConfirm).toHaveBeenCalled() + }, +} + +export const ShowsInUse: Story = { + args: { + description: 'Delete this term? This cannot be undone.', + onConfirm: async () => { throw new InUseError(7) }, + }, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Delete' })) + const confirm = await within(document.body).findByRole('button', { name: 'Delete' }) + await userEvent.click(confirm) + await expect(await within(document.body).findByRole('alert')).toHaveTextContent(/used by 7/i) + }, +} +``` +Add `within` to the import from `storybook/test` (i.e. `import { expect, userEvent, fn, within } from 'storybook/test'`). If `AlertDialog` does not portal in the test environment, query via `canvas` instead of `within(document.body)` — run the story to confirm which. + +- [ ] **Step 3: Run the stories.** +```bash +cd web && pnpm test -- delete-confirm-dialog +``` +Expected: PASS (both stories). If the portal query fails, switch to `canvas` and re-run. + +- [ ] **Step 4: typecheck + lint + commit.** +```bash +cd web && pnpm typecheck && pnpm lint +git add web/src/components/delete-confirm-dialog.tsx web/src/components/delete-confirm-dialog.stories.tsx +git commit -m "feat(web): reusable DeleteConfirmDialog with in-use handling + stories" +``` + +--- + +## Task 8: Fields screen — edit/delete UI + stories + +**Files:** +- Modify: `web/src/fields/fields-page.tsx`, `web/src/fields/field-list.tsx`, `web/src/fields/field-form.tsx` +- Create: `web/src/fields/field-form.stories.tsx` +- Test: story `play` + existing field tests. + +The right pane (`FieldForm`) becomes create-or-edit; the left list selects a row and offers delete. + +- [ ] **Step 1: Lift selection state into `FieldsPage`** (`web/src/fields/fields-page.tsx`): +```tsx +import { useState } from "react"; + +import type { components } from "../api/schema"; +import { FieldList } from "./field-list"; +import { FieldForm } from "./field-form"; + +type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; + +export function FieldsPage() { + const [selected, setSelected] = useState(null); + + return ( +
+
+ +
+
+ setSelected(null)} /> +
+
+ ); +} +``` + +- [ ] **Step 2: Extend `FieldList`** (`web/src/fields/field-list.tsx`) with `onSelect`/`selectedKey` props, a clickable row (selects → edit), and a delete affordance using `DeleteConfirmDialog` + `useDeleteFieldDefinition`. Change the component signature and the row markup: +```tsx +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries"; +import { labelText } from "../lib/labels"; +import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { Skeleton } from "@/components/ui/skeleton"; + +type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; + +export function FieldList({ + selectedKey, + onSelect, +}: { + selectedKey: string | null; + onSelect: (def: FieldDefinitionView) => void; +}) { + const { t, i18n } = useTranslation(); + const { data, isLoading, isError } = useFieldDefinitions(); + const del = useDeleteFieldDefinition(); + const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + + // ...keep the existing loading / error / empty branches and group-building logic... + + // Replace the inner row
  • with: + // {defs.map((def) => ( + //
  • + // + // del.mutateAsync(def.key)} + // /> + //
  • + // ))} +} +``` +(Keep the existing group sorting/skeleton code unchanged — only the row markup and the props/imports change.) + +- [ ] **Step 3: Make `FieldForm` create-or-edit** (`web/src/fields/field-form.tsx`). Add `editing`/`onDone` props; when `editing` is set, hydrate state from it, disable `key`/type/binding inputs, and submit via `useUpdateFieldDefinition`; otherwise behave as today. +```tsx +import { useEffect, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { + useCreateFieldDefinition, + useUpdateFieldDefinition, + useVocabularies, +} from "../api/queries"; +import { LabelEditor } from "../components/label-editor"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; + +type LabelInput = components["schemas"]["LabelInput"]; +type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; + +const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const; +const KINDS = ["person", "organisation", "place"] as const; + +export function FieldForm({ + editing, + onDone, +}: { + editing: FieldDefinitionView | null; + onDone: () => void; +}) { + const { t } = useTranslation(); + const create = useCreateFieldDefinition(); + const update = useUpdateFieldDefinition(); + const { data: vocabularies } = useVocabularies(); + + const isEdit = editing !== null; + + const [key, setKey] = useState(""); + const [labels, setLabels] = useState([]); + const [dataType, setDataType] = useState("text"); + const [vocabularyId, setVocabularyId] = useState(""); + const [authorityKind, setAuthorityKind] = useState(""); + const [group, setGroup] = useState(""); + const [required, setRequired] = useState(false); + const [error, setError] = useState(false); + + // Hydrate when the selected definition changes (or reset to create mode). + useEffect(() => { + if (editing) { + setKey(editing.key); + setLabels(editing.labels as LabelInput[]); + setDataType(editing.data_type); + setVocabularyId(editing.vocabulary_id ?? ""); + setAuthorityKind(editing.authority_kind ?? ""); + setGroup(editing.group ?? ""); + setRequired(editing.required); + setError(false); + } else { + setKey(""); setLabels([]); setDataType("text"); setVocabularyId(""); + setAuthorityKind(""); setGroup(""); setRequired(false); setError(false); + } + }, [editing]); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const hasLabel = labels.some((l) => l.label); + if (!hasLabel || (!isEdit && !key.trim()) || (!isEdit && dataType === "term" && !vocabularyId)) { + setError(true); + return; + } + setError(false); + + if (isEdit) { + update.mutate( + { key: editing.key, required, group: group.trim() || null, labels }, + { onSuccess: onDone }, + ); + } else { + create.mutate( + { + key: key.trim(), data_type: dataType, + vocabulary_id: dataType === "term" ? vocabularyId : null, + authority_kind: dataType === "authority" ? authorityKind || null : null, + required, group: group.trim() || null, labels, + }, + { onSuccess: onDone }, + ); + } + }; + + const pending = isEdit ? update.isPending : create.isPending; + const failed = isEdit ? update.isError : create.isError; + + return ( +
    +
    +
    {isEdit ? labelTextOrKey(editing) : t("fields.newField")}
    + {isEdit && ( + + )} +
    + +
    + + setKey(e.target.value)} /> +
    + + + +
    + + +
    + + {dataType === "term" && ( +
    + + +
    + )} + + {dataType === "authority" && ( +
    + + +
    + )} + +
    + + setGroup(e.target.value)} /> +
    + + + + {error &&

    {t("form.required")}

    } + {failed &&

    {t("form.rejected")}

    } + + + + ); +} + +function labelTextOrKey(def: FieldDefinitionView): string { + return def.labels[0]?.label ?? def.key; +} +``` + +- [ ] **Step 4: Write the story** `web/src/fields/field-form.stories.tsx` — assert edit mode disables the key input and shows a Save button (the story mirrors the established format; the form needs the provider tree, which `.storybook/preview.tsx` already supplies): +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, fn } from 'storybook/test' + +import { FieldForm } from './field-form' + +const meta = { + component: FieldForm, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Create: Story = { + args: { editing: null, onDone: fn() }, + play: async ({ canvas }) => { + await expect(canvas.getByLabelText('Key')).toBeEnabled() + }, +} + +export const Edit: Story = { + args: { + editing: { + key: 'material', data_type: 'text', vocabulary_id: null, authority_kind: null, + required: true, group: 'Identification', + labels: [{ lang: 'en', label: 'Material' }], + }, + onDone: fn(), + }, + play: async ({ canvas }) => { + await expect(canvas.getByLabelText('Key')).toBeDisabled() + await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible() + }, +} +``` + +- [ ] **Step 5: Run the relevant tests + typecheck + lint.** +```bash +cd web && pnpm test -- field-form fields && pnpm typecheck && pnpm lint +``` +Expected: PASS. (If existing `field-list`/`fields-page` tests exist, update any that render ``/`` to pass the new props or use `` which wires them.) + +- [ ] **Step 6: Commit.** +```bash +git add web/src/fields +git commit -m "feat(web): edit/delete field definitions on /fields (in-place edit pane) (#36)" +``` + +--- + +## Task 9: Vocabularies screen — rename + term edit/delete + stories + +**Files:** +- Modify: `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx` +- Create: `web/src/vocab/term-row.tsx`, `web/src/vocab/term-row.stories.tsx` +- Test: story `play` + existing vocab tests. + +- [ ] **Step 1: Add rename + delete to `VocabularyList`** rows. Each row keeps the `NavLink` but gains an inline rename (toggle an `Input` + Save) using `useRenameVocabulary`, and a `DeleteConfirmDialog` using `useDeleteVocabulary`. Add local `editingId`/`draftKey` state: +```tsx +// imports add: +import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries"; +import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +// inside component: +const rename = useRenameVocabulary(); +const del = useDeleteVocabulary(); +const [editingId, setEditingId] = useState(null); +const [draftKey, setDraftKey] = useState(""); +// row markup (replace the existing
  • body): +// {data?.map((v) => ( +//
  • +// {editingId === v.id ? ( +//
    { e.preventDefault(); +// rename.mutate({ id: v.id, key: draftKey.trim() }, +// { onSuccess: () => setEditingId(null) }); }}> +// setDraftKey(e.target.value)} /> +// +//
    +// ) : ( +// <> +// +// `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}> +// {v.key} +// +// +// del.mutateAsync(v.id)} /> +// +// )} +//
  • +// ))} +``` +(Keep the existing create form above the list unchanged.) + +- [ ] **Step 2: Extract `TermRow`** (`web/src/vocab/term-row.tsx`) — a display row that toggles to an inline edit form (LabelEditor + URI + Save/Cancel) via `useUpdateTerm`, plus a `DeleteConfirmDialog` via `useDeleteTerm`: +```tsx +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useUpdateTerm, useDeleteTerm } from "../api/queries"; +import { LabelEditor } from "../components/label-editor"; +import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { labelText } from "../lib/labels"; + +type TermView = components["schemas"]["TermView"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) { + const { t } = useTranslation(); + const update = useUpdateTerm(); + const del = useDeleteTerm(); + const [editing, setEditing] = useState(false); + const [labels, setLabels] = useState(term.labels as LabelInput[]); + const [uri, setUri] = useState(term.external_uri ?? ""); + + if (editing) { + return ( +
  • + +
    + + setUri(e.target.value)} /> +
    +
    + + +
    +
  • + ); + } + + return ( +
  • + {labelText(term.labels, lang)} + + del.mutateAsync({ vocabularyId, termId: term.id })} /> +
  • + ); +} +``` + +- [ ] **Step 3: Use `TermRow` in `VocabularyTerms`** — replace the inline term `
  • ` mapping with ``. Keep the add-term form unchanged. Add `import { TermRow } from "./term-row";`. + +- [ ] **Step 4: Write a story** `web/src/vocab/term-row.stories.tsx` (the row uses hooks that hit the API; rely on the preview MSW handlers — assert the display + edit toggle which need no network): +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent } from 'storybook/test' + +import { TermRow } from './term-row' + +const meta = { + component: TermRow, + tags: ['ai-generated'], + args: { + vocabularyId: 'v1', + lang: 'en', + term: { id: 't1', external_uri: null, labels: [{ lang: 'en', label: 'Wood' }] }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Display: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText('Wood')).toBeVisible() + }, +} + +export const TogglesEdit: Story = { + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Edit' })) + await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible() + }, +} +``` + +- [ ] **Step 5: Run tests + typecheck + lint.** +```bash +cd web && pnpm test -- term-row vocab && pnpm typecheck && pnpm lint +``` +Expected: PASS. Update any existing vocab test that asserts the old plain term `
  • ` markup. + +- [ ] **Step 6: Commit.** +```bash +git add web/src/vocab +git commit -m "feat(web): rename vocabularies + edit/delete terms in place (#30)" +``` + +--- + +## Task 10: Authorities screen — edit/delete + stories + +**Files:** +- Modify: `web/src/authorities/authorities-page.tsx` +- Create: `web/src/authorities/authority-row.tsx`, `web/src/authorities/authority-row.stories.tsx` +- Test: story `play` + existing authority tests. + +- [ ] **Step 1: Extract `AuthorityRow`** (`web/src/authorities/authority-row.tsx`) mirroring `TermRow` but using `useUpdateAuthority`/`useDeleteAuthority` (both need `kind` for query invalidation): +```tsx +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useUpdateAuthority, useDeleteAuthority } from "../api/queries"; +import { LabelEditor } from "../components/label-editor"; +import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { labelText } from "../lib/labels"; + +type AuthorityView = components["schemas"]["AuthorityView"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) { + const { t } = useTranslation(); + const update = useUpdateAuthority(); + const del = useDeleteAuthority(); + const [editing, setEditing] = useState(false); + const [labels, setLabels] = useState(authority.labels as LabelInput[]); + const [uri, setUri] = useState(authority.external_uri ?? ""); + + if (editing) { + return ( +
  • + +
    + + setUri(e.target.value)} /> +
    +
    + + +
    +
  • + ); + } + + return ( +
  • + {labelText(authority.labels, lang)} + + del.mutateAsync({ id: authority.id, kind })} /> +
  • + ); +} +``` + +- [ ] **Step 2: Use `AuthorityRow` in `AuthoritiesPage`** — replace the inline authority `
  • ` mapping with ``. Add `import { AuthorityRow } from "./authority-row";`. + +- [ ] **Step 3: Write a story** `web/src/authorities/authority-row.stories.tsx`: +```tsx +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent } from 'storybook/test' + +import { AuthorityRow } from './authority-row' + +const meta = { + component: AuthorityRow, + tags: ['ai-generated'], + args: { + kind: 'person', + lang: 'en', + authority: { id: 'a1', kind: 'person', external_uri: null, labels: [{ lang: 'en', label: 'Astrid Lindgren' }] }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Display: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText('Astrid Lindgren')).toBeVisible() + }, +} + +export const TogglesEdit: Story = { + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Edit' })) + await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible() + }, +} +``` + +- [ ] **Step 4: Run tests + typecheck + lint.** +```bash +cd web && pnpm test -- authority-row authorities && pnpm typecheck && pnpm lint +``` +Expected: PASS. Update any existing authorities test asserting the old `
  • ` markup. + +- [ ] **Step 5: Commit.** +```bash +git add web/src/authorities +git commit -m "feat(web): edit/delete authorities in place (#30)" +``` + +--- + +## Task 11: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full backend suite + lint + fmt.** +```bash +cargo +nightly fmt --check +cargo clippy --workspace --all-targets -- -D warnings +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \ +MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace +``` +Expected: all green. + +- [ ] **Step 2: Full web suite.** +```bash +cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build +``` +Expected: all green; bundle ≤ 150 KB gz (check `pnpm check:size` if defined). + +- [ ] **Step 3: en/sv i18n parity.** Run the existing parity test (it lives in the web suite) and confirm the new `actions.*` keys exist in both `en.json` and `sv.json` with identical structure. +```bash +cd web && pnpm test -- i18n +``` +Expected: PASS. + +- [ ] **Step 4: Codename scan.** +```bash +git grep -in 'biggus\|dickus' -- crates web/src | grep -v node_modules || echo "clean" +``` +Expected: `clean`. + +- [ ] **Step 5: Cargo.lock hygiene.** Confirm no dangling `Cargo.lock` change (no new deps were added in this milestone, but verify): +```bash +git status --short +``` +Expected: clean working tree after all task commits. + +- [ ] **Step 6: Manual smoke (optional but recommended).** With the stack up and `cargo run -p server` + `cd web && pnpm dev`, log in as an editor and verify: rename a vocabulary; edit a term and an authority; toggle a field's `required`; attempt to delete a term that an object uses → see the "used by N" message; clear the object's field → delete succeeds. + +--- + +## Self-Review (completed) + +**1. Spec coverage:** +- Endpoints — term PATCH/DELETE (T1), vocabulary PATCH/DELETE (T2), authority PATCH/DELETE (T3), field-def PATCH/DELETE (T4). ✓ +- Block-with-409+count via JSONB scans — `count_objects_referencing_term/authority` (term/authority data_type-scoped `fields ->> key`), `count_objects_using_field` (`jsonb_exists`), vocab delete (terms + bindings count). ✓ +- Field-def immutability — `UpdateFieldDefinitionRequest` exposes only `required`/`group`/`labels`; key/type/binding inputs disabled in the edit form. ✓ +- `required` not retroactively enforced — update just sets the column; no object re-validation. ✓ +- Vocabulary key rename allowed; unique collision → 409. ✓ +- Audited — every db mutation records `Updated`/`Deleted` in-tx. ✓ +- Frontend in-place edit + AlertDialog delete; 409 keeps dialog open with "used by N" (`DeleteConfirmDialog`). ✓ +- Storybook — `DeleteConfirmDialog` (T7), `FieldForm` edit (T8), `TermRow` (T9), `AuthorityRow` (T10). ✓ +- OpenAPI regenerated (T5); en/sv parity (T6, T11). ✓ + +**2. Placeholder scan:** Row-markup blocks in T8/T9/T1 are shown as commented JSX to splice into existing loops (the surrounding component code is unchanged and already in the repo); all new functions/handlers/components are complete. No "TBD"/"add error handling"/"similar to". The few "confirm the variant name" notes (e.g. `FieldType::Integer`) are verification steps, not deferred implementation. + +**3. Type consistency:** `DeleteOutcome { Deleted, InUse { count: i64 }, NotFound }` defined once (T1) and matched in every handler. `InUseView { count: i64 }` defined once in `admin_vocab.rs` and imported by `admin_authorities.rs`/`admin_objects.rs`. Hook input shapes match the handler path params and request bodies (`{vocabularyId, termId, ...}`, `{id, kind, ...}`, `{key, required, group, labels}`). `InUseError(count: number)` parsed from the 409 JSON `{count}` in every delete hook and consumed by `DeleteConfirmDialog`. + +## Notes +- No new crates/deps → no `Cargo.lock` churn expected; if any appears, stage it in the same commit (lesson from a prior milestone). +- `actor(&auth.user)` exists only in `admin_objects.rs`; `admin_vocab.rs`/`admin_authorities.rs` inline `AuditActor::User(auth.user.id.to_uuid())` (existing convention) — both are used as-is. +- Field-definition *create* remains unaudited (pre-existing gap, out of scope); this milestone audits only the new update/delete paths.