//! 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) } } /// 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. // Used by write handlers added in later tasks. #[allow(dead_code)] 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(), } } /// Admin object routes, parameterized over [`AppState`]. pub(crate) fn routes() -> Router { Router::new() .route("/api/admin/objects", get(list_objects)) .route("/api/admin/objects/{id}", get(get_object)) }