Files
biggus-dickus/docs/plans/2026-06-02-admin-crud.md

51 KiB
Raw Permalink Blame History

Admin CRUD 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: The authenticated admin surface the React UI consumes — full catalogue-record lifecycle (create/read/list/update/delete + flexible-field values), read-only field-definition listing, and management of the controlled vocabularies / terms / authority records that catalogue fields reference. All gated by the Authorized<Cap> framework and audited with the real acting user.

Architecture: Pure HTTP wiring over the existing db repositories (no new business logic; SQL stays in db). New axum route modules under crates/api/src/ register endpoints, each behind a typed capability extractor: reads require Authorized<ViewInternal> (admin reads see all visibility levels), writes require Authorized<EditCatalogue>. Writes record AuditActor::User(id) from the extracted AuthUser (advancing #7). Two new db read helpers (paginated object list + list_vocabularies) are added; everything else already exists.

Tech Stack: Rust 2024, axum 0.8, utoipa 5, sqlx 0.8, time (date parsing/formatting). Tests: #[sqlx::test(migrations = "../db/migrations")] + axum oneshot (with the session-cookie login helper from the auth tests).

Design decisions (approved)

  • Scope: object lifecycle plus vocabulary/term/authority admin (the full slice that makes cataloguing usable end-to-end).
  • Reads (ViewInternal) show all visibility levels; writes (EditCatalogue) — both roles. Delete is EditCatalogue (Editor + Admin).
  • Create accepts initial visibility Draft or Internal (never Public — publishing goes through the stepwise POST .../visibility endpoint from the auth phase).
  • Update edits the inventory-minimum fields but NOT visibility (the stepwise machine stays authoritative).
  • Audit actor is the real user (AuditActor::User) on every admin write.
  • Object list is paginated (limit/offset + total), same shape as the public surface (advances #10).
  • Vocabulary/term/authority creation is not audited for now (the db create fns take no actor) — tracked as a follow-up; objects + users remain audited.

Prerequisites

  • Postgres for tests; pass DATABASE_URL inline. cargo +nightly fmt (nightly). Clean clippy --all-targets -- -D warnings.
  • Codename "biggus"/"dickus" must appear nowhere.
  • The session/login test helpers exist in crates/api/tests/admin.rs — the new test files replicate the small login/session_cookie/seed_user helpers (or factor them into a shared tests/common/mod.rs; either is fine — keep it DRY within reason).

Existing building blocks (verified — do not reimplement)

  • db::catalog: create_object(conn, actor, &ObjectInput) -> ObjectId; object_by_id(exec, ObjectId) -> Option<CatalogueObject>; update_object(conn, actor, id, &ObjectInput) -> bool; delete_object(conn, actor, id) -> bool; set_object_fields(conn, actor, id, &Map) -> Result<(), FieldError> (FieldError { ObjectNotFound, UnknownField(String), TypeMismatch{field,expected}, Unresolved{field,kind}, Db }); set_visibility (publish — already wired to POST /api/admin/objects/{id}/visibility).
  • db::fields: list_field_definitions(exec) -> Vec<FieldDefinition>.
  • db::vocab: create_vocabulary(exec, &str) -> Vocabulary; vocabulary_by_key; add_term(conn, &NewTerm) -> TermId; list_terms(exec, VocabularyId) -> Vec<Term>.
  • db::authority: create_authority(conn, &NewAuthority) -> AuthorityId; list_by_kind(exec, AuthorityKind) -> Vec<Authority>.
  • domain: ObjectInput, CatalogueObject, Visibility, FieldType (to_parts() -> (&'static str, Option<VocabularyId>, Option<AuthorityKind>)), FieldDefinition, Vocabulary{id,key}, Term{id,vocabulary_id,external_uri,labels}, NewTerm{vocabulary_id,external_uri,labels}, Authority{id,kind,external_uri,labels}, NewAuthority{kind,external_uri,labels}, AuthorityKind{Person,Organisation,Place} (as_str/from_db, serde lowercase), LocalizedLabel{lang,label}.
  • auth: AuthUser{id,email,role}, Authorized<C>{user,..}, markers EditCatalogue, ViewInternal. AuthError → 401/403.

File Structure

crates/db/src/catalog.rs          + list_objects_paged, count_objects
crates/db/src/vocab.rs            + list_vocabularies
crates/api/Cargo.toml             + time features (macros/parsing/formatting) if needed
crates/api/src/admin_objects.rs   (new) AdminObjectView/Page, Create/Update reqs, field DTOs, LabelView, routes
crates/api/src/admin_vocab.rs     (new) Vocabulary/Term DTOs + routes
crates/api/src/admin_authorities.rs (new) Authority DTOs + routes
crates/api/src/lib.rs             + mod decls; merge the three route fns in build_app
crates/api/src/openapi.rs         + register the new paths + schemas
crates/api/tests/admin_objects.rs   (new)
crates/api/tests/admin_catalog.rs   (new) vocab + authority

Task 1: db paginated object list + admin object READ surface

Files: modify crates/db/src/catalog.rs, crates/api/src/lib.rs, crates/api/src/openapi.rs, crates/api/Cargo.toml; create crates/api/src/admin_objects.rs, crates/api/tests/admin_objects.rs.

  • Step 1: Add paginated readers to crates/db/src/catalog.rs (all visibility levels — admin sees everything). Place after list_objects:
/// List objects (all visibility levels) ordered by object number, with paging.
pub async fn list_objects_paged<'e, E>(
    executor: E,
    limit: i64,
    offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let sql = format!(
        "SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number LIMIT $1 OFFSET $2"
    );
    let rows = sqlx::query(&sql).bind(limit).bind(offset).fetch_all(executor).await?;
    rows.into_iter().map(map_object).collect()
}

/// Count all objects (for pagination totals).
pub async fn count_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let row = sqlx::query("SELECT count(*) AS n FROM object").fetch_one(executor).await?;
    row.try_get("n")
}
  • Step 2: Cargo — ensure time can parse/format dates. In crates/api/Cargo.toml, time is already a dependency; ensure the workspace time has the features ["serde", "macros", "parsing", "formatting"] (extend root [workspace.dependencies] time if needed). These let the handlers parse/format recording_date as YYYY-MM-DD.

  • Step 3: Write the failing read tests crates/api/tests/admin_objects.rs (replicate the login/seed helpers from tests/admin.rs):

use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::{catalog, users};
use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;

fn state(pool: PgPool) -> AppState {
    AppState { db: db::Db::from_pool(pool), app_name: "Test".into(), cookie_secure: false }
}
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
    let db = db::Db::from_pool(pool.clone());
    let mut tx = db.pool().begin().await.unwrap();
    users::create_user(&mut tx, AuditActor::System, &NewUser {
        email: Email::parse(email).unwrap(), password_hash: auth::hash_password(password).unwrap(), role,
    }).await.unwrap();
    tx.commit().await.unwrap();
}
fn login_request(email: &str, password: &str) -> Request<Body> {
    Request::builder().method("POST").uri("/api/admin/login")
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(format!(r#"{{"email":"{email}","password":"{password}"}}"#))).unwrap()
}
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
    resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_owned()
}
async fn login(app: &axum::Router, email: &str, pw: &str) -> String {
    let resp = app.clone().oneshot(login_request(email, pw)).await.unwrap();
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    session_cookie(&resp)
}
fn obj(number: &str, name: &str, v: Visibility) -> ObjectInput {
    ObjectInput {
        object_number: number.into(), object_name: name.into(), number_of_objects: 1,
        brief_description: Some("d".into()), current_location: Some("vault".into()),
        current_owner: None, recorder: None, recording_date: None, visibility: v,
    }
}

#[sqlx::test(migrations = "../db/migrations")]
async fn list_and_get_require_auth(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    let app = build_app(state(pool));
    let list = app.clone().oneshot(Request::builder().uri("/api/admin/objects").body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(list.status(), StatusCode::UNAUTHORIZED);
}

#[sqlx::test(migrations = "../db/migrations")]
async fn list_shows_all_visibility_levels(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let db = db::Db::from_pool(pool.clone());
    let mut tx = db.pool().begin().await.unwrap();
    catalog::create_object(&mut tx, AuditActor::System, &obj("D-1", "draft", Visibility::Draft)).await.unwrap();
    catalog::create_object(&mut tx, AuditActor::System, &obj("P-1", "pub", Visibility::Public)).await.unwrap();
    tx.commit().await.unwrap();

    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
    let resp = app.oneshot(Request::builder().uri("/api/admin/objects").header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let json: serde_json::Value = serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(json["total"], 2);
    // admin view exposes internal fields (unlike the public surface)
    let items = json["items"].as_array().unwrap();
    assert!(items.iter().any(|i| i["object_number"] == "D-1"));
    assert!(items[0].get("current_location").is_some());
}

#[sqlx::test(migrations = "../db/migrations")]
async fn get_by_id_returns_full_view(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let db = db::Db::from_pool(pool.clone());
    let mut tx = db.pool().begin().await.unwrap();
    let id = catalog::create_object(&mut tx, AuditActor::System, &obj("D-1", "draft", Visibility::Draft)).await.unwrap();
    tx.commit().await.unwrap();

    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
    let resp = app.clone().oneshot(Request::builder().uri(format!("/api/admin/objects/{id}")).header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let json: serde_json::Value = serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(json["object_number"], "D-1");
    assert_eq!(json["visibility"], "draft");

    // missing → 404
    let missing = app.oneshot(Request::builder().uri(format!("/api/admin/objects/{}", domain::ObjectId::new())).header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
  • Step 4: Implement crates/api/src/admin_objects.rs (read parts in this task; write handlers added in Task 2, fields in Task 3 — but define the shared DTOs now):
//! Admin catalogue-object surface (authenticated). Reads require `ViewInternal`;
//! writes require `EditCatalogue` (added in later tasks).

use auth::{Authorized, ViewInternal};
use axum::{
    Json, Router,
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    routing::get,
};
use domain::{CatalogueObject, ObjectId};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::AppState;

/// A localized label `{ lang, label }` (shared across admin views).
#[derive(Serialize, ToSchema)]
pub(crate) struct LabelView {
    pub lang: String,
    pub label: String,
}

/// Full admin view of a catalogue object (all fields, all visibility levels).
#[derive(Serialize, ToSchema)]
pub(crate) struct AdminObjectView {
    pub id: String,
    pub object_number: String,
    pub object_name: String,
    pub number_of_objects: i32,
    pub brief_description: Option<String>,
    pub current_location: Option<String>,
    pub current_owner: Option<String>,
    pub recorder: Option<String>,
    /// `YYYY-MM-DD` or null.
    pub recording_date: Option<String>,
    /// "draft" | "internal" | "public".
    pub visibility: String,
    /// Flexible field values (key -> value).
    #[schema(value_type = Object)]
    pub fields: serde_json::Value,
}

impl AdminObjectView {
    pub(crate) fn from_object(o: &CatalogueObject) -> Self {
        AdminObjectView {
            id: o.id.to_string(),
            object_number: o.object_number.clone(),
            object_name: o.object_name.clone(),
            number_of_objects: o.number_of_objects,
            brief_description: o.brief_description.clone(),
            current_location: o.current_location.clone(),
            current_owner: o.current_owner.clone(),
            recorder: o.recorder.clone(),
            recording_date: o.recording_date.map(format_date),
            visibility: o.visibility.as_str().to_owned(),
            fields: o.fields.clone(),
        }
    }
}

/// A page of admin objects.
#[derive(Serialize, ToSchema)]
pub(crate) struct AdminObjectPage {
    pub items: Vec<AdminObjectView>,
    pub total: i64,
    pub limit: i64,
    pub offset: i64,
}

#[derive(Deserialize)]
pub(crate) struct Pagination {
    limit: Option<i64>,
    offset: Option<i64>,
}
const DEFAULT_LIMIT: i64 = 50;
const MAX_LIMIT: i64 = 200;
impl Pagination {
    fn limit(&self) -> i64 { self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) }
    fn offset(&self) -> i64 { self.offset.unwrap_or(0).max(0) }
}

// Date helpers (YYYY-MM-DD). Adapt to the installed `time` API if the macro/format
// item type differs; the contract is an ISO calendar date string.
pub(crate) fn format_date(d: time::Date) -> String {
    let fmt = time::macros::format_description!("[year]-[month]-[day]");
    d.format(&fmt).unwrap_or_default()
}
pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
    let fmt = time::macros::format_description!("[year]-[month]-[day]");
    time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
}

/// List objects (paginated, all visibility levels). Requires `ViewInternal`.
#[utoipa::path(
    get, path = "/api/admin/objects",
    params(("limit" = Option<i64>, Query, description = "1..=200, default 50"),
           ("offset" = Option<i64>, Query, description = "default 0")),
    responses((status = 200, body = AdminObjectPage), (status = 401), (status = 403))
)]
pub(crate) async fn list_objects(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Result<Json<AdminObjectPage>, StatusCode> {
    let (limit, offset) = (page.limit(), page.offset());
    let objects = db::catalog::list_objects_paged(state.db.pool(), limit, offset)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let total = db::catalog::count_objects(state.db.pool())
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(AdminObjectPage {
        items: objects.iter().map(AdminObjectView::from_object).collect(),
        total, limit, offset,
    }))
}

/// Get one object (any visibility). Requires `ViewInternal`. 404 if missing.
#[utoipa::path(
    get, path = "/api/admin/objects/{id}",
    params(("id" = String, Path, description = "Object id (UUID)")),
    responses((status = 200, body = AdminObjectView), (status = 401), (status = 403), (status = 404))
)]
pub(crate) async fn get_object(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> impl IntoResponse {
    let Ok(object_id) = id.parse::<ObjectId>() else {
        return StatusCode::NOT_FOUND.into_response();
    };
    match db::catalog::object_by_id(state.db.pool(), object_id).await {
        Ok(Some(o)) => Json(AdminObjectView::from_object(&o)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route("/api/admin/objects", get(list_objects))
        .route("/api/admin/objects/{id}", get(get_object))
}
  • Step 5: Wire it. In crates/api/src/lib.rs add mod admin_objects; and .merge(admin_objects::routes()) in build_app. In crates/api/src/openapi.rs add admin_objects to the use and register admin_objects::list_objects, admin_objects::get_object in paths(...) and admin_objects::{AdminObjectView, AdminObjectPage, LabelView} in components(schemas(...)).

  • Step 6: Run. DATABASE_URL=<url> cargo test -p db --test catalog_mutations (smoke that catalog still builds) then DATABASE_URL=<url> cargo test -p api --test admin_objects → PASS. Then cargo test -p api → all PASS.

  • Step 7: Lint. cargo +nightly fmt; DATABASE_URL=<url> cargo clippy -p api -p db --all-targets -- -D warnings → clean.

  • Step 8: Commit.

git add crates/db crates/api
git commit -m "feat(api): admin object read surface (paginated list + get, ViewInternal)"

Task 2: admin object WRITE (create / update / delete)

Files: modify crates/api/src/admin_objects.rs, crates/api/src/openapi.rs, crates/api/tests/admin_objects.rs.

  • Step 1: Write the failing tests (append to crates/api/tests/admin_objects.rs):
#[sqlx::test(migrations = "../db/migrations")]
async fn create_update_delete_lifecycle(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let app = build_app(state(pool.clone()));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;

    // create (internal allowed)
    let create = app.clone().oneshot(Request::builder().method("POST").uri("/api/admin/objects")
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"object_number":"A-1","object_name":"amphora","number_of_objects":1,"visibility":"internal"}"#)).unwrap()).await.unwrap();
    assert_eq!(create.status(), StatusCode::CREATED);
    let created: serde_json::Value = serde_json::from_slice(&create.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let id = created["id"].as_str().unwrap().to_owned();

    // update (name change; visibility omitted and unchanged)
    let update = app.clone().oneshot(Request::builder().method("PUT").uri(format!("/api/admin/objects/{id}"))
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"object_number":"A-1","object_name":"big amphora","number_of_objects":2}"#)).unwrap()).await.unwrap();
    assert_eq!(update.status(), StatusCode::NO_CONTENT);

    let db = db::Db::from_pool(pool.clone());
    let obj = catalog::object_by_id(db.pool(), id.parse().unwrap()).await.unwrap().unwrap();
    assert_eq!(obj.object_name, "big amphora");
    assert_eq!(obj.visibility, Visibility::Internal); // unchanged by update

    // delete
    let del = app.clone().oneshot(Request::builder().method("DELETE").uri(format!("/api/admin/objects/{id}"))
        .header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(del.status(), StatusCode::NO_CONTENT);
    assert!(catalog::object_by_id(db.pool(), id.parse().unwrap()).await.unwrap().is_none());
}

#[sqlx::test(migrations = "../db/migrations")]
async fn create_rejects_public_visibility(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    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 resp = app.oneshot(Request::builder().method("POST").uri("/api/admin/objects")
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"object_number":"A-1","object_name":"x","number_of_objects":1,"visibility":"public"}"#)).unwrap()).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[sqlx::test(migrations = "../db/migrations")]
async fn create_requires_auth(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    let app = build_app(state(pool));
    let resp = app.oneshot(Request::builder().method("POST").uri("/api/admin/objects")
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"object_number":"A-1","object_name":"x","number_of_objects":1,"visibility":"draft"}"#)).unwrap()).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
  • Step 2: Run to verify it fails. DATABASE_URL=<url> cargo test -p api --test admin_objects → FAIL (write routes missing).

  • Step 3: Implement — add to crates/api/src/admin_objects.rs:

use auth::{AuthUser, EditCatalogue};
use axum::routing::{delete, post, put};
use domain::{AuditActor, ObjectInput, Visibility};

/// Inventory-minimum fields for create. `recording_date` is `YYYY-MM-DD`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ObjectCreateRequest {
    pub object_number: String,
    pub object_name: String,
    pub number_of_objects: i32,
    pub brief_description: Option<String>,
    pub current_location: Option<String>,
    pub current_owner: Option<String>,
    pub recorder: Option<String>,
    pub recording_date: Option<String>,
    /// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
    pub visibility: Visibility,
}

/// Inventory-minimum fields for update. Visibility is intentionally absent — it changes
/// only through the stepwise publish endpoint.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ObjectUpdateRequest {
    pub object_number: String,
    pub object_name: String,
    pub number_of_objects: i32,
    pub brief_description: Option<String>,
    pub current_location: Option<String>,
    pub current_owner: Option<String>,
    pub recorder: Option<String>,
    pub recording_date: Option<String>,
}

/// The id of a newly created object.
#[derive(Serialize, ToSchema)]
pub(crate) struct CreatedObject {
    pub id: String,
}

fn actor(user: &AuthUser) -> AuditActor {
    AuditActor::User(user.id.to_uuid())
}

/// Create an object (initial visibility Draft or Internal). Requires `EditCatalogue`.
#[utoipa::path(
    post, path = "/api/admin/objects", request_body = ObjectCreateRequest,
    responses((status = 201, body = CreatedObject), (status = 401), (status = 403),
              (status = 422, description = "Invalid input (e.g. visibility=public or bad date)"))
)]
pub(crate) async fn create_object(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Json(req): Json<ObjectCreateRequest>,
) -> Result<(StatusCode, Json<CreatedObject>), StatusCode> {
    if req.visibility == Visibility::Public {
        return Err(StatusCode::UNPROCESSABLE_ENTITY); // publish via the stepwise endpoint
    }
    let recording_date = req.recording_date.as_deref().map(parse_date).transpose()?;
    let input = ObjectInput {
        object_number: req.object_number, object_name: req.object_name,
        number_of_objects: req.number_of_objects, brief_description: req.brief_description,
        current_location: req.current_location, current_owner: req.current_owner,
        recorder: req.recorder, recording_date, visibility: req.visibility,
    };
    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let id = db::catalog::create_object(&mut tx, actor(&auth.user), &input)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok((StatusCode::CREATED, Json(CreatedObject { id: id.to_string() })))
}

/// Update an object's inventory-minimum fields (NOT visibility). Requires `EditCatalogue`.
#[utoipa::path(
    put, path = "/api/admin/objects/{id}", request_body = ObjectUpdateRequest,
    params(("id" = String, Path, description = "Object id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404), (status = 422))
)]
pub(crate) async fn update_object(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<ObjectUpdateRequest>,
) -> Result<StatusCode, StatusCode> {
    let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let recording_date = req.recording_date.as_deref().map(parse_date).transpose()?;

    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    // Preserve the current visibility — updates never change it.
    let Some(current) = db::catalog::object_by_id(&mut *tx, object_id)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? else {
        return Err(StatusCode::NOT_FOUND);
    };
    let input = ObjectInput {
        object_number: req.object_number, object_name: req.object_name,
        number_of_objects: req.number_of_objects, brief_description: req.brief_description,
        current_location: req.current_location, current_owner: req.current_owner,
        recorder: req.recorder, recording_date, visibility: current.visibility,
    };
    let existed = db::catalog::update_object(&mut tx, actor(&auth.user), object_id, &input)
        .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) }
}

/// Delete an object. Requires `EditCatalogue`. 404 if it did not exist.
#[utoipa::path(
    delete, path = "/api/admin/objects/{id}",
    params(("id" = String, Path, description = "Object id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404))
)]
pub(crate) async fn delete_object(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
    let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let existed = db::catalog::delete_object(&mut tx, actor(&auth.user), object_id)
        .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) }
}

