Compare commits
11 Commits
807ac1a9f8
...
c4e0c4c834
| Author | SHA1 | Date | |
|---|---|---|---|
| c4e0c4c834 | |||
| 01abd5cbbc | |||
| d81b069b8f | |||
| 7a18e0e9bf | |||
| 8b929c7180 | |||
| b6a30c3995 | |||
| 34e5754815 | |||
| 3f4da46b78 | |||
| 1888e185f7 | |||
| 0055616099 | |||
| 3dc621b6dd |
+1
-1
@@ -13,7 +13,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres",
|
|||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
time = { version = "0.3", features = ["serde"] }
|
time = { version = "0.3", features = ["serde", "macros", "parsing", "formatting"] }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
utoipa = { version = "5", features = ["uuid"] }
|
utoipa = { version = "5", features = ["uuid"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ rust-version.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
time.workspace = true
|
time.workspace = true
|
||||||
tower-sessions.workspace = true
|
tower-sessions.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! 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,
|
||||||
|
admin_objects::LabelView,
|
||||||
|
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(|authority| AuthorityView {
|
||||||
|
id: authority.id.to_string(),
|
||||||
|
kind: authority.kind.as_str().to_owned(),
|
||||||
|
external_uri: authority.external_uri,
|
||||||
|
labels: authority
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| LabelView {
|
||||||
|
lang: label.lang,
|
||||||
|
label: label.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(|label| LocalizedLabel {
|
||||||
|
lang: label.lang,
|
||||||
|
label: label.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//! 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,
|
||||||
|
routing::{get, put},
|
||||||
|
};
|
||||||
|
use domain::{AuditActor, CatalogueObject, ObjectId, ObjectInput, Visibility};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{AppState, pagination::Pagination};
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
#[schema(value_type = String)]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(|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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, 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),
|
||||||
|
Err(db::catalog::FieldError::UnknownField(_)) => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
Err(db::catalog::FieldError::TypeMismatch { .. }) => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
Err(db::catalog::FieldError::Unresolved { .. }) => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin object routes, parameterized over [`AppState`].
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! 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,
|
||||||
|
};
|
||||||
|
use domain::{LocalizedLabel, NewTerm, VocabularyId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{AppState, 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(|vocab| VocabularyView {
|
||||||
|
id: vocab.id.to_string(),
|
||||||
|
key: vocab.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 vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(VocabularyView {
|
||||||
|
id: vocab.id.to_string(),
|
||||||
|
key: vocab.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(|term| TermView {
|
||||||
|
id: term.id.to_string(),
|
||||||
|
external_uri: term.external_uri,
|
||||||
|
labels: term
|
||||||
|
.labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|label| LabelView {
|
||||||
|
lang: label.lang,
|
||||||
|
label: label.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(|label| LocalizedLabel {
|
||||||
|
lang: label.lang,
|
||||||
|
label: label.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
//! HTTP API: router, handlers, and OpenAPI document.
|
//! HTTP API: router, handlers, and OpenAPI document.
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod admin_authorities;
|
||||||
|
mod admin_objects;
|
||||||
|
mod admin_vocab;
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
|
mod pagination;
|
||||||
mod public;
|
mod public;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -40,6 +44,9 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.merge(openapi::routes())
|
.merge(openapi::routes())
|
||||||
.merge(public::routes())
|
.merge(public::routes())
|
||||||
.merge(admin::routes())
|
.merge(admin::routes())
|
||||||
|
.merge(admin_objects::routes())
|
||||||
|
.merge(admin_vocab::routes())
|
||||||
|
.merge(admin_authorities::routes())
|
||||||
.layer(session_layer)
|
.layer(session_layer)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::{Json, Router, extract::State, routing::get};
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{AppState, admin, health, public};
|
use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, health, public};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
@@ -14,7 +14,20 @@ use crate::{AppState, admin, health, public};
|
|||||||
admin::logout,
|
admin::logout,
|
||||||
admin::me,
|
admin::me,
|
||||||
admin::list_users,
|
admin::list_users,
|
||||||
admin::set_visibility
|
admin::set_visibility,
|
||||||
|
admin_objects::list_objects,
|
||||||
|
admin_objects::get_object,
|
||||||
|
admin_objects::create_object,
|
||||||
|
admin_objects::update_object,
|
||||||
|
admin_objects::delete_object,
|
||||||
|
admin_objects::list_field_definitions,
|
||||||
|
admin_objects::set_fields,
|
||||||
|
admin_vocab::list_vocabularies,
|
||||||
|
admin_vocab::create_vocabulary,
|
||||||
|
admin_vocab::list_terms,
|
||||||
|
admin_vocab::add_term,
|
||||||
|
admin_authorities::list_authorities,
|
||||||
|
admin_authorities::create_authority
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
health::Live,
|
health::Live,
|
||||||
@@ -23,7 +36,22 @@ use crate::{AppState, admin, health, public};
|
|||||||
public::PublicObjectPage,
|
public::PublicObjectPage,
|
||||||
admin::LoginRequest,
|
admin::LoginRequest,
|
||||||
admin::UserView,
|
admin::UserView,
|
||||||
admin::VisibilityRequest
|
admin::VisibilityRequest,
|
||||||
|
admin_objects::AdminObjectView,
|
||||||
|
admin_objects::AdminObjectPage,
|
||||||
|
admin_objects::LabelView,
|
||||||
|
admin_objects::ObjectCreateRequest,
|
||||||
|
admin_objects::ObjectUpdateRequest,
|
||||||
|
admin_objects::CreatedObject,
|
||||||
|
admin_objects::FieldDefinitionView,
|
||||||
|
admin_vocab::VocabularyView,
|
||||||
|
admin_vocab::NewVocabularyRequest,
|
||||||
|
admin_vocab::NewTermRequest,
|
||||||
|
admin_vocab::LabelInput,
|
||||||
|
admin_vocab::TermView,
|
||||||
|
admin_vocab::CreatedId,
|
||||||
|
admin_authorities::AuthorityView,
|
||||||
|
admin_authorities::NewAuthorityRequest
|
||||||
)),
|
)),
|
||||||
info(title = "Collection Management System", version = "0.0.0")
|
info(title = "Collection Management System", version = "0.0.0")
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! Shared pagination query parameters used by both admin and public handlers.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
pub(crate) const DEFAULT_LIMIT: i64 = 50;
|
||||||
|
pub(crate) const MAX_LIMIT: i64 = 200;
|
||||||
|
|
||||||
|
/// Pagination query parameters with sane defaults and a hard cap.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct Pagination {
|
||||||
|
pub(crate) limit: Option<i64>,
|
||||||
|
pub(crate) offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pagination {
|
||||||
|
pub(crate) fn limit(&self) -> i64 {
|
||||||
|
self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn offset(&self) -> i64 {
|
||||||
|
self.offset.unwrap_or(0).max(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,10 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{CatalogueObject, ObjectId};
|
use domain::{CatalogueObject, ObjectId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::{AppState, pagination::Pagination};
|
||||||
|
|
||||||
/// A catalogue object as exposed on the public surface (public-safe fields only).
|
/// A catalogue object as exposed on the public surface (public-safe fields only).
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -50,26 +50,6 @@ pub(crate) struct PublicObjectPage {
|
|||||||
pub offset: i64,
|
pub offset: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pagination query parameters with sane defaults and a hard cap.
|
|
||||||
#[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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List public objects (paginated).
|
/// List public objects (paginated).
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
use api::{AppState, build_app, migrate_sessions};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use db::users;
|
||||||
|
use domain::{AuditActor, Email, NewUser, Role};
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 vocab: serde_json::Value =
|
||||||
|
serde_json::from_slice(&created.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let vocab_id = vocab["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(|item| item["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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn app2_get(app: &axum::Router, cookie: &str, uri: &str) -> StatusCode {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::COOKIE, cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/authorities?kind=place")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(places.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
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, FieldType, LocalizedLabel, NewFieldDefinition, 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();
|
||||||
|
|
||||||
|
let get = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/admin/objects/{}", domain::ObjectId::new()))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(list.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
assert_eq!(get.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);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 set_fields_and_list_field_definitions(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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn field_endpoints_require_auth(pool: PgPool) {
|
||||||
|
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let defs = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/field-definitions")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let set = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri(format!(
|
||||||
|
"/api/admin/objects/{}/fields",
|
||||||
|
domain::ObjectId::new()
|
||||||
|
))
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"k":"v"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(defs.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
assert_eq!(set.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
@@ -96,6 +96,39 @@ where
|
|||||||
rows.into_iter().map(map_object).collect()
|
rows.into_iter().map(map_object).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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")
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
||||||
/// not public — callers map both to 404 so non-public existence isn't revealed.
|
/// not public — callers map both to 404 so non-public existence isn't revealed.
|
||||||
pub async fn public_object_by_id<'e, E>(
|
pub async fn public_object_by_id<'e, E>(
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ where
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a vocabulary by its key.
|
/// Look up a vocabulary by its key.
|
||||||
pub async fn vocabulary_by_key<'e, E>(
|
pub async fn vocabulary_by_key<'e, E>(
|
||||||
executor: E,
|
executor: E,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user