Files
biggus-dickus/docs/superpowers/plans/2026-06-05-reference-data-edit-delete.md
T
2026-06-05 18:30:55 +02:00

2299 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<bool, sqlx::Error> {
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<i64, sqlx::Error>
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<crate::DeleteOutcome, sqlx::Error> {
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<Body> {
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<String>,
pub labels: Vec<LabelInput>,
}
```
- 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<EditCatalogue>,
State(state): State<AppState>,
Path((_id, term_id)): Path<(String, String)>,
Json(req): Json<UpdateTermRequest>,
) -> Result<StatusCode, StatusCode> {
let term_id = term_id.parse::<TermId>().map_err(|_| StatusCode::NOT_FOUND)?;
let labels: Vec<LocalizedLabel> = 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<EditCatalogue>,
State(state): State<AppState>,
Path((id, term_id)): Path<(String, String)>,
) -> Response {
let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>()) 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<bool, sqlx::Error> {
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<crate::DeleteOutcome, sqlx::Error> {
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<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<RenameVocabularyRequest>,
) -> Result<StatusCode, StatusCode> {
let id = id.parse::<VocabularyId>().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<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let Ok(id) = id.parse::<VocabularyId>() 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<bool, sqlx::Error> {
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<i64, sqlx::Error>
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<crate::DeleteOutcome, sqlx::Error> {
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<String>,
pub labels: Vec<LabelInput>,
}
#[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<EditCatalogue>,
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
Json(req): Json<UpdateAuthorityRequest>,
) -> Result<StatusCode, StatusCode> {
let id = id.parse::<AuthorityId>().map_err(|_| StatusCode::NOT_FOUND)?;
let labels: Vec<LocalizedLabel> = 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<EditCatalogue>,
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
let Ok(id) = id.parse::<AuthorityId>() 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<bool, sqlx::Error> {
let id: Option<uuid::Uuid> =
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<i64, sqlx::Error>
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<crate::DeleteOutcome, sqlx::Error> {
let id: Option<uuid::Uuid> =
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<EditCatalogue>`, `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<String>,
pub labels: Vec<LabelInput>,
}
#[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<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
Json(req): Json<UpdateFieldDefinitionRequest>,
) -> Result<StatusCode, StatusCode> {
let labels: Vec<LocalizedLabel> = 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<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
) -> 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 810.
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<void>;
/** 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<string | null>(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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button variant="ghost" size="sm" className="text-red-600">
{triggerLabel ?? t("actions.delete")}
</Button>
}
/>
<AlertDialogContent>
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
{message && (
<p role="alert" className="text-sm text-red-600">
{message}
</p>
)}
<AlertDialogFooter>
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirm}>{t("actions.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
```
- [ ] **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<typeof DeleteConfirmDialog>
export default meta
type Story = StoryObj<typeof meta>
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<FieldDefinitionView | null>(null);
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
</div>
<div className="overflow-hidden">
<FieldForm editing={selected} onDone={() => setSelected(null)} />
</div>
</div>
);
}
```
- [ ] **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 <li> with:
// {defs.map((def) => (
// <li key={def.key}
// className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
// def.key === selectedKey ? "bg-indigo-50" : ""}`}>
// <button type="button" className="flex flex-1 items-center gap-2 text-left"
// onClick={() => onSelect(def)}>
// <span className="font-medium">{labelText(def.labels, lang)}</span>
// <span className="text-xs text-neutral-400">{def.key}</span>
// <span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
// {t(`fields.types.${def.data_type}`)}
// </span>
// {def.required && (
// <span className="text-xs text-red-600" title={t("fields.required")}
// aria-label={t("fields.required")}>*</span>
// )}
// </button>
// <DeleteConfirmDialog
// description={t("actions.confirmDeleteField")}
// onConfirm={() => del.mutateAsync(def.key)}
// />
// </li>
// ))}
}
```
(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<LabelInput[]>([]);
const [dataType, setDataType] = useState<string>("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 (
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{isEdit ? labelTextOrKey(editing) : t("fields.newField")}</div>
{isEdit && (
<Button type="button" variant="ghost" size="sm" onClick={onDone}>
{t("fields.newField")}
</Button>
)}
</div>
<div className="space-y-1">
<Label htmlFor="field-key">{t("fields.key")}</Label>
<Input id="field-key" value={key} disabled={isEdit} onChange={(e) => setKey(e.target.value)} />
</div>
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor="field-type">{t("fields.type")}</Label>
<select id="field-type" value={dataType} disabled={isEdit}
onChange={(e) => setDataType(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
{TYPES.map((type) => (
<option key={type} value={type}>{t(`fields.types.${type}`)}</option>
))}
</select>
</div>
{dataType === "term" && (
<div className="space-y-1">
<Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
<select id="field-vocab" value={vocabularyId} disabled={isEdit}
onChange={(e) => setVocabularyId(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
<option value="">{t("form.selectPlaceholder")}</option>
{vocabularies?.map((vocab) => (
<option key={vocab.id} value={vocab.id}>{vocab.key}</option>
))}
</select>
</div>
)}
{dataType === "authority" && (
<div className="space-y-1">
<Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
<select id="field-kind" value={authorityKind} disabled={isEdit}
onChange={(e) => setAuthorityKind(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
<option value="">{t("fields.anyKind")}</option>
{KINDS.map((kind) => (
<option key={kind} value={kind}>{t(`authorities.${kind}`)}</option>
))}
</select>
</div>
)}
<div className="space-y-1">
<Label htmlFor="field-group">{t("fields.group")}</Label>
<Input id="field-group" value={group} onChange={(e) => setGroup(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={required} onCheckedChange={(checked) => setRequired(checked === true)} />
{t("fields.required")}
</label>
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
{failed && <p role="alert" className="text-xs text-red-600">{t("form.rejected")}</p>}
<Button type="submit" size="sm" disabled={pending}>
{isEdit ? t("actions.save") : t("fields.create")}
</Button>
</form>
);
}
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<typeof FieldForm>
export default meta
type Story = StoryObj<typeof meta>
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 `<FieldList/>`/`<FieldsPage/>` to pass the new props or use `<FieldsPage/>` 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<string | null>(null);
const [draftKey, setDraftKey] = useState("");
// row markup (replace the existing <li> body):
// {data?.map((v) => (
// <li key={v.id} className="flex items-center gap-1 border-b pr-2">
// {editingId === v.id ? (
// <form className="flex flex-1 gap-1 p-1"
// onSubmit={(e) => { e.preventDefault();
// rename.mutate({ id: v.id, key: draftKey.trim() },
// { onSuccess: () => setEditingId(null) }); }}>
// <Input value={draftKey} onChange={(e) => setDraftKey(e.target.value)} />
// <Button type="submit" size="sm">{t("actions.save")}</Button>
// </form>
// ) : (
// <>
// <NavLink to={`/vocabularies/${v.id}`}
// className={({ isActive }) =>
// `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
// {v.key}
// </NavLink>
// <Button type="button" variant="ghost" size="sm"
// onClick={() => { setEditingId(v.id); setDraftKey(v.key); }}>
// {t("actions.rename")}
// </Button>
// <DeleteConfirmDialog description={t("actions.confirmDeleteVocabulary")}
// onConfirm={() => del.mutateAsync(v.id)} />
// </>
// )}
// </li>
// ))}
```
(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<LabelInput[]>(term.labels as LabelInput[]);
const [uri, setUri] = useState(term.external_uri ?? "");
if (editing) {
return (
<li className="space-y-2 border-b py-2">
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={`term-uri-${term.id}`}>{t("labels.externalUri")}</Label>
<Input id={`term-uri-${term.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
<div className="flex gap-2">
<Button type="button" size="sm" disabled={update.isPending}
onClick={() => update.mutate(
{ vocabularyId, termId: term.id, external_uri: uri.trim() || null, labels },
{ onSuccess: () => setEditing(false) })}>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
{t("form.cancel")}
</Button>
</div>
</li>
);
}
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(term.labels, lang)}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog description={t("actions.confirmDeleteTerm")}
onConfirm={() => del.mutateAsync({ vocabularyId, termId: term.id })} />
</li>
);
}
```
- [ ] **Step 3: Use `TermRow` in `VocabularyTerms`** — replace the inline term `<li>` mapping with `<TermRow vocabularyId={id} term={term} lang={lang} />`. 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<typeof TermRow>
export default meta
type Story = StoryObj<typeof meta>
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 `<li>` 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<LabelInput[]>(authority.labels as LabelInput[]);
const [uri, setUri] = useState(authority.external_uri ?? "");
if (editing) {
return (
<li className="space-y-2 border-b py-2">
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
<Input id={`auth-uri-${authority.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
<div className="flex gap-2">
<Button type="button" size="sm" disabled={update.isPending}
onClick={() => update.mutate(
{ id: authority.id, kind, external_uri: uri.trim() || null, labels },
{ onSuccess: () => setEditing(false) })}>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
{t("form.cancel")}
</Button>
</div>
</li>
);
}
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(authority.labels, lang)}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog description={t("actions.confirmDeleteAuthority")}
onConfirm={() => del.mutateAsync({ id: authority.id, kind })} />
</li>
);
}
```
- [ ] **Step 2: Use `AuthorityRow` in `AuthoritiesPage`** — replace the inline authority `<li>` mapping with `<AuthorityRow authority={a} kind={currentKind} lang={lang} />`. 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<typeof AuthorityRow>
export default meta
type Story = StoryObj<typeof meta>
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 `<li>` 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.