Compare commits
14 Commits
e9a5a10524
...
260eac903e
| Author | SHA1 | Date | |
|---|---|---|---|
| 260eac903e | |||
| 9d0475e8ec | |||
| 04e9c95c52 | |||
| de11292203 | |||
| 825b23adec | |||
| 2460a1368d | |||
| 4a76d6043a | |||
| 0f43c75b24 | |||
| 3c6a41a80a | |||
| 146e0164e7 | |||
| 984be697ac | |||
| 7181437625 | |||
| 7e235ffd3e | |||
| b0d2c247df |
@@ -7,7 +7,7 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
|
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ pub(crate) async fn list_authorities(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn create_authority(
|
pub(crate) async fn create_authority(
|
||||||
_auth: Authorized<EditCatalogue>,
|
auth: Authorized<EditCatalogue>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<NewAuthorityRequest>,
|
Json(req): Json<NewAuthorityRequest>,
|
||||||
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
||||||
@@ -117,7 +117,8 @@ pub(crate) async fn create_authority(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let id = db::authority::create_authority(&mut tx, &new)
|
let id =
|
||||||
|
db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{LocalizedLabel, NewTerm, VocabularyId};
|
use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
@@ -85,11 +85,23 @@ pub(crate) async fn list_vocabularies(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn create_vocabulary(
|
pub(crate) async fn create_vocabulary(
|
||||||
_auth: Authorized<EditCatalogue>,
|
auth: Authorized<EditCatalogue>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<NewVocabularyRequest>,
|
Json(req): Json<NewVocabularyRequest>,
|
||||||
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
|
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
|
||||||
let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key)
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.pool()
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let vocab =
|
||||||
|
db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
@@ -156,7 +168,7 @@ pub(crate) async fn list_terms(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn add_term(
|
pub(crate) async fn add_term(
|
||||||
_auth: Authorized<EditCatalogue>,
|
auth: Authorized<EditCatalogue>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(req): Json<NewTermRequest>,
|
Json(req): Json<NewTermRequest>,
|
||||||
@@ -185,7 +197,9 @@ pub(crate) async fn add_term(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| {
|
let term_id = db::vocab::add_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
|
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
|
||||||
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
|
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Public, non-sensitive instance configuration the SPA needs before login.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct ConfigView {
|
||||||
|
/// User-facing product name.
|
||||||
|
pub app_name: String,
|
||||||
|
/// Default UI/content language (i18n key, e.g. "sv").
|
||||||
|
pub default_language: String,
|
||||||
|
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
|
||||||
|
pub default_timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
|
||||||
|
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
|
||||||
|
Json(ConfigView {
|
||||||
|
app_name: state.app_name.clone(),
|
||||||
|
default_language: state.default_language.clone(),
|
||||||
|
default_timezone: state.default_timezone.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/api/config", get(get_config))
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod admin_authorities;
|
|||||||
mod admin_objects;
|
mod admin_objects;
|
||||||
mod admin_search;
|
mod admin_search;
|
||||||
mod admin_vocab;
|
mod admin_vocab;
|
||||||
|
mod config;
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
mod pagination;
|
mod pagination;
|
||||||
@@ -30,6 +31,10 @@ pub struct AppState {
|
|||||||
/// Search client for on-write index sync. `None` disables indexing (search is a
|
/// Search client for on-write index sync. `None` disables indexing (search is a
|
||||||
/// best-effort feature; absent when Meilisearch is not configured).
|
/// best-effort feature; absent when Meilisearch is not configured).
|
||||||
pub search: Option<search::SearchClient>,
|
pub search: Option<search::SearchClient>,
|
||||||
|
/// Instance default UI/content language (from config).
|
||||||
|
pub default_language: String,
|
||||||
|
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
|
||||||
|
pub default_timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: keep the search index in step with a catalogue write that has already
|
/// Best-effort: keep the search index in step with a catalogue write that has already
|
||||||
@@ -58,6 +63,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.merge(config::routes())
|
||||||
.merge(health::routes())
|
.merge(health::routes())
|
||||||
.merge(openapi::routes())
|
.merge(openapi::routes())
|
||||||
.merge(public::routes())
|
.merge(public::routes())
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get};
|
|||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public,
|
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, config, health,
|
||||||
|
public,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
|
config::get_config,
|
||||||
health::live,
|
health::live,
|
||||||
health::ready,
|
health::ready,
|
||||||
public::list_objects,
|
public::list_objects,
|
||||||
@@ -34,6 +36,7 @@ use crate::{
|
|||||||
admin_authorities::create_authority
|
admin_authorities::create_authority
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
|
config::ConfigView,
|
||||||
health::Live,
|
health::Live,
|
||||||
health::Ready,
|
health::Ready,
|
||||||
public::PublicView,
|
public::PublicView,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use api::{AppState, build_app, migrate_sessions};
|
use api::{AppState, build_app, migrate_sessions};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, StatusCode, header};
|
use axum::http::{Request, StatusCode, header};
|
||||||
use db::users;
|
use db::{audit, users};
|
||||||
use domain::{AuditActor, Email, NewUser, Role};
|
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,3 +292,44 @@ async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
|
|||||||
|
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn creating_a_vocabulary_writes_an_audit_entry(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;
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/admin/vocabularies")
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"key":"audit-test"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
let vocab_id: uuid::Uuid = body["id"].as_str().unwrap().parse().unwrap();
|
||||||
|
|
||||||
|
let history = audit::history_for(&pool, "vocabulary", vocab_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(history.len(), 1);
|
||||||
|
assert_eq!(history[0].action, AuditAction::Created);
|
||||||
|
assert!(
|
||||||
|
matches!(history[0].actor, AuditActor::User(_)),
|
||||||
|
"expected actor to be a user"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search,
|
search,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use api::{AppState, build_app};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
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 Museum".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn config_is_public_and_reflects_state(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/config")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body["app_name"], "Test Museum");
|
||||||
|
assert_eq!(body["default_language"], "sv");
|
||||||
|
assert_eq!(body["default_timezone"], "Europe/Stockholm");
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
|
|||||||
app_name: app_name.to_string(),
|
app_name: app_name.to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
|
|||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState {
|
|||||||
app_name: "Test".into(),
|
app_name: "Test".into(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: Some(search),
|
search: Some(search),
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
//! Authority records (person / organisation / place).
|
//! Authority records (person / organisation / place).
|
||||||
|
|
||||||
use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel,
|
||||||
|
NewAuditEvent, NewAuthority,
|
||||||
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::audit;
|
||||||
|
|
||||||
|
const AUTHORITY_ENTITY_TYPE: &str = "authority";
|
||||||
|
|
||||||
/// Labels aggregated per row as JSON, to read an authority and its labels in one query.
|
/// Labels aggregated per row as JSON, to read an authority and its labels in one query.
|
||||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \
|
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \
|
||||||
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
|
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
|
||||||
|
|
||||||
/// Insert an authority and its labels. Multiple statements — pass a transaction
|
/// Insert an authority and its labels, then record a `created` audit entry. Multiple
|
||||||
/// connection (`&mut *tx`) for atomicity.
|
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
|
||||||
|
/// atomically.
|
||||||
pub async fn create_authority(
|
pub async fn create_authority(
|
||||||
conn: &mut sqlx::PgConnection,
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
new: &NewAuthority,
|
new: &NewAuthority,
|
||||||
) -> Result<AuthorityId, sqlx::Error> {
|
) -> Result<AuthorityId, sqlx::Error> {
|
||||||
let id = AuthorityId::new();
|
let id = AuthorityId::new();
|
||||||
@@ -31,6 +40,18 @@ pub async fn create_authority(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Created,
|
||||||
|
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ pub struct Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Connect to the database at `database_url`, opening a connection pool.
|
/// Connect to the database at `database_url`, opening a connection pool with at most
|
||||||
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
|
/// `max_connections` connections.
|
||||||
|
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(max_connections)
|
||||||
.connect(database_url)
|
.connect(database_url)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
//! populated by the organization or a later import. The inventory-minimum fields
|
//! populated by the organization or a later import. The inventory-minimum fields
|
||||||
//! (object number, name, location, …) live in the typed object core, not here.
|
//! (object number, name, location, …) live in the typed object core, not here.
|
||||||
|
|
||||||
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId};
|
use domain::{
|
||||||
|
AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{fields, vocab};
|
use crate::{fields, vocab};
|
||||||
|
|
||||||
@@ -119,7 +121,11 @@ async fn ensure_vocabulary(
|
|||||||
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
|
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
|
||||||
Ok(existing.id)
|
Ok(existing.id)
|
||||||
} else {
|
} else {
|
||||||
Ok(vocab::create_vocabulary(&mut *conn, key).await?.id)
|
Ok(
|
||||||
|
vocab::create_vocabulary(&mut *conn, AuditActor::System, key)
|
||||||
|
.await?
|
||||||
|
.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-10
@@ -1,23 +1,45 @@
|
|||||||
//! Controlled vocabularies and terms.
|
//! Controlled vocabularies and terms.
|
||||||
|
|
||||||
use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef,
|
||||||
|
Vocabulary, VocabularyId,
|
||||||
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::audit;
|
||||||
|
|
||||||
|
const VOCABULARY_ENTITY_TYPE: &str = "vocabulary";
|
||||||
|
const TERM_ENTITY_TYPE: &str = "term";
|
||||||
|
|
||||||
/// Labels aggregated per row as JSON, to read a term and its labels in one query.
|
/// Labels aggregated per row as JSON, to read a term and its labels in one query.
|
||||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \
|
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \
|
||||||
ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)";
|
ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)";
|
||||||
|
|
||||||
/// Create a vocabulary with the given key.
|
/// Create a vocabulary with the given key and record a `created` audit entry, both on
|
||||||
pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result<Vocabulary, sqlx::Error>
|
/// `conn` (pass a transaction connection `&mut *tx` so they commit atomically).
|
||||||
where
|
pub async fn create_vocabulary(
|
||||||
E: sqlx::PgExecutor<'e>,
|
conn: &mut sqlx::PgConnection,
|
||||||
{
|
actor: AuditActor,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Vocabulary, sqlx::Error> {
|
||||||
let id = VocabularyId::new();
|
let id = VocabularyId::new();
|
||||||
|
|
||||||
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
|
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
|
||||||
.bind(id.to_uuid())
|
.bind(id.to_uuid())
|
||||||
.bind(key)
|
.bind(key)
|
||||||
.execute(executor)
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Created,
|
||||||
|
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Vocabulary {
|
Ok(Vocabulary {
|
||||||
@@ -54,9 +76,14 @@ where
|
|||||||
row.map(map_vocabulary).transpose()
|
row.map(map_vocabulary).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a term and its labels. Multiple statements — pass a transaction
|
/// Insert a term and its labels, then record a `created` audit entry. Multiple
|
||||||
/// connection (`&mut *tx`) so the term and its labels commit atomically.
|
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
|
||||||
pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<TermId, sqlx::Error> {
|
/// atomically.
|
||||||
|
pub async fn add_term(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
actor: AuditActor,
|
||||||
|
new: &NewTerm,
|
||||||
|
) -> Result<TermId, sqlx::Error> {
|
||||||
let id = TermId::new();
|
let id = TermId::new();
|
||||||
|
|
||||||
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
|
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
|
||||||
@@ -75,6 +102,18 @@ pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<Te
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit::record(
|
||||||
|
&mut *conn,
|
||||||
|
&NewAuditEvent {
|
||||||
|
actor,
|
||||||
|
action: AuditAction::Created,
|
||||||
|
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||||
|
entity_id: id.to_uuid(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use db::{Db, authority};
|
use db::{Db, authority};
|
||||||
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
|
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
||||||
@@ -24,7 +24,11 @@ async fn authority_round_trips_with_labels(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let id = authority::create_authority(&mut tx, &new_person("Carl Larsson", "Carl Larsson"))
|
let id = authority::create_authority(
|
||||||
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
|
&new_person("Carl Larsson", "Carl Larsson"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tx.commit().await.unwrap();
|
tx.commit().await.unwrap();
|
||||||
@@ -47,11 +51,12 @@ async fn list_by_kind_filters(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
authority::create_authority(&mut tx, &new_person("A", "A"))
|
authority::create_authority(&mut tx, AuditActor::System, &new_person("A", "A"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
authority::create_authority(
|
authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewAuthority {
|
&NewAuthority {
|
||||||
kind: AuthorityKind::Place,
|
kind: AuthorityKind::Place,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -83,7 +88,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let id = authority::create_authority(&mut tx, &new_person("X", "X"))
|
let id = authority::create_authority(&mut tx, AuditActor::System, &new_person("X", "X"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tx.commit().await.unwrap();
|
tx.commit().await.unwrap();
|
||||||
@@ -108,6 +113,7 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let id = authority::create_authority(
|
let id = authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewAuthority {
|
&NewAuthority {
|
||||||
kind: AuthorityKind::Organisation,
|
kind: AuthorityKind::Organisation,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use db::{Db, fields, vocab};
|
use db::{Db, fields, vocab};
|
||||||
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
|
use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
fn labels() -> Vec<LocalizedLabel> {
|
fn labels() -> Vec<LocalizedLabel> {
|
||||||
@@ -52,9 +52,11 @@ async fn text_field_round_trips(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
|
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
fields::create_field_definition(
|
fields::create_field_definition(
|
||||||
|
|||||||
@@ -95,9 +95,12 @@ async fn sets_scalar_fields_and_audits(pool: PgPool) {
|
|||||||
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let id = setup_object(&db).await;
|
let id = setup_object(&db).await;
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
define(
|
define(
|
||||||
&db,
|
&db,
|
||||||
"material",
|
"material",
|
||||||
@@ -110,6 +113,7 @@ async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let wood = vocab::add_term(
|
let wood = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewTerm {
|
&domain::NewTerm {
|
||||||
vocabulary_id: material.id,
|
vocabulary_id: material.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -180,6 +184,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let person = db::authority::create_authority(
|
let person = db::authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewAuthority {
|
&domain::NewAuthority {
|
||||||
kind: domain::AuthorityKind::Person,
|
kind: domain::AuthorityKind::Person,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -190,6 +195,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let place = db::authority::create_authority(
|
let place = db::authority::create_authority(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewAuthority {
|
&domain::NewAuthority {
|
||||||
kind: domain::AuthorityKind::Place,
|
kind: domain::AuthorityKind::Place,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -219,12 +225,14 @@ async fn authority_field_enforces_kind(pool: PgPool) {
|
|||||||
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let id = setup_object(&db).await;
|
let id = setup_object(&db).await;
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let technique = vocab::create_vocabulary(db.pool(), "technique")
|
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
define(
|
define(
|
||||||
&db,
|
&db,
|
||||||
"material",
|
"material",
|
||||||
@@ -238,6 +246,7 @@ async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
|||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let other = vocab::add_term(
|
let other = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&domain::NewTerm {
|
&domain::NewTerm {
|
||||||
vocabulary_id: technique.id,
|
vocabulary_id: technique.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use db::{Db, vocab};
|
use db::{Db, vocab};
|
||||||
use domain::{LocalizedLabel, NewTerm};
|
use domain::{AuditActor, LocalizedLabel, NewTerm};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn vocabulary_create_and_lookup(pool: PgPool) {
|
async fn vocabulary_create_and_lookup(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let found = vocab::vocabulary_by_key(db.pool(), "material")
|
let found = vocab::vocabulary_by_key(db.pool(), "material")
|
||||||
.await
|
.await
|
||||||
@@ -27,13 +29,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let term_id = vocab::add_term(
|
let term_id = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: v.id,
|
vocabulary_id: v.id,
|
||||||
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
|
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
|
||||||
@@ -76,13 +81,16 @@ async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let term_id = vocab::add_term(
|
let term_id = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: v.id,
|
vocabulary_id: v.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
@@ -103,10 +111,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let err = vocab::create_vocabulary(db.pool(), "material")
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let err = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -118,16 +130,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
|||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let technique = vocab::create_vocabulary(db.pool(), "technique")
|
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
let term_id = vocab::add_term(
|
let term_id = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: material.id,
|
vocabulary_id: material.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) {
|
|||||||
let db = Db::from_pool(pool);
|
let db = Db::from_pool(pool);
|
||||||
|
|
||||||
// a material vocabulary with a "wood" term
|
// a material vocabulary with a "wood" term
|
||||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
let mut tx = db.pool().begin().await.unwrap();
|
||||||
|
|
||||||
|
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mut tx = db.pool().begin().await.unwrap();
|
|
||||||
|
|
||||||
let wood = vocab::add_term(
|
let wood = vocab::add_term(
|
||||||
&mut tx,
|
&mut tx,
|
||||||
|
AuditActor::System,
|
||||||
&NewTerm {
|
&NewTerm {
|
||||||
vocabulary_id: material.id,
|
vocabulary_id: material.id,
|
||||||
external_uri: None,
|
external_uri: None,
|
||||||
|
|||||||
@@ -42,4 +42,29 @@ pub struct Config {
|
|||||||
/// Meilisearch index name for catalogue objects.
|
/// Meilisearch index name for catalogue objects.
|
||||||
#[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")]
|
#[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")]
|
||||||
pub meili_index: String,
|
pub meili_index: String,
|
||||||
|
|
||||||
|
/// Maximum size of the PostgreSQL connection pool.
|
||||||
|
#[arg(
|
||||||
|
long = "db-max-connections",
|
||||||
|
env = "DB_MAX_CONNECTIONS",
|
||||||
|
default_value_t = 5
|
||||||
|
)]
|
||||||
|
pub db_max_connections: u32,
|
||||||
|
|
||||||
|
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
|
||||||
|
#[arg(
|
||||||
|
long = "default-language",
|
||||||
|
env = "DEFAULT_LANGUAGE",
|
||||||
|
default_value = "sv"
|
||||||
|
)]
|
||||||
|
pub default_language: String,
|
||||||
|
|
||||||
|
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
|
||||||
|
/// this is a display hint surfaced to clients (and, later, server-side renderers).
|
||||||
|
#[arg(
|
||||||
|
long = "default-timezone",
|
||||||
|
env = "DEFAULT_TIMEZONE",
|
||||||
|
default_value = "Europe/Stockholm"
|
||||||
|
)]
|
||||||
|
pub default_timezone: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use tokio::net::TcpListener;
|
|||||||
|
|
||||||
/// Connect dependencies from `config` and serve until shutdown.
|
/// Connect dependencies from `config` and serve until shutdown.
|
||||||
pub async fn run(config: Config) -> anyhow::Result<()> {
|
pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
let db = Db::connect(&config.database_url)
|
let db = Db::connect(&config.database_url, config.db_max_connections)
|
||||||
.await
|
.await
|
||||||
.context("connecting to the database")?;
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
@@ -53,6 +53,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
app_name: config.app_name,
|
app_name: config.app_name,
|
||||||
cookie_secure: config.cookie_secure,
|
cookie_secure: config.cookie_secure,
|
||||||
search,
|
search,
|
||||||
|
default_language: config.default_language,
|
||||||
|
default_timezone: config.default_timezone,
|
||||||
};
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind(&config.bind_addr)
|
let listener = TcpListener::bind(&config.bind_addr)
|
||||||
@@ -64,6 +66,34 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
|
|||||||
serve(listener, state).await
|
serve(listener, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
|
||||||
|
/// drain in-flight requests before exiting.
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("install Ctrl-C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("shutdown signal received; draining");
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve the API on an already-bound listener (used by `run` and tests).
|
/// Serve the API on an already-bound listener (used by `run` and tests).
|
||||||
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
|
||||||
let app = build_app(state);
|
let app = build_app(state);
|
||||||
@@ -72,6 +102,7 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()>
|
|||||||
let app = app.merge(web_assets::routes());
|
let app = app.merge(web_assets::routes());
|
||||||
|
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await
|
.await
|
||||||
.context("running the HTTP server")?;
|
.context("running the HTTP server")?;
|
||||||
|
|
||||||
@@ -107,7 +138,8 @@ pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow:
|
|||||||
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
|
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let db = Db::connect(database_url)
|
// CLI one-shot: a tiny pool is plenty.
|
||||||
|
let db = Db::connect(database_url, 2)
|
||||||
.await
|
.await
|
||||||
.context("connecting to the database")?;
|
.context("connecting to the database")?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use server::Config;
|
use server::Config;
|
||||||
|
|
||||||
const CLEARED: [(&str, Option<&str>); 4] = [
|
const CLEARED: [(&str, Option<&str>); 6] = [
|
||||||
("DATABASE_URL", None),
|
("DATABASE_URL", None),
|
||||||
("BIND_ADDR", None),
|
("BIND_ADDR", None),
|
||||||
("APP_NAME", None),
|
("APP_NAME", None),
|
||||||
("SESSION_COOKIE_SECURE", None),
|
("SESSION_COOKIE_SECURE", None),
|
||||||
|
("DEFAULT_LANGUAGE", None),
|
||||||
|
("DEFAULT_TIMEZONE", None),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -17,6 +19,8 @@ fn parses_from_args_with_defaults() {
|
|||||||
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
||||||
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
||||||
assert_eq!(cfg.app_name, "Collection Management System");
|
assert_eq!(cfg.app_name, "Collection Management System");
|
||||||
|
assert_eq!(cfg.default_language, "sv");
|
||||||
|
assert_eq!(cfg.default_timezone, "Europe/Stockholm");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio::net::TcpListener;
|
|||||||
async fn serves_health_live_over_tcp() {
|
async fn serves_health_live_over_tcp() {
|
||||||
let database_url =
|
let database_url =
|
||||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
|
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
|
||||||
let db = Db::connect(&database_url)
|
let db = Db::connect(&database_url, 2)
|
||||||
.await
|
.await
|
||||||
.expect("connect to database");
|
.expect("connect to database");
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
@@ -17,6 +17,8 @@ async fn serves_health_live_over_tcp() {
|
|||||||
app_name: "Test".to_string(),
|
app_name: "Test".to_string(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
search: None,
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Tier 4 Hardening — Batch 1 (#1, #2, #21) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** The mechanical, well-specified hardening items — graceful HTTP shutdown (#1), configurable DB pool size (#2), and audit logging for vocabulary/term/authority creation (#21). (The design-heavy Tier 4 items #20/#5/#7 are handled separately.)
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum 0.8, sqlx, tokio, anyhow). Backend-only.
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy `-D warnings`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey` (`#[sqlx::test]` provisions its own DB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: #1 — graceful shutdown
|
||||||
|
|
||||||
|
**Files:** `crates/server/src/lib.rs`, `crates/server/Cargo.toml` (tokio `signal` feature if missing).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ensure tokio `signal` feature.** Check `crates/server/Cargo.toml`'s `tokio` dependency features include `"signal"`. If the workspace `tokio` is `features = ["full"]` it's already included; otherwise add `"signal"` (and `"macros"`/`"rt-multi-thread"` if not already). Verify with `cargo build -p server`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a shutdown-signal future** in `crates/server/src/lib.rs` (above `serve`):
|
||||||
|
```rust
|
||||||
|
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
|
||||||
|
/// drain in-flight requests before exiting.
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("install Ctrl-C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("shutdown signal received; draining");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire it into `serve`.** Change the `axum::serve(...)` call:
|
||||||
|
```rust
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.context("running the HTTP server")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy -p server --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server` (the existing `serve.rs` smoke test still passes — it aborts the handle, which is unaffected). Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/server
|
||||||
|
git commit -m "feat(server): graceful shutdown on SIGINT/SIGTERM (#1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: #2 — configurable DB pool size
|
||||||
|
|
||||||
|
**Files:** `crates/db/src/lib.rs`, `crates/server/src/config.rs`, `crates/server/src/lib.rs`.
|
||||||
|
|
||||||
|
`Db::connect` currently hardcodes `.max_connections(5)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Parameterize `Db::connect`.** In `crates/db/src/lib.rs`:
|
||||||
|
```rust
|
||||||
|
/// Connect to the database at `database_url`, opening a connection pool with at most
|
||||||
|
/// `max_connections` connections.
|
||||||
|
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the config knob.** In `crates/server/src/config.rs`, add a field to `Config`:
|
||||||
|
```rust
|
||||||
|
/// Maximum size of the PostgreSQL connection pool.
|
||||||
|
#[arg(long = "db-max-connections", env = "DB_MAX_CONNECTIONS", default_value_t = 5)]
|
||||||
|
pub db_max_connections: u32,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Thread it through the two `Db::connect` call sites** in `crates/server/src/lib.rs`:
|
||||||
|
- In `run`: `Db::connect(&config.database_url, config.db_max_connections)`.
|
||||||
|
- In `create_user` (the CLI one-shot — it has only `database_url: &str`, no `Config`): pass a small fixed default, `Db::connect(database_url, 2)` (a one-shot CLI needs minimal connections), and add a brief comment.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server`. Confirm `cargo run -p server -- --help` shows the new `--db-max-connections` flag (optional). Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/db crates/server
|
||||||
|
git commit -m "feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: #21 — audit vocabulary/term/authority creation
|
||||||
|
|
||||||
|
**Files:** `crates/db/src/vocab.rs`, `crates/db/src/authority.rs`, `crates/api/src/admin_vocab.rs`, `crates/api/src/admin_authorities.rs`; Test in `crates/api/tests/admin_catalog.rs`.
|
||||||
|
|
||||||
|
The three admin create paths (`create_vocabulary`, `add_term`, `create_authority`) take no `AuditActor` and write no audit entry. The catalogue object writes do — **`db::catalog::create_object` is the template**: it takes `actor: AuditActor` and calls `audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type, entity_id, ... })` inside the same transaction. READ `create_object` (`crates/db/src/catalog.rs`) and `audit::record` / `NewAuditEvent` (`crates/db/src/audit.rs`, `domain::NewAuditEvent`) first to copy the exact shape.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `actor` + audit to the db functions.** Each must run the insert **and** the audit record in one transaction (so they're atomic), mirroring `create_object`:
|
||||||
|
- `db::vocab::create_vocabulary` — currently `(executor: E, key: &str)`. Change to `(conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str)` (tx-connection like `add_term`), insert the vocabulary, then `audit::record(&mut *conn, &NewAuditEvent { actor, action: Created, entity_type: "vocabulary", entity_id: <new vocab id>, ... })`. Return the `Vocabulary` as before.
|
||||||
|
- `db::vocab::add_term` — currently `(conn: &mut PgConnection, new: &NewTerm)`. Add `actor: AuditActor`; after inserting the term, record an audit entry (`entity_type: "term"`, `entity_id: <term id>`).
|
||||||
|
- `db::authority::create_authority` — add `actor: AuditActor`; record (`entity_type: "authority"`, `entity_id: <authority id>`).
|
||||||
|
Match `create_object`'s `NewAuditEvent` field names exactly (e.g. `changes`/`metadata` may be empty/None — copy whatever `create_object` passes for a creation with no field diff).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Thread the actor through the handlers.** In `crates/api/src/admin_vocab.rs` (`create_vocabulary`, `add_term`) and `crates/api/src/admin_authorities.rs` (`create_authority`):
|
||||||
|
- Change `_auth: Authorized<EditCatalogue>` → `auth: Authorized<EditCatalogue>`.
|
||||||
|
- Build the actor as the object handlers do: `AuditActor::User(auth.user.id.to_uuid())`. To avoid duplicating the helper, either make `admin_objects::actor` `pub(crate)` and import it, or inline `AuditActor::User(auth.user.id.to_uuid())` at each site (it's a one-liner — pick the cleaner option; if you make the helper shared, take `&AuthUser`).
|
||||||
|
- `create_vocabulary` handler currently calls `db::vocab::create_vocabulary(state.db.pool(), &req.key)` on the **pool** — change it to open a transaction (`let mut tx = state.db.pool().begin().await...`), call the new `create_vocabulary(&mut tx, actor, &req.key)`, then `tx.commit()` (like `add_term`'s handler already does). `add_term`/`create_authority` handlers already use a tx — just pass the actor.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test** — add to `crates/api/tests/admin_catalog.rs` (it already seeds an editor + logs in). After creating a vocabulary (or term/authority) via the API, assert an audit row exists attributing the user. Use `db::audit::history_for` (or a direct `SELECT` on `audit_log`) to find the entry — read the file for how existing tests inspect audit rows (the object tests likely already do this; mirror them). Minimal: create a vocabulary, then query `audit_log` for `entity_type='vocabulary'` with the created id and assert `actor_kind='user'` + the right `actor_id`. Name it e.g. `creating_a_vocabulary_writes_an_audit_entry`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db`. All green. Commit:
|
||||||
|
```bash
|
||||||
|
git add crates/db crates/api
|
||||||
|
git commit -m "feat: audit vocabulary/term/authority creation, attributing the acting user (#21)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace` — all green.
|
||||||
|
- [ ] **Step 2:** `cargo clippy --workspace --all-targets` and `cargo +nightly fmt --check` — clean.
|
||||||
|
- [ ] **Step 3:** `git grep -in 'biggus\|dickus' -- crates` → none.
|
||||||
|
- [ ] **Step 4:** Confirm `Cargo.lock` is committed if any dependency/feature changed (e.g. tokio `signal` feature does not add a new lockfile entry, but verify `git status` is clean after the commits — no dangling `M Cargo.lock`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** #1 (graceful shutdown) → T1; #2 (configurable pool) → T2; #21 (audit 3 admin creates) → T3. ✓
|
||||||
|
- **Placeholder scan:** none — concrete code for #1/#2; #21 points at `create_object`/`audit::record` as the exact template to mirror (the audit-event field names live there and must match, so copying beats guessing).
|
||||||
|
- **Type consistency:** `Db::connect(url, max: u32)` updated at both call sites (run + create_user); `db_max_connections: u32` matches `max_connections(u32)`; the three db create fns gain `actor: AuditActor` and the handlers pass `AuditActor::User(auth.user.id.to_uuid())` consistently with `admin_objects::actor`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- #21 keeps within the current audit model (`AuditAction::Created` + non-null `entity_type`/`entity_id`) — no schema change needed (the auth-event model extension is the separate #7).
|
||||||
|
- Watch the `Cargo.lock`: if the tokio `signal` feature pulls a new transitive crate, stage the root `Cargo.lock` in the same commit (don't leave it dangling).
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# Instance Locale + Single-Language Content Authoring Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||||
|
|
||||||
|
**Goal:** Drive instance UI/content language + display timezone from environment variables (no settings table), surface them to the SPA via a public `GET /api/config`, default the UI language from it, and collapse content authoring (`LabelEditor` + `LocalizedText` field input) to a single language — **without touching the multilingual content schema** (dormant, re-enabled by UI alone).
|
||||||
|
|
||||||
|
**Architecture:** Two `server::Config` env knobs (`DEFAULT_LANGUAGE`, `DEFAULT_TIMEZONE`) flow into `AppState` and a public `ConfigView` endpoint. A frontend `ConfigProvider` fetches it once, sets the i18n language (when no per-browser override), and feeds the default language to the simplified content inputs. Storage stays UTC; timezone is exposed but has no frontend formatter yet (no timestamp displays exist — deferred to its first consumer).
|
||||||
|
|
||||||
|
**Tech Stack:** Rust (axum, utoipa, clap), React + TS, react-i18next, TanStack Query, Vitest + RTL + MSW.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-05-instance-locale-and-content-authoring-design.md`
|
||||||
|
|
||||||
|
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — config knobs + `AppState` + public `GET /api/config` + regen client
|
||||||
|
|
||||||
|
**Files:** Modify `crates/server/src/config.rs`, `crates/server/src/lib.rs`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; Create `crates/api/src/config.rs`; Modify all `AppState { … }` construction sites (server + api test harnesses); Test `crates/api/tests/config.rs`; Regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Config knobs.** In `crates/server/src/config.rs`, add to `Config` (clap derive, matching `app_name`'s style):
|
||||||
|
```rust
|
||||||
|
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
|
||||||
|
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
|
||||||
|
pub default_language: String,
|
||||||
|
|
||||||
|
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
|
||||||
|
/// this is a display hint surfaced to clients (and, later, server-side renderers).
|
||||||
|
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
|
||||||
|
pub default_timezone: String,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `AppState` fields.** In `crates/api/src/lib.rs`, add to `pub struct AppState`:
|
||||||
|
```rust
|
||||||
|
/// Instance default UI/content language (from config).
|
||||||
|
pub default_language: String,
|
||||||
|
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
|
||||||
|
pub default_timezone: String,
|
||||||
|
```
|
||||||
|
In `crates/server/src/lib.rs` `run`, populate them when building `AppState`:
|
||||||
|
```rust
|
||||||
|
default_language: config.default_language,
|
||||||
|
default_timezone: config.default_timezone,
|
||||||
|
```
|
||||||
|
(place after `app_name: config.app_name,` — note these are moves; `config` fields are disjoint.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update every other `AppState { … }` site.** Run `grep -rn "AppState {" crates/` — besides `crates/api/src/lib.rs` (the struct def) and `server/src/lib.rs` (done above), there are ~9 test `state(...)` helpers (`crates/server/tests/serve.rs`, `crates/api/tests/{admin,admin_objects,admin_search,public,reindex,admin_catalog,admin_fields,health}.rs`). Add to each literal:
|
||||||
|
```rust
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
```
|
||||||
|
(The build will fail to compile until all are updated — that's the checklist.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the failing API test** — create `crates/api/tests/config.rs`:
|
||||||
|
```rust
|
||||||
|
use api::{AppState, build_app};
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
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 Museum".into(),
|
||||||
|
cookie_secure: false,
|
||||||
|
search: None,
|
||||||
|
default_language: "sv".into(),
|
||||||
|
default_timezone: "Europe/Stockholm".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../db/migrations")]
|
||||||
|
async fn config_is_public_and_reflects_state(pool: PgPool) {
|
||||||
|
let app = build_app(state(pool));
|
||||||
|
let resp = app
|
||||||
|
.oneshot(Request::builder().uri("/api/config").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||||
|
assert_eq!(body["app_name"], "Test Museum");
|
||||||
|
assert_eq!(body["default_language"], "sv");
|
||||||
|
assert_eq!(body["default_timezone"], "Europe/Stockholm");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run → fails** (`/api/config` 404): `cargo test -p api --test config`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement the endpoint** — create `crates/api/src/config.rs` (mirror `health.rs`):
|
||||||
|
```rust
|
||||||
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Public, non-sensitive instance configuration the SPA needs before login.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct ConfigView {
|
||||||
|
/// User-facing product name.
|
||||||
|
pub app_name: String,
|
||||||
|
/// Default UI/content language (i18n key, e.g. "sv").
|
||||||
|
pub default_language: String,
|
||||||
|
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
|
||||||
|
pub default_timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
|
||||||
|
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
|
||||||
|
Json(ConfigView {
|
||||||
|
app_name: state.app_name.clone(),
|
||||||
|
default_language: state.default_language.clone(),
|
||||||
|
default_timezone: state.default_timezone.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
|
Router::new().route("/api/config", get(get_config))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Register the module + route + schema.**
|
||||||
|
- `crates/api/src/lib.rs`: add `mod config;` (alphabetical with other `mod`s) and `.merge(config::routes())` in `build_app` (next to `health::routes()`).
|
||||||
|
- `crates/api/src/openapi.rs`: add `config` to the `use crate::{…}` import; add `config::get_config` to `paths(…)`; add `config::ConfigView` to `components(schemas(…))`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run → passes.** `cargo test -p api --test config`, then `cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, and full `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo test -p api -p server` (the AppState field additions compile everywhere).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Regenerate the typed client.**
|
||||||
|
```bash
|
||||||
|
cargo build -p server
|
||||||
|
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 2
|
||||||
|
( cd web && pnpm gen:api )
|
||||||
|
kill "$SERVER_PID"
|
||||||
|
grep -n "ConfigView\|api/config" web/src/api/schema.d.ts
|
||||||
|
```
|
||||||
|
Both must appear; diff additive. `cd web && pnpm typecheck` clean.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit.**
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add crates/server crates/api web/src/api/schema.d.ts
|
||||||
|
git commit -m "feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend — config provider + i18n default wiring
|
||||||
|
|
||||||
|
**Files:** Create `web/src/config/config-context.tsx`; Modify `web/src/main.tsx`, `web/src/test/handlers.ts`; Test `web/src/config/config-context.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: MSW handler.** In `web/src/test/handlers.ts`, add to the `handlers` array a default config response:
|
||||||
|
```ts
|
||||||
|
http.get("/api/config", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
app_name: "Test Museum",
|
||||||
|
default_language: "sv",
|
||||||
|
default_timezone: "Europe/Stockholm",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Failing provider test** — create `web/src/config/config-context.test.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { expect, test, beforeEach } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
import { LOCALE_KEY } from "../i18n";
|
||||||
|
import { ConfigProvider, useConfig } from "./config-context";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const config = useConfig();
|
||||||
|
return <span data-testid="lang">{config.default_language}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ConfigProvider><Probe /></ConfigProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes config and applies default language when no stored preference", async () => {
|
||||||
|
renderProvider();
|
||||||
|
expect(await screen.findByText("sv")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(i18n.language).toBe("sv"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stored locale preference wins over the instance default", async () => {
|
||||||
|
localStorage.setItem(LOCALE_KEY, "en");
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
renderProvider();
|
||||||
|
await screen.findByText("sv"); // config still loads
|
||||||
|
await waitFor(() => expect(i18n.language).toBe("en")); // but language stays en
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run → fails** (module missing): `cd web && pnpm test src/config/config-context.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the provider** — create `web/src/config/config-context.tsx`:
|
||||||
|
```tsx
|
||||||
|
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import i18n, { LOCALE_KEY } from "../i18n";
|
||||||
|
|
||||||
|
type ConfigView = components["schemas"]["ConfigView"];
|
||||||
|
|
||||||
|
const DEFAULTS: ConfigView = {
|
||||||
|
app_name: "Collection Management System",
|
||||||
|
default_language: "sv",
|
||||||
|
default_timezone: "Europe/Stockholm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConfigContext = createContext<ConfigView>(DEFAULTS);
|
||||||
|
|
||||||
|
export function useConfig(): ConfigView {
|
||||||
|
return useContext(ConfigContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async (): Promise<ConfigView> => {
|
||||||
|
const { data, error } = await api.GET("/api/config");
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load config");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default the UI language to the instance default, unless the user has chosen one
|
||||||
|
// for this browser (LangSwitch persists to localStorage[LOCALE_KEY]).
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !localStorage.getItem(LOCALE_KEY)) {
|
||||||
|
void i18n.changeLanguage(data.default_language);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run → passes.** `pnpm test src/config/config-context.test.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Mount the provider.** In `web/src/main.tsx`, wrap `<App />` (inside `QueryClientProvider`, since the provider uses TanStack Query):
|
||||||
|
```tsx
|
||||||
|
import { ConfigProvider } from "./config/config-context";
|
||||||
|
// ...
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigProvider>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify + commit.** `pnpm test && pnpm typecheck && pnpm lint && pnpm build`. All green (existing tests unaffected — MSW now answers `/api/config` so `onUnhandledRequest:"error"` stays happy app-wide).
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): config provider — fetch /api/config, default UI language from instance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — single-language content authoring
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/components/label-editor.tsx`, `web/src/objects/field-input.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/components/label-editor.test.tsx`, `web/src/vocab/vocabularies.test.tsx`, `web/src/fields/fields.test.tsx`, `web/src/authorities/authorities.test.tsx`.
|
||||||
|
|
||||||
|
> The content schema, DTOs (`LabelInput`/`LabelView`), DB tables, `LocalizedLabel`, and `FieldType::LocalizedText` are **unchanged**. Only the input components collapse to one language. Reading/display (`labelText`/`pick_label`) already falls back (UI lang → en → first), so single-language data still renders — no change to the read path.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n key.** Add `labels.label` to BOTH `web/src/i18n/en.json` and `sv.json`:
|
||||||
|
- en `labels`: `"label": "Label"`
|
||||||
|
- sv `labels`: `"label": "Etikett"`
|
||||||
|
(Keep the existing `labels.en`/`labels.sv`/`labels.externalUri` keys — `externalUri` is still used; `labels.en`/`labels.sv` may become unused after this task — if `pnpm lint`/grep shows them unreferenced, remove them from BOTH files to keep parity, else leave.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Collapse `LabelEditor`** — replace `web/src/components/label-editor.tsx` body:
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
/** Single-language label editor. Authors one label at the instance default language;
|
||||||
|
* emits a one-entry LabelInput[] (empty array when blank). The multilingual data model
|
||||||
|
* is unchanged — this only simplifies authoring. */
|
||||||
|
export function LabelEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: LabelInput[];
|
||||||
|
onChange: (labels: LabelInput[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { default_language } = useConfig();
|
||||||
|
|
||||||
|
const current =
|
||||||
|
value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";
|
||||||
|
|
||||||
|
const set = (label: string) =>
|
||||||
|
onChange(label.trim() ? [{ lang: default_language, label }] : []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||||
|
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `LabelEditor`'s own test** — `web/src/components/label-editor.test.tsx` currently types into `/label \(en\)/i` + `/label \(sv\)/i` and asserts both langs. Rewrite it for the single input (it must render under a `ConfigProvider` so `useConfig` works — wrap with the test's existing `renderApp`/provider, adding `ConfigProvider`; the MSW `/api/config` handler returns `default_language: "sv"`). New test:
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ConfigProvider } from "../config/config-context";
|
||||||
|
import { LabelEditor } from "./label-editor";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||||
|
const [value, setValue] = useState<LabelInput[]>([]);
|
||||||
|
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("emits a single label at the instance default language", async () => {
|
||||||
|
const seen: LabelInput[][] = [];
|
||||||
|
renderApp(<ConfigProvider><Harness onChange={(v) => seen.push(v)} /></ConfigProvider>);
|
||||||
|
// config (default_language "sv") must load before the editor authors
|
||||||
|
await screen.findByLabelText(/^label$/i);
|
||||||
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = seen[seen.length - 1]!;
|
||||||
|
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
NOTE: if `renderApp` doesn't already provide a `QueryClientProvider` that `ConfigProvider` needs, check `web/src/test/render.tsx` — it does wrap `QueryClientProvider` (the vocab/search tests rely on it). The MSW `/api/config` default handler (Task 2) supplies the config.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the consumer tests.** The forms that use `LabelEditor` have tests typing into `/label \(en\)/i`. They now render a single `/^label$/i` input writing `sv`. Update each:
|
||||||
|
- `web/src/vocab/vocabularies.test.tsx:48` — `getByLabelText(/label \(en\)/i)` → `getByLabelText(/^label$/i)`. These tests render the full app/route tree which must include `ConfigProvider` for `useConfig` — check `renderApp`/the test tree; if the tree doesn't wrap `ConfigProvider`, wrap the rendered subtree in `<ConfigProvider>` (the MSW `/api/config` handler answers). Adjust any assertion expecting an EN/SV pair to the single `sv` label.
|
||||||
|
- `web/src/fields/fields.test.tsx` (3 sites: lines ~38, ~58, ~79) — same `getByLabelText(/^label$/i)` swap + wrap `ConfigProvider` if needed.
|
||||||
|
- `web/src/authorities/authorities.test.tsx:28` — same.
|
||||||
|
Run each file and fix selector/provider issues until green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Collapse the `LocalizedText` field input** — in `web/src/objects/field-input.tsx`, the `case "localized_text":` block renders `${key}.en` + `${key}.sv` inputs. Replace with a single input registering `${key}.${default_language}`. Add `const { default_language } = useConfig();` near the top of the `FieldInput` component (alongside the existing `const lang = …`). New case:
|
||||||
|
```tsx
|
||||||
|
case "localized_text":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
<Input
|
||||||
|
id={definition.key}
|
||||||
|
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
|
||||||
|
required: definition.required,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
(Imports: `useConfig` from `../config/config-context`.) The stored value remains a `{ lang: text }` map — now `{ [default_language]: text }`. The `field-input.test.tsx` may reference the EN/SV localized inputs — update it to the single input (register path `${key}.${default_language}`), wrapping with `ConfigProvider` if the test renders the component directly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify + commit.** `cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. en/sv parity holds.
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): single-language content authoring (LabelEditor + localized_text at default lang)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n parity** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||||
|
```
|
||||||
|
Expected `PARITY OK`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Frontend** — `pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Backend** —
|
||||||
|
```bash
|
||||||
|
cd /Users/olsson/Laboratory/biggus-dickus
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo +nightly fmt --check
|
||||||
|
```
|
||||||
|
All pass; clippy + fmt clean.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Acceptance spot-checks.**
|
||||||
|
- `cargo run -p server -- --help | grep -E "default-language|default-timezone"` shows both flags.
|
||||||
|
- Content schema untouched: `git diff main..HEAD -- crates/db/migrations crates/domain/src/label.rs` is empty (no schema/domain label changes).
|
||||||
|
- `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
- **Spec coverage:** env knobs + AppState → T1; public `/api/config` → T1; config provider + i18n default → T2; single-language `LabelEditor` + `LocalizedText` → T3; UTC storage unchanged (no timestamp code touched); timezone exposed (no formatter — no consumer, per spec's "forward-ready if none"); parity/bundle → T4. ✓ Per-account UI language + da/no + server-side tz are out of scope (issue #40 / #39). ✓
|
||||||
|
- **Placeholder scan:** none — concrete code; the "wrap ConfigProvider if the test tree doesn't already" notes are real verification steps against named files (the provider dependency is new, so tests that mount label-authoring components need it).
|
||||||
|
- **Type consistency:** `ConfigView { app_name, default_language, default_timezone }` is the single shape across the Rust struct, the `components["schemas"]["ConfigView"]` TS type, the provider `DEFAULTS`, and the MSW handler; `LabelEditor` still emits `LabelInput[]` (one entry); `default_language` threaded from `useConfig()` consistently in both the editor and the field input.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- **Timezone has no frontend consumer yet** (no timestamp is displayed — only `recording_date`, a plain DATE). The value is exposed via `/api/config` + `useConfig` so PDF export (#39) and any future audit/timestamp view can format with it; building a `formatTimestamp` helper now would be unused (YAGNI).
|
||||||
|
- **`AppState` gained two fields** → every `AppState { … }` literal (incl. all api/server test harnesses) must add them or the workspace won't compile; Task 1 Step 3 enumerates this.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Instance Locale (env-driven) + Single-Language Content Authoring — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The app is **Sweden-first** but the UI must stay translatable (English now; Danish/
|
||||||
|
Norwegian conceivable later). A question was raised: do we need the **content-level
|
||||||
|
multilingual machinery** (the `lang`-keyed label tables + `LocalizedText` field type), or
|
||||||
|
should we remove it to simplify the codebase?
|
||||||
|
|
||||||
|
### The content-translation decision (keep the schema; simplify only the inputs)
|
||||||
|
|
||||||
|
Two different things are both called "translation":
|
||||||
|
- **UI translation** — react-i18next app chrome (sv/en JSON, `LangSwitch`, localStorage
|
||||||
|
persistence). Stays and grows.
|
||||||
|
- **Content multilingualism** — the *data*: `domain::LocalizedLabel { lang, label }`, the
|
||||||
|
`FieldType::LocalizedText` flexible-field type, and three DB tables keyed by
|
||||||
|
`(parent_id, lang)`: `term_label`, `authority_label`, `field_definition_label`.
|
||||||
|
|
||||||
|
**Decision: keep the content schema; simplify only the authoring UI** (brainstorm option
|
||||||
|
A + the migration-risk analysis). Rationale:
|
||||||
|
- The expensive, hard-to-reverse part is the **database schema**. Removing it and adding it
|
||||||
|
back later means new migrations, backfilling every row with a language tag, rewriting
|
||||||
|
every db read/write (`vocab`/`authority`/`fields`), changing API DTOs, swapping the
|
||||||
|
frontend editor, and regenerating the typed client — a domain→db→api→web cross-cutting
|
||||||
|
refactor. That is the exact pain to avoid.
|
||||||
|
- The cheap, reversible part is the **UI** (one input vs two). Keeping the schema is nearly
|
||||||
|
free: a Sweden-only instance just stores `lang = <default>` rows; re-enabling multilingual
|
||||||
|
authoring later is "show the second input again" — **zero migration**.
|
||||||
|
- The machinery is already built, tested, and merged (M2/M4). Removing it is *also* work +
|
||||||
|
risk. Museum cataloguing commonly needs bilingual descriptive metadata
|
||||||
|
(Spectrum/Europeana/loans), so "Sweden only" is the assumption most likely to change.
|
||||||
|
|
||||||
|
So this milestone **keeps all multilingual capacity dormant in the schema** and **collapses
|
||||||
|
the authoring inputs to a single language** (the instance default).
|
||||||
|
|
||||||
|
### Instance locale via environment variables (no settings table/page)
|
||||||
|
|
||||||
|
The app is **single-tenant** (no org/tenant table today — tables: object, audit_log,
|
||||||
|
field_definition[+label], vocabulary, term[+label], authority[+label], app_user). The
|
||||||
|
instance language and timezone are set-and-forget per deployment and never change at
|
||||||
|
runtime, so they are **environment variables**, not a database settings table or an admin
|
||||||
|
settings page. (If multi-org ever lands, this becomes per-org via a future migration — out
|
||||||
|
of scope now.)
|
||||||
|
|
||||||
|
### Timezone: always store UTC
|
||||||
|
|
||||||
|
Timestamps are already `TIMESTAMPTZ` (UTC); `recording_date` is a plain `DATE`. **Storage and
|
||||||
|
transmission stay UTC** — the API never localizes timestamps. The instance timezone is a
|
||||||
|
**display/formatting** concern only:
|
||||||
|
- **Interactive UI** → the frontend formats UTC → instance tz via `Intl.DateTimeFormat`.
|
||||||
|
- **Server-rendered artifacts** (PDF export #39, future reports/CLI) → the backend will need
|
||||||
|
the tz to format without a browser. That server-side formatting is **owned by #39**; this
|
||||||
|
milestone only *stores and exposes* the tz value.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
1. Two backend config knobs: `default_language` (`DEFAULT_LANGUAGE`, default `sv`) and
|
||||||
|
`default_timezone` (`DEFAULT_TIMEZONE`, default `Europe/Stockholm`), in `server::Config`
|
||||||
|
and `AppState`.
|
||||||
|
2. A public `GET /api/config` endpoint exposing `{ app_name, default_language,
|
||||||
|
default_timezone }`.
|
||||||
|
3. Frontend config provider: fetch `/api/config` on boot; apply `default_language` to
|
||||||
|
i18n (when no stored preference); expose the values via context.
|
||||||
|
4. Single-language content authoring: collapse `LabelEditor` and the `LocalizedText` field
|
||||||
|
input to one input writing at `default_language`. Schema/DTOs unchanged.
|
||||||
|
|
||||||
|
**Out (deferred / owned elsewhere):**
|
||||||
|
- **Per-account UI language** (cross-device persistence: a `language` column on `app_user`,
|
||||||
|
returned at login, SPA inits from it) → file as a follow-up issue. (Per-*browser*
|
||||||
|
persistence already exists via `LangSwitch` + localStorage.)
|
||||||
|
- Danish/Norwegian UI translations (just more i18n JSON when wanted).
|
||||||
|
- Server-side timezone formatting → PDF export (#39).
|
||||||
|
- A real org-settings page → only if multi-org lands.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Config (`crates/server/src/config.rs`)
|
||||||
|
Add to `Config` (clap derive, matching `app_name`):
|
||||||
|
```rust
|
||||||
|
/// Default UI + content-authoring language for this instance (BCP-47 / i18n key, e.g. "sv").
|
||||||
|
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
|
||||||
|
pub default_language: String,
|
||||||
|
|
||||||
|
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
|
||||||
|
/// this is a display hint surfaced to clients and (later) server-side renderers.
|
||||||
|
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
|
||||||
|
pub default_timezone: String,
|
||||||
|
```
|
||||||
|
The timezone is treated as an opaque string (no backend tz library; validated client-side
|
||||||
|
by `Intl`). Thread both into `AppState` (alongside `app_name`).
|
||||||
|
|
||||||
|
### `AppState` (`crates/api/src/lib.rs`)
|
||||||
|
Add `pub default_language: String` and `pub default_timezone: String`. `server::run` populates
|
||||||
|
them from `Config`.
|
||||||
|
|
||||||
|
### Config endpoint (`crates/api/src/` — e.g. a small `config.rs` or in `health`/`public`)
|
||||||
|
```
|
||||||
|
GET /api/config (unauthenticated)
|
||||||
|
→ 200 { "app_name": String, "default_language": String, "default_timezone": String }
|
||||||
|
```
|
||||||
|
`#[derive(Serialize, ToSchema)]` `ConfigView`, registered in `openapi.rs`. Unauthenticated
|
||||||
|
because the SPA needs it before login (so the login page renders in the instance language).
|
||||||
|
No secrets are exposed. Regenerate `web/src/api/schema.d.ts`.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Config provider (`web/src/`)
|
||||||
|
- A `useConfig` query/hook fetching `GET /api/config` once on boot (TanStack Query, long
|
||||||
|
`staleTime`), exposing `{ app_name, default_language, default_timezone }` via context.
|
||||||
|
- **Language:** i18n still inits synchronously with a safe fallback. After config loads, if
|
||||||
|
there is **no** `localStorage[LOCALE_KEY]` preference, call
|
||||||
|
`i18n.changeLanguage(config.default_language)`. The existing `LangSwitch` + localStorage
|
||||||
|
override path is unchanged. (Net effect: a fresh browser defaults to the instance language;
|
||||||
|
a user who has switched keeps their choice.)
|
||||||
|
- **Timezone:** add a small `formatTimestamp(utc, config.default_timezone, locale)` helper
|
||||||
|
using `Intl.DateTimeFormat(locale, { timeZone })`. Use it wherever timestamps are rendered.
|
||||||
|
(During planning, audit the current UI for timestamp surfaces; if there are none yet, the
|
||||||
|
helper + config value are forward-ready for PDF #39 / a future audit screen — do not
|
||||||
|
invent displays.)
|
||||||
|
|
||||||
|
### Single-language content authoring (`web/src/components/label-editor.tsx`, the `LocalizedText` field input in `web/src/objects/field-input.tsx`)
|
||||||
|
- **`LabelEditor`:** replace the EN+SV pair with a **single** labelled text input. `onChange`
|
||||||
|
emits `[{ lang: config.default_language, label }]` (omit empty). The "EN required / SV
|
||||||
|
optional" rule collapses to "the single label is required."
|
||||||
|
- **`LocalizedText` field input:** replace the per-language inputs with a single textbox;
|
||||||
|
the value written is `{ [config.default_language]: text }`.
|
||||||
|
- **Reading:** `labelText` / `domain::pick_label` already pick a language with fallback; set
|
||||||
|
the preferred lang to `config.default_language` (then English, then first) so existing
|
||||||
|
multilingual data (e.g. seeded) still displays.
|
||||||
|
- **Unchanged:** `LabelInput`/`LabelView` DTOs, the three `*_label` tables, `LocalizedLabel`,
|
||||||
|
`FieldType::LocalizedText`, and all db read/write paths. Only the input components change.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
Boot → SPA fetches `/api/config` → context holds `{ app_name, default_language,
|
||||||
|
default_timezone }` → i18n language set to `default_language` (unless overridden in
|
||||||
|
localStorage) → content editors author one label at `default_language` (stored as a
|
||||||
|
single-entry `[{lang,label}]`) → timestamps render via `Intl` in `default_timezone`.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
- `/api/config` is static, env-derived — it cannot fail at the DB level (no query). If the
|
||||||
|
fetch fails (network), the SPA falls back to the synchronous i18n default and a sensible
|
||||||
|
built-in timezone (`"Europe/Stockholm"`), and retries via TanStack Query.
|
||||||
|
- Empty/invalid timezone: the frontend `Intl` call throws for an invalid IANA name; the
|
||||||
|
helper catches and falls back to UTC display rather than crashing.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- **Backend:** `GET /api/config` returns the configured values; defaults applied when env
|
||||||
|
unset; endpoint is reachable unauthenticated. `Config` parses `DEFAULT_LANGUAGE` /
|
||||||
|
`DEFAULT_TIMEZONE` (+ `--default-language` / `--default-timezone`).
|
||||||
|
- **Frontend (Vitest + RTL + MSW):** with a config of `{ default_language: "sv" }` and no
|
||||||
|
stored locale, i18n switches to `sv`; with a stored `en`, it stays `en`. `LabelEditor`
|
||||||
|
renders one input and emits `[{ lang: "sv", label }]`. A `LocalizedText` field input
|
||||||
|
emits `{ sv: text }`. Existing screens that read labels still render.
|
||||||
|
- en/sv i18n key parity; no `any`/`eslint-disable`; codename ban; bundle ≤150 KB gz.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
1. `DEFAULT_LANGUAGE` / `DEFAULT_TIMEZONE` env vars (with CLI flags + defaults `sv` /
|
||||||
|
`Europe/Stockholm`) drive instance locale; no settings table or admin page.
|
||||||
|
2. `GET /api/config` (public) returns `app_name` + `default_language` + `default_timezone`;
|
||||||
|
in `schema.d.ts`.
|
||||||
|
3. The SPA defaults its UI language to the instance default (overridable per-browser via the
|
||||||
|
existing `LangSwitch`/localStorage).
|
||||||
|
4. Content authoring is single-language: `LabelEditor` and `LocalizedText` inputs collapse to
|
||||||
|
one field writing at the default language; **the content schema/DTOs/tables are unchanged**
|
||||||
|
(multilingual capacity remains dormant, re-enabled by UI alone).
|
||||||
|
5. Timestamps render in the instance timezone via `Intl` where displayed; storage stays UTC.
|
||||||
|
6. CI green (cargo + web typecheck/lint/test/build, bundle ≤150 KB); en/sv parity.
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
- Per-account UI language (cross-device) — separate issue (filed with this milestone).
|
||||||
|
- Danish/Norwegian UI locales; server-side tz formatting (#39); org-level settings page.
|
||||||
Vendored
+44
@@ -238,6 +238,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["get_config"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/public/objects": {
|
"/api/public/objects": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -355,6 +371,15 @@ export interface components {
|
|||||||
kind: components["schemas"]["AuthorityKind"];
|
kind: components["schemas"]["AuthorityKind"];
|
||||||
labels: components["schemas"]["LabelView"][];
|
labels: components["schemas"]["LabelView"][];
|
||||||
};
|
};
|
||||||
|
/** @description Public, non-sensitive instance configuration the SPA needs before login. */
|
||||||
|
ConfigView: {
|
||||||
|
/** @description User-facing product name. */
|
||||||
|
app_name: string;
|
||||||
|
/** @description Default UI/content language (i18n key, e.g. "sv"). */
|
||||||
|
default_language: string;
|
||||||
|
/** @description Default display timezone (IANA name). Storage is UTC; this is a display hint. */
|
||||||
|
default_timezone: string;
|
||||||
|
};
|
||||||
CreatedField: {
|
CreatedField: {
|
||||||
key: string;
|
key: string;
|
||||||
};
|
};
|
||||||
@@ -1308,6 +1333,25 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_config: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ConfigView"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_objects: {
|
list_objects: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function AuthoritiesPage() {
|
|||||||
const onCreate = (event: FormEvent) => {
|
const onCreate = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!labels.some((l) => l.lang === "en" && l.label)) {
|
if (!labels.some((l) => l.label)) {
|
||||||
setError(true);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test("lists authorities for the kind and creates one", async () => {
|
|||||||
);
|
);
|
||||||
renderApp(tree(), { route: "/authorities/person" });
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Carl von Linné");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
||||||
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { LabelEditor } from "./label-editor";
|
import { LabelEditor } from "./label-editor";
|
||||||
@@ -10,28 +10,24 @@ type LabelInput = components["schemas"]["LabelInput"];
|
|||||||
|
|
||||||
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||||
const [value, setValue] = useState<LabelInput[]>([]);
|
const [value, setValue] = useState<LabelInput[]>([]);
|
||||||
return (
|
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
|
||||||
<LabelEditor
|
|
||||||
value={value}
|
|
||||||
onChange={(v) => {
|
|
||||||
setValue(v);
|
|
||||||
onChange(v);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("typing EN and SV emits both labels; empty langs are omitted", async () => {
|
test("emits a single label at the instance default language", async () => {
|
||||||
const seen: LabelInput[][] = [];
|
const seen: LabelInput[][] = [];
|
||||||
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
|
||||||
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
|
await waitFor(() => {
|
||||||
const last = seen[seen.length - 1]!;
|
const last = seen[seen.length - 1]!;
|
||||||
expect(last).toEqual(
|
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
|
||||||
expect.arrayContaining([
|
});
|
||||||
{ lang: "en", label: "Bronze" },
|
});
|
||||||
{ lang: "sv", label: "Brons" },
|
|
||||||
]),
|
test("clearing the input emits an empty array", async () => {
|
||||||
);
|
const seen: LabelInput[][] = [];
|
||||||
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
|
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
||||||
|
const input = screen.getByLabelText(/^label$/i);
|
||||||
|
await userEvent.type(input, "X");
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await waitFor(() => expect(seen[seen.length - 1]).toEqual([]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
type LabelInput = components["schemas"]["LabelInput"];
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */
|
/** Single-language label editor. Authors one label at the instance default language;
|
||||||
|
* emits a one-entry LabelInput[] (empty array when blank). The multilingual data model
|
||||||
|
* is unchanged — this only simplifies authoring. */
|
||||||
export function LabelEditor({
|
export function LabelEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -15,33 +18,18 @@ export function LabelEditor({
|
|||||||
onChange: (labels: LabelInput[]) => void;
|
onChange: (labels: LabelInput[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { default_language } = useConfig();
|
||||||
|
|
||||||
const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? "";
|
const current =
|
||||||
|
value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";
|
||||||
|
|
||||||
const set = (lang: string, label: string) => {
|
const set = (label: string) =>
|
||||||
const others = value.filter((l) => l.lang !== lang);
|
onChange(label.trim() ? [{ lang: default_language, label }] : []);
|
||||||
|
|
||||||
onChange(label.trim() ? [...others, { lang, label }] : others);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="label-en">{t("labels.en")}</Label>
|
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||||
<Input
|
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||||
id="label-en"
|
|
||||||
value={valueFor("en")}
|
|
||||||
onChange={(e) => set("en", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
|
|
||||||
<Input
|
|
||||||
id="label-sv"
|
|
||||||
value={valueFor("sv")}
|
|
||||||
onChange={(e) => set("sv", e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test, beforeEach } from "vitest";
|
||||||
|
import { screen, waitFor, render } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import i18n, { LOCALE_KEY } from "../i18n";
|
||||||
|
import { ConfigProvider } from "./config-provider";
|
||||||
|
import { useConfig } from "./config-context";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const config = useConfig();
|
||||||
|
return <span data-testid="lang">{config.default_language}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ConfigProvider><Probe /></ConfigProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes config and applies default language when no stored preference", async () => {
|
||||||
|
renderProvider();
|
||||||
|
expect(await screen.findByTestId("lang")).toHaveTextContent("sv");
|
||||||
|
await waitFor(() => expect(i18n.language).toBe("sv"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stored locale preference wins over the instance default", async () => {
|
||||||
|
localStorage.setItem(LOCALE_KEY, "en");
|
||||||
|
void i18n.changeLanguage("en");
|
||||||
|
renderProvider();
|
||||||
|
await screen.findByTestId("lang");
|
||||||
|
await waitFor(() => expect(i18n.language).toBe("en"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
export type ConfigView = components["schemas"]["ConfigView"];
|
||||||
|
|
||||||
|
export const DEFAULTS: ConfigView = {
|
||||||
|
app_name: "Collection Management System",
|
||||||
|
default_language: "sv",
|
||||||
|
default_timezone: "Europe/Stockholm",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfigContext = createContext<ConfigView>(DEFAULTS);
|
||||||
|
|
||||||
|
export function useConfig(): ConfigView {
|
||||||
|
return useContext(ConfigContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import i18n, { LOCALE_KEY } from "../i18n";
|
||||||
|
import { ConfigContext, DEFAULTS, type ConfigView } from "./config-context";
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async (): Promise<ConfigView> => {
|
||||||
|
const { data, error } = await api.GET("/api/config");
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load config");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default the UI language to the instance default, unless the user has chosen one for
|
||||||
|
// this browser (LangSwitch persists to localStorage[LOCALE_KEY]).
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && !localStorage.getItem(LOCALE_KEY)) {
|
||||||
|
void i18n.changeLanguage(data.default_language);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
|
||||||
|
}
|
||||||
@@ -42,10 +42,10 @@ export function FieldForm() {
|
|||||||
const onSubmit = (event: FormEvent) => {
|
const onSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const hasEn = labels.some((l) => l.lang === "en" && l.label);
|
const hasLabel = labels.some((l) => l.label);
|
||||||
const termNeedsVocab = dataType === "term" && !vocabularyId;
|
const termNeedsVocab = dataType === "term" && !vocabularyId;
|
||||||
|
|
||||||
if (!key.trim() || !hasEn || termNeedsVocab) {
|
if (!key.trim() || !hasLabel || termNeedsVocab) {
|
||||||
setError(true);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test("creates a text field — posts the body and clears the key input", async (
|
|||||||
renderApp(tree(), { route: "/fields" });
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/^key$/i), "notes");
|
await userEvent.type(screen.getByLabelText(/^key$/i), "notes");
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Notes");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(body?.key).toBe("notes"));
|
await waitFor(() => expect(body?.key).toBe("notes"));
|
||||||
@@ -55,7 +55,7 @@ test("selecting Authority reveals the kind picker and posts the chosen kind", as
|
|||||||
renderApp(tree(), { route: "/fields" });
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/^key$/i), "maker");
|
await userEvent.type(screen.getByLabelText(/^key$/i), "maker");
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Maker");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Maker");
|
||||||
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority");
|
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "authority");
|
||||||
const kind = await screen.findByLabelText(/authority kind/i);
|
const kind = await screen.findByLabelText(/authority kind/i);
|
||||||
await userEvent.selectOptions(kind, "person");
|
await userEvent.selectOptions(kind, "person");
|
||||||
@@ -76,7 +76,7 @@ test("selecting Term reveals the vocabulary picker and blocks submit until chose
|
|||||||
renderApp(tree(), { route: "/fields" });
|
renderApp(tree(), { route: "/fields" });
|
||||||
|
|
||||||
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
|
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Material");
|
||||||
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
|
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
|
||||||
|
|
||||||
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
||||||
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" },
|
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" },
|
||||||
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
|
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
|
||||||
"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" },
|
"labels": { "label": "Label", "externalUri": "External URI (optional)" },
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"newVocabulary": "New vocabulary", "key": "Key",
|
"newVocabulary": "New vocabulary", "key": "Key",
|
||||||
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||||
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" },
|
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" },
|
||||||
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
|
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
|
||||||
"labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" },
|
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" },
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { App } from "./app";
|
import { App } from "./app";
|
||||||
|
import { ConfigProvider } from "./config/config-provider";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
|
||||||
@@ -13,7 +14,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ test("boolean field renders a checkbox", async () => {
|
|||||||
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
|
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("localized_text renders sv and en inputs", async () => {
|
test("localized_text renders a single input for the default language", async () => {
|
||||||
renderApp(<Harness defKey="title_ml" />);
|
renderApp(<Harness defKey="title_ml" />);
|
||||||
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
|
expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("term field renders a select populated from the vocabulary", async () => {
|
test("term field renders a select populated from the vocabulary", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useAuthorities, useTerms } from "../api/queries";
|
import { useAuthorities, useTerms } from "../api/queries";
|
||||||
|
import { useConfig } from "../config/config-context";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -70,6 +71,7 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
|
|||||||
form: FieldForm<TValues>;
|
form: FieldForm<TValues>;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { default_language } = useConfig();
|
||||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
const label = labelIn(definition.labels, lang);
|
const label = labelIn(definition.labels, lang);
|
||||||
const name = fieldPath<TValues>(definition.key);
|
const name = fieldPath<TValues>(definition.key);
|
||||||
@@ -128,30 +130,12 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
|
|||||||
case "localized_text":
|
case "localized_text":
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-medium">{label}</div>
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
<Label
|
|
||||||
htmlFor={`${definition.key}-en`}
|
|
||||||
className="text-xs text-neutral-500"
|
|
||||||
>
|
|
||||||
{label} (EN)
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id={`${definition.key}-en`}
|
id={definition.key}
|
||||||
{...form.register(fieldPath<TValues>(`${definition.key}.en`), { required: definition.required })}
|
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
|
||||||
/>
|
required: definition.required,
|
||||||
|
})}
|
||||||
<Label
|
|
||||||
htmlFor={`${definition.key}-sv`}
|
|
||||||
className="text-xs text-neutral-500"
|
|
||||||
>
|
|
||||||
{label} (SV)
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id={`${definition.key}-sv`}
|
|
||||||
{...form.register(fieldPath<TValues>(`${definition.key}.sv`))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { http, HttpResponse } from "msw";
|
|||||||
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures";
|
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures";
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
|
http.get("/api/config", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
app_name: "Test Museum",
|
||||||
|
default_language: "sv",
|
||||||
|
default_timezone: "Europe/Stockholm",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
http.get("/api/admin/me", () =>
|
http.get("/api/admin/me", () =>
|
||||||
HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
|
HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test("selecting a vocabulary shows its terms and adds one", async () => {
|
|||||||
);
|
);
|
||||||
renderApp(tree(), { route: "/vocabularies/v-material" });
|
renderApp(tree(), { route: "/vocabularies/v-material" });
|
||||||
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone");
|
await userEvent.type(screen.getByLabelText(/^label$/i), "Stone");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function VocabularyTerms() {
|
|||||||
const onAdd = (event: FormEvent) => {
|
const onAdd = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!labels.some((l) => l.lang === "en" && l.label)) {
|
if (!labels.some((l) => l.label)) {
|
||||||
setError(true);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user