Add the three routes to routes():

        .route("/api/admin/objects", get(list_objects).post(create_object))
        .route("/api/admin/objects/{id}", get(get_object).put(update_object).delete(delete_object))

(Replace the two get(...) route lines from Task 1 with these combined ones.)

  • Step 4: Register OpenAPI — add create_object, update_object, delete_object to paths(...) and ObjectCreateRequest, ObjectUpdateRequest, CreatedObject to components(schemas(...)).

  • Step 5: Run. DATABASE_URL=<url> cargo test -p api --test admin_objects → PASS. cargo test -p api → all PASS.

  • Step 6: Lint + Commit.

cargo +nightly fmt
DATABASE_URL=<url> cargo clippy -p api --all-targets -- -D warnings
git add crates/api && git commit -m "feat(api): admin object create/update/delete (EditCatalogue, audited as user)"

Task 3: flexible-field values + field-definition listing

Files: modify crates/api/src/admin_objects.rs, crates/api/src/openapi.rs, crates/api/tests/admin_objects.rs.

  • Step 1: Write the failing tests (append to crates/api/tests/admin_objects.rs). Uses the Spectrum seed so a known field key exists, OR creates a field definition via db. Simplest: create a Text field definition directly via db::fields::create_field_definition, then set it.
#[sqlx::test(migrations = "../db/migrations")]
async fn set_fields_and_list_field_definitions(pool: PgPool) {
    use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;

    let db = db::Db::from_pool(pool.clone());
    let mut tx = db.pool().begin().await.unwrap();
    db::fields::create_field_definition(&mut tx, &NewFieldDefinition {
        key: "inscription".into(), field_type: FieldType::Text, required: false,
        group_key: None, labels: vec![LocalizedLabel { lang: "en".into(), label: "Inscription".into() }],
    }).await.unwrap();
    let id = catalog::create_object(&mut tx, AuditActor::System, &obj("A-1", "amphora", Visibility::Draft)).await.unwrap();
    tx.commit().await.unwrap();

    let app = build_app(state(pool.clone()));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;

    // field-definitions list
    let defs = app.clone().oneshot(Request::builder().uri("/api/admin/field-definitions").header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(defs.status(), StatusCode::OK);
    let defs_json: serde_json::Value = serde_json::from_slice(&defs.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert!(defs_json.as_array().unwrap().iter().any(|d| d["key"] == "inscription" && d["data_type"] == "text"));

    // set the field
    let set = app.clone().oneshot(Request::builder().method("PUT").uri(format!("/api/admin/objects/{id}/fields"))
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"inscription":"To the gods"}"#)).unwrap()).await.unwrap();
    assert_eq!(set.status(), StatusCode::NO_CONTENT);
    let stored = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
    assert_eq!(stored.fields["inscription"], "To the gods");

    // unknown field → 422
    let bad = app.oneshot(Request::builder().method("PUT").uri(format!("/api/admin/objects/{id}/fields"))
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"nope":"x"}"#)).unwrap()).await.unwrap();
    assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
  • Step 2: Run to verify it fails.

  • Step 3: Implement — add to crates/api/src/admin_objects.rs:

/// Field-definition descriptor for the UI to render forms.
#[derive(Serialize, ToSchema)]
pub(crate) struct FieldDefinitionView {
    pub key: String,
    /// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
    pub data_type: String,
    pub vocabulary_id: Option<String>,
    pub authority_kind: Option<String>,
    pub required: bool,
    pub group: Option<String>,
    pub labels: Vec<LabelView>,
}

/// List all field definitions. Requires `ViewInternal`.
#[utoipa::path(get, path = "/api/admin/field-definitions",
    responses((status = 200, body = [FieldDefinitionView]), (status = 401), (status = 403)))]
pub(crate) async fn list_field_definitions(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
) -> Result<Json<Vec<FieldDefinitionView>>, StatusCode> {
    let defs = db::fields::list_field_definitions(state.db.pool())
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(defs.into_iter().map(|d| {
        let (data_type, vocabulary_id, authority_kind) = d.field_type.to_parts();
        FieldDefinitionView {
            key: d.key,
            data_type: data_type.to_owned(),
            vocabulary_id: vocabulary_id.map(|v| v.to_string()),
            authority_kind: authority_kind.map(|k| k.as_str().to_owned()),
            required: d.required,
            group: d.group_key,
            labels: d.labels.into_iter().map(|l| LabelView { lang: l.lang, label: l.label }).collect(),
        }
    }).collect()))
}

