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

94 KiB
Raw Permalink Blame History

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):

/// 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):

/// 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.
#[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):

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:
/// 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:
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()
    }
}
#[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:
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateTermRequest {
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}
  • Add handlers:
#[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:
/// 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():
.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.
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:

#[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:
/// 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):
#[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:
#[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():

.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.
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 }).

#[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):
/// 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:
#[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:
#[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():
.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.
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):

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:
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:

/// 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:
#[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:
#[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:

.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.
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:
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.
cd web && pnpm gen:api
  • Step 3: Verify the new endpoints/DTOs landed.
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).
cd web && pnpm typecheck

Expected: PASS.

  • Step 5: Commit.
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:

"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):

"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:
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.
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:
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:

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.
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.
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:
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):
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.
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.
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):
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:
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.
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):
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.
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.
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:

// 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:
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):

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.
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.
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):

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:

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.
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.
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.
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.
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.
cd web && pnpm test -- i18n

Expected: PASS.

  • Step 4: Codename scan.
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):
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.