Compare commits
415 Commits
283e27fa06
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c63ac25b | |||
| 62c569741f | |||
| 3ad0e56ecd | |||
| ada5d06dad | |||
| 3a57c0a77c | |||
| 9a896bb5f6 | |||
| 78f5afad35 | |||
| 27205c65ef | |||
| 091a1a651d | |||
| ec11c9dc76 | |||
| 1d19ddfd96 | |||
| 79a6567530 | |||
| fe448034ac | |||
| 67c5da57bf | |||
| 53405d7831 | |||
| e615260422 | |||
| 3b6441688f | |||
| a0b7dcdc2d | |||
| 7f9cf9fe60 | |||
| b83149e0bb | |||
| 80c2aad298 | |||
| b5756e16b5 | |||
| b3f061ced7 | |||
| eec3a261b4 | |||
| 390f6897a8 | |||
| 8b881f369b | |||
| aef5000543 | |||
| 878db9a37b | |||
| 0b44bc0855 | |||
| 79ee402b33 | |||
| 64f35e5a57 | |||
| 3aff10557c | |||
| e8fe24f755 | |||
| fc170ccf10 | |||
| 3ae9d87e6e | |||
| 3dbede6bc2 | |||
| ba238ca962 | |||
| 7cabebc338 | |||
| 74cde67a54 | |||
| 900f85f8ac | |||
| 00a7ce772e | |||
| 71dee23028 | |||
| 91716e628a | |||
| 002af9d1f8 | |||
| d8d8035850 | |||
| 704b159d48 | |||
| c1bddb47c4 | |||
| a21ab85576 | |||
| 7ddf6967ce | |||
| 404cf67f35 | |||
| 50d2512123 | |||
| c689b8c0e9 | |||
| acdaf8d07f | |||
| 77c56f7a9d | |||
| 030472c2da | |||
| f1eb6a9ba5 | |||
| 285a1323ad | |||
| da3e078fbc | |||
| 0def81ab42 | |||
| 546680017d | |||
| 3efb7e175d | |||
| 56076c4daa | |||
| aeb1b084d9 | |||
| 6e02ac874f | |||
| dd131ee740 | |||
| cad5a980c5 | |||
| 17bfd3e9d8 | |||
| d90aa75468 | |||
| 7a43f794e5 | |||
| af3f1a5367 | |||
| ec6e90ef5b | |||
| 3c59f47f81 | |||
| 76f65a95dd | |||
| a0aab6571f | |||
| 6e72f24f0a | |||
| d447e2d8a8 | |||
| a9a0c4d477 | |||
| c0c86a5859 | |||
| faca2670a4 | |||
| c68bbb9460 | |||
| 30da072d96 | |||
| 1cdfa21259 | |||
| d37ac821f0 | |||
| 150ca63fc0 | |||
| d082836529 | |||
| 69d3d2be15 | |||
| 57504c941d | |||
| 4530004d87 | |||
| 1948d09d16 | |||
| 4c24f0387c | |||
| 0209638552 | |||
| 2b6ea1b4a4 | |||
| 3575282dc2 | |||
| 882d0c828f | |||
| 75e7cf9047 | |||
| 76b2cbde1d | |||
| 6c2fa63cac | |||
| a4fb05a175 | |||
| 0678cefd13 | |||
| 53c98102d2 | |||
| 0d4026a968 | |||
| d0da77a004 | |||
| 6bce1e6782 | |||
| 506bfd63dd | |||
| f45f1d8807 | |||
| ede32551be | |||
| 71d899cbdc | |||
| 09e9b3f4d4 | |||
| e54ea89b1e | |||
| 3782120b49 | |||
| 28e444c6c5 | |||
| d3ee4365e0 | |||
| e18cad9c6a | |||
| 537b847acb | |||
| 3900bc362c | |||
| ed0c13907c | |||
| f3881e8c7c | |||
| 6ed137f49e | |||
| e005e76f5b | |||
| b7242caf51 | |||
| 6efe09d40c | |||
| 5c8fe3cd81 | |||
| 4b55218c69 | |||
| af6004f731 | |||
| 18cb35beff | |||
| dbaf22500e | |||
| 4fad3c43f0 | |||
| e4badbdefc | |||
| 285d35601b | |||
| 9b3a587eab | |||
| 8511aebb53 | |||
| 6e1f5ea50f | |||
| 70025e1e71 | |||
| 40384d91dd | |||
| d3e88be70f | |||
| 03f6e1d7ed | |||
| aab1bb37dc | |||
| 9323c608ee | |||
| eead013ccd | |||
| 4f3db60ed2 | |||
| 6d17e5f84d | |||
| d452dd9b35 | |||
| e5c03383fe | |||
| 5e7a80e377 | |||
| 5d63f06863 | |||
| d0e3772c34 | |||
| a9e6788b0b | |||
| 48edb0391e | |||
| 93234aae29 | |||
| cde7be9f2a | |||
| 04ed0c50e2 | |||
| 67e486df46 | |||
| d408464e91 | |||
| 1bfa44a0ed | |||
| 303c986d40 | |||
| fcad638549 | |||
| 604d4f6005 | |||
| 63bfff417b | |||
| 8eb527957b | |||
| e2ae093ed8 | |||
| 03d5b59b48 | |||
| 2e38af565a | |||
| 7258b3fd03 | |||
| 6ec31b6c51 | |||
| 0a88a86bb3 | |||
| 6a62cf64bf | |||
| c052ddc5af | |||
| e7b0f65686 | |||
| b8f70212a1 | |||
| 184e4ea2a5 | |||
| 04c33cb1aa | |||
| 49f694d1fb | |||
| 98c00d3732 | |||
| 60a1b8dccf | |||
| 5efa7b8a16 | |||
| e7ff817c63 | |||
| fb80146430 | |||
| b49699175d | |||
| e700e1d3cf | |||
| de035bd032 | |||
| 4267aae4e5 | |||
| c84b84b153 | |||
| 0188e730e8 | |||
| 6e52a331bc | |||
| 8e57789dd7 | |||
| 8ed747c6a7 | |||
| dd02bddb07 | |||
| 6ebcc10405 | |||
| 325917a98e | |||
| d74500f901 | |||
| 7d40a2cd56 | |||
| 873efe199f | |||
| 27caaa9787 | |||
| c9120848f5 | |||
| 83ca506702 | |||
| 65ca79f2bd | |||
| 194f18c8ed | |||
| 282e6430d4 | |||
| 78c950d2ee | |||
| 3e7c6ad712 | |||
| 47240dafcc | |||
| 83a7202861 | |||
| 09baf2949f | |||
| f6053068be | |||
| e58b150ab2 | |||
| e7ae41362e | |||
| ffcfb41c7e | |||
| b2d026f217 | |||
| 4e1138f8ce | |||
| e6fc3eaf2c | |||
| b4d71b0f80 | |||
| 0a29127f7e | |||
| 0c9db7bcdb | |||
| d6dc1c9b57 | |||
| cd3606c0e9 | |||
| 260eac903e | |||
| 9d0475e8ec | |||
| 04e9c95c52 | |||
| de11292203 | |||
| 825b23adec | |||
| 2460a1368d | |||
| 4a76d6043a | |||
| 0f43c75b24 | |||
| 3c6a41a80a | |||
| 146e0164e7 | |||
| 984be697ac | |||
| 7181437625 | |||
| 7e235ffd3e | |||
| b0d2c247df | |||
| e9a5a10524 | |||
| df113bd7ac | |||
| 0ee3b970cb | |||
| 5a72f85989 | |||
| d3c33a6c5d | |||
| 331a6d7f34 | |||
| 0d971cda15 | |||
| 914527edc6 | |||
| ff513e1712 | |||
| 1a91b8a242 | |||
| 2bce469ed2 | |||
| fbb7a297a6 | |||
| 8442afbf02 | |||
| 869a2c6e50 | |||
| 126f84962a | |||
| daad9438ba | |||
| fd1c22191b | |||
| 37c80121ed | |||
| 6ad1304efd | |||
| df8f31d14d | |||
| b508273a52 | |||
| b490db13b1 | |||
| 19408f6282 | |||
| 2d0b76ab34 | |||
| 4dd00362b8 | |||
| 358d793e44 | |||
| ee65b27595 | |||
| de830999d4 | |||
| 18ed9bd947 | |||
| 90a1539090 | |||
| a87501b902 | |||
| 9b1771d584 | |||
| 84c4c2807b | |||
| 38e4525404 | |||
| a9208f56fe | |||
| 18a19eec16 | |||
| 352d899fa5 | |||
| 38673e52ba | |||
| 02e4f34a1b | |||
| ac30eadbb2 | |||
| e8d173a18f | |||
| 8d2323ed95 | |||
| 6afc358334 | |||
| 26e10704a9 | |||
| 684b5449ca | |||
| 7a8e7ff2d7 | |||
| 34d4ed2fd6 | |||
| 39b7fc51e9 | |||
| 01f757a239 | |||
| 516ecf3e95 | |||
| f206ee8995 | |||
| bb05331a3f | |||
| 1cf36e39cc | |||
| eedeb179e3 | |||
| 5087e34280 | |||
| 9880f24dd2 | |||
| 22b37c138b | |||
| 30d851182e | |||
| 616c232a22 | |||
| cf0b34b254 | |||
| cb191225cc | |||
| b23a48c310 | |||
| f3bab3336c | |||
| 9f43793c4a | |||
| 0a2398f507 | |||
| 397e606793 | |||
| 89132f6745 | |||
| 7170be016d | |||
| 1d1be5fbe9 | |||
| 859f41dcb9 | |||
| d6fe0b0597 | |||
| 684469273f | |||
| 057a00c413 | |||
| 01f43e1f67 | |||
| cf02eeb991 | |||
| 2e4187c850 | |||
| 478b4ce44e | |||
| 66d0624279 | |||
| dcfddc88c7 | |||
| 5267f05089 | |||
| b7ec4b1041 | |||
| 8466ed4d08 | |||
| f64688a16f | |||
| a177b02145 | |||
| 31e2a3f30a | |||
| 8cfcf07387 | |||
| e96f74f47a | |||
| 4921c73fa7 | |||
| d15afda9b2 | |||
| c4e0c4c834 | |||
| 01abd5cbbc | |||
| d81b069b8f | |||
| 7a18e0e9bf | |||
| 8b929c7180 | |||
| b6a30c3995 | |||
| 34e5754815 | |||
| 3f4da46b78 | |||
| 1888e185f7 | |||
| 0055616099 | |||
| 3dc621b6dd | |||
| 807ac1a9f8 | |||
| 5cfee93037 | |||
| 369eee4098 | |||
| dbff95c2a9 | |||
| 642f709bbe | |||
| 5135aeee6c | |||
| 4e7288731a | |||
| 992526ef77 | |||
| bea9b6b39a | |||
| f8ec2d7cf1 | |||
| 9597a42eeb | |||
| 74b2cf65ed | |||
| 1ed9798a1f | |||
| 6cd01f9b97 | |||
| 1b48f082ee | |||
| 720c7ddbbf | |||
| 3c4ada202f | |||
| b948cae269 | |||
| 14cdd2a04a | |||
| 5e2ebbc8d9 | |||
| 59400062ae | |||
| 5ea1febb91 | |||
| f0e00fba40 | |||
| fac4b703ff | |||
| 4bafac397a | |||
| 7b91989411 | |||
| b8d198f150 | |||
| dc903989f7 | |||
| 851181d91d | |||
| 5ee9fd88f1 | |||
| adc7c61ee2 | |||
| 91a9eb2964 | |||
| f30ce9d9dc | |||
| 45c1d1b123 | |||
| c94fd1638c | |||
| 2b0056c038 | |||
| 2aaf98794f | |||
| 7b0f804461 | |||
| f4152b2102 | |||
| 66ad67ca77 | |||
| cbed662c18 | |||
| 6e27288f43 | |||
| 2242ff5ef1 | |||
| da2db11a30 | |||
| 2938649d62 | |||
| a690c60ec6 | |||
| 9e1c88b294 | |||
| 616a6f05c6 | |||
| e0c0187f29 | |||
| 95357f01dd | |||
| c1dda280e2 | |||
| bf332ac0ae | |||
| 266f914b88 | |||
| ed608c6e37 | |||
| 7782bd764a | |||
| 6e45baa8d4 | |||
| 345073b130 | |||
| 5dc07ddf4c | |||
| cc1fbf5b7d | |||
| 93d54d7783 | |||
| d5ed2a261f | |||
| 8cf737d8a9 | |||
| 42e0a5f5f1 | |||
| cc26c96a82 | |||
| 86a3a8a47c | |||
| 45aea6b702 | |||
| c67b588188 | |||
| 87b016a56c | |||
| 01c42837d1 | |||
| 152fc30116 | |||
| 4c6f77b999 | |||
| 0447284d43 | |||
| d3f5e73dad | |||
| 19c7bdc3ae | |||
| b8cea49427 | |||
| b9acc03761 | |||
| 8da3eefdce | |||
| db6183405e | |||
| 67057e2ee3 | |||
| 08e5f797f8 | |||
| b9b99c0332 | |||
| 8ae9de12c0 | |||
| b97c950f77 | |||
| 87f06d1f2d | |||
| caa12f5366 | |||
| 8f67503f45 |
@@ -0,0 +1,11 @@
|
||||
# cargo-nextest configuration. https://nexte.st/book/configuration
|
||||
#
|
||||
# nextest runs each test in its own process: live per-test output, and a hard
|
||||
# per-test timeout so a genuinely wedged test is killed + named rather than
|
||||
# stalling the whole run.
|
||||
|
||||
[profile.default]
|
||||
# Warn at 60s, terminate a test after 2×60s = 120s. The slowest real test is a
|
||||
# couple of seconds (each #[sqlx::test] provisions its own temp DB), so this
|
||||
# only ever fires on an actual hang.
|
||||
slow-timeout = { period = "60s", terminate-after = 2 }
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copy to .env for local development: cp .env.example .env
|
||||
# These defaults match the services in docker-compose.yml.
|
||||
|
||||
# PostgreSQL connection string (used for local dev and the test suite).
|
||||
# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs).
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev
|
||||
|
||||
# HTTP bind address.
|
||||
BIND_ADDR=0.0.0.0:8080
|
||||
|
||||
# User-facing product name (OpenAPI title, page title). Set the real name at deploy time.
|
||||
APP_NAME=Collection Management System
|
||||
|
||||
# Local development is plain HTTP. Browsers drop `Secure` cookies on http://localhost,
|
||||
# so the session cookie must NOT be Secure-only or login will silently fail. Set this
|
||||
# back to `true` (the default) for any HTTPS deployment.
|
||||
SESSION_COOKIE_SECURE=false
|
||||
|
||||
# Meilisearch (matches docker-compose.yml). Both must be set to enable search;
|
||||
# leave them unset to run with search disabled.
|
||||
MEILI_URL=http://localhost:7700
|
||||
MEILI_MASTER_KEY=masterKey
|
||||
@@ -0,0 +1,33 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
web:
|
||||
runs-on: aceofba-cluster
|
||||
container:
|
||||
image: ghcr.io/catthehacker/ubuntu:act-22.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- run: pnpm check:size
|
||||
- run: pnpm check:colors
|
||||
@@ -1 +1,10 @@
|
||||
/target
|
||||
.env
|
||||
|
||||
# Local-only Docker Compose overrides (machine-specific port remaps, etc.)
|
||||
docker-compose.override.yml
|
||||
|
||||
.superpowers/
|
||||
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Status
|
||||
|
||||
Rust (edition 2024) workspace + React SPA collection-management system. Backend crates: `domain`, `db`, `api`, `auth`, `search`, `server` (axum 0.8 + sqlx/Postgres + Meilisearch). Frontend in `web/` (React 19 + Vite + pnpm). Tests need the docker-compose stack up (Postgres on **:5442**, Meilisearch on **:7700**); each `#[sqlx::test]` provisions its own temp DB.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
just check # fmt + lint + test — the standard pre-commit gate
|
||||
docker compose up -d # start Postgres (:5442) + Meilisearch (:7700) for tests
|
||||
cargo build --workspace # build
|
||||
cargo run -p server # run the server (or: just run — loads .env)
|
||||
cargo nextest run --workspace # run all tests — PREFERRED (per-test isolation, live output, hang timeouts)
|
||||
cargo nextest run -E 'test(<name>)' # run tests matching a name substring
|
||||
cargo test --workspace --doc # doctests (nextest does not run these)
|
||||
cargo +nightly fmt # format — always nightly, not stable
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lint before committing
|
||||
```
|
||||
|
||||
(`just test` runs nextest + doctests; config in `.config/nextest.toml`.)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **CLI args & env vars:** use `clap` with the `derive` feature.
|
||||
- **Code navigation:** prefer the insikt LSP server over grep/glob — it resolves macro-generated symbols that text search misses. (insikt runs standalone, not via the gateway MCP.)
|
||||
- **Dependencies:** manage via the `cargo-mcp` server rather than editing `Cargo.toml` by hand.
|
||||
- **Formatting:** `cargo +nightly fmt` (nightly toolchain required).
|
||||
- **Frontend guardrails:** before touching `web/`, read **[web/GUARDRAILS.md](web/GUARDRAILS.md)** — it covers the CI gate (`check:size` 250 KB-gz budget, `check:colors` design-token enforcement) and the test-harness quirks (MSW `onUnhandledRequest: "error"`, the jsdom/storybook vitest split, RTL accessible-name collisions, Storybook nested-router and portal handling, and the `components/ui/` code-style split).
|
||||
Generated
+3677
File diff suppressed because it is too large
Load Diff
+31
-5
@@ -1,6 +1,32 @@
|
||||
[package]
|
||||
name = "biggus-dickus"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search", "crates/auth"]
|
||||
|
||||
[dependencies]
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.8"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros", "time", "json"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = { version = "0.3", features = ["serde", "macros", "parsing", "formatting"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
utoipa = { version = "5", features = ["uuid"] }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
meilisearch-sdk = "0.33"
|
||||
argon2 = "0.5"
|
||||
tower-sessions = "0.14"
|
||||
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||
rpassword = "7"
|
||||
dotenvy = "0.15"
|
||||
memory-serve = "2.1"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Biggus Dickus
|
||||
|
||||

|
||||
|
||||
A museum collection-management system: a Rust (axum + sqlx + Postgres) API with a
|
||||
React + TypeScript admin SPA and optional Meilisearch-backed full-text search.
|
||||
|
||||
## Running locally
|
||||
|
||||
The whole backing stack runs from one `docker compose` file (PostgreSQL + Meilisearch).
|
||||
|
||||
### Prerequisites
|
||||
- Docker (PostgreSQL + Meilisearch)
|
||||
- Rust (stable; plus a nightly toolchain for `cargo +nightly fmt`)
|
||||
- Node.js and [`pnpm`](https://pnpm.io/) (web frontend)
|
||||
- [`just`](https://github.com/casey/just) — optional, for the shortcuts below
|
||||
|
||||
### 1. Start the backing services
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
PostgreSQL listens on `localhost:5432` (database `cms_dev`) and Meilisearch on
|
||||
`localhost:7700`. Give them a few seconds to become healthy on first start.
|
||||
|
||||
### 2. Configure the environment
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
The defaults already match the compose services. Note **`SESSION_COOKIE_SECURE=false`**:
|
||||
local development is plain HTTP, and browsers drop `Secure` cookies on `http://localhost`,
|
||||
so leaving it `true` would make login silently fail. Set it back to `true` for any HTTPS
|
||||
deployment.
|
||||
|
||||
### 3. Run the API server
|
||||
```bash
|
||||
just run # or: cargo run -p server
|
||||
```
|
||||
On startup the server connects to PostgreSQL, **runs database migrations automatically**,
|
||||
ensures the Meilisearch index exists, and listens on `http://localhost:8080`. (If the
|
||||
`MEILI_*` variables are unset, search is disabled and everything else still works.)
|
||||
|
||||
### 4. Create a login user
|
||||
There is no seeded account — create one (you'll be prompted for a password, minimum 8
|
||||
characters):
|
||||
```bash
|
||||
cargo run -p server -- create-user --email you@example.com --role admin
|
||||
# non-interactive:
|
||||
BOOTSTRAP_PASSWORD=changeme123 cargo run -p server -- create-user --email you@example.com --role editor
|
||||
```
|
||||
Roles are `admin` or `editor`.
|
||||
|
||||
### 5. Seed the baseline cataloguing fields (idempotent)
|
||||
```bash
|
||||
just seed # or: cargo run -p server -- seed
|
||||
```
|
||||
Populates the baseline Spectrum cataloguing vocabularies and field definitions. Safe to
|
||||
re-run — the seed is idempotent.
|
||||
|
||||
### 6. Run the web frontend
|
||||
The API server serves JSON only; in development the SPA is served by Vite, which proxies
|
||||
`/api` to `:8080`:
|
||||
```bash
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173
|
||||
```
|
||||
Open **http://localhost:5173** and sign in with the user from step 4.
|
||||
|
||||
### Single-binary alternative
|
||||
To serve the built SPA and the API from one process (no Vite), build the web assets and
|
||||
enable the `embed-web` feature:
|
||||
```bash
|
||||
cd web && pnpm build # outputs web/dist
|
||||
cargo run -p server --features embed-web # SPA + API on http://localhost:8080
|
||||
```
|
||||
Assets are embedded at compile time, so rebuild `web/dist` and recompile after frontend
|
||||
changes.
|
||||
|
||||
## Running tests
|
||||
|
||||
Backend tests reuse the same compose services — PostgreSQL provisions a throwaway database
|
||||
per test (`sqlx::test`) and Meilisearch tests use isolated, unique index names, so they
|
||||
don't touch your dev data. With `docker compose up -d` running and `.env` in place:
|
||||
```bash
|
||||
just test # cargo test --workspace (reads .env via dotenv)
|
||||
cd web && pnpm test # frontend tests (Vitest + MSW; no services needed)
|
||||
```
|
||||
`just check` runs format + lint + the Rust test suite. Run `cargo test` directly only if
|
||||
`DATABASE_URL`/`MEILI_URL`/`MEILI_MASTER_KEY` are exported in your shell — the Meilisearch
|
||||
tests require them; `just` loads them from `.env` for you.
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
utoipa.workspace = true
|
||||
time.workspace = true
|
||||
tower-sessions.workspace = true
|
||||
tower-sessions-sqlx-store.workspace = true
|
||||
sqlx.workspace = true
|
||||
tracing.workspace = true
|
||||
auth = { path = "../auth" }
|
||||
db = { path = "../db" }
|
||||
domain = { path = "../domain" }
|
||||
search = { path = "../search" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
http-body-util.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Admin (authenticated) surface: login/logout/session, user listing, and publishing.
|
||||
|
||||
use auth::{AuthUser, Authorized, ManageUsers, PublishObjects};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
use domain::{AuditActor, ObjectId, Visibility};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::Session;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, reindex};
|
||||
|
||||
/// Credentials for password login.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// A user as exposed on the admin surface (no password material).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct UserView {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// Desired visibility for a publish/unpublish request.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct VisibilityRequest {
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// Log in with email + password. On success establishes a session (Set-Cookie) and
|
||||
/// returns 204; on failure 401 with no detail (no user enumeration).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/login",
|
||||
request_body = LoginRequest,
|
||||
responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials"))
|
||||
)]
|
||||
pub(crate) async fn login(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let normalized = req.email.trim().to_lowercase();
|
||||
|
||||
let credentials = db::users::credentials_by_email(state.db.pool(), &normalized)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let verified = match &credentials {
|
||||
Some((_, hash)) => auth::verify_password(&req.password, hash),
|
||||
None => {
|
||||
auth::verify_dummy(&req.password);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !verified {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let (user, _) = credentials.expect("verified implies Some");
|
||||
|
||||
auth::establish_session(&session, user.id, &user.email, user.role)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Log out: clear the session.
|
||||
#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))]
|
||||
pub(crate) async fn logout(session: Session) -> Result<StatusCode, StatusCode> {
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// The current authenticated user.
|
||||
#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))]
|
||||
pub(crate) async fn me(user: AuthUser) -> Json<UserView> {
|
||||
Json(UserView {
|
||||
id: user.id.to_string(),
|
||||
email: user.email.as_str().to_owned(),
|
||||
role: user.role.as_str().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all users (Admin only).
|
||||
#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))]
|
||||
pub(crate) async fn list_users(
|
||||
_auth: Authorized<ManageUsers>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<UserView>>, StatusCode> {
|
||||
let users = db::users::list_users(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(
|
||||
users
|
||||
.into_iter()
|
||||
.map(|u| UserView {
|
||||
id: u.id.to_string(),
|
||||
email: u.email.as_str().to_owned(),
|
||||
role: u.role.as_str().to_owned(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/objects/{id}/visibility",
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
request_body = VisibilityRequest,
|
||||
responses(
|
||||
(status = 204, description = "Visibility changed"),
|
||||
(status = 401), (status = 403),
|
||||
(status = 404, description = "No such object"),
|
||||
(status = 409, description = "Illegal visibility transition")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_visibility(
|
||||
_auth: Authorized<PublishObjects>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<VisibilityRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
// 404 (not 400) for an unparseable id — same non-leaking convention as the public
|
||||
// surface: never reveal whether an id could exist.
|
||||
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// TODO(#7): record the per-user actor (AuthUser carries the id) once auth-event
|
||||
// auditing lands; System for now.
|
||||
let result =
|
||||
db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
reindex(&state, object_id).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
|
||||
Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
|
||||
Err(db::catalog::VisibilityError::MissingRequiredFields(_)) => {
|
||||
Err(StatusCode::UNPROCESSABLE_ENTITY)
|
||||
}
|
||||
Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin routes, parameterized over [`AppState`].
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/login", post(login))
|
||||
.route("/api/admin/logout", post(logout))
|
||||
.route("/api/admin/me", get(me))
|
||||
.route("/api/admin/users", get(list_users))
|
||||
.route("/api/admin/objects/{id}/visibility", post(set_visibility))
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Admin authority-record management. Reads require `ViewInternal`; writes `EditCatalogue`.
|
||||
|
||||
use auth::{Authorized, EditCatalogue, ViewInternal};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
admin_objects::LabelView,
|
||||
admin_vocab::{CreatedId, InUseView, LabelInput},
|
||||
};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct AuthorityView {
|
||||
pub id: String,
|
||||
#[schema(value_type = domain::AuthorityKind)]
|
||||
pub kind: String,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LabelView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct NewAuthorityRequest {
|
||||
/// "person" | "organisation" | "place".
|
||||
pub kind: String,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LabelInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct KindQuery {
|
||||
kind: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/authorities",
|
||||
params(("kind" = String, Query, description = "person | organisation | place")),
|
||||
responses(
|
||||
(status = 200, body = [AuthorityView]),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 422)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_authorities(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<KindQuery>,
|
||||
) -> Result<Json<Vec<AuthorityView>>, StatusCode> {
|
||||
let kind = AuthorityKind::from_db(&q.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||
|
||||
let authorities = db::authority::list_by_kind(state.db.pool(), kind)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(
|
||||
authorities
|
||||
.into_iter()
|
||||
.map(|authority| AuthorityView {
|
||||
id: authority.id.to_string(),
|
||||
kind: authority.kind.as_str().to_owned(),
|
||||
external_uri: authority.external_uri,
|
||||
labels: authority
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|label| LabelView {
|
||||
lang: label.lang,
|
||||
label: label.label,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/admin/authorities",
|
||||
request_body = NewAuthorityRequest,
|
||||
responses(
|
||||
(status = 201, body = CreatedId),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 422)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_authority(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NewAuthorityRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
||||
let kind = AuthorityKind::from_db(&req.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||
|
||||
let new = NewAuthority {
|
||||
kind,
|
||||
external_uri: req.external_uri,
|
||||
labels: req
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|label| LocalizedLabel {
|
||||
lang: label.lang,
|
||||
label: label.label,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let id =
|
||||
db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateAuthorityRequest {
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LabelInput>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/admin/authorities/{id}",
|
||||
request_body = UpdateAuthorityRequest,
|
||||
params(("id" = String, Path, description = "Authority id (UUID)")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_authority(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateAuthorityRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let id = id
|
||||
.parse::<AuthorityId>()
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let labels: Vec<LocalizedLabel> = req
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|l| LocalizedLabel {
|
||||
lang: l.lang,
|
||||
label: l.label,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let existed = db::authority::update_authority(
|
||||
&mut tx,
|
||||
AuditActor::User(auth.user.id.to_uuid()),
|
||||
id,
|
||||
req.external_uri.as_deref(),
|
||||
&labels,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if existed {
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/admin/authorities/{id}",
|
||||
params(("id" = String, Path, description = "Authority id (UUID)")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_authority(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let Ok(id) = id.parse::<AuthorityId>() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id)
|
||||
.await
|
||||
{
|
||||
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
},
|
||||
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||
}
|
||||
Ok(db::DeleteOutcome::NotFound) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/admin/authorities",
|
||||
get(list_authorities).post(create_authority),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/authorities/{id}",
|
||||
axum::routing::patch(update_authority).delete(delete_authority),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
//! Admin catalogue-object surface (authenticated). Reads require `ViewInternal`;
|
||||
//! writes require `EditCatalogue`.
|
||||
|
||||
use auth::{AuthUser, Authorized, EditCatalogue, ViewInternal};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, put},
|
||||
};
|
||||
use domain::{
|
||||
AuditActor, AuthorityKind, CatalogueObject, FieldType, LocalizedLabel, NewFieldDefinition,
|
||||
ObjectId, ObjectInput, Visibility, VocabularyId,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, admin_vocab::LabelInput, reindex};
|
||||
|
||||
/// A localized label `{ lang, label }` (shared across admin views).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct LabelView {
|
||||
pub lang: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Full admin view of a catalogue object (all fields, all visibility levels).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct AdminObjectView {
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
/// `YYYY-MM-DD` or null.
|
||||
pub recording_date: Option<String>,
|
||||
/// "draft" | "internal" | "public".
|
||||
#[schema(value_type = domain::Visibility)]
|
||||
pub visibility: String,
|
||||
/// Flexible field values (key -> value).
|
||||
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||
pub fields: serde_json::Value,
|
||||
/// RFC3339 UTC timestamp.
|
||||
pub created_at: String,
|
||||
/// RFC3339 UTC timestamp.
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl AdminObjectView {
|
||||
pub(crate) fn from_object(o: &CatalogueObject) -> Self {
|
||||
AdminObjectView {
|
||||
id: o.id.to_string(),
|
||||
object_number: o.object_number.clone(),
|
||||
object_name: o.object_name.clone(),
|
||||
number_of_objects: o.number_of_objects,
|
||||
brief_description: o.brief_description.clone(),
|
||||
current_location: o.current_location.clone(),
|
||||
current_owner: o.current_owner.clone(),
|
||||
recorder: o.recorder.clone(),
|
||||
recording_date: o.recording_date.map(format_date),
|
||||
visibility: o.visibility.as_str().to_owned(),
|
||||
fields: o.fields.clone(),
|
||||
created_at: o
|
||||
.created_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default(),
|
||||
updated_at: o
|
||||
.updated_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of admin objects.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct AdminObjectPage {
|
||||
pub items: Vec<AdminObjectView>,
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Format a `time::Date` as `YYYY-MM-DD`.
|
||||
pub(crate) fn format_date(d: time::Date) -> String {
|
||||
let fmt = time::macros::format_description!("[year]-[month]-[day]");
|
||||
|
||||
d.format(&fmt).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse a `YYYY-MM-DD` string into a `time::Date`, returning 422 on failure.
|
||||
pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
|
||||
let fmt = time::macros::format_description!("[year]-[month]-[day]");
|
||||
|
||||
time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
|
||||
}
|
||||
|
||||
/// Query parameters for the object list: pagination plus whitelisted sort/order and
|
||||
/// optional visibility/quick-filter. All values are validated/clamped server-side; the
|
||||
/// `sort` token maps onto an enum (never a raw column name) before reaching SQL.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ObjectListParams {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
pub sort: Option<String>,
|
||||
pub order: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
impl ObjectListParams {
|
||||
fn limit(&self) -> i64 {
|
||||
self.limit
|
||||
.unwrap_or(crate::pagination::DEFAULT_LIMIT)
|
||||
.clamp(1, crate::pagination::MAX_LIMIT)
|
||||
}
|
||||
|
||||
fn offset(&self) -> i64 {
|
||||
self.offset.unwrap_or(0).max(0)
|
||||
}
|
||||
|
||||
fn sort(&self) -> db::catalog::ObjectSort {
|
||||
use db::catalog::ObjectSort;
|
||||
|
||||
match self.sort.as_deref() {
|
||||
Some("object_name") => ObjectSort::ObjectName,
|
||||
Some("updated_at") => ObjectSort::UpdatedAt,
|
||||
Some("created_at") => ObjectSort::CreatedAt,
|
||||
Some("visibility") => ObjectSort::Visibility,
|
||||
// Unknown or absent → stable default.
|
||||
_ => ObjectSort::ObjectNumber,
|
||||
}
|
||||
}
|
||||
|
||||
fn descending(&self) -> bool {
|
||||
self.order.as_deref() == Some("desc")
|
||||
}
|
||||
|
||||
/// Validate `visibility` against the domain enum; an unknown value is ignored
|
||||
/// (treated as no filter) so hand-edited URLs degrade gracefully instead of 500ing.
|
||||
fn visibility(&self) -> Option<&str> {
|
||||
self.visibility
|
||||
.as_deref()
|
||||
.filter(|v| Visibility::from_db(v).is_some())
|
||||
}
|
||||
|
||||
fn q(&self) -> Option<&str> {
|
||||
self.q.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// List objects (paginated, all visibility levels). Requires `ViewInternal`.
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/objects",
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "1..=200, default 50"),
|
||||
("offset" = Option<i64>, Query, description = "default 0"),
|
||||
("sort" = Option<String>, Query,
|
||||
description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"),
|
||||
("order" = Option<String>, Query, description = "asc | desc (default asc)"),
|
||||
("visibility" = Option<String>, Query,
|
||||
description = "draft | internal | public — filter; unknown values ignored"),
|
||||
("q" = Option<String>, Query,
|
||||
description = "quick filter: ILIKE match on object_number or object_name")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = AdminObjectPage),
|
||||
(status = 401),
|
||||
(status = 403)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_objects(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ObjectListParams>,
|
||||
) -> Result<Json<AdminObjectPage>, StatusCode> {
|
||||
let (limit, offset) = (params.limit(), params.offset());
|
||||
|
||||
let query = db::catalog::ObjectQuery {
|
||||
sort: params.sort(),
|
||||
descending: params.descending(),
|
||||
visibility: params.visibility(),
|
||||
q: params.q(),
|
||||
};
|
||||
|
||||
let objects = db::catalog::list_objects_query(state.db.pool(), &query, limit, offset)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let total = db::catalog::count_objects_query(state.db.pool(), query.visibility, query.q)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(AdminObjectPage {
|
||||
items: objects.iter().map(AdminObjectView::from_object).collect(),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get one object (any visibility). Requires `ViewInternal`. 404 if missing.
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/objects/{id}",
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
responses(
|
||||
(status = 200, body = AdminObjectView),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_object(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(object_id) = id.parse::<ObjectId>() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
match db::catalog::object_by_id(state.db.pool(), object_id).await {
|
||||
Ok(Some(o)) => Json(AdminObjectView::from_object(&o)).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inventory-minimum fields for create. `recording_date` is `YYYY-MM-DD`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ObjectCreateRequest {
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<String>,
|
||||
/// "draft" | "internal" (public is rejected — publish via the visibility endpoint).
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// Inventory-minimum fields for update. Visibility is intentionally absent — it changes
|
||||
/// only through the stepwise publish endpoint.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ObjectUpdateRequest {
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<String>,
|
||||
}
|
||||
|
||||
/// The id of a newly created object.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct CreatedObject {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
fn actor(user: &AuthUser) -> AuditActor {
|
||||
AuditActor::User(user.id.to_uuid())
|
||||
}
|
||||
|
||||
/// Create an object (initial visibility Draft or Internal). Requires `EditCatalogue`.
|
||||
#[utoipa::path(
|
||||
post, path = "/api/admin/objects", request_body = ObjectCreateRequest,
|
||||
responses(
|
||||
(status = 201, body = CreatedObject),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 422, description = "Invalid input (e.g. visibility=public or bad date)")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_object(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ObjectCreateRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatedObject>), StatusCode> {
|
||||
if req.visibility == Visibility::Public {
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
let recording_date = req.recording_date.as_deref().map(parse_date).transpose()?;
|
||||
|
||||
let input = ObjectInput {
|
||||
object_number: req.object_number,
|
||||
object_name: req.object_name,
|
||||
number_of_objects: req.number_of_objects,
|
||||
brief_description: req.brief_description,
|
||||
current_location: req.current_location,
|
||||
current_owner: req.current_owner,
|
||||
recorder: req.recorder,
|
||||
recording_date,
|
||||
visibility: req.visibility,
|
||||
};
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let id = db::catalog::create_object(&mut tx, actor(&auth.user), &input)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
reindex(&state, id).await;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatedObject { id: id.to_string() }),
|
||||
))
|
||||
}
|
||||
|
||||
/// Update an object's inventory-minimum fields (NOT visibility). Requires `EditCatalogue`.
|
||||
#[utoipa::path(
|
||||
put, path = "/api/admin/objects/{id}", request_body = ObjectUpdateRequest,
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 422)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_object(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<ObjectUpdateRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let recording_date = req.recording_date.as_deref().map(parse_date).transpose()?;
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Read current visibility inside the tx so the read and update are atomic —
|
||||
// visibility changes only through the stepwise publish endpoint.
|
||||
let Some(current) = db::catalog::object_by_id(&mut *tx, object_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
else {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
};
|
||||
|
||||
let input = ObjectInput {
|
||||
object_number: req.object_number,
|
||||
object_name: req.object_name,
|
||||
number_of_objects: req.number_of_objects,
|
||||
brief_description: req.brief_description,
|
||||
current_location: req.current_location,
|
||||
current_owner: req.current_owner,
|
||||
recorder: req.recorder,
|
||||
recording_date,
|
||||
visibility: current.visibility,
|
||||
};
|
||||
|
||||
let existed = db::catalog::update_object(&mut tx, actor(&auth.user), object_id, &input)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if existed {
|
||||
reindex(&state, object_id).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an object. Requires `EditCatalogue`. 404 if it did not exist.
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/admin/objects/{id}",
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_object(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let existed = db::catalog::delete_object(&mut tx, actor(&auth.user), object_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if existed {
|
||||
reindex(&state, object_id).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
/// Field-definition descriptor for the UI to render forms.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct FieldDefinitionView {
|
||||
pub key: String,
|
||||
/// "text" | "localized_text" | "integer" | "date" | "boolean" | "term" | "authority".
|
||||
#[schema(value_type = domain::DataType)]
|
||||
pub data_type: String,
|
||||
pub vocabulary_id: Option<String>,
|
||||
#[schema(value_type = Option<domain::AuthorityKind>)]
|
||||
pub authority_kind: Option<String>,
|
||||
pub required: bool,
|
||||
pub group: Option<String>,
|
||||
pub labels: Vec<LabelView>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub(crate) struct NewFieldDefinitionRequest {
|
||||
pub key: String,
|
||||
/// text | localized_text | integer | date | boolean | term | authority
|
||||
pub data_type: String,
|
||||
pub vocabulary_id: Option<String>,
|
||||
pub authority_kind: Option<String>,
|
||||
pub required: bool,
|
||||
pub group: Option<String>,
|
||||
pub labels: Vec<LabelInput>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(crate) struct CreatedField {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// List all field definitions. Requires `ViewInternal`.
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/field-definitions",
|
||||
responses(
|
||||
(status = 200, body = [FieldDefinitionView]),
|
||||
(status = 401),
|
||||
(status = 403)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_field_definitions(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<FieldDefinitionView>>, StatusCode> {
|
||||
let defs = db::fields::list_field_definitions(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(
|
||||
defs.into_iter()
|
||||
.map(|def| {
|
||||
let (data_type, vocabulary_id, authority_kind) = def.field_type.to_parts();
|
||||
|
||||
FieldDefinitionView {
|
||||
key: def.key,
|
||||
data_type: data_type.to_owned(),
|
||||
vocabulary_id: vocabulary_id.map(|vocab_id| vocab_id.to_string()),
|
||||
authority_kind: authority_kind.map(|kind| kind.as_str().to_owned()),
|
||||
required: def.required,
|
||||
group: def.group_key,
|
||||
labels: def
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|label| LabelView {
|
||||
lang: label.lang,
|
||||
label: label.label,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a field definition. Requires `EditCatalogue`. All type/binding consistency
|
||||
/// (term needs a vocabulary, authority takes no vocabulary, scalars take no binding) is
|
||||
/// validated by `FieldType::from_parts`, which returns `None` for any bad combination.
|
||||
#[utoipa::path(
|
||||
post, path = "/api/admin/field-definitions",
|
||||
request_body = NewFieldDefinitionRequest,
|
||||
responses(
|
||||
(status = 201, body = CreatedField),
|
||||
(status = 400, description = "Malformed vocabulary_id or authority_kind"),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 409, description = "Duplicate key"),
|
||||
(status = 422, description = "Inconsistent type/binding")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_field_definition(
|
||||
_auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NewFieldDefinitionRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatedField>), StatusCode> {
|
||||
let vocabulary_id = match req.vocabulary_id.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some(s) => Some(
|
||||
s.parse::<VocabularyId>()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
),
|
||||
};
|
||||
|
||||
let authority_kind = match req.authority_kind.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some(s) => Some(AuthorityKind::from_db(s).ok_or(StatusCode::BAD_REQUEST)?),
|
||||
};
|
||||
|
||||
let field_type = FieldType::from_parts(&req.data_type, vocabulary_id, authority_kind)
|
||||
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||
|
||||
let new = NewFieldDefinition {
|
||||
key: req.key,
|
||||
field_type,
|
||||
required: req.required,
|
||||
group_key: req.group,
|
||||
labels: req
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|l| LocalizedLabel {
|
||||
lang: l.lang,
|
||||
label: l.label,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
match db::fields::create_field_definition(&mut tx, &new).await {
|
||||
Ok(_) => {
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(CreatedField { key: new.key })))
|
||||
}
|
||||
Err(err) => {
|
||||
match err.as_database_error().and_then(|e| e.code()).as_deref() {
|
||||
// Duplicate `key` violates the unique index.
|
||||
Some("23505") => Err(StatusCode::CONFLICT),
|
||||
// Referenced vocabulary doesn't exist — client error, not server fault.
|
||||
Some("23503") => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||
// CHECK constraint violated (e.g. empty key) — client error.
|
||||
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields that may be changed on an existing field definition. `key`, `data_type`, and
|
||||
/// binding are immutable and intentionally absent from this request.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateFieldDefinitionRequest {
|
||||
pub required: bool,
|
||||
pub group: Option<String>,
|
||||
pub labels: Vec<LabelInput>,
|
||||
}
|
||||
|
||||
/// Update a field definition's mutable attributes (labels, group, required).
|
||||
/// `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/admin/field-definitions/{key}",
|
||||
request_body = UpdateFieldDefinitionRequest,
|
||||
params(("key" = String, Path, description = "Field definition key")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 422, description = "CHECK constraint violated (e.g. empty label)")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_field_definition(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
Json(req): Json<UpdateFieldDefinitionRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let labels: Vec<LocalizedLabel> = req
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|l| LocalizedLabel {
|
||||
lang: l.lang,
|
||||
label: l.label,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let result = db::fields::update_field_definition(
|
||||
&mut tx,
|
||||
actor(&auth.user),
|
||||
&key,
|
||||
req.required,
|
||||
req.group.as_deref(),
|
||||
&labels,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(true) => {
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Ok(false) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
match err.as_database_error().and_then(|e| e.code()).as_deref() {
|
||||
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
|
||||
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a field definition. Blocked (409) when catalogue objects store a value under
|
||||
/// this key. Requires `EditCatalogue`.
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/admin/field-definitions/{key}",
|
||||
params(("key" = String, Path, description = "Field definition key")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 409, body = crate::admin_vocab::InUseView,
|
||||
description = "Field is used by catalogue objects")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_field_definition(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(key): Path<String>,
|
||||
) -> Response {
|
||||
use crate::admin_vocab::InUseView;
|
||||
|
||||
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await {
|
||||
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
},
|
||||
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||
}
|
||||
Ok(db::DeleteOutcome::NotFound) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct FieldErrorView {
|
||||
/// The flexible-field key that was rejected.
|
||||
pub field: String,
|
||||
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Replace an object's flexible-field values (validated against the registry).
|
||||
///
|
||||
/// **Replace semantics:** the body is the *complete* desired field set. Omitting a key
|
||||
/// that was previously set removes it — send every key the caller wants to retain.
|
||||
///
|
||||
/// Requires `EditCatalogue`.
|
||||
#[utoipa::path(
|
||||
put, path = "/api/admin/objects/{id}/fields",
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
request_body = Object,
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404, description = "Object not found"),
|
||||
(status = 422, body = FieldErrorView, description = "A field was rejected")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn set_fields(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(values): Json<serde_json::Map<String, serde_json::Value>>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
let Ok(object_id) = id.parse::<ObjectId>() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let mut tx = match state.db.pool().begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
|
||||
let result =
|
||||
db::catalog::set_object_fields(&mut tx, actor(&auth.user), object_id, &values).await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if tx.commit().await.is_err() {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
||||
reindex(&state, object_id).await;
|
||||
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(db::catalog::FieldError::ObjectNotFound) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(db::catalog::FieldError::Db(_)) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
Err(db::catalog::FieldError::UnknownField(field)) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(FieldErrorView {
|
||||
field,
|
||||
code: "unknown".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(db::catalog::FieldError::TypeMismatch { field, .. }) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(FieldErrorView {
|
||||
field,
|
||||
code: "type_mismatch".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(db::catalog::FieldError::Unresolved { field, .. }) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(FieldErrorView {
|
||||
field,
|
||||
code: "unresolved".to_owned(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin object routes, parameterized over [`AppState`].
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/objects", get(list_objects).post(create_object))
|
||||
.route(
|
||||
"/api/admin/objects/{id}",
|
||||
get(get_object).put(update_object).delete(delete_object),
|
||||
)
|
||||
.route("/api/admin/objects/{id}/fields", put(set_fields))
|
||||
.route(
|
||||
"/api/admin/field-definitions",
|
||||
get(list_field_definitions).post(create_field_definition),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/field-definitions/{key}",
|
||||
axum::routing::patch(update_field_definition).delete(delete_field_definition),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Admin full-text search over catalogue objects. Read capability: `ViewInternal`
|
||||
//! (admins search across all visibility levels). Backed by the Meilisearch index.
|
||||
|
||||
use auth::{Authorized, ViewInternal};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct SearchParams {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
visibility: Option<String>,
|
||||
offset: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct SearchHitView {
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
#[schema(value_type = domain::Visibility)]
|
||||
pub visibility: String,
|
||||
pub recording_date: Option<String>,
|
||||
pub snippet: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct SearchResultsView {
|
||||
pub hits: Vec<SearchHitView>,
|
||||
/// Meilisearch's estimate of the total number of matches.
|
||||
pub estimated_total: usize,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/search",
|
||||
params(
|
||||
("q" = String, Query, description = "Search query text"),
|
||||
("visibility" = Option<String>, Query, description = "Filter: draft|internal|public"),
|
||||
("offset" = Option<i64>, Query, description = "default 0"),
|
||||
("limit" = Option<i64>, Query, description = "1..=50, default 20")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = SearchResultsView),
|
||||
(status = 400, description = "Invalid visibility value"),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 503, description = "Search is not configured")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn search_objects(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<SearchResultsView>, StatusCode> {
|
||||
let Some(search) = &state.search else {
|
||||
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||
};
|
||||
|
||||
let visibility = match params.visibility.as_deref() {
|
||||
None | Some("") => None,
|
||||
Some(v @ ("draft" | "internal" | "public")) => Some(v),
|
||||
Some(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
|
||||
let q = params.q.trim();
|
||||
|
||||
if q.is_empty() {
|
||||
return Ok(Json(SearchResultsView {
|
||||
hits: Vec::new(),
|
||||
estimated_total: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// Search uses a tighter default/cap (20, max 50) than the shared `Pagination`
|
||||
// (default 50, max 200): result pages are slower to scan than a raw object list.
|
||||
let offset = params.offset.unwrap_or(0).max(0) as usize;
|
||||
let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize;
|
||||
|
||||
let results = search
|
||||
.search_objects(q, visibility, offset, limit)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!(?err, "search query failed");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(SearchResultsView {
|
||||
hits: results
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|h| SearchHitView {
|
||||
id: h.id,
|
||||
object_number: h.object_number,
|
||||
object_name: h.object_name,
|
||||
brief_description: h.brief_description,
|
||||
visibility: h.visibility,
|
||||
recording_date: h.recording_date,
|
||||
snippet: h.snippet,
|
||||
})
|
||||
.collect(),
|
||||
estimated_total: results.estimated_total,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/admin/search", get(search_objects))
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Admin vocabulary + term management. Reads require `ViewInternal`; writes `EditCatalogue`.
|
||||
|
||||
use auth::{Authorized, EditCatalogue, ViewInternal};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, admin_objects::LabelView};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct VocabularyView {
|
||||
pub id: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct NewVocabularyRequest {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct LabelInput {
|
||||
pub lang: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct NewTermRequest {
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LabelInput>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct TermView {
|
||||
pub id: String,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LabelView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct CreatedId {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/vocabularies",
|
||||
responses(
|
||||
(status = 200, body = [VocabularyView]),
|
||||
(status = 401),
|
||||
(status = 403)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_vocabularies(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<VocabularyView>>, StatusCode> {
|
||||
let vocabs = db::vocab::list_vocabularies(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(
|
||||
vocabs
|
||||
.into_iter()
|
||||
.map(|vocab| VocabularyView {
|
||||
id: vocab.id.to_string(),
|
||||
key: vocab.key,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/admin/vocabularies",
|
||||
request_body = NewVocabularyRequest,
|
||||
responses(
|
||||
(status = 201, body = VocabularyView),
|
||||
(status = 401),
|
||||
(status = 403)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn create_vocabulary(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NewVocabularyRequest>,
|
||||
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
|
||||
let 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)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(VocabularyView {
|
||||
id: vocab.id.to_string(),
|
||||
key: vocab.key,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/vocabularies/{id}/terms",
|
||||
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
|
||||
responses(
|
||||
(status = 200, body = [TermView]),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list_terms(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Vec<TermView>>, StatusCode> {
|
||||
let vocab_id = id
|
||||
.parse::<VocabularyId>()
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let terms = db::vocab::list_terms(state.db.pool(), vocab_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(
|
||||
terms
|
||||
.into_iter()
|
||||
.map(|term| TermView {
|
||||
id: term.id.to_string(),
|
||||
external_uri: term.external_uri,
|
||||
labels: term
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|label| LabelView {
|
||||
lang: label.lang,
|
||||
label: label.label,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/admin/vocabularies/{id}/terms",
|
||||
request_body = NewTermRequest,
|
||||
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
|
||||
responses(
|
||||
(status = 201, body = CreatedId),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn add_term(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<NewTermRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
|
||||
let vocabulary_id = id
|
||||
.parse::<VocabularyId>()
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let new = NewTerm {
|
||||
vocabulary_id,
|
||||
external_uri: req.external_uri,
|
||||
labels: req
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|label| LocalizedLabel {
|
||||
lang: label.lang,
|
||||
label: label.label,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let term_id = db::vocab::add_term(&mut tx, 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
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatedId {
|
||||
id: term_id.to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// 409 body: how many catalogue objects still reference the entity.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct InUseView {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateTermRequest {
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LabelInput>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||
request_body = UpdateTermRequest,
|
||||
params(
|
||||
("id" = String, Path, description = "Vocabulary id (UUID)"),
|
||||
("term_id" = String, Path, description = "Term id (UUID)")
|
||||
),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn update_term(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path((id, term_id)): Path<(String, String)>,
|
||||
Json(req): Json<UpdateTermRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let vocabulary_id = id
|
||||
.parse::<VocabularyId>()
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let term_id = term_id
|
||||
.parse::<TermId>()
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let labels: Vec<LocalizedLabel> = req
|
||||
.labels
|
||||
.into_iter()
|
||||
.map(|l| LocalizedLabel {
|
||||
lang: l.lang,
|
||||
label: l.label,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let existed = db::vocab::update_term(
|
||||
&mut tx,
|
||||
AuditActor::User(auth.user.id.to_uuid()),
|
||||
vocabulary_id,
|
||||
term_id,
|
||||
req.external_uri.as_deref(),
|
||||
&labels,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if existed {
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Vocabulary id (UUID)"),
|
||||
("term_id" = String, Path, description = "Term id (UUID)")
|
||||
),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_term(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path((id, term_id)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>())
|
||||
else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
let outcome = db::vocab::delete_term(
|
||||
&mut tx,
|
||||
AuditActor::User(auth.user.id.to_uuid()),
|
||||
vocab_id,
|
||||
term_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match outcome {
|
||||
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
},
|
||||
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||
}
|
||||
Ok(db::DeleteOutcome::NotFound) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct RenameVocabularyRequest {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/admin/vocabularies/{id}",
|
||||
request_body = RenameVocabularyRequest,
|
||||
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 409, description = "Key already in use")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn rename_vocabulary(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<RenameVocabularyRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let id = id
|
||||
.parse::<VocabularyId>()
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let existed = db::vocab::rename_vocabulary(
|
||||
&mut tx,
|
||||
AuditActor::User(auth.user.id.to_uuid()),
|
||||
id,
|
||||
&req.key,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
|
||||
StatusCode::CONFLICT
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
if existed {
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/admin/vocabularies/{id}",
|
||||
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 401),
|
||||
(status = 403),
|
||||
(status = 404),
|
||||
(status = 409, body = InUseView, description = "Has terms or is bound by a field")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn delete_vocabulary(
|
||||
auth: Authorized<EditCatalogue>,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let Ok(id) = id.parse::<VocabularyId>() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let Ok(mut tx) = state.db.pool().begin().await else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await
|
||||
{
|
||||
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
},
|
||||
Ok(db::DeleteOutcome::InUse { count }) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
|
||||
}
|
||||
Ok(db::DeleteOutcome::NotFound) => {
|
||||
let _ = tx.rollback().await;
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/admin/vocabularies",
|
||||
get(list_vocabularies).post(create_vocabulary),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/vocabularies/{id}",
|
||||
axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/vocabularies/{id}/terms",
|
||||
get(list_terms).post(add_term),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||
axum::routing::patch(update_term).delete(delete_term),
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// Liveness payload: the process is running.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct Live {
|
||||
/// Always `"ok"` when the process serves requests.
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
/// Readiness payload: dependencies were checked.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct Ready {
|
||||
/// `"ok"` when ready, `"degraded"` otherwise.
|
||||
pub status: &'static str,
|
||||
/// Whether the database responded to a ping.
|
||||
pub database: bool,
|
||||
}
|
||||
|
||||
/// Liveness probe — no dependencies checked.
|
||||
#[utoipa::path(get, path = "/health/live", responses((status = 200, body = Live)))]
|
||||
pub(crate) async fn live() -> Json<Live> {
|
||||
Json(Live { status: "ok" })
|
||||
}
|
||||
|
||||
/// Readiness probe — confirms the database answers.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/ready",
|
||||
responses(
|
||||
(status = 200, body = Ready, description = "Ready"),
|
||||
(status = 503, body = Ready, description = "A dependency is unavailable")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn ready(State(state): State<AppState>) -> impl IntoResponse {
|
||||
match state.db.ping().await {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(Ready {
|
||||
status: "ok",
|
||||
database: true,
|
||||
}),
|
||||
),
|
||||
Err(_) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(Ready {
|
||||
status: "degraded",
|
||||
database: false,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Health routes, parameterized over [`AppState`].
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health/live", get(live))
|
||||
.route("/health/ready", get(ready))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! HTTP API: router, handlers, and OpenAPI document.
|
||||
|
||||
mod admin;
|
||||
mod admin_authorities;
|
||||
mod admin_objects;
|
||||
mod admin_search;
|
||||
mod admin_vocab;
|
||||
mod config;
|
||||
mod health;
|
||||
mod openapi;
|
||||
mod pagination;
|
||||
mod public;
|
||||
|
||||
use axum::Router;
|
||||
use db::Db;
|
||||
use time::Duration;
|
||||
use tower_sessions::cookie::SameSite;
|
||||
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
use tower_sessions_sqlx_store::PostgresStore;
|
||||
|
||||
/// Shared application state passed to handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// Database handle for this organization.
|
||||
pub db: Db,
|
||||
/// User-facing product name (from config). Never hardcoded.
|
||||
pub app_name: String,
|
||||
/// Whether the session cookie carries the `Secure` attribute (default true;
|
||||
/// disable only for plain-HTTP self-hosting).
|
||||
pub cookie_secure: bool,
|
||||
/// 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
|
||||
/// committed. Re-projects and indexes the object, or removes it if it no longer exists.
|
||||
/// Never fails the request — a search outage must not undo a committed write, and
|
||||
/// `reindex_all` is the recovery path. A no-op when search is not configured.
|
||||
pub(crate) async fn reindex(state: &AppState, id: domain::ObjectId) {
|
||||
let Some(search) = &state.search else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = search.sync_object(&state.db, id).await {
|
||||
tracing::error!(?err, object_id = %id, "search reindex after write failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the application router from shared state.
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
let store = PostgresStore::new(state.db.pool().clone());
|
||||
|
||||
let session_layer = SessionManagerLayer::new(store)
|
||||
.with_name("id")
|
||||
.with_http_only(true)
|
||||
.with_secure(state.cookie_secure)
|
||||
.with_same_site(SameSite::Strict)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::hours(8)));
|
||||
|
||||
Router::new()
|
||||
.merge(config::routes())
|
||||
.merge(health::routes())
|
||||
.merge(openapi::routes())
|
||||
.merge(public::routes())
|
||||
.merge(admin::routes())
|
||||
.merge(admin_objects::routes())
|
||||
.merge(admin_vocab::routes())
|
||||
.merge(admin_search::routes())
|
||||
.merge(admin_authorities::routes())
|
||||
.layer(session_layer)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Create the session store's table if absent. Run once at startup (and in tests
|
||||
/// before exercising auth). Separate from `Db::migrate` — the session library's own
|
||||
/// bookkeeping table.
|
||||
pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> {
|
||||
PostgresStore::new(db.pool().clone()).migrate().await
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
public::get_object,
|
||||
admin::login,
|
||||
admin::logout,
|
||||
admin::me,
|
||||
admin::list_users,
|
||||
admin::set_visibility,
|
||||
admin_objects::list_objects,
|
||||
admin_objects::get_object,
|
||||
admin_objects::create_object,
|
||||
admin_objects::update_object,
|
||||
admin_objects::delete_object,
|
||||
admin_objects::list_field_definitions,
|
||||
admin_objects::create_field_definition,
|
||||
admin_objects::update_field_definition,
|
||||
admin_objects::delete_field_definition,
|
||||
admin_objects::set_fields,
|
||||
admin_vocab::list_vocabularies,
|
||||
admin_vocab::create_vocabulary,
|
||||
admin_vocab::list_terms,
|
||||
admin_vocab::add_term,
|
||||
admin_vocab::update_term,
|
||||
admin_vocab::delete_term,
|
||||
admin_vocab::rename_vocabulary,
|
||||
admin_vocab::delete_vocabulary,
|
||||
admin_search::search_objects,
|
||||
admin_authorities::list_authorities,
|
||||
admin_authorities::create_authority,
|
||||
admin_authorities::update_authority,
|
||||
admin_authorities::delete_authority
|
||||
),
|
||||
components(schemas(
|
||||
config::ConfigView,
|
||||
health::Live,
|
||||
health::Ready,
|
||||
public::PublicView,
|
||||
public::PublicObjectPage,
|
||||
admin::LoginRequest,
|
||||
admin::UserView,
|
||||
admin::VisibilityRequest,
|
||||
admin_objects::AdminObjectView,
|
||||
admin_objects::AdminObjectPage,
|
||||
admin_objects::LabelView,
|
||||
admin_objects::ObjectCreateRequest,
|
||||
admin_objects::ObjectUpdateRequest,
|
||||
admin_objects::CreatedObject,
|
||||
admin_objects::FieldDefinitionView,
|
||||
admin_objects::NewFieldDefinitionRequest,
|
||||
admin_objects::UpdateFieldDefinitionRequest,
|
||||
admin_objects::CreatedField,
|
||||
admin_objects::FieldErrorView,
|
||||
admin_vocab::VocabularyView,
|
||||
admin_vocab::NewVocabularyRequest,
|
||||
admin_vocab::NewTermRequest,
|
||||
admin_vocab::LabelInput,
|
||||
admin_vocab::TermView,
|
||||
admin_vocab::CreatedId,
|
||||
admin_vocab::UpdateTermRequest,
|
||||
admin_vocab::InUseView,
|
||||
admin_vocab::RenameVocabularyRequest,
|
||||
admin_search::SearchHitView,
|
||||
admin_search::SearchResultsView,
|
||||
admin_authorities::AuthorityView,
|
||||
admin_authorities::NewAuthorityRequest,
|
||||
admin_authorities::UpdateAuthorityRequest,
|
||||
domain::Visibility,
|
||||
domain::AuthorityKind,
|
||||
domain::DataType
|
||||
)),
|
||||
info(title = "Collection Management System", version = "0.0.0")
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
/// Serve the OpenAPI document, overriding the title from runtime config so the
|
||||
/// product name is never hardcoded.
|
||||
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
||||
let mut doc = ApiDoc::openapi();
|
||||
|
||||
doc.info.title = state.app_name.clone();
|
||||
|
||||
Json(doc)
|
||||
}
|
||||
|
||||
/// OpenAPI routes, parameterized over [`AppState`].
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api-docs/openapi.json", get(openapi_json))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Shared pagination query parameters used by both admin and public handlers.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(crate) const DEFAULT_LIMIT: i64 = 50;
|
||||
pub(crate) const MAX_LIMIT: i64 = 200;
|
||||
|
||||
/// Pagination query parameters with sane defaults and a hard cap.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Pagination {
|
||||
pub(crate) limit: Option<i64>,
|
||||
pub(crate) offset: Option<i64>,
|
||||
}
|
||||
|
||||
impl Pagination {
|
||||
pub(crate) fn limit(&self) -> i64 {
|
||||
self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
|
||||
}
|
||||
|
||||
pub(crate) fn offset(&self) -> i64 {
|
||||
self.offset.unwrap_or(0).max(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Public, unauthenticated, read-only surface (`/api/public/**`).
|
||||
//!
|
||||
//! Serves only `public` records as a [`PublicView`] — a projection that carries
|
||||
//! ONLY public-safe fields. The never-public set (location, owner, recorder, dates,
|
||||
//! and any flexible fields) is excluded by construction: the type lacks those fields,
|
||||
//! so leaking one here is impossible. Per-field publishability (to surface selected
|
||||
//! flexible fields) is post-MVP.
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use domain::{CatalogueObject, ObjectId};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, pagination::Pagination};
|
||||
|
||||
/// A catalogue object as exposed on the public surface (public-safe fields only).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PublicView {
|
||||
/// Stable object id (UUID).
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
}
|
||||
|
||||
impl PublicView {
|
||||
fn from_object(object: &CatalogueObject) -> Self {
|
||||
PublicView {
|
||||
id: object.id.to_string(),
|
||||
object_number: object.object_number.clone(),
|
||||
object_name: object.object_name.clone(),
|
||||
brief_description: object.brief_description.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of public objects.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PublicObjectPage {
|
||||
pub items: Vec<PublicView>,
|
||||
/// Total number of public objects (independent of paging).
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// List public objects (paginated).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/public/objects",
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "Max items (1..=200, default 50)"),
|
||||
("offset" = Option<i64>, Query, description = "Items to skip (default 0)")
|
||||
),
|
||||
responses((status = 200, body = PublicObjectPage))
|
||||
)]
|
||||
pub(crate) async fn list_objects(
|
||||
State(state): State<AppState>,
|
||||
Query(page): Query<Pagination>,
|
||||
) -> Result<Json<PublicObjectPage>, StatusCode> {
|
||||
let (limit, offset) = (page.limit(), page.offset());
|
||||
|
||||
// `items` and `total` come from two separate queries; under concurrent
|
||||
// publish/unpublish they can momentarily disagree by one — acceptable for a
|
||||
// public read surface.
|
||||
let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!(?err, "listing public objects");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let total = db::catalog::count_public_objects(state.db.pool())
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!(?err, "counting public objects");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(PublicObjectPage {
|
||||
items: objects.iter().map(PublicView::from_object).collect(),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get one public object by id. Returns 404 if missing OR not public.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/public/objects/{id}",
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
responses(
|
||||
(status = 200, body = PublicView),
|
||||
(status = 404, description = "No public object with that id")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_object(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(object_id) = id.parse::<ObjectId>() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
match db::catalog::public_object_by_id(state.db.pool(), object_id).await {
|
||||
Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "fetching public object");
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Public routes, parameterized over [`AppState`].
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/public/objects", get(list_objects))
|
||||
.route("/api/public/objects/{id}", get(get_object))
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use db::{catalog, users};
|
||||
use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: auth::hash_password(password).unwrap(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
fn login_request(email: &str, password: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
|
||||
let raw = resp
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.expect("Set-Cookie")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
raw.split(';').next().unwrap().to_owned()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn login_then_me_returns_identity(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("admin@example.com", "s3cret-passw0rd"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let cookie = session_cookie(&resp);
|
||||
|
||||
let me = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/me")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(me.status(), StatusCode::OK);
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(json["email"], "admin@example.com");
|
||||
assert_eq!(json["role"], "admin");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn me_without_session_is_401(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/me")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn wrong_password_is_401(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "admin@example.com", "right", Role::Admin).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.oneshot(login_request("admin@example.com", "wrong"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn editor_cannot_list_users_but_admin_can(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||
seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let editor_cookie = session_cookie(&resp);
|
||||
|
||||
let listed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/users")
|
||||
.header(header::COOKIE, &editor_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("admin@example.com", "pw-admin-123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let admin_cookie = session_cookie(&resp);
|
||||
|
||||
let listed = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/users")
|
||||
.header(header::COOKIE, &admin_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn editor_can_publish_via_admin_endpoint(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&ObjectInput {
|
||||
object_number: "P-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Internal,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let cookie = session_cookie(&resp);
|
||||
|
||||
let publish = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/admin/objects/{id}/visibility"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"visibility":"public"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(publish.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn logout_invalidates_the_session(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await;
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("admin@example.com", "s3cret-passw0rd"))
|
||||
.await
|
||||
.unwrap();
|
||||
let cookie = session_cookie(&resp);
|
||||
|
||||
// logout with the session cookie
|
||||
let out = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/logout")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// the old cookie no longer authenticates
|
||||
let me = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/me")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(me.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn illegal_visibility_transition_is_409(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
// a draft object — draft -> public in one step is illegal (must pass through internal)
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&ObjectInput {
|
||||
object_number: "D-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let cookie = session_cookie(&resp);
|
||||
|
||||
let publish = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/admin/objects/{id}/visibility"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"visibility":"public"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(publish.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn publishing_without_required_field_is_422(pool: PgPool) {
|
||||
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
|
||||
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
db::fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "inscription".into(),
|
||||
field_type: FieldType::Text,
|
||||
required: true,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "Inscription".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&ObjectInput {
|
||||
object_number: "P-2".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Internal,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool.clone()));
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request("editor@example.com", "pw-editor-123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let cookie = session_cookie(&resp);
|
||||
|
||||
// publishing while a required field has no value -> 422, visibility unchanged
|
||||
let publish = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/admin/objects/{id}/visibility"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"visibility":"public"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(publish.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Internal);
|
||||
}
|
||||
@@ -0,0 +1,985 @@
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use db::{audit, users};
|
||||
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: auth::hash_password(password).unwrap(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
fn login_request(email: &str, password: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
|
||||
resp.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, email: &str, pw: &str) -> String {
|
||||
let resp = app.clone().oneshot(login_request(email, pw)).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
session_cookie(&resp)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_list_vocabulary_and_terms(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// create a vocabulary
|
||||
let created = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/vocabularies")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"key":"colour"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
let vocab: serde_json::Value =
|
||||
serde_json::from_slice(&created.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let vocab_id = vocab["id"].as_str().unwrap().to_owned();
|
||||
|
||||
// list vocabularies includes it
|
||||
let list = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/vocabularies")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let list_json: serde_json::Value =
|
||||
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert!(
|
||||
list_json
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["key"] == "colour")
|
||||
);
|
||||
|
||||
// add a term with labels
|
||||
let term = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/admin/vocabularies/{vocab_id}/terms"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"external_uri":null,"labels":[{"lang":"en","label":"red"},{"lang":"sv","label":"röd"}]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(term.status(), StatusCode::CREATED);
|
||||
|
||||
// list terms shows it (with both labels)
|
||||
let terms = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/admin/vocabularies/{vocab_id}/terms"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let terms_json: serde_json::Value =
|
||||
serde_json::from_slice(&terms.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let arr = terms_json.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["labels"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn vocabulary_create_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/vocabularies")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"key":"x"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
async fn app2_get(app: &axum::Router, cookie: &str, uri: &str) -> StatusCode {
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_and_list_authorities_by_kind(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let created = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/authorities")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"kind":"person","external_uri":null,"labels":[{"lang":"en","label":"Ada Lovelace"}]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
// list by kind
|
||||
let list = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/authorities?kind=person")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.status(), StatusCode::OK);
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert_eq!(json.as_array().unwrap().len(), 1);
|
||||
assert_eq!(json[0]["kind"], "person");
|
||||
|
||||
// a different kind is empty
|
||||
let places = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/authorities?kind=place")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(places.status(), StatusCode::OK);
|
||||
|
||||
let places_json: serde_json::Value =
|
||||
serde_json::from_slice(&places.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert!(places_json.as_array().unwrap().is_empty());
|
||||
|
||||
// bad kind → 422
|
||||
let bad = app2_get(&app, &cookie, "/api/admin/authorities?kind=alien").await;
|
||||
assert_eq!(bad, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"labels":[{"lang":"en","label":"X"}]}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
async fn send(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
body: Option<&str>,
|
||||
) -> axum::http::Response<Body> {
|
||||
let mut req = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, cookie);
|
||||
|
||||
if body.is_some() {
|
||||
req = req.header(header::CONTENT_TYPE, "application/json");
|
||||
}
|
||||
|
||||
let body = body
|
||||
.map(|b| Body::from(b.to_owned()))
|
||||
.unwrap_or_else(Body::empty);
|
||||
|
||||
app.clone().oneshot(req.body(body).unwrap()).await.unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn edit_and_delete_term(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let v = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/vocabularies",
|
||||
Some(r#"{"key":"material"}"#),
|
||||
)
|
||||
.await;
|
||||
let vid: serde_json::Value =
|
||||
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let vid = vid["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let t = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
&format!("/api/admin/vocabularies/{vid}/terms"),
|
||||
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
|
||||
)
|
||||
.await;
|
||||
let tid: serde_json::Value =
|
||||
serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let tid = tid["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let patched = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PATCH",
|
||||
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
|
||||
Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let deleted = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let again = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(again.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn term_edit_delete_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let term_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000";
|
||||
|
||||
let patch_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(term_uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"labels":[]}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let delete_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(term_uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn vocabulary_edit_delete_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let vocab_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000";
|
||||
|
||||
let patch_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(vocab_uri)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"key":"x"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let delete_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(vocab_uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn rename_and_delete_vocabulary(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let v = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/vocabularies",
|
||||
Some(r#"{"key":"old"}"#),
|
||||
)
|
||||
.await;
|
||||
let vid: serde_json::Value =
|
||||
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let vid = vid["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let renamed = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PATCH",
|
||||
&format!("/api/admin/vocabularies/{vid}"),
|
||||
Some(r#"{"key":"new"}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let deleted = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
&format!("/api/admin/vocabularies/{vid}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_vocabulary_with_terms_is_409(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let v = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/vocabularies",
|
||||
Some(r#"{"key":"material"}"#),
|
||||
)
|
||||
.await;
|
||||
let vid: serde_json::Value =
|
||||
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let vid = vid["id"].as_str().unwrap().to_owned();
|
||||
|
||||
send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
&format!("/api/admin/vocabularies/{vid}/terms"),
|
||||
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
|
||||
)
|
||||
.await;
|
||||
|
||||
let blocked = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
&format!("/api/admin/vocabularies/{vid}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(blocked.status(), StatusCode::CONFLICT);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(body["count"], 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_authority_referenced_is_409(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// create an authority
|
||||
let a = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/authorities",
|
||||
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Astrid"}]}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(a.status(), StatusCode::CREATED);
|
||||
|
||||
let aid: serde_json::Value =
|
||||
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let aid = aid["id"].as_str().unwrap().to_owned();
|
||||
|
||||
// create an authority-typed field definition
|
||||
send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/field-definitions",
|
||||
Some(
|
||||
r#"{"key":"maker","data_type":"authority","vocabulary_id":null,"authority_kind":"person","required":false,"group":null,"labels":[{"lang":"sv","label":"Tillverkare"}]}"#,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// create an object
|
||||
let obj = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/objects",
|
||||
Some(
|
||||
r#"{"object_number":"T-1","object_name":"test object","number_of_objects":1,"visibility":"draft"}"#,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(obj.status(), StatusCode::CREATED);
|
||||
|
||||
let obj_json: serde_json::Value =
|
||||
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
|
||||
|
||||
// set the object's maker field to the authority id
|
||||
let fields_body = format!(r#"{{"maker":"{aid}"}}"#);
|
||||
let set = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PUT",
|
||||
&format!("/api/admin/objects/{obj_id}/fields"),
|
||||
Some(&fields_body),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(set.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// delete the authority — must be blocked
|
||||
let blocked = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
&format!("/api/admin/authorities/{aid}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(blocked.status(), StatusCode::CONFLICT);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(body["count"], 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn edit_and_delete_authority(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let a = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/authorities",
|
||||
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#),
|
||||
)
|
||||
.await;
|
||||
let aid: serde_json::Value =
|
||||
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let aid = aid["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let patched = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PATCH",
|
||||
&format!("/api/admin/authorities/{aid}"),
|
||||
Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let deleted = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
&format!("/api/admin/authorities/{aid}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn edit_and_delete_field_definition(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// create a field definition
|
||||
send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/field-definitions",
|
||||
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
|
||||
)
|
||||
.await;
|
||||
|
||||
// PATCH: update required + group + labels
|
||||
let patched = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PATCH",
|
||||
"/api/admin/field-definitions/weight",
|
||||
Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// PATCH unknown key → 404
|
||||
let missing = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PATCH",
|
||||
"/api/admin/field-definitions/nope",
|
||||
Some(r#"{"required":false,"group":null,"labels":[]}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// DELETE the (unreferenced) field definition
|
||||
let deleted = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
"/api/admin/field-definitions/weight",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// DELETE again → 404
|
||||
let again = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
"/api/admin/field-definitions/weight",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(again.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_field_definition_referenced_is_409(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// create a field definition
|
||||
send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/field-definitions",
|
||||
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
|
||||
)
|
||||
.await;
|
||||
|
||||
// create an object and set the field
|
||||
let obj = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/objects",
|
||||
Some(r#"{"object_number":"T-2","object_name":"test","number_of_objects":1,"visibility":"draft"}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(obj.status(), StatusCode::CREATED);
|
||||
|
||||
let obj_json: serde_json::Value =
|
||||
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let set = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"PUT",
|
||||
&format!("/api/admin/objects/{obj_id}/fields"),
|
||||
Some(r#"{"weight":42}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(set.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
// delete the field definition — must be blocked
|
||||
let blocked = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"DELETE",
|
||||
"/api/admin/field-definitions/weight",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(blocked.status(), StatusCode::CONFLICT);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(body["count"], 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn listed_object_carries_timestamps(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let created = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/objects",
|
||||
Some(r#"{"object_number":"TS-1","object_name":"clock","number_of_objects":1,"visibility":"draft"}"#),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
let list = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
|
||||
assert_eq!(list.status(), StatusCode::OK);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
let item = &body["items"][0];
|
||||
let created_at = item["created_at"].as_str().unwrap();
|
||||
let updated_at = item["updated_at"].as_str().unwrap();
|
||||
assert!(!created_at.is_empty(), "created_at must be non-empty");
|
||||
assert!(!updated_at.is_empty(), "updated_at must be non-empty");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn list_objects_sort_filter_quick_search(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let create = |number: &str, name: &str| {
|
||||
format!(
|
||||
r#"{{"object_number":"{number}","object_name":"{name}","number_of_objects":1,"visibility":"draft"}}"#
|
||||
)
|
||||
};
|
||||
|
||||
for (number, name) in [
|
||||
("FOO-1", "foo apple"),
|
||||
("FOO-2", "foo banana"),
|
||||
("BAR-1", "bar cherry"),
|
||||
] {
|
||||
let resp = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"POST",
|
||||
"/api/admin/objects",
|
||||
Some(&create(number, name)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
// No params → default order is object_number ascending.
|
||||
let default = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&default.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let numbers: Vec<&str> = body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["object_number"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(numbers, ["BAR-1", "FOO-1", "FOO-2"]);
|
||||
assert_eq!(body["total"], 3);
|
||||
|
||||
// sort=object_name&order=desc&visibility=draft&q=foo
|
||||
let filtered = send(
|
||||
&app,
|
||||
&cookie,
|
||||
"GET",
|
||||
"/api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(filtered.status(), StatusCode::OK);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
let names: Vec<&str> = body["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["object_name"].as_str().unwrap())
|
||||
.collect();
|
||||
// Only the two "foo …" objects, name descending.
|
||||
assert_eq!(names, ["foo banana", "foo apple"]);
|
||||
assert_eq!(body["total"], 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let patch_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PATCH")
|
||||
.uri("/api/admin/field-definitions/weight")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"required":false,"group":null,"labels":[]}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let delete_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/admin/field-definitions/weight")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use db::users;
|
||||
use domain::{AuditActor, Email, NewUser, Role};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn post_json(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
uri: &str,
|
||||
body: &str,
|
||||
) -> axum::http::Response<Body> {
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: auth::hash_password(password).unwrap(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
resp.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn post_field(app: &axum::Router, cookie: &str, body: &str) -> axum::http::Response<Body> {
|
||||
post_json(app, cookie, "/api/admin/field-definitions", body).await
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/field-definitions")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"key":"x","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_scalar_field_then_lists_it(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = post_field(
|
||||
&app,
|
||||
&cookie,
|
||||
r#"{"key":"height_cm","data_type":"integer","required":true,"group":"Dimensions","labels":[{"lang":"en","label":"Height"},{"lang":"sv","label":"Höjd"}]}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert_eq!(body["key"], "height_cm");
|
||||
|
||||
let list = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/field-definitions")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let defs: serde_json::Value =
|
||||
serde_json::from_slice(&list.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert!(
|
||||
defs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["key"] == "height_cm")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn term_without_vocabulary_is_422(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = post_field(
|
||||
&app,
|
||||
&cookie,
|
||||
r#"{"key":"material","data_type":"term","required":false,"labels":[{"lang":"en","label":"Material"}]}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn duplicate_key_is_409(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let body = r#"{"key":"dup","data_type":"text","required":false,"labels":[{"lang":"en","label":"Dup"}]}"#;
|
||||
|
||||
assert_eq!(
|
||||
post_field(&app, &cookie, body).await.status(),
|
||||
StatusCode::CREATED
|
||||
);
|
||||
assert_eq!(
|
||||
post_field(&app, &cookie, body).await.status(),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_term_field_with_valid_vocabulary(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let vocab_resp = post_json(
|
||||
&app,
|
||||
&cookie,
|
||||
"/api/admin/vocabularies",
|
||||
r#"{"key":"material"}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(vocab_resp.status(), StatusCode::CREATED);
|
||||
|
||||
let vocab_body: serde_json::Value =
|
||||
serde_json::from_slice(&vocab_resp.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
|
||||
let vocab_id = vocab_body["id"].as_str().unwrap();
|
||||
|
||||
let resp = post_field(
|
||||
&app,
|
||||
&cookie,
|
||||
&format!(
|
||||
r#"{{"key":"material_ref","data_type":"term","vocabulary_id":"{vocab_id}","required":false,"labels":[{{"lang":"en","label":"Material"}}]}}"#
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn term_with_nonexistent_vocabulary_is_422(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = post_field(
|
||||
&app,
|
||||
&cookie,
|
||||
r#"{"key":"bad_ref","data_type":"term","vocabulary_id":"00000000-0000-0000-0000-000000000000","required":false,"labels":[{"lang":"en","label":"Bad"}]}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_authority_field(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = post_field(
|
||||
&app,
|
||||
&cookie,
|
||||
r#"{"key":"maker_ref","data_type":"authority","authority_kind":"person","required":false,"labels":[{"lang":"en","label":"Maker"}]}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn empty_key_is_422(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = post_field(
|
||||
&app,
|
||||
&cookie,
|
||||
r#"{"key":"","data_type":"text","required":false,"labels":[{"lang":"en","label":"X"}]}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use db::{catalog, users};
|
||||
use domain::{
|
||||
AuditActor, Email, FieldType, LocalizedLabel, NewFieldDefinition, NewUser, ObjectInput, Role,
|
||||
Visibility,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: auth::hash_password(password).unwrap(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
fn login_request(email: &str, password: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn session_cookie(resp: &axum::http::Response<Body>) -> String {
|
||||
resp.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, email: &str, pw: &str) -> String {
|
||||
let resp = app.clone().oneshot(login_request(email, pw)).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
session_cookie(&resp)
|
||||
}
|
||||
|
||||
fn obj(number: &str, name: &str, v: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: Some("d".into()),
|
||||
current_location: Some("vault".into()),
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: v,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn list_and_get_require_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let list = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/objects")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let get = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/admin/objects/{}", domain::ObjectId::new()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(list.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(get.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn list_shows_all_visibility_levels(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&obj("D-1", "draft", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&obj("P-1", "pub", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(json["total"], 2);
|
||||
|
||||
let items = json["items"].as_array().unwrap();
|
||||
assert!(items.iter().any(|i| i["object_number"] == "D-1"));
|
||||
assert!(items[0].get("current_location").is_some());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn get_by_id_returns_full_view(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&obj("D-1", "draft", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/admin/objects/{id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(json["object_number"], "D-1");
|
||||
assert_eq!(json["visibility"], "draft");
|
||||
|
||||
let missing = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/admin/objects/{}", domain::ObjectId::new()))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_update_delete_lifecycle(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool.clone()));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// create (internal allowed)
|
||||
let create = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"A-1","object_name":"amphora","number_of_objects":1,"visibility":"internal"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
|
||||
let created: serde_json::Value =
|
||||
serde_json::from_slice(&create.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let id = created["id"].as_str().unwrap().to_owned();
|
||||
|
||||
// update (name change; visibility omitted and unchanged)
|
||||
let update = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/admin/objects/{id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"A-1","object_name":"big amphora","number_of_objects":2}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(update.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let obj = catalog::object_by_id(db.pool(), id.parse().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(obj.object_name, "big amphora");
|
||||
assert_eq!(obj.visibility, Visibility::Internal); // unchanged by update
|
||||
|
||||
// delete
|
||||
let del = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/admin/objects/{id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(del.status(), StatusCode::NO_CONTENT);
|
||||
assert!(
|
||||
catalog::object_by_id(db.pool(), id.parse().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_rejects_public_visibility(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"A-1","object_name":"x","number_of_objects":1,"visibility":"public"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn set_fields_and_list_field_definitions(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
db::fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "inscription".into(),
|
||||
field_type: FieldType::Text,
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "Inscription".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&obj("A-1", "amphora", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool.clone()));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// field-definitions list
|
||||
let defs = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/field-definitions")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(defs.status(), StatusCode::OK);
|
||||
|
||||
let defs_json: serde_json::Value =
|
||||
serde_json::from_slice(&defs.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert!(
|
||||
defs_json
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|d| d["key"] == "inscription" && d["data_type"] == "text")
|
||||
);
|
||||
|
||||
// set the field
|
||||
let set = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/admin/objects/{id}/fields"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"inscription":"To the gods"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(set.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let stored = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(stored.fields["inscription"], "To the gods");
|
||||
|
||||
// unknown field → 422
|
||||
let bad = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/admin/objects/{id}/fields"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"nope":"x"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bad.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn set_fields_unknown_field_returns_field_detail(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
|
||||
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&obj("A-1", "amphora", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!("/api/admin/objects/{id}/fields"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"definitely_not_a_field":"x"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert_eq!(body["field"], "definitely_not_a_field");
|
||||
assert_eq!(body["code"], "unknown");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"A-1","object_name":"x","number_of_objects":1,"visibility":"draft"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn field_endpoints_require_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
|
||||
let defs = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/field-definitions")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let set = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(format!(
|
||||
"/api/admin/objects/{}/fields",
|
||||
domain::ObjectId::new()
|
||||
))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"k":"v"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(defs.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(set.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use db::users;
|
||||
use domain::{AuditActor, Email, NewUser, Role};
|
||||
use http_body_util::BodyExt;
|
||||
use search::SearchClient;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("api_search_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn state(pool: PgPool, search: Option<SearchClient>) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: auth::hash_password(password).unwrap(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
resp.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn search_requires_auth(pool: PgPool) {
|
||||
migrate_sessions(&db::Db::from_pool(pool.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (url, key) = meili();
|
||||
let search = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
search.ensure_index().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool, Some(search)));
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=bronze")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn search_returns_results_and_validates_params(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 (url, key) = meili();
|
||||
let search = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
search.ensure_index().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool.clone(), Some(search)));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let create = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"R-1","object_name":"astrolabe","number_of_objects":1,"visibility":"internal"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=astrolabe")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.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["estimated_total"], 1);
|
||||
assert_eq!(body["hits"][0]["object_name"], "astrolabe");
|
||||
|
||||
let empty = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(empty.status(), StatusCode::OK);
|
||||
|
||||
let empty_body: serde_json::Value =
|
||||
serde_json::from_slice(&empty.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert_eq!(empty_body["estimated_total"], 0);
|
||||
|
||||
let bad = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=astrolabe&visibility=bogus")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn search_visibility_filter_narrows_results(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 (url, key) = meili();
|
||||
let search = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
search.ensure_index().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool.clone(), Some(search)));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let create_internal = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"R-2","object_name":"astrolabe-internal","number_of_objects":1,"visibility":"internal"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(create_internal.status(), StatusCode::CREATED);
|
||||
|
||||
let create_draft = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"R-3","object_name":"astrolabe-draft","number_of_objects":1,"visibility":"draft"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(create_draft.status(), StatusCode::CREATED);
|
||||
|
||||
let all = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=astrolabe")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(all.status(), StatusCode::OK);
|
||||
|
||||
let all_body: serde_json::Value =
|
||||
serde_json::from_slice(&all.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert_eq!(all_body["estimated_total"], 2);
|
||||
|
||||
let filtered = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=astrolabe&visibility=internal")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(filtered.status(), StatusCode::OK);
|
||||
|
||||
let filtered_body: serde_json::Value =
|
||||
serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
|
||||
assert_eq!(filtered_body["estimated_total"], 1);
|
||||
assert_eq!(filtered_body["hits"][0]["visibility"], "internal");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn search_unavailable_when_not_configured(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, None));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/search?q=bronze")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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; // for `oneshot`
|
||||
|
||||
fn state(pool: PgPool, app_name: &str) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: app_name.to_string(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn live_returns_ok(pool: PgPool) {
|
||||
let app = build_app(state(pool, "Test"));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health/live")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn ready_reports_database_true(pool: PgPool) {
|
||||
let app = build_app(state(pool, "Test"));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health/ready")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(json["database"], true);
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn openapi_doc_uses_configured_title(pool: PgPool) {
|
||||
let app = build_app(state(pool, "My Museum CMS"));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api-docs/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(json["info"]["title"], "My Museum CMS");
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use api::{AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use db::catalog;
|
||||
use domain::{AuditActor, ObjectInput, Visibility};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt; // for `oneshot`
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".to_string(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: Some("a description".into()),
|
||||
current_location: Some("vault B".into()), // never-public; must NOT appear in output
|
||||
current_owner: Some("the museum".into()), // never-public
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
async fn body_json(resp: axum::http::Response<Body>) -> serde_json::Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn list_returns_only_public_as_public_view(pool: PgPool) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("D-1", "draft vase", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("P-1", "public vase", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/public/objects")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["total"], 1);
|
||||
assert_eq!(json["items"].as_array().unwrap().len(), 1);
|
||||
let item = &json["items"][0];
|
||||
assert_eq!(item["object_number"], "P-1");
|
||||
assert_eq!(item["object_name"], "public vase");
|
||||
assert_eq!(item["brief_description"], "a description");
|
||||
assert!(item.get("current_location").is_none());
|
||||
assert!(item.get("current_owner").is_none());
|
||||
assert!(item.get("recorder").is_none());
|
||||
assert!(item.get("visibility").is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn get_public_object_returns_it(pool: PgPool) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("P-1", "public vase", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/public/objects/{id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["object_number"], "P-1");
|
||||
assert!(json.get("current_location").is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn non_public_objects_are_404(pool: PgPool) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let draft = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("D-1", "draft vase", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let internal = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("I-1", "internal vase", Visibility::Internal),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// both non-public states are hidden behind a 404 — not 403 — so existence isn't leaked
|
||||
let app = build_app(state(pool));
|
||||
for id in [draft, internal] {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/public/objects/{id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn get_missing_object_is_404(pool: PgPool) {
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/public/objects/{}", domain::ObjectId::new()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn openapi_lists_the_public_paths(pool: PgPool) {
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api-docs/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["paths"]["/api/public/objects"].is_object());
|
||||
assert!(json["paths"]["/api/public/objects/{id}"].is_object());
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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, ObjectId, Role};
|
||||
use http_body_util::BodyExt;
|
||||
use search::SearchClient;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("api_reindex_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn state(pool: PgPool, search: SearchClient) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search: Some(search),
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: auth::hash_password(password).unwrap(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, email: &str, password: &str) -> String {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/login")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"email":"{email}","password":"{password}"}}"#
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
resp.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn admin_writes_sync_the_search_index(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 (url, key) = meili();
|
||||
let search = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
search.ensure_index().await.unwrap();
|
||||
|
||||
// a second handle to the same index, used to observe what the handlers indexed
|
||||
let observer = search.clone();
|
||||
|
||||
let app = build_app(state(pool.clone(), search));
|
||||
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
|
||||
|
||||
// create via the admin API -> the object is indexed on commit
|
||||
let create = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/objects")
|
||||
.header(header::COOKIE, &cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"object_number":"R-1","object_name":"astrolabe","number_of_objects":1,"visibility":"internal"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::CREATED);
|
||||
|
||||
let created: serde_json::Value =
|
||||
serde_json::from_slice(&create.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
let id: ObjectId = created["id"].as_str().unwrap().parse().unwrap();
|
||||
|
||||
assert_eq!(observer.search("astrolabe").await.unwrap(), vec![id]);
|
||||
|
||||
// delete via the admin API -> the object drops out of the index
|
||||
let delete = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/admin/objects/{id}"))
|
||||
.header(header::COOKIE, &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(observer.search("astrolabe").await.unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "auth"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
argon2.workspace = true
|
||||
tower-sessions.workspace = true
|
||||
serde.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Authentication & authorization: argon2id password hashing and the type-driven
|
||||
//! axum extractors that gate handlers. Identity is read from the session (set at
|
||||
//! login); these extractors do not touch the database.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::request::Parts;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use domain::{Capability, Email, Role, UserId};
|
||||
use tower_sessions::Session;
|
||||
|
||||
const SESSION_USER_ID: &str = "user_id";
|
||||
const SESSION_EMAIL: &str = "email";
|
||||
const SESSION_ROLE: &str = "role";
|
||||
|
||||
/// Hash a plaintext password as an argon2id PHC string.
|
||||
pub fn hash_password(plaintext: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
Ok(Argon2::default()
|
||||
.hash_password(plaintext.as_bytes(), &salt)?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a
|
||||
/// wrong password OR a malformed/unparseable hash (never errors out).
|
||||
pub fn verify_password(plaintext: &str, phc: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(phc) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(plaintext.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Spend a verify's worth of time against a fixed dummy hash. Call this on the
|
||||
/// "user not found" login path to blunt user-enumeration via response timing.
|
||||
pub fn verify_dummy(plaintext: &str) {
|
||||
static DUMMY: OnceLock<String> = OnceLock::new();
|
||||
let hash =
|
||||
DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy"));
|
||||
|
||||
let _ = verify_password(plaintext, hash);
|
||||
}
|
||||
|
||||
/// Record the authenticated identity into the session (call after a successful
|
||||
/// password check). Cycles the session id first to prevent session fixation.
|
||||
pub async fn establish_session(
|
||||
session: &Session,
|
||||
id: UserId,
|
||||
email: &Email,
|
||||
role: Role,
|
||||
) -> Result<(), tower_sessions::session::Error> {
|
||||
session.cycle_id().await?;
|
||||
session.insert(SESSION_USER_ID, id.to_uuid()).await?;
|
||||
session.insert(SESSION_EMAIL, email.as_str()).await?;
|
||||
session.insert(SESSION_ROLE, role.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rejection for the auth extractors.
|
||||
#[derive(Debug, Clone, Copy, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("authentication required")]
|
||||
Unauthenticated,
|
||||
#[error("insufficient permissions")]
|
||||
Forbidden,
|
||||
/// The session store itself failed (e.g. the database is unreachable) — distinct
|
||||
/// from "no session", so an outage surfaces as 500 rather than a misleading 401.
|
||||
#[error("session store unavailable")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
|
||||
AuthError::Forbidden => StatusCode::FORBIDDEN,
|
||||
AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// The authenticated user, reconstructed from the session. Extracting this proves
|
||||
/// the request carries a valid session (else `401`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: UserId,
|
||||
pub email: Email,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// A failed extraction here means the SessionManagerLayer is missing from the
|
||||
// stack — a wiring bug, not an auth failure: surface it as 500.
|
||||
let session = Session::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| AuthError::Internal)?;
|
||||
|
||||
// For each key: a store error (DB down) is `Internal` (500); an absent key is
|
||||
// `Unauthenticated` (401) — these must not be conflated.
|
||||
let id: uuid::Uuid = session
|
||||
.get(SESSION_USER_ID)
|
||||
.await
|
||||
.map_err(|_| AuthError::Internal)?
|
||||
.ok_or(AuthError::Unauthenticated)?;
|
||||
|
||||
let email: String = session
|
||||
.get(SESSION_EMAIL)
|
||||
.await
|
||||
.map_err(|_| AuthError::Internal)?
|
||||
.ok_or(AuthError::Unauthenticated)?;
|
||||
|
||||
let role_str: String = session
|
||||
.get(SESSION_ROLE)
|
||||
.await
|
||||
.map_err(|_| AuthError::Internal)?
|
||||
.ok_or(AuthError::Unauthenticated)?;
|
||||
|
||||
let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?;
|
||||
|
||||
Ok(AuthUser {
|
||||
id: UserId::from_uuid(id),
|
||||
email: Email::from_db(email),
|
||||
role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero-sized type naming a required [`Capability`]. Implementors are used as the
|
||||
/// type parameter of [`Authorized`].
|
||||
pub trait CapabilityMarker {
|
||||
const CAP: Capability;
|
||||
}
|
||||
|
||||
/// Require `ManageUsers`.
|
||||
pub struct ManageUsers;
|
||||
|
||||
impl CapabilityMarker for ManageUsers {
|
||||
const CAP: Capability = Capability::ManageUsers;
|
||||
}
|
||||
|
||||
/// Require `EditCatalogue`.
|
||||
pub struct EditCatalogue;
|
||||
|
||||
impl CapabilityMarker for EditCatalogue {
|
||||
const CAP: Capability = Capability::EditCatalogue;
|
||||
}
|
||||
|
||||
/// Require `PublishObjects`.
|
||||
pub struct PublishObjects;
|
||||
|
||||
impl CapabilityMarker for PublishObjects {
|
||||
const CAP: Capability = Capability::PublishObjects;
|
||||
}
|
||||
|
||||
/// Require `ViewInternal`.
|
||||
pub struct ViewInternal;
|
||||
|
||||
impl CapabilityMarker for ViewInternal {
|
||||
const CAP: Capability = Capability::ViewInternal;
|
||||
}
|
||||
|
||||
/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized<C>`
|
||||
/// cannot run without the request's role allowing `C` (else `403`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Authorized<C: CapabilityMarker> {
|
||||
pub user: AuthUser,
|
||||
_capability: PhantomData<C>,
|
||||
}
|
||||
|
||||
impl<S, C> FromRequestParts<S> for Authorized<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
C: CapabilityMarker,
|
||||
{
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let user = AuthUser::from_request_parts(parts, state).await?;
|
||||
|
||||
if user.role.allows(C::CAP) {
|
||||
Ok(Authorized {
|
||||
user,
|
||||
_capability: PhantomData,
|
||||
})
|
||||
} else {
|
||||
Err(AuthError::Forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_then_verify_round_trips() {
|
||||
let hash = hash_password("correct horse battery staple").unwrap();
|
||||
assert!(hash.starts_with("$argon2id$"));
|
||||
assert!(verify_password("correct horse battery staple", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_wrong_password() {
|
||||
let hash = hash_password("right").unwrap();
|
||||
assert!(!verify_password("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_malformed_hash() {
|
||||
assert!(!verify_password("anything", "not-a-phc-string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_dummy_does_not_panic() {
|
||||
verify_dummy("any input");
|
||||
verify_dummy("called again"); // exercises the already-initialized OnceLock path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_markers_map_to_domain_capabilities() {
|
||||
assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
|
||||
assert_eq!(EditCatalogue::CAP, domain::Capability::EditCatalogue);
|
||||
assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
|
||||
assert_eq!(ViewInternal::CAP, domain::Capability::ViewInternal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "db"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
uuid.workspace = true
|
||||
time.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
time.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Append-only audit log. One database == one organization, so there is no org_id.
|
||||
CREATE TABLE audit_log (
|
||||
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'system')),
|
||||
actor_id UUID,
|
||||
action TEXT NOT NULL CHECK (action IN ('created', 'updated', 'deleted')),
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
CONSTRAINT actor_id_matches_kind CHECK (
|
||||
(actor_kind = 'user' AND actor_id IS NOT NULL) OR
|
||||
(actor_kind = 'system' AND actor_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, seq);
|
||||
|
||||
-- Enforce append-only at the database level: reject any UPDATE or DELETE.
|
||||
CREATE OR REPLACE FUNCTION audit_log_reject_mutation() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_log is append-only; % is not permitted', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER audit_log_immutable
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_log_reject_mutation();
|
||||
|
||||
CREATE TRIGGER audit_log_no_truncate
|
||||
BEFORE TRUNCATE ON audit_log
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION audit_log_reject_mutation();
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Controlled vocabularies (term sources) and their terms.
|
||||
CREATE TABLE vocabulary (
|
||||
id UUID PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE -- e.g. 'material', 'object_name'
|
||||
);
|
||||
|
||||
CREATE TABLE term (
|
||||
id UUID PRIMARY KEY,
|
||||
vocabulary_id UUID NOT NULL REFERENCES vocabulary (id) ON DELETE RESTRICT,
|
||||
external_uri TEXT -- e.g. Getty AAT / KulturNav / Wikidata URI
|
||||
);
|
||||
CREATE INDEX term_vocabulary_idx ON term (vocabulary_id);
|
||||
|
||||
CREATE TABLE term_label (
|
||||
term_id UUID NOT NULL REFERENCES term (id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL CHECK (lang <> ''),
|
||||
label TEXT NOT NULL CHECK (label <> ''),
|
||||
PRIMARY KEY (term_id, lang)
|
||||
);
|
||||
|
||||
-- Authority records: person / organisation / place. Store once, link many.
|
||||
CREATE TABLE authority (
|
||||
id UUID PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('person', 'organisation', 'place')),
|
||||
external_uri TEXT
|
||||
);
|
||||
CREATE INDEX authority_kind_idx ON authority (kind);
|
||||
|
||||
CREATE TABLE authority_label (
|
||||
authority_id UUID NOT NULL REFERENCES authority (id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL CHECK (lang <> ''),
|
||||
label TEXT NOT NULL CHECK (label <> ''),
|
||||
PRIMARY KEY (authority_id, lang)
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
|
||||
CREATE TABLE object (
|
||||
id UUID PRIMARY KEY,
|
||||
object_number TEXT NOT NULL UNIQUE CHECK (object_number <> ''),
|
||||
object_name TEXT NOT NULL CHECK (object_name <> ''),
|
||||
number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
|
||||
brief_description TEXT CHECK (brief_description <> ''),
|
||||
current_location TEXT CHECK (current_location <> ''),
|
||||
current_owner TEXT CHECK (current_owner <> ''),
|
||||
recorder TEXT CHECK (recorder <> ''),
|
||||
recording_date DATE,
|
||||
visibility TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (visibility IN ('draft', 'internal', 'public')),
|
||||
-- updated_at is maintained by the repository (set to now() on update).
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX object_visibility_idx ON object (visibility);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Registry of flexible field definitions (the "schema of schemas").
|
||||
CREATE TABLE field_definition (
|
||||
id UUID PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE CHECK (key <> ''),
|
||||
data_type TEXT NOT NULL CHECK (data_type IN
|
||||
('text', 'localized_text', 'integer', 'date', 'boolean', 'term', 'authority')),
|
||||
vocabulary_id UUID REFERENCES vocabulary (id) ON DELETE RESTRICT,
|
||||
authority_kind TEXT CHECK (authority_kind IN ('person', 'organisation', 'place')),
|
||||
required BOOLEAN NOT NULL DEFAULT false,
|
||||
group_key TEXT CHECK (group_key <> ''),
|
||||
-- A term field must name a vocabulary; any other type must not.
|
||||
CONSTRAINT term_has_vocabulary CHECK ((data_type = 'term') = (vocabulary_id IS NOT NULL)),
|
||||
-- authority_kind is only meaningful for authority fields.
|
||||
CONSTRAINT authority_kind_only_for_authority
|
||||
CHECK (authority_kind IS NULL OR data_type = 'authority')
|
||||
);
|
||||
|
||||
CREATE TABLE field_definition_label (
|
||||
field_definition_id UUID NOT NULL REFERENCES field_definition (id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL CHECK (lang <> ''),
|
||||
label TEXT NOT NULL CHECK (label <> ''),
|
||||
PRIMARY KEY (field_definition_id, lang)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Flexible field values for a catalogue object, keyed by field-definition key.
|
||||
ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Users of this organization's instance. One database == one organization, so no
|
||||
-- org_id. Passwords are stored only as argon2id PHC strings.
|
||||
--
|
||||
-- `updated_at` is maintained manually in UPDATE statements (as in the object table);
|
||||
-- there is no auto-update trigger and no update path exists yet.
|
||||
CREATE TABLE app_user (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL CHECK (email <> ''),
|
||||
password_hash TEXT NOT NULL CHECK (password_hash <> ''),
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'editor')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Case-insensitive uniqueness on email, enforced at the database. The application
|
||||
-- stores normalized (lowercased) emails and looks up via `lower(email) = $1`, so this
|
||||
-- functional unique index both backs those lookups and guarantees no case-variant
|
||||
-- duplicate can exist even if a non-normalized value were ever written.
|
||||
CREATE UNIQUE INDEX app_user_email_lower_key ON app_user (lower(email));
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Append-only audit log access.
|
||||
|
||||
use domain::{AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Append an audit event. Accepts any executor, so callers can record the event
|
||||
/// inside the same transaction as the change it describes.
|
||||
pub async fn record<'e, E>(executor: E, event: &NewAuditEvent) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let (actor_kind, actor_id) = match event.actor {
|
||||
AuditActor::User(id) => ("user", Some(id)),
|
||||
AuditActor::System => ("system", None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO audit_log \
|
||||
(actor_kind, actor_id, action, entity_type, entity_id, changes) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(actor_kind)
|
||||
.bind(actor_id)
|
||||
.bind(event.action.as_str())
|
||||
.bind(&event.entity_type)
|
||||
.bind(event.entity_id)
|
||||
.bind(sqlx::types::Json(&event.changes))
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the full history for one entity, oldest first.
|
||||
pub async fn history_for<'e, E>(
|
||||
executor: E,
|
||||
entity_type: &str,
|
||||
entity_id: Uuid,
|
||||
) -> Result<Vec<AuditEntry>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
// TODO: add LIMIT/keyset pagination before exposing history_for via the API.
|
||||
let rows = sqlx::query(
|
||||
"SELECT seq, at, actor_kind, actor_id, action, entity_type, entity_id, changes \
|
||||
FROM audit_log \
|
||||
WHERE entity_type = $1 AND entity_id = $2 \
|
||||
ORDER BY seq",
|
||||
)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(map_row).collect()
|
||||
}
|
||||
|
||||
fn map_row(row: sqlx::postgres::PgRow) -> Result<AuditEntry, sqlx::Error> {
|
||||
let seq: i64 = row.try_get("seq")?;
|
||||
let at: time::OffsetDateTime = row.try_get("at")?;
|
||||
let actor_kind: String = row.try_get("actor_kind")?;
|
||||
let actor_id: Option<Uuid> = row.try_get("actor_id")?;
|
||||
let action_str: String = row.try_get("action")?;
|
||||
let entity_type: String = row.try_get("entity_type")?;
|
||||
let entity_id: Uuid = row.try_get("entity_id")?;
|
||||
let changes: sqlx::types::Json<Vec<FieldChange>> = row.try_get("changes")?;
|
||||
|
||||
let actor = match actor_kind.as_str() {
|
||||
"user" => AuditActor::User(
|
||||
actor_id.ok_or_else(|| sqlx::Error::Decode("user actor missing actor_id".into()))?,
|
||||
),
|
||||
"system" => AuditActor::System,
|
||||
other => {
|
||||
return Err(sqlx::Error::Decode(
|
||||
format!("unknown actor_kind: {other}").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let action = domain::AuditAction::from_db(&action_str)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown action: {action_str}").into()))?;
|
||||
|
||||
Ok(AuditEntry {
|
||||
seq,
|
||||
at,
|
||||
actor,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
changes: changes.0,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Authority records (person / organisation / place).
|
||||
|
||||
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, 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();
|
||||
|
||||
sqlx::query("INSERT INTO authority (id, kind, external_uri) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(new.kind.as_str())
|
||||
.bind(new.external_uri.as_deref())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in &new.labels {
|
||||
sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Fetch one authority (with its labels).
|
||||
pub async fn authority_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: AuthorityId,
|
||||
) -> Result<Option<Authority>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \
|
||||
WHERE a.id = $1 GROUP BY a.id"
|
||||
);
|
||||
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
row.map(map_authority).transpose()
|
||||
}
|
||||
|
||||
/// List authorities of a given kind (with labels), ordered by id.
|
||||
pub async fn list_by_kind<'e, E>(
|
||||
executor: E,
|
||||
kind: AuthorityKind,
|
||||
) -> Result<Vec<Authority>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \
|
||||
WHERE a.kind = $1 GROUP BY a.id ORDER BY a.id"
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(kind.as_str())
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(map_authority).collect()
|
||||
}
|
||||
|
||||
/// Resolve an authority to an [`AuthorityRef`] (carrying its kind).
|
||||
pub async fn resolve_authority<'e, E>(
|
||||
executor: E,
|
||||
id: AuthorityId,
|
||||
) -> Result<Option<AuthorityRef>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM authority WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
match kind {
|
||||
Some(k) => {
|
||||
let kind = AuthorityKind::from_db(&k).ok_or_else(|| {
|
||||
sqlx::Error::Decode(format!("unknown authority kind: {k}").into())
|
||||
})?;
|
||||
|
||||
Ok(Some(AuthorityRef::new(id, kind)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an authority's `external_uri` and labels (full replace), recording an
|
||||
/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable.
|
||||
pub async fn update_authority(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: AuthorityId,
|
||||
external_uri: Option<&str>,
|
||||
labels: &[LocalizedLabel],
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.bind(external_uri)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if updated == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM authority_label WHERE authority_id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in labels {
|
||||
sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Count catalogue objects referencing `id` through an `authority`-typed field.
|
||||
pub async fn count_objects_referencing_authority<'e, E>(
|
||||
executor: E,
|
||||
id: AuthorityId,
|
||||
) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query_scalar(
|
||||
"SELECT count(*) FROM object o WHERE EXISTS ( \
|
||||
SELECT 1 FROM field_definition fd \
|
||||
WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )",
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete an authority (labels cascade) unless catalogue objects reference it,
|
||||
/// recording a `deleted` audit entry.
|
||||
pub async fn delete_authority(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: AuthorityId,
|
||||
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Ok(crate::DeleteOutcome::NotFound);
|
||||
}
|
||||
|
||||
let count = count_objects_referencing_authority(&mut *conn, id).await?;
|
||||
|
||||
if count > 0 {
|
||||
return Ok(crate::DeleteOutcome::InUse { count });
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM authority WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(crate::DeleteOutcome::Deleted)
|
||||
}
|
||||
|
||||
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
|
||||
let kind_str: String = row.try_get("kind")?;
|
||||
let kind = AuthorityKind::from_db(&kind_str)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {kind_str}").into()))?;
|
||||
|
||||
let labels: sqlx::types::Json<Vec<LocalizedLabel>> = row.try_get("labels")?;
|
||||
|
||||
Ok(Authority {
|
||||
id: AuthorityId::from_uuid(row.try_get("id")?),
|
||||
kind,
|
||||
external_uri: row.try_get("external_uri")?,
|
||||
labels: labels.0,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
|
||||
//! on the caller's connection, so the change and its audit entry commit together.
|
||||
|
||||
use domain::{
|
||||
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition,
|
||||
NewAuditEvent, ObjectId, ObjectInput, Visibility,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{audit, authority, fields, vocab};
|
||||
|
||||
/// The entity_type recorded in the audit log for catalogue objects.
|
||||
const ENTITY_TYPE: &str = "object";
|
||||
|
||||
/// The visibility value eligible for the public surface.
|
||||
const PUBLIC_VISIBILITY: &str = Visibility::Public.as_str();
|
||||
|
||||
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
|
||||
brief_description, current_location, current_owner, recorder, recording_date, \
|
||||
visibility, fields, created_at, updated_at";
|
||||
|
||||
/// Create an object and record a `created` audit entry, both on `conn`
|
||||
/// (pass a transaction connection `&mut *tx` so they commit atomically).
|
||||
pub async fn create_object(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
input: &ObjectInput,
|
||||
) -> Result<ObjectId, sqlx::Error> {
|
||||
let id = ObjectId::new();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO object \
|
||||
(id, object_number, object_name, number_of_objects, brief_description, \
|
||||
current_location, current_owner, recorder, recording_date, visibility) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&input.object_number)
|
||||
.bind(&input.object_name)
|
||||
.bind(input.number_of_objects)
|
||||
.bind(input.brief_description.as_deref())
|
||||
.bind(input.current_location.as_deref())
|
||||
.bind(input.current_owner.as_deref())
|
||||
.bind(input.recorder.as_deref())
|
||||
.bind(input.recording_date)
|
||||
.bind(input.visibility.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let changes = creation_changes(input);
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Created,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Fetch one object by id.
|
||||
pub async fn object_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: ObjectId,
|
||||
) -> Result<Option<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1");
|
||||
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
row.map(map_object).transpose()
|
||||
}
|
||||
|
||||
/// List all objects, ordered by object number.
|
||||
pub async fn list_objects<'e, E>(executor: E) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
// TODO: add LIMIT/keyset pagination before exposing this via the API.
|
||||
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number");
|
||||
|
||||
let rows = sqlx::query(&sql).fetch_all(executor).await?;
|
||||
|
||||
rows.into_iter().map(map_object).collect()
|
||||
}
|
||||
|
||||
/// Whitelisted, injection-safe sort columns for the object list. The client never
|
||||
/// supplies a column name directly — the API layer maps an opaque token onto a variant,
|
||||
/// and only [`ObjectSort::column`] (returning a `'static str`) reaches the SQL string.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ObjectSort {
|
||||
ObjectNumber,
|
||||
ObjectName,
|
||||
UpdatedAt,
|
||||
CreatedAt,
|
||||
Visibility,
|
||||
}
|
||||
|
||||
impl ObjectSort {
|
||||
fn column(self) -> &'static str {
|
||||
match self {
|
||||
ObjectSort::ObjectNumber => "object_number",
|
||||
ObjectSort::ObjectName => "object_name",
|
||||
ObjectSort::UpdatedAt => "updated_at",
|
||||
ObjectSort::CreatedAt => "created_at",
|
||||
ObjectSort::Visibility => "visibility",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters + ordering for a paged object query. `visibility`/`q` are optional;
|
||||
/// both are bound as parameters, never interpolated into the SQL string.
|
||||
pub struct ObjectQuery<'a> {
|
||||
pub sort: ObjectSort,
|
||||
pub descending: bool,
|
||||
pub visibility: Option<&'a str>,
|
||||
pub q: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Build the optional `WHERE` clause and its ordered bind values from the filters.
|
||||
/// Each clause references a positional placeholder (`$1`, `$2`, …) matching the order
|
||||
/// the returned `binds` are applied; the client's strings only ever arrive as binds.
|
||||
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
|
||||
let mut clauses = Vec::new();
|
||||
let mut binds = Vec::new();
|
||||
|
||||
if let Some(v) = visibility {
|
||||
binds.push(v.to_owned());
|
||||
|
||||
clauses.push(format!("visibility = ${}", binds.len()));
|
||||
}
|
||||
|
||||
if let Some(term) = q {
|
||||
binds.push(format!("%{term}%"));
|
||||
|
||||
let p = binds.len();
|
||||
|
||||
clauses.push(format!(
|
||||
"(object_number ILIKE ${p} OR object_name ILIKE ${p})"
|
||||
));
|
||||
}
|
||||
|
||||
let sql = if clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", clauses.join(" AND "))
|
||||
};
|
||||
|
||||
(sql, binds)
|
||||
}
|
||||
|
||||
/// List objects (all visibility levels) with whitelisted sort, optional visibility/quick
|
||||
/// filters, and paging. Ordering uses [`ObjectSort::column`] (a `'static str`) plus a
|
||||
/// stable secondary key, so no client-controlled string ever reaches the SQL text.
|
||||
pub async fn list_objects_query(
|
||||
pool: &sqlx::PgPool,
|
||||
query: &ObjectQuery<'_>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CatalogueObject>, sqlx::Error> {
|
||||
let (where_sql, binds) = where_clause(query.visibility, query.q);
|
||||
|
||||
let dir = if query.descending { "DESC" } else { "ASC" };
|
||||
|
||||
// Secondary key keeps ordering stable when the primary sort has ties.
|
||||
let sql = format!(
|
||||
"SELECT {OBJECT_COLUMNS} FROM object{where_sql} \
|
||||
ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}",
|
||||
query.sort.column(),
|
||||
binds.len() + 1,
|
||||
binds.len() + 2,
|
||||
);
|
||||
|
||||
let mut sql_query = sqlx::query(&sql);
|
||||
|
||||
for bind in &binds {
|
||||
sql_query = sql_query.bind(bind);
|
||||
}
|
||||
|
||||
let rows = sql_query.bind(limit).bind(offset).fetch_all(pool).await?;
|
||||
|
||||
rows.into_iter().map(map_object).collect()
|
||||
}
|
||||
|
||||
/// Count objects matching the optional visibility/quick filters (for pagination totals).
|
||||
pub async fn count_objects_query(
|
||||
pool: &sqlx::PgPool,
|
||||
visibility: Option<&str>,
|
||||
q: Option<&str>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let (where_sql, binds) = where_clause(visibility, q);
|
||||
|
||||
let sql = format!("SELECT count(*) AS n FROM object{where_sql}");
|
||||
|
||||
let mut sql_query = sqlx::query(&sql);
|
||||
|
||||
for bind in &binds {
|
||||
sql_query = sql_query.bind(bind);
|
||||
}
|
||||
|
||||
sql_query.fetch_one(pool).await?.try_get("n")
|
||||
}
|
||||
|
||||
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
||||
/// not public — callers map both to 404 so non-public existence isn't revealed.
|
||||
pub async fn public_object_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: ObjectId,
|
||||
) -> Result<Option<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2");
|
||||
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(id.to_uuid())
|
||||
.bind(PUBLIC_VISIBILITY)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
row.map(map_object).transpose()
|
||||
}
|
||||
|
||||
/// List **public** objects ordered by object number, with `limit`/`offset` paging.
|
||||
///
|
||||
/// `limit` and `offset` must be non-negative (Postgres rejects a negative `LIMIT`);
|
||||
/// the public API layer clamps them before calling.
|
||||
pub async fn list_public_objects<'e, E>(
|
||||
executor: E,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \
|
||||
ORDER BY object_number LIMIT $2 OFFSET $3"
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(PUBLIC_VISIBILITY)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(map_object).collect()
|
||||
}
|
||||
|
||||
/// Count all public objects (for pagination totals).
|
||||
pub async fn count_public_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1")
|
||||
.bind(PUBLIC_VISIBILITY)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
row.try_get("n")
|
||||
}
|
||||
|
||||
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
|
||||
let visibility_str: String = row.try_get("visibility")?;
|
||||
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
|
||||
sqlx::Error::Decode(format!("unknown visibility: {visibility_str}").into())
|
||||
})?;
|
||||
|
||||
Ok(CatalogueObject {
|
||||
id: ObjectId::from_uuid(row.try_get("id")?),
|
||||
object_number: row.try_get("object_number")?,
|
||||
object_name: row.try_get("object_name")?,
|
||||
number_of_objects: row.try_get("number_of_objects")?,
|
||||
brief_description: row.try_get("brief_description")?,
|
||||
current_location: row.try_get("current_location")?,
|
||||
current_owner: row.try_get("current_owner")?,
|
||||
recorder: row.try_get("recorder")?,
|
||||
recording_date: row.try_get("recording_date")?,
|
||||
visibility,
|
||||
fields: row.try_get("fields")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The mutable fields as `(name, value)` pairs, for building audit diffs.
|
||||
/// `None` means the field is unset (NULL).
|
||||
fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
|
||||
vec![
|
||||
("object_number", Some(json!(input.object_number))),
|
||||
("object_name", Some(json!(input.object_name))),
|
||||
("number_of_objects", Some(json!(input.number_of_objects))),
|
||||
(
|
||||
"brief_description",
|
||||
input.brief_description.as_ref().map(|v| json!(v)),
|
||||
),
|
||||
(
|
||||
"current_location",
|
||||
input.current_location.as_ref().map(|v| json!(v)),
|
||||
),
|
||||
(
|
||||
"current_owner",
|
||||
input.current_owner.as_ref().map(|v| json!(v)),
|
||||
),
|
||||
("recorder", input.recorder.as_ref().map(|v| json!(v))),
|
||||
("recording_date", input.recording_date.map(|d| json!(d))),
|
||||
("visibility", Some(json!(input.visibility.as_str()))),
|
||||
]
|
||||
}
|
||||
|
||||
/// Audit changes for a newly created object: every set field as an `after` value.
|
||||
/// Unset (`None`) optional fields are omitted — absence is conveyed by their not
|
||||
/// appearing, consistent with `FieldChange`'s `None`-means-no-value convention.
|
||||
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
|
||||
field_values(input)
|
||||
.into_iter()
|
||||
.filter_map(|(field, after)| {
|
||||
after.map(|a| FieldChange {
|
||||
field: field.to_owned(),
|
||||
before: None,
|
||||
after: Some(a),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Audit changes between two field sets: only the fields whose value changed.
|
||||
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
|
||||
field_values(old)
|
||||
.into_iter()
|
||||
.zip(field_values(new))
|
||||
.filter_map(|((field, before), (_, after))| {
|
||||
if before != after {
|
||||
Some(FieldChange {
|
||||
field: field.to_owned(),
|
||||
before,
|
||||
after,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Update an object and record an `updated` audit entry with field-level diffs,
|
||||
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
|
||||
/// (no fields changed) records no audit entry.
|
||||
pub async fn update_object(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
input: &ObjectInput,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let Some(old) = object_by_id(&mut *conn, id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
apply_object_update(&mut *conn, actor, id, &old.to_input(), input).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Diff `old`→`new`, write the changed columns + an `updated` audit entry, both on
|
||||
/// `conn`. A no-op (no field changed) touches neither the row's `updated_at` nor the
|
||||
/// audit log.
|
||||
async fn apply_object_update(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
old: &ObjectInput,
|
||||
new: &ObjectInput,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let changes = update_changes(old, new);
|
||||
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE object SET \
|
||||
object_number = $2, object_name = $3, number_of_objects = $4, \
|
||||
brief_description = $5, current_location = $6, current_owner = $7, \
|
||||
recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&new.object_number)
|
||||
.bind(&new.object_name)
|
||||
.bind(new.number_of_objects)
|
||||
.bind(new.brief_description.as_deref())
|
||||
.bind(new.current_location.as_deref())
|
||||
.bind(new.current_owner.as_deref())
|
||||
.bind(new.recorder.as_deref())
|
||||
.bind(new.recording_date)
|
||||
.bind(new.visibility.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why changing an object's visibility failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VisibilityError {
|
||||
#[error("object not found")]
|
||||
ObjectNotFound,
|
||||
#[error(transparent)]
|
||||
Illegal(#[from] IllegalTransition),
|
||||
#[error("missing required field(s): {}", .0.join(", "))]
|
||||
MissingRequiredFields(Vec<String>),
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Move an object to `target` visibility, enforcing the stepwise state machine, and
|
||||
/// audit the change. Uses the same diff/audit path as `update_object`, so only
|
||||
/// `visibility` appears in the audit entry — and setting to the current value is an
|
||||
/// idempotent no-op (no row touch, no audit). Pass a transaction connection.
|
||||
pub async fn set_visibility(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
target: Visibility,
|
||||
) -> Result<(), VisibilityError> {
|
||||
let Some(object) = object_by_id(&mut *conn, id).await? else {
|
||||
return Err(VisibilityError::ObjectNotFound);
|
||||
};
|
||||
|
||||
let new_visibility = object.visibility.transition_to(target)?;
|
||||
|
||||
// The publish gate: a record may only *become* public once every required field
|
||||
// has a value. The typed inventory-minimum columns are already NOT NULL, so only
|
||||
// the flexible required fields need checking here. Gated on an actual transition
|
||||
// into public so a set-to-current no-op stays a no-op (never a late rejection).
|
||||
if new_visibility == Visibility::Public && object.visibility != Visibility::Public {
|
||||
let missing = missing_required_fields(&mut *conn, &object.fields).await?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
return Err(VisibilityError::MissingRequiredFields(missing));
|
||||
}
|
||||
}
|
||||
|
||||
let old_input = object.to_input();
|
||||
let mut new_input = old_input.clone();
|
||||
|
||||
new_input.visibility = new_visibility;
|
||||
apply_object_update(&mut *conn, actor, id, &old_input, &new_input).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The keys of `required` field definitions that have no value on `fields` (absent or
|
||||
/// null). Empty when every required field is present.
|
||||
async fn missing_required_fields(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
fields: &Value,
|
||||
) -> Result<Vec<String>, sqlx::Error> {
|
||||
let definitions = fields::list_field_definitions(&mut *conn).await?;
|
||||
|
||||
Ok(definitions
|
||||
.into_iter()
|
||||
.filter(|definition| definition.required)
|
||||
.filter(|definition| fields.get(&definition.key).is_none_or(Value::is_null))
|
||||
.map(|definition| definition.key)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
||||
/// Returns `false` if the object did not exist.
|
||||
pub async fn delete_object(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM object WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Why setting flexible field values failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FieldError {
|
||||
#[error("object not found")]
|
||||
ObjectNotFound,
|
||||
#[error("unknown field: {0}")]
|
||||
UnknownField(String),
|
||||
#[error("field `{field}` expects a {expected} value")]
|
||||
TypeMismatch {
|
||||
field: String,
|
||||
expected: &'static str,
|
||||
},
|
||||
#[error("field `{field}`: value does not resolve to an existing {kind}")]
|
||||
Unresolved { field: String, kind: &'static str },
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Replace an object's flexible field values, validating each against the registry
|
||||
/// (type + term/authority resolution), and audit the per-field diff — all on `conn`.
|
||||
/// A no-op (identical to the current values) writes nothing and records no audit.
|
||||
///
|
||||
/// **Replace semantics:** `values` is the *complete* desired set. Omitting a key that
|
||||
/// was previously set REMOVES it (recorded in the audit as a removal); send every key
|
||||
/// the caller wants to retain.
|
||||
///
|
||||
/// Required-field *completeness* is intentionally NOT enforced here — a caller may set
|
||||
/// any subset. That check belongs to the publish gate (when moving to
|
||||
/// `Visibility::Public`, Plan 7).
|
||||
pub async fn set_object_fields(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
object_id: ObjectId,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<(), FieldError> {
|
||||
let Some(old) = object_by_id(&mut *conn, object_id).await? else {
|
||||
return Err(FieldError::ObjectNotFound);
|
||||
};
|
||||
|
||||
for (key, value) in values {
|
||||
validate_field(&mut *conn, key, value).await?;
|
||||
}
|
||||
|
||||
let new_fields = Value::Object(values.clone());
|
||||
let changes = field_map_changes(&old.fields, &new_fields);
|
||||
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE object SET fields = $2, updated_at = now() WHERE id = $1")
|
||||
.bind(object_id.to_uuid())
|
||||
.bind(&new_fields)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: object_id.to_uuid(),
|
||||
changes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_field(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
key: &str,
|
||||
value: &Value,
|
||||
) -> Result<(), FieldError> {
|
||||
let def = fields::field_definition_by_key(&mut *conn, key)
|
||||
.await?
|
||||
.ok_or_else(|| FieldError::UnknownField(key.to_owned()))?;
|
||||
|
||||
match def.field_type {
|
||||
FieldType::Text => require(value.is_string(), key, "text")?,
|
||||
FieldType::LocalizedText => require(
|
||||
value
|
||||
.as_object()
|
||||
.is_some_and(|o| o.values().all(Value::is_string)),
|
||||
key,
|
||||
"localized-text object {lang: string}",
|
||||
)?,
|
||||
FieldType::Integer => require(value.is_i64(), key, "integer")?,
|
||||
// Format/range validation (real date parsing) is deferred to issue #11;
|
||||
// here a date field only requires a string value.
|
||||
FieldType::Date => require(value.is_string(), key, "date string")?,
|
||||
FieldType::Boolean => require(value.is_boolean(), key, "boolean")?,
|
||||
FieldType::Term { vocabulary_id } => {
|
||||
let term_id = parse_uuid(value, key, "term id (uuid string)")?;
|
||||
|
||||
if vocab::resolve_term(
|
||||
&mut *conn,
|
||||
vocabulary_id,
|
||||
domain::TermId::from_uuid(term_id),
|
||||
)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(FieldError::Unresolved {
|
||||
field: key.to_owned(),
|
||||
kind: "term",
|
||||
});
|
||||
}
|
||||
}
|
||||
FieldType::Authority { kind } => {
|
||||
let authority_id = parse_uuid(value, key, "authority id (uuid string)")?;
|
||||
|
||||
match authority::resolve_authority(
|
||||
&mut *conn,
|
||||
domain::AuthorityId::from_uuid(authority_id),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(ref_) if kind.is_none_or(|k| ref_.kind() == k) => {}
|
||||
_ => {
|
||||
return Err(FieldError::Unresolved {
|
||||
field: key.to_owned(),
|
||||
kind: "authority",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(ok: bool, field: &str, expected: &'static str) -> Result<(), FieldError> {
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FieldError::TypeMismatch {
|
||||
field: field.to_owned(),
|
||||
expected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid(
|
||||
value: &Value,
|
||||
field: &str,
|
||||
expected: &'static str,
|
||||
) -> Result<uuid::Uuid, FieldError> {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<uuid::Uuid>().ok())
|
||||
.ok_or_else(|| FieldError::TypeMismatch {
|
||||
field: field.to_owned(),
|
||||
expected,
|
||||
})
|
||||
}
|
||||
|
||||
/// Per-key diff between two flexible-field maps. `before`/`after` are `None` when
|
||||
/// the key is absent on that side (so adds and removes are captured).
|
||||
fn field_map_changes(old: &Value, new: &Value) -> Vec<FieldChange> {
|
||||
let empty = serde_json::Map::new();
|
||||
let old_map = old.as_object().unwrap_or(&empty);
|
||||
let new_map = new.as_object().unwrap_or(&empty);
|
||||
|
||||
let keys: std::collections::BTreeSet<&String> = old_map.keys().chain(new_map.keys()).collect();
|
||||
|
||||
keys.into_iter()
|
||||
.filter_map(|key| {
|
||||
let before = old_map.get(key).cloned();
|
||||
let after = new_map.get(key).cloned();
|
||||
|
||||
if before != after {
|
||||
Some(FieldChange {
|
||||
field: key.clone(),
|
||||
before,
|
||||
after,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Registry of flexible field definitions.
|
||||
|
||||
use domain::{
|
||||
AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
|
||||
LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::audit;
|
||||
|
||||
const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
|
||||
|
||||
/// Labels aggregated per row as JSON, to read a definition and its labels in one query.
|
||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
|
||||
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
|
||||
|
||||
const SELECT_COLUMNS: &str =
|
||||
"fd.id, fd.key, fd.data_type, fd.vocabulary_id, fd.authority_kind, fd.required, fd.group_key";
|
||||
|
||||
/// Create a field definition and its labels. Multiple statements — pass a
|
||||
/// transaction connection (`&mut *tx`) for atomicity.
|
||||
pub async fn create_field_definition(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
new: &NewFieldDefinition,
|
||||
) -> Result<FieldDefinitionId, sqlx::Error> {
|
||||
let id = FieldDefinitionId::new();
|
||||
let (data_type, vocabulary_id, authority_kind) = new.field_type.to_parts();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO field_definition \
|
||||
(id, key, data_type, vocabulary_id, authority_kind, required, group_key) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&new.key)
|
||||
.bind(data_type)
|
||||
.bind(vocabulary_id.map(|v| v.to_uuid()))
|
||||
.bind(authority_kind.map(|k| k.as_str()))
|
||||
.bind(new.required)
|
||||
.bind(new.group_key.as_deref())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in &new.labels {
|
||||
sqlx::query(
|
||||
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Look up a field definition by its key (with labels).
|
||||
pub async fn field_definition_by_key<'e, E>(
|
||||
executor: E,
|
||||
key: &str,
|
||||
) -> Result<Option<FieldDefinition>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \
|
||||
FROM field_definition fd \
|
||||
LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \
|
||||
WHERE fd.key = $1 GROUP BY fd.id"
|
||||
);
|
||||
|
||||
let row = sqlx::query(&sql).bind(key).fetch_optional(executor).await?;
|
||||
|
||||
row.map(map_field_definition).transpose()
|
||||
}
|
||||
|
||||
/// List all field definitions (with labels), ordered by key.
|
||||
pub async fn list_field_definitions<'e, E>(executor: E) -> Result<Vec<FieldDefinition>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \
|
||||
FROM field_definition fd \
|
||||
LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \
|
||||
GROUP BY fd.id ORDER BY fd.key"
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&sql).fetch_all(executor).await?;
|
||||
|
||||
rows.into_iter().map(map_field_definition).collect()
|
||||
}
|
||||
|
||||
fn map_field_definition(row: sqlx::postgres::PgRow) -> Result<FieldDefinition, sqlx::Error> {
|
||||
let data_type: String = row.try_get("data_type")?;
|
||||
let vocabulary_id: Option<uuid::Uuid> = row.try_get("vocabulary_id")?;
|
||||
let authority_kind: Option<String> = row.try_get("authority_kind")?;
|
||||
|
||||
let authority_kind = authority_kind
|
||||
.map(|k| {
|
||||
AuthorityKind::from_db(&k)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {k}").into()))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let field_type = FieldType::from_parts(
|
||||
&data_type,
|
||||
vocabulary_id.map(VocabularyId::from_uuid),
|
||||
authority_kind,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
sqlx::Error::Decode(format!("inconsistent field type stored: {data_type}").into())
|
||||
})?;
|
||||
|
||||
let labels: sqlx::types::Json<Vec<LocalizedLabel>> = row.try_get("labels")?;
|
||||
|
||||
Ok(FieldDefinition {
|
||||
id: FieldDefinitionId::from_uuid(row.try_get("id")?),
|
||||
key: row.try_get("key")?,
|
||||
field_type,
|
||||
required: row.try_get("required")?,
|
||||
group_key: row.try_get("group_key")?,
|
||||
labels: labels.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update a field definition's mutable attributes (`required`, `group_key`, labels);
|
||||
/// `key`, `data_type`, and binding are immutable and untouched. Records an `updated`
|
||||
/// audit entry. Returns `false` if no such key. Pass a transaction connection.
|
||||
pub async fn update_field_definition(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
key: &str,
|
||||
required: bool,
|
||||
group_key: Option<&str>,
|
||||
labels: &[LocalizedLabel],
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let id: Option<uuid::Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let Some(id) = id else { return Ok(false) };
|
||||
|
||||
sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1")
|
||||
.bind(id)
|
||||
.bind(required)
|
||||
.bind(group_key)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in labels {
|
||||
sqlx::query(
|
||||
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id,
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Count catalogue objects that store a value under field `key`.
|
||||
pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)")
|
||||
.bind(key)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a field definition (labels cascade) unless catalogue objects use its key,
|
||||
/// recording a `deleted` audit entry. Pass a transaction connection.
|
||||
pub async fn delete_field_definition(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
key: &str,
|
||||
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||
let id: Option<uuid::Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let Some(id) = id else {
|
||||
return Ok(crate::DeleteOutcome::NotFound);
|
||||
};
|
||||
|
||||
let count = count_objects_using_field(&mut *conn, key).await?;
|
||||
|
||||
if count > 0 {
|
||||
return Ok(crate::DeleteOutcome::InUse { count });
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM field_definition WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id,
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(crate::DeleteOutcome::Deleted)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Database access. All SQL lives in this crate.
|
||||
|
||||
pub mod audit;
|
||||
pub mod authority;
|
||||
pub mod catalog;
|
||||
pub mod fields;
|
||||
pub mod seed;
|
||||
pub mod users;
|
||||
pub mod vocab;
|
||||
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
|
||||
/// Result of a delete that catalogue-object references may block.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum DeleteOutcome {
|
||||
/// The row was deleted.
|
||||
Deleted,
|
||||
/// Refused: `count` catalogue objects still reference it.
|
||||
InUse { count: i64 },
|
||||
/// The row did not exist.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// A handle to the organization's PostgreSQL database.
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// 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 })
|
||||
}
|
||||
|
||||
/// Build a handle from an existing pool (used in tests).
|
||||
pub fn from_pool(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Borrow the underlying pool for repository modules.
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Readiness check: run a trivial query to confirm the database answers.
|
||||
pub async fn ping(&self) -> Result<(), sqlx::Error> {
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply all pending schema migrations (embedded at compile time).
|
||||
///
|
||||
/// Pre-1.0 the migration files are rewritten freely and dev databases are
|
||||
/// recreated; this is the schema-bootstrap mechanism, not forward-migration
|
||||
/// discipline.
|
||||
pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
sqlx::migrate!().run(&self.pool).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Seed data: a representative subset of the Spectrum Cataloguing field set.
|
||||
//!
|
||||
//! Idempotent — each vocabulary and field definition is created only if a row with
|
||||
//! that key does not already exist. Vocabularies are seeded empty; their terms are
|
||||
//! 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::{
|
||||
AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId,
|
||||
};
|
||||
|
||||
use crate::{fields, vocab};
|
||||
|
||||
/// Seed the Spectrum cataloguing vocabularies and field definitions on `conn`.
|
||||
/// Pass a transaction connection (`&mut *tx`) so the whole seed is atomic.
|
||||
pub async fn seed_spectrum_cataloguing(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
|
||||
let material = ensure_vocabulary(conn, "material").await?;
|
||||
let object_name = ensure_vocabulary(conn, "object_name").await?;
|
||||
let technique = ensure_vocabulary(conn, "technique").await?;
|
||||
|
||||
let definitions = [
|
||||
def(
|
||||
"object_type",
|
||||
FieldType::Term {
|
||||
vocabulary_id: object_name,
|
||||
},
|
||||
"identification",
|
||||
&[("sv", "Sakord"), ("en", "Object type")],
|
||||
),
|
||||
def(
|
||||
"title",
|
||||
FieldType::LocalizedText,
|
||||
"identification",
|
||||
&[("sv", "Titel"), ("en", "Title")],
|
||||
),
|
||||
def(
|
||||
"comments",
|
||||
FieldType::Text,
|
||||
"identification",
|
||||
&[("sv", "Kommentarer"), ("en", "Comments")],
|
||||
),
|
||||
def(
|
||||
"material",
|
||||
FieldType::Term {
|
||||
vocabulary_id: material,
|
||||
},
|
||||
"description",
|
||||
&[("sv", "Material"), ("en", "Material")],
|
||||
),
|
||||
def(
|
||||
"technique",
|
||||
FieldType::Term {
|
||||
vocabulary_id: technique,
|
||||
},
|
||||
"description",
|
||||
&[("sv", "Teknik"), ("en", "Technique")],
|
||||
),
|
||||
def(
|
||||
"physical_description",
|
||||
FieldType::Text,
|
||||
"description",
|
||||
&[("sv", "Fysisk beskrivning"), ("en", "Physical description")],
|
||||
),
|
||||
def(
|
||||
"dimensions",
|
||||
FieldType::Text,
|
||||
"description",
|
||||
&[("sv", "Mått"), ("en", "Dimensions")],
|
||||
),
|
||||
def(
|
||||
"inscription",
|
||||
FieldType::Text,
|
||||
"description",
|
||||
&[("sv", "Inskription"), ("en", "Inscription")],
|
||||
),
|
||||
def(
|
||||
"content_description",
|
||||
FieldType::Text,
|
||||
"content",
|
||||
&[
|
||||
("sv", "Innehållsbeskrivning"),
|
||||
("en", "Content description"),
|
||||
],
|
||||
),
|
||||
def(
|
||||
"production_date",
|
||||
FieldType::Date,
|
||||
"production",
|
||||
&[("sv", "Tillverkningsdatum"), ("en", "Production date")],
|
||||
),
|
||||
def(
|
||||
"production_place",
|
||||
FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Place),
|
||||
},
|
||||
"production",
|
||||
&[("sv", "Tillverkningsplats"), ("en", "Production place")],
|
||||
),
|
||||
def(
|
||||
"production_person",
|
||||
FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Person),
|
||||
},
|
||||
"production",
|
||||
&[("sv", "Tillverkare"), ("en", "Maker")],
|
||||
),
|
||||
];
|
||||
|
||||
for definition in &definitions {
|
||||
ensure_field_definition(conn, definition).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get-or-create a vocabulary by key, returning its id.
|
||||
async fn ensure_vocabulary(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
key: &str,
|
||||
) -> Result<VocabularyId, sqlx::Error> {
|
||||
if let Some(existing) = vocab::vocabulary_by_key(&mut *conn, key).await? {
|
||||
Ok(existing.id)
|
||||
} else {
|
||||
Ok(
|
||||
vocab::create_vocabulary(&mut *conn, AuditActor::System, key)
|
||||
.await?
|
||||
.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a field definition only if its key is not already present.
|
||||
async fn ensure_field_definition(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
definition: &NewFieldDefinition,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
if fields::field_definition_by_key(&mut *conn, &definition.key)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
fields::create_field_definition(&mut *conn, definition).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn def(
|
||||
key: &str,
|
||||
field_type: FieldType,
|
||||
group: &str,
|
||||
label_pairs: &[(&str, &str)],
|
||||
) -> NewFieldDefinition {
|
||||
NewFieldDefinition {
|
||||
key: key.to_owned(),
|
||||
field_type,
|
||||
required: false,
|
||||
group_key: Some(group.to_owned()),
|
||||
labels: label_pairs
|
||||
.iter()
|
||||
.map(|(lang, label)| LocalizedLabel {
|
||||
lang: (*lang).to_owned(),
|
||||
label: (*label).to_owned(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Users of this organization's instance. All SQL for users lives here.
|
||||
|
||||
use domain::{
|
||||
AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::audit;
|
||||
|
||||
const ENTITY_TYPE: &str = "user";
|
||||
|
||||
const USER_COLUMNS: &str = "id, email, role";
|
||||
|
||||
/// Create a user and record a `created` audit entry (email + role only — never the
|
||||
/// password hash), both on `conn`. Pass a transaction connection.
|
||||
pub async fn create_user(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
new: &NewUser,
|
||||
) -> Result<UserId, sqlx::Error> {
|
||||
let id = UserId::new();
|
||||
|
||||
sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(new.email.as_str())
|
||||
.bind(&new.password_hash)
|
||||
.bind(new.role.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Created,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: vec![
|
||||
FieldChange {
|
||||
field: "email".to_owned(),
|
||||
before: None,
|
||||
after: Some(json!(new.email.as_str())),
|
||||
},
|
||||
FieldChange {
|
||||
field: "role".to_owned(),
|
||||
before: None,
|
||||
after: Some(json!(new.role.as_str())),
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Fetch a user by id.
|
||||
pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result<Option<User>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1");
|
||||
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
row.map(map_user).transpose()
|
||||
}
|
||||
|
||||
/// Fetch a user and their password hash by (normalized) email, for login.
|
||||
pub async fn credentials_by_email<'e, E>(
|
||||
executor: E,
|
||||
email: &str,
|
||||
) -> Result<Option<(User, String)>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
// Match the `lower(email)` unique index; `email` is already normalized by callers.
|
||||
let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE lower(email) = $1");
|
||||
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(email)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => {
|
||||
let hash: String = row.try_get("password_hash")?;
|
||||
|
||||
Ok(Some((map_user(row)?, hash)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all users, ordered by email.
|
||||
// TODO: add LIMIT/keyset pagination before exposing this via the API.
|
||||
pub async fn list_users<'e, E>(executor: E) -> Result<Vec<User>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email");
|
||||
|
||||
let rows = sqlx::query(&sql).fetch_all(executor).await?;
|
||||
|
||||
rows.into_iter().map(map_user).collect()
|
||||
}
|
||||
|
||||
fn map_user(row: sqlx::postgres::PgRow) -> Result<User, sqlx::Error> {
|
||||
let role_str: String = row.try_get("role")?;
|
||||
|
||||
let role = Role::from_db(&role_str)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?;
|
||||
|
||||
Ok(User {
|
||||
id: UserId::from_uuid(row.try_get("id")?),
|
||||
email: Email::from_db(row.try_get("email")?),
|
||||
role,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Controlled vocabularies and terms.
|
||||
|
||||
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 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(&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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all vocabularies, ordered by key.
|
||||
pub async fn list_vocabularies<'e, E>(executor: E) -> Result<Vec<Vocabulary>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let rows = sqlx::query("SELECT id, key FROM vocabulary ORDER BY key")
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(map_vocabulary).collect()
|
||||
}
|
||||
|
||||
/// Look up a vocabulary by its key.
|
||||
pub async fn vocabulary_by_key<'e, E>(
|
||||
executor: E,
|
||||
key: &str,
|
||||
) -> Result<Option<Vocabulary>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let row = sqlx::query("SELECT id, key FROM vocabulary WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
row.map(map_vocabulary).transpose()
|
||||
}
|
||||
|
||||
/// 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)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(new.vocabulary_id.to_uuid())
|
||||
.bind(new.external_uri.as_deref())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in &new.labels {
|
||||
sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Fetch one term (with its labels).
|
||||
pub async fn term_by_id<'e, E>(executor: E, id: TermId) -> Result<Option<Term>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT t.id, t.vocabulary_id, t.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM term t LEFT JOIN term_label tl ON tl.term_id = t.id \
|
||||
WHERE t.id = $1 GROUP BY t.id"
|
||||
);
|
||||
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
row.map(map_term).transpose()
|
||||
}
|
||||
|
||||
/// List all terms in a vocabulary (with labels), ordered by id.
|
||||
pub async fn list_terms<'e, E>(
|
||||
executor: E,
|
||||
vocabulary_id: VocabularyId,
|
||||
) -> Result<Vec<Term>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT t.id, t.vocabulary_id, t.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM term t LEFT JOIN term_label tl ON tl.term_id = t.id \
|
||||
WHERE t.vocabulary_id = $1 GROUP BY t.id ORDER BY t.id"
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(vocabulary_id.to_uuid())
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(map_term).collect()
|
||||
}
|
||||
|
||||
/// Resolve a term to a [`TermRef`], confirming it belongs to `vocabulary_id`.
|
||||
pub async fn resolve_term<'e, E>(
|
||||
executor: E,
|
||||
vocabulary_id: VocabularyId,
|
||||
term_id: TermId,
|
||||
) -> Result<Option<TermRef>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let found =
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2")
|
||||
.bind(term_id.to_uuid())
|
||||
.bind(vocabulary_id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
|
||||
}
|
||||
|
||||
/// Update a term's `external_uri` and labels (full replace), recording an `updated`
|
||||
/// audit entry. Returns `false` if no such term or the term does not belong to
|
||||
/// `vocabulary_id`. Pass a transaction connection.
|
||||
pub async fn update_term(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
vocabulary_id: VocabularyId,
|
||||
term_id: TermId,
|
||||
external_uri: Option<&str>,
|
||||
labels: &[LocalizedLabel],
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let updated =
|
||||
sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1 AND vocabulary_id = $3")
|
||||
.bind(term_id.to_uuid())
|
||||
.bind(external_uri)
|
||||
.bind(vocabulary_id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if updated == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM term_label WHERE term_id = $1")
|
||||
.bind(term_id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in labels {
|
||||
sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
|
||||
.bind(term_id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||
entity_id: term_id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Count catalogue objects that reference `term_id` through a `term`-typed field.
|
||||
pub async fn count_objects_referencing_term<'e, E>(
|
||||
executor: E,
|
||||
term_id: TermId,
|
||||
) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
sqlx::query_scalar(
|
||||
"SELECT count(*) FROM object o WHERE EXISTS ( \
|
||||
SELECT 1 FROM field_definition fd \
|
||||
WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )",
|
||||
)
|
||||
.bind(term_id.to_string())
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a
|
||||
/// `deleted` audit entry. Pass a transaction connection.
|
||||
pub async fn delete_term(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
vocabulary_id: VocabularyId,
|
||||
term_id: TermId,
|
||||
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||
let exists =
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2")
|
||||
.bind(term_id.to_uuid())
|
||||
.bind(vocabulary_id.to_uuid())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Ok(crate::DeleteOutcome::NotFound);
|
||||
}
|
||||
|
||||
let count = count_objects_referencing_term(&mut *conn, term_id).await?;
|
||||
|
||||
if count > 0 {
|
||||
return Ok(crate::DeleteOutcome::InUse { count });
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM term WHERE id = $1")
|
||||
.bind(term_id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||
entity_id: term_id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(crate::DeleteOutcome::Deleted)
|
||||
}
|
||||
|
||||
/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no
|
||||
/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505).
|
||||
pub async fn rename_vocabulary(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: VocabularyId,
|
||||
key: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.bind(key)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if updated == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Delete a vocabulary unless it still has terms or is bound by a field definition
|
||||
/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry.
|
||||
pub async fn delete_vocabulary(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: VocabularyId,
|
||||
) -> Result<crate::DeleteOutcome, sqlx::Error> {
|
||||
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if exists.is_none() {
|
||||
return Ok(crate::DeleteOutcome::NotFound);
|
||||
}
|
||||
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \
|
||||
+ (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if count > 0 {
|
||||
return Ok(crate::DeleteOutcome::InUse { count });
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM vocabulary WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(crate::DeleteOutcome::Deleted)
|
||||
}
|
||||
|
||||
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> {
|
||||
Ok(Vocabulary {
|
||||
id: VocabularyId::from_uuid(row.try_get("id")?),
|
||||
key: row.try_get("key")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_term(row: sqlx::postgres::PgRow) -> Result<Term, sqlx::Error> {
|
||||
let labels: sqlx::types::Json<Vec<LocalizedLabel>> = row.try_get("labels")?;
|
||||
|
||||
Ok(Term {
|
||||
id: TermId::from_uuid(row.try_get("id")?),
|
||||
vocabulary_id: VocabularyId::from_uuid(row.try_get("vocabulary_id")?),
|
||||
external_uri: row.try_get("external_uri")?,
|
||||
labels: labels.0,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use db::Db;
|
||||
use db::audit;
|
||||
use domain::{AuditAction, AuditActor, FieldChange, NewAuditEvent};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn created(entity_id: Uuid, name: &str) -> NewAuditEvent {
|
||||
NewAuditEvent {
|
||||
actor: AuditActor::System,
|
||||
action: AuditAction::Created,
|
||||
entity_type: "object".into(),
|
||||
entity_id,
|
||||
changes: vec![FieldChange {
|
||||
field: "name".into(),
|
||||
before: None,
|
||||
after: Some(json!(name)),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn records_and_reads_back_history_in_order(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = Uuid::new_v4();
|
||||
let user = Uuid::new_v4();
|
||||
|
||||
audit::record(db.pool(), &created(id, "Vase"))
|
||||
.await
|
||||
.unwrap();
|
||||
audit::record(
|
||||
db.pool(),
|
||||
&NewAuditEvent {
|
||||
actor: AuditActor::User(user),
|
||||
action: AuditAction::Updated,
|
||||
entity_type: "object".into(),
|
||||
entity_id: id,
|
||||
changes: vec![FieldChange {
|
||||
field: "name".into(),
|
||||
before: Some(json!("Vase")),
|
||||
after: Some(json!("Roman Vase")),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id).await.unwrap();
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].action, AuditAction::Created);
|
||||
assert_eq!(history[0].actor, AuditActor::System);
|
||||
assert_eq!(history[1].action, AuditAction::Updated);
|
||||
assert_eq!(history[1].actor, AuditActor::User(user));
|
||||
assert!(history[0].seq < history[1].seq, "ordered by seq");
|
||||
assert_eq!(history[1].changes[0].field, "name");
|
||||
assert_eq!(history[1].changes[0].after, Some(json!("Roman Vase")));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn history_is_scoped_to_one_entity(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
audit::record(db.pool(), &created(a, "A")).await.unwrap();
|
||||
audit::record(db.pool(), &created(b, "B")).await.unwrap();
|
||||
|
||||
let only_a = audit::history_for(db.pool(), "object", a).await.unwrap();
|
||||
assert_eq!(only_a.len(), 1);
|
||||
assert_eq!(only_a[0].entity_id, a);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn deleted_action_with_empty_changes_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
audit::record(
|
||||
db.pool(),
|
||||
&NewAuditEvent {
|
||||
actor: AuditActor::System,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: "object".into(),
|
||||
entity_id: id,
|
||||
changes: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id).await.unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].action, AuditAction::Deleted);
|
||||
assert!(history[0].changes.is_empty());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn history_is_empty_for_unknown_entity(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use db::Db;
|
||||
use db::audit;
|
||||
use domain::{AuditAction, AuditActor, NewAuditEvent};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample() -> NewAuditEvent {
|
||||
NewAuditEvent {
|
||||
actor: AuditActor::System,
|
||||
action: AuditAction::Created,
|
||||
entity_type: "object".into(),
|
||||
entity_id: Uuid::new_v4(),
|
||||
changes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn count(pool: &PgPool) -> i64 {
|
||||
sqlx::query_scalar("SELECT count(*) FROM audit_log")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_delete_truncate_are_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
audit::record(db.pool(), &sample()).await.unwrap();
|
||||
|
||||
// Each failing statement poisons its connection (Postgres enters aborted-transaction
|
||||
// state). Acquire a fresh connection per statement so later assertions are independent.
|
||||
let update_err = sqlx::query("UPDATE audit_log SET action = 'deleted'")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
update_err.contains("audit_log is append-only"),
|
||||
"UPDATE must be rejected by the trigger, got: {update_err}"
|
||||
);
|
||||
|
||||
let delete_err = sqlx::query("DELETE FROM audit_log")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
delete_err.contains("audit_log is append-only"),
|
||||
"DELETE must be rejected by the trigger, got: {delete_err}"
|
||||
);
|
||||
|
||||
let truncate_err = sqlx::query("TRUNCATE audit_log")
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
truncate_err.contains("audit_log is append-only"),
|
||||
"TRUNCATE must be rejected by the trigger, got: {truncate_err}"
|
||||
);
|
||||
|
||||
assert_eq!(count(db.pool()).await, 1, "the row is still there");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn record_rolls_back_with_caller_transaction(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
audit::record(&mut *tx, &sample()).await.unwrap();
|
||||
|
||||
tx.rollback().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(db.pool()).await,
|
||||
0,
|
||||
"a rolled-back audit record must not persist"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn record_commits_with_caller_transaction(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
audit::record(&mut *tx, &sample()).await.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(db.pool()).await,
|
||||
1,
|
||||
"a committed audit record persists"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use db::{Db, authority, catalog, fields};
|
||||
use domain::{
|
||||
AuditActor, AuthorityKind, LocalizedLabel, NewAuthority, NewFieldDefinition, Visibility,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn sample_object_input() -> domain::ObjectInput {
|
||||
domain::ObjectInput {
|
||||
object_number: "X.1".into(),
|
||||
object_name: "Test".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
||||
NewAuthority {
|
||||
kind: AuthorityKind::Person,
|
||||
external_uri: None,
|
||||
labels: vec![
|
||||
LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: name_sv.into(),
|
||||
},
|
||||
LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: name_en.into(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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,
|
||||
AuditActor::System,
|
||||
&new_person("Carl Larsson", "Carl Larsson"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let got = authority::authority_by_id(db.pool(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(got.id, id);
|
||||
assert_eq!(got.kind, AuthorityKind::Person);
|
||||
assert_eq!(got.labels.len(), 2);
|
||||
assert_eq!(
|
||||
domain::pick_label(&got.labels, "sv", "en"),
|
||||
Some("Carl Larsson")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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, AuditActor::System, &new_person("A", "A"))
|
||||
.await
|
||||
.unwrap();
|
||||
authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Place,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "Stockholm".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let people = authority::list_by_kind(db.pool(), AuthorityKind::Person)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(people.len(), 1);
|
||||
assert_eq!(people[0].kind, AuthorityKind::Person);
|
||||
|
||||
let places = authority::list_by_kind(db.pool(), AuthorityKind::Place)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(places.len(), 1);
|
||||
assert_eq!(places[0].kind, AuthorityKind::Place);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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, AuditActor::System, &new_person("X", "X"))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let r = authority::resolve_authority(db.pool(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(r.authority_id(), id);
|
||||
assert_eq!(r.kind(), AuthorityKind::Person);
|
||||
|
||||
let missing = authority::resolve_authority(db.pool(), domain::AuthorityId::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Organisation,
|
||||
external_uri: None,
|
||||
labels: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let got = authority::authority_by_id(db.pool(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(got.kind, AuthorityKind::Organisation);
|
||||
assert!(got.labels.is_empty());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn update_authority_changes_labels(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Person,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Anon".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let existed = authority::update_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
Some("https://viaf.org/1"),
|
||||
&[LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Astrid".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(existed);
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let a = authority::authority_by_id(db.pool(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1"));
|
||||
assert_eq!(a.labels[0].label, "Astrid");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_authority_blocks_when_referenced(pool: PgPool) {
|
||||
use db::DeleteOutcome;
|
||||
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Person,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Astrid".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "maker".into(),
|
||||
field_type: domain::FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Person),
|
||||
},
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Tillverkare".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("maker".into(), serde_json::Value::String(id.to_string()));
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
authority::delete_authority(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::InUse { count: 1 }
|
||||
);
|
||||
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
authority::delete_authority(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::Deleted
|
||||
);
|
||||
assert_eq!(
|
||||
authority::delete_authority(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::NotFound
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn sample_input(number: &str) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: Some("a small vase".into()),
|
||||
current_location: Some("shelf A1".into()),
|
||||
current_owner: None,
|
||||
recorder: Some("anna".into()),
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn create_reads_back_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-1"))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.object_number, "LM-1");
|
||||
assert_eq!(obj.object_name, "vase");
|
||||
assert_eq!(obj.number_of_objects, 1);
|
||||
assert_eq!(obj.brief_description.as_deref(), Some("a small vase"));
|
||||
assert_eq!(obj.visibility, Visibility::Draft);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].action, AuditAction::Created);
|
||||
assert_eq!(history[0].actor, AuditActor::System);
|
||||
assert!(
|
||||
history[0]
|
||||
.changes
|
||||
.iter()
|
||||
.any(|c| c.field == "object_number")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn list_returns_created_objects(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-1"))
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-2"))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let all = catalog::list_objects(db.pool()).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].object_number, "LM-1");
|
||||
assert_eq!(all[1].object_number, "LM-2");
|
||||
}
|
||||
|
||||
fn input(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed(pool: &PgPool, inputs: &[ObjectInput]) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
for it in inputs {
|
||||
catalog::create_object(&mut tx, AuditActor::System, it)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn query_orders_by_name_descending(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
seed(
|
||||
&pool,
|
||||
&[
|
||||
input("LM-1", "alpha", Visibility::Draft),
|
||||
input("LM-2", "gamma", Visibility::Draft),
|
||||
input("LM-3", "beta", Visibility::Draft),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let query = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectName,
|
||||
descending: true,
|
||||
visibility: None,
|
||||
q: None,
|
||||
};
|
||||
|
||||
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let names: Vec<&str> = rows.iter().map(|o| o.object_name.as_str()).collect();
|
||||
assert_eq!(names, ["gamma", "beta", "alpha"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn query_filters_by_visibility(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
seed(
|
||||
&pool,
|
||||
&[
|
||||
input("LM-1", "draft one", Visibility::Draft),
|
||||
input("LM-2", "internal one", Visibility::Internal),
|
||||
input("LM-3", "draft two", Visibility::Draft),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let query = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectNumber,
|
||||
descending: false,
|
||||
visibility: Some("draft"),
|
||||
q: None,
|
||||
};
|
||||
|
||||
let rows = catalog::list_objects_query(db.pool(), &query, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows.iter().all(|o| o.visibility == Visibility::Draft));
|
||||
|
||||
let total = catalog::count_objects_query(db.pool(), Some("draft"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn query_quick_filter_matches_number_or_name(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
seed(
|
||||
&pool,
|
||||
&[
|
||||
input("RED-1", "scarlet vase", Visibility::Draft),
|
||||
input("BLU-1", "azure bowl", Visibility::Draft),
|
||||
input("LM-9", "red kettle", Visibility::Internal),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Matches the object_number of the first row.
|
||||
let by_number = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectNumber,
|
||||
descending: false,
|
||||
visibility: None,
|
||||
q: Some("red"),
|
||||
};
|
||||
let rows = catalog::list_objects_query(db.pool(), &by_number, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
// ILIKE: "RED-1" by number and "red kettle" by name.
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
let total = catalog::count_objects_query(db.pool(), None, Some("red"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(total, 2);
|
||||
|
||||
// A term matching only a name.
|
||||
let by_name = catalog::ObjectQuery {
|
||||
sort: catalog::ObjectSort::ObjectNumber,
|
||||
descending: false,
|
||||
visibility: None,
|
||||
q: Some("azure"),
|
||||
};
|
||||
let rows = catalog::list_objects_query(db.pool(), &by_name, 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].object_number, "BLU-1");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn object_by_id_missing_is_none(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
assert!(
|
||||
catalog::object_by_id(db.pool(), domain::ObjectId::new())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn object_with_date_and_all_none_optionals_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let date = time::Date::from_calendar_date(2020, time::Month::January, 28).unwrap();
|
||||
let input = ObjectInput {
|
||||
object_number: "LM-3".into(),
|
||||
object_name: "drawing".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: Some(date),
|
||||
visibility: Visibility::Internal,
|
||||
};
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &input)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.recording_date, Some(date));
|
||||
assert_eq!(obj.brief_description, None);
|
||||
assert_eq!(obj.current_location, None);
|
||||
assert_eq!(obj.current_owner, None);
|
||||
assert_eq!(obj.recorder, None);
|
||||
assert_eq!(obj.visibility, Visibility::Internal);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history[0]
|
||||
.changes
|
||||
.iter()
|
||||
.any(|c| c.field == "recording_date")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn new_object_has_empty_fields(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-9"))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.fields, serde_json::json!({}));
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn base() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: Some("shelf A1".into()),
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut changed = base();
|
||||
changed.object_name = "roman vase".into();
|
||||
changed.visibility = Visibility::Public;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &changed)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.object_name, "roman vase");
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 2); // created + updated
|
||||
let update = &history[1];
|
||||
assert_eq!(update.action, AuditAction::Updated);
|
||||
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
|
||||
fields.sort_unstable();
|
||||
assert_eq!(fields, vec!["object_name", "visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_update_records_no_audit(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history.len(),
|
||||
1,
|
||||
"a no-op update must not add an audit entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_missing_returns_false(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let missing = domain::ObjectId::new();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut tx, AuditActor::System, missing, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(!updated);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", missing.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history.is_empty(),
|
||||
"updating a missing object records no audit"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn delete_missing_returns_false(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let deleted = catalog::delete_object(&mut tx, AuditActor::System, domain::ObjectId::new())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn clearing_a_field_is_audited(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut cleared = base();
|
||||
cleared.current_location = None;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::update_object(&mut tx, AuditActor::System, id, &cleared)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
let update = history.last().unwrap();
|
||||
let loc = update
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.field == "current_location")
|
||||
.expect("location change recorded");
|
||||
assert!(
|
||||
loc.before.is_some(),
|
||||
"cleared field should have before = Some"
|
||||
);
|
||||
assert!(
|
||||
loc.after.is_none(),
|
||||
"cleared field should have after = None"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn delete_removes_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let deleted = catalog::delete_object(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
assert!(
|
||||
catalog::object_by_id(db.pool(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
use db::{Db, DeleteOutcome, audit, catalog, fields, vocab};
|
||||
use domain::{
|
||||
AuditAction, AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition,
|
||||
ObjectInput, Visibility,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn sample_object_input() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "X.1".into(),
|
||||
object_name: "Test".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
fn labels() -> Vec<LocalizedLabel> {
|
||||
vec![
|
||||
LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "material".into(),
|
||||
},
|
||||
LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "material".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn text_field_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "comments".into(),
|
||||
field_type: FieldType::Text,
|
||||
required: false,
|
||||
group_key: Some("identification".into()),
|
||||
labels: labels(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let def = fields::field_definition_by_key(db.pool(), "comments")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(def.id, id);
|
||||
assert_eq!(def.field_type, FieldType::Text);
|
||||
assert_eq!(def.group_key.as_deref(), Some("identification"));
|
||||
assert_eq!(def.labels.len(), 2);
|
||||
assert!(
|
||||
fields::field_definition_by_key(db.pool(), "nope")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
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(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "material".into(),
|
||||
field_type: FieldType::Term {
|
||||
vocabulary_id: material.id,
|
||||
},
|
||||
required: true,
|
||||
group_key: None,
|
||||
labels: labels(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "maker".into(),
|
||||
field_type: FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Person),
|
||||
},
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "maker".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let material_def = fields::field_definition_by_key(db.pool(), "material")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
material_def.field_type,
|
||||
FieldType::Term {
|
||||
vocabulary_id: material.id
|
||||
}
|
||||
);
|
||||
assert!(material_def.required);
|
||||
|
||||
let maker_def = fields::field_definition_by_key(db.pool(), "maker")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
maker_def.field_type,
|
||||
FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Person)
|
||||
}
|
||||
);
|
||||
|
||||
let all = fields::list_field_definitions(db.pool()).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn any_authority_scalar_and_zero_labels_round_trip(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "donor".into(),
|
||||
field_type: FieldType::Authority { kind: None },
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "on_display".into(),
|
||||
field_type: FieldType::Boolean,
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "on display".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let donor = fields::field_definition_by_key(db.pool(), "donor")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(donor.field_type, FieldType::Authority { kind: None });
|
||||
assert!(donor.labels.is_empty());
|
||||
|
||||
let on_display = fields::field_definition_by_key(db.pool(), "on_display")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(on_display.field_type, FieldType::Boolean);
|
||||
|
||||
// list_field_definitions is ordered by key.
|
||||
let all = fields::list_field_definitions(db.pool()).await.unwrap();
|
||||
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
|
||||
assert_eq!(keys, vec!["donor", "on_display"]);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn update_field_definition_edits_labels_group_required(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "weight".into(),
|
||||
field_type: FieldType::Integer,
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Vikt".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let existed = fields::update_field_definition(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
"weight",
|
||||
true,
|
||||
Some("Mått"),
|
||||
&[LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Vikt (g)".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(existed);
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let def = fields::field_definition_by_key(db.pool(), "weight")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(def.required);
|
||||
assert_eq!(def.group_key.as_deref(), Some("Mått"));
|
||||
assert_eq!(def.labels[0].label, "Vikt (g)");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "weight".into(),
|
||||
field_type: FieldType::Integer,
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Vikt".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let field_def_id = fields::field_definition_by_key(&mut *tx, "weight")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
.to_uuid();
|
||||
|
||||
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("weight".into(), serde_json::Value::from(42));
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::InUse { count: 1 }
|
||||
);
|
||||
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::Deleted
|
||||
);
|
||||
|
||||
let history = audit::history_for(&mut *tx, "field_definition", field_def_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history.iter().any(|e| e.action == AuditAction::Deleted),
|
||||
"expected a Deleted audit entry for the field_definition"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::NotFound
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use db::Db;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
// sqlx::test already applied migrations to this temp DB; re-running must be a
|
||||
// no-op success (idempotent).
|
||||
db.migrate()
|
||||
.await
|
||||
.expect("re-running migrate is idempotent");
|
||||
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar("SELECT to_regclass('public.audit_log')::text")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(regclass.as_deref(), Some("audit_log"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_object_table(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let regclass: Option<String> = sqlx::query_scalar("SELECT to_regclass('public.object')::text")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(regclass.as_deref(), Some("object"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_vocabulary_and_authority_tables(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
for table in [
|
||||
"vocabulary",
|
||||
"term",
|
||||
"term_label",
|
||||
"authority",
|
||||
"authority_label",
|
||||
] {
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar(&format!("SELECT to_regclass('public.{table}')::text"))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
regclass.as_deref(),
|
||||
Some(table),
|
||||
"table {table} should exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_field_definition_tables(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
for table in ["field_definition", "field_definition_label"] {
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar(&format!("SELECT to_regclass('public.{table}')::text"))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
regclass.as_deref(),
|
||||
Some(table),
|
||||
"table {table} should exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_adds_object_fields_column(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let exists: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT true FROM information_schema.columns \
|
||||
WHERE table_name = 'object' AND column_name = 'fields'",
|
||||
)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(exists, Some(true));
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use db::catalog::FieldError;
|
||||
use db::{Db, audit, catalog, fields, vocab};
|
||||
use domain::{
|
||||
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, ObjectInput, Visibility,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn obj_input() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(text: &str) -> Vec<LocalizedLabel> {
|
||||
vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: text.into(),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn setup_object(db: &Db) -> domain::ObjectId {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &obj_input())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
async fn define(db: &Db, key: &str, field_type: FieldType) {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: key.into(),
|
||||
field_type,
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: label(key),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sets_scalar_fields_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "comments", FieldType::Text).await;
|
||||
define(&db, "year", FieldType::Integer).await;
|
||||
define(&db, "on_display", FieldType::Boolean).await;
|
||||
|
||||
let values = serde_json::json!({ "comments": "nice", "year": 1850, "on_display": true });
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.fields["comments"], "nice");
|
||||
assert_eq!(obj.fields["year"], 1850);
|
||||
assert_eq!(obj.fields["on_display"], true);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.last().unwrap().action, AuditAction::Updated);
|
||||
let changed: Vec<&str> = history
|
||||
.last()
|
||||
.unwrap()
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.field.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
changed.contains(&"comments")
|
||||
&& changed.contains(&"year")
|
||||
&& changed.contains(&"on_display")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn term_field_must_resolve_in_its_vocabulary(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
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",
|
||||
FieldType::Term {
|
||||
vocabulary_id: material.id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
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,
|
||||
labels: label("wood"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let ok = serde_json::json!({ "material": wood.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, ok.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let bad = serde_json::json!({ "material": domain::TermId::new().to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err =
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await;
|
||||
assert!(matches!(err, Err(FieldError::Unresolved { .. })));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn unknown_field_and_type_mismatch_are_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "year", FieldType::Integer).await;
|
||||
|
||||
let unknown = serde_json::json!({ "nope": "x" });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
unknown.as_object().unwrap()
|
||||
)
|
||||
.await,
|
||||
Err(FieldError::UnknownField(_))
|
||||
));
|
||||
drop(tx);
|
||||
|
||||
let wrong = serde_json::json!({ "year": "not a number" });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, wrong.as_object().unwrap())
|
||||
.await,
|
||||
Err(FieldError::TypeMismatch { .. })
|
||||
));
|
||||
drop(tx);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn authority_field_enforces_kind(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(
|
||||
&db,
|
||||
"maker",
|
||||
FieldType::Authority {
|
||||
kind: Some(domain::AuthorityKind::Person),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
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,
|
||||
labels: label("Carl"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let place = db::authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&domain::NewAuthority {
|
||||
kind: domain::AuthorityKind::Place,
|
||||
external_uri: None,
|
||||
labels: label("Stockholm"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let ok = serde_json::json!({ "maker": person.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, ok.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let bad = serde_json::json!({ "maker": place.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
||||
Err(FieldError::Unresolved { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn term_from_wrong_vocabulary_is_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
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(&mut tx, AuditActor::System, "technique")
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
define(
|
||||
&db,
|
||||
"material",
|
||||
FieldType::Term {
|
||||
vocabulary_id: material.id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// a real term, but in the WRONG vocabulary
|
||||
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,
|
||||
labels: label("forged"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let bad = serde_json::json!({ "material": other.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
||||
Err(FieldError::Unresolved { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn localized_text_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "title", FieldType::LocalizedText).await;
|
||||
|
||||
let values = serde_json::json!({ "title": { "sv": "Vas", "en": "Vase" } });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.fields["title"]["sv"], "Vas");
|
||||
assert_eq!(obj.fields["title"]["en"], "Vase");
|
||||
|
||||
let bad = serde_json::json!({ "title": { "sv": 5 } });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
||||
Err(FieldError::TypeMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn replace_semantics_remove_a_field_and_audit_it(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "comments", FieldType::Text).await;
|
||||
define(&db, "year", FieldType::Integer).await;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
serde_json::json!({ "comments": "x", "year": 1850 })
|
||||
.as_object()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
serde_json::json!({ "comments": "x" }).as_object().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert!(obj.fields.get("year").is_none());
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
let last = history.last().unwrap();
|
||||
let year = last
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.field == "year")
|
||||
.expect("year removal recorded");
|
||||
assert!(year.before.is_some());
|
||||
assert!(year.after.is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_set_records_no_audit(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "comments", FieldType::Text).await;
|
||||
|
||||
let values = serde_json::json!({ "comments": "x" });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let before = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let after = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap()
|
||||
.len();
|
||||
assert_eq!(before, after, "a no-op set must not add an audit entry");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn set_on_missing_object_errors(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
domain::ObjectId::new(),
|
||||
serde_json::json!({}).as_object().unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(err, Err(FieldError::ObjectNotFound)));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use db::Db;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn ping_succeeds(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
db.ping()
|
||||
.await
|
||||
.expect("ping should succeed against a live database");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use db::{Db, fields, seed, vocab};
|
||||
use domain::{AuthorityKind, FieldType};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn seed_creates_vocabularies_and_field_definitions(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
for key in ["material", "object_name", "technique"] {
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), key)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"vocabulary {key} should be seeded"
|
||||
);
|
||||
}
|
||||
|
||||
let material_vocab = vocab::vocabulary_by_key(db.pool(), "material")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let material_field = fields::field_definition_by_key(db.pool(), "material")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
material_field.field_type,
|
||||
FieldType::Term {
|
||||
vocabulary_id: material_vocab.id
|
||||
}
|
||||
);
|
||||
assert_eq!(material_field.group_key.as_deref(), Some("description"));
|
||||
|
||||
let place = fields::field_definition_by_key(db.pool(), "production_place")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
place.field_type,
|
||||
FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Place)
|
||||
}
|
||||
);
|
||||
|
||||
let title = fields::field_definition_by_key(db.pool(), "title")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(title.field_type, FieldType::LocalizedText);
|
||||
let date = fields::field_definition_by_key(db.pool(), "production_date")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(date.field_type, FieldType::Date);
|
||||
|
||||
assert_eq!(
|
||||
fields::list_field_definitions(db.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
12
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn seed_is_idempotent(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
for _ in 0..2 {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fields::list_field_definitions(db.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
12
|
||||
);
|
||||
for key in ["material", "object_name", "technique"] {
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), key)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"vocabulary {key} should remain after re-seeding"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use db::{Db, audit, users};
|
||||
use domain::{AuditAction, AuditActor, Email, NewUser, Role};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn new_user(email: &str, role: Role) -> NewUser {
|
||||
NewUser {
|
||||
email: Email::parse(email).unwrap(),
|
||||
password_hash: "$argon2id$dummy".to_owned(),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn create_then_fetch_by_id_and_email(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_user("anna@example.com", Role::Admin),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let user = users::user_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(user.email.as_str(), "anna@example.com");
|
||||
assert_eq!(user.role, Role::Admin);
|
||||
|
||||
let (by_email, hash) = users::credentials_by_email(db.pool(), "anna@example.com")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(by_email.id, id);
|
||||
assert_eq!(hash, "$argon2id$dummy");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn create_user_audits_email_and_role_but_never_the_hash(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_user("anna@example.com", Role::Editor),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "user", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].action, AuditAction::Created);
|
||||
let mut fields: Vec<&str> = history[0]
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.field.as_str())
|
||||
.collect();
|
||||
fields.sort_unstable();
|
||||
assert_eq!(fields, vec!["email", "role"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn missing_email_returns_none(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
assert!(
|
||||
users::credentials_by_email(db.pool(), "nobody@example.com")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn list_users_is_ordered_by_email(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_user("zoe@example.com", Role::Editor),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_user("amy@example.com", Role::Admin),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let users = users::list_users(db.pool()).await.unwrap();
|
||||
let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect();
|
||||
assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn duplicate_email_is_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_user("anna@example.com", Role::Admin),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Same normalized email again — the lower(email) unique index must reject it.
|
||||
let err = users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_user("anna@example.com", Role::Editor),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, sqlx::Error::Database(_)),
|
||||
"expected a unique-violation database error, got {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn object(number: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn publish_steps_through_internal_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 3); // created + two visibility updates
|
||||
assert_eq!(history[2].action, AuditAction::Updated);
|
||||
let changed: Vec<&str> = history[2]
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.field.as_str())
|
||||
.collect();
|
||||
assert_eq!(changed, vec!["visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn skipping_a_step_is_rejected_and_unchanged(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(matches!(
|
||||
err,
|
||||
catalog::VisibilityError::Illegal(IllegalTransition {
|
||||
from: Visibility::Draft,
|
||||
to: Visibility::Public
|
||||
})
|
||||
));
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Draft); // unchanged
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn set_visibility_on_missing_object_errors(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_visibility(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
domain::ObjectId::new(),
|
||||
Visibility::Internal,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(matches!(err, catalog::VisibilityError::ObjectNotFound));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_set_to_current_visibility_writes_no_audit(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Draft)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn public_reads_return_only_public_records(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let draft = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("D-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let pub_id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("P-1", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let internal = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("I-1", Visibility::Internal),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert!(
|
||||
catalog::public_object_by_id(db.pool(), pub_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
catalog::public_object_by_id(db.pool(), draft)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let listed = catalog::list_public_objects(db.pool(), 50, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, pub_id);
|
||||
assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1);
|
||||
|
||||
assert!(
|
||||
catalog::list_public_objects(db.pool(), 50, 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// internal records are excluded from public reads too (not just draft)
|
||||
assert!(
|
||||
catalog::public_object_by_id(db.pool(), internal)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn publishing_requires_all_required_fields_present(pool: PgPool) {
|
||||
use db::fields;
|
||||
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
|
||||
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
// a required flexible field
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "inscription".into(),
|
||||
field_type: FieldType::Text,
|
||||
required: true,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "Inscription".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("LM-1", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// publishing without the required field present is rejected
|
||||
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, catalog::VisibilityError::MissingRequiredFields(ref keys) if keys == &["inscription"])
|
||||
);
|
||||
|
||||
// the object is still not public
|
||||
let still = catalog::object_by_id(&mut *tx, id).await.unwrap().unwrap();
|
||||
assert_eq!(still.visibility, Visibility::Internal);
|
||||
|
||||
// set the required field, then publishing succeeds
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
serde_json::json!({ "inscription": "To the gods" })
|
||||
.as_object()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let published = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(published.visibility, Visibility::Public);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn republishing_a_public_object_is_a_noop_even_with_a_new_required_field(pool: PgPool) {
|
||||
use db::fields;
|
||||
use domain::{FieldType, LocalizedLabel, NewFieldDefinition};
|
||||
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
// an already-public object (created public directly at the db layer)
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("LM-2", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// a required field is introduced AFTER the object is already public
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "inscription".into(),
|
||||
field_type: FieldType::Text,
|
||||
required: true,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "Inscription".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// setting visibility to its current value stays an idempotent no-op — the publish
|
||||
// gate only fires on an actual transition into public, not on a re-set.
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use db::{Db, audit, catalog, fields, vocab};
|
||||
use domain::{
|
||||
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput,
|
||||
Visibility,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn vocabulary_create_and_lookup(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
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
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(found.id, v.id);
|
||||
assert_eq!(found.key, "material");
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), "nope")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
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()),
|
||||
labels: vec![
|
||||
LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "trä".into(),
|
||||
},
|
||||
LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "wood".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let term = vocab::term_by_id(db.pool(), term_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(term.vocabulary_id, v.id);
|
||||
assert_eq!(
|
||||
term.external_uri.as_deref(),
|
||||
Some("http://vocab.getty.edu/aat/300011914")
|
||||
);
|
||||
assert_eq!(term.labels.len(), 2);
|
||||
assert_eq!(domain::pick_label(&term.labels, "sv", "en"), Some("trä"));
|
||||
assert_eq!(domain::pick_label(&term.labels, "de", "en"), Some("wood"));
|
||||
|
||||
let listed = vocab::list_terms(db.pool(), v.id).await.unwrap();
|
||||
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, term_id);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
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,
|
||||
labels: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let term = vocab::term_by_id(db.pool(), term_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(term.labels.is_empty());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
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!(
|
||||
matches!(err, sqlx::Error::Database(_)),
|
||||
"expected a unique-violation DB error, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
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(&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,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "wood".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert!(
|
||||
vocab::resolve_term(db.pool(), material.id, term_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
vocab::resolve_term(db.pool(), technique.id, term_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_object_input() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "X.1".into(),
|
||||
object_name: "Test".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn update_term_changes_labels_and_uri(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: vocab.id,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Trä".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let existed = vocab::update_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
vocab.id,
|
||||
term_id,
|
||||
Some("https://example.org/wood"),
|
||||
&[LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Träslag".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(existed);
|
||||
|
||||
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history.iter().any(|e| e.action == AuditAction::Updated),
|
||||
"expected an Updated audit entry for the term"
|
||||
);
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let term = vocab::term_by_id(db.pool(), term_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
term.external_uri.as_deref(),
|
||||
Some("https://example.org/wood")
|
||||
);
|
||||
assert_eq!(term.labels.len(), 1);
|
||||
assert_eq!(term.labels[0].label, "Träslag");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
|
||||
use db::DeleteOutcome;
|
||||
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: vocab.id,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Trä".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "material".into(),
|
||||
field_type: FieldType::Term {
|
||||
vocabulary_id: vocab.id,
|
||||
},
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Material".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
"material".into(),
|
||||
serde_json::Value::String(term_id.to_string()),
|
||||
);
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
|
||||
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ok, DeleteOutcome::Deleted);
|
||||
assert!(
|
||||
vocab::term_by_id(&mut *tx, term_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history.iter().any(|e| e.action == AuditAction::Deleted),
|
||||
"expected a Deleted audit entry for the term"
|
||||
);
|
||||
|
||||
let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(gone, DeleteOutcome::NotFound);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn rename_vocabulary_changes_key(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old")
|
||||
.await
|
||||
.unwrap();
|
||||
let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(existed);
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), "new")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), "old")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) {
|
||||
use db::DeleteOutcome;
|
||||
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: v.id,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "Trä".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
|
||||
|
||||
let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
|
||||
.await
|
||||
.unwrap(),
|
||||
DeleteOutcome::Deleted
|
||||
);
|
||||
|
||||
let gone = vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(gone, DeleteOutcome::NotFound);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
uuid.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
time.workspace = true
|
||||
utoipa.workspace = true
|
||||
@@ -0,0 +1,151 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// What kind of change an audit entry records.
|
||||
///
|
||||
/// NOTE: kept in sync by hand with the
|
||||
/// `CHECK (action IN ('created', 'updated', 'deleted'))` constraint in
|
||||
/// `crates/db/migrations/0001_audit_log.sql` — add a variant in both places.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuditAction {
|
||||
Created,
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
/// The database/text representation.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AuditAction::Created => "created",
|
||||
AuditAction::Updated => "updated",
|
||||
AuditAction::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from the database/text representation.
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"created" => Some(AuditAction::Created),
|
||||
"updated" => Some(AuditAction::Updated),
|
||||
"deleted" => Some(AuditAction::Deleted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who performed the change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", content = "id")]
|
||||
pub enum AuditActor {
|
||||
/// A specific user, referenced by id (a `UserId` newtype arrives with auth).
|
||||
User(Uuid),
|
||||
/// The system itself (migrations, automated processes).
|
||||
System,
|
||||
}
|
||||
|
||||
/// One field's before/after values within a change.
|
||||
///
|
||||
/// Note: after a JSON round-trip, `Some(Value::Null)` is indistinguishable from
|
||||
/// `None`. Use `None` to mean "no value"; do not encode an absent value as
|
||||
/// `Some(Value::Null)`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FieldChange {
|
||||
/// Field name (catalogue field key or column name).
|
||||
pub field: String,
|
||||
/// Value before the change (None when newly set).
|
||||
pub before: Option<Value>,
|
||||
/// Value after the change (None when cleared).
|
||||
pub after: Option<Value>,
|
||||
}
|
||||
|
||||
/// An audit event to be recorded.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewAuditEvent {
|
||||
pub actor: AuditActor,
|
||||
pub action: AuditAction,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub changes: Vec<FieldChange>,
|
||||
}
|
||||
|
||||
/// A recorded audit entry, read back from the log.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuditEntry {
|
||||
/// Monotonic sequence number (insertion order).
|
||||
pub seq: i64,
|
||||
/// When it was recorded.
|
||||
pub at: OffsetDateTime,
|
||||
pub actor: AuditActor,
|
||||
pub action: AuditAction,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub changes: Vec<FieldChange>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn action_round_trips_via_db_string() {
|
||||
for a in [
|
||||
AuditAction::Created,
|
||||
AuditAction::Updated,
|
||||
AuditAction::Deleted,
|
||||
] {
|
||||
assert_eq!(AuditAction::from_db(a.as_str()), Some(a));
|
||||
}
|
||||
assert_eq!(AuditAction::from_db("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_change_serde_round_trip() {
|
||||
let fc = FieldChange {
|
||||
field: "name".into(),
|
||||
before: Some(json!("Vase")),
|
||||
after: Some(json!("Roman Vase")),
|
||||
};
|
||||
let v = serde_json::to_value(&fc).unwrap();
|
||||
assert_eq!(v["field"], "name");
|
||||
assert_eq!(v["before"], "Vase");
|
||||
assert_eq!(v["after"], "Roman Vase");
|
||||
let back: FieldChange = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, fc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actor_serde_round_trips() {
|
||||
for actor in [AuditActor::User(Uuid::nil()), AuditActor::System] {
|
||||
let v = serde_json::to_value(actor).unwrap();
|
||||
let back: AuditActor = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, actor);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_value(AuditActor::User(Uuid::nil())).unwrap()["kind"],
|
||||
"user"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AuditActor::System).unwrap()["kind"],
|
||||
"system"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_serde_matches_as_str() {
|
||||
for a in [
|
||||
AuditAction::Created,
|
||||
AuditAction::Updated,
|
||||
AuditAction::Deleted,
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_value(a).unwrap(),
|
||||
serde_json::Value::String(a.as_str().to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AuthorityId, LocalizedLabel};
|
||||
|
||||
/// The kind of authority record.
|
||||
///
|
||||
/// NOTE: kept in sync by hand with the
|
||||
/// `CHECK (kind IN ('person', 'organisation', 'place'))` constraint in
|
||||
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — add a variant in both places.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthorityKind {
|
||||
Person,
|
||||
Organisation,
|
||||
Place,
|
||||
}
|
||||
|
||||
impl AuthorityKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AuthorityKind::Person => "person",
|
||||
AuthorityKind::Organisation => "organisation",
|
||||
AuthorityKind::Place => "place",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"person" => Some(AuthorityKind::Person),
|
||||
"organisation" => Some(AuthorityKind::Organisation),
|
||||
"place" => Some(AuthorityKind::Place),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An authority record (person / organisation / place), with multilingual labels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Authority {
|
||||
pub id: AuthorityId,
|
||||
pub kind: AuthorityKind,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// An authority to be created.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewAuthority {
|
||||
pub kind: AuthorityKind,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A reference to an authority confirmed to exist (carries its kind).
|
||||
///
|
||||
/// Obtain via `db::authority::resolve_authority`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthorityRef {
|
||||
authority_id: AuthorityId,
|
||||
kind: AuthorityKind,
|
||||
}
|
||||
|
||||
impl AuthorityRef {
|
||||
pub fn new(authority_id: AuthorityId, kind: AuthorityKind) -> Self {
|
||||
Self { authority_id, kind }
|
||||
}
|
||||
pub fn authority_id(&self) -> AuthorityId {
|
||||
self.authority_id
|
||||
}
|
||||
pub fn kind(&self) -> AuthorityKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kind_round_trips_via_db_string() {
|
||||
for k in [
|
||||
AuthorityKind::Person,
|
||||
AuthorityKind::Organisation,
|
||||
AuthorityKind::Place,
|
||||
] {
|
||||
assert_eq!(AuthorityKind::from_db(k.as_str()), Some(k));
|
||||
}
|
||||
assert_eq!(AuthorityKind::from_db("ufo"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use crate::{AuthorityKind, FieldDefinitionId, LocalizedLabel, VocabularyId};
|
||||
|
||||
/// The type of a flexible field, carrying its binding where applicable.
|
||||
///
|
||||
/// Type-driven: a `Term` always names its vocabulary; a non-term never carries one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FieldType {
|
||||
Text,
|
||||
LocalizedText,
|
||||
Integer,
|
||||
Date,
|
||||
Boolean,
|
||||
Term {
|
||||
vocabulary_id: VocabularyId,
|
||||
},
|
||||
/// An authority reference. `kind: None` accepts any authority kind;
|
||||
/// `Some(kind)` constrains to that kind.
|
||||
Authority {
|
||||
kind: Option<AuthorityKind>,
|
||||
},
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
/// The stored discriminant string.
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
FieldType::Text => "text",
|
||||
FieldType::LocalizedText => "localized_text",
|
||||
FieldType::Integer => "integer",
|
||||
FieldType::Date => "date",
|
||||
FieldType::Boolean => "boolean",
|
||||
FieldType::Term { .. } => "term",
|
||||
FieldType::Authority { .. } => "authority",
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompose into the three stored columns: `(data_type, vocabulary_id, authority_kind)`.
|
||||
pub fn to_parts(&self) -> (&'static str, Option<VocabularyId>, Option<AuthorityKind>) {
|
||||
match self {
|
||||
FieldType::Term { vocabulary_id } => ("term", Some(*vocabulary_id), None),
|
||||
FieldType::Authority { kind } => ("authority", None, *kind),
|
||||
other => (other.kind_str(), None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from the stored columns. Returns `None` for an unknown
|
||||
/// discriminant or an inconsistent combination — a scalar type carrying any
|
||||
/// binding, a `term` without a vocabulary (or with an authority kind), or an
|
||||
/// `authority` carrying a vocabulary.
|
||||
pub fn from_parts(
|
||||
data_type: &str,
|
||||
vocabulary_id: Option<VocabularyId>,
|
||||
authority_kind: Option<AuthorityKind>,
|
||||
) -> Option<Self> {
|
||||
let scalar = vocabulary_id.is_none() && authority_kind.is_none();
|
||||
|
||||
match data_type {
|
||||
"text" if scalar => Some(FieldType::Text),
|
||||
"localized_text" if scalar => Some(FieldType::LocalizedText),
|
||||
"integer" if scalar => Some(FieldType::Integer),
|
||||
"date" if scalar => Some(FieldType::Date),
|
||||
"boolean" if scalar => Some(FieldType::Boolean),
|
||||
"term" => match vocabulary_id {
|
||||
Some(vocabulary_id) if authority_kind.is_none() => {
|
||||
Some(FieldType::Term { vocabulary_id })
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
"authority" if vocabulary_id.is_none() => Some(FieldType::Authority {
|
||||
kind: authority_kind,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored `data_type` discriminant of a field definition — mirrors the strings from
|
||||
/// [`FieldType::kind_str`]. Exists so the OpenAPI schema can describe `data_type` as a
|
||||
/// closed string enum (consumed by the typed web client). Keep in sync with `kind_str`.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DataType {
|
||||
Text,
|
||||
LocalizedText,
|
||||
Integer,
|
||||
Date,
|
||||
Boolean,
|
||||
Term,
|
||||
Authority,
|
||||
}
|
||||
|
||||
/// A registered flexible field, with its multilingual display labels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldDefinition {
|
||||
pub id: FieldDefinitionId,
|
||||
pub key: String,
|
||||
pub field_type: FieldType,
|
||||
pub required: bool,
|
||||
pub group_key: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A field definition to be created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewFieldDefinition {
|
||||
pub key: String,
|
||||
pub field_type: FieldType,
|
||||
pub required: bool,
|
||||
pub group_key: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_type_round_trips_through_parts() {
|
||||
let v = VocabularyId::new();
|
||||
let cases = [
|
||||
FieldType::Text,
|
||||
FieldType::LocalizedText,
|
||||
FieldType::Integer,
|
||||
FieldType::Date,
|
||||
FieldType::Boolean,
|
||||
FieldType::Term { vocabulary_id: v },
|
||||
FieldType::Authority {
|
||||
kind: Some(AuthorityKind::Person),
|
||||
},
|
||||
FieldType::Authority { kind: None },
|
||||
];
|
||||
for ft in cases {
|
||||
let (data_type, vocabulary_id, authority_kind) = ft.to_parts();
|
||||
assert_eq!(
|
||||
FieldType::from_parts(data_type, vocabulary_id, authority_kind),
|
||||
Some(ft)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_without_vocabulary_is_invalid() {
|
||||
assert_eq!(FieldType::from_parts("term", None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_is_none() {
|
||||
assert_eq!(FieldType::from_parts("blob", None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stray_binding_on_scalar_type_is_rejected() {
|
||||
let v = VocabularyId::new();
|
||||
assert_eq!(FieldType::from_parts("text", Some(v), None), None);
|
||||
assert_eq!(
|
||||
FieldType::from_parts("boolean", None, Some(AuthorityKind::Person)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_or_authority_with_wrong_binding_is_rejected() {
|
||||
let v = VocabularyId::new();
|
||||
assert_eq!(
|
||||
FieldType::from_parts("term", Some(v), Some(AuthorityKind::Person)),
|
||||
None
|
||||
);
|
||||
assert_eq!(FieldType::from_parts("authority", Some(v), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_type_serde_matches_kind_str() {
|
||||
use serde_json::json;
|
||||
assert_eq!(
|
||||
serde_json::to_value(DataType::LocalizedText).unwrap(),
|
||||
json!("localized_text")
|
||||
);
|
||||
assert_eq!(serde_json::to_value(DataType::Text).unwrap(), json!("text"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(DataType::Authority).unwrap(),
|
||||
json!("authority")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Strongly-typed identifiers.
|
||||
|
||||
/// Define a UUID newtype identifier with the standard constructors and conversions.
|
||||
macro_rules! id_newtype {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(uuid::Uuid);
|
||||
|
||||
impl $name {
|
||||
/// Generate a fresh random id.
|
||||
#[must_use = "generating an id and discarding it is almost certainly a mistake"]
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Wrap an existing [`uuid::Uuid`].
|
||||
pub fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// The underlying [`uuid::Uuid`].
|
||||
pub fn to_uuid(&self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for $name {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(uuid::Uuid::parse_str(s)?))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
id_newtype!(
|
||||
/// Identifier for an organization (tenant).
|
||||
OrgId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a controlled vocabulary (term source).
|
||||
VocabularyId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a term within a vocabulary.
|
||||
TermId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for an authority record (person, organisation, or place).
|
||||
AuthorityId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a catalogue object (or group of objects).
|
||||
ObjectId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a flexible-field definition.
|
||||
FieldDefinitionId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a user of this organization's instance.
|
||||
UserId
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_displays_round_trip() {
|
||||
let text = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let id: OrgId = text.parse().expect("valid uuid should parse");
|
||||
assert_eq!(id.to_string(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_uuid() {
|
||||
assert!("not-a-uuid".parse::<OrgId>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_id_types_parse_independently() {
|
||||
let text = "550e8400-e29b-41d4-a716-446655440000";
|
||||
assert_eq!(text.parse::<VocabularyId>().unwrap().to_string(), text);
|
||||
assert_eq!(text.parse::<TermId>().unwrap().to_string(), text);
|
||||
assert_eq!(text.parse::<AuthorityId>().unwrap().to_string(), text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A label in a specific language (BCP-47 tag, e.g. "sv", "en").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedLabel {
|
||||
pub lang: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Pick the best label for `lang`, falling back to `fallback`, then the first.
|
||||
pub fn pick_label<'a>(labels: &'a [LocalizedLabel], lang: &str, fallback: &str) -> Option<&'a str> {
|
||||
labels
|
||||
.iter()
|
||||
.find(|l| l.lang == lang)
|
||||
.or_else(|| labels.iter().find(|l| l.lang == fallback))
|
||||
.or_else(|| labels.first())
|
||||
.map(|l| l.label.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Vec<LocalizedLabel> {
|
||||
vec![
|
||||
LocalizedLabel {
|
||||
lang: "sv".into(),
|
||||
label: "trä".into(),
|
||||
},
|
||||
LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "wood".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_requested_language() {
|
||||
assert_eq!(pick_label(&sample(), "sv", "en"), Some("trä"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_then_first() {
|
||||
assert_eq!(pick_label(&sample(), "de", "en"), Some("wood"));
|
||||
assert_eq!(pick_label(&sample(), "de", "fr"), Some("trä"));
|
||||
assert_eq!(pick_label(&[], "sv", "en"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Core domain types and invariants. No I/O dependencies.
|
||||
|
||||
mod audit;
|
||||
mod authority;
|
||||
mod field_definition;
|
||||
mod id;
|
||||
mod label;
|
||||
mod object;
|
||||
mod user;
|
||||
mod vocabulary;
|
||||
|
||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
||||
pub use field_definition::{DataType, FieldDefinition, FieldType, NewFieldDefinition};
|
||||
pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId};
|
||||
pub use label::{LocalizedLabel, pick_label};
|
||||
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
||||
pub use user::{Capability, Email, EmailError, NewUser, Role, User};
|
||||
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
|
||||
@@ -0,0 +1,196 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
use crate::ObjectId;
|
||||
|
||||
/// Publication state of a catalogue record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Visibility {
|
||||
/// Work in progress; not shown anywhere public.
|
||||
#[default]
|
||||
Draft,
|
||||
/// Complete but internal-only.
|
||||
Internal,
|
||||
/// Published; eligible for the public API.
|
||||
Public,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Visibility::Draft => "draft",
|
||||
Visibility::Internal => "internal",
|
||||
Visibility::Public => "public",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"draft" => Some(Visibility::Draft),
|
||||
"internal" => Some(Visibility::Internal),
|
||||
"public" => Some(Visibility::Public),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
/// Whether `self` may move directly to `target`. Legal single steps are
|
||||
/// `draft↔internal` and `internal↔public`; `draft↔public` is not one step.
|
||||
pub fn can_transition_to(self, target: Visibility) -> bool {
|
||||
use Visibility::*;
|
||||
|
||||
matches!(
|
||||
(self, target),
|
||||
(Draft, Internal) | (Internal, Draft) | (Internal, Public) | (Public, Internal)
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate a stepwise transition to `target`. Setting to the current value is an
|
||||
/// idempotent no-op (`Ok`). A forbidden jump returns [`IllegalTransition`].
|
||||
pub fn transition_to(self, target: Visibility) -> Result<Visibility, IllegalTransition> {
|
||||
if self == target || self.can_transition_to(target) {
|
||||
Ok(target)
|
||||
} else {
|
||||
Err(IllegalTransition {
|
||||
from: self,
|
||||
to: target,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An attempted visibility change the state machine forbids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct IllegalTransition {
|
||||
pub from: Visibility,
|
||||
pub to: Visibility,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IllegalTransition {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"illegal visibility transition: {} -> {}",
|
||||
self.from.as_str(),
|
||||
self.to.as_str()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IllegalTransition {}
|
||||
|
||||
/// The mutable inventory-minimum fields of a catalogue object.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ObjectInput {
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// A catalogue object (or group of objects), read back from storage.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CatalogueObject {
|
||||
pub id: ObjectId,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
/// Flexible field values (field key -> value), validated against the registry.
|
||||
pub fields: serde_json::Value,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl CatalogueObject {
|
||||
/// The mutable fields as an [`ObjectInput`] (used to diff against an update).
|
||||
pub fn to_input(&self) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: self.object_number.clone(),
|
||||
object_name: self.object_name.clone(),
|
||||
number_of_objects: self.number_of_objects,
|
||||
brief_description: self.brief_description.clone(),
|
||||
current_location: self.current_location.clone(),
|
||||
current_owner: self.current_owner.clone(),
|
||||
recorder: self.recorder.clone(),
|
||||
recording_date: self.recording_date,
|
||||
visibility: self.visibility,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visibility_round_trips_and_defaults_to_draft() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(Visibility::from_db(v.as_str()), Some(v));
|
||||
}
|
||||
assert_eq!(Visibility::from_db("secret"), None);
|
||||
assert_eq!(Visibility::default(), Visibility::Draft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_serde_matches_as_str() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(
|
||||
serde_json::to_value(v).unwrap(),
|
||||
serde_json::Value::String(v.as_str().to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepwise_transitions_are_legal() {
|
||||
use Visibility::*;
|
||||
assert_eq!(Draft.transition_to(Internal), Ok(Internal));
|
||||
assert_eq!(Internal.transition_to(Public), Ok(Public));
|
||||
assert_eq!(Public.transition_to(Internal), Ok(Internal));
|
||||
assert_eq!(Internal.transition_to(Draft), Ok(Draft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipping_a_step_is_illegal() {
|
||||
use Visibility::*;
|
||||
assert_eq!(
|
||||
Draft.transition_to(Public),
|
||||
Err(IllegalTransition {
|
||||
from: Draft,
|
||||
to: Public
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Public.transition_to(Draft),
|
||||
Err(IllegalTransition {
|
||||
from: Public,
|
||||
to: Draft
|
||||
})
|
||||
);
|
||||
// the Display message is the user-visible surface of the error
|
||||
assert_eq!(
|
||||
Draft.transition_to(Public).unwrap_err().to_string(),
|
||||
"illegal visibility transition: draft -> public"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_to_current_value_is_a_noop_ok() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(v.transition_to(v), Ok(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! User identity, roles, and the capability policy.
|
||||
//!
|
||||
//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The
|
||||
//! role→capability mapping (`Role::allows`) is the single source of authorization
|
||||
//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth`
|
||||
//! boundary, never in these types.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::UserId;
|
||||
|
||||
/// A validated email address (normalized to lowercase, trimmed).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Email(String);
|
||||
|
||||
/// The supplied string is not a syntactically acceptable email.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EmailError;
|
||||
|
||||
impl std::fmt::Display for EmailError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("invalid email address")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EmailError {}
|
||||
|
||||
impl Email {
|
||||
/// Parse and normalize an email. Light MVP validation: a single `@`, non-empty
|
||||
/// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321
|
||||
/// validation is deferred.)
|
||||
pub fn parse(raw: &str) -> Result<Email, EmailError> {
|
||||
let normalized = raw.trim().to_lowercase();
|
||||
|
||||
if normalized.contains(char::is_whitespace) {
|
||||
return Err(EmailError);
|
||||
}
|
||||
|
||||
let mut parts = normalized.split('@');
|
||||
let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return Err(EmailError);
|
||||
};
|
||||
|
||||
let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.');
|
||||
|
||||
if local.is_empty() || !domain_ok {
|
||||
return Err(EmailError);
|
||||
}
|
||||
|
||||
Ok(Email(normalized))
|
||||
}
|
||||
|
||||
/// The normalized string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Reconstruct from a stored (already-validated) value, without re-validating.
|
||||
/// For reading values back from the database only — never to construct an `Email`
|
||||
/// destined to be written (writes must go through [`Email::parse`] so storage
|
||||
/// stays normalized).
|
||||
pub fn from_db(value: String) -> Email {
|
||||
Email(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A user's role within the organization.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
/// Full access, including user management.
|
||||
Admin,
|
||||
/// Catalogue work: create/edit/publish records; cannot manage users.
|
||||
Editor,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::Admin => "admin",
|
||||
Role::Editor => "editor",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Role::Admin),
|
||||
"editor" => Some(Role::Editor),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The authorization policy: whether this role may perform `capability`.
|
||||
///
|
||||
/// The `Editor` arm is an exhaustive `match` on purpose: adding a new
|
||||
/// [`Capability`] variant is a compile error here until its Editor access is
|
||||
/// decided explicitly, so the policy fails closed rather than silently granting
|
||||
/// new capabilities to Editors.
|
||||
pub fn allows(self, capability: Capability) -> bool {
|
||||
match self {
|
||||
Role::Admin => true,
|
||||
Role::Editor => match capability {
|
||||
Capability::EditCatalogue
|
||||
| Capability::PublishObjects
|
||||
| Capability::ViewInternal => true,
|
||||
Capability::ManageUsers => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A guarded action. `Authorized<C>` (in the `auth` crate) gates a handler on one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Capability {
|
||||
/// Create/list/modify users.
|
||||
ManageUsers,
|
||||
/// Create and edit catalogue records.
|
||||
EditCatalogue,
|
||||
/// Change a record's visibility (publish/unpublish).
|
||||
PublishObjects,
|
||||
/// Read internal (non-public) records.
|
||||
ViewInternal,
|
||||
}
|
||||
|
||||
/// A user as read back from storage. Carries no password material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub email: Email,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewUser {
|
||||
pub email: Email,
|
||||
pub password_hash: String,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_parses_and_normalizes() {
|
||||
assert_eq!(
|
||||
Email::parse(" Anna@Example.COM ").unwrap().as_str(),
|
||||
"anna@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_rejects_garbage() {
|
||||
for bad in [
|
||||
"",
|
||||
"no-at",
|
||||
"a@b",
|
||||
"a@@b.com",
|
||||
"a b@c.com",
|
||||
"@example.com",
|
||||
"x@.com",
|
||||
"x@com.",
|
||||
] {
|
||||
assert!(Email::parse(bad).is_err(), "should reject {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_round_trips() {
|
||||
for r in [Role::Admin, Role::Editor] {
|
||||
assert_eq!(Role::from_db(r.as_str()), Some(r));
|
||||
}
|
||||
assert_eq!(Role::from_db("superuser"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_policy_matrix() {
|
||||
use Capability::*;
|
||||
for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] {
|
||||
assert!(Role::Admin.allows(cap));
|
||||
}
|
||||
assert!(!Role::Editor.allows(ManageUsers));
|
||||
for cap in [EditCatalogue, PublishObjects, ViewInternal] {
|
||||
assert!(Role::Editor.allows(cap));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{LocalizedLabel, TermId, VocabularyId};
|
||||
|
||||
/// A controlled vocabulary (term source), e.g. "material" or "object_name".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Vocabulary {
|
||||
pub id: VocabularyId,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// A term within a vocabulary, with its multilingual labels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Term {
|
||||
pub id: TermId,
|
||||
pub vocabulary_id: VocabularyId,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A term to be created.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewTerm {
|
||||
pub vocabulary_id: VocabularyId,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A reference to a term confirmed to exist in a given vocabulary.
|
||||
///
|
||||
/// Obtain via `db::vocab::resolve_term`; do not construct ad hoc for
|
||||
/// values that haven't been resolved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TermRef {
|
||||
term_id: TermId,
|
||||
vocabulary_id: VocabularyId,
|
||||
}
|
||||
|
||||
impl TermRef {
|
||||
pub fn new(term_id: TermId, vocabulary_id: VocabularyId) -> Self {
|
||||
Self {
|
||||
term_id,
|
||||
vocabulary_id,
|
||||
}
|
||||
}
|
||||
pub fn term_id(&self) -> TermId {
|
||||
self.term_id
|
||||
}
|
||||
pub fn vocabulary_id(&self) -> VocabularyId {
|
||||
self.vocabulary_id
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{TermId, VocabularyId};
|
||||
|
||||
#[test]
|
||||
fn term_ref_exposes_its_parts() {
|
||||
let term_id = TermId::new();
|
||||
let vocabulary_id = VocabularyId::new();
|
||||
let r = TermRef::new(term_id, vocabulary_id);
|
||||
assert_eq!(r.term_id(), term_id);
|
||||
assert_eq!(r.vocabulary_id(), vocabulary_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "search"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
meilisearch-sdk.workspace = true
|
||||
serde = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
db = { path = "../db" }
|
||||
sqlx.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
sqlx.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Full-text search over catalogue objects, backed by Meilisearch.
|
||||
//!
|
||||
//! This crate provides the search *capability* plus a `reindex_all` rebuild path.
|
||||
//! On-write index sync (calling `index_object`/`remove_object` after a catalogue
|
||||
//! mutation commits) is wired at the API/service layer (Plan 7+). Meilisearch is not
|
||||
//! transactional with Postgres, so the index is eventually consistent; `reindex_all`
|
||||
//! is the recovery path.
|
||||
|
||||
use db::Db;
|
||||
use domain::{CatalogueObject, ObjectId};
|
||||
use meilisearch_sdk::search::Selectors;
|
||||
use meilisearch_sdk::tasks::Task;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Errors from the search subsystem.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SearchError {
|
||||
#[error(transparent)]
|
||||
Meili(#[from] meilisearch_sdk::errors::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid object id in index: {0}")]
|
||||
BadId(String),
|
||||
}
|
||||
|
||||
/// The indexed shape of a catalogue object.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchDocument {
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<String>,
|
||||
/// Filterable: "draft" | "internal" | "public".
|
||||
pub visibility: String,
|
||||
/// Flexible field values flattened to searchable text.
|
||||
pub fields_text: Vec<String>,
|
||||
}
|
||||
|
||||
/// Non-HTML highlight markers. These ASCII control characters cannot occur in
|
||||
/// catalogue text, so the frontend can safely split on them to render matches —
|
||||
/// no HTML ever crosses the API boundary.
|
||||
pub const HL_PRE: &str = "\u{2}";
|
||||
pub const HL_POST: &str = "\u{3}";
|
||||
|
||||
/// One search result: display metadata projected from the index, plus an optional
|
||||
/// snippet of matched text with [`HL_PRE`]/[`HL_POST`] markers around the matches.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchHit {
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
pub visibility: String,
|
||||
pub recording_date: Option<String>,
|
||||
pub snippet: Option<String>,
|
||||
}
|
||||
|
||||
/// A page of search results plus Meilisearch's estimate of the total match count.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResults {
|
||||
pub hits: Vec<SearchHit>,
|
||||
pub estimated_total: usize,
|
||||
}
|
||||
|
||||
/// A Meilisearch-backed search client scoped to one index.
|
||||
#[derive(Clone)]
|
||||
pub struct SearchClient {
|
||||
client: meilisearch_sdk::client::Client,
|
||||
index_uid: String,
|
||||
}
|
||||
|
||||
/// Turn a completed task into an error if Meilisearch rejected it.
|
||||
fn check_task(task: Task) -> Result<(), SearchError> {
|
||||
match task {
|
||||
Task::Failed { content } => Err(SearchError::Meili(
|
||||
meilisearch_sdk::errors::Error::Meilisearch(content.error),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchClient {
|
||||
pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result<Self, SearchError> {
|
||||
let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
index_uid: index_uid.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn ensure_index(&self) -> Result<(), SearchError> {
|
||||
let task = self
|
||||
.client
|
||||
.create_index(&self.index_uid, Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
// Tolerate "index already exists"; surface any other task failure.
|
||||
if let Task::Failed { content } = &task {
|
||||
if content.error.error_code != meilisearch_sdk::errors::ErrorCode::IndexAlreadyExists {
|
||||
return Err(SearchError::Meili(
|
||||
meilisearch_sdk::errors::Error::Meilisearch(content.error.clone()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// set_filterable_attributes is idempotent on an existing index
|
||||
let task = self
|
||||
.client
|
||||
.index(&self.index_uid)
|
||||
.set_filterable_attributes(["visibility"])
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
check_task(task)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> {
|
||||
let task = self
|
||||
.client
|
||||
.index(&self.index_uid)
|
||||
.add_or_replace(std::slice::from_ref(doc), Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
check_task(task)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> {
|
||||
let task = self
|
||||
.client
|
||||
.index(&self.index_uid)
|
||||
.delete_document(id.to_string())
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
check_task(task)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<ObjectId>, SearchError> {
|
||||
let index = self.client.index(&self.index_uid);
|
||||
|
||||
let results = index
|
||||
.search()
|
||||
.with_query(query)
|
||||
.build()
|
||||
.execute::<SearchDocument>()
|
||||
.await?;
|
||||
|
||||
results
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
hit.result
|
||||
.id
|
||||
.parse::<ObjectId>()
|
||||
.map_err(|_| SearchError::BadId(hit.result.id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Full-text query returning display-ready hits with highlighted snippets and the
|
||||
/// estimated total match count. `visibility`, when set, filters on the indexed
|
||||
/// `visibility` attribute. Pagination is offset/limit.
|
||||
///
|
||||
/// # Preconditions
|
||||
///
|
||||
/// When `visibility` is `Some`, the value must be one of `"draft"`, `"internal"`, or
|
||||
/// `"public"`. The caller owns this validation (the API layer enforces it); this
|
||||
/// method `debug_assert!`s the constraint as defense-in-depth.
|
||||
pub async fn search_objects(
|
||||
&self,
|
||||
query: &str,
|
||||
visibility: Option<&str>,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<SearchResults, SearchError> {
|
||||
let index = self.client.index(&self.index_uid);
|
||||
|
||||
let filter = visibility.map(|v| {
|
||||
debug_assert!(
|
||||
matches!(v, "draft" | "internal" | "public"),
|
||||
"visibility filter must be a known value; got {v:?}"
|
||||
);
|
||||
|
||||
format!("visibility = \"{v}\"")
|
||||
});
|
||||
let highlight: &[&str] = &["object_name", "brief_description", "fields_text"];
|
||||
let crop: &[(&str, Option<usize>)] = &[("brief_description", None), ("fields_text", None)];
|
||||
|
||||
let mut search = index.search();
|
||||
search
|
||||
.with_query(query)
|
||||
.with_offset(offset)
|
||||
.with_limit(limit)
|
||||
.with_attributes_to_highlight(Selectors::Some(highlight))
|
||||
.with_attributes_to_crop(Selectors::Some(crop))
|
||||
// ~20 words gives enough catalogue-description context around a match.
|
||||
.with_crop_length(20)
|
||||
.with_highlight_pre_tag(HL_PRE)
|
||||
.with_highlight_post_tag(HL_POST);
|
||||
|
||||
if let Some(filter) = &filter {
|
||||
search.with_filter(filter);
|
||||
}
|
||||
|
||||
let results = search.execute::<SearchDocument>().await?;
|
||||
|
||||
let hits = results
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
let snippet = hit.formatted_result.as_ref().and_then(extract_snippet);
|
||||
let doc = hit.result;
|
||||
|
||||
SearchHit {
|
||||
id: doc.id,
|
||||
object_number: doc.object_number,
|
||||
object_name: doc.object_name,
|
||||
brief_description: doc.brief_description,
|
||||
visibility: doc.visibility,
|
||||
recording_date: doc.recording_date,
|
||||
snippet,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(SearchResults {
|
||||
hits,
|
||||
// estimated_total_hits is always present for offset/limit pagination;
|
||||
// None only under page-based mode, which we don't use.
|
||||
estimated_total: results.estimated_total_hits.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sync a single object's index entry with the database after a catalogue write
|
||||
/// commits: re-project and index it if it still exists, otherwise remove it. This
|
||||
/// is the uniform on-write path for create/update/delete/field/visibility changes —
|
||||
/// a delete (object gone) removes the entry; everything else re-indexes the current
|
||||
/// projection. Best-effort: callers invoke it after the DB transaction commits and
|
||||
/// log (not propagate) any error, since `reindex_all` is the recovery path.
|
||||
pub async fn sync_object(&self, db: &Db, id: ObjectId) -> Result<(), SearchError> {
|
||||
match db::catalog::object_by_id(db.pool(), id).await? {
|
||||
Some(object) => {
|
||||
let document = build_document(db, &object).await?;
|
||||
|
||||
self.index_object(&document).await
|
||||
}
|
||||
None => self.remove_object(id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the whole index from the database (clears then re-adds all objects).
|
||||
pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> {
|
||||
let index = self.client.index(&self.index_uid);
|
||||
|
||||
let task = index
|
||||
.delete_all_documents()
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
check_task(task)?;
|
||||
|
||||
let objects = db::catalog::list_objects(db.pool()).await?;
|
||||
|
||||
let mut docs = Vec::with_capacity(objects.len());
|
||||
|
||||
for object in &objects {
|
||||
docs.push(build_document(db, object).await?);
|
||||
}
|
||||
|
||||
if !docs.is_empty() {
|
||||
let task = index
|
||||
.add_or_replace(&docs, Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
check_task(task)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`SearchDocument`] from a catalogue object, resolving term and authority
|
||||
/// references to their human-readable labels so Meilisearch can match on them.
|
||||
pub async fn build_document(
|
||||
db: &Db,
|
||||
object: &CatalogueObject,
|
||||
) -> Result<SearchDocument, SearchError> {
|
||||
let mut fields_text = Vec::new();
|
||||
|
||||
if let Some(map) = object.fields.as_object() {
|
||||
for (key, value) in map {
|
||||
let Some(def) = db::fields::field_definition_by_key(db.pool(), key).await? else {
|
||||
// Stale field with no definition — skip.
|
||||
continue;
|
||||
};
|
||||
|
||||
match def.field_type {
|
||||
domain::FieldType::Text | domain::FieldType::Date => {
|
||||
if let Some(s) = value.as_str() {
|
||||
fields_text.push(s.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
domain::FieldType::Integer | domain::FieldType::Boolean => {
|
||||
fields_text.push(value.to_string());
|
||||
}
|
||||
|
||||
domain::FieldType::LocalizedText => {
|
||||
if let Some(obj) = value.as_object() {
|
||||
for v in obj.values() {
|
||||
if let Some(s) = v.as_str() {
|
||||
fields_text.push(s.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
domain::FieldType::Term { .. } => {
|
||||
if let Some(term_id) = value
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<domain::TermId>().ok())
|
||||
{
|
||||
if let Some(term) = db::vocab::term_by_id(db.pool(), term_id).await? {
|
||||
fields_text.extend(term.labels.into_iter().map(|l| l.label));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
domain::FieldType::Authority { .. } => {
|
||||
if let Some(authority_id) = value
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<domain::AuthorityId>().ok())
|
||||
{
|
||||
if let Some(authority) =
|
||||
db::authority::authority_by_id(db.pool(), authority_id).await?
|
||||
{
|
||||
fields_text.extend(authority.labels.into_iter().map(|l| l.label));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchDocument {
|
||||
id: object.id.to_string(),
|
||||
object_number: object.object_number.clone(),
|
||||
object_name: object.object_name.clone(),
|
||||
brief_description: object.brief_description.clone(),
|
||||
current_owner: object.current_owner.clone(),
|
||||
recorder: object.recorder.clone(),
|
||||
recording_date: object.recording_date.map(|d| d.to_string()),
|
||||
visibility: object.visibility.as_str().to_owned(),
|
||||
fields_text,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the best snippet from Meilisearch's `_formatted` map: prefer a highlighted
|
||||
/// `brief_description`, then a highlighted `fields_text` entry, then `object_name`;
|
||||
/// fall back to an unhighlighted `brief_description` so a hit still shows context.
|
||||
fn extract_snippet(formatted: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
|
||||
let has_mark = |s: &str| s.contains(HL_PRE);
|
||||
|
||||
if let Some(serde_json::Value::String(s)) = formatted.get("brief_description") {
|
||||
if has_mark(s) {
|
||||
return Some(s.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(serde_json::Value::Array(items)) = formatted.get("fields_text") {
|
||||
for item in items {
|
||||
if let Some(s) = item.as_str() {
|
||||
if has_mark(s) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(serde_json::Value::String(s)) = formatted.get("object_name") {
|
||||
if has_mark(s) {
|
||||
return Some(s.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(serde_json::Value::String(s)) = formatted.get("brief_description") {
|
||||
return Some(s.clone());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use db::{Db, catalog, fields, vocab};
|
||||
use domain::{
|
||||
AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, Visibility,
|
||||
};
|
||||
use search::SearchClient;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("reindex_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
// Path is relative to this crate's root; the schema lives in the `db` crate.
|
||||
// If the workspace layout changes, update this path.
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
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 mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let wood = vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: material.id,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "wood".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "material".into(),
|
||||
field_type: FieldType::Term {
|
||||
vocabulary_id: material.id,
|
||||
},
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel {
|
||||
lang: "en".into(),
|
||||
label: "material".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let object_id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Public,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// set the material field to the wood term
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
object_id,
|
||||
serde_json::json!({ "material": wood.to_string() })
|
||||
.as_object()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
client.ensure_index().await.unwrap();
|
||||
client.reindex_all(&db).await.unwrap();
|
||||
|
||||
// found by the object name
|
||||
assert_eq!(client.search("vase").await.unwrap(), vec![object_id]);
|
||||
// found by the resolved TERM LABEL (not the uuid)
|
||||
assert_eq!(client.search("wood").await.unwrap(), vec![object_id]);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use search::{self, SearchClient, SearchDocument};
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("objects_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
|
||||
SearchDocument {
|
||||
id: id.to_string(),
|
||||
object_number: format!("N-{id}"),
|
||||
object_name: object_name.to_string(),
|
||||
brief_description: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: "draft".to_string(),
|
||||
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_search_and_remove() {
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
client.ensure_index().await.unwrap();
|
||||
|
||||
let vase = domain::ObjectId::new();
|
||||
let chair = domain::ObjectId::new();
|
||||
client
|
||||
.index_object(&doc(&vase.to_string(), "vase", &["wood", "trä"]))
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.index_object(&doc(&chair.to_string(), "chair", &["oak"]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hits = client.search("wood").await.unwrap();
|
||||
assert_eq!(hits, vec![vase]);
|
||||
|
||||
let hits = client.search("chair").await.unwrap();
|
||||
assert_eq!(hits, vec![chair]);
|
||||
|
||||
client.remove_object(vase).await.unwrap();
|
||||
assert!(client.search("wood").await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
client.ensure_index().await.unwrap();
|
||||
|
||||
let a = domain::ObjectId::new();
|
||||
let b = domain::ObjectId::new();
|
||||
let c = domain::ObjectId::new();
|
||||
let mut bronze_a = doc(
|
||||
&a.to_string(),
|
||||
"Bronze figurine",
|
||||
&["cast bronze with green patina"],
|
||||
);
|
||||
bronze_a.visibility = "public".to_string();
|
||||
bronze_a.recording_date = Some("1962-04-03".to_string());
|
||||
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
|
||||
bronze_b.visibility = "public".to_string();
|
||||
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
|
||||
bronze_c.visibility = "draft".to_string();
|
||||
client.index_object(&bronze_a).await.unwrap();
|
||||
client.index_object(&bronze_b).await.unwrap();
|
||||
client.index_object(&bronze_c).await.unwrap();
|
||||
|
||||
let results = client.search_objects("bronze", None, 0, 20).await.unwrap();
|
||||
assert_eq!(results.estimated_total, 3);
|
||||
assert_eq!(results.hits.len(), 3);
|
||||
|
||||
let hit = results.hits.iter().find(|h| h.id == a.to_string()).unwrap();
|
||||
assert_eq!(hit.object_name, "Bronze figurine");
|
||||
assert_eq!(hit.object_number, format!("N-{a}"));
|
||||
let snippet = hit.snippet.as_ref().expect("a matched snippet");
|
||||
assert!(
|
||||
snippet.contains(search::HL_PRE),
|
||||
"snippet must mark the match"
|
||||
);
|
||||
assert!(snippet.contains(search::HL_POST));
|
||||
assert_eq!(hit.recording_date.as_deref(), Some("1962-04-03"));
|
||||
|
||||
let public = client
|
||||
.search_objects("bronze", Some("public"), 0, 20)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(public.estimated_total, 2);
|
||||
assert!(public.hits.iter().all(|h| h.visibility == "public"));
|
||||
|
||||
let page = client.search_objects("bronze", None, 0, 1).await.unwrap();
|
||||
assert_eq!(page.hits.len(), 1);
|
||||
assert_eq!(page.estimated_total, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_index_is_idempotent() {
|
||||
let (url, key) = meili();
|
||||
let index = unique_index();
|
||||
let client = SearchClient::connect(&url, &key, &index).unwrap();
|
||||
client.ensure_index().await.unwrap();
|
||||
// second call against the now-existing index must succeed
|
||||
client.ensure_index().await.unwrap();
|
||||
|
||||
// and the client still works
|
||||
let id = domain::ObjectId::new();
|
||||
client
|
||||
.index_object(&doc(&id.to_string(), "lamp", &[]))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.search("lamp").await.unwrap(), vec![id]);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use db::{Db, catalog};
|
||||
use domain::{AuditActor, ObjectInput, Visibility};
|
||||
use search::SearchClient;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("sync_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn object(number: &str, name: &str) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn sync_object_indexes_then_removes(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &object("S-1", "lamp"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
|
||||
client.ensure_index().await.unwrap();
|
||||
|
||||
// object exists -> sync indexes it
|
||||
client.sync_object(&db, id).await.unwrap();
|
||||
assert_eq!(client.search("lamp").await.unwrap(), vec![id]);
|
||||
|
||||
// object deleted -> sync removes it from the index
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
let existed = catalog::delete_object(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(existed);
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
client.sync_object(&db, id).await.unwrap();
|
||||
assert!(client.search("lamp").await.unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
embed-web = ["dep:memory-serve"]
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
api = { path = "../api" }
|
||||
auth = { path = "../auth" }
|
||||
db = { path = "../db" }
|
||||
domain = { path = "../domain" }
|
||||
search = { path = "../search" }
|
||||
rpassword.workspace = true
|
||||
dotenvy.workspace = true
|
||||
memory-serve = { workspace = true, optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
memory-serve = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
tower.workspace = true
|
||||
http-body-util.workspace = true
|
||||
api = { path = "../api" }
|
||||
auth = { path = "../auth" }
|
||||
db = { path = "../db" }
|
||||
domain = { path = "../domain" }
|
||||
sqlx.workspace = true
|
||||
temp-env = "0.3"
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
if std::env::var("CARGO_FEATURE_EMBED_WEB").is_ok() {
|
||||
memory_serve::load_directory("../../web/dist");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use clap::Parser;
|
||||
|
||||
/// Runtime configuration, sourced from CLI arguments and environment variables.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(version, about = "Collection management system server")]
|
||||
pub struct Config {
|
||||
/// PostgreSQL connection string.
|
||||
#[arg(long, env = "DATABASE_URL")]
|
||||
pub database_url: String,
|
||||
|
||||
/// Address to bind the HTTP server to.
|
||||
#[arg(long, env = "BIND_ADDR", default_value = "0.0.0.0:8080")]
|
||||
pub bind_addr: String,
|
||||
|
||||
/// User-facing application name (OpenAPI title, page title, …).
|
||||
///
|
||||
/// Defaults to a neutral name; set this to the real product name at deploy
|
||||
/// time. The product name must never be hardcoded in source.
|
||||
#[arg(long, env = "APP_NAME", default_value = "Collection Management System")]
|
||||
pub app_name: String,
|
||||
|
||||
/// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable
|
||||
/// only for plain-HTTP self-hosting behind no TLS at all.
|
||||
#[arg(
|
||||
long = "session-cookie-secure",
|
||||
env = "SESSION_COOKIE_SECURE",
|
||||
default_value_t = true
|
||||
)]
|
||||
pub cookie_secure: bool,
|
||||
|
||||
/// Meilisearch base URL (e.g. `http://localhost:7700`). On-write search indexing
|
||||
/// is enabled only when both this and `--meili-master-key` are set; otherwise
|
||||
/// search is disabled (best-effort feature) and `reindex_all` remains the rebuild
|
||||
/// path.
|
||||
#[arg(long = "meili-url", env = "MEILI_URL")]
|
||||
pub meili_url: Option<String>,
|
||||
|
||||
/// Meilisearch API key (master or a scoped key).
|
||||
#[arg(long = "meili-master-key", env = "MEILI_MASTER_KEY")]
|
||||
pub meili_master_key: Option<String>,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Server wiring: configuration and startup.
|
||||
|
||||
mod config;
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
mod web_assets;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
use anyhow::Context;
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use db::Db;
|
||||
use domain::{AuditActor, Email, NewUser, Role};
|
||||
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, config.db_max_connections)
|
||||
.await
|
||||
.context("connecting to the database")?;
|
||||
|
||||
db.migrate().await.context("running database migrations")?;
|
||||
|
||||
migrate_sessions(&db)
|
||||
.await
|
||||
.context("creating the session store")?;
|
||||
|
||||
let search = match (&config.meili_url, &config.meili_master_key) {
|
||||
(Some(url), Some(key)) => {
|
||||
let client = search::SearchClient::connect(url, key, &config.meili_index)
|
||||
.context("connecting to Meilisearch")?;
|
||||
|
||||
client
|
||||
.ensure_index()
|
||||
.await
|
||||
.context("ensuring the search index exists")?;
|
||||
|
||||
tracing::info!(index = %config.meili_index, "search indexing enabled");
|
||||
|
||||
Some(client)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"MEILI_URL/MEILI_MASTER_KEY not set — search indexing disabled (reindex_all remains the rebuild path)"
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
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)
|
||||
.await
|
||||
.with_context(|| format!("binding to {}", config.bind_addr))?;
|
||||
|
||||
tracing::info!(addr = %config.bind_addr, "server listening");
|
||||
|
||||
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);
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
let app = app.merge(web_assets::routes());
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.context("running the HTTP server")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
pub mod test_support {
|
||||
/// The SPA-asset router, for tests.
|
||||
pub fn web_router() -> axum::Router {
|
||||
super::web_assets::routes()
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot: apply migrations (idempotent), then seed the baseline Spectrum cataloguing
|
||||
/// vocabularies + field definitions. Safe to re-run (the seed is idempotent).
|
||||
pub async fn seed(database_url: &str) -> anyhow::Result<()> {
|
||||
// CLI one-shot: a tiny pool is plenty.
|
||||
let db = Db::connect(database_url, 2)
|
||||
.await
|
||||
.context("connecting to the database")?;
|
||||
|
||||
// Apply migrations first so `server seed` works on a fresh DB without first
|
||||
// starting the server. Migrations are idempotent.
|
||||
db.migrate().await.context("running database migrations")?;
|
||||
|
||||
let mut tx = db.pool().begin().await?;
|
||||
|
||||
db::seed::seed_spectrum_cataloguing(&mut tx)
|
||||
.await
|
||||
.context("seeding Spectrum cataloguing baseline")?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
println!("seeded Spectrum cataloguing baseline (idempotent)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
|
||||
/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set,
|
||||
/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is
|
||||
/// confined to the scope below and dropped before any network I/O.
|
||||
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
|
||||
let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
// Read, validate, and hash the password in a scope so the plaintext `String` is
|
||||
// dropped before we open a connection / run any awaits.
|
||||
let password_hash = {
|
||||
let password = match std::env::var("BOOTSTRAP_PASSWORD") {
|
||||
Ok(p) => p,
|
||||
Err(_) => rpassword::prompt_password("Password: ").context("reading password")?,
|
||||
};
|
||||
anyhow::ensure!(
|
||||
password.chars().count() >= 8,
|
||||
"password must be at least 8 characters"
|
||||
);
|
||||
auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?
|
||||
};
|
||||
|
||||
// CLI one-shot: a tiny pool is plenty.
|
||||
let db = Db::connect(database_url, 2)
|
||||
.await
|
||||
.context("connecting to the database")?;
|
||||
|
||||
let mut tx = db.pool().begin().await?;
|
||||
|
||||
let id = db::users::create_user(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewUser {
|
||||
email,
|
||||
password_hash,
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("creating the user (is the email already taken?)")?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
println!("created user {id} ({role:?})");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use domain::Role;
|
||||
use server::{Config, create_user, run, seed};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "Collection management system server")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
#[command(flatten)]
|
||||
config: Config,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Create a user (admin bootstrap).
|
||||
CreateUser {
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
#[arg(long, value_enum)]
|
||||
role: RoleArg,
|
||||
},
|
||||
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
|
||||
Seed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
enum RoleArg {
|
||||
Admin,
|
||||
Editor,
|
||||
}
|
||||
|
||||
impl From<RoleArg> for Role {
|
||||
fn from(r: RoleArg) -> Self {
|
||||
match r {
|
||||
RoleArg::Admin => Role::Admin,
|
||||
RoleArg::Editor => Role::Editor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load a .env file (if present) so the binary picks up config when run directly,
|
||||
// not only via `just` (which uses `set dotenv-load`). A missing .env is fine.
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
None => run(cli.config).await,
|
||||
Some(Command::CreateUser { email, role }) => {
|
||||
create_user(&cli.config.database_url, &email, role.into()).await
|
||||
}
|
||||
Some(Command::Seed) => seed(&cli.config.database_url).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Serves the embedded SPA (built `web/dist`) at `/` with a client-side-routing
|
||||
//! fallback. Compiled only with the `embed-web` feature; in dev the SPA is served by
|
||||
//! Vite (which proxies `/api` to this server), so this module is absent.
|
||||
|
||||
use axum::{Router, http::StatusCode};
|
||||
|
||||
/// A router that serves the embedded `web/dist` assets, falling back to `index.html`
|
||||
/// for unknown paths so the SPA can own client-side routes.
|
||||
pub(crate) fn routes() -> Router {
|
||||
memory_serve::load!()
|
||||
.index_file(Some("/index.html"))
|
||||
.fallback(Some("/index.html"))
|
||||
.fallback_status(StatusCode::OK)
|
||||
.into_router()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use clap::Parser;
|
||||
use server::Config;
|
||||
|
||||
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]
|
||||
fn parses_from_args_with_defaults() {
|
||||
temp_env::with_vars(CLEARED, || {
|
||||
let cfg = Config::try_parse_from(["server", "--database-url", "postgres://localhost/test"])
|
||||
.expect("should parse");
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_url_is_required() {
|
||||
temp_env::with_vars(CLEARED, || {
|
||||
assert!(Config::try_parse_from(["server"]).is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_secure_defaults_to_true() {
|
||||
temp_env::with_vars(CLEARED, || {
|
||||
let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap();
|
||||
assert!(config.cookie_secure);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use db::Db;
|
||||
use domain::Role;
|
||||
use sqlx::PgPool;
|
||||
|
||||
// Note: `server::create_user` opens its own DB connection by URL, but `#[sqlx::test]`
|
||||
// provisions a temporary database whose URL is not directly exposed. The test below
|
||||
// exercises the same building blocks that `server::create_user` composes —
|
||||
// `auth::hash_password` + `db::users::create_user` + `db::users::credentials_by_email` —
|
||||
// against the test pool, which fully validates the end-to-end bootstrap logic.
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn create_user_persists_and_password_verifies(pool: PgPool) {
|
||||
let db = Db::from_pool(pool.clone());
|
||||
|
||||
let hash = auth::hash_password("bootstrap-pw-123").unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
|
||||
db::users::create_user(
|
||||
&mut tx,
|
||||
domain::AuditActor::System,
|
||||
&domain::NewUser {
|
||||
email: domain::Email::parse("boss@example.com").unwrap(),
|
||||
password_hash: hash,
|
||||
role: Role::Admin,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let (user, stored_hash) = db::users::credentials_by_email(db.pool(), "boss@example.com")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.role, Role::Admin);
|
||||
assert!(auth::verify_password("bootstrap-pw-123", &stored_hash));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_user_rejects_invalid_email() {
|
||||
// The email is parsed before the password is read or the DB is touched, so an
|
||||
// invalid email errors out without reaching the (unreachable) database URL.
|
||||
let err = server::create_user("postgres://unused", "not-an-email", Role::Admin)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("email"), "got: {err}");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Only meaningful with `--features embed-web` and a built `web/dist`. Compiled only
|
||||
//! under that feature.
|
||||
#![cfg(feature = "embed-web")]
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_index_at_root_and_spa_fallback() {
|
||||
let app = server::test_support::web_router();
|
||||
|
||||
let root = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(root.status(), StatusCode::OK);
|
||||
|
||||
let body = root.into_body().collect().await.unwrap().to_bytes();
|
||||
|
||||
assert!(String::from_utf8_lossy(&body).contains("<div id=\"root\">"));
|
||||
|
||||
let deep = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/objects/123")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(deep.status(), StatusCode::OK);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use db::{Db, fields, seed, vocab};
|
||||
use sqlx::PgPool;
|
||||
|
||||
// Note: `server::seed` opens its own DB connection by URL, but `#[sqlx::test]`
|
||||
// provisions a temporary database whose URL is not directly exposed. This test
|
||||
// exercises the building block the command composes — `db::seed::seed_spectrum_cataloguing`
|
||||
// — against the test pool, run twice to prove the idempotency the command relies on.
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn seed_is_idempotent_via_building_block(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
for _ in 0..2 {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
// A representative seeded vocabulary and field definition are present after two runs.
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), "material")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"vocabulary 'material' should be seeded"
|
||||
);
|
||||
assert!(
|
||||
fields::field_definition_by_key(db.pool(), "title")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"field definition 'title' should be seeded"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use api::AppState;
|
||||
use db::Db;
|
||||
use server::serve;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::test]
|
||||
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, 2)
|
||||
.await
|
||||
.expect("connect to database");
|
||||
let state = AppState {
|
||||
db,
|
||||
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();
|
||||
let addr: SocketAddr = listener.local_addr().unwrap();
|
||||
|
||||
let handle = tokio::spawn(async move { serve(listener, state).await });
|
||||
|
||||
let url = format!("http://{addr}/health/live");
|
||||
let response = reqwest::get(&url).await;
|
||||
|
||||
// If the request failed and the server task already ended, it errored — surface that
|
||||
// (a clear server error) instead of the opaque reqwest failure.
|
||||
if response.is_err() && handle.is_finished() {
|
||||
match handle.await {
|
||||
Ok(Err(err)) => panic!("server failed: {err:?}"),
|
||||
other => panic!("server task ended unexpectedly: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
.expect("request succeeds")
|
||||
.json()
|
||||
.await
|
||||
.expect("json body");
|
||||
assert_eq!(body["status"], "ok");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: cms_dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
environment:
|
||||
# Development mode relaxes the production master-key length requirement and
|
||||
# enables the search-preview UI. The key below is for local use only.
|
||||
MEILI_ENV: development
|
||||
MEILI_MASTER_KEY: masterKey
|
||||
ports:
|
||||
- "7700:7700"
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
meilidata:
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
# Vision — Collection Management System (working name TBD)
|
||||
|
||||
> **Codename note:** the repository folder is a throwaway working name. The real
|
||||
> product name is undecided and **must never appear in code** (see the
|
||||
> architecture spec, "Naming"). This document uses neutral terms — "the
|
||||
> platform", "the system".
|
||||
|
||||
## What this is
|
||||
|
||||
A modern **collection management system** (Swedish: *samlingsförvaltningssystem*)
|
||||
for museums and other heritage organizations: software for documenting, managing,
|
||||
and selectively publishing the objects in a collection. It is built around the
|
||||
**Spectrum 5.0** standard (Collections Trust) and the guidance from
|
||||
Riksantikvarieämbetet — see [`reference/`](../reference/) for the source material
|
||||
this design is grounded in.
|
||||
|
||||
It is **not** primarily a web-publishing tool or a digital-asset manager. Its job
|
||||
is to support the internal processes of collection management — cataloguing,
|
||||
location/movement control, loans, condition, and so on — with selective public
|
||||
access layered on top.
|
||||
|
||||
## Who it is for
|
||||
|
||||
- **Primary (now):** small and mid-sized **non-profit** heritage organizations —
|
||||
limited budget, limited or volunteer IT, who need something *easy* but correct.
|
||||
- **Roadmap:** larger institutions with professional staff and IT, who need the
|
||||
full Spectrum process coverage, custom fields, and the option to run it in their
|
||||
own environment.
|
||||
|
||||
The design tension we hold throughout: **easy by default, flexible when needed.**
|
||||
A small org runs with a tiny subset and sensible defaults; an advanced org enables
|
||||
the full standard, adds custom fields, and self-hosts.
|
||||
|
||||
## Guiding principles
|
||||
|
||||
1. **Small, well-tested, extensible core.** The accountability backbone is small
|
||||
and strongly typed; extensibility lives in well-bounded modules around it.
|
||||
2. **Make illegal states unrepresentable.** Lean on Rust's type system to remove
|
||||
bug classes (newtype IDs, validated value objects, projection types, auth via
|
||||
extractors). Strong types also shrink the test surface.
|
||||
3. **Isolation by construction.** An organization's data must *never* bleed into
|
||||
another's. We achieve this at the deployment/credential layer, not by app
|
||||
discipline (see architecture spec).
|
||||
4. **Easy to self-host.** The single-tenant binary *is* the self-host artifact:
|
||||
one binary, minimal external dependencies, sensible defaults, local-disk
|
||||
storage option, standalone auth.
|
||||
5. **Standards-aligned.** Spectrum 5.0 for process/data; CIDOC-CRM / LIDO and
|
||||
controlled vocabularies (Getty, KulturNav, Wikidata) on the roadmap for
|
||||
interchange.
|
||||
6. **Minimal custom code, reversible bets.** Prefer existing crates. Pre-1.0 we
|
||||
choose dependencies on *fit*, not maturity, and isolate experimental ones
|
||||
behind our own traits so swapping stays cheap.
|
||||
7. **Clean public/private separation.** The public, unauthenticated surface is a
|
||||
distinct, narrow boundary — which makes publishing, caching, rate-limiting, and
|
||||
network lockdown all clean.
|
||||
|
||||
## Architecture in one paragraph
|
||||
|
||||
The application binary is **always single-tenant** — one running instance serves
|
||||
exactly one organization and knows nothing of any other. "Multi-tenancy" is purely
|
||||
a *deployment* concern: a hosted fleet runs many copies of the same binary, each
|
||||
with its own Postgres database and Meilisearch index (scoped credentials) against
|
||||
shared database/search servers, each on its own domain, independently rolled out.
|
||||
**Self-hosting is the same binary with one database.** Data isolation is therefore
|
||||
guaranteed by credentials and topology, not by `org_id` filtering in code. See
|
||||
[`specs/2026-06-02-mvp-architecture.md`](specs/2026-06-02-mvp-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Feature catalogue
|
||||
|
||||
Each feature is tagged **[MVP]**, **[Post-MVP]**, or **[Later]**. The MVP cut is
|
||||
the smallest build that is genuinely useful *and* exercises every architectural
|
||||
pillar, so nothing structural is discovered late.
|
||||
|
||||
### Catalogue core
|
||||
|
||||
- **[MVP]** Catalogue records for objects and groups of objects, with a typed
|
||||
**inventory minimum** (object number, name, count, brief description, current
|
||||
location, current owner, recorder, recording date).
|
||||
- **[MVP]** **Hybrid flexible fields** — a field-definition registry + JSONB value
|
||||
layer, seeded with the **Spectrum 5.0 Cataloguing** field set; orgs enable a
|
||||
subset or the whole set without schema changes.
|
||||
- **[MVP]** Object numbering with a configurable standard format; multiple
|
||||
historical numbers per object.
|
||||
- **[Post-MVP]** Org-defined **custom fields** beyond Spectrum (the registry
|
||||
already supports it; this is the management UI + validation polish).
|
||||
- **[Post-MVP]** Object groups / hierarchical relationships, related-object links.
|
||||
- **[Later]** Subject-specialist templates / external cataloguing standards.
|
||||
|
||||
### Controlled vocabularies & authority records
|
||||
|
||||
- **[MVP]** **Authority records** for person, organization, place — *store once,
|
||||
link many* — referenced from core and flexible fields.
|
||||
- **[MVP]** **Controlled vocabularies** (term sources) for fields like material,
|
||||
object name, technique; fields bound to a vocabulary accept only resolved terms.
|
||||
- **[MVP]** **Multilingual labels** on terms and authorities (sv/en) in the data
|
||||
model.
|
||||
- **[Post-MVP]** Import/sync from external vocabularies — Getty AAT/TGN/ULAN,
|
||||
KulturNav, Wikidata; storing external URIs on local terms.
|
||||
- **[Later]** Linked-open-data publishing of authorities.
|
||||
|
||||
### Media & files
|
||||
|
||||
- **[MVP]** Upload and attach images/documents to records via **OpenDAL**
|
||||
(S3 or local disk), behind a `BlobStore` trait.
|
||||
- **[Post-MVP]** Thumbnails / derivative generation; per-reproduction licensing;
|
||||
multiple reproductions per object.
|
||||
- **[Later]** **IIIF** image serving; bulk/mass ingest pipelines; dedicated
|
||||
image-management (DAM-style) workflows.
|
||||
|
||||
### Search
|
||||
|
||||
- **[MVP]** **Meilisearch** indexing of records; basic faceted/full-text search in
|
||||
the admin UI.
|
||||
- **[Post-MVP]** Saved searches, advanced filters, sort presets, search across
|
||||
all fields incl. flexible fields.
|
||||
- **[Later]** Public-facing search on the published catalogue.
|
||||
|
||||
### Audit & history
|
||||
|
||||
- **[MVP]** **Append-only, immutable audit log** — who/when/what with field-level
|
||||
before→after diffs — covering domain writes and auth/security events; surfaced
|
||||
as Spectrum **amendment history** on records.
|
||||
- **[Post-MVP]** Auditing of sensitive *reads*; audit export/reporting; retention
|
||||
policy controls.
|
||||
|
||||
### Publishing & public access
|
||||
|
||||
- **[MVP]** **Record-level visibility** (draft / internal / public) with a fixed
|
||||
set of never-public fields (location, valuation, insurance, personal data).
|
||||
- **[MVP]** **Public read API** (OpenAPI) serving only public records, only
|
||||
public-safe fields (a typed `PublicView` projection).
|
||||
- **[Post-MVP]** **Per-field publishability** flags; public collection landing
|
||||
pages / embeddable widgets.
|
||||
- **[Later]** Aggregator interoperability — **LIDO** export, **OAI-PMH** harvest,
|
||||
feeds to **K-samsök/Kringla**, **Europeana**, Sveriges dataportal; Wikidata/
|
||||
Wikimedia publishing.
|
||||
|
||||
### Authentication & access control
|
||||
|
||||
- **[MVP]** **Email/password** and **external OIDC** login, scoped to the single
|
||||
org the instance serves; role/permission model enforced via typed extractors.
|
||||
- **[Post-MVP]** Granular per-field / per-process permissions; API tokens for
|
||||
integrations.
|
||||
- **[Later]** **Shared identity provider** + **cross-org membership and fast
|
||||
switching** (deferred by decision; revisit if multi-org usage grows).
|
||||
|
||||
### Import / export / portability
|
||||
|
||||
- **[MVP]** **Portable export**: a single **SQLite** file (metadata incl.
|
||||
flattened flexible fields + vocab/authority tables) + plain media files + a
|
||||
manifest — a whole-org archive, openable anywhere.
|
||||
- **[Post-MVP]** Import from Excel/CSV (the common "we have a spreadsheet" path)
|
||||
and from another instance's export.
|
||||
- **[Post-MVP]** Migration tooling from legacy systems.
|
||||
|
||||
### Reporting & output
|
||||
|
||||
- **[Post-MVP]** Templated outputs: exhibition labels, loan letters, condition
|
||||
reports, inventory lists; user-defined templates.
|
||||
- **[Later]** Statistics/dashboards (records per year, % with images, etc.).
|
||||
|
||||
### Spectrum procedure coverage
|
||||
|
||||
The MVP implements **Cataloguing**. The other Spectrum 5.0 procedures are the
|
||||
functional roadmap:
|
||||
|
||||
- **[Post-MVP] Primary procedures:** Object entry, Acquisition & accessioning,
|
||||
**Location & movement control**, Inventory, Loans in, Loans out, Object exit,
|
||||
Documentation planning.
|
||||
- **[Later] Secondary procedures:** Rights management, Reproduction, Condition
|
||||
checking & technical assessment, Conservation & collections care, Valuation,
|
||||
Insurance & indemnity, Use of collections (incl. exhibitions), Emergency/disaster
|
||||
planning, Damage & loss, Deaccession & disposal, Collections review, Audit.
|
||||
|
||||
### Internationalization
|
||||
|
||||
- **[MVP]** UI localization (Swedish + English); localized API validation/error
|
||||
messages; multilingual vocab/authority labels; data model carries language-tagged
|
||||
content values.
|
||||
- **[Post-MVP]** Translation **workflow/UI** for per-field record content;
|
||||
additional UI locales.
|
||||
|
||||
### Hosting, fleet & operations
|
||||
|
||||
- **[MVP]** Runs as a single instance (self-host or one hosted cell); local-disk or
|
||||
S3 storage; per-instance migrations on startup.
|
||||
- **[Post-MVP]** Per-org **provisioning control plane** (create DB + role + Meili
|
||||
key + deployment + domain); batched/canary rollouts; A/B routing.
|
||||
- **[Post-MVP]** Optional **Redis** (cache/sessions/rate-limit) with per-org key
|
||||
prefixing, added only when a real bottleneck appears.
|
||||
- **[Post-MVP]** In-app IP-allowlist middleware as a portable fallback for
|
||||
self-hosters without ingress-level controls.
|
||||
- **[Later]** Multi-Postgres sharding for large fleets; per-org Redis instances.
|
||||
|
||||
---
|
||||
|
||||
## Explicitly deferred decisions (recorded so they aren't relitigated)
|
||||
|
||||
- **Multi-org user switching / shared IdP** — rare case; deferred until it
|
||||
demonstrably hurts.
|
||||
- **Database migrations machinery** — not until 1.0. Pre-1.0 the data model is
|
||||
reshaped freely (recreate, don't migrate).
|
||||
- **Final product name** — TBD; never hardcoded.
|
||||
- **Hosting/ops documentation** — later, but the design keeps self-host easy
|
||||
throughout.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
||||
# Audit Spine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the append-only, immutable audit log — recording who/when/what with field-level before→after diffs — that every later write path will call to satisfy Spectrum "amendment history" (`docs/specs/2026-06-02-mvp-architecture.md` §13).
|
||||
|
||||
**Architecture:** Audit value types live in `domain` (pure, no I/O). The `db` crate owns the `audit_log` table (via a schema-bootstrap migration) and a transaction-capable `audit` repository (`record` / `history_for`). Immutability is enforced *in the database* by a trigger that rejects UPDATE/DELETE — infrastructure-enforced, not convention. There is **no `org_id`** column: each deployment's database *is* one organization (§3/§4). No HTTP surface yet — the spine is consumed by future write paths; an audit/history API arrives when entities do.
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8 (Postgres, +`time` +`json` features), `time` for timestamps, `serde_json` for the JSONB change payload. Tests use `#[sqlx::test]` (auto-applies the migration to a fresh temp DB).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A PostgreSQL reachable for tests where the role may CREATE DATABASE. Bring it up with the project compose (`docker compose up -d`) and export `DATABASE_URL`. In a host where 5432 is taken, run an isolated instance, e.g.:
|
||||
`docker run -d --name cms-test-pg -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=cms_dev -p 5433:5432 postgres:17` and use `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev`.
|
||||
- Shell env does NOT persist between commands; pass `DATABASE_URL` inline on every test/clippy command.
|
||||
- Verify crate versions with the cratesio tooling before pinning new ones.
|
||||
|
||||
## Design decisions (review these)
|
||||
|
||||
1. **Schema bootstrap pre-1.0.** The schema lives as sqlx migration files under **`crates/db/migrations/`** (SQL belongs with the `db` crate). `#[sqlx::test]` auto-applies them to each temp DB; the server applies them on startup via `Db::migrate()` (`sqlx::migrate!()` embeds them at compile time). Per spec §8/D15 we are **not** maintaining forward-only migration history yet — pre-1.0 we **rewrite these files freely and recreate dev databases** (drop & re-apply) rather than writing incremental migrations. At 1.0 we freeze and switch to disciplined migrations. *(This refines the spec's "recreate, don't migrate" into a concrete mechanism — fold it back into the spec.)*
|
||||
2. **Immutability in the database.** A `BEFORE UPDATE OR DELETE` trigger on `audit_log` raises an exception, so append-only is enforced by Postgres, not by "we only wrote an insert function." Matches the infrastructure-enforced philosophy (§4).
|
||||
3. **No `org_id`.** Single-tenant database per deployment; the DB is the org boundary.
|
||||
4. **Actor model.** `AuditActor = User(Uuid) | System`. No `User` entity exists yet, so the user is referenced by raw `Uuid`; auth (Plan 9) will introduce a `UserId` newtype that maps onto this. Auth *events* (login success/failure) are deferred to Plan 9 — this spine covers entity-change events (`created`/`updated`/`deleted`).
|
||||
5. **Transaction-capable `record`.** `record` takes an `impl sqlx::PgExecutor`, so a future write path can record the audit entry **inside the same transaction** as the entity change (atomic: both commit or both roll back).
|
||||
6. **`domain` gets wired in.** `db` depends on `domain` for the audit types — this lands the "everything points inward to `domain`" relationship that was aspirational after Plan 0 (issue #4).
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Cargo.toml + time dep; sqlx +time +json features
|
||||
crates/domain/
|
||||
Cargo.toml + serde_json, time
|
||||
src/lib.rs re-export audit types
|
||||
src/audit.rs AuditAction, AuditActor, FieldChange, NewAuditEvent, AuditEntry (+ unit tests)
|
||||
crates/db/
|
||||
Cargo.toml + domain, uuid, time
|
||||
migrations/0001_audit_log.sql audit_log table + immutability trigger + index
|
||||
src/lib.rs + pub mod audit; + Db::migrate()
|
||||
src/audit.rs record() + history_for() (transaction-capable)
|
||||
tests/migrate.rs migrate idempotent + table exists
|
||||
tests/audit.rs record/read-back, ordering, entity isolation
|
||||
tests/audit_immutability.rs UPDATE/DELETE rejected; rolled-back tx leaves nothing
|
||||
crates/server/
|
||||
src/lib.rs run() applies migrations on startup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — audit value types
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/Cargo.toml`
|
||||
- Create: `crates/domain/src/audit.rs`
|
||||
- Modify: `crates/domain/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Add dependencies.** In `Cargo.toml` (workspace root) add to `[workspace.dependencies]` a `time` entry (verify latest 0.3.x):
|
||||
```toml
|
||||
time = { version = "0.3", features = ["serde"] }
|
||||
```
|
||||
Then in `crates/domain/Cargo.toml`, set `[dependencies]` to:
|
||||
```toml
|
||||
[dependencies]
|
||||
uuid.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
time.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test + types.** Create `crates/domain/src/audit.rs`:
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// What kind of change an audit entry records.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuditAction {
|
||||
Created,
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
/// The database/text representation.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AuditAction::Created => "created",
|
||||
AuditAction::Updated => "updated",
|
||||
AuditAction::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from the database/text representation.
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"created" => Some(AuditAction::Created),
|
||||
"updated" => Some(AuditAction::Updated),
|
||||
"deleted" => Some(AuditAction::Deleted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who performed the change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind", content = "id")]
|
||||
pub enum AuditActor {
|
||||
/// A specific user, referenced by id (a `UserId` newtype arrives with auth).
|
||||
User(Uuid),
|
||||
/// The system itself (migrations, automated processes).
|
||||
System,
|
||||
}
|
||||
|
||||
/// One field's before/after values within a change.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FieldChange {
|
||||
/// Field name (catalogue field key or column name).
|
||||
pub field: String,
|
||||
/// Value before the change (None when newly set).
|
||||
pub before: Option<Value>,
|
||||
/// Value after the change (None when cleared).
|
||||
pub after: Option<Value>,
|
||||
}
|
||||
|
||||
/// An audit event to be recorded.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewAuditEvent {
|
||||
pub actor: AuditActor,
|
||||
pub action: AuditAction,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub changes: Vec<FieldChange>,
|
||||
}
|
||||
|
||||
/// A recorded audit entry, read back from the log.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuditEntry {
|
||||
/// Monotonic sequence number (insertion order).
|
||||
pub seq: i64,
|
||||
/// When it was recorded.
|
||||
pub at: OffsetDateTime,
|
||||
pub actor: AuditActor,
|
||||
pub action: AuditAction,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub changes: Vec<FieldChange>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn action_round_trips_via_db_string() {
|
||||
for a in [AuditAction::Created, AuditAction::Updated, AuditAction::Deleted] {
|
||||
assert_eq!(AuditAction::from_db(a.as_str()), Some(a));
|
||||
}
|
||||
assert_eq!(AuditAction::from_db("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_change_serde_round_trip() {
|
||||
let fc = FieldChange {
|
||||
field: "name".into(),
|
||||
before: Some(json!("Vase")),
|
||||
after: Some(json!("Roman Vase")),
|
||||
};
|
||||
let v = serde_json::to_value(&fc).unwrap();
|
||||
assert_eq!(v["field"], "name");
|
||||
assert_eq!(v["before"], "Vase");
|
||||
assert_eq!(v["after"], "Roman Vase");
|
||||
let back: FieldChange = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, fc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actor_is_adjacently_tagged() {
|
||||
let v = serde_json::to_value(AuditActor::User(Uuid::nil())).unwrap();
|
||||
assert_eq!(v["kind"], "user");
|
||||
let v2 = serde_json::to_value(AuditActor::System).unwrap();
|
||||
assert_eq!(v2["kind"], "system");
|
||||
}
|
||||
}
|
||||
```
|
||||
Wire into `crates/domain/src/lib.rs` (keep the existing `mod id; pub use id::OrgId;`), adding:
|
||||
```rust
|
||||
mod audit;
|
||||
|
||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests to verify they fail, then pass.** First confirm the test file compiles and fails if you stub the types out — but since the types and tests are added together here, run:
|
||||
`cargo test -p domain`
|
||||
Expected: PASS — the three new audit tests plus the two existing `id` tests (5 total). If it fails to compile, fix the types until green. (TDD note: the assertions encode the intended behavior — `as_str`/`from_db` inverse, serde shapes — so a regression in those will fail.)
|
||||
|
||||
- [ ] **Step 4: Lint + format.** `cargo +nightly fmt` and `cargo clippy -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add Cargo.toml crates/domain
|
||||
git commit -m "feat(domain): add audit value types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Schema bootstrap + `audit_log` table
|
||||
|
||||
**Files:**
|
||||
- Modify: `Cargo.toml` (sqlx features)
|
||||
- Create: `crates/db/migrations/0001_audit_log.sql`
|
||||
- Modify: `crates/db/src/lib.rs`
|
||||
- Modify: `crates/server/src/lib.rs`
|
||||
- Test: `crates/db/tests/migrate.rs`
|
||||
|
||||
- [ ] **Step 1: Enable sqlx `time` + `json` features.** In root `Cargo.toml`, update the sqlx workspace dependency features to include `time` and `json`:
|
||||
```toml
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros", "time", "json"] }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the migration (schema + immutability trigger).** Create `crates/db/migrations/0001_audit_log.sql`:
|
||||
```sql
|
||||
-- Append-only audit log. One database == one organization, so there is no org_id.
|
||||
CREATE TABLE audit_log (
|
||||
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'system')),
|
||||
actor_id UUID,
|
||||
action TEXT NOT NULL CHECK (action IN ('created', 'updated', 'deleted')),
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
CONSTRAINT actor_id_matches_kind CHECK (
|
||||
(actor_kind = 'user' AND actor_id IS NOT NULL) OR
|
||||
(actor_kind = 'system' AND actor_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, seq);
|
||||
|
||||
-- Enforce append-only at the database level: reject any UPDATE or DELETE.
|
||||
CREATE OR REPLACE FUNCTION audit_log_reject_mutation() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_log is append-only; % is not permitted', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER audit_log_immutable
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_log_reject_mutation();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `Db::migrate()`.** In `crates/db/src/lib.rs`, add this method to the `impl Db` block (after `ping`):
|
||||
```rust
|
||||
/// Apply all pending schema migrations (embedded at compile time).
|
||||
///
|
||||
/// Pre-1.0 the migration files are rewritten freely and dev databases are
|
||||
/// recreated; this is the schema-bootstrap mechanism, not forward-migration
|
||||
/// discipline.
|
||||
pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
sqlx::migrate!().run(&self.pool).await
|
||||
}
|
||||
```
|
||||
(`sqlx::migrate!()` defaults to `./migrations` relative to the `db` crate, i.e. `crates/db/migrations`.)
|
||||
|
||||
- [ ] **Step 4: Apply migrations on server startup.** In `crates/server/src/lib.rs`, inside `run`, immediately after the `Db::connect(...)?` line and before building `AppState`, add:
|
||||
```rust
|
||||
db.migrate().await.context("running database migrations")?;
|
||||
```
|
||||
(`anyhow::Context` is already imported; `MigrateError` implements `std::error::Error`, so `.context(...)?` works.)
|
||||
|
||||
- [ ] **Step 5: Write the migrate test.** Create `crates/db/tests/migrate.rs`:
|
||||
```rust
|
||||
use db::Db;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
// sqlx::test already applied migrations to this temp DB; re-running must be a
|
||||
// no-op success (idempotent).
|
||||
db.migrate().await.expect("re-running migrate is idempotent");
|
||||
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar("SELECT to_regclass('public.audit_log')::text")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(regclass.as_deref(), Some("audit_log"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run it.** `DATABASE_URL=<url> cargo test -p db --test migrate` → PASS (1 test).
|
||||
|
||||
- [ ] **Step 7: Lint + format.** `cargo +nightly fmt` and `DATABASE_URL=<url> cargo clippy -p db -p server --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 8: Commit.**
|
||||
```bash
|
||||
git add Cargo.toml crates/db crates/server
|
||||
git commit -m "feat(db): schema bootstrap with append-only audit_log table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `db::audit` repository — record & read history
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/db/Cargo.toml`
|
||||
- Create: `crates/db/src/audit.rs`
|
||||
- Modify: `crates/db/src/lib.rs`
|
||||
- Test: `crates/db/tests/audit.rs`
|
||||
|
||||
- [ ] **Step 1: Add dependencies.** In `crates/db/Cargo.toml`, set:
|
||||
```toml
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
uuid.workspace = true
|
||||
time.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
serde_json.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test.** Create `crates/db/tests/audit.rs`:
|
||||
```rust
|
||||
use db::Db;
|
||||
use db::audit;
|
||||
use domain::{AuditAction, AuditActor, FieldChange, NewAuditEvent};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn created(entity_id: Uuid, name: &str) -> NewAuditEvent {
|
||||
NewAuditEvent {
|
||||
actor: AuditActor::System,
|
||||
action: AuditAction::Created,
|
||||
entity_type: "object".into(),
|
||||
entity_id,
|
||||
changes: vec![FieldChange {
|
||||
field: "name".into(),
|
||||
before: None,
|
||||
after: Some(json!(name)),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn records_and_reads_back_history_in_order(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = Uuid::new_v4();
|
||||
let user = Uuid::new_v4();
|
||||
|
||||
audit::record(db.pool(), &created(id, "Vase")).await.unwrap();
|
||||
audit::record(
|
||||
db.pool(),
|
||||
&NewAuditEvent {
|
||||
actor: AuditActor::User(user),
|
||||
action: AuditAction::Updated,
|
||||
entity_type: "object".into(),
|
||||
entity_id: id,
|
||||
changes: vec![FieldChange {
|
||||
field: "name".into(),
|
||||
before: Some(json!("Vase")),
|
||||
after: Some(json!("Roman Vase")),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id).await.unwrap();
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].action, AuditAction::Created);
|
||||
assert_eq!(history[0].actor, AuditActor::System);
|
||||
assert_eq!(history[1].action, AuditAction::Updated);
|
||||
assert_eq!(history[1].actor, AuditActor::User(user));
|
||||
assert!(history[0].seq < history[1].seq, "ordered by seq");
|
||||
assert_eq!(history[1].changes[0].field, "name");
|
||||
assert_eq!(history[1].changes[0].after, Some(json!("Roman Vase")));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn history_is_scoped_to_one_entity(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
audit::record(db.pool(), &created(a, "A")).await.unwrap();
|
||||
audit::record(db.pool(), &created(b, "B")).await.unwrap();
|
||||
|
||||
let only_a = audit::history_for(db.pool(), "object", a).await.unwrap();
|
||||
assert_eq!(only_a.len(), 1);
|
||||
assert_eq!(only_a[0].entity_id, a);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test audit` → FAIL (`db::audit` / `record` / `history_for` don't exist).
|
||||
|
||||
- [ ] **Step 4: Implement the repository.** Create `crates/db/src/audit.rs`:
|
||||
```rust
|
||||
//! Append-only audit log access.
|
||||
|
||||
use domain::{AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Append an audit event. Accepts any executor, so callers can record the event
|
||||
/// inside the same transaction as the change it describes.
|
||||
pub async fn record<'e, E>(executor: E, event: &NewAuditEvent) -> Result<(), sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let (actor_kind, actor_id) = match event.actor {
|
||||
AuditActor::User(id) => ("user", Some(id)),
|
||||
AuditActor::System => ("system", None),
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO audit_log \
|
||||
(actor_kind, actor_id, action, entity_type, entity_id, changes) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(actor_kind)
|
||||
.bind(actor_id)
|
||||
.bind(event.action.as_str())
|
||||
.bind(&event.entity_type)
|
||||
.bind(event.entity_id)
|
||||
.bind(sqlx::types::Json(&event.changes))
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the full history for one entity, oldest first.
|
||||
pub async fn history_for<'e, E>(
|
||||
executor: E,
|
||||
entity_type: &str,
|
||||
entity_id: Uuid,
|
||||
) -> Result<Vec<AuditEntry>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let rows = sqlx::query(
|
||||
"SELECT seq, at, actor_kind, actor_id, action, entity_type, entity_id, changes \
|
||||
FROM audit_log \
|
||||
WHERE entity_type = $1 AND entity_id = $2 \
|
||||
ORDER BY seq",
|
||||
)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(map_row).collect()
|
||||
}
|
||||
|
||||
fn map_row(row: sqlx::postgres::PgRow) -> Result<AuditEntry, sqlx::Error> {
|
||||
let seq: i64 = row.try_get("seq")?;
|
||||
let at: time::OffsetDateTime = row.try_get("at")?;
|
||||
let actor_kind: String = row.try_get("actor_kind")?;
|
||||
let actor_id: Option<Uuid> = row.try_get("actor_id")?;
|
||||
let action: String = row.try_get("action")?;
|
||||
let entity_type: String = row.try_get("entity_type")?;
|
||||
let entity_id: Uuid = row.try_get("entity_id")?;
|
||||
let changes: sqlx::types::Json<Vec<FieldChange>> = row.try_get("changes")?;
|
||||
|
||||
let actor = match actor_kind.as_str() {
|
||||
"user" => AuditActor::User(
|
||||
actor_id.ok_or_else(|| sqlx::Error::Decode("user actor missing actor_id".into()))?,
|
||||
),
|
||||
"system" => AuditActor::System,
|
||||
other => {
|
||||
return Err(sqlx::Error::Decode(
|
||||
format!("unknown actor_kind: {other}").into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let action = domain::AuditAction::from_db(&action)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown action: {action}").into()))?;
|
||||
|
||||
Ok(AuditEntry {
|
||||
seq,
|
||||
at,
|
||||
actor,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
changes: changes.0,
|
||||
})
|
||||
}
|
||||
```
|
||||
Add to `crates/db/src/lib.rs` (top-level, after the module doc comment):
|
||||
```rust
|
||||
pub mod audit;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run it to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test audit` → PASS (2 tests).
|
||||
|
||||
- [ ] **Step 6: Lint + format.** `cargo +nightly fmt` and `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add append-only audit repository (record, history_for)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Immutability & transactional guarantees
|
||||
|
||||
**Files:**
|
||||
- Test: `crates/db/tests/audit_immutability.rs`
|
||||
|
||||
- [ ] **Step 1: Write the tests.** Create `crates/db/tests/audit_immutability.rs`:
|
||||
```rust
|
||||
use db::Db;
|
||||
use db::audit;
|
||||
use domain::{AuditAction, AuditActor, NewAuditEvent};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample() -> NewAuditEvent {
|
||||
NewAuditEvent {
|
||||
actor: AuditActor::System,
|
||||
action: AuditAction::Created,
|
||||
entity_type: "object".into(),
|
||||
entity_id: Uuid::new_v4(),
|
||||
changes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn count(pool: &PgPool) -> i64 {
|
||||
sqlx::query_scalar("SELECT count(*) FROM audit_log")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_and_delete_are_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
audit::record(db.pool(), &sample()).await.unwrap();
|
||||
|
||||
let updated = sqlx::query("UPDATE audit_log SET action = 'deleted'")
|
||||
.execute(db.pool())
|
||||
.await;
|
||||
assert!(updated.is_err(), "UPDATE must be rejected by the trigger");
|
||||
|
||||
let deleted = sqlx::query("DELETE FROM audit_log").execute(db.pool()).await;
|
||||
assert!(deleted.is_err(), "DELETE must be rejected by the trigger");
|
||||
|
||||
assert_eq!(count(db.pool()).await, 1, "the row is still there");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn record_rolls_back_with_caller_transaction(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
audit::record(&mut *tx, &sample()).await.unwrap();
|
||||
tx.rollback().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(db.pool()).await,
|
||||
0,
|
||||
"a rolled-back audit record must not persist"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn record_commits_with_caller_transaction(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
audit::record(&mut *tx, &sample()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert_eq!(count(db.pool()).await, 1, "a committed audit record persists");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it.** `DATABASE_URL=<url> cargo test -p db --test audit_immutability` → PASS (3 tests). These exercise the DB-level trigger and the `impl PgExecutor` transaction seam (`&mut *tx`).
|
||||
|
||||
- [ ] **Step 3: Full workspace check.** Run:
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green — domain (5), db (migrate 1 + audit 2 + immutability 3), api (3), server (config 2 + serve 1).
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "test(db): enforce audit_log immutability and transactional atomicity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (§13 Audit & amendment history):**
|
||||
- Append-only, immutable → Task 2 trigger + Task 4 negative tests. ✓
|
||||
- who/when/what with field-level before→after diffs → `AuditActor`, `at`, `AuditAction`, `entity_type`/`entity_id`, `Vec<FieldChange>` (Tasks 1–3). ✓
|
||||
- Stored in the org DB; no `org_id` (single-tenant) → Task 2 schema. ✓
|
||||
- Doubles as amendment history (history per entity) → `history_for` (Task 3). ✓
|
||||
- Covers entity-change events; **auth events deferred to Plan 9** (documented in Design decisions). ✓ (intentional scope boundary, not a gap)
|
||||
- Transaction-capable so future write paths record atomically → `impl PgExecutor` + Task 4 rollback/commit tests. ✓
|
||||
- Wires `domain` into `db` (issue #4) → Task 3 dep. ✓
|
||||
|
||||
**Placeholder scan:** no TODO/TBD; every step has concrete SQL/Rust/commands. The `<url>` token in commands is the documented `DATABASE_URL` value, not a code placeholder.
|
||||
|
||||
**Type consistency:** `NewAuditEvent` / `AuditEntry` / `AuditActor` / `AuditAction` / `FieldChange` field names and signatures are identical across `domain` (Task 1), the `db` repository (Task 3), and all tests (Tasks 3–4). `record(impl PgExecutor, &NewAuditEvent)` and `history_for(impl PgExecutor, &str, Uuid)` signatures match every call site. `Db::migrate()` is defined in Task 2 and used in Task 2's test and `server::run`.
|
||||
|
||||
## Notes for follow-on plans
|
||||
|
||||
- An audit/amendment-history **HTTP endpoint** lands when entities exist and the admin UI needs it (Plan 8/10), reusing `history_for`.
|
||||
- **Auth events** (login success/failure) attach to this spine in Plan 9, likely via an `actor`/`action` extension or a sibling table — decide then.
|
||||
- When the first entity write path lands (Plan 3/4), record its audit entry **inside the entity's transaction** using `record(&mut *tx, …)`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
# Catalogue Core Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** The typed inventory-minimum catalogue object (Approach C's strongly-typed core, §6.1) with CRUD, and — crucially — the **first real consumer of the audit spine**: every create/update/delete records an audit entry (field-level diffs on update) inside the write transaction.
|
||||
|
||||
**Architecture:** `domain` holds `ObjectId`, `Visibility`, `ObjectInput` (mutable fields) and `CatalogueObject` (read model). `db::catalog` owns the `object` table (migration 0003) and the repository. Writes take `&mut PgConnection` and record audit via `db::audit::record` on the same connection, so the change and its audit entry commit atomically. Vocabulary/authority *binding* of fields is deferred to the flexible layer (Plan 4); fields are simple types here. No HTTP, no flexible fields, no search.
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8 (`time`+`json`), `time::Date`/`OffsetDateTime`, `serde_json` (now a normal dep of `db`, to build audit `FieldChange` values). Tests use `#[sqlx::test]`.
|
||||
|
||||
## Design decisions (approved)
|
||||
- One `object` table = object **or group** (`number_of_objects ≥ 1`).
|
||||
- Inventory-minimum fields as **simple types** (free text); vocab/authority binding deferred to Plan 4.
|
||||
- **Audit on every write**, in the write transaction (this plan is the audit spine's first consumer).
|
||||
- `Visibility` stored now; publish/unpublish transitions + `PublicView` + public API in Plan 7.
|
||||
- Scope: object CRUD + list in `domain` + `db`.
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline (e.g. `postgres://postgres:postgres@localhost:5433/cms_dev`). Shell env does not persist between commands.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/domain/
|
||||
src/id.rs + ObjectId via id_newtype!
|
||||
src/object.rs Visibility, ObjectInput, CatalogueObject (+ to_input)
|
||||
src/lib.rs re-exports
|
||||
crates/db/
|
||||
Cargo.toml serde_json -> normal dependency
|
||||
migrations/0003_object.sql
|
||||
src/catalog.rs create/get/list/update/delete + audit integration
|
||||
src/lib.rs pub mod catalog;
|
||||
tests/catalog.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — object types
|
||||
|
||||
**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/object.rs`.
|
||||
|
||||
- [ ] **Step 1: Add `ObjectId`** to `crates/domain/src/id.rs` — add another `id_newtype!` invocation after the existing ones:
|
||||
```rust
|
||||
id_newtype!(
|
||||
/// Identifier for a catalogue object (or group of objects).
|
||||
ObjectId
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `crates/domain/src/object.rs`:**
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
use crate::ObjectId;
|
||||
|
||||
/// Publication state of a catalogue record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Visibility {
|
||||
/// Work in progress; not shown anywhere public.
|
||||
#[default]
|
||||
Draft,
|
||||
/// Complete but internal-only.
|
||||
Internal,
|
||||
/// Published; eligible for the public API.
|
||||
Public,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Visibility::Draft => "draft",
|
||||
Visibility::Internal => "internal",
|
||||
Visibility::Public => "public",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"draft" => Some(Visibility::Draft),
|
||||
"internal" => Some(Visibility::Internal),
|
||||
"public" => Some(Visibility::Public),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutable inventory-minimum fields of a catalogue object.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ObjectInput {
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// A catalogue object (or group of objects), read back from storage.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CatalogueObject {
|
||||
pub id: ObjectId,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl CatalogueObject {
|
||||
/// The mutable fields as an [`ObjectInput`] (used to diff against an update).
|
||||
pub fn to_input(&self) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: self.object_number.clone(),
|
||||
object_name: self.object_name.clone(),
|
||||
number_of_objects: self.number_of_objects,
|
||||
brief_description: self.brief_description.clone(),
|
||||
current_location: self.current_location.clone(),
|
||||
current_owner: self.current_owner.clone(),
|
||||
recorder: self.recorder.clone(),
|
||||
recording_date: self.recording_date,
|
||||
visibility: self.visibility,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visibility_round_trips_and_defaults_to_draft() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(Visibility::from_db(v.as_str()), Some(v));
|
||||
}
|
||||
assert_eq!(Visibility::from_db("secret"), None);
|
||||
assert_eq!(Visibility::default(), Visibility::Draft);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `crates/domain/src/lib.rs`** — add `mod object;` (alphabetical, after `mod label;` / before `mod vocabulary;` is fine) and add `ObjectId` to the id re-export and the object types. The id re-export becomes:
|
||||
```rust
|
||||
pub use id::{AuthorityId, ObjectId, OrgId, TermId, VocabularyId};
|
||||
```
|
||||
and add:
|
||||
```rust
|
||||
pub use object::{CatalogueObject, ObjectInput, Visibility};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test + lint.** `cargo test -p domain` → all pass (incl. the new visibility test). `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add crates/domain
|
||||
git commit -m "feat(domain): add catalogue object types (Visibility, ObjectInput, CatalogueObject)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `db` migration — object table
|
||||
|
||||
**Files:** create `crates/db/migrations/0003_object.sql`; modify `crates/db/tests/migrate.rs`.
|
||||
|
||||
- [ ] **Step 1: Create `crates/db/migrations/0003_object.sql`:**
|
||||
```sql
|
||||
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
|
||||
CREATE TABLE object (
|
||||
id UUID PRIMARY KEY,
|
||||
object_number TEXT NOT NULL UNIQUE,
|
||||
object_name TEXT NOT NULL,
|
||||
number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
|
||||
brief_description TEXT,
|
||||
current_location TEXT,
|
||||
current_owner TEXT,
|
||||
recorder TEXT,
|
||||
recording_date DATE,
|
||||
visibility TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (visibility IN ('draft', 'internal', 'public')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX object_visibility_idx ON object (visibility);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `crates/db/tests/migrate.rs`** — append:
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_object_table(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar("SELECT to_regclass('public.object')::text")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(regclass.as_deref(), Some("object"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate` → 3 tests pass. `cargo +nightly fmt`; clippy clean.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add crates/db/migrations crates/db/tests/migrate.rs
|
||||
git commit -m "feat(db): add object table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `db::catalog` — create, read, list (with audit on create)
|
||||
|
||||
**Files:** modify `crates/db/Cargo.toml`, `crates/db/src/lib.rs`; create `crates/db/src/catalog.rs`, `crates/db/tests/catalog.rs`.
|
||||
|
||||
- [ ] **Step 1: Make `serde_json` a normal dependency of `db`.** In `crates/db/Cargo.toml`, move `serde_json` from `[dev-dependencies]` to `[dependencies]` (it is needed in `catalog.rs` to build audit `FieldChange` values). Result:
|
||||
```toml
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
uuid.workspace = true
|
||||
time.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `crates/db/tests/catalog.rs`:
|
||||
```rust
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn sample_input(number: &str) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: Some("a small vase".into()),
|
||||
current_location: Some("shelf A1".into()),
|
||||
current_owner: None,
|
||||
recorder: Some("anna".into()),
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn create_reads_back_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-1"))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.object_number, "LM-1");
|
||||
assert_eq!(obj.object_name, "vase");
|
||||
assert_eq!(obj.number_of_objects, 1);
|
||||
assert_eq!(obj.brief_description.as_deref(), Some("a small vase"));
|
||||
assert_eq!(obj.visibility, Visibility::Draft);
|
||||
|
||||
// The create was audited within the same transaction.
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].action, AuditAction::Created);
|
||||
assert_eq!(history[0].actor, AuditActor::System);
|
||||
assert!(history[0].changes.iter().any(|c| c.field == "object_number"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn list_returns_created_objects(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-1")).await.unwrap();
|
||||
catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-2")).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let all = catalog::list_objects(db.pool()).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].object_number, "LM-1");
|
||||
assert_eq!(all[1].object_number, "LM-2");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn object_by_id_missing_is_none(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
assert!(catalog::object_by_id(db.pool(), domain::ObjectId::new()).await.unwrap().is_none());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog` → FAIL.
|
||||
|
||||
- [ ] **Step 4: Implement** `crates/db/src/catalog.rs`:
|
||||
```rust
|
||||
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
|
||||
//! in the caller's transaction.
|
||||
|
||||
use domain::{
|
||||
AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput,
|
||||
Visibility,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::audit;
|
||||
|
||||
/// The entity_type recorded in the audit log for catalogue objects.
|
||||
const ENTITY_TYPE: &str = "object";
|
||||
|
||||
/// Create an object and record a `created` audit entry, both on `conn`
|
||||
/// (pass a transaction connection `&mut *tx` so they commit atomically).
|
||||
pub async fn create_object(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
input: &ObjectInput,
|
||||
) -> Result<ObjectId, sqlx::Error> {
|
||||
let id = ObjectId::new();
|
||||
sqlx::query(
|
||||
"INSERT INTO object \
|
||||
(id, object_number, object_name, number_of_objects, brief_description, \
|
||||
current_location, current_owner, recorder, recording_date, visibility) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&input.object_number)
|
||||
.bind(&input.object_name)
|
||||
.bind(input.number_of_objects)
|
||||
.bind(input.brief_description.as_deref())
|
||||
.bind(input.current_location.as_deref())
|
||||
.bind(input.current_owner.as_deref())
|
||||
.bind(input.recorder.as_deref())
|
||||
.bind(input.recording_date)
|
||||
.bind(input.visibility.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let changes = creation_changes(input);
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Created,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Fetch one object by id.
|
||||
pub async fn object_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: ObjectId,
|
||||
) -> Result<Option<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let row = sqlx::query(SELECT_OBJECT_BY_ID)
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
row.map(map_object).transpose()
|
||||
}
|
||||
|
||||
/// List all objects, ordered by object number.
|
||||
pub async fn list_objects<'e, E>(executor: E) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
// TODO: add LIMIT/keyset pagination before exposing this via the API.
|
||||
let rows = sqlx::query(SELECT_OBJECTS_ORDERED)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
rows.into_iter().map(map_object).collect()
|
||||
}
|
||||
|
||||
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
|
||||
brief_description, current_location, current_owner, recorder, recording_date, \
|
||||
visibility, created_at, updated_at";
|
||||
|
||||
const SELECT_OBJECT_BY_ID: &str =
|
||||
"SELECT id, object_number, object_name, number_of_objects, brief_description, \
|
||||
current_location, current_owner, recorder, recording_date, visibility, \
|
||||
created_at, updated_at FROM object WHERE id = $1";
|
||||
|
||||
const SELECT_OBJECTS_ORDERED: &str =
|
||||
"SELECT id, object_number, object_name, number_of_objects, brief_description, \
|
||||
current_location, current_owner, recorder, recording_date, visibility, \
|
||||
created_at, updated_at FROM object ORDER BY object_number";
|
||||
|
||||
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
|
||||
let visibility_str: String = row.try_get("visibility")?;
|
||||
let visibility = Visibility::from_db(&visibility_str)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown visibility: {visibility_str}").into()))?;
|
||||
Ok(CatalogueObject {
|
||||
id: ObjectId::from_uuid(row.try_get("id")?),
|
||||
object_number: row.try_get("object_number")?,
|
||||
object_name: row.try_get("object_name")?,
|
||||
number_of_objects: row.try_get("number_of_objects")?,
|
||||
brief_description: row.try_get("brief_description")?,
|
||||
current_location: row.try_get("current_location")?,
|
||||
current_owner: row.try_get("current_owner")?,
|
||||
recorder: row.try_get("recorder")?,
|
||||
recording_date: row.try_get("recording_date")?,
|
||||
visibility,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The mutable fields as `(name, value)` pairs, for building audit diffs.
|
||||
/// `None` means the field is unset (NULL).
|
||||
fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
|
||||
vec![
|
||||
("object_number", Some(json!(input.object_number))),
|
||||
("object_name", Some(json!(input.object_name))),
|
||||
("number_of_objects", Some(json!(input.number_of_objects))),
|
||||
("brief_description", input.brief_description.as_ref().map(|v| json!(v))),
|
||||
("current_location", input.current_location.as_ref().map(|v| json!(v))),
|
||||
("current_owner", input.current_owner.as_ref().map(|v| json!(v))),
|
||||
("recorder", input.recorder.as_ref().map(|v| json!(v))),
|
||||
(
|
||||
"recording_date",
|
||||
input.recording_date.and_then(|d| serde_json::to_value(d).ok()),
|
||||
),
|
||||
("visibility", Some(json!(input.visibility.as_str()))),
|
||||
]
|
||||
}
|
||||
|
||||
/// Audit changes for a newly created object: every set field as an `after` value.
|
||||
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
|
||||
field_values(input)
|
||||
.into_iter()
|
||||
.filter_map(|(field, after)| {
|
||||
after.map(|a| FieldChange {
|
||||
field: field.to_owned(),
|
||||
before: None,
|
||||
after: Some(a),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Audit changes between two field sets: only the fields whose value changed.
|
||||
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
|
||||
field_values(old)
|
||||
.into_iter()
|
||||
.zip(field_values(new))
|
||||
.filter_map(|((field, before), (_, after))| {
|
||||
if before != after {
|
||||
Some(FieldChange { field: field.to_owned(), before, after })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
Note: `OBJECT_COLUMNS` is intentionally unused for now (kept adjacent for documentation of the column set); if clippy flags it as dead code, DELETE the `OBJECT_COLUMNS` const (the two SELECT consts are the live ones). `update_changes` is used in Task 4 — if clippy flags it unused in this task, add `#[allow(dead_code)]` to `update_changes` with a `// used in Task 4 (update_object)` comment, OR implement Task 4 immediately after so it's used. (Recommended: proceed to Task 4 before the final clippy gate.)
|
||||
|
||||
Add to `crates/db/src/lib.rs` (top-level): `pub mod catalog;`
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog` → PASS (3 tests).
|
||||
|
||||
- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean (resolve the `OBJECT_COLUMNS`/`update_changes` dead-code note as above).
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add catalogue object create/read/list with audit on create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `db::catalog` — update & delete (with audit diffs)
|
||||
|
||||
**Files:** modify `crates/db/src/catalog.rs`; test `crates/db/tests/catalog_mutations.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/catalog_mutations.rs`:
|
||||
```rust
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn base() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: Some("shelf A1".into()),
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut changed = base();
|
||||
changed.object_name = "roman vase".into();
|
||||
changed.visibility = Visibility::Public;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &changed).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.object_name, "roman vase");
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 2); // created + updated
|
||||
let update = &history[1];
|
||||
assert_eq!(update.action, AuditAction::Updated);
|
||||
// Exactly the two changed fields are recorded.
|
||||
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
|
||||
fields.sort_unstable();
|
||||
assert_eq!(fields, vec!["object_name", "visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_update_records_no_audit(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 1, "a no-op update must not add an audit entry");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_missing_returns_false(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut *tx, AuditActor::System, domain::ObjectId::new(), &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(!updated);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn delete_removes_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let deleted = catalog::delete_object(&mut *tx, AuditActor::System, id).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
assert!(catalog::object_by_id(db.pool(), id).await.unwrap().is_none());
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → FAIL (`update_object`/`delete_object` missing).
|
||||
|
||||
- [ ] **Step 3: Implement** — append to `crates/db/src/catalog.rs`:
|
||||
```rust
|
||||
/// Update an object and record an `updated` audit entry with field-level diffs,
|
||||
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
|
||||
/// (no fields changed) records no audit entry.
|
||||
pub async fn update_object(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
input: &ObjectInput,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let Some(old) = object_by_id(&mut *conn, id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE object SET \
|
||||
object_number = $2, object_name = $3, number_of_objects = $4, \
|
||||
brief_description = $5, current_location = $6, current_owner = $7, \
|
||||
recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&input.object_number)
|
||||
.bind(&input.object_name)
|
||||
.bind(input.number_of_objects)
|
||||
.bind(input.brief_description.as_deref())
|
||||
.bind(input.current_location.as_deref())
|
||||
.bind(input.current_owner.as_deref())
|
||||
.bind(input.recorder.as_deref())
|
||||
.bind(input.recording_date)
|
||||
.bind(input.visibility.as_str())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let changes = update_changes(&old.to_input(), input);
|
||||
if !changes.is_empty() {
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Delete an object and record a `deleted` audit entry, both on `conn`.
|
||||
/// Returns `false` if the object did not exist.
|
||||
pub async fn delete_object(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
if object_by_id(&mut *conn, id).await?.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM object WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Deleted,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
```
|
||||
If you added a temporary `#[allow(dead_code)]` to `update_changes` in Task 3, remove it now (it is used here). If `OBJECT_COLUMNS` is unused, delete that const.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add catalogue object update/delete with audited field diffs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (§6.1 typed core + audit integration):**
|
||||
- Inventory-minimum object (object/group via number_of_objects), simple typed fields → Tasks 1–2. ✓
|
||||
- CRUD + list → Tasks 3–4. ✓
|
||||
- Audit on create/update/delete inside the write transaction, field-level diffs on update → Task 3 (create) + Task 4 (update/delete), verified via `audit::history_for` in tests. ✓
|
||||
- Visibility stored; transitions/PublicView/public API deferred to Plan 7. ✓ (intentional)
|
||||
- Vocab/authority binding deferred to Plan 4. ✓ (intentional)
|
||||
- SQL confined to `db`; `domain` I/O-free. ✓
|
||||
|
||||
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`. The `OBJECT_COLUMNS`/`update_changes` dead-code notes are explicit resolution instructions, not placeholders.
|
||||
|
||||
**Type consistency:** `ObjectInput`/`CatalogueObject` field names/types identical across `domain` (Task 1), the repo (Tasks 3–4), and tests. `create_object`/`update_object`/`delete_object` take `(&mut PgConnection, AuditActor, …)`; reads take `impl PgExecutor`. `field_values`/`creation_changes`/`update_changes` operate on the same nine mutable fields; `to_input` (domain) bridges `CatalogueObject` → `ObjectInput` for diffing. `ENTITY_TYPE = "object"` matches the `"object"` literal used in tests' `history_for` calls.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- The audit `actor` is threaded as a parameter; the API layer (Plan 7+) will pass the authenticated user (Plan 9 introduces `UserId`; until then `AuditActor::System` or a raw user uuid).
|
||||
- `list_objects` is unpaginated (TODO in code) — add keyset pagination before the API exposes it (same as `audit::history_for`, `vocab::list_terms`, `authority::list_by_kind`).
|
||||
- Flexible fields (Plan 4) attach to this object via the field-definition registry + JSONB; vocab/authority-bound fields (object_name as TermRef, owner as AuthorityRef) live there.
|
||||
- `PublicView` projection + visibility transition methods + public read API: Plan 7.
|
||||
@@ -0,0 +1,487 @@
|
||||
# Field-Definition Registry Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** The "schema of schemas" for Approach C's flexible layer — a registry of field definitions (key, type, vocabulary/authority binding, required, group, multilingual labels). This is half of the flexible-fields subsystem; the object JSONB values + validation + audit are the next plan (Plan 5).
|
||||
|
||||
**Architecture:** `domain` holds `FieldDefinitionId`, a type-driven `FieldType` enum that *carries its binding* (a `Term` always has a `VocabularyId`; a non-term never does — illegal states unrepresentable), and `FieldDefinition`/`NewFieldDefinition`. `db::fields` owns the `field_definition` + `field_definition_label` tables (migration 0004) and a create/read/list repository. The DB enforces the type↔binding invariant with a CHECK constraint mirroring the enum. No values, no validation engine, no HTTP yet.
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8 (`time`+`json`), `serde_json` (labels json_agg). Tests use `#[sqlx::test]`.
|
||||
|
||||
## Design decisions (approved)
|
||||
- Split flexible fields into **this plan (registry)** and **Plan 5 (values + validation + audit)**.
|
||||
- `data_type` set: `text`, `localized_text`, `integer`, `date`, `boolean`, `term`, `authority`.
|
||||
- `FieldType` is a type-driven enum carrying the binding; the DB stores `(data_type, vocabulary_id, authority_kind)` with a CHECK enforcing `term ⇔ vocabulary_id present`.
|
||||
- Field definitions carry multilingual display labels (reusing `LocalizedLabel`) and a `group_key`.
|
||||
- Scope: create/read/list of definitions. Update/delete of definitions deferred (admin UI, Plan 10).
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline (e.g. `postgres://postgres:postgres@localhost:5433/cms_dev`). Shell env does not persist between commands.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/domain/
|
||||
src/id.rs + FieldDefinitionId
|
||||
src/field_definition.rs FieldType, FieldDefinition, NewFieldDefinition
|
||||
src/lib.rs re-exports
|
||||
crates/db/
|
||||
migrations/0004_field_definition.sql
|
||||
src/fields.rs create/read/list field definitions
|
||||
src/lib.rs pub mod fields;
|
||||
tests/fields.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — field definition types
|
||||
|
||||
**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/field_definition.rs`.
|
||||
|
||||
- [ ] **Step 1: Add `FieldDefinitionId`** to `crates/domain/src/id.rs` (another `id_newtype!` invocation):
|
||||
```rust
|
||||
id_newtype!(
|
||||
/// Identifier for a flexible-field definition.
|
||||
FieldDefinitionId
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `crates/domain/src/field_definition.rs`:**
|
||||
```rust
|
||||
use crate::{AuthorityKind, FieldDefinitionId, LocalizedLabel, VocabularyId};
|
||||
|
||||
/// The type of a flexible field, carrying its binding where applicable.
|
||||
///
|
||||
/// Type-driven: a `Term` always names its vocabulary; a non-term never carries one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FieldType {
|
||||
Text,
|
||||
LocalizedText,
|
||||
Integer,
|
||||
Date,
|
||||
Boolean,
|
||||
Term { vocabulary_id: VocabularyId },
|
||||
Authority { kind: Option<AuthorityKind> },
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
/// The stored discriminant string.
|
||||
pub fn kind_str(&self) -> &'static str {
|
||||
match self {
|
||||
FieldType::Text => "text",
|
||||
FieldType::LocalizedText => "localized_text",
|
||||
FieldType::Integer => "integer",
|
||||
FieldType::Date => "date",
|
||||
FieldType::Boolean => "boolean",
|
||||
FieldType::Term { .. } => "term",
|
||||
FieldType::Authority { .. } => "authority",
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompose into the three stored columns: `(data_type, vocabulary_id, authority_kind)`.
|
||||
pub fn to_parts(&self) -> (&'static str, Option<VocabularyId>, Option<AuthorityKind>) {
|
||||
match self {
|
||||
FieldType::Term { vocabulary_id } => ("term", Some(*vocabulary_id), None),
|
||||
FieldType::Authority { kind } => ("authority", None, *kind),
|
||||
other => (other.kind_str(), None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from the stored columns. `None` for an unknown or inconsistent combo
|
||||
/// (e.g. `term` without a vocabulary).
|
||||
pub fn from_parts(
|
||||
data_type: &str,
|
||||
vocabulary_id: Option<VocabularyId>,
|
||||
authority_kind: Option<AuthorityKind>,
|
||||
) -> Option<Self> {
|
||||
match data_type {
|
||||
"text" => Some(FieldType::Text),
|
||||
"localized_text" => Some(FieldType::LocalizedText),
|
||||
"integer" => Some(FieldType::Integer),
|
||||
"date" => Some(FieldType::Date),
|
||||
"boolean" => Some(FieldType::Boolean),
|
||||
"term" => vocabulary_id.map(|vocabulary_id| FieldType::Term { vocabulary_id }),
|
||||
"authority" => Some(FieldType::Authority { kind: authority_kind }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered flexible field, with its multilingual display labels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldDefinition {
|
||||
pub id: FieldDefinitionId,
|
||||
pub key: String,
|
||||
pub field_type: FieldType,
|
||||
pub required: bool,
|
||||
pub group_key: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A field definition to be created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewFieldDefinition {
|
||||
pub key: String,
|
||||
pub field_type: FieldType,
|
||||
pub required: bool,
|
||||
pub group_key: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn field_type_round_trips_through_parts() {
|
||||
let v = VocabularyId::new();
|
||||
let cases = [
|
||||
FieldType::Text,
|
||||
FieldType::LocalizedText,
|
||||
FieldType::Integer,
|
||||
FieldType::Date,
|
||||
FieldType::Boolean,
|
||||
FieldType::Term { vocabulary_id: v },
|
||||
FieldType::Authority { kind: Some(AuthorityKind::Person) },
|
||||
FieldType::Authority { kind: None },
|
||||
];
|
||||
for ft in cases {
|
||||
let (data_type, vocabulary_id, authority_kind) = ft.to_parts();
|
||||
assert_eq!(FieldType::from_parts(data_type, vocabulary_id, authority_kind), Some(ft));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_without_vocabulary_is_invalid() {
|
||||
assert_eq!(FieldType::from_parts("term", None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_is_none() {
|
||||
assert_eq!(FieldType::from_parts("blob", None, None), None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `crates/domain/src/lib.rs`:** add `mod field_definition;` (sorted: audit, authority, field_definition, id, label, object, vocabulary), add `FieldDefinitionId` to the id re-export, and add:
|
||||
```rust
|
||||
pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test + lint.** `cargo test -p domain` → all pass. `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add crates/domain
|
||||
git commit -m "feat(domain): add field definition types (FieldType, FieldDefinition)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `db` migration — field_definition tables
|
||||
|
||||
**Files:** create `crates/db/migrations/0004_field_definition.sql`; modify `crates/db/tests/migrate.rs`.
|
||||
|
||||
- [ ] **Step 1: Create `crates/db/migrations/0004_field_definition.sql`:**
|
||||
```sql
|
||||
-- Registry of flexible field definitions (the "schema of schemas").
|
||||
CREATE TABLE field_definition (
|
||||
id UUID PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE CHECK (key <> ''),
|
||||
data_type TEXT NOT NULL CHECK (data_type IN
|
||||
('text', 'localized_text', 'integer', 'date', 'boolean', 'term', 'authority')),
|
||||
vocabulary_id UUID REFERENCES vocabulary (id) ON DELETE RESTRICT,
|
||||
authority_kind TEXT CHECK (authority_kind IN ('person', 'organisation', 'place')),
|
||||
required BOOLEAN NOT NULL DEFAULT false,
|
||||
group_key TEXT CHECK (group_key <> ''),
|
||||
-- A term field must name a vocabulary; any other type must not.
|
||||
CONSTRAINT term_has_vocabulary CHECK ((data_type = 'term') = (vocabulary_id IS NOT NULL)),
|
||||
-- authority_kind is only meaningful for authority fields.
|
||||
CONSTRAINT authority_kind_only_for_authority
|
||||
CHECK (authority_kind IS NULL OR data_type = 'authority')
|
||||
);
|
||||
|
||||
CREATE TABLE field_definition_label (
|
||||
field_definition_id UUID NOT NULL REFERENCES field_definition (id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL CHECK (lang <> ''),
|
||||
label TEXT NOT NULL CHECK (label <> ''),
|
||||
PRIMARY KEY (field_definition_id, lang)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Append to `crates/db/tests/migrate.rs`:**
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_field_definition_tables(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
for table in ["field_definition", "field_definition_label"] {
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar(&format!("SELECT to_regclass('public.{table}')::text"))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(regclass.as_deref(), Some(table), "table {table} should exist");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate` → 4 tests pass. `cargo +nightly fmt`; clippy clean.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add crates/db/migrations crates/db/tests/migrate.rs
|
||||
git commit -m "feat(db): add field_definition tables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `db::fields` repository
|
||||
|
||||
**Files:** create `crates/db/src/fields.rs`; modify `crates/db/src/lib.rs`; create `crates/db/tests/fields.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/fields.rs`:
|
||||
```rust
|
||||
use db::{Db, fields, vocab};
|
||||
use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn labels() -> Vec<LocalizedLabel> {
|
||||
vec![
|
||||
LocalizedLabel { lang: "sv".into(), label: "material".into() },
|
||||
LocalizedLabel { lang: "en".into(), label: "material".into() },
|
||||
]
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn text_field_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "comments".into(),
|
||||
field_type: FieldType::Text,
|
||||
required: false,
|
||||
group_key: Some("identification".into()),
|
||||
labels: labels(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let def = fields::field_definition_by_key(db.pool(), "comments").await.unwrap().unwrap();
|
||||
assert_eq!(def.id, id);
|
||||
assert_eq!(def.field_type, FieldType::Text);
|
||||
assert_eq!(def.group_key.as_deref(), Some("identification"));
|
||||
assert_eq!(def.labels.len(), 2);
|
||||
assert!(fields::field_definition_by_key(db.pool(), "nope").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[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").await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "material".into(),
|
||||
field_type: FieldType::Term { vocabulary_id: material.id },
|
||||
required: true,
|
||||
group_key: None,
|
||||
labels: labels(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "maker".into(),
|
||||
field_type: FieldType::Authority { kind: Some(AuthorityKind::Person) },
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel { lang: "en".into(), label: "maker".into() }],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let material_def = fields::field_definition_by_key(db.pool(), "material").await.unwrap().unwrap();
|
||||
assert_eq!(material_def.field_type, FieldType::Term { vocabulary_id: material.id });
|
||||
assert!(material_def.required);
|
||||
|
||||
let maker_def = fields::field_definition_by_key(db.pool(), "maker").await.unwrap().unwrap();
|
||||
assert_eq!(maker_def.field_type, FieldType::Authority { kind: Some(AuthorityKind::Person) });
|
||||
|
||||
let all = fields::list_field_definitions(db.pool()).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test fields` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement** `crates/db/src/fields.rs`:
|
||||
```rust
|
||||
//! Registry of flexible field definitions.
|
||||
|
||||
use domain::{
|
||||
AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel,
|
||||
NewFieldDefinition, VocabularyId,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
/// Labels aggregated per row as JSON, to read a definition and its labels in one query.
|
||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
|
||||
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
|
||||
|
||||
const SELECT_COLUMNS: &str =
|
||||
"fd.id, fd.key, fd.data_type, fd.vocabulary_id, fd.authority_kind, fd.required, fd.group_key";
|
||||
|
||||
/// Create a field definition and its labels. Multiple statements — pass a
|
||||
/// transaction connection (`&mut *tx`) for atomicity.
|
||||
pub async fn create_field_definition(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
new: &NewFieldDefinition,
|
||||
) -> Result<FieldDefinitionId, sqlx::Error> {
|
||||
let id = FieldDefinitionId::new();
|
||||
let (data_type, vocabulary_id, authority_kind) = new.field_type.to_parts();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO field_definition \
|
||||
(id, key, data_type, vocabulary_id, authority_kind, required, group_key) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&new.key)
|
||||
.bind(data_type)
|
||||
.bind(vocabulary_id.map(|v| v.to_uuid()))
|
||||
.bind(authority_kind.map(|k| k.as_str()))
|
||||
.bind(new.required)
|
||||
.bind(new.group_key.as_deref())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
for label in &new.labels {
|
||||
sqlx::query(
|
||||
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Look up a field definition by its key (with labels).
|
||||
pub async fn field_definition_by_key<'e, E>(
|
||||
executor: E,
|
||||
key: &str,
|
||||
) -> Result<Option<FieldDefinition>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \
|
||||
FROM field_definition fd \
|
||||
LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \
|
||||
WHERE fd.key = $1 GROUP BY fd.id"
|
||||
);
|
||||
let row = sqlx::query(&sql).bind(key).fetch_optional(executor).await?;
|
||||
row.map(map_field_definition).transpose()
|
||||
}
|
||||
|
||||
/// List all field definitions (with labels), ordered by key.
|
||||
pub async fn list_field_definitions<'e, E>(
|
||||
executor: E,
|
||||
) -> Result<Vec<FieldDefinition>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \
|
||||
FROM field_definition fd \
|
||||
LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \
|
||||
GROUP BY fd.id ORDER BY fd.key"
|
||||
);
|
||||
let rows = sqlx::query(&sql).fetch_all(executor).await?;
|
||||
rows.into_iter().map(map_field_definition).collect()
|
||||
}
|
||||
|
||||
fn map_field_definition(row: sqlx::postgres::PgRow) -> Result<FieldDefinition, sqlx::Error> {
|
||||
let data_type: String = row.try_get("data_type")?;
|
||||
let vocabulary_id: Option<uuid::Uuid> = row.try_get("vocabulary_id")?;
|
||||
let authority_kind: Option<String> = row.try_get("authority_kind")?;
|
||||
|
||||
let authority_kind = authority_kind
|
||||
.map(|k| {
|
||||
AuthorityKind::from_db(&k)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {k}").into()))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let field_type = FieldType::from_parts(
|
||||
&data_type,
|
||||
vocabulary_id.map(VocabularyId::from_uuid),
|
||||
authority_kind,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
sqlx::Error::Decode(format!("inconsistent field type stored: {data_type}").into())
|
||||
})?;
|
||||
|
||||
let labels: sqlx::types::Json<Vec<LocalizedLabel>> = row.try_get("labels")?;
|
||||
|
||||
Ok(FieldDefinition {
|
||||
id: FieldDefinitionId::from_uuid(row.try_get("id")?),
|
||||
key: row.try_get("key")?,
|
||||
field_type,
|
||||
required: row.try_get("required")?,
|
||||
group_key: row.try_get("group_key")?,
|
||||
labels: labels.0,
|
||||
})
|
||||
}
|
||||
```
|
||||
Add to `crates/db/src/lib.rs` (top-level): `pub mod fields;`
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test fields` → PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add field-definition registry repository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (§6.2 registry portion):**
|
||||
- Field-definition registry: key, type, binding, required, group, multilingual labels → Tasks 1–3. ✓
|
||||
- Type-driven `FieldType` carrying binding; DB CHECK enforces `term ⇔ vocabulary` → Task 1 (enum) + Task 2 (CHECK). ✓
|
||||
- Approved `data_type` set incl. `localized_text` → Task 1. ✓
|
||||
- Create/read/list; update/delete deferred to admin UI. ✓ (intentional)
|
||||
- SQL confined to `db`; `domain` I/O-free. ✓
|
||||
- No values/validation/HTTP (Plan 5+). ✓ (intentional)
|
||||
|
||||
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`.
|
||||
|
||||
**Type consistency:** `FieldType`/`FieldDefinition`/`NewFieldDefinition`/`FieldDefinitionId` names+fields identical across `domain` (Task 1), the repo (Task 3), tests. `to_parts`/`from_parts` are inverse (tested in Task 1) and used symmetrically in `create_field_definition` (to_parts → binds) and `map_field_definition` (from_parts ← columns). The DB CHECK `(data_type='term') = (vocabulary_id IS NOT NULL)` mirrors `from_parts` returning None for term-without-vocabulary.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- **Plan 5 (values + validation + audit):** add `object.fields jsonb`, set/get with validation against this registry (type match; `term`/`authority` values resolved via `vocab::resolve_term`/`authority::resolve_authority`), and audit flexible-field changes on the object. Seed the Spectrum Cataloguing field set there (or a small follow-on) using `reference/spectrum-5.0-cataloguing-units-of-information.md`.
|
||||
- Update/delete of field definitions (and the impact on existing values) is an admin-UI concern (Plan 10).
|
||||
- `list_field_definitions` unbounded — covered by the pagination follow-up (#10) before API exposure.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,584 @@
|
||||
# Object Flexible-Field Values Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Where the registry gets used — a `fields jsonb` column on `object`, with `set_object_fields` that validates each value against the field-definition registry (Plan 4) + resolves term/authority references (Plan 2) + audits the per-field changes (Plan 1/3), all in the write transaction.
|
||||
|
||||
**Architecture:** `CatalogueObject` gains a `fields: serde_json::Value` (the raw JSONB map). `db::catalog::set_object_fields` takes the complete desired field map, validates every value (unknown key → error; type mismatch → error; term/authority must resolve), **replaces** the JSONB, and audits the diff against the old map. A typed `FieldError` (using the `db` crate's so-far-unused `thiserror` dep) surfaces validation failures. Required-field *completeness* is NOT enforced here (deferred to the publish gate, Plan 7).
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8 (`json`), `serde_json` (values + validation), `thiserror` (FieldError). Tests use `#[sqlx::test]`.
|
||||
|
||||
## Design decisions (approved)
|
||||
- Storage: `object.fields jsonb NOT NULL DEFAULT '{}'`, `field_key → value`.
|
||||
- Value shapes: text→string, localized_text→`{lang: string}`, integer→JSON integer, date→string, boolean→bool, term→term-UUID string (resolves in bound vocab), authority→authority-UUID string (resolves; kind matches if constrained).
|
||||
- `set_object_fields` = **replace** the whole map, **separate** from `update_object`; audits the diff; no-op skips write+audit (consistent with `update_object`).
|
||||
- Required-field enforcement deferred to publish (Plan 7).
|
||||
- Strict per-field rules (date format, min/max, regex) deferred to #11; here `date` validates as a string only.
|
||||
- Spectrum field-set seeding is a separate follow-on (not this plan).
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline. Shell env does not persist between commands. Pass transaction connections as `&mut tx` (NOT `&mut *tx`) to avoid clippy `explicit_auto_deref`.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/domain/src/object.rs CatalogueObject gains `fields: serde_json::Value`
|
||||
crates/db/
|
||||
Cargo.toml serde_json also in [dev-dependencies]
|
||||
migrations/0005_object_fields.sql
|
||||
src/catalog.rs + fields in SELECT/map; FieldError; set_object_fields + validation helpers
|
||||
tests/object_fields.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `fields` column + read it back
|
||||
|
||||
**Files:** create `crates/db/migrations/0005_object_fields.sql`; modify `crates/domain/src/object.rs`, `crates/db/src/catalog.rs`, `crates/db/tests/migrate.rs`, `crates/db/Cargo.toml`.
|
||||
|
||||
- [ ] **Step 1: Migration.** Create `crates/db/migrations/0005_object_fields.sql`:
|
||||
```sql
|
||||
-- Flexible field values for a catalogue object, keyed by field-definition key.
|
||||
ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Domain.** In `crates/domain/src/object.rs`, add a `fields` field to `CatalogueObject` (after `visibility`, before the timestamps):
|
||||
```rust
|
||||
pub visibility: Visibility,
|
||||
/// Flexible field values (field key -> value), validated against the registry.
|
||||
pub fields: serde_json::Value,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
```
|
||||
Add `use serde_json` if needed (serde_json is already a domain dependency). `CatalogueObject` derives `Debug, Clone, PartialEq` (NOT `Eq`) — `serde_json::Value` is `PartialEq` but not `Eq`, so do not add `Eq`. `to_input()` is unchanged (it maps only the 9 core mutable fields; `fields` is not part of `ObjectInput`).
|
||||
|
||||
- [ ] **Step 3: db reads the column.** In `crates/db/src/catalog.rs`:
|
||||
- Add `fields` to the `OBJECT_COLUMNS` const (append `, fields`).
|
||||
- In `map_object`, add: `fields: row.try_get("fields")?,` (between `visibility` and `created_at`). `sqlx` decodes a `jsonb` column directly into `serde_json::Value`.
|
||||
(`create_object`'s INSERT does NOT list `fields`, so it uses the `'{}'` default — leave the INSERT unchanged.)
|
||||
|
||||
- [ ] **Step 4: dev-dep.** In `crates/db/Cargo.toml`, add `serde_json` to `[dev-dependencies]` (it is a normal dep, but integration tests need it in scope):
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
time.workspace = true
|
||||
serde_json.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Tests.** Append to `crates/db/tests/migrate.rs`:
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn migrate_adds_object_fields_column(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let exists: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT true FROM information_schema.columns \
|
||||
WHERE table_name = 'object' AND column_name = 'fields'",
|
||||
)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(exists, Some(true));
|
||||
}
|
||||
```
|
||||
And append to `crates/db/tests/catalog.rs`:
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn new_object_has_empty_fields(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-9")).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.fields, serde_json::json!({}));
|
||||
}
|
||||
```
|
||||
(`crates/db/tests/catalog.rs` already imports what it needs except possibly `serde_json` — add `use serde_json` if the test uses the `json!` macro and it is not already imported; `serde_json::json!` works fully-qualified as written.)
|
||||
|
||||
- [ ] **Step 6: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate --test catalog` and `cargo test -p domain` → all pass. `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/domain crates/db
|
||||
git commit -m "feat(db): add object.fields jsonb column, read it into CatalogueObject"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `set_object_fields` — validate, write, audit
|
||||
|
||||
**Files:** modify `crates/db/src/catalog.rs`; create `crates/db/tests/object_fields.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/object_fields.rs`:
|
||||
```rust
|
||||
use db::catalog::FieldError;
|
||||
use db::{Db, audit, catalog, fields, vocab};
|
||||
use domain::{
|
||||
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, ObjectInput, Visibility,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn obj_input() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(text: &str) -> Vec<LocalizedLabel> {
|
||||
vec![LocalizedLabel { lang: "en".into(), label: text.into() }]
|
||||
}
|
||||
|
||||
async fn setup_object(db: &Db) -> domain::ObjectId {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &obj_input()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
async fn define(db: &Db, key: &str, field_type: FieldType) {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition { key: key.into(), field_type, required: false, group_key: None, labels: label(key) },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn sets_scalar_fields_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "comments", FieldType::Text).await;
|
||||
define(&db, "year", FieldType::Integer).await;
|
||||
define(&db, "on_display", FieldType::Boolean).await;
|
||||
|
||||
let values = serde_json::json!({ "comments": "nice", "year": 1850, "on_display": true });
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.fields["comments"], "nice");
|
||||
assert_eq!(obj.fields["year"], 1850);
|
||||
assert_eq!(obj.fields["on_display"], true);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
// created + the field set
|
||||
assert_eq!(history.last().unwrap().action, AuditAction::Updated);
|
||||
let changed: Vec<&str> = history.last().unwrap().changes.iter().map(|c| c.field.as_str()).collect();
|
||||
assert!(changed.contains(&"comments") && changed.contains(&"year") && changed.contains(&"on_display"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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").await.unwrap();
|
||||
define(&db, "material", FieldType::Term { vocabulary_id: material.id }).await;
|
||||
|
||||
// add a real term
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let wood = vocab::add_term(
|
||||
&mut tx,
|
||||
&domain::NewTerm { vocabulary_id: material.id, external_uri: None, labels: label("wood") },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// valid term id resolves
|
||||
let ok = serde_json::json!({ "material": wood.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, ok.as_object().unwrap()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// random uuid does not resolve
|
||||
let bad = serde_json::json!({ "material": domain::TermId::new().to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await;
|
||||
assert!(matches!(err, Err(FieldError::Unresolved { .. })));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn unknown_field_and_type_mismatch_are_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "year", FieldType::Integer).await;
|
||||
|
||||
let unknown = serde_json::json!({ "nope": "x" });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, unknown.as_object().unwrap()).await,
|
||||
Err(FieldError::UnknownField(_))
|
||||
));
|
||||
drop(tx);
|
||||
|
||||
let wrong = serde_json::json!({ "year": "not a number" });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, wrong.as_object().unwrap()).await,
|
||||
Err(FieldError::TypeMismatch { .. })
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test object_fields` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement** — add to the top of `crates/db/src/catalog.rs` the `FieldError` type, and append the function + helpers. Add imports as needed (`use crate::{audit, authority, fields, vocab};` — `audit` is already imported; add `authority, fields, vocab`). Also ensure `use domain::{... TermId, AuthorityId ...}` are available (add to the existing domain import).
|
||||
|
||||
`FieldError`:
|
||||
```rust
|
||||
/// Why setting flexible field values failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FieldError {
|
||||
#[error("object not found")]
|
||||
ObjectNotFound,
|
||||
#[error("unknown field: {0}")]
|
||||
UnknownField(String),
|
||||
#[error("field `{field}` expects a {expected} value")]
|
||||
TypeMismatch { field: String, expected: &'static str },
|
||||
#[error("field `{field}`: value does not resolve to an existing {kind}")]
|
||||
Unresolved { field: String, kind: &'static str },
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
```
|
||||
|
||||
`set_object_fields` + helpers:
|
||||
```rust
|
||||
/// Replace an object's flexible field values, validating each against the registry
|
||||
/// (type + term/authority resolution), and audit the per-field diff — all on `conn`.
|
||||
/// A no-op (identical to the current values) writes nothing and records no audit.
|
||||
pub async fn set_object_fields(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
object_id: ObjectId,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<(), FieldError> {
|
||||
let Some(old) = object_by_id(&mut *conn, object_id).await? else {
|
||||
return Err(FieldError::ObjectNotFound);
|
||||
};
|
||||
|
||||
for (key, value) in values {
|
||||
validate_field(&mut *conn, key, value).await?;
|
||||
}
|
||||
|
||||
let new_fields = Value::Object(values.clone());
|
||||
let changes = field_map_changes(&old.fields, &new_fields);
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE object SET fields = $2, updated_at = now() WHERE id = $1")
|
||||
.bind(object_id.to_uuid())
|
||||
.bind(&new_fields)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Updated,
|
||||
entity_type: ENTITY_TYPE.to_owned(),
|
||||
entity_id: object_id.to_uuid(),
|
||||
changes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_field(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
key: &str,
|
||||
value: &Value,
|
||||
) -> Result<(), FieldError> {
|
||||
let def = fields::field_definition_by_key(&mut *conn, key)
|
||||
.await?
|
||||
.ok_or_else(|| FieldError::UnknownField(key.to_owned()))?;
|
||||
|
||||
match def.field_type {
|
||||
FieldType::Text => require(value.is_string(), key, "text")?,
|
||||
FieldType::LocalizedText => require(
|
||||
value.as_object().is_some_and(|o| o.values().all(Value::is_string)),
|
||||
key,
|
||||
"localized-text object {lang: string}",
|
||||
)?,
|
||||
FieldType::Integer => require(value.is_i64(), key, "integer")?,
|
||||
FieldType::Date => require(value.is_string(), key, "date string")?,
|
||||
FieldType::Boolean => require(value.is_boolean(), key, "boolean")?,
|
||||
FieldType::Term { vocabulary_id } => {
|
||||
let term_id = parse_uuid(value, key, "term id (uuid string)")?;
|
||||
if vocab::resolve_term(&mut *conn, vocabulary_id, domain::TermId::from_uuid(term_id))
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(FieldError::Unresolved { field: key.to_owned(), kind: "term" });
|
||||
}
|
||||
}
|
||||
FieldType::Authority { kind } => {
|
||||
let authority_id = parse_uuid(value, key, "authority id (uuid string)")?;
|
||||
match authority::resolve_authority(&mut *conn, domain::AuthorityId::from_uuid(authority_id)).await? {
|
||||
Some(r) if kind.is_none_or(|k| r.kind() == k) => {}
|
||||
_ => return Err(FieldError::Unresolved { field: key.to_owned(), kind: "authority" }),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require(ok: bool, field: &str, expected: &'static str) -> Result<(), FieldError> {
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FieldError::TypeMismatch { field: field.to_owned(), expected })
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid(value: &Value, field: &str, expected: &'static str) -> Result<uuid::Uuid, FieldError> {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<uuid::Uuid>().ok())
|
||||
.ok_or_else(|| FieldError::TypeMismatch { field: field.to_owned(), expected })
|
||||
}
|
||||
|
||||
/// Per-key diff between two flexible-field maps. `before`/`after` are `None` when
|
||||
/// the key is absent on that side (so adds and removes are captured).
|
||||
fn field_map_changes(old: &Value, new: &Value) -> Vec<FieldChange> {
|
||||
let empty = serde_json::Map::new();
|
||||
let old_map = old.as_object().unwrap_or(&empty);
|
||||
let new_map = new.as_object().unwrap_or(&empty);
|
||||
|
||||
let keys: std::collections::BTreeSet<&String> = old_map.keys().chain(new_map.keys()).collect();
|
||||
keys.into_iter()
|
||||
.filter_map(|key| {
|
||||
let before = old_map.get(key).cloned();
|
||||
let after = new_map.get(key).cloned();
|
||||
if before != after {
|
||||
Some(FieldChange { field: key.clone(), before, after })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
Notes:
|
||||
- `db` already depends on `thiserror`, `uuid`, `serde_json`, `domain`, and has the `vocab`/`authority`/`fields`/`audit` sibling modules — no Cargo changes beyond Task 1's dev-dep.
|
||||
- `Value` and `FieldChange` are already imported at the top of `catalog.rs` (`use serde_json::{Value, json};` and `use domain::{..., FieldChange, ...}`). If `FieldChange` is not in the existing domain import, add it.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test object_fields` → PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): set_object_fields with registry validation and audited diffs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: validation coverage + replace/no-op semantics
|
||||
|
||||
**Files:** modify `crates/db/tests/object_fields.rs`.
|
||||
|
||||
- [ ] **Step 1: Add tests** to `crates/db/tests/object_fields.rs`:
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn authority_field_enforces_kind(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "maker", FieldType::Authority { kind: Some(domain::AuthorityKind::Person) }).await;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let person = db::authority::create_authority(
|
||||
&mut tx,
|
||||
&domain::NewAuthority {
|
||||
kind: domain::AuthorityKind::Person,
|
||||
external_uri: None,
|
||||
labels: label("Carl"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let place = db::authority::create_authority(
|
||||
&mut tx,
|
||||
&domain::NewAuthority {
|
||||
kind: domain::AuthorityKind::Place,
|
||||
external_uri: None,
|
||||
labels: label("Stockholm"),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// a person resolves
|
||||
let ok = serde_json::json!({ "maker": person.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, ok.as_object().unwrap()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// a place is the wrong kind
|
||||
let bad = serde_json::json!({ "maker": place.to_string() });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
||||
Err(FieldError::Unresolved { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn localized_text_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "title", FieldType::LocalizedText).await;
|
||||
|
||||
let values = serde_json::json!({ "title": { "sv": "Vas", "en": "Vase" } });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.fields["title"]["sv"], "Vas");
|
||||
assert_eq!(obj.fields["title"]["en"], "Vase");
|
||||
|
||||
// a non-string member is rejected
|
||||
let bad = serde_json::json!({ "title": { "sv": 5 } });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
assert!(matches!(
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, bad.as_object().unwrap()).await,
|
||||
Err(FieldError::TypeMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn replace_semantics_remove_a_field_and_audit_it(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "comments", FieldType::Text).await;
|
||||
define(&db, "year", FieldType::Integer).await;
|
||||
|
||||
// set both
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
serde_json::json!({ "comments": "x", "year": 1850 }).as_object().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// replace with only `comments` -> `year` removed
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
id,
|
||||
serde_json::json!({ "comments": "x" }).as_object().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert!(obj.fields.get("year").is_none());
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
let last = history.last().unwrap();
|
||||
let year = last.changes.iter().find(|c| c.field == "year").expect("year removal recorded");
|
||||
assert!(year.before.is_some());
|
||||
assert!(year.after.is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_set_records_no_audit(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let id = setup_object(&db).await;
|
||||
define(&db, "comments", FieldType::Text).await;
|
||||
|
||||
let values = serde_json::json!({ "comments": "x" });
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let before = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap().len();
|
||||
|
||||
// setting the identical map again is a no-op
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(&mut tx, AuditActor::System, id, values.as_object().unwrap()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let after = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap().len();
|
||||
assert_eq!(before, after, "a no-op set must not add an audit entry");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn set_on_missing_object_errors(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
domain::ObjectId::new(),
|
||||
serde_json::json!({}).as_object().unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(err, Err(FieldError::ObjectNotFound)));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run.** `DATABASE_URL=<url> cargo test -p db --test object_fields` → PASS (8 tests total).
|
||||
|
||||
- [ ] **Step 3: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "test(db): cover authority-kind, localized text, replace/remove, no-op, missing object"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (§6.2 values + validation):**
|
||||
- `fields jsonb` on object; value shapes per type → Tasks 1–2. ✓
|
||||
- Validation against registry (type) + term/authority resolution → Task 2 `validate_field`. ✓
|
||||
- Replace semantics; audited per-field diffs (adds/removes/changes); no-op skip → Task 2 `set_object_fields`/`field_map_changes` + Task 3 tests. ✓
|
||||
- Required-completeness deferred to publish (Plan 7); strict date/format rules deferred to #11. ✓ (intentional)
|
||||
- Typed `FieldError` (uses the `db` crate's `thiserror`). ✓
|
||||
- SQL confined to `db`; `domain` I/O-free (only gains a `serde_json::Value` field). ✓
|
||||
|
||||
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`.
|
||||
|
||||
**Type consistency:** `set_object_fields(&mut PgConnection, AuditActor, ObjectId, &serde_json::Map<String, Value>) -> Result<(), FieldError>` used identically across impl + all tests. `FieldError` variants matched in tests. `validate_field` matches on `FieldType` variants from `domain` (Plan 4); `vocab::resolve_term`/`authority::resolve_authority` signatures (Plan 2) used as defined. `field_map_changes` produces `FieldChange` (Plan 1) consumed by `audit::record`. `CatalogueObject.fields` added in Task 1 is read by `map_object` and asserted in tests.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- **Spectrum field-set seeding** (a follow-on): use `reference/spectrum-5.0-cataloguing-units-of-information.md` to seed `field_definition`s (key + type + vocabulary binding for "use a standard term source" fields, authority binding for "form of name" fields).
|
||||
- **Required-field completeness** is enforced at the publish gate (Plan 7): when moving to `Visibility::Public`, check all `required` field definitions have a value.
|
||||
- **Strict per-field validation rules** (date format, ranges, regex) — issue #11.
|
||||
- The API layer (Plan 7+/10) will call `update_object` (core) and `set_object_fields` (flexible) within one transaction for a single logical edit, yielding two audit entries; consider whether to coalesce.
|
||||
@@ -0,0 +1,777 @@
|
||||
# Publishing: Visibility Transitions, PublicView & Public Read API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn the publishing pillar on: a type-driven `Visibility` state machine (stepwise `draft↔internal↔public`), an audited `db` transition + public-only reads, and the first real domain HTTP surface — an unauthenticated, read-only **public API** (`/api/public/objects`) that serves only `public` records as a leak-proof `PublicView` projection.
|
||||
|
||||
**Architecture:** Three layers, each testable in isolation (no auth needed — the public surface is unauthenticated by definition; the admin HTTP endpoint that *triggers* transitions waits for the auth phase, same "build capability now, wire surface later" pattern used for search).
|
||||
- `domain` — `Visibility::transition_to` / `can_transition_to` + an `IllegalTransition` error (the state machine).
|
||||
- `db` — `set_visibility` (validates via the domain machine, reuses `update_object`'s diff/audit path) + `public_object_by_id` / `list_public_objects` / `count_public_objects` (filter `visibility = 'public'` in SQL).
|
||||
- `api` — a `PublicView` response DTO (carries only public-safe fields, so leaking an internal field is structurally impossible) + `/api/public/objects` (paginated list) and `/api/public/objects/{id}` (404 for missing **or** non-public, so non-public existence isn't revealed), registered in the OpenAPI doc.
|
||||
|
||||
**Tech Stack:** Rust 2024, axum 0.8, sqlx 0.8, utoipa 5, serde, thiserror. Tests: `#[sqlx::test]` (db) and axum `oneshot` over `#[sqlx::test]` (api).
|
||||
|
||||
## Design decisions (approved)
|
||||
- **PublicView is core-only for MVP:** `id`, `object_number`, `object_name`, `brief_description`. **No flexible fields, no location/owner/recorder/dates.** Per-field publishability (which would let flexible fields surface selectively) is post-MVP; until then the projection type simply lacks the unsafe fields.
|
||||
- **Stepwise transitions:** legal single steps are `draft↔internal` and `internal↔public` only. `draft→public` (and `public→draft`) in one jump is illegal. Setting visibility to its current value is an idempotent no-op (`Ok`).
|
||||
- **Transitions land in `domain` + `db` only** this phase. The admin HTTP endpoint to invoke them arrives with auth (later phase).
|
||||
- **Public-facing search is post-MVP** (arch spec §12) — this plan adds no public search endpoint; public list is a `db` query.
|
||||
- **404, not 403,** for a non-public record on the public surface (don't leak existence).
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests; pass `DATABASE_URL` inline. Pass transaction connections as `&mut tx` (NOT `&mut *tx`).
|
||||
- `cargo +nightly fmt` (nightly). `cargo clippy --all-targets -- -D warnings` must stay clean.
|
||||
- The codename "biggus"/"dickus" must appear nowhere in code/comments/identifiers.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/domain/src/object.rs + IllegalTransition, Visibility::{can_transition_to, transition_to}, tests
|
||||
crates/domain/src/lib.rs + export IllegalTransition
|
||||
crates/db/src/catalog.rs + VisibilityError, set_visibility, public_object_by_id,
|
||||
list_public_objects, count_public_objects
|
||||
crates/db/tests/visibility.rs (new) transition rules + audit + public-read filtering
|
||||
crates/api/Cargo.toml + domain, uuid deps
|
||||
crates/api/src/public.rs (new) PublicView, Pagination, PublicObjectPage, handlers, routes
|
||||
crates/api/src/lib.rs + mod public; merge public::routes()
|
||||
crates/api/src/openapi.rs + register public paths + schemas
|
||||
crates/api/tests/public.rs (new) list/get handler tests (incl. leak + 404 assertions)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — `Visibility` state machine
|
||||
|
||||
**Files:** modify `crates/domain/src/object.rs`, `crates/domain/src/lib.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.** Add to the `#[cfg(test)] mod tests` in `crates/domain/src/object.rs`:
|
||||
```rust
|
||||
#[test]
|
||||
fn stepwise_transitions_are_legal() {
|
||||
use Visibility::*;
|
||||
assert_eq!(Draft.transition_to(Internal), Ok(Internal));
|
||||
assert_eq!(Internal.transition_to(Public), Ok(Public));
|
||||
assert_eq!(Public.transition_to(Internal), Ok(Internal));
|
||||
assert_eq!(Internal.transition_to(Draft), Ok(Draft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipping_a_step_is_illegal() {
|
||||
use Visibility::*;
|
||||
assert_eq!(
|
||||
Draft.transition_to(Public),
|
||||
Err(IllegalTransition { from: Draft, to: Public })
|
||||
);
|
||||
assert_eq!(
|
||||
Public.transition_to(Draft),
|
||||
Err(IllegalTransition { from: Public, to: Draft })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_to_current_value_is_a_noop_ok() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(v.transition_to(v), Ok(v));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `cargo test -p domain` → FAIL (`transition_to` / `IllegalTransition` missing).
|
||||
|
||||
- [ ] **Step 3: Implement.** In `crates/domain/src/object.rs`, after the `impl Visibility` block (the existing one with `as_str`/`from_db`), add the transition API and the error type. (domain has no `thiserror` dependency — implement `Display`/`Error` by hand to keep the core dependency-free.)
|
||||
```rust
|
||||
impl Visibility {
|
||||
/// Whether `self` may move directly to `target`. Legal single steps are
|
||||
/// `draft↔internal` and `internal↔public`; `draft↔public` is not one step.
|
||||
pub fn can_transition_to(self, target: Visibility) -> bool {
|
||||
use Visibility::*;
|
||||
matches!(
|
||||
(self, target),
|
||||
(Draft, Internal) | (Internal, Draft) | (Internal, Public) | (Public, Internal)
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate a stepwise transition to `target`. Setting to the current value is an
|
||||
/// idempotent no-op (`Ok`). A forbidden jump returns [`IllegalTransition`].
|
||||
pub fn transition_to(self, target: Visibility) -> Result<Visibility, IllegalTransition> {
|
||||
if self == target || self.can_transition_to(target) {
|
||||
Ok(target)
|
||||
} else {
|
||||
Err(IllegalTransition { from: self, to: target })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An attempted visibility change the state machine forbids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct IllegalTransition {
|
||||
pub from: Visibility,
|
||||
pub to: Visibility,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IllegalTransition {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"illegal visibility transition: {} -> {}",
|
||||
self.from.as_str(),
|
||||
self.to.as_str()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for IllegalTransition {}
|
||||
```
|
||||
In `crates/domain/src/lib.rs`, extend the object re-export:
|
||||
```rust
|
||||
pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `cargo test -p domain` → PASS.
|
||||
|
||||
- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/domain
|
||||
git commit -m "feat(domain): stepwise Visibility state machine (transition_to + IllegalTransition)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `db` — audited visibility transition + public reads
|
||||
|
||||
**Files:** modify `crates/db/src/catalog.rs`; create `crates/db/tests/visibility.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** `crates/db/tests/visibility.rs`:
|
||||
```rust
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn object(number: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn publish_steps_through_internal_and_audits(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &object("LM-1", Visibility::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal)
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
// created + two visibility updates
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 3);
|
||||
assert_eq!(history[2].action, AuditAction::Updated);
|
||||
let changed: Vec<&str> = history[2].changes.iter().map(|c| c.field.as_str()).collect();
|
||||
assert_eq!(changed, vec!["visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn skipping_a_step_is_rejected_and_unchanged(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &object("LM-1", Visibility::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(matches!(
|
||||
err,
|
||||
catalog::VisibilityError::Illegal(IllegalTransition {
|
||||
from: Visibility::Draft,
|
||||
to: Visibility::Public
|
||||
})
|
||||
));
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.visibility, Visibility::Draft); // unchanged
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn set_visibility_on_missing_object_errors(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = catalog::set_visibility(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
domain::ObjectId::new(),
|
||||
Visibility::Internal,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(matches!(err, catalog::VisibilityError::ObjectNotFound));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_set_to_current_visibility_writes_no_audit(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &object("LM-1", Visibility::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Draft)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn public_reads_return_only_public_records(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let draft = catalog::create_object(&mut tx, AuditActor::System, &object("D-1", Visibility::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
let pub_id =
|
||||
catalog::create_object(&mut tx, AuditActor::System, &object("P-1", Visibility::Public))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// by-id: public visible, draft hidden
|
||||
assert!(catalog::public_object_by_id(db.pool(), pub_id).await.unwrap().is_some());
|
||||
assert!(catalog::public_object_by_id(db.pool(), draft).await.unwrap().is_none());
|
||||
|
||||
// list + count: only the public one
|
||||
let listed = catalog::list_public_objects(db.pool(), 50, 0).await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, pub_id);
|
||||
assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1);
|
||||
|
||||
// paging: offset past the end yields nothing
|
||||
assert!(catalog::list_public_objects(db.pool(), 50, 1).await.unwrap().is_empty());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test visibility` → FAIL (`set_visibility` / `VisibilityError` / public readers missing).
|
||||
|
||||
- [ ] **Step 3: Implement** in `crates/db/src/catalog.rs`.
|
||||
|
||||
Extend the `domain` import (add `IllegalTransition`):
|
||||
```rust
|
||||
use domain::{
|
||||
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition,
|
||||
NewAuditEvent, ObjectId, ObjectInput, Visibility,
|
||||
};
|
||||
```
|
||||
|
||||
Add the visibility-eligible constant next to the existing `ENTITY_TYPE` const:
|
||||
```rust
|
||||
/// The visibility value eligible for the public surface.
|
||||
const PUBLIC_VISIBILITY: &str = "public";
|
||||
```
|
||||
|
||||
Add the error type and `set_visibility` (place after `update_object`, before `delete_object`):
|
||||
```rust
|
||||
/// Why changing an object's visibility failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VisibilityError {
|
||||
#[error("object not found")]
|
||||
ObjectNotFound,
|
||||
#[error(transparent)]
|
||||
Illegal(#[from] IllegalTransition),
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Move an object to `target` visibility, enforcing the stepwise state machine, and
|
||||
/// audit the change. Reuses [`update_object`]'s diff/audit path, so only `visibility`
|
||||
/// appears in the audit entry — and setting to the current value is an idempotent no-op
|
||||
/// (no row touch, no audit). Pass a transaction connection (`&mut tx`).
|
||||
pub async fn set_visibility(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
id: ObjectId,
|
||||
target: Visibility,
|
||||
) -> Result<(), VisibilityError> {
|
||||
let Some(object) = object_by_id(&mut *conn, id).await? else {
|
||||
return Err(VisibilityError::ObjectNotFound);
|
||||
};
|
||||
let new_visibility = object.visibility.transition_to(target)?;
|
||||
|
||||
let mut input = object.to_input();
|
||||
input.visibility = new_visibility;
|
||||
update_object(&mut *conn, actor, id, &input).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Add the public readers (place after `list_objects`):
|
||||
```rust
|
||||
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
|
||||
/// not public — callers map both to 404 so non-public existence isn't revealed.
|
||||
pub async fn public_object_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: ObjectId,
|
||||
) -> Result<Option<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2");
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(id.to_uuid())
|
||||
.bind(PUBLIC_VISIBILITY)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
row.map(map_object).transpose()
|
||||
}
|
||||
|
||||
/// List **public** objects ordered by object number, with `limit`/`offset` paging.
|
||||
pub async fn list_public_objects<'e, E>(
|
||||
executor: E,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \
|
||||
ORDER BY object_number LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(PUBLIC_VISIBILITY)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
rows.into_iter().map(map_object).collect()
|
||||
}
|
||||
|
||||
/// Count all public objects (for pagination totals).
|
||||
pub async fn count_public_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1")
|
||||
.bind(PUBLIC_VISIBILITY)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
row.try_get("n")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test visibility` → PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): audited stepwise set_visibility + public-only object readers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `api` — public read API (`PublicView` + routes + OpenAPI)
|
||||
|
||||
**Files:** modify `crates/api/Cargo.toml`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; create `crates/api/src/public.rs`, `crates/api/tests/public.rs`.
|
||||
|
||||
- [ ] **Step 1: Cargo deps.** In `crates/api/Cargo.toml` `[dependencies]`, add `domain` and `uuid` (the projection consumes `domain::CatalogueObject`; the path handler parses a UUID):
|
||||
```toml
|
||||
domain = { path = "../domain" }
|
||||
uuid = { workspace = true }
|
||||
```
|
||||
Add to `[dev-dependencies]` (the handler tests seed objects through `db` repos, which need `domain` types):
|
||||
```toml
|
||||
domain = { path = "../domain" }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `crates/api/tests/public.rs`:
|
||||
```rust
|
||||
use api::{AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use db::catalog;
|
||||
use domain::{AuditActor, ObjectInput, Visibility};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt; // for `oneshot`
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(number: &str, name: &str, visibility: Visibility) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: number.into(),
|
||||
object_name: name.into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: Some("a description".into()),
|
||||
current_location: Some("vault B".into()), // never-public; must NOT appear in output
|
||||
current_owner: Some("the museum".into()), // never-public
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility,
|
||||
}
|
||||
}
|
||||
|
||||
async fn body_json(resp: axum::http::Response<Body>) -> serde_json::Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn list_returns_only_public_as_public_view(pool: PgPool) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::create_object(&mut tx, AuditActor::System, &object("D-1", "draft vase", Visibility::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
catalog::create_object(&mut tx, AuditActor::System, &object("P-1", "public vase", Visibility::Public))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/api/public/objects").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["total"], 1);
|
||||
assert_eq!(json["items"].as_array().unwrap().len(), 1);
|
||||
let item = &json["items"][0];
|
||||
assert_eq!(item["object_number"], "P-1");
|
||||
assert_eq!(item["object_name"], "public vase");
|
||||
assert_eq!(item["brief_description"], "a description");
|
||||
// never-public fields must be structurally absent
|
||||
assert!(item.get("current_location").is_none());
|
||||
assert!(item.get("current_owner").is_none());
|
||||
assert!(item.get("recorder").is_none());
|
||||
assert!(item.get("visibility").is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn get_public_object_returns_it(pool: PgPool) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("P-1", "public vase", Visibility::Public),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/public/objects/{id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["object_number"], "P-1");
|
||||
assert!(json.get("current_location").is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn get_non_public_object_is_404(pool: PgPool) {
|
||||
let db = db::Db::from_pool(pool.clone());
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&object("D-1", "draft vase", Visibility::Draft),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/public/objects/{id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND); // not 403 — don't leak existence
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn get_missing_object_is_404(pool: PgPool) {
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/public/objects/{}", domain::ObjectId::new()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn openapi_lists_the_public_paths(pool: PgPool) {
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api-docs/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let json = body_json(resp).await;
|
||||
assert!(json["paths"]["/api/public/objects"].is_object());
|
||||
assert!(json["paths"]["/api/public/objects/{id}"].is_object());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p api --test public` → FAIL (`public` module / routes missing).
|
||||
|
||||
- [ ] **Step 4: Implement** `crates/api/src/public.rs`:
|
||||
```rust
|
||||
//! Public, unauthenticated, read-only surface (`/api/public/**`).
|
||||
//!
|
||||
//! Serves only `public` records as a [`PublicView`] — a projection that carries
|
||||
//! ONLY public-safe fields. The never-public set (location, owner, recorder, dates,
|
||||
//! and any flexible fields) is excluded by construction: the type lacks those fields,
|
||||
//! so leaking one here is impossible. Per-field publishability (to surface selected
|
||||
//! flexible fields) is post-MVP.
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use domain::{CatalogueObject, ObjectId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// A catalogue object as exposed on the public surface (public-safe fields only).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PublicView {
|
||||
/// Stable object id (UUID).
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
}
|
||||
|
||||
impl PublicView {
|
||||
fn from_object(object: &CatalogueObject) -> Self {
|
||||
PublicView {
|
||||
id: object.id.to_string(),
|
||||
object_number: object.object_number.clone(),
|
||||
object_name: object.object_name.clone(),
|
||||
brief_description: object.brief_description.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of public objects.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct PublicObjectPage {
|
||||
pub items: Vec<PublicView>,
|
||||
/// Total number of public objects (independent of paging).
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Pagination query parameters with sane defaults and a hard cap.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Pagination {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
const MAX_LIMIT: i64 = 200;
|
||||
|
||||
impl Pagination {
|
||||
fn limit(&self) -> i64 {
|
||||
self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
|
||||
}
|
||||
fn offset(&self) -> i64 {
|
||||
self.offset.unwrap_or(0).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// List public objects (paginated).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/public/objects",
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "Max items (1..=200, default 50)"),
|
||||
("offset" = Option<i64>, Query, description = "Items to skip (default 0)")
|
||||
),
|
||||
responses((status = 200, body = PublicObjectPage))
|
||||
)]
|
||||
pub(crate) async fn list_objects(
|
||||
State(state): State<AppState>,
|
||||
Query(page): Query<Pagination>,
|
||||
) -> Result<Json<PublicObjectPage>, StatusCode> {
|
||||
let (limit, offset) = (page.limit(), page.offset());
|
||||
let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let total = db::catalog::count_public_objects(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(PublicObjectPage {
|
||||
items: objects.iter().map(PublicView::from_object).collect(),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get one public object by id. Returns 404 if missing OR not public.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/public/objects/{id}",
|
||||
params(("id" = String, Path, description = "Object id (UUID)")),
|
||||
responses(
|
||||
(status = 200, body = PublicView),
|
||||
(status = 404, description = "No public object with that id")
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_object(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(object_id) = id.parse::<ObjectId>() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
match db::catalog::public_object_by_id(state.db.pool(), object_id).await {
|
||||
Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Public routes, parameterized over [`AppState`].
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/public/objects", get(list_objects))
|
||||
.route("/api/public/objects/{id}", get(get_object))
|
||||
}
|
||||
```
|
||||
NOTE: axum 0.8 path syntax is `{id}` (braces), matching the existing routes. `ObjectId: FromStr` exists (id macro). `state.db.pool()` returns the `&PgPool` (used by the health readiness handler too).
|
||||
|
||||
In `crates/api/src/lib.rs`, declare the module and merge its routes:
|
||||
```rust
|
||||
mod health;
|
||||
mod openapi;
|
||||
mod public;
|
||||
```
|
||||
```rust
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.merge(health::routes())
|
||||
.merge(openapi::routes())
|
||||
.merge(public::routes())
|
||||
.with_state(state)
|
||||
}
|
||||
```
|
||||
|
||||
In `crates/api/src/openapi.rs`, register the public paths + schemas. Update the imports and the `#[openapi(...)]` attribute:
|
||||
```rust
|
||||
use crate::{AppState, health, public};
|
||||
```
|
||||
```rust
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(health::live, health::ready, public::list_objects, public::get_object),
|
||||
components(schemas(health::Live, health::Ready, public::PublicView, public::PublicObjectPage)),
|
||||
info(title = "Collection Management System", version = "0.0.0")
|
||||
)]
|
||||
struct ApiDoc;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p api --test public` → PASS (5 tests). Re-run the existing `health` test too: `DATABASE_URL=<url> cargo test -p api` → all PASS.
|
||||
|
||||
- [ ] **Step 6: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace
|
||||
```
|
||||
Expected: all green. (`search` tests need the MEILI env vars; the rest need `DATABASE_URL`.)
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/api
|
||||
git commit -m "feat(api): public read API (PublicView projection, paginated list + get, OpenAPI)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (VISION "Publishing & public access" [MVP]; arch spec §7, §9, §14):**
|
||||
- Record-level visibility draft/internal/public with a type-driven state machine → Task 1 (`transition_to`/`IllegalTransition`). ✓
|
||||
- Fixed never-public field set; public API serves only public records via `PublicView` → Task 3 (`PublicView` carries only safe fields; db filters `visibility='public'`). ✓
|
||||
- Public surface `/api/public/**`, unauthenticated, read-only, OpenAPI (utoipa) → Task 3. ✓
|
||||
- All SQL stays in `db`; `api` calls repos → Tasks 2–3. ✓
|
||||
- Audited writes (visibility change in the amendment history) → Task 2 reuses `update_object`'s audit. ✓
|
||||
- 404 (not 403) for non-public → Task 3 handler + test. ✓
|
||||
|
||||
**Placeholder scan:** none. `<url>`/`<key>` are the documented env values.
|
||||
|
||||
**Type consistency:** `Visibility::{transition_to, can_transition_to}` + `IllegalTransition` defined in Task 1 and consumed in Tasks 2–3; `set_visibility`/`VisibilityError`/`public_object_by_id`/`list_public_objects`/`count_public_objects` defined in Task 2 and consumed by Task 3 handlers; `PublicView`/`PublicObjectPage`/`Pagination` defined and used consistently within Task 3; reuses existing `catalog::{create_object, object_by_id, update_object, OBJECT_COLUMNS, map_object}`, `audit::history_for`, `AppState`, `db.pool()`, and the axum `{id}` path convention.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- **Admin transition endpoint + auth:** the HTTP surface to *invoke* `set_visibility` (publish/unpublish) is a privileged write — it lands with the auth phase via an `Authorized<Cap>` extractor. `domain` may then add ergonomic `publish()`/`unpublish()` wrappers over `transition_to` (omitted now to avoid dead code).
|
||||
- **Required-field completeness on publish:** `set_object_fields` defers required-completeness to "the publish gate" (see `catalog.rs` doc comment). A future gate should validate that all `required` field definitions are present before allowing `→ Public`. **File a gitea follow-up.**
|
||||
- **On-write search sync:** when `set_visibility` / catalogue writes commit, the API/service layer should re-index (`index_object`) or drop from the index — relates to the Plan 6 deferred on-write sync.
|
||||
- **Per-field publishability (post-MVP):** replaces the core-only `PublicView` with a registry-driven projection that can surface selected flexible fields.
|
||||
- **Keyset pagination:** `list_public_objects` uses `LIMIT/OFFSET` (fine for MVP). Switch to keyset when collections grow (the same TODO already noted on `list_objects`).
|
||||
- **Public-facing search (post-MVP):** the `search` crate already stores `visibility` as filterable; add a `with_filter("visibility = public")` variant when public search is built.
|
||||
@@ -0,0 +1,464 @@
|
||||
# Search (Meilisearch) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A `search` crate that indexes catalogue objects (core + flexible fields, with term/authority values resolved to their labels) into Meilisearch and runs full-text search, plus a `reindex_all` rebuild. On-write sync orchestration is deferred to the API/service layer (Plan 7+); this plan builds the capability and `reindex_all`.
|
||||
|
||||
**Architecture:** A new role-named crate `search` depending on `db` + `domain` (cycle-free: `search → db → domain`). It exposes a `SearchClient` (Meilisearch adapter behind our own type, so the engine stays swappable), a `SearchDocument` (the indexed shape), `build_document` (reads `db` to resolve a `CatalogueObject`'s flexible fields to searchable text), and `reindex_all`. Search returns object ids; callers load full objects from `db`. `visibility` is a filterable attribute (for the future public API).
|
||||
|
||||
**Tech Stack:** Rust 2024, `meilisearch-sdk` (async client), `serde` (document), `thiserror` (SearchError), tokio. Tests run against a real Meilisearch (Docker) + Postgres.
|
||||
|
||||
## Design decisions (approved)
|
||||
- `search` crate: `SearchClient` wrapping `meilisearch-sdk`, swappable behind our type.
|
||||
- Index doc = core text + flexible values flattened to searchable text; **term/authority resolved to labels**; `localized_text` → all language strings; `visibility` filterable. Search returns object ids.
|
||||
- Build the capability + `reindex_all` now; **on-write sync is wired at the API/service layer (Plan 7+)**. Eventual consistency (Meili not transactional with Postgres).
|
||||
- Integration tests use a real Meilisearch in Docker, each test on a **unique index** for isolation.
|
||||
|
||||
## ⚠️ Implementer note on the Meilisearch SDK
|
||||
The `meilisearch-sdk` API (method names, async task handling) varies by version. The **code blocks below are the intended shape**; adapt the exact SDK calls to the installed version while preserving behavior. **The tests are the contract** — make them pass. Key behaviors: indexing operations must `wait_for_completion` (Meilisearch indexes asynchronously) so a subsequent search sees the document. Verify the current `meilisearch-sdk` version via the cratesio tooling and pin it.
|
||||
|
||||
## Prerequisites
|
||||
- Postgres (as before) AND a Meilisearch instance. The controller will start Meilisearch in Docker (e.g. `getmeili/meilisearch`) with a master key. Tests read `MEILI_URL` (e.g. `http://localhost:7700`) and `MEILI_MASTER_KEY`; pass them inline alongside `DATABASE_URL`. Pass transaction connections as `&mut tx`.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
Cargo.toml + search member; meilisearch-sdk in workspace deps
|
||||
crates/search/
|
||||
Cargo.toml
|
||||
src/lib.rs SearchError, SearchDocument, SearchClient, build_document, reindex_all
|
||||
tests/search.rs (Meili only) index/search/remove
|
||||
tests/reindex.rs (Meili + Postgres) build_document + reindex_all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `search` crate — client, document, index/search/remove
|
||||
|
||||
**Files:** modify root `Cargo.toml`; create `crates/search/Cargo.toml`, `crates/search/src/lib.rs`, `crates/search/tests/search.rs`.
|
||||
|
||||
- [ ] **Step 1: Workspace + crate setup.**
|
||||
- In root `Cargo.toml`, add `"crates/search"` to `members`, and add to `[workspace.dependencies]` (verify the latest version via cratesio):
|
||||
```toml
|
||||
meilisearch-sdk = "0.28"
|
||||
```
|
||||
- Create `crates/search/Cargo.toml`:
|
||||
```toml
|
||||
[package]
|
||||
name = "search"
|
||||
version = "0.0.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
meilisearch-sdk.workspace = true
|
||||
serde = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
db = { path = "../db" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `crates/search/tests/search.rs` (Meilisearch only — hand-built documents, no Postgres):
|
||||
```rust
|
||||
use search::{SearchClient, SearchDocument};
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("objects_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument {
|
||||
SearchDocument {
|
||||
id: id.to_string(),
|
||||
object_number: format!("N-{id}"),
|
||||
object_name: object_name.to_string(),
|
||||
brief_description: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
visibility: "draft".to_string(),
|
||||
fields_text: fields_text.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_search_and_remove() {
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
client.ensure_index().await.unwrap();
|
||||
|
||||
let vase = domain::ObjectId::new();
|
||||
let chair = domain::ObjectId::new();
|
||||
client.index_object(&doc(&vase.to_string(), "vase", &["wood", "trä"])).await.unwrap();
|
||||
client.index_object(&doc(&chair.to_string(), "chair", &["oak"])).await.unwrap();
|
||||
|
||||
// full-text on a flexible value
|
||||
let hits = client.search("wood").await.unwrap();
|
||||
assert_eq!(hits, vec![vase]);
|
||||
|
||||
// full-text on the object name
|
||||
let hits = client.search("chair").await.unwrap();
|
||||
assert_eq!(hits, vec![chair]);
|
||||
|
||||
// remove
|
||||
client.remove_object(vase).await.unwrap();
|
||||
assert!(client.search("wood").await.unwrap().is_empty());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails.** `MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test -p search --test search` → FAIL (crate/types missing).
|
||||
|
||||
- [ ] **Step 4: Implement** `crates/search/src/lib.rs` (adapt the SDK calls to the installed version; keep behavior + signatures):
|
||||
```rust
|
||||
//! Full-text search over catalogue objects, backed by Meilisearch.
|
||||
|
||||
use db::Db;
|
||||
use domain::{CatalogueObject, ObjectId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Errors from the search subsystem.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SearchError {
|
||||
#[error(transparent)]
|
||||
Meili(#[from] meilisearch_sdk::errors::Error),
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("invalid object id in index: {0}")]
|
||||
BadId(String),
|
||||
}
|
||||
|
||||
/// The indexed shape of a catalogue object.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchDocument {
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
/// Filterable: "draft" | "internal" | "public".
|
||||
pub visibility: String,
|
||||
/// Flexible field values flattened to searchable text (term/authority labels,
|
||||
/// localized strings, and scalar values).
|
||||
pub fields_text: Vec<String>,
|
||||
}
|
||||
|
||||
/// A Meilisearch-backed search client scoped to one index.
|
||||
pub struct SearchClient {
|
||||
client: meilisearch_sdk::client::Client,
|
||||
index_uid: String,
|
||||
}
|
||||
|
||||
impl SearchClient {
|
||||
/// Connect to Meilisearch at `url` with `api_key`, scoped to `index_uid`.
|
||||
pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result<Self, SearchError> {
|
||||
let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?;
|
||||
Ok(Self { client, index_uid: index_uid.to_owned() })
|
||||
}
|
||||
|
||||
/// Create the index (primary key "id") if absent and set filterable attributes.
|
||||
pub async fn ensure_index(&self) -> Result<(), SearchError> {
|
||||
// Create the index if it doesn't exist (ignore "index already exists").
|
||||
let task = self.client.create_index(&self.index_uid, Some("id")).await?;
|
||||
task.wait_for_completion(&self.client, None, None).await?;
|
||||
let index = self.client.index(&self.index_uid);
|
||||
index
|
||||
.set_filterable_attributes(["visibility"])
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert one object document (waits for indexing to complete).
|
||||
pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> {
|
||||
self.client
|
||||
.index(&self.index_uid)
|
||||
.add_or_replace_documents(std::slice::from_ref(doc), Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove one object from the index by id (waits for completion).
|
||||
pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> {
|
||||
self.client
|
||||
.index(&self.index_uid)
|
||||
.delete_document(id.to_string())
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full-text search; returns matching object ids (in Meilisearch ranking order).
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<ObjectId>, SearchError> {
|
||||
let results = self
|
||||
.client
|
||||
.index(&self.index_uid)
|
||||
.search()
|
||||
.with_query(query)
|
||||
.execute::<SearchDocument>()
|
||||
.await?;
|
||||
results
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| hit.result.id.parse::<ObjectId>().map_err(|_| SearchError::BadId(hit.result.id)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuild the whole index from the database (clears then re-adds all objects).
|
||||
pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> {
|
||||
let index = self.client.index(&self.index_uid);
|
||||
index.delete_all_documents().await?.wait_for_completion(&self.client, None, None).await?;
|
||||
|
||||
let objects = db::catalog::list_objects(db.pool()).await?;
|
||||
let mut docs = Vec::with_capacity(objects.len());
|
||||
for object in &objects {
|
||||
docs.push(build_document(db, object).await?);
|
||||
}
|
||||
if !docs.is_empty() {
|
||||
index
|
||||
.add_or_replace_documents(&docs, Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`SearchDocument`] from an object, resolving its flexible fields to
|
||||
/// searchable text (term/authority → labels, localized text → all values).
|
||||
/// Implemented in Task 2; declared here so the crate compiles.
|
||||
pub async fn build_document(
|
||||
_db: &Db,
|
||||
_object: &CatalogueObject,
|
||||
) -> Result<SearchDocument, SearchError> {
|
||||
unimplemented!("implemented in Task 2")
|
||||
}
|
||||
```
|
||||
NOTE: `ObjectId: FromStr` (Err = `uuid::Error`) exists from the id macro. `reindex_all`/`build_document` are needed for compilation now (Task 1 test doesn't call them) — `build_document` is a stub `unimplemented!()` filled in Task 2. If clippy flags the stub's unused params, the leading underscores suppress that; if it flags `unimplemented!` in a non-test fn, add `#[allow(clippy::unimplemented)]` to `build_document` with a `// Task 2` note, OR move `reindex_all`+`build_document` entirely into Task 2 (preferred if it keeps Task 1 clippy-clean — in that case omit them here and add `pub mod`-level items in Task 2).
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes.** `MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test -p search --test search` → PASS. (You may need to adapt SDK calls; iterate until the test passes.)
|
||||
|
||||
- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `cargo clippy -p search --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add Cargo.toml crates/search
|
||||
git commit -m "feat(search): add Meilisearch-backed SearchClient (index, search, remove)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `build_document` + `reindex_all` (db integration)
|
||||
|
||||
**Files:** modify `crates/search/src/lib.rs`; create `crates/search/tests/reindex.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/search/tests/reindex.rs` (Meilisearch + Postgres):
|
||||
```rust
|
||||
use db::{Db, catalog, fields, vocab};
|
||||
use domain::{
|
||||
AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, Visibility,
|
||||
};
|
||||
use search::SearchClient;
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn meili() -> (String, String) {
|
||||
(
|
||||
std::env::var("MEILI_URL").expect("MEILI_URL must be set"),
|
||||
std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
fn unique_index() -> String {
|
||||
format!("reindex_test_{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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").await.unwrap();
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let wood = vocab::add_term(
|
||||
&mut tx,
|
||||
&NewTerm {
|
||||
vocabulary_id: material.id,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel { lang: "en".into(), label: "wood".into() }],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
fields::create_field_definition(
|
||||
&mut tx,
|
||||
&NewFieldDefinition {
|
||||
key: "material".into(),
|
||||
field_type: FieldType::Term { vocabulary_id: material.id },
|
||||
required: false,
|
||||
group_key: None,
|
||||
labels: vec![LocalizedLabel { lang: "en".into(), label: "material".into() }],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let object_id = catalog::create_object(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Public,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
// set the material field to the wood term
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::set_object_fields(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
object_id,
|
||||
serde_json::json!({ "material": wood.to_string() }).as_object().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let (url, key) = meili();
|
||||
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
|
||||
client.ensure_index().await.unwrap();
|
||||
client.reindex_all(&db).await.unwrap();
|
||||
|
||||
// found by the object name
|
||||
assert_eq!(client.search("vase").await.unwrap(), vec![object_id]);
|
||||
// found by the resolved TERM LABEL (not the uuid)
|
||||
assert_eq!(client.search("wood").await.unwrap(), vec![object_id]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** With both env vars + `DATABASE_URL`: `... cargo test -p search --test reindex` → FAIL (`build_document` is `unimplemented!`).
|
||||
|
||||
- [ ] **Step 3: Implement `build_document`** in `crates/search/src/lib.rs` — replace the stub body with a real implementation that flattens the object's flexible fields to searchable text, resolving term/authority values to labels:
|
||||
```rust
|
||||
pub async fn build_document(
|
||||
db: &Db,
|
||||
object: &CatalogueObject,
|
||||
) -> Result<SearchDocument, SearchError> {
|
||||
let mut fields_text = Vec::new();
|
||||
|
||||
if let Some(map) = object.fields.as_object() {
|
||||
for (key, value) in map {
|
||||
let Some(def) = db::fields::field_definition_by_key(db.pool(), key).await? else {
|
||||
continue; // a field with no definition (stale) — skip
|
||||
};
|
||||
match def.field_type {
|
||||
domain::FieldType::Text | domain::FieldType::Date => {
|
||||
if let Some(s) = value.as_str() {
|
||||
fields_text.push(s.to_owned());
|
||||
}
|
||||
}
|
||||
domain::FieldType::Integer | domain::FieldType::Boolean => {
|
||||
fields_text.push(value.to_string());
|
||||
}
|
||||
domain::FieldType::LocalizedText => {
|
||||
if let Some(obj) = value.as_object() {
|
||||
for v in obj.values() {
|
||||
if let Some(s) = v.as_str() {
|
||||
fields_text.push(s.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
domain::FieldType::Term { .. } => {
|
||||
if let Some(term_id) = value.as_str().and_then(|s| s.parse().ok()) {
|
||||
if let Some(term) = db::vocab::term_by_id(db.pool(), term_id).await? {
|
||||
fields_text.extend(term.labels.into_iter().map(|l| l.label));
|
||||
}
|
||||
}
|
||||
}
|
||||
domain::FieldType::Authority { .. } => {
|
||||
if let Some(authority_id) = value.as_str().and_then(|s| s.parse().ok()) {
|
||||
if let Some(authority) =
|
||||
db::authority::authority_by_id(db.pool(), authority_id).await?
|
||||
{
|
||||
fields_text.extend(authority.labels.into_iter().map(|l| l.label));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchDocument {
|
||||
id: object.id.to_string(),
|
||||
object_number: object.object_number.clone(),
|
||||
object_name: object.object_name.clone(),
|
||||
brief_description: object.brief_description.clone(),
|
||||
current_owner: object.current_owner.clone(),
|
||||
recorder: object.recorder.clone(),
|
||||
visibility: object.visibility.as_str().to_owned(),
|
||||
fields_text,
|
||||
})
|
||||
}
|
||||
```
|
||||
(`db::vocab::term_by_id` takes a `TermId`; `db::authority::authority_by_id` takes an `AuthorityId` — `value.as_str().and_then(|s| s.parse().ok())` parses into the inferred id type. If type inference needs help, annotate: `let term_id: domain::TermId = ...`.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `MEILI_URL=<url> MEILI_MASTER_KEY=<key> DATABASE_URL=<url> cargo test -p search --test reindex` → PASS.
|
||||
|
||||
- [ ] **Step 5: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> MEILI_URL=<url> MEILI_MASTER_KEY=<key> cargo test --workspace
|
||||
```
|
||||
Expected: all green. (The `search` tests need the MEILI env vars; the rest need `DATABASE_URL`.)
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/search
|
||||
git commit -m "feat(search): build documents resolving term/authority labels; reindex_all"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (Plan 6 / VISION search MVP):**
|
||||
- `search` crate, Meilisearch adapter behind `SearchClient`, swappable → Task 1. ✓
|
||||
- Index core + flexible text; term/authority resolved to labels; localized → all values; visibility filterable; search returns object ids → Tasks 1–2. ✓
|
||||
- Build capability + `reindex_all` now; on-write sync deferred to API/service → this plan + notes. ✓
|
||||
- `search → db → domain` (no cycle); SQL stays in `db` (search calls db repos) → Cargo deps. ✓
|
||||
- Real-Meili integration tests, unique index per test → Tasks 1–2. ✓
|
||||
|
||||
**Placeholder scan:** the only `unimplemented!` is the Task 1 `build_document` stub, explicitly filled in Task 2 (with a fallback instruction). `<url>`/`<key>` are documented env values. No other placeholders.
|
||||
|
||||
**Type consistency:** `SearchDocument` fields used identically in tests + `build_document`; `SearchClient::{connect, ensure_index, index_object, remove_object, search, reindex_all}` signatures consistent across tasks/tests; `search` returns `Vec<ObjectId>` parsed via `ObjectId: FromStr`; `build_document` matches on `domain::FieldType` (Plan 4) and calls `db::vocab::term_by_id`/`db::authority::authority_by_id`/`db::fields::field_definition_by_key`/`db::catalog::list_objects` as defined.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- **On-write sync (API/service, Plan 7+):** after a catalogue create/update/delete/set_fields commits, call `index_object`/`remove_object` best-effort (log failures; `reindex_all` is the recovery path). Meili is not transactional with Postgres — eventual consistency.
|
||||
- **Public API (Plan 7):** `search` already stores `visibility` as filterable; add a `with_filter("visibility = public")` search variant for the public surface.
|
||||
- **Per-deployment index/credentials:** production uses a fixed index uid (e.g. `objects`) with a scoped Meili key per the single-tenant deployment; only tests use unique index names.
|
||||
- **Reindex cost:** `reindex_all` is N+1 over objects×fields (resolves labels per field) — fine for now; batch when collections grow (relates to #12).
|
||||
@@ -0,0 +1,223 @@
|
||||
# Spectrum Cataloguing Seed Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Seed a representative subset of the Spectrum Cataloguing field set — empty controlled vocabularies + the descriptive field definitions that bind to them and to authorities — turning the abstract registry (Plans 2/4) into usable museum fields. Idempotent; no terms seeded (orgs/imports populate vocabularies later).
|
||||
|
||||
**Architecture:** A new `db::seed` module with `seed_spectrum_cataloguing(&mut PgConnection)`: get-or-create the vocabularies by key, then get-or-create each field definition by key (using the vocabularies' ids for `Term`-bound fields). Built entirely on the existing `db::vocab`/`db::fields` repositories. No migration, no domain changes. Invoking the seed (CLI / server flag / per-org provisioning) is a deferred follow-on.
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8. Tests use `#[sqlx::test]`.
|
||||
|
||||
## Design decisions (approved)
|
||||
- Representative subset (~12 descriptive fields + 3 vocabularies), not all ~90 Spectrum units; the inventory minimum stays in the typed core (Plan 3).
|
||||
- Seed empty vocabularies + the field definitions only — not terms.
|
||||
- Idempotent (get-or-create by unique key); safe to re-run.
|
||||
- Wiring (how/when the seed runs) deferred.
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests; pass `DATABASE_URL` inline. Pass transaction connections as `&mut tx` (NOT `&mut *tx`).
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/db/
|
||||
src/seed.rs seed_spectrum_cataloguing + helpers
|
||||
src/lib.rs pub mod seed;
|
||||
tests/seed.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `db::seed` — Spectrum cataloguing seed
|
||||
|
||||
**Files:** create `crates/db/src/seed.rs`, `crates/db/tests/seed.rs`; modify `crates/db/src/lib.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/seed.rs`:
|
||||
```rust
|
||||
use db::{Db, fields, seed, vocab};
|
||||
use domain::{AuthorityKind, FieldType};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn seed_creates_vocabularies_and_field_definitions(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
for key in ["material", "object_name", "technique"] {
|
||||
assert!(
|
||||
vocab::vocabulary_by_key(db.pool(), key).await.unwrap().is_some(),
|
||||
"vocabulary {key} should be seeded"
|
||||
);
|
||||
}
|
||||
|
||||
// a Term field is bound to the right vocabulary
|
||||
let material_vocab = vocab::vocabulary_by_key(db.pool(), "material").await.unwrap().unwrap();
|
||||
let material_field = fields::field_definition_by_key(db.pool(), "material").await.unwrap().unwrap();
|
||||
assert_eq!(material_field.field_type, FieldType::Term { vocabulary_id: material_vocab.id });
|
||||
|
||||
// an Authority field carries its kind
|
||||
let place = fields::field_definition_by_key(db.pool(), "production_place").await.unwrap().unwrap();
|
||||
assert_eq!(place.field_type, FieldType::Authority { kind: Some(AuthorityKind::Place) });
|
||||
|
||||
// a localized-text and a date field exist
|
||||
let title = fields::field_definition_by_key(db.pool(), "title").await.unwrap().unwrap();
|
||||
assert_eq!(title.field_type, FieldType::LocalizedText);
|
||||
let date = fields::field_definition_by_key(db.pool(), "production_date").await.unwrap().unwrap();
|
||||
assert_eq!(date.field_type, FieldType::Date);
|
||||
|
||||
assert_eq!(fields::list_field_definitions(db.pool()).await.unwrap().len(), 12);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn seed_is_idempotent(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
for _ in 0..2 {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
seed::seed_spectrum_cataloguing(&mut tx).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
|
||||
// re-running did not duplicate (would have hit the UNIQUE key constraints otherwise)
|
||||
assert_eq!(fields::list_field_definitions(db.pool()).await.unwrap().len(), 12);
|
||||
let materials = vocab::vocabulary_by_key(db.pool(), "material").await.unwrap();
|
||||
assert!(materials.is_some());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test seed` → FAIL (`db::seed` missing).
|
||||
|
||||
- [ ] **Step 3: Implement** `crates/db/src/seed.rs`:
|
||||
```rust
|
||||
//! Seed data: a representative subset of the Spectrum Cataloguing field set.
|
||||
//!
|
||||
//! Idempotent — each vocabulary and field definition is created only if a row with
|
||||
//! that key does not already exist. Vocabularies are seeded empty; their terms are
|
||||
//! 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 crate::{fields, vocab};
|
||||
|
||||
/// Seed the Spectrum cataloguing vocabularies and field definitions on `conn`.
|
||||
/// Pass a transaction connection (`&mut *tx`) so the whole seed is atomic.
|
||||
pub async fn seed_spectrum_cataloguing(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
|
||||
let material = ensure_vocabulary(conn, "material").await?;
|
||||
let object_name = ensure_vocabulary(conn, "object_name").await?;
|
||||
let technique = ensure_vocabulary(conn, "technique").await?;
|
||||
|
||||
let definitions = [
|
||||
def("object_type", FieldType::Term { vocabulary_id: object_name }, "identification",
|
||||
&[("sv", "Sakord"), ("en", "Object type")]),
|
||||
def("title", FieldType::LocalizedText, "identification",
|
||||
&[("sv", "Titel"), ("en", "Title")]),
|
||||
def("comments", FieldType::Text, "identification",
|
||||
&[("sv", "Kommentarer"), ("en", "Comments")]),
|
||||
def("material", FieldType::Term { vocabulary_id: material }, "description",
|
||||
&[("sv", "Material"), ("en", "Material")]),
|
||||
def("technique", FieldType::Term { vocabulary_id: technique }, "description",
|
||||
&[("sv", "Teknik"), ("en", "Technique")]),
|
||||
def("physical_description", FieldType::Text, "description",
|
||||
&[("sv", "Fysisk beskrivning"), ("en", "Physical description")]),
|
||||
def("dimensions", FieldType::Text, "description",
|
||||
&[("sv", "Mått"), ("en", "Dimensions")]),
|
||||
def("inscription", FieldType::Text, "description",
|
||||
&[("sv", "Inskription"), ("en", "Inscription")]),
|
||||
def("content_description", FieldType::Text, "content",
|
||||
&[("sv", "Innehållsbeskrivning"), ("en", "Content description")]),
|
||||
def("production_date", FieldType::Date, "production",
|
||||
&[("sv", "Tillverkningsdatum"), ("en", "Production date")]),
|
||||
def("production_place", FieldType::Authority { kind: Some(AuthorityKind::Place) }, "production",
|
||||
&[("sv", "Tillverkningsplats"), ("en", "Production place")]),
|
||||
def("production_person", FieldType::Authority { kind: Some(AuthorityKind::Person) }, "production",
|
||||
&[("sv", "Tillverkare"), ("en", "Producer")]),
|
||||
];
|
||||
|
||||
for definition in &definitions {
|
||||
ensure_field_definition(conn, definition).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get-or-create a vocabulary by key, returning its id.
|
||||
async fn ensure_vocabulary(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
key: &str,
|
||||
) -> Result<VocabularyId, sqlx::Error> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a field definition only if its key is not already present.
|
||||
async fn ensure_field_definition(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
definition: &NewFieldDefinition,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
if fields::field_definition_by_key(&mut *conn, &definition.key).await?.is_none() {
|
||||
fields::create_field_definition(&mut *conn, definition).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn def(
|
||||
key: &str,
|
||||
field_type: FieldType,
|
||||
group: &str,
|
||||
label_pairs: &[(&str, &str)],
|
||||
) -> NewFieldDefinition {
|
||||
NewFieldDefinition {
|
||||
key: key.to_owned(),
|
||||
field_type,
|
||||
required: false,
|
||||
group_key: Some(group.to_owned()),
|
||||
labels: label_pairs
|
||||
.iter()
|
||||
.map(|(lang, label)| LocalizedLabel { lang: (*lang).to_owned(), label: (*label).to_owned() })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
```
|
||||
Add to `crates/db/src/lib.rs` (top-level): `pub mod seed;`
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test seed` → PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): seed a representative Spectrum cataloguing field set (idempotent)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage:**
|
||||
- Representative Spectrum descriptive field set as vocabularies + field definitions → the `definitions` array + `ensure_*`. ✓
|
||||
- Empty vocabularies, no terms; inventory minimum stays in the core. ✓
|
||||
- Idempotent (get-or-create by key) → `ensure_vocabulary`/`ensure_field_definition`; tested by `seed_is_idempotent`. ✓
|
||||
- Built on existing repos; no migration/domain change; SQL stays in `db`. ✓
|
||||
- Wiring deferred. ✓ (intentional)
|
||||
|
||||
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`.
|
||||
|
||||
**Type consistency:** `seed_spectrum_cataloguing(&mut PgConnection) -> Result<(), sqlx::Error>`; uses `vocab::vocabulary_by_key`/`create_vocabulary`, `fields::field_definition_by_key`/`create_field_definition`, and `domain::{FieldType, NewFieldDefinition, LocalizedLabel, AuthorityKind, VocabularyId}` exactly as defined. The test's expected count (12) matches the `definitions` array length.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- **Wiring the seed:** options are a server `--seed`/config flag at startup, a small CLI subcommand, or running it as part of per-org provisioning (the control plane). Decide alongside the provisioning work.
|
||||
- **Populating vocabulary terms:** Getty AAT / KulturNav / Wikidata import (VISION post-MVP) fills the empty `material`/`object_name`/`technique` vocabularies.
|
||||
- The seeded set is a starting point — extend toward the full Spectrum unit list (`reference/spectrum-5.0-cataloguing-units-of-information.md`) as needed.
|
||||
@@ -0,0 +1,866 @@
|
||||
# Vocabularies & Authorities Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the "store once, link many" subsystem — controlled vocabularies (term sources) and person/organisation/place authority records, both with multilingual labels — that the catalogue core will reference (`docs/specs/2026-06-02-mvp-architecture.md` §6.3).
|
||||
|
||||
**Architecture:** Value types and validated reference types in `domain` (pure). The `db` crate owns the tables (migration 0002) and two repositories (`db::vocab`, `db::authority`). Multilingual labels are normalized into per-entity label tables, read back via a single `json_agg` query. Reference types `TermRef`/`AuthorityRef` are produced by `db` resolve functions; hard referential integrity arrives when the catalogue FK-references terms/authorities (Plan 4). No HTTP surface yet.
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8 (Postgres, `time`+`json` features already enabled), `serde_json` for the aggregated-label payload. Tests use `#[sqlx::test]`.
|
||||
|
||||
## Design decisions (approved)
|
||||
- **Unified `authority` table** with `kind ∈ {person, organisation, place}` (one FK target; kind-specific fields later).
|
||||
- **Normalized per-entity label tables** (`term_label`, `authority_label`) keyed `(id, lang)`; display resolved as requested-lang → fallback → first.
|
||||
- **`TermRef`/`AuthorityRef`** validated newtypes produced by `db` resolve functions; FK integrity comes in Plan 4.
|
||||
- App-generated UUID ids (matches `OrgId`). A `id_newtype!` macro removes the per-id boilerplate (DRYs `OrgId` + the three new ids).
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline on every test/clippy command (e.g. `postgres://postgres:postgres@localhost:5433/cms_dev`). Shell env does not persist between commands.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/domain/
|
||||
src/id.rs id_newtype! macro + OrgId, VocabularyId, TermId, AuthorityId
|
||||
src/label.rs LocalizedLabel + pick_label
|
||||
src/vocabulary.rs Vocabulary, Term, NewTerm, TermRef
|
||||
src/authority.rs AuthorityKind, Authority, NewAuthority, AuthorityRef
|
||||
src/lib.rs re-exports
|
||||
crates/db/
|
||||
migrations/0002_vocabularies_authorities.sql
|
||||
src/vocab.rs create_vocabulary, vocabulary_by_key, add_term, term_by_id, list_terms, resolve_term
|
||||
src/authority.rs create_authority, authority_by_id, list_by_kind, resolve_authority
|
||||
src/lib.rs pub mod vocab; pub mod authority;
|
||||
tests/vocab.rs
|
||||
tests/authority.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — id macro, labels, vocabulary & authority types
|
||||
|
||||
**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/label.rs`, `crates/domain/src/vocabulary.rs`, `crates/domain/src/authority.rs`.
|
||||
|
||||
- [ ] **Step 1: Replace `crates/domain/src/id.rs`** with a macro + the four ids (keeps the existing OrgId behavior/tests):
|
||||
```rust
|
||||
//! Strongly-typed identifiers.
|
||||
|
||||
/// Define a UUID newtype identifier with the standard constructors and conversions.
|
||||
macro_rules! id_newtype {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(uuid::Uuid);
|
||||
|
||||
impl $name {
|
||||
/// Generate a fresh random id.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Wrap an existing [`uuid::Uuid`].
|
||||
pub fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// The underlying [`uuid::Uuid`].
|
||||
pub fn to_uuid(&self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for $name {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(uuid::Uuid::parse_str(s)?))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
id_newtype!(
|
||||
/// Identifier for an organization (tenant).
|
||||
OrgId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a controlled vocabulary (term source).
|
||||
VocabularyId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a term within a vocabulary.
|
||||
TermId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for an authority record (person, organisation, or place).
|
||||
AuthorityId
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_displays_round_trip() {
|
||||
let text = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let id: OrgId = text.parse().expect("valid uuid should parse");
|
||||
assert_eq!(id.to_string(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_uuid() {
|
||||
assert!("not-a-uuid".parse::<OrgId>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_id_types_parse_independently() {
|
||||
let text = "550e8400-e29b-41d4-a716-446655440000";
|
||||
assert_eq!(text.parse::<VocabularyId>().unwrap().to_string(), text);
|
||||
assert_eq!(text.parse::<TermId>().unwrap().to_string(), text);
|
||||
assert_eq!(text.parse::<AuthorityId>().unwrap().to_string(), text);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `crates/domain/src/label.rs`:**
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A label in a specific language (BCP-47 tag, e.g. "sv", "en").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedLabel {
|
||||
pub lang: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Pick the best label for `lang`, falling back to `fallback`, then the first.
|
||||
pub fn pick_label<'a>(labels: &'a [LocalizedLabel], lang: &str, fallback: &str) -> Option<&'a str> {
|
||||
labels
|
||||
.iter()
|
||||
.find(|l| l.lang == lang)
|
||||
.or_else(|| labels.iter().find(|l| l.lang == fallback))
|
||||
.or_else(|| labels.first())
|
||||
.map(|l| l.label.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Vec<LocalizedLabel> {
|
||||
vec![
|
||||
LocalizedLabel { lang: "sv".into(), label: "trä".into() },
|
||||
LocalizedLabel { lang: "en".into(), label: "wood".into() },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_requested_language() {
|
||||
assert_eq!(pick_label(&sample(), "sv", "en"), Some("trä"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_then_first() {
|
||||
assert_eq!(pick_label(&sample(), "de", "en"), Some("wood"));
|
||||
assert_eq!(pick_label(&sample(), "de", "fr"), Some("trä"));
|
||||
assert_eq!(pick_label(&[], "sv", "en"), None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `crates/domain/src/vocabulary.rs`:**
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{LocalizedLabel, TermId, VocabularyId};
|
||||
|
||||
/// A controlled vocabulary (term source), e.g. "material" or "object_name".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Vocabulary {
|
||||
pub id: VocabularyId,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// A term within a vocabulary, with its multilingual labels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Term {
|
||||
pub id: TermId,
|
||||
pub vocabulary_id: VocabularyId,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A term to be created.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewTerm {
|
||||
pub vocabulary_id: VocabularyId,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A reference to a term confirmed to exist in a given vocabulary.
|
||||
///
|
||||
/// Obtain via `db::vocab::resolve_term`; do not construct ad hoc for
|
||||
/// values that haven't been resolved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TermRef {
|
||||
term_id: TermId,
|
||||
vocabulary_id: VocabularyId,
|
||||
}
|
||||
|
||||
impl TermRef {
|
||||
pub fn new(term_id: TermId, vocabulary_id: VocabularyId) -> Self {
|
||||
Self { term_id, vocabulary_id }
|
||||
}
|
||||
pub fn term_id(&self) -> TermId {
|
||||
self.term_id
|
||||
}
|
||||
pub fn vocabulary_id(&self) -> VocabularyId {
|
||||
self.vocabulary_id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `crates/domain/src/authority.rs`:**
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AuthorityId, LocalizedLabel};
|
||||
|
||||
/// The kind of authority record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthorityKind {
|
||||
Person,
|
||||
Organisation,
|
||||
Place,
|
||||
}
|
||||
|
||||
impl AuthorityKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AuthorityKind::Person => "person",
|
||||
AuthorityKind::Organisation => "organisation",
|
||||
AuthorityKind::Place => "place",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"person" => Some(AuthorityKind::Person),
|
||||
"organisation" => Some(AuthorityKind::Organisation),
|
||||
"place" => Some(AuthorityKind::Place),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An authority record (person / organisation / place), with multilingual labels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Authority {
|
||||
pub id: AuthorityId,
|
||||
pub kind: AuthorityKind,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// An authority to be created.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewAuthority {
|
||||
pub kind: AuthorityKind,
|
||||
pub external_uri: Option<String>,
|
||||
pub labels: Vec<LocalizedLabel>,
|
||||
}
|
||||
|
||||
/// A reference to an authority confirmed to exist (carries its kind).
|
||||
///
|
||||
/// Obtain via `db::authority::resolve_authority`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthorityRef {
|
||||
authority_id: AuthorityId,
|
||||
kind: AuthorityKind,
|
||||
}
|
||||
|
||||
impl AuthorityRef {
|
||||
pub fn new(authority_id: AuthorityId, kind: AuthorityKind) -> Self {
|
||||
Self { authority_id, kind }
|
||||
}
|
||||
pub fn authority_id(&self) -> AuthorityId {
|
||||
self.authority_id
|
||||
}
|
||||
pub fn kind(&self) -> AuthorityKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kind_round_trips_via_db_string() {
|
||||
for k in [AuthorityKind::Person, AuthorityKind::Organisation, AuthorityKind::Place] {
|
||||
assert_eq!(AuthorityKind::from_db(k.as_str()), Some(k));
|
||||
}
|
||||
assert_eq!(AuthorityKind::from_db("ufo"), None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `crates/domain/src/lib.rs`** — keep existing `mod audit;`/`mod id;` lines and their re-exports; add the new modules and re-exports. The full module/re-export block should be:
|
||||
```rust
|
||||
mod audit;
|
||||
mod authority;
|
||||
mod id;
|
||||
mod label;
|
||||
mod vocabulary;
|
||||
|
||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
||||
pub use id::{AuthorityId, OrgId, TermId, VocabularyId};
|
||||
pub use label::{LocalizedLabel, pick_label};
|
||||
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
|
||||
```
|
||||
(Keep the crate-level `//!` doc comment at the top.)
|
||||
|
||||
- [ ] **Step 6: Test + lint.** `cargo test -p domain` → all pass (existing audit/id tests + the new label/authority/id tests). `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/domain
|
||||
git commit -m "feat(domain): id macro + vocabulary/authority/label value types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `db` migration — vocabularies, terms, authorities, labels
|
||||
|
||||
**Files:** create `crates/db/migrations/0002_vocabularies_authorities.sql`; test `crates/db/tests/migrate.rs` (extend).
|
||||
|
||||
- [ ] **Step 1: Create `crates/db/migrations/0002_vocabularies_authorities.sql`:**
|
||||
```sql
|
||||
-- Controlled vocabularies (term sources) and their terms.
|
||||
CREATE TABLE vocabulary (
|
||||
id UUID PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE -- e.g. 'material', 'object_name'
|
||||
);
|
||||
|
||||
CREATE TABLE term (
|
||||
id UUID PRIMARY KEY,
|
||||
vocabulary_id UUID NOT NULL REFERENCES vocabulary (id) ON DELETE CASCADE,
|
||||
external_uri TEXT -- e.g. Getty AAT / KulturNav / Wikidata URI
|
||||
);
|
||||
CREATE INDEX term_vocabulary_idx ON term (vocabulary_id);
|
||||
|
||||
CREATE TABLE term_label (
|
||||
term_id UUID NOT NULL REFERENCES term (id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL, -- BCP-47, e.g. 'sv', 'en'
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (term_id, lang)
|
||||
);
|
||||
|
||||
-- Authority records: person / organisation / place. Store once, link many.
|
||||
CREATE TABLE authority (
|
||||
id UUID PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('person', 'organisation', 'place')),
|
||||
external_uri TEXT
|
||||
);
|
||||
CREATE INDEX authority_kind_idx ON authority (kind);
|
||||
|
||||
CREATE TABLE authority_label (
|
||||
authority_id UUID NOT NULL REFERENCES authority (id) ON DELETE CASCADE,
|
||||
lang TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (authority_id, lang)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the migrate test** — add to `crates/db/tests/migrate.rs` a check that the new tables exist (append this test):
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_vocabulary_and_authority_tables(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
for table in ["vocabulary", "term", "term_label", "authority", "authority_label"] {
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar(&format!("SELECT to_regclass('public.{table}')::text"))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(regclass.as_deref(), Some(table), "table {table} should exist");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate` → 2 tests pass. `cargo +nightly fmt`; clippy clean.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add crates/db/migrations crates/db/tests/migrate.rs
|
||||
git commit -m "feat(db): add vocabulary, term, and authority tables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `db::vocab` repository
|
||||
|
||||
**Files:** create `crates/db/src/vocab.rs`; modify `crates/db/src/lib.rs`; test `crates/db/tests/vocab.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/vocab.rs`:
|
||||
```rust
|
||||
use db::{Db, vocab};
|
||||
use domain::{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").await.unwrap();
|
||||
let found = vocab::vocabulary_by_key(db.pool(), "material")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(found.id, v.id);
|
||||
assert_eq!(found.key, "material");
|
||||
assert!(vocab::vocabulary_by_key(db.pool(), "nope").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[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").await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut *tx,
|
||||
&NewTerm {
|
||||
vocabulary_id: v.id,
|
||||
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
|
||||
labels: vec![
|
||||
LocalizedLabel { lang: "sv".into(), label: "trä".into() },
|
||||
LocalizedLabel { lang: "en".into(), label: "wood".into() },
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let term = vocab::term_by_id(db.pool(), term_id).await.unwrap().unwrap();
|
||||
assert_eq!(term.vocabulary_id, v.id);
|
||||
assert_eq!(
|
||||
term.external_uri.as_deref(),
|
||||
Some("http://vocab.getty.edu/aat/300011914")
|
||||
);
|
||||
assert_eq!(term.labels.len(), 2);
|
||||
assert_eq!(domain::pick_label(&term.labels, "sv", "en"), Some("trä"));
|
||||
assert_eq!(domain::pick_label(&term.labels, "de", "en"), Some("wood"));
|
||||
|
||||
let listed = vocab::list_terms(db.pool(), v.id).await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, term_id);
|
||||
}
|
||||
|
||||
#[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").await.unwrap();
|
||||
let technique = vocab::create_vocabulary(db.pool(), "technique").await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut *tx,
|
||||
&NewTerm {
|
||||
vocabulary_id: material.id,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel { lang: "en".into(), label: "wood".into() }],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert!(vocab::resolve_term(db.pool(), material.id, term_id).await.unwrap().is_some());
|
||||
assert!(vocab::resolve_term(db.pool(), technique.id, term_id).await.unwrap().is_none());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test vocab` → FAIL (`db::vocab` missing).
|
||||
|
||||
- [ ] **Step 3: Implement** `crates/db/src/vocab.rs`:
|
||||
```rust
|
||||
//! Controlled vocabularies and terms.
|
||||
|
||||
use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId};
|
||||
use sqlx::Row;
|
||||
|
||||
/// Labels aggregated per row as JSON, to read a term/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>,
|
||||
{
|
||||
let id = VocabularyId::new();
|
||||
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(key)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
Ok(Vocabulary { id, key: key.to_owned() })
|
||||
}
|
||||
|
||||
/// Look up a vocabulary by its key.
|
||||
pub async fn vocabulary_by_key<'e, E>(
|
||||
executor: E,
|
||||
key: &str,
|
||||
) -> Result<Option<Vocabulary>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let row = sqlx::query("SELECT id, key FROM vocabulary WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
Ok(row.map(|r| Vocabulary {
|
||||
id: VocabularyId::from_uuid(r.get("id")),
|
||||
key: r.get("key"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let id = TermId::new();
|
||||
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(new.vocabulary_id.to_uuid())
|
||||
.bind(new.external_uri.as_deref())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for label in &new.labels {
|
||||
sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Fetch one term (with its labels).
|
||||
pub async fn term_by_id<'e, E>(executor: E, id: TermId) -> Result<Option<Term>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT t.id, t.vocabulary_id, t.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM term t LEFT JOIN term_label tl ON tl.term_id = t.id \
|
||||
WHERE t.id = $1 GROUP BY t.id"
|
||||
);
|
||||
let row = sqlx::query(&sql).bind(id.to_uuid()).fetch_optional(executor).await?;
|
||||
row.map(map_term).transpose()
|
||||
}
|
||||
|
||||
/// List all terms in a vocabulary (with labels), ordered by id.
|
||||
pub async fn list_terms<'e, E>(
|
||||
executor: E,
|
||||
vocabulary_id: VocabularyId,
|
||||
) -> Result<Vec<Term>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT t.id, t.vocabulary_id, t.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM term t LEFT JOIN term_label tl ON tl.term_id = t.id \
|
||||
WHERE t.vocabulary_id = $1 GROUP BY t.id ORDER BY t.id"
|
||||
);
|
||||
let rows = sqlx::query(&sql).bind(vocabulary_id.to_uuid()).fetch_all(executor).await?;
|
||||
rows.into_iter().map(map_term).collect()
|
||||
}
|
||||
|
||||
/// Resolve a term to a [`TermRef`], confirming it belongs to `vocabulary_id`.
|
||||
pub async fn resolve_term<'e, E>(
|
||||
executor: E,
|
||||
vocabulary_id: VocabularyId,
|
||||
term_id: TermId,
|
||||
) -> Result<Option<TermRef>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let found = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2",
|
||||
)
|
||||
.bind(term_id.to_uuid())
|
||||
.bind(vocabulary_id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
|
||||
}
|
||||
|
||||
fn map_term(row: sqlx::postgres::PgRow) -> Result<Term, sqlx::Error> {
|
||||
let labels: sqlx::types::Json<Vec<LocalizedLabel>> = row.try_get("labels")?;
|
||||
Ok(Term {
|
||||
id: TermId::from_uuid(row.try_get("id")?),
|
||||
vocabulary_id: VocabularyId::from_uuid(row.try_get("vocabulary_id")?),
|
||||
external_uri: row.try_get("external_uri")?,
|
||||
labels: labels.0,
|
||||
})
|
||||
}
|
||||
```
|
||||
Add to `crates/db/src/lib.rs` (top-level): `pub mod vocab;`
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test vocab` → PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add vocabulary/term repository with multilingual labels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `db::authority` repository
|
||||
|
||||
**Files:** create `crates/db/src/authority.rs`; modify `crates/db/src/lib.rs`; test `crates/db/tests/authority.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/authority.rs`:
|
||||
```rust
|
||||
use db::{Db, authority};
|
||||
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
||||
NewAuthority {
|
||||
kind: AuthorityKind::Person,
|
||||
external_uri: None,
|
||||
labels: vec![
|
||||
LocalizedLabel { lang: "sv".into(), label: name_sv.into() },
|
||||
LocalizedLabel { lang: "en".into(), label: name_en.into() },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let got = authority::authority_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(got.id, id);
|
||||
assert_eq!(got.kind, AuthorityKind::Person);
|
||||
assert_eq!(got.labels.len(), 2);
|
||||
assert_eq!(domain::pick_label(&got.labels, "sv", "en"), Some("Carl Larsson"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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")).await.unwrap();
|
||||
authority::create_authority(
|
||||
&mut *tx,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Place,
|
||||
external_uri: None,
|
||||
labels: vec![LocalizedLabel { lang: "en".into(), label: "Stockholm".into() }],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let people = authority::list_by_kind(db.pool(), AuthorityKind::Person).await.unwrap();
|
||||
assert_eq!(people.len(), 1);
|
||||
assert_eq!(people[0].kind, AuthorityKind::Person);
|
||||
|
||||
let places = authority::list_by_kind(db.pool(), AuthorityKind::Place).await.unwrap();
|
||||
assert_eq!(places.len(), 1);
|
||||
assert_eq!(places[0].kind, AuthorityKind::Place);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
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")).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let r = authority::resolve_authority(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(r.authority_id(), id);
|
||||
assert_eq!(r.kind(), AuthorityKind::Person);
|
||||
|
||||
let missing = authority::resolve_authority(db.pool(), domain::AuthorityId::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test authority` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement** `crates/db/src/authority.rs`:
|
||||
```rust
|
||||
//! Authority records (person / organisation / place).
|
||||
|
||||
use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority};
|
||||
use sqlx::Row;
|
||||
|
||||
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.
|
||||
pub async fn create_authority(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
new: &NewAuthority,
|
||||
) -> Result<AuthorityId, sqlx::Error> {
|
||||
let id = AuthorityId::new();
|
||||
sqlx::query("INSERT INTO authority (id, kind, external_uri) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(new.kind.as_str())
|
||||
.bind(new.external_uri.as_deref())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
for label in &new.labels {
|
||||
sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(&label.lang)
|
||||
.bind(&label.label)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Fetch one authority (with its labels).
|
||||
pub async fn authority_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: AuthorityId,
|
||||
) -> Result<Option<Authority>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \
|
||||
WHERE a.id = $1 GROUP BY a.id"
|
||||
);
|
||||
let row = sqlx::query(&sql).bind(id.to_uuid()).fetch_optional(executor).await?;
|
||||
row.map(map_authority).transpose()
|
||||
}
|
||||
|
||||
/// List authorities of a given kind (with labels), ordered by id.
|
||||
pub async fn list_by_kind<'e, E>(
|
||||
executor: E,
|
||||
kind: AuthorityKind,
|
||||
) -> Result<Vec<Authority>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let sql = format!(
|
||||
"SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \
|
||||
FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \
|
||||
WHERE a.kind = $1 GROUP BY a.id ORDER BY a.id"
|
||||
);
|
||||
let rows = sqlx::query(&sql).bind(kind.as_str()).fetch_all(executor).await?;
|
||||
rows.into_iter().map(map_authority).collect()
|
||||
}
|
||||
|
||||
/// Resolve an authority to an [`AuthorityRef`] (carrying its kind).
|
||||
pub async fn resolve_authority<'e, E>(
|
||||
executor: E,
|
||||
id: AuthorityId,
|
||||
) -> Result<Option<AuthorityRef>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM authority WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
match kind {
|
||||
Some(k) => {
|
||||
let kind = AuthorityKind::from_db(&k)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {k}").into()))?;
|
||||
Ok(Some(AuthorityRef::new(id, kind)))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
|
||||
let kind_str: String = row.try_get("kind")?;
|
||||
let kind = AuthorityKind::from_db(&kind_str)
|
||||
.ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {kind_str}").into()))?;
|
||||
let labels: sqlx::types::Json<Vec<LocalizedLabel>> = row.try_get("labels")?;
|
||||
Ok(Authority {
|
||||
id: AuthorityId::from_uuid(row.try_get("id")?),
|
||||
kind,
|
||||
external_uri: row.try_get("external_uri")?,
|
||||
labels: labels.0,
|
||||
})
|
||||
}
|
||||
```
|
||||
Add to `crates/db/src/lib.rs` (top-level): `pub mod authority;`
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test authority` → PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add authority repository with multilingual labels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (§6.3 vocab/authority):**
|
||||
- Controlled vocabularies + terms, person/org/place authorities, store-once-link-many → Tasks 2–4. ✓
|
||||
- Multilingual labels (sv/en…) → label tables + `LocalizedLabel`/`pick_label` (Tasks 1–4). ✓
|
||||
- Validated reference types `TermRef`/`AuthorityRef` produced by resolve functions → Tasks 1, 3, 4. ✓
|
||||
- SQL confined to `db`; `domain` I/O-free; uses `domain` ids → all tasks. ✓
|
||||
- Unified authority table + normalized labels (approved decisions) → Task 2. ✓
|
||||
- No HTTP/admin UI (deferred to Plan 10). ✓ (intentional)
|
||||
|
||||
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`.
|
||||
|
||||
**Type consistency:** `VocabularyId`/`TermId`/`AuthorityId`/`AuthorityKind`/`LocalizedLabel`/`Vocabulary`/`Term`/`NewTerm`/`TermRef`/`Authority`/`NewAuthority`/`AuthorityRef` names + fields are identical across `domain` (Task 1), the repositories (Tasks 3–4), and tests. Repo signatures: reads take `impl PgExecutor`; multi-statement writes (`add_term`, `create_authority`) take `&mut PgConnection` and are called with `&mut *tx` in tests. `LABELS_JSON` aliases differ per module (`tl`/`term_id` vs `al`/`authority_id`) matching their joins.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- `TermRef`/`AuthorityRef` become FK-backed when the catalogue references them (Plan 4); consider whether `resolve_*` should run inside the catalogue write transaction.
|
||||
- Authority/term **search by label** (fuzzy/substring) is deferred to Meilisearch (Plan 6) and the admin UI (Plan 10); the relational repos here cover by-id/by-key/by-kind/list.
|
||||
- Seeding the Spectrum-recommended vocabularies (and Getty/KulturNav import) is a later concern (VISION post-MVP).
|
||||
@@ -0,0 +1,316 @@
|
||||
# MVP Architecture & Design
|
||||
|
||||
**Status:** approved design, pre-implementation
|
||||
**Date:** 2026-06-02
|
||||
**Scope:** the MVP — the smallest useful build that exercises every architectural
|
||||
pillar. Companion to [`../VISION.md`](../VISION.md) (full feature catalogue) and
|
||||
grounded in [`../../reference/`](../../reference/) (Spectrum 5.0 + Riksantikvarie-
|
||||
ämbetet source material).
|
||||
|
||||
> Neutral naming throughout. No product/brand name appears in code or these docs
|
||||
> (see §13).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals & non-goals
|
||||
|
||||
**Goals**
|
||||
- A small, strongly-typed, well-tested **core** that is easy to extend.
|
||||
- An organization can **catalogue** its collection (Spectrum Cataloguing), attach
|
||||
**media**, **search** it, control **visibility**, and expose **public** records.
|
||||
- **Airtight org isolation** and a full **audit trail** from day one.
|
||||
- **Easy self-hosting**: one binary, one database, minimal dependencies.
|
||||
|
||||
**Non-goals (MVP)**
|
||||
- Other Spectrum procedures as workflows (entry, accession, loans, location/
|
||||
movement control, …) — roadmap.
|
||||
- Reporting/label templates, aggregator/LIDO/OAI-PMH/IIIF, translation workflow
|
||||
UI, fleet provisioning/control plane, migrations machinery (none until 1.0).
|
||||
|
||||
---
|
||||
|
||||
## 2. Guiding principles
|
||||
|
||||
- **Make illegal states unrepresentable** (§9). Parse, don't validate.
|
||||
- **Isolation by construction** (§4): credentials + topology, not `org_id`
|
||||
filtering in code.
|
||||
- **Module separation; no SQL spread.** SQL lives only in repository modules (§5,
|
||||
§8).
|
||||
- **Minimal custom code, reversible dependency bets** (§14).
|
||||
- **Self-host is first-class** (§12).
|
||||
- **Well-tested, not overboard** (§15): strong types shrink the test surface; the
|
||||
isolation/security and the core get thorough tests; the dynamic field layer is
|
||||
validated at runtime.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deployment topology & tenancy
|
||||
|
||||
**The application binary is always single-tenant.** One running instance serves
|
||||
exactly one organization and contains no concept of "other orgs". There is **no
|
||||
multi-tenant code path**. Multi-tenancy is achieved entirely at the deployment
|
||||
layer:
|
||||
|
||||
| | Self-host | Hosted fleet |
|
||||
|---|---|---|
|
||||
| App instances | one (1+ pods) | one deployment **per org** (1+ pods each) |
|
||||
| Postgres | its own database | **one shared server**, one **database per org** |
|
||||
| Meilisearch | its own index | **one shared server**, one **index per org** |
|
||||
| Files | local disk or S3 | S3 (or RWX volume per org) |
|
||||
| Domain | the org's domain | each org its own domain |
|
||||
| Rollout | upgrade the instance | **per-org** image bump |
|
||||
|
||||
Consequences (recorded):
|
||||
- **Per-org rollout & schema version.** Bumping one org's image rolls out that org
|
||||
only; the instance runs its own migrations against its own database. Orgs may sit
|
||||
on different versions. (Pre-1.0: recreate rather than migrate.)
|
||||
- **Files:** with more than one pod per org, files must be on shared storage (S3 or
|
||||
RWX volume) — local disk is single-pod/self-host only. `BlobStore` (§11) abstracts
|
||||
this.
|
||||
- **Cross-org features** (a future aggregator searching across museums; fleet
|
||||
admin) are a **separate service**, never a single org-app. Out of MVP.
|
||||
|
||||
## 4. Isolation model
|
||||
|
||||
Because each org-app holds **credentials scoped to its own database and its own
|
||||
search index**, cross-org access is not "prevented" — it is **impossible, because
|
||||
the access path does not exist**:
|
||||
|
||||
- **Postgres:** database-per-org + a role granted access to *only* that database.
|
||||
An instance physically cannot connect to another org's database.
|
||||
- **Meilisearch:** index-per-org + an API key scoped to that org's index only.
|
||||
- **No Row-Level Security needed** — there is no shared multi-org data in any
|
||||
single database to protect, and the app has no cross-org code.
|
||||
- **Files:** per-org bucket/prefix (S3) or per-org volume, with scoped credentials.
|
||||
|
||||
Defense-in-depth / verification:
|
||||
- A **single configuration chokepoint** establishes "which org am I" at startup
|
||||
from config; nothing reconstructs it ad hoc.
|
||||
- **Negative tests** assert the app cannot be pointed outside its configured
|
||||
database/index and that scoped credentials reject foreign access.
|
||||
|
||||
## 5. Crate / module layout
|
||||
|
||||
A Cargo **workspace** with **role-named** member crates (no brand name anywhere):
|
||||
|
||||
```
|
||||
/ virtual workspace
|
||||
crates/
|
||||
domain/ core types, value objects, invariants (no I/O)
|
||||
db/ sqlx repositories; ALL SQL lives here
|
||||
storage/ BlobStore trait + OpenDAL adapter (S3 / local)
|
||||
search/ search abstraction + Meilisearch adapter
|
||||
auth/ password + OIDC, session/token, extractors
|
||||
api/ axum router, handlers, OpenAPI (utoipa), public + admin
|
||||
server/ binary: config, wiring, startup, migrations runner
|
||||
web/ React SPA (separate build), consumes the OpenAPI
|
||||
migrations/ SQL migrations (post-1.0; pre-1.0 = recreate)
|
||||
```
|
||||
|
||||
Dependency direction points inward toward `domain`. `domain` has no I/O deps.
|
||||
Each crate has one clear purpose, a defined interface, and is testable in
|
||||
isolation. Experimental/volatile dependencies sit behind a crate-owned trait
|
||||
(`BlobStore`, the search trait, …) so they are swappable (§14).
|
||||
|
||||
## 6. Data model — hybrid (Approach C)
|
||||
|
||||
Three layers:
|
||||
|
||||
### 6.1 Typed relational core
|
||||
The accountability backbone and the most queried/integrity-critical fields, as
|
||||
real columns/tables with strong types:
|
||||
- object number (configurable format), object name, number of objects,
|
||||
brief description, current location, current owner, recorder, recording date,
|
||||
**visibility**, media links, audit linkage.
|
||||
|
||||
### 6.2 Flexible field layer
|
||||
- A **field-definition registry**: each definition has a key, data type, optional
|
||||
**vocabulary/authority binding**, validation rules, grouping, and locale
|
||||
behavior.
|
||||
- Field **values** stored as **JSONB** on the record, validated at write time
|
||||
against the registry.
|
||||
- The **Spectrum 5.0 Cataloguing field set** ships as **seed field definitions**
|
||||
(see [`reference/spectrum-5.0-cataloguing-units-of-information.md`](../../reference/spectrum-5.0-cataloguing-units-of-information.md)).
|
||||
Orgs enable a subset or the full set; custom fields are *data*, not migrations.
|
||||
- **Trade (explicit):** this layer is **runtime-typed by design** — validated
|
||||
against definitions at runtime, not by the compiler. Hard types where structure
|
||||
is fixed (core, IDs, refs), runtime validation where it is dynamic.
|
||||
|
||||
### 6.3 Controlled vocabularies & authority records
|
||||
- First-class relational tables for **person / organization / place** authorities
|
||||
and **term sources** (vocabularies) — *store once, link many*.
|
||||
- Referenced from both the typed core and the flexible fields. A field bound to a
|
||||
term source accepts only a **resolved reference** (§9), never a free string.
|
||||
- **Multilingual labels** (sv/en …) on terms and authorities.
|
||||
|
||||
### 6.4 Content i18n (capability now, workflow later)
|
||||
- Localizable text values are **language-tagged in the data model from day one**
|
||||
(so no painful migration later).
|
||||
- The **translation workflow/UI is post-MVP**; MVP authors enter content in one
|
||||
language while the model already supports more.
|
||||
|
||||
## 7. Surfaces & API
|
||||
|
||||
Two cleanly separated surfaces — a **load-bearing** rule:
|
||||
- **Public surface** — `/api/public/**`: unauthenticated, **read-only**, serves
|
||||
only **public** records as a typed **`PublicView`** (public-safe fields only).
|
||||
- **Admin/privileged surface** — everything else: authenticated, read/write.
|
||||
|
||||
This separation enables independent **IP/VPN lockdown** (admin behind an ingress
|
||||
allowlist while public stays open), caching, and rate-limiting — all at the
|
||||
ingress layer, not in app code. An optional in-app IP-allowlist middleware is a
|
||||
post-MVP portable fallback.
|
||||
|
||||
**OpenAPI:** code-first with **utoipa** — the spec is generated from Rust
|
||||
types/handlers (cannot drift) and is the contract the React client consumes.
|
||||
|
||||
## 8. Persistence & data access
|
||||
|
||||
- **PostgreSQL** via **sqlx** (async, compile-time-checked queries). **All SQL is
|
||||
confined to the `db` crate**, one repository per aggregate — satisfying "no SQL
|
||||
spread everywhere" without an ORM's abstraction.
|
||||
- JSONB for the flexible field values (GIN-indexable for search/filter needs).
|
||||
- **No migrations until 1.0** — pre-1.0 we reshape freely (drop & recreate). Post-
|
||||
1.0, each instance runs its own migrations on startup (per-org schema version).
|
||||
|
||||
## 9. Type-driven design (cross-cutting)
|
||||
|
||||
- **Newtype IDs** — `ObjectId`, `OrgId`, `MediaId`, `TermId`, `AuthorityId`; never
|
||||
bare UUIDs.
|
||||
- **Validated value objects** — `ObjectNumber`, `Email`, and `TermRef` /
|
||||
`AuthorityRef` that are **constructable only by resolving** against the
|
||||
vocabulary/authority. An unvalidated term cannot exist as that type. (Direct
|
||||
mapping of Spectrum's "use a standard term source / form of name".)
|
||||
- **`PublicView` projection** — a distinct type carrying only public-safe fields;
|
||||
leaking an internal field on the public surface is impossible because the type
|
||||
lacks it. (Preferred over a literal `Record<Public>` generic, since visibility is
|
||||
runtime data from the DB.)
|
||||
- **Visibility** — an enum with explicit transition methods (`publish`,
|
||||
`unpublish`, `archive`): a type-driven state machine, not a stringly-typed flag.
|
||||
- **Auth via extractors** — public handlers take no auth extractor; privileged
|
||||
handlers require an `AuthUser` / `Authorized<Cap>` extractor, so a privileged
|
||||
handler cannot compile without proof of authorization.
|
||||
|
||||
## 10. Authentication & authorization
|
||||
|
||||
- **Email/password** + **external OIDC** (the org-app is an OIDC relying party),
|
||||
scoped to the single org the instance serves.
|
||||
- **No separate IdP and no cross-org switching** in MVP (deferred; rare case).
|
||||
- Sessions: stateless tokens or a sessions table in the org DB (no Redis required).
|
||||
- Authorization enforced through typed extractors (§9); role/permission model kept
|
||||
simple in MVP.
|
||||
|
||||
## 11. File storage
|
||||
|
||||
- **`BlobStore` trait** in the `storage` crate; **OpenDAL** adapter for **S3 and
|
||||
local disk**. Chosen on fit (high-level, multi-backend; our bottleneck is
|
||||
network/S3, not syscall I/O). `fusio` is watch-listed and swappable behind the
|
||||
trait (§14).
|
||||
- Media files are linked to records; derivatives/thumbnails/IIIF are post-MVP.
|
||||
|
||||
## 12. Search
|
||||
|
||||
- **Meilisearch**, one index per org, scoped API key. A search abstraction in the
|
||||
`search` crate; Meili adapter behind it.
|
||||
- MVP: index catalogue records on write; basic full-text + facet search in admin.
|
||||
- Public-facing search is post-MVP.
|
||||
|
||||
## 13. Audit & amendment history
|
||||
|
||||
- **One append-only, immutable log** in the org database: who / when / what, with
|
||||
**field-level before→after diffs**, covering domain create/update/delete and
|
||||
auth/security events.
|
||||
- Doubles as Spectrum **amendment history** surfaced on catalogue records
|
||||
(Spectrum requires a transparent record of changes — never silently erase prior
|
||||
terminology).
|
||||
- MVP audits **writes + auth events**; auditing reads is deferred.
|
||||
|
||||
## 14. Visibility & publishing
|
||||
|
||||
- **Record-level visibility**: `draft` / `internal` / `public`.
|
||||
- A fixed **never-public** field set (location, valuation, insurance, personal
|
||||
data). Per-field publishability is post-MVP.
|
||||
- Public API serves only `public` records via `PublicView`.
|
||||
|
||||
## 15. Export & backup (distinct)
|
||||
|
||||
- **Backup** (operational): `pg_dump` / PITR of the org database. Ops concern.
|
||||
- **Export** (portable handover): a single **SQLite** file (metadata incl.
|
||||
flattened flexible fields + vocab/authority tables) + plain **media files** + a
|
||||
**manifest** — a whole-org archive, openable anywhere, stable long-term.
|
||||
|
||||
## 16. Internationalization
|
||||
|
||||
- **UI:** Swedish + English via a React i18n library + locale files; localized API
|
||||
validation/error messages.
|
||||
- **Data:** multilingual labels on vocab/authority terms; language-tagged content
|
||||
values in the model (workflow post-MVP, §6.4).
|
||||
|
||||
## 17. Frontend
|
||||
|
||||
- **Lean React SPA**, evergreen browsers, consuming the OpenAPI. Separate build in
|
||||
`web/`.
|
||||
- **"Potato hardware" = an explicit bundle-discipline budget**: small dependency
|
||||
set, code-splitting, measured bundle size as a tracked target — *not* a framework
|
||||
compromise.
|
||||
- Suits the data-entry-heavy cataloguing UI (vocabulary autocomplete, dynamic field
|
||||
groups from the registry, inline validation).
|
||||
|
||||
## 18. Dependencies & tech stack
|
||||
|
||||
| Concern | Choice | Notes |
|
||||
|---|---|---|
|
||||
| Language | **Rust 2024** | |
|
||||
| HTTP | **axum** | |
|
||||
| API spec | **utoipa** (code-first OpenAPI) | drives the React client |
|
||||
| DB | **PostgreSQL** + **sqlx** | SQL confined to `db` crate |
|
||||
| Storage | **OpenDAL** behind `BlobStore` | S3 + local; `fusio` watch-listed |
|
||||
| Search | **Meilisearch** behind a search trait | index-per-org |
|
||||
| Cache | **Redis** — *deferred* | add only when needed; key-prefixed |
|
||||
| Frontend | **React** (lean SPA) | bundle budget enforced |
|
||||
| i18n (FE) | React i18n lib | sv/en |
|
||||
|
||||
**Dependency philosophy:** pre-1.0, choose on **capability/fit, not maturity**;
|
||||
isolate volatile deps behind owned traits (reversible bets); **re-evaluate each
|
||||
bet before 1.0**, when the API surface and data formats lock.
|
||||
|
||||
## 19. Testing strategy
|
||||
|
||||
- **Core & domain:** thorough unit tests; strong types remove whole categories from
|
||||
the test surface.
|
||||
- **Isolation/security:** dedicated **negative tests** (scoped credentials reject
|
||||
foreign access; the public surface never emits internal fields/non-public
|
||||
records).
|
||||
- **Repositories:** integration tests against Postgres.
|
||||
- **Flexible fields:** validation tested against field definitions.
|
||||
- Deliberately **not overboard** elsewhere.
|
||||
|
||||
## 20. Decision log
|
||||
|
||||
| # | Decision | Why | Alternatives rejected |
|
||||
|---|---|---|---|
|
||||
| D1 | Per-org single-tenant binary; tenancy is deployment-only | Simplest core (no tenant plumbing); self-host = same artifact; isolation by construction | Shared multi-tenant app w/ `org_id`+RLS (bleed risk, complex core) |
|
||||
| D2 | Database-per-org + scoped role; index-per-org + scoped key | Hard isolation; clean per-org export; no RLS | Schema-per-org (softer); shared DB + RLS (shared data path) |
|
||||
| D3 | Hybrid data model (typed core + JSONB flexible + relational vocab/authority) | Small tested core + extensible tail; matches "link don't duplicate" | Fixed Spectrum schema (rigid); pure EAV/JSONB (weak integrity) |
|
||||
| D4 | Type-driven design; `PublicView` projection; refs as validated types | Removes bug classes incl. public-data leaks; shrinks tests | Runtime checks only |
|
||||
| D5 | sqlx + repository layer | Compile-time-checked SQL, no ORM, SQL in one place | SeaORM (more abstraction); Diesel (sync) |
|
||||
| D6 | Clean public/admin surface split | Enables IP-lock/caching/publishing cleanly | Single mixed surface |
|
||||
| D7 | Ingress-layer IP/VPN lockdown, admin-only-lockable | Not the app's job; public stays open | App-level firewall (fallback only) |
|
||||
| D8 | Lean React SPA, evergreen + bundle budget | Growth path; ecosystem for data-entry UI; fits weak HW if disciplined | htmx/SSR (only needed for ancient browsers — none required) |
|
||||
| D9 | Append-only audit w/ field diffs = amendment history | One mechanism satisfies ops audit + Spectrum requirement | Separate audit & history systems |
|
||||
| D10 | Export = SQLite + files; backup = pg_dump | Portable, openable anywhere; distinct from ops backup | pg_dump as the only "export" (not portable) |
|
||||
| D11 | OpenDAL behind `BlobStore` | Right altitude, multi-backend; bottleneck is network not syscalls | fusio now (lower-level, DB-engine focus) — watch-listed |
|
||||
| D12 | utoipa code-first OpenAPI | Spec can't drift; drives client | spec-first |
|
||||
| D13 | i18n: UI+vocab labels MVP; content workflow later, model ready now | Avoids painful migration; keeps MVP small | Full content translation in MVP (too big) |
|
||||
| D14 | No IdP / no cross-org switching now | Rare case; keeps auth simple | Build shared IdP now |
|
||||
| D15 | No migrations until 1.0 | Freedom to reshape pre-1.0 | Migrations from day one |
|
||||
| D16 | No product name in code; role-named workspace; name from config | Placeholder must never leak; trivial rename later | Hardcode a working name |
|
||||
|
||||
## 21. Open items for the implementation plan
|
||||
|
||||
- First scaffolding task: **dissolve the current `biggus-dickus` package** into the
|
||||
role-named workspace (the placeholder name must not survive into real code).
|
||||
- Decide the role/permission model's MVP shape (kept minimal).
|
||||
- Decide the object-number format configuration mechanism.
|
||||
- Define the SQLite export schema mapping for the hybrid model.
|
||||
- Choose specific crates for OIDC, JSONB validation, and React i18n during planning.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user