/// Replace an object's flexible-field values (validated against the registry).
/// Requires `EditCatalogue`.
#[utoipa::path(
    put, path = "/api/admin/objects/{id}/fields",
    params(("id" = String, Path, description = "Object id (UUID)")),
    request_body = Object,
    responses((status = 204), (status = 401), (status = 403),
              (status = 404, description = "Object not found"),
              (status = 422, description = "Unknown field, type mismatch, or unresolved reference"))
)]
pub(crate) async fn set_fields(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(values): Json<serde_json::Map<String, serde_json::Value>>,
) -> Result<StatusCode, StatusCode> {
    let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let result = db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
    match result {
        Ok(()) => {
            tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            Ok(StatusCode::NO_CONTENT)
        }
        Err(db::catalog::FieldError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
        Err(db::catalog::FieldError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
        // UnknownField / TypeMismatch / Unresolved are client input errors
        Err(_) => Err(StatusCode::UNPROCESSABLE_ENTITY),
    }
}

Add to routes():

        .route("/api/admin/objects/{id}/fields", put(set_fields))
        .route("/api/admin/field-definitions", get(list_field_definitions))

NOTE: set_object_fields has replace semantics (the body is the complete desired field set). Document that in the handler doc comment so callers send all keys they want to keep.

  • Step 4: Register OpenAPI — add set_fields, list_field_definitions to paths(...) and FieldDefinitionView to components(schemas(...)).

  • Step 5: Run / Lint / Commit.

DATABASE_URL=<url> cargo test -p api --test admin_objects   # PASS
cargo +nightly fmt; DATABASE_URL=<url> cargo clippy -p api --all-targets -- -D warnings
git add crates/api && git commit -m "feat(api): admin set flexible fields + field-definition listing"

Task 4: vocabulary + term admin

Files: modify crates/db/src/vocab.rs, crates/api/src/lib.rs, crates/api/src/openapi.rs; create crates/api/src/admin_vocab.rs, crates/api/tests/admin_catalog.rs.

  • Step 1: Add list_vocabularies to crates/db/src/vocab.rs (place after vocabulary_by_key):
/// List all vocabularies, ordered by key.
pub async fn list_vocabularies<'e, E>(executor: E) -> Result<Vec<Vocabulary>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let rows = sqlx::query("SELECT id, key FROM vocabulary ORDER BY key").fetch_all(executor).await?;
    rows.into_iter().map(map_vocabulary).collect()
}

(map_vocabulary already exists in this module.)

  • Step 2: Write the failing tests crates/api/tests/admin_catalog.rs (replicate the login/seed helpers; this file also covers authorities in Task 5):
// (same imports + helpers as admin_objects.rs: state, seed_user, login_request, session_cookie, login)
// ... include them here too ...

#[sqlx::test(migrations = "../db/migrations")]
async fn create_list_vocabulary_and_terms(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    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
    let created = app.clone().oneshot(Request::builder().method("POST").uri("/api/admin/vocabularies")
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"key":"colour"}"#)).unwrap()).await.unwrap();
    assert_eq!(created.status(), StatusCode::CREATED);
    let v: serde_json::Value = serde_json::from_slice(&created.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let vocab_id = v["id"].as_str().unwrap().to_owned();

    // list vocabularies includes it
    let list = app.clone().oneshot(Request::builder().uri("/api/admin/vocabularies").header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    let list_json: serde_json::Value = serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert!(list_json.as_array().unwrap().iter().any(|x| x["key"] == "colour"));

    // add a term with labels
    let term = app.clone().oneshot(Request::builder().method("POST").uri(format!("/api/admin/vocabularies/{vocab_id}/terms"))
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"external_uri":null,"labels":[{"lang":"en","label":"red"},{"lang":"sv","label":"röd"}]}"#)).unwrap()).await.unwrap();
    assert_eq!(term.status(), StatusCode::CREATED);

    // list terms shows it (with both labels)
    let terms = app.oneshot(Request::builder().uri(format!("/api/admin/vocabularies/{vocab_id}/terms")).header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    let terms_json: serde_json::Value = serde_json::from_slice(&terms.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let arr = terms_json.as_array().unwrap();
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["labels"].as_array().unwrap().len(), 2);
}

#[sqlx::test(migrations = "../db/migrations")]
async fn vocabulary_create_requires_auth(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    let app = build_app(state(pool));
    let resp = app.oneshot(Request::builder().method("POST").uri("/api/admin/vocabularies")
        .header(header::CONTENT_TYPE, "application/json").body(Body::from(r#"{"key":"x"}"#)).unwrap()).await.unwrap();
    assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
  • Step 3: Implement crates/api/src/admin_vocab.rs:
//! Admin vocabulary + term management. Reads require `ViewInternal`; writes `EditCatalogue`.

use auth::{Authorized, EditCatalogue, ViewInternal};
use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
};
use domain::{LocalizedLabel, NewTerm, VocabularyId};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::AppState;
use crate::admin_objects::LabelView;

#[derive(Serialize, ToSchema)]
pub(crate) struct VocabularyView {
    pub id: String,
    pub key: String,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct NewVocabularyRequest {
    pub key: String,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct LabelInput {
    pub lang: String,
    pub label: String,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct NewTermRequest {
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}

#[derive(Serialize, ToSchema)]
pub(crate) struct TermView {
    pub id: String,
    pub external_uri: Option<String>,
    pub labels: Vec<LabelView>,
}

#[derive(Serialize, ToSchema)]
pub(crate) struct CreatedId {
    pub id: String,
}

#[utoipa::path(get, path = "/api/admin/vocabularies",
    responses((status = 200, body = [VocabularyView]), (status = 401), (status = 403)))]
pub(crate) async fn list_vocabularies(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
) -> Result<Json<Vec<VocabularyView>>, StatusCode> {
    let vocabs = db::vocab::list_vocabularies(state.db.pool())
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(vocabs.into_iter().map(|v| VocabularyView { id: v.id.to_string(), key: v.key }).collect()))
}

#[utoipa::path(post, path = "/api/admin/vocabularies", request_body = NewVocabularyRequest,
    responses((status = 201, body = VocabularyView), (status = 401), (status = 403)))]
pub(crate) async fn create_vocabulary(
    _auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Json(req): Json<NewVocabularyRequest>,
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
    let v = db::vocab::create_vocabulary(state.db.pool(), &req.key)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok((StatusCode::CREATED, Json(VocabularyView { id: v.id.to_string(), key: v.key })))
}

#[utoipa::path(get, path = "/api/admin/vocabularies/{id}/terms",
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses((status = 200, body = [TermView]), (status = 401), (status = 403), (status = 404)))]
pub(crate) async fn list_terms(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Json<Vec<TermView>>, StatusCode> {
    let vocab_id = id.parse::<VocabularyId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let terms = db::vocab::list_terms(state.db.pool(), vocab_id)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(terms.into_iter().map(|t| TermView {
        id: t.id.to_string(), external_uri: t.external_uri,
        labels: t.labels.into_iter().map(|l| LabelView { lang: l.lang, label: l.label }).collect(),
    }).collect()))
}

#[utoipa::path(post, path = "/api/admin/vocabularies/{id}/terms", request_body = NewTermRequest,
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses((status = 201, body = CreatedId), (status = 401), (status = 403), (status = 404)))]
pub(crate) async fn add_term(
    _auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<NewTermRequest>,
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
    let vocabulary_id = id.parse::<VocabularyId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let new = NewTerm {
        vocabulary_id,
        external_uri: req.external_uri,
        labels: 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 term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok((StatusCode::CREATED, Json(CreatedId { id: term_id.to_string() })))
}

pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route("/api/admin/vocabularies", get(list_vocabularies).post(create_vocabulary))
        .route("/api/admin/vocabularies/{id}/terms", get(list_terms).post(add_term))
}

NOTE: add_term against a non-existent vocabulary id will fail the FK and currently maps to 500 (the id parsed as a UUID but no such vocabulary). Acceptable for MVP; a 404 pre-check is a possible refinement (note it, don't build it).

  • Step 4: Wire + OpenAPI. mod admin_vocab; + .merge(admin_vocab::routes()) in build_app; register the 4 paths + VocabularyView, NewVocabularyRequest, NewTermRequest, LabelInput, TermView, CreatedId schemas in openapi.rs.

  • Step 5: Run / Lint / Commit.

DATABASE_URL=<url> cargo test -p api --test admin_catalog   # vocab tests PASS
cargo +nightly fmt; DATABASE_URL=<url> cargo clippy -p api -p db --all-targets -- -D warnings
git add crates/db crates/api && git commit -m "feat(api): admin vocabulary + term management"

Task 5: authority admin

Files: modify crates/api/src/admin_authorities.rs (new), crates/api/src/lib.rs, crates/api/src/openapi.rs, crates/api/tests/admin_catalog.rs.

  • Step 1: Write the failing tests (append to crates/api/tests/admin_catalog.rs):
#[sqlx::test(migrations = "../db/migrations")]
async fn create_and_list_authorities_by_kind(pool: PgPool) {
    migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap();
    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 created = app.clone().oneshot(Request::builder().method("POST").uri("/api/admin/authorities")
        .header(header::COOKIE, &cookie).header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"en","label":"Ada Lovelace"}]}"#)).unwrap()).await.unwrap();
    assert_eq!(created.status(), StatusCode::CREATED);

    // list by kind
    let list = app.clone().oneshot(Request::builder().uri("/api/admin/authorities?kind=person").header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(list.status(), StatusCode::OK);
    let json: serde_json::Value = serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(json.as_array().unwrap().len(), 1);
    assert_eq!(json[0]["kind"], "person");

    // a different kind is empty
    let places = app.oneshot(Request::builder().uri("/api/admin/authorities?kind=place").header(header::COOKIE, &cookie).body(Body::empty()).unwrap()).await.unwrap();
    let places_json: serde_json::Value = serde_json::from_slice(&places.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert!(places_json.as_array().unwrap().is_empty());

    // bad kind → 422
    let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await;
    assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY);
}

(Define a tiny helper async fn app2_get(app, cookie, uri) -> StatusCode inline, or inline the request; keep it simple.)

  • Step 2: Run to verify it fails.

  • Step 3: Implement crates/api/src/admin_authorities.rs:

//! Admin authority-record management. Reads require `ViewInternal`; writes `EditCatalogue`.

use auth::{Authorized, EditCatalogue, ViewInternal};
use axum::{
    Json, Router,
    extract::{Query, State},
    http::StatusCode,
    routing::get,
};
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::AppState;
use crate::admin_objects::LabelView;
use crate::admin_vocab::{CreatedId, LabelInput};

#[derive(Serialize, ToSchema)]
pub(crate) struct AuthorityView {
    pub id: String,
    pub kind: String,
    pub external_uri: Option<String>,
    pub labels: Vec<LabelView>,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct NewAuthorityRequest {
    /// "person" | "organisation" | "place".
    pub kind: String,
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}

#[derive(Deserialize)]
pub(crate) struct KindQuery {
    kind: String,
}

#[utoipa::path(get, path = "/api/admin/authorities",
    params(("kind" = String, Query, description = "person | organisation | place")),
    responses((status = 200, body = [AuthorityView]), (status = 401), (status = 403), (status = 422)))]
