Compare commits

..

14 Commits

Author SHA1 Message Date
logaritmisk 260eac903e merge: instance locale (env) + single-language content authoring
CI / web (push) Has been cancelled
DEFAULT_LANGUAGE/DEFAULT_TIMEZONE env config surfaced via public GET /api/config;
SPA config provider defaults the UI language from the instance (overridable per
browser). Content authoring collapsed to a single language (LabelEditor +
localized_text) at the instance default. The multilingual content SCHEMA is left
completely untouched (dormant) — re-enabling is UI-only, zero migration. Storage
stays UTC; timezone exposed for future display/PDF use.

81 web tests; full backend green; bundle 145.7 KB gz; en/sv parity 106/106.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:16:12 +02:00
logaritmisk 9d0475e8ec feat(web): single-language content authoring (LabelEditor + localized_text at default lang)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:05:20 +02:00
logaritmisk 04e9c95c52 refactor(web): split config hook/context (.ts) from provider (.tsx) to clear react-refresh lint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 15:01:33 +02:00
logaritmisk de11292203 feat(web): config provider — fetch /api/config, default UI language from instance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:58:01 +02:00
logaritmisk 825b23adec test(server): assert default_language/default_timezone config defaults 2026-06-05 14:55:37 +02:00
logaritmisk 2460a1368d feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config 2026-06-05 14:52:09 +02:00
logaritmisk 4a76d6043a docs(plans): instance locale + single-language content authoring (4 tasks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:29:40 +02:00
logaritmisk 0f43c75b24 docs(specs): instance locale (env) + single-language content authoring
Keep the multilingual content schema (dormant); simplify authoring inputs to one
language. Default language + timezone via env vars (no settings table). Public
/api/config endpoint surfaces them to the SPA. Per-account UI language deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:23:32 +02:00
logaritmisk 3c6a41a80a merge: tier 4 hardening batch 1 (#1 #2 #21)
#1 graceful shutdown on SIGINT/SIGTERM (axum with_graceful_shutdown).
#2 configurable DB pool size (--db-max-connections / DB_MAX_CONNECTIONS, default 5).
#21 audit vocabulary/term/authority creation atomically, attributing the acting
user; ~15 call sites threaded an AuditActor.

173 workspace tests; clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:10:13 +02:00
logaritmisk 146e0164e7 refactor(db): name audit entity_type constants for vocab/term/authority (#21)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:05:06 +02:00
logaritmisk 984be697ac feat: audit vocabulary/term/authority creation, attributing the acting user (#21)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:54:50 +02:00
logaritmisk 7181437625 feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 21:46:41 +02:00
logaritmisk 7e235ffd3e feat(server): graceful shutdown on SIGINT/SIGTERM (#1) 2026-06-04 21:42:55 +02:00
logaritmisk b0d2c247df docs(plans): tier 4 hardening batch 1 (#1 #2 #21)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:41:29 +02:00
48 changed files with 1326 additions and 148 deletions
+6 -5
View File
@@ -7,7 +7,7 @@ use axum::{
http::StatusCode,
routing::get,
};
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -91,7 +91,7 @@ pub(crate) async fn list_authorities(
)
)]
pub(crate) async fn create_authority(
_auth: Authorized<EditCatalogue>,
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Json(req): Json<NewAuthorityRequest>,
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
@@ -117,9 +117,10 @@ pub(crate) async fn create_authority(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let id = db::authority::create_authority(&mut tx, &new)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let id =
db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit()
.await
+27 -13
View File
@@ -7,7 +7,7 @@ use axum::{
http::StatusCode,
routing::get,
};
use domain::{LocalizedLabel, NewTerm, VocabularyId};
use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -85,11 +85,23 @@ pub(crate) async fn list_vocabularies(
)
)]
pub(crate) async fn create_vocabulary(
_auth: Authorized<EditCatalogue>,
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Json(req): Json<NewVocabularyRequest>,
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
let vocab = db::vocab::create_vocabulary(state.db.pool(), &req.key)
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
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -156,7 +168,7 @@ pub(crate) async fn list_terms(
)
)]
pub(crate) async fn add_term(
_auth: Authorized<EditCatalogue>,
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<NewTermRequest>,
@@ -185,15 +197,17 @@ pub(crate) async fn add_term(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| {
// 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") {
StatusCode::NOT_FOUND
} else {
tracing::error!(?err, "adding term");
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
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).
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
StatusCode::NOT_FOUND
} else {
tracing::error!(?err, "adding term");
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
tx.commit()
.await
+29
View File
@@ -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))
}
+6
View File
@@ -5,6 +5,7 @@ mod admin_authorities;
mod admin_objects;
mod admin_search;
mod admin_vocab;
mod config;
mod health;
mod openapi;
mod pagination;
@@ -30,6 +31,10 @@ pub struct AppState {
/// Search client for on-write index sync. `None` disables indexing (search is a
/// best-effort feature; absent when Meilisearch is not configured).
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
@@ -58,6 +63,7 @@ pub fn build_app(state: AppState) -> Router {
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
Router::new()
.merge(config::routes())
.merge(health::routes())
.merge(openapi::routes())
.merge(public::routes())
+4 -1
View File
@@ -2,12 +2,14 @@ use axum::{Json, Router, extract::State, routing::get};
use utoipa::OpenApi;
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)]
#[openapi(
paths(
config::get_config,
health::live,
health::ready,
public::list_objects,
@@ -34,6 +36,7 @@ use crate::{
admin_authorities::create_authority
),
components(schemas(
config::ConfigView,
health::Live,
health::Ready,
public::PublicView,
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+45 -2
View File
@@ -1,8 +1,8 @@
use api::{AppState, build_app, migrate_sessions};
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use db::users;
use domain::{AuditActor, Email, NewUser, Role};
use db::{audit, users};
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt;
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(),
cookie_secure: false,
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);
}
#[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"
);
}
+2
View File
@@ -33,6 +33,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+2
View File
@@ -16,6 +16,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".into(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+2
View File
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
app_name: "Test".into(),
cookie_secure: false,
search,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+41
View File
@@ -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");
}
+2
View File
@@ -11,6 +11,8 @@ fn state(pool: PgPool, app_name: &str) -> AppState {
app_name: app_name.to_string(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+2
View File
@@ -13,6 +13,8 @@ fn state(pool: PgPool) -> AppState {
app_name: "Test".to_string(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+2
View File
@@ -25,6 +25,8 @@ fn state(pool: PgPool, search: SearchClient) -> AppState {
app_name: "Test".into(),
cookie_secure: false,
search: Some(search),
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
}
}
+24 -3
View File
@@ -1,16 +1,25 @@
//! 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 crate::audit;
const AUTHORITY_ENTITY_TYPE: &str = "authority";
/// 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) \
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
/// Insert an authority and its labels. Multiple statements — pass a transaction
/// connection (`&mut *tx`) for atomicity.
/// Insert an authority and its labels, then record a `created` audit entry. Multiple
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
/// atomically.
pub async fn create_authority(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
new: &NewAuthority,
) -> Result<AuthorityId, sqlx::Error> {
let id = AuthorityId::new();
@@ -31,6 +40,18 @@ pub async fn create_authority(
.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)
}
+4 -3
View File
@@ -17,10 +17,11 @@ pub struct Db {
}
impl Db {
/// Connect to the database at `database_url`, opening a connection pool.
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
/// 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(5)
.max_connections(max_connections)
.connect(database_url)
.await?;
+8 -2
View File
@@ -5,7 +5,9 @@
//! populated by the organization or a later import. The inventory-minimum fields
//! (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};
@@ -119,7 +121,11 @@ async fn ensure_vocabulary(
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
Ok(existing.id)
} else {
Ok(vocab::create_vocabulary(&mut *conn, key).await?.id)
Ok(
vocab::create_vocabulary(&mut *conn, AuditActor::System, key)
.await?
.id,
)
}
}
+49 -10
View File
@@ -1,25 +1,47 @@
//! 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 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.
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)";
/// Create a vocabulary with the given key.
pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result<Vocabulary, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
/// Create a vocabulary with the given key and record a `created` audit entry, both on
/// `conn` (pass a transaction connection `&mut *tx` so they commit atomically).
pub async fn create_vocabulary(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
) -> Result<Vocabulary, sqlx::Error> {
let id = VocabularyId::new();
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
.bind(id.to_uuid())
.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?;
Ok(Vocabulary {
id,
key: key.to_owned(),
@@ -54,9 +76,14 @@ where
row.map(map_vocabulary).transpose()
}
/// Insert a term and its labels. Multiple statements — pass a transaction
/// connection (`&mut *tx`) so the term and its labels commit atomically.
pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<TermId, sqlx::Error> {
/// Insert a term and its labels, then record a `created` audit entry. Multiple
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
/// atomically.
pub async fn add_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
new: &NewTerm,
) -> Result<TermId, sqlx::Error> {
let id = TermId::new();
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?;
}
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)
}
+12 -6
View File
@@ -1,5 +1,5 @@
use db::{Db, authority};
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
use sqlx::PgPool;
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
@@ -24,9 +24,13 @@ async fn authority_round_trips_with_labels(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(&mut tx, &new_person("Carl Larsson", "Carl Larsson"))
.await
.unwrap();
let id = authority::create_authority(
&mut tx,
AuditActor::System,
&new_person("Carl Larsson", "Carl Larsson"),
)
.await
.unwrap();
tx.commit().await.unwrap();
let got = authority::authority_by_id(db.pool(), id)
@@ -47,11 +51,12 @@ async fn list_by_kind_filters(pool: PgPool) {
let db = Db::from_pool(pool);
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
.unwrap();
authority::create_authority(
&mut tx,
AuditActor::System,
&NewAuthority {
kind: AuthorityKind::Place,
external_uri: None,
@@ -83,7 +88,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) {
let db = Db::from_pool(pool);
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
.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 id = authority::create_authority(
&mut tx,
AuditActor::System,
&NewAuthority {
kind: AuthorityKind::Organisation,
external_uri: None,
+4 -2
View File
@@ -1,5 +1,5 @@
use db::{Db, fields, vocab};
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
use sqlx::PgPool;
fn labels() -> Vec<LocalizedLabel> {
@@ -52,9 +52,11 @@ async fn text_field_round_trips(pool: PgPool) {
#[sqlx::test]
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
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
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition(
+12 -3
View File
@@ -95,9 +95,12 @@ async fn sets_scalar_fields_and_audits(pool: PgPool) {
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
let db = Db::from_pool(pool);
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
.unwrap();
tx.commit().await.unwrap();
define(
&db,
"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 wood = vocab::add_term(
&mut tx,
AuditActor::System,
&domain::NewTerm {
vocabulary_id: material.id,
external_uri: None,
@@ -180,6 +184,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
let mut tx = db.pool().begin().await.unwrap();
let person = db::authority::create_authority(
&mut tx,
AuditActor::System,
&domain::NewAuthority {
kind: domain::AuthorityKind::Person,
external_uri: None,
@@ -190,6 +195,7 @@ async fn authority_field_enforces_kind(pool: PgPool) {
.unwrap();
let place = db::authority::create_authority(
&mut tx,
AuditActor::System,
&domain::NewAuthority {
kind: domain::AuthorityKind::Place,
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) {
let db = Db::from_pool(pool);
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
.unwrap();
let technique = vocab::create_vocabulary(db.pool(), "technique")
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
.await
.unwrap();
tx.commit().await.unwrap();
define(
&db,
"material",
@@ -238,6 +246,7 @@ async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
let mut tx = db.pool().begin().await.unwrap();
let other = vocab::add_term(
&mut tx,
AuditActor::System,
&domain::NewTerm {
vocabulary_id: technique.id,
external_uri: None,
+23 -8
View File
@@ -1,13 +1,15 @@
use db::{Db, vocab};
use domain::{LocalizedLabel, NewTerm};
use domain::{AuditActor, LocalizedLabel, NewTerm};
use sqlx::PgPool;
#[sqlx::test]
async fn vocabulary_create_and_lookup(pool: PgPool) {
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
.unwrap();
tx.commit().await.unwrap();
let found = vocab::vocabulary_by_key(db.pool(), "material")
.await
@@ -27,13 +29,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) {
#[sqlx::test]
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
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
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: v.id,
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]
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
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
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: v.id,
external_uri: None,
@@ -103,10 +111,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
#[sqlx::test]
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
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
.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
.unwrap_err();
assert!(
@@ -118,16 +130,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
#[sqlx::test]
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
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
.unwrap();
let technique = vocab::create_vocabulary(db.pool(), "technique")
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: material.id,
external_uri: None,
+4 -3
View File
@@ -23,14 +23,15 @@ async fn reindex_resolves_term_labels_and_finds_by_label(pool: PgPool) {
let db = Db::from_pool(pool);
// 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
.unwrap();
let mut tx = db.pool().begin().await.unwrap();
let wood = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: material.id,
external_uri: None,
+25
View File
@@ -42,4 +42,29 @@ pub struct Config {
/// Meilisearch index name for catalogue objects.
#[arg(long = "meili-index", env = "MEILI_INDEX", default_value = "objects")]
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,
}
+34 -2
View File
@@ -15,7 +15,7 @@ use tokio::net::TcpListener;
/// Connect dependencies from `config` and serve until shutdown.
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
.context("connecting to the database")?;
@@ -53,6 +53,8 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
app_name: config.app_name,
cookie_secure: config.cookie_secure,
search,
default_language: config.default_language,
default_timezone: config.default_timezone,
};
let listener = TcpListener::bind(&config.bind_addr)
@@ -64,6 +66,34 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
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).
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
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());
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.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}"))?
};
let db = Db::connect(database_url)
// CLI one-shot: a tiny pool is plenty.
let db = Db::connect(database_url, 2)
.await
.context("connecting to the database")?;
+5 -1
View File
@@ -1,11 +1,13 @@
use clap::Parser;
use server::Config;
const CLEARED: [(&str, Option<&str>); 4] = [
const CLEARED: [(&str, Option<&str>); 6] = [
("DATABASE_URL", None),
("BIND_ADDR", None),
("APP_NAME", None),
("SESSION_COOKIE_SECURE", None),
("DEFAULT_LANGUAGE", None),
("DEFAULT_TIMEZONE", None),
];
#[test]
@@ -17,6 +19,8 @@ fn parses_from_args_with_defaults() {
assert_eq!(cfg.database_url, "postgres://localhost/test");
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
assert_eq!(cfg.app_name, "Collection Management System");
assert_eq!(cfg.default_language, "sv");
assert_eq!(cfg.default_timezone, "Europe/Stockholm");
});
}
+3 -1
View File
@@ -9,7 +9,7 @@ use tokio::net::TcpListener;
async fn serves_health_live_over_tcp() {
let database_url =
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
.expect("connect to database");
let state = AppState {
@@ -17,6 +17,8 @@ async fn serves_health_live_over_tcp() {
app_name: "Test".to_string(),
cookie_secure: false,
search: None,
default_language: "sv".into(),
default_timezone: "Europe/Stockholm".into(),
};
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.
+44
View File
@@ -238,6 +238,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -355,6 +371,15 @@ export interface components {
kind: components["schemas"]["AuthorityKind"];
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: {
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: {
parameters: {
query?: {
+1 -1
View File
@@ -31,7 +31,7 @@ export function AuthoritiesPage() {
const onCreate = (event: FormEvent) => {
event.preventDefault();
if (!labels.some((l) => l.lang === "en" && l.label)) {
if (!labels.some((l) => l.label)) {
setError(true);
return;
}
+1 -1
View File
@@ -25,7 +25,7 @@ test("lists authorities for the kind and creates one", async () => {
);
renderApp(tree(), { route: "/authorities/person" });
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 waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
+17 -21
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
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 { renderApp } from "../test/render";
import { LabelEditor } from "./label-editor";
@@ -10,28 +10,24 @@ 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);
}}
/>
);
return <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[][] = [];
renderApp(<Harness onChange={(v) => seen.push(v)} />);
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
const last = seen[seen.length - 1]!;
expect(last).toEqual(
expect.arrayContaining([
{ lang: "en", label: "Bronze" },
{ lang: "sv", label: "Brons" },
]),
);
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
await waitFor(() => {
const last = seen[seen.length - 1]!;
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
});
});
test("clearing the input emits an empty array", async () => {
const seen: LabelInput[][] = [];
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([]));
});
+12 -24
View File
@@ -1,12 +1,15 @@
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"];
/** 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({
value,
onChange,
@@ -15,33 +18,18 @@ export function LabelEditor({
onChange: (labels: LabelInput[]) => void;
}) {
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 others = value.filter((l) => l.lang !== lang);
onChange(label.trim() ? [...others, { lang, label }] : others);
};
const set = (label: string) =>
onChange(label.trim() ? [{ lang: default_language, label }] : []);
return (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="label-en">{t("labels.en")}</Label>
<Input
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 className="space-y-1">
<Label htmlFor="label">{t("labels.label")}</Label>
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
</div>
);
}
+39
View File
@@ -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"));
});
+17
View File
@@ -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);
}
+30
View File
@@ -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>;
}
+2 -2
View File
@@ -42,10 +42,10 @@ export function FieldForm() {
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const hasEn = labels.some((l) => l.lang === "en" && l.label);
const hasLabel = labels.some((l) => l.label);
const termNeedsVocab = dataType === "term" && !vocabularyId;
if (!key.trim() || !hasEn || termNeedsVocab) {
if (!key.trim() || !hasLabel || termNeedsVocab) {
setError(true);
return;
}
+3 -3
View File
@@ -35,7 +35,7 @@ test("creates a text field — posts the body and clears the key input", async (
renderApp(tree(), { route: "/fields" });
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 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" });
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");
const kind = await screen.findByLabelText(/authority kind/i);
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" });
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");
const vocab = await screen.findByLabelText(/^vocabulary$/i);
+1 -1
View File
@@ -7,7 +7,7 @@
"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" },
"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": {
"newVocabulary": "New vocabulary", "key": "Key",
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
+1 -1
View File
@@ -7,7 +7,7 @@
"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" },
"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": {
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
+4 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./app";
import { ConfigProvider } from "./config/config-provider";
import "./index.css";
import "./i18n";
@@ -13,7 +14,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ConfigProvider>
<App />
</ConfigProvider>
</QueryClientProvider>
</StrictMode>,
);
+2 -3
View File
@@ -22,10 +22,9 @@ test("boolean field renders a checkbox", async () => {
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" />);
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
expect(await screen.findByLabelText(/^title/i)).toBeInTheDocument();
});
test("term field renders a select populated from the vocabulary", async () => {
+7 -23
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useAuthorities, useTerms } from "../api/queries";
import { useConfig } from "../config/config-context";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -70,6 +71,7 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
form: FieldForm<TValues>;
}) {
const { t, i18n } = useTranslation();
const { default_language } = useConfig();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const label = labelIn(definition.labels, lang);
const name = fieldPath<TValues>(definition.key);
@@ -128,30 +130,12 @@ export function FieldInput<TValues extends { fields: Record<string, unknown> }>(
case "localized_text":
return (
<div className="space-y-1">
<div className="text-sm font-medium">{label}</div>
<Label
htmlFor={`${definition.key}-en`}
className="text-xs text-neutral-500"
>
{label} (EN)
</Label>
<Label htmlFor={definition.key}>{label}</Label>
<Input
id={`${definition.key}-en`}
{...form.register(fieldPath<TValues>(`${definition.key}.en`), { 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`))}
id={definition.key}
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
required: definition.required,
})}
/>
</div>
);
+8
View File
@@ -3,6 +3,14 @@ import { http, HttpResponse } from "msw";
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures";
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", () =>
HttpResponse.json({ id: "u1", email: "editor@example.com", role: "editor" }),
),
+1 -1
View File
@@ -45,7 +45,7 @@ test("selecting a vocabulary shows its terms and adds one", async () => {
);
renderApp(tree(), { route: "/vocabularies/v-material" });
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 waitFor(() =>
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
+1 -1
View File
@@ -34,7 +34,7 @@ export function VocabularyTerms() {
const onAdd = (event: FormEvent) => {
event.preventDefault();
if (!labels.some((l) => l.lang === "en" && l.label)) {
if (!labels.some((l) => l.label)) {
setError(true);
return;
}