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

1041 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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<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`):
```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<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):
```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<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.**
```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=<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<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()`:
```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=<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=<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<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()`:
```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=<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`):
```rust
/// 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):
```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<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.**
```bash
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`):
```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<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.**
```bash
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.**
```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 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).