//! Admin catalogue-object surface (authenticated). Reads require `ViewInternal`; //! writes require `EditCatalogue`. use auth::{AuthUser, Authorized, EditCatalogue, ViewInternal}; use axum::{ Json, Router, extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, put}, }; use domain::{ AuditActor, AuthorityKind, CatalogueObject, FieldType, LocalizedLabel, NewFieldDefinition, ObjectId, ObjectInput, Visibility, VocabularyId, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::{AppState, admin_vocab::LabelInput, reindex}; /// 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". #[schema(value_type = domain::Visibility)] pub visibility: String, /// Flexible field values (key -> value). #[schema(value_type = std::collections::HashMap)] pub fields: serde_json::Value, /// RFC3339 UTC timestamp. pub created_at: String, /// RFC3339 UTC timestamp. pub updated_at: String, } 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(), created_at: o .created_at .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default(), updated_at: o .updated_at .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default(), } } } /// A page of admin objects. #[derive(Serialize, ToSchema)] pub(crate) struct AdminObjectPage { pub items: Vec, pub total: i64, pub limit: i64, pub offset: i64, } /// Format a `time::Date` as `YYYY-MM-DD`. pub(crate) fn format_date(d: time::Date) -> String { let fmt = time::macros::format_description!("[year]-[month]-[day]"); d.format(&fmt).unwrap_or_default() } /// Parse a `YYYY-MM-DD` string into a `time::Date`, returning 422 on failure. 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) } /// Query parameters for the object list: pagination plus whitelisted sort/order and /// optional visibility/quick-filter. All values are validated/clamped server-side; the /// `sort` token maps onto an enum (never a raw column name) before reaching SQL. #[derive(Deserialize)] pub(crate) struct ObjectListParams { pub limit: Option, pub offset: Option, pub sort: Option, pub order: Option, pub visibility: Option, pub q: Option, } impl ObjectListParams { fn limit(&self) -> i64 { self.limit .unwrap_or(crate::pagination::DEFAULT_LIMIT) .clamp(1, crate::pagination::MAX_LIMIT) } fn offset(&self) -> i64 { self.offset.unwrap_or(0).max(0) } fn sort(&self) -> db::catalog::ObjectSort { use db::catalog::ObjectSort; match self.sort.as_deref() { Some("object_name") => ObjectSort::ObjectName, Some("updated_at") => ObjectSort::UpdatedAt, Some("created_at") => ObjectSort::CreatedAt, Some("visibility") => ObjectSort::Visibility, // Unknown or absent → stable default. _ => ObjectSort::ObjectNumber, } } fn descending(&self) -> bool { self.order.as_deref() == Some("desc") } /// Validate `visibility` against the domain enum; an unknown value is ignored /// (treated as no filter) so hand-edited URLs degrade gracefully instead of 500ing. fn visibility(&self) -> Option<&str> { self.visibility .as_deref() .filter(|v| Visibility::from_db(v).is_some()) } fn q(&self) -> Option<&str> { self.q.as_deref().map(str::trim).filter(|s| !s.is_empty()) } } /// 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"), ("sort" = Option, Query, description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"), ("order" = Option, Query, description = "asc | desc (default asc)"), ("visibility" = Option, Query, description = "draft | internal | public — filter; unknown values ignored"), ("q" = Option, Query, description = "quick filter: ILIKE match on object_number or object_name") ), responses( (status = 200, body = AdminObjectPage), (status = 401), (status = 403) ) )] pub(crate) async fn list_objects( _auth: Authorized, State(state): State, Query(params): Query, ) -> Result, StatusCode> { let (limit, offset) = (params.limit(), params.offset()); let query = db::catalog::ObjectQuery { sort: params.sort(), descending: params.descending(), visibility: params.visibility(), q: params.q(), }; let objects = db::catalog::list_objects_query(state.db.pool(), &query, limit, offset) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let total = db::catalog::count_objects_query(state.db.pool(), query.visibility, query.q) .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(), } } /// 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); } 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)?; reindex(&state, id).await; 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)?; // Read current visibility inside the tx so the read and update are atomic — // visibility changes only through the stepwise publish endpoint. 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 { reindex(&state, object_id).await; 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 { reindex(&state, object_id).await; Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) } } /// 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". #[schema(value_type = domain::DataType)] pub data_type: String, pub vocabulary_id: Option, #[schema(value_type = Option)] pub authority_kind: Option, pub required: bool, pub group: Option, pub labels: Vec, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub(crate) struct NewFieldDefinitionRequest { 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, } #[derive(serde::Serialize, utoipa::ToSchema)] pub(crate) struct CreatedField { pub key: String, } /// 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(|def| { let (data_type, vocabulary_id, authority_kind) = def.field_type.to_parts(); FieldDefinitionView { key: def.key, data_type: data_type.to_owned(), vocabulary_id: vocabulary_id.map(|vocab_id| vocab_id.to_string()), authority_kind: authority_kind.map(|kind| kind.as_str().to_owned()), required: def.required, group: def.group_key, labels: def .labels .into_iter() .map(|label| LabelView { lang: label.lang, label: label.label, }) .collect(), } }) .collect(), )) } /// Create a field definition. Requires `EditCatalogue`. All type/binding consistency /// (term needs a vocabulary, authority takes no vocabulary, scalars take no binding) is /// validated by `FieldType::from_parts`, which returns `None` for any bad combination. #[utoipa::path( post, path = "/api/admin/field-definitions", request_body = NewFieldDefinitionRequest, responses( (status = 201, body = CreatedField), (status = 400, description = "Malformed vocabulary_id or authority_kind"), (status = 401), (status = 403), (status = 409, description = "Duplicate key"), (status = 422, description = "Inconsistent type/binding") ) )] pub(crate) async fn create_field_definition( _auth: Authorized, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { let vocabulary_id = match req.vocabulary_id.as_deref() { None | Some("") => None, Some(s) => Some( s.parse::() .map_err(|_| StatusCode::BAD_REQUEST)?, ), }; let authority_kind = match req.authority_kind.as_deref() { None | Some("") => None, Some(s) => Some(AuthorityKind::from_db(s).ok_or(StatusCode::BAD_REQUEST)?), }; let field_type = FieldType::from_parts(&req.data_type, vocabulary_id, authority_kind) .ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; let new = NewFieldDefinition { key: req.key, field_type, required: req.required, group_key: req.group, 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)?; match db::fields::create_field_definition(&mut tx, &new).await { Ok(_) => { tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok((StatusCode::CREATED, Json(CreatedField { key: new.key }))) } Err(err) => { match err.as_database_error().and_then(|e| e.code()).as_deref() { // Duplicate `key` violates the unique index. Some("23505") => Err(StatusCode::CONFLICT), // Referenced vocabulary doesn't exist — client error, not server fault. Some("23503") => Err(StatusCode::UNPROCESSABLE_ENTITY), // CHECK constraint violated (e.g. empty key) — client error. Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY), _ => Err(StatusCode::INTERNAL_SERVER_ERROR), } } } } /// Fields that may be changed on an existing field definition. `key`, `data_type`, and /// binding are immutable and intentionally absent from this request. #[derive(Deserialize, ToSchema)] pub(crate) struct UpdateFieldDefinitionRequest { pub required: bool, pub group: Option, pub labels: Vec, } /// Update a field definition's mutable attributes (labels, group, required). /// `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`. #[utoipa::path( patch, path = "/api/admin/field-definitions/{key}", request_body = UpdateFieldDefinitionRequest, params(("key" = String, Path, description = "Field definition key")), responses( (status = 204), (status = 401), (status = 403), (status = 404), (status = 422, description = "CHECK constraint violated (e.g. empty label)") ) )] pub(crate) async fn update_field_definition( auth: Authorized, State(state): State, Path(key): Path, Json(req): Json, ) -> Result { let labels: Vec = 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 result = db::fields::update_field_definition( &mut tx, actor(&auth.user), &key, req.required, req.group.as_deref(), &labels, ) .await; match result { Ok(true) => { tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } Ok(false) => { let _ = tx.rollback().await; Err(StatusCode::NOT_FOUND) } Err(err) => { let _ = tx.rollback().await; match err.as_database_error().and_then(|e| e.code()).as_deref() { Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY), _ => Err(StatusCode::INTERNAL_SERVER_ERROR), } } } } /// Delete a field definition. Blocked (409) when catalogue objects store a value under /// this key. Requires `EditCatalogue`. #[utoipa::path( delete, path = "/api/admin/field-definitions/{key}", params(("key" = String, Path, description = "Field definition key")), responses( (status = 204), (status = 401), (status = 403), (status = 404), (status = 409, body = crate::admin_vocab::InUseView, description = "Field is used by catalogue objects") ) )] pub(crate) async fn delete_field_definition( auth: Authorized, State(state): State, Path(key): Path, ) -> Response { use crate::admin_vocab::InUseView; let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await { Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }, Ok(db::DeleteOutcome::InUse { count }) => { let _ = tx.rollback().await; (StatusCode::CONFLICT, Json(InUseView { count })).into_response() } Ok(db::DeleteOutcome::NotFound) => { let _ = tx.rollback().await; StatusCode::NOT_FOUND.into_response() } Err(_) => { let _ = tx.rollback().await; StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } /// Field-level rejection detail for `set_fields`, so the UI can highlight the field. #[derive(Serialize, ToSchema)] pub(crate) struct FieldErrorView { /// The flexible-field key that was rejected. pub field: String, /// Machine code: "unknown" | "type_mismatch" | "unresolved". pub code: String, } /// Replace an object's flexible-field values (validated against the registry). /// /// **Replace semantics:** the body is the *complete* desired field set. Omitting a key /// that was previously set removes it — send every key the caller wants to retain. /// /// 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, body = FieldErrorView, description = "A field was rejected") ) )] pub(crate) async fn set_fields( auth: Authorized, State(state): State, Path(id): Path, Json(values): Json>, ) -> axum::response::Response { use axum::response::IntoResponse; let Ok(object_id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response(); }; let mut tx = match state.db.pool().begin().await { Ok(tx) => tx, Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), }; let result = db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await; match result { Ok(()) => { if tx.commit().await.is_err() { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } reindex(&state, object_id).await; StatusCode::NO_CONTENT.into_response() } Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(), Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), Err(db::catalog::FieldError::UnknownField(field)) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(FieldErrorView { field, code: "unknown".to_owned(), }), ) .into_response(), Err(db::catalog::FieldError::TypeMismatch { field, .. }) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(FieldErrorView { field, code: "type_mismatch".to_owned(), }), ) .into_response(), Err(db::catalog::FieldError::Unresolved { field, .. }) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(FieldErrorView { field, code: "unresolved".to_owned(), }), ) .into_response(), } } /// Admin object routes, parameterized over [`AppState`]. pub(crate) fn routes() -> Router { Router::new() .route("/api/admin/objects", get(list_objects).post(create_object)) .route( "/api/admin/objects/{id}", get(get_object).put(update_object).delete(delete_object), ) .route("/api/admin/objects/{id}/fields", put(set_fields)) .route( "/api/admin/field-definitions", get(list_field_definitions).post(create_field_definition), ) .route( "/api/admin/field-definitions/{key}", axum::routing::patch(update_field_definition).delete(delete_field_definition), ) }