pub(crate) async fn list_authorities(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
    Query(q): Query<KindQuery>,
) -> Result<Json<Vec<AuthorityView>>, StatusCode> {
    let kind = AuthorityKind::from_db(&q.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
    let authorities = db::authority::list_by_kind(state.db.pool(), kind)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok(Json(authorities.into_iter().map(|a| AuthorityView {
        id: a.id.to_string(), kind: a.kind.as_str().to_owned(), external_uri: a.external_uri,
        labels: a.labels.into_iter().map(|l| LabelView { lang: l.lang, label: l.label }).collect(),
    }).collect()))
}

#[utoipa::path(post, path = "/api/admin/authorities", request_body = NewAuthorityRequest,
    responses((status = 201, body = CreatedId), (status = 401), (status = 403), (status = 422)))]
pub(crate) async fn create_authority(
    _auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Json(req): Json<NewAuthorityRequest>,
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
    let kind = AuthorityKind::from_db(&req.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
    let new = NewAuthority {
        kind, external_uri: req.external_uri,
        labels: 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 id = db::authority::create_authority(&mut tx, &new).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
}

pub(crate) fn routes() -> Router<AppState> {
    Router::new().route("/api/admin/authorities", get(list_authorities).post(create_authority))
}
  • Step 4: Wire + OpenAPI. mod admin_authorities; + .merge(admin_authorities::routes()) in build_app; register the 2 paths + AuthorityView, NewAuthorityRequest schemas (note: CreatedId/LabelInput/LabelView are already registered from earlier tasks — don't double-register).

  • Step 5: Full workspace check.

cargo +nightly fmt --check
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace

Expected: all green.

  • Step 6: Commit.
git add crates/api && git commit -m "feat(api): admin authority management (create + list by kind)"

Self-Review (completed)

Spec coverage (VISION catalogue/vocab/authority [MVP]; arch spec §7, §9):

  • Object create/read/list/update/delete + flexible fields → Tasks 13. ✓
  • Reads (ViewInternal) see all visibility levels; writes (EditCatalogue); delete EditCatalogue → all tasks. ✓
  • Create allows Draft/Internal, rejects Public; update never changes visibility → Task 2. ✓
  • Real audit actor (AuditActor::User) on writes → Tasks 23. ✓
  • Field-definition listing for form rendering → Task 3. ✓
  • Vocabulary/term/authority management → Tasks 45. ✓
  • Paginated object list → Task 1 (advances #10); SQL stays in db (two new readers + list_vocabularies). ✓

Placeholder scan: none. <url>/<key> are documented env values. The app2_get helper in Task 5 is described inline.

Type consistency: AdminObjectView/AdminObjectPage/LabelView/Pagination/format_date/parse_date/actor defined in Tasks 12 and reused; LabelView imported by admin_vocab/admin_authorities; CreatedId/LabelInput defined in admin_vocab (Task 4) and reused by admin_authorities (Task 5); handlers use the verified db signatures and FieldError/Visibility/AuthorityKind exactly.

Notes for follow-on plans

  • Audit vocab/authority/term creation: the db create fns take no AuditActor; add actor + audit when those become security-relevant (file a follow-up).
  • #7 (per-user audit actor): object writes now record AuditActor::User; login/logout/auth-event auditing still pending.
  • #10 (pagination): object list is paginated; list_field_definitions, list_terms, list_by_kind return all rows (small sets — revisit if they grow).
  • #18 (tracing on 500s): these handlers also .map_err(|_| 500); wire tracing alongside the existing public-surface work.
  • term/authority value pickers: the UI can now populate Term/Authority flexible fields via these endpoints; a future refinement is a 404 pre-check when adding a term to a non-existent vocabulary (currently a 500 via FK).
  • Object-number uniqueness / format: not enforced here; relates to the configurable numbering standard (VISION MVP, separate concern).