From 3dc621b6dd3de817fc424d5a34f0f3fb62d3791f Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 19:02:47 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20admin=20CRUD=20=E2=80=94=20objec?= =?UTF-8?q?t=20lifecycle=20+=20vocab/authority=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-06-02-admin-crud.md | 1040 +++++++++++++++++++++++++++ 1 file changed, 1040 insertions(+) create mode 100644 docs/plans/2026-06-02-admin-crud.md diff --git a/docs/plans/2026-06-02-admin-crud.md b/docs/plans/2026-06-02-admin-crud.md new file mode 100644 index 0000000..c1eeb9a --- /dev/null +++ b/docs/plans/2026-06-02-admin-crud.md @@ -0,0 +1,1040 @@ +# 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` 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` (admin reads see all visibility levels), **writes** require `Authorized`. 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`; `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`. +- `db::vocab`: `create_vocabulary(exec, &str) -> Vocabulary`; `vocabulary_by_key`; `add_term(conn, &NewTerm) -> TermId`; `list_terms(exec, VocabularyId) -> Vec`. +- `db::authority`: `create_authority(conn, &NewAuthority) -> AuthorityId`; `list_by_kind(exec, AuthorityKind) -> Vec`. +- domain: `ObjectInput`, `CatalogueObject`, `Visibility`, `FieldType` (`to_parts() -> (&'static str, Option, Option)`), `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{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`: +```rust +/// 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, 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 +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`): +```rust +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 { + 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) -> 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): +```rust +//! 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, + pub current_location: Option, + pub current_owner: Option, + pub recorder: Option, + /// `YYYY-MM-DD` or null. + pub recording_date: Option, + /// "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, + pub total: i64, + pub limit: i64, + pub offset: i64, +} + +#[derive(Deserialize)] +pub(crate) struct Pagination { + limit: Option, + offset: Option, +} +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 { + 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, Query, description = "1..=200, default 50"), + ("offset" = Option, Query, description = "default 0")), + responses((status = 200, body = AdminObjectPage), (status = 401), (status = 403)) +)] +pub(crate) async fn list_objects( + _auth: Authorized, + State(state): State, + Query(page): Query, +) -> Result, 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, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let Ok(object_id) = id.parse::() 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 { + 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= cargo test -p db --test catalog_mutations` (smoke that catalog still builds) then `DATABASE_URL= cargo test -p api --test admin_objects` → PASS. Then `cargo test -p api` → all PASS. + +- [ ] **Step 7: Lint.** `cargo +nightly fmt`; `DATABASE_URL= cargo clippy -p api -p db --all-targets -- -D warnings` → clean. + +- [ ] **Step 8: Commit.** +```bash +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`): +```rust +#[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= cargo test -p api --test admin_objects` → FAIL (write routes missing). + +- [ ] **Step 3: Implement** — add to `crates/api/src/admin_objects.rs`: +```rust +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, + pub current_location: Option, + pub current_owner: Option, + pub recorder: Option, + pub recording_date: Option, + /// "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, + pub current_location: Option, + pub current_owner: Option, + pub recorder: Option, + pub recording_date: Option, +} + +/// 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, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), 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, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let object_id = id.parse::().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, + State(state): State, + Path(id): Path, +) -> Result { + let object_id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let existed = db::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()`: +```rust + .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= cargo test -p api --test admin_objects` → PASS. `cargo test -p api` → all PASS. + +- [ ] **Step 6: Lint + Commit.** +```bash +cargo +nightly fmt +DATABASE_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. +```rust +#[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`: +```rust +/// 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, + pub authority_kind: Option, + pub required: bool, + pub group: Option, + pub labels: Vec, +} + +/// 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, + State(state): State, +) -> Result>, 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, + State(state): State, + Path(id): Path, + Json(values): Json>, +) -> Result { + let object_id = id.parse::().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()`: +```rust + .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.** +```bash +DATABASE_URL= cargo test -p api --test admin_objects # PASS +cargo +nightly fmt; DATABASE_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`): +```rust +/// List all vocabularies, ordered by key. +pub async fn list_vocabularies<'e, E>(executor: E) -> Result, 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): +```rust +// (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`: +```rust +//! 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, + pub labels: Vec, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct TermView { + pub id: String, + pub external_uri: Option, + pub labels: Vec, +} + +#[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, + State(state): State, +) -> Result>, 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, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), 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, + State(state): State, + Path(id): Path, +) -> Result>, StatusCode> { + let vocab_id = id.parse::().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, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let vocabulary_id = id.parse::().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 { + 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.** +```bash +DATABASE_URL= cargo test -p api --test admin_catalog # vocab tests PASS +cargo +nightly fmt; DATABASE_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`): +```rust +#[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`: +```rust +//! 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, + pub labels: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub(crate) struct NewAuthorityRequest { + /// "person" | "organisation" | "place". + pub kind: String, + pub external_uri: Option, + pub labels: Vec, +} + +#[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, + State(state): State, + Query(q): Query, +) -> Result>, 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, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), 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 { + 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.** +```bash +cargo +nightly fmt --check +DATABASE_URL= cargo clippy --workspace --all-targets -- -D warnings +DATABASE_URL= MEILI_URL= MEILI_MASTER_KEY= cargo test --workspace +``` +Expected: all green. + +- [ ] **Step 6: Commit.** +```bash +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 1–3. ✓ +- 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 2–3. ✓ +- Field-definition listing for form rendering → Task 3. ✓ +- Vocabulary/term/authority management → Tasks 4–5. ✓ +- Paginated object list → Task 1 (advances #10); SQL stays in `db` (two new readers + `list_vocabularies`). ✓ + +**Placeholder scan:** none. ``/`` 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 1–2 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).