merge: publishing — visibility transitions, PublicView & public read API
This commit is contained in:
Generated
+1
@@ -79,6 +79,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"db",
|
"db",
|
||||||
|
"domain",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ axum.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
db = { path = "../db" }
|
db = { path = "../db" }
|
||||||
|
domain = { path = "../domain" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
|
mod public;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use db::Db;
|
use db::Db;
|
||||||
@@ -20,5 +21,6 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(openapi::routes())
|
.merge(openapi::routes())
|
||||||
|
.merge(public::routes())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
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, health};
|
use crate::{AppState, health, public};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(health::live, health::ready),
|
paths(health::live, health::ready, public::list_objects, public::get_object),
|
||||||
components(schemas(health::Live, health::Ready)),
|
components(schemas(
|
||||||
|
health::Live,
|
||||||
|
health::Ready,
|
||||||
|
public::PublicView,
|
||||||
|
public::PublicObjectPage
|
||||||
|
)),
|
||||||
info(title = "Collection Management System", version = "0.0.0")
|
info(title = "Collection Management System", version = "0.0.0")
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Public, unauthenticated, read-only surface (`/api/public/**`).
|
||||||
|
//!
|
||||||
|
//! Serves only `public` records as a [`PublicView`] — a projection that carries
|
||||||
|
//! ONLY public-safe fields. The never-public set (location, owner, recorder, dates,
|
||||||
|
//! and any flexible fields) is excluded by construction: the type lacks those fields,
|
||||||
|
//! so leaking one here is impossible. Per-field publishability (to surface selected
|
||||||
|
//! flexible fields) is post-MVP.
|
||||||
|
|
||||||
|
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 catalogue object as exposed on the public surface (public-safe fields only).
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct PublicView {
|
||||||
|
/// Stable object id (UUID).
|
||||||
|
pub id: String,
|
||||||
|
pub object_number: String,
|
||||||
|
pub object_name: String,
|
||||||
|
pub brief_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PublicView {
|
||||||
|
fn from_object(object: &CatalogueObject) -> Self {
|
||||||
|
PublicView {
|
||||||
|
id: object.id.to_string(),
|
||||||
|
object_number: object.object_number.clone(),
|
||||||
|
object_name: object.object_name.clone(),
|
||||||
|
brief_description: object.brief_description.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of public objects.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct PublicObjectPage {
|
||||||
|
pub items: Vec<PublicView>,
|
||||||
|
/// Total number of public objects (independent of paging).
|
||||||
|
pub total: i64,
|
||||||
|
pub limit: 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).
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/public/objects",
|
||||||
|
params(
|
||||||
|
("limit" = Option<i64>, Query, description = "Max items (1..=200, default 50)"),
|
||||||
|
("offset" = Option<i64>, Query, description = "Items to skip (default 0)")
|
||||||
|
),
|
||||||
|
responses((status = 200, body = PublicObjectPage))
|
||||||
|
)]
|
||||||
|
pub(crate) async fn list_objects(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(page): Query<Pagination>,
|
||||||
|
) -> Result<Json<PublicObjectPage>, StatusCode> {
|
||||||
|
let (limit, offset) = (page.limit(), page.offset());
|
||||||
|
|
||||||
|
// `items` and `total` come from two separate queries; under concurrent
|
||||||
|
// publish/unpublish they can momentarily disagree by one — acceptable for a
|
||||||
|
// public read surface.
|
||||||
|
let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let total = db::catalog::count_public_objects(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(PublicObjectPage {
|
||||||
|
items: objects.iter().map(PublicView::from_object).collect(),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get one public object by id. Returns 404 if missing OR not public.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/public/objects/{id}",
|
||||||
|
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = PublicView),
|
||||||
|
(status = 404, description = "No public object with that id")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn get_object(
|
||||||
|
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::public_object_by_id(state.db.pool(), object_id).await {
|
||||||
|
Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(),
|
||||||
|
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public routes, parameterized over [`AppState`].
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/public/objects", get(list_objects))
|
||||||
|
.route("/api/public/objects/{id}", get(get_object))
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use api::{AppState, build_app};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use db::catalog;
|
||||||
|
use domain::{AuditActor, ObjectInput, Visibility};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt; // for `oneshot`
|
||||||
|
|
||||||
|
fn state(pool: PgPool) -> AppState {
|
||||||
|
AppState {
|
||||||
|
db: db::Db::from_pool(pool),
|
||||||
|
app_name: "Test".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
|
||||||
|
ObjectInput {
|
||||||
|
object_number: number.into(),
|
||||||
|
object_name: name.into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: Some("a description".into()),
|
||||||
|
current_location: Some("vault B".into()), // never-public; must NOT appear in output
|
||||||
|
current_owner: Some("the museum".into()), // never-public
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn body_json(resp: axum::http::Response<Body>) -> serde_json::Value {
|
||||||
|
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
serde_json::from_slice(&bytes).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn list_returns_only_public_as_public_view(pool: PgPool) {
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("D-1", "draft vase", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("P-1", "public vase", Visibility::Public),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/public/objects")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let json = body_json(resp).await;
|
||||||
|
assert_eq!(json["total"], 1);
|
||||||
|
assert_eq!(json["items"].as_array().unwrap().len(), 1);
|
||||||
|
let item = &json["items"][0];
|
||||||
|
assert_eq!(item["object_number"], "P-1");
|
||||||
|
assert_eq!(item["object_name"], "public vase");
|
||||||
|
assert_eq!(item["brief_description"], "a description");
|
||||||
|
assert!(item.get("current_location").is_none());
|
||||||
|
assert!(item.get("current_owner").is_none());
|
||||||
|
assert!(item.get("recorder").is_none());
|
||||||
|
assert!(item.get("visibility").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn get_public_object_returns_it(pool: PgPool) {
|
||||||
|
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,
|
||||||
|
&object("P-1", "public vase", Visibility::Public),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/public/objects/{id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let json = body_json(resp).await;
|
||||||
|
assert_eq!(json["object_number"], "P-1");
|
||||||
|
assert!(json.get("current_location").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn non_public_objects_are_404(pool: PgPool) {
|
||||||
|
let db = db::Db::from_pool(pool.clone());
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let draft = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("D-1", "draft vase", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let internal = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("I-1", "internal vase", Visibility::Internal),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
// both non-public states are hidden behind a 404 — not 403 — so existence isn't leaked
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
for id in [draft, internal] {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/public/objects/{id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn get_missing_object_is_404(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/api/public/objects/{}", domain::ObjectId::new()))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn openapi_lists_the_public_paths(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api-docs/openapi.json")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json = body_json(resp).await;
|
||||||
|
assert!(json["paths"]["/api/public/objects"].is_object());
|
||||||
|
assert!(json["paths"]["/api/public/objects/{id}"].is_object());
|
||||||
|
}
|
||||||
+129
-15
@@ -2,8 +2,8 @@
|
|||||||
//! on the caller's connection, so the change and its audit entry commit together.
|
//! on the caller's connection, so the change and its audit entry commit together.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId,
|
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition,
|
||||||
ObjectInput, Visibility,
|
NewAuditEvent, ObjectId, ObjectInput, Visibility,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
@@ -13,6 +13,9 @@ use crate::{audit, authority, fields, vocab};
|
|||||||
/// The entity_type recorded in the audit log for catalogue objects.
|
/// The entity_type recorded in the audit log for catalogue objects.
|
||||||
const ENTITY_TYPE: &str = "object";
|
const ENTITY_TYPE: &str = "object";
|
||||||
|
|
||||||
|
/// The visibility value eligible for the public surface.
|
||||||
|
const PUBLIC_VISIBILITY: &str = Visibility::Public.as_str();
|
||||||
|
|
||||||
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
|
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
|
||||||
brief_description, current_location, current_owner, recorder, recording_date, \
|
brief_description, current_location, current_owner, recorder, recording_date, \
|
||||||
visibility, fields, created_at, updated_at";
|
visibility, fields, created_at, updated_at";
|
||||||
@@ -93,6 +96,66 @@ where
|
|||||||
rows.into_iter().map(map_object).collect()
|
rows.into_iter().map(map_object).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub async fn public_object_by_id<'e, E>(
|
||||||
|
executor: E,
|
||||||
|
id: ObjectId,
|
||||||
|
) -> Result<Option<CatalogueObject>, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
|
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2");
|
||||||
|
|
||||||
|
let row = sqlx::query(&sql)
|
||||||
|
.bind(id.to_uuid())
|
||||||
|
.bind(PUBLIC_VISIBILITY)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.map(map_object).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List **public** objects ordered by object number, with `limit`/`offset` paging.
|
||||||
|
///
|
||||||
|
/// `limit` and `offset` must be non-negative (Postgres rejects a negative `LIMIT`);
|
||||||
|
/// the public API layer clamps them before calling.
|
||||||
|
pub async fn list_public_objects<'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 WHERE visibility = $1 \
|
||||||
|
ORDER BY object_number LIMIT $2 OFFSET $3"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.bind(PUBLIC_VISIBILITY)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
rows.into_iter().map(map_object).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count all public objects (for pagination totals).
|
||||||
|
pub async fn count_public_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'e>,
|
||||||
|
{
|
||||||
|
let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1")
|
||||||
|
.bind(PUBLIC_VISIBILITY)
|
||||||
|
.fetch_one(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.try_get("n")
|
||||||
|
}
|
||||||
|
|
||||||
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
|
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
|
||||||
let visibility_str: String = row.try_get("visibility")?;
|
let visibility_str: String = row.try_get("visibility")?;
|
||||||
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
|
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
|
||||||
@@ -189,10 +252,25 @@ pub async fn update_object(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let changes = update_changes(&old.to_input(), input);
|
apply_object_update(&mut *conn, actor, id, &old.to_input(), input).await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff `old`→`new`, write the changed columns + an `updated` audit entry, both on
|
||||||
|
/// `conn`. A no-op (no field changed) touches neither the row's `updated_at` nor the
|
||||||
|
/// audit log.
|
||||||
|
async fn apply_object_update(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: ObjectId,
|
||||||
|
old: &ObjectInput,
|
||||||
|
new: &ObjectInput,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let changes = update_changes(old, new);
|
||||||
|
|
||||||
if changes.is_empty() {
|
if changes.is_empty() {
|
||||||
// No-op: don't touch updated_at or the audit log.
|
return Ok(());
|
||||||
return Ok(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -203,15 +281,15 @@ pub async fn update_object(
|
|||||||
WHERE id = $1",
|
WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.to_uuid())
|
.bind(id.to_uuid())
|
||||||
.bind(&input.object_number)
|
.bind(&new.object_number)
|
||||||
.bind(&input.object_name)
|
.bind(&new.object_name)
|
||||||
.bind(input.number_of_objects)
|
.bind(new.number_of_objects)
|
||||||
.bind(input.brief_description.as_deref())
|
.bind(new.brief_description.as_deref())
|
||||||
.bind(input.current_location.as_deref())
|
.bind(new.current_location.as_deref())
|
||||||
.bind(input.current_owner.as_deref())
|
.bind(new.current_owner.as_deref())
|
||||||
.bind(input.recorder.as_deref())
|
.bind(new.recorder.as_deref())
|
||||||
.bind(input.recording_date)
|
.bind(new.recording_date)
|
||||||
.bind(input.visibility.as_str())
|
.bind(new.visibility.as_str())
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -227,7 +305,43 @@ pub async fn update_object(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why changing an object's visibility failed.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum VisibilityError {
|
||||||
|
#[error("object not found")]
|
||||||
|
ObjectNotFound,
|
||||||
|
#[error(transparent)]
|
||||||
|
Illegal(#[from] IllegalTransition),
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move an object to `target` visibility, enforcing the stepwise state machine, and
|
||||||
|
/// audit the change. Uses the same diff/audit path as `update_object`, so only
|
||||||
|
/// `visibility` appears in the audit entry — and setting to the current value is an
|
||||||
|
/// idempotent no-op (no row touch, no audit). Pass a transaction connection.
|
||||||
|
pub async fn set_visibility(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
id: ObjectId,
|
||||||
|
target: Visibility,
|
||||||
|
) -> Result<(), VisibilityError> {
|
||||||
|
let Some(object) = object_by_id(&mut *conn, id).await? else {
|
||||||
|
return Err(VisibilityError::ObjectNotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_visibility = object.visibility.transition_to(target)?;
|
||||||
|
|
||||||
|
let old_input = object.to_input();
|
||||||
|
let mut new_input = old_input.clone();
|
||||||
|
|
||||||
|
new_input.visibility = new_visibility;
|
||||||
|
apply_object_update(&mut *conn, actor, id, &old_input, &new_input).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use db::{Db, audit, catalog};
|
||||||
|
use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
fn object(number: &str, visibility: Visibility) -> ObjectInput {
|
||||||
|
ObjectInput {
|
||||||
|
object_number: number.into(),
|
||||||
|
object_name: "vase".into(),
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: None,
|
||||||
|
current_location: None,
|
||||||
|
current_owner: None,
|
||||||
|
recorder: None,
|
||||||
|
recording_date: None,
|
||||||
|
visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn publish_steps_through_internal_and_audits(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("LM-1", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(obj.visibility, Visibility::Public);
|
||||||
|
|
||||||
|
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(history.len(), 3); // created + two visibility updates
|
||||||
|
assert_eq!(history[2].action, AuditAction::Updated);
|
||||||
|
let changed: Vec<&str> = history[2]
|
||||||
|
.changes
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.field.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(changed, vec!["visibility"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn skipping_a_step_is_rejected_and_unchanged(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("LM-1", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
catalog::VisibilityError::Illegal(IllegalTransition {
|
||||||
|
from: Visibility::Draft,
|
||||||
|
to: Visibility::Public
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(obj.visibility, Visibility::Draft); // unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn set_visibility_on_missing_object_errors(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let err = catalog::set_visibility(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
domain::ObjectId::new(),
|
||||||
|
Visibility::Internal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
assert!(matches!(err, catalog::VisibilityError::ObjectNotFound));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn no_op_set_to_current_visibility_writes_no_audit(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("LM-1", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Draft)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn public_reads_return_only_public_records(pool: PgPool) {
|
||||||
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let draft = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("D-1", Visibility::Draft),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let pub_id = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("P-1", Visibility::Public),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let internal = catalog::create_object(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&object("I-1", Visibility::Internal),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
catalog::public_object_by_id(db.pool(), pub_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
catalog::public_object_by_id(db.pool(), draft)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed = catalog::list_public_objects(db.pool(), 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].id, pub_id);
|
||||||
|
assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
catalog::list_public_objects(db.pool(), 50, 1)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
// internal records are excluded from public reads too (not just draft)
|
||||||
|
assert!(
|
||||||
|
catalog::public_object_by_id(db.pool(), internal)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,5 +13,5 @@ pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
|||||||
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition};
|
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition};
|
||||||
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, VocabularyId};
|
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, VocabularyId};
|
||||||
pub use label::{LocalizedLabel, pick_label};
|
pub use label::{LocalizedLabel, pick_label};
|
||||||
pub use object::{CatalogueObject, ObjectInput, Visibility};
|
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
||||||
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
|
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub enum Visibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Visibility {
|
impl Visibility {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Visibility::Draft => "draft",
|
Visibility::Draft => "draft",
|
||||||
Visibility::Internal => "internal",
|
Visibility::Internal => "internal",
|
||||||
@@ -35,6 +35,52 @@ impl Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Visibility {
|
||||||
|
/// Whether `self` may move directly to `target`. Legal single steps are
|
||||||
|
/// `draft↔internal` and `internal↔public`; `draft↔public` is not one step.
|
||||||
|
pub fn can_transition_to(self, target: Visibility) -> bool {
|
||||||
|
use Visibility::*;
|
||||||
|
|
||||||
|
matches!(
|
||||||
|
(self, target),
|
||||||
|
(Draft, Internal) | (Internal, Draft) | (Internal, Public) | (Public, Internal)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a stepwise transition to `target`. Setting to the current value is an
|
||||||
|
/// idempotent no-op (`Ok`). A forbidden jump returns [`IllegalTransition`].
|
||||||
|
pub fn transition_to(self, target: Visibility) -> Result<Visibility, IllegalTransition> {
|
||||||
|
if self == target || self.can_transition_to(target) {
|
||||||
|
Ok(target)
|
||||||
|
} else {
|
||||||
|
Err(IllegalTransition {
|
||||||
|
from: self,
|
||||||
|
to: target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An attempted visibility change the state machine forbids.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct IllegalTransition {
|
||||||
|
pub from: Visibility,
|
||||||
|
pub to: Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for IllegalTransition {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"illegal visibility transition: {} -> {}",
|
||||||
|
self.from.as_str(),
|
||||||
|
self.to.as_str()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for IllegalTransition {}
|
||||||
|
|
||||||
/// The mutable inventory-minimum fields of a catalogue object.
|
/// The mutable inventory-minimum fields of a catalogue object.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ObjectInput {
|
pub struct ObjectInput {
|
||||||
@@ -107,4 +153,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stepwise_transitions_are_legal() {
|
||||||
|
use Visibility::*;
|
||||||
|
assert_eq!(Draft.transition_to(Internal), Ok(Internal));
|
||||||
|
assert_eq!(Internal.transition_to(Public), Ok(Public));
|
||||||
|
assert_eq!(Public.transition_to(Internal), Ok(Internal));
|
||||||
|
assert_eq!(Internal.transition_to(Draft), Ok(Draft));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skipping_a_step_is_illegal() {
|
||||||
|
use Visibility::*;
|
||||||
|
assert_eq!(
|
||||||
|
Draft.transition_to(Public),
|
||||||
|
Err(IllegalTransition {
|
||||||
|
from: Draft,
|
||||||
|
to: Public
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Public.transition_to(Draft),
|
||||||
|
Err(IllegalTransition {
|
||||||
|
from: Public,
|
||||||
|
to: Draft
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// the Display message is the user-visible surface of the error
|
||||||
|
assert_eq!(
|
||||||
|
Draft.transition_to(Public).unwrap_err().to_string(),
|
||||||
|
"illegal visibility transition: draft -> public"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setting_to_current_value_is_a_noop_ok() {
|
||||||
|
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||||
|
assert_eq!(v.transition_to(v), Ok(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user