Compare commits
371 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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 }
|
||||
+18
-1
@@ -1,5 +1,22 @@
|
||||
# Connection string for local development and tests.
|
||||
# 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,2 +1,10 @@
|
||||
/target
|
||||
.env
|
||||
|
||||
# Local-only Docker Compose overrides (machine-specific port remaps, etc.)
|
||||
docker-compose.override.yml
|
||||
|
||||
.superpowers/
|
||||
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
@@ -4,22 +4,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Status
|
||||
|
||||
Freshly scaffolded Rust binary crate (edition 2024). `src/main.rs` is still the `cargo new` "Hello, world!" stub and `Cargo.toml` has no dependencies yet. There is no architecture to document — update this file as real structure emerges.
|
||||
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
|
||||
cargo build # build
|
||||
cargo run # run the binary
|
||||
cargo test # run all tests
|
||||
cargo test <name> # run a single test by name substring
|
||||
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 # lint before committing
|
||||
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
+740
-10
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/domain", "crates/db", "crates/api", "crates/server"]
|
||||
members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search", "crates/auth"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
@@ -9,10 +9,11 @@ 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"] }
|
||||
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"
|
||||
@@ -22,3 +23,10 @@ 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"
|
||||
|
||||
@@ -1,3 +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.
|
||||
|
||||
+10
-1
@@ -7,12 +7,21 @@ 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
|
||||
sqlx.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))
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
//! 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)]
|
||||
@@ -13,12 +25,60 @@ pub struct AppState {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,86 @@
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{AppState, health};
|
||||
use crate::{
|
||||
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, config, health,
|
||||
public,
|
||||
};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(health::live, health::ready),
|
||||
components(schemas(health::Live, health::Ready)),
|
||||
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;
|
||||
@@ -15,7 +89,9 @@ struct ApiDoc;
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -9,6 +9,10 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@ 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)
|
||||
}
|
||||
+32
-3
@@ -1,7 +1,26 @@
|
||||
//! 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 {
|
||||
@@ -9,10 +28,11 @@ pub struct Db {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Connect to the database at `database_url`, opening a connection pool.
|
||||
pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
/// Connect to the database at `database_url`, opening a connection pool with at most
|
||||
/// `max_connections` connections.
|
||||
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
@@ -37,4 +57,13 @@ impl Db {
|
||||
|
||||
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,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);
|
||||
}
|
||||
@@ -7,3 +7,6 @@ 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
+59
-23
@@ -1,53 +1,81 @@
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
//! Strongly-typed identifiers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Identifier for an organization (tenant).
|
||||
///
|
||||
/// A newtype over [`Uuid`] so it can never be confused with another entity's id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
/// 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 OrgId(Uuid);
|
||||
pub struct $name(uuid::Uuid);
|
||||
|
||||
impl OrgId {
|
||||
impl $name {
|
||||
/// Generate a fresh random id.
|
||||
#[must_use = "generating an OrgId and discarding it is almost certainly a mistake"]
|
||||
#[must_use = "generating an id and discarding it is almost certainly a mistake"]
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Wrap an existing [`Uuid`].
|
||||
pub fn from_uuid(uuid: Uuid) -> Self {
|
||||
/// Wrap an existing [`uuid::Uuid`].
|
||||
pub fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
/// Return the underlying [`Uuid`].
|
||||
pub fn to_uuid(&self) -> Uuid {
|
||||
/// The underlying [`uuid::Uuid`].
|
||||
pub fn to_uuid(&self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OrgId {
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OrgId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
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 FromStr for OrgId {
|
||||
impl std::str::FromStr for $name {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(Uuid::parse_str(s)?))
|
||||
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 {
|
||||
@@ -64,4 +92,12 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +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 id::OrgId;
|
||||
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());
|
||||
}
|
||||
@@ -11,6 +11,9 @@ path = "src/lib.rs"
|
||||
name = "server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
embed-web = ["dep:memory-serve"]
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
@@ -19,12 +22,25 @@ 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");
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,53 @@ pub struct Config {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
+156
-3
@@ -2,36 +2,189 @@
|
||||
|
||||
mod config;
|
||||
|
||||
#[cfg(feature = "embed-web")]
|
||||
mod web_assets;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
use anyhow::Context;
|
||||
use api::{AppState, build_app};
|
||||
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)
|
||||
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.clone(),
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
use clap::Parser;
|
||||
use server::{Config, run};
|
||||
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 config = Config::parse();
|
||||
run(config).await
|
||||
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()
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use clap::Parser;
|
||||
use server::Config;
|
||||
|
||||
const CLEARED: [(&str, Option<&str>); 3] = [
|
||||
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]
|
||||
@@ -16,6 +19,8 @@ fn parses_from_args_with_defaults() {
|
||||
assert_eq!(cfg.database_url, "postgres://localhost/test");
|
||||
assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
|
||||
assert_eq!(cfg.app_name, "Collection Management System");
|
||||
assert_eq!(cfg.default_language, "sv");
|
||||
assert_eq!(cfg.default_timezone, "Europe/Stockholm");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,3 +30,11 @@ fn database_url_is_required() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -9,24 +9,36 @@ use tokio::net::TcpListener;
|
||||
async fn serves_health_live_over_tcp() {
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
|
||||
let db = Db::connect(&database_url)
|
||||
let db = Db::connect(&database_url, 2)
|
||||
.await
|
||||
.expect("connect to database");
|
||||
let state = AppState {
|
||||
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.unwrap();
|
||||
});
|
||||
let handle = tokio::spawn(async move { serve(listener, state).await });
|
||||
|
||||
let url = format!("http://{addr}/health/live");
|
||||
let body: serde_json::Value = reqwest::get(&url)
|
||||
.await
|
||||
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
|
||||
|
||||
@@ -9,6 +9,24 @@ services:
|
||||
- "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:
|
||||
|
||||
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.
|
||||
@@ -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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,922 @@
|
||||
# Fields Management 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:** Let admins create flexible field definitions — expose `POST /api/admin/field-definitions` over the existing db layer, and build a `/fields` two-pane screen (grouped list + create form) that enables the last nav stub.
|
||||
|
||||
**Architecture:** A thin axum write handler reuses `FieldType::from_parts` as the single type/binding validation chokepoint and `db::fields::create_field_definition`. The frontend reuses the Objects/Vocabularies two-pane idiom: a grouped read-only list (`useFieldDefinitions`, already cached and shared with the M2 object editor) plus a create form with native `<select>`s and conditional config (vocabulary for `term`, kind for `authority`). Creating a field invalidates `["field-definitions"]`, so it appears in both the list and the object editor.
|
||||
|
||||
**Tech Stack:** Rust (axum 0.8, utoipa, sqlx 0.8), React 19 + TS, TanStack Query v5, react-router-dom 7, react-i18next (sv/en), Vitest + RTL + MSW.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-04-fields-management-design.md`
|
||||
|
||||
**Conventions (every task):**
|
||||
- Rust fmt with **nightly** (`cargo +nightly fmt`); `cargo clippy`.
|
||||
- Frontend: no `any` / `eslint-disable` / `@ts-ignore`; en/sv i18n key parity; codename "biggus"/"dickus" nowhere; native `<select>` for dropdowns (matches `web/src/objects/field-input.tsx` — a deliberate bundle-lean choice).
|
||||
- Test infra (running docker containers; start if down): `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev`, `MEILI_URL=http://localhost:7701`, `MEILI_MASTER_KEY=masterKey`. (Field-definition tests need only Postgres; `#[sqlx::test]` provisions its own DB.)
|
||||
- Run web commands from `web/`; cargo from repo root.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — `POST /api/admin/field-definitions`
|
||||
|
||||
The GET handler already lives in `crates/api/src/admin_objects.rs` and its route is registered there. **axum panics if the same path is declared in two merged routers**, so the POST handler goes in `admin_objects.rs` too and chains `.post(...)` onto the existing `.route("/api/admin/field-definitions", get(list_field_definitions))`. No domain or db changes — `FieldType::from_parts` and `db::fields::create_field_definition` already exist.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/api/src/admin_objects.rs` (add request/response structs, handler, chain `.post`)
|
||||
- Modify: `crates/api/src/openapi.rs` (register path + schemas)
|
||||
- Test: `crates/api/tests/admin_fields.rs` (new)
|
||||
- Regenerate: `web/src/api/schema.d.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing API test** — create `crates/api/tests/admin_fields.rs`:
|
||||
|
||||
```rust
|
||||
use api::{AppState, build_app, migrate_sessions};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use db::users;
|
||||
use domain::{AuditActor, Email, NewUser, Role};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test".into(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/field-definitions")
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// It appears in the GET listing.
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to confirm it fails** —
|
||||
|
||||
```bash
|
||||
cargo test -p api --test admin_fields
|
||||
```
|
||||
Expected: 401-test may pass incidentally, but the create tests fail (route has no POST → 405/404).
|
||||
|
||||
- [ ] **Step 3: Add the request/response structs + handler** — in `crates/api/src/admin_objects.rs`. First ensure the imports at the top include what's needed (the file already imports axum bits, `State`, `StatusCode`, `Json`, `db`, `auth::{Authorized, ViewInternal}`; add `EditCatalogue` and the domain types). Add to the `use auth::...` line: `EditCatalogue`. Add `use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, VocabularyId};` if not already present (the file may already import some domain types — merge, don't duplicate). Reuse `LabelInput` — it is defined in `admin_vocab`; import it: `use crate::admin_vocab::LabelInput;` (the file already imports from `crate`; add this).
|
||||
|
||||
Then add the structs (near `FieldDefinitionView`):
|
||||
|
||||
```rust
|
||||
#[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,
|
||||
}
|
||||
```
|
||||
(If `serde::{Deserialize, Serialize}` and `utoipa::ToSchema` are already imported in this file, use the bare derive names to match the file's style.)
|
||||
|
||||
And the handler:
|
||||
|
||||
```rust
|
||||
/// 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 })))
|
||||
}
|
||||
// Duplicate `key` violates the unique index (SQLSTATE 23505).
|
||||
Err(err)
|
||||
if err
|
||||
.as_database_error()
|
||||
.and_then(|e| e.code())
|
||||
.as_deref()
|
||||
== Some("23505") =>
|
||||
{
|
||||
Err(StatusCode::CONFLICT)
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: `AuthorityKind::from_db` is the existing parser (`crates/domain/src/authority.rs`); confirm the method name there (it is `from_db`, returning `Option<AuthorityKind>`). `VocabularyId: FromStr` is used the same way `admin_vocab` parses ids.
|
||||
|
||||
- [ ] **Step 4: Chain `.post` onto the existing route** — in `admin_objects.rs` `routes()`, change:
|
||||
```rust
|
||||
.route("/api/admin/field-definitions", get(list_field_definitions))
|
||||
```
|
||||
to
|
||||
```rust
|
||||
.route(
|
||||
"/api/admin/field-definitions",
|
||||
get(list_field_definitions).post(create_field_definition),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Register in OpenAPI** — in `crates/api/src/openapi.rs`: add `admin_objects::create_field_definition` to `paths(...)`; add `admin_objects::NewFieldDefinitionRequest` and `admin_objects::CreatedField` to `components(schemas(...))`.
|
||||
|
||||
- [ ] **Step 6: Run the API tests** — `cargo test -p api --test admin_fields` → 4 pass.
|
||||
|
||||
- [ ] **Step 7: Regenerate the typed web client** —
|
||||
|
||||
```bash
|
||||
cargo build -p server
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \
|
||||
MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \
|
||||
./target/debug/server &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
( cd web && pnpm gen:api )
|
||||
kill "$SERVER_PID"
|
||||
grep -n "NewFieldDefinitionRequest\|CreatedField" web/src/api/schema.d.ts
|
||||
```
|
||||
The grep must show both schemas. Then `cd web && pnpm typecheck` to confirm the regenerated file is well-formed (the diff should be purely additive — the existing `/api/admin/field-definitions` GET path gains a `post` operation; no existing paths removed). If a stale server occupies :8080, kill it first (`lsof -ti :8080 | xargs kill`).
|
||||
|
||||
- [ ] **Step 8: Format, lint, commit** —
|
||||
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy -p api --all-targets
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add crates/api web/src/api/schema.d.ts
|
||||
git commit -m "feat(api): POST /api/admin/field-definitions (create field definition)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend data layer — `useCreateFieldDefinition` + MSW handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`
|
||||
- Test: `web/src/api/queries.fields.test.tsx` (new)
|
||||
|
||||
The `fieldDefinitions` GET fixture already exists (`web/src/test/fixtures.ts`) with a grouped entry (`inscription`, group "Description") and ungrouped entries, and the GET handler is already wired. Only the mutation + POST handler are new.
|
||||
|
||||
- [ ] **Step 1: Add the MSW POST handler** — in `web/src/test/handlers.ts`, add to the `handlers` array:
|
||||
|
||||
```ts
|
||||
http.post("/api/admin/field-definitions", () =>
|
||||
HttpResponse.json({ key: "new_field" }, { status: 201 }),
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing hook test** — create `web/src/api/queries.fields.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { expect, test } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/server";
|
||||
import { useCreateFieldDefinition } from "./queries";
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
test("useCreateFieldDefinition POSTs the request body", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/field-definitions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ key: "technique" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCreateFieldDefinition(), { wrapper });
|
||||
result.current.mutate({
|
||||
key: "technique",
|
||||
data_type: "term",
|
||||
vocabulary_id: "v-technique",
|
||||
authority_kind: null,
|
||||
required: false,
|
||||
group: "Provenance",
|
||||
labels: [{ lang: "en", label: "Technique" }],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect((body as { key: string; data_type: string }).key).toBe("technique");
|
||||
expect((body as { data_type: string }).data_type).toBe("term");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run it to confirm it fails** — `cd web && pnpm test src/api/queries.fields.test.tsx` → FAIL (no `useCreateFieldDefinition`).
|
||||
|
||||
- [ ] **Step 4: Implement the hook** — in `web/src/api/queries.ts`, append (it uses the already-imported `useMutation`, `useQueryClient`, `api`, and `components`):
|
||||
|
||||
```ts
|
||||
type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"];
|
||||
|
||||
export function useCreateFieldDefinition() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: NewFieldDefinitionRequest) => {
|
||||
const { data, response } = await api.POST("/api/admin/field-definitions", { body });
|
||||
|
||||
if (response.status !== 201 || !data) throw new Error("failed to create field definition");
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run it to confirm it passes** — `pnpm test src/api/queries.fields.test.tsx` → PASS.
|
||||
|
||||
- [ ] **Step 6: Commit** —
|
||||
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "feat(web): useCreateFieldDefinition mutation + MSW handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend — `/fields` two-pane screen, route, nav, i18n
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/fields/fields-page.tsx`, `web/src/fields/field-list.tsx`, `web/src/fields/field-form.tsx`, `web/src/fields/fields.test.tsx`
|
||||
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
|
||||
|
||||
- [ ] **Step 1: i18n** — add a `fields` namespace to BOTH `web/src/i18n/en.json` and `sv.json` (keep parity; authority-kind option labels reuse the existing `authorities.{person,organisation,place}` keys).
|
||||
`en.json`:
|
||||
```json
|
||||
"fields": {
|
||||
"title": "Fields",
|
||||
"newField": "New field definition",
|
||||
"key": "Key",
|
||||
"type": "Type",
|
||||
"vocabulary": "Vocabulary",
|
||||
"authorityKind": "Authority kind",
|
||||
"anyKind": "Any",
|
||||
"group": "Group",
|
||||
"required": "Required",
|
||||
"create": "Create field",
|
||||
"empty": "No field definitions yet",
|
||||
"loadError": "Could not load",
|
||||
"other": "Other",
|
||||
"types": {
|
||||
"text": "Text",
|
||||
"localized_text": "Localized text",
|
||||
"integer": "Integer",
|
||||
"date": "Date",
|
||||
"boolean": "Boolean",
|
||||
"term": "Term",
|
||||
"authority": "Authority"
|
||||
}
|
||||
}
|
||||
```
|
||||
`sv.json`:
|
||||
```json
|
||||
"fields": {
|
||||
"title": "Fält",
|
||||
"newField": "Nytt fältdefinition",
|
||||
"key": "Nyckel",
|
||||
"type": "Typ",
|
||||
"vocabulary": "Vokabulär",
|
||||
"authorityKind": "Auktoritetstyp",
|
||||
"anyKind": "Alla",
|
||||
"group": "Grupp",
|
||||
"required": "Obligatoriskt",
|
||||
"create": "Skapa fält",
|
||||
"empty": "Inga fältdefinitioner ännu",
|
||||
"loadError": "Kunde inte ladda",
|
||||
"other": "Övrigt",
|
||||
"types": {
|
||||
"text": "Text",
|
||||
"localized_text": "Lokaliserad text",
|
||||
"integer": "Heltal",
|
||||
"date": "Datum",
|
||||
"boolean": "Boolesk",
|
||||
"term": "Term",
|
||||
"authority": "Auktoritet"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement `FieldList`** — create `web/src/fields/field-list.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useFieldDefinitions } from "../api/queries";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||
|
||||
function labelText(labels: FieldDefinitionView["labels"], lang: string): string {
|
||||
return (
|
||||
labels.find((l) => l.lang === lang)?.label ??
|
||||
labels.find((l) => l.lang === "en")?.label ??
|
||||
labels[0]?.label ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldList() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data, isLoading, isError } = useFieldDefinitions();
|
||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) return <p className="p-4 text-sm text-red-600">{t("fields.loadError")}</p>;
|
||||
if (!data || data.length === 0)
|
||||
return <p className="p-4 text-sm text-neutral-500">{t("fields.empty")}</p>;
|
||||
|
||||
// Group by `group`; ungrouped (null/empty) collected under the "Other" heading.
|
||||
const groups = new Map<string, FieldDefinitionView[]>();
|
||||
for (const def of data) {
|
||||
const key = def.group?.trim() ? def.group : t("fields.other");
|
||||
const bucket = groups.get(key) ?? [];
|
||||
bucket.push(def);
|
||||
groups.set(key, bucket);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="overflow-auto">
|
||||
{[...groups.entries()].map(([group, defs]) => (
|
||||
<li key={group}>
|
||||
<div className="border-b bg-neutral-50 px-3 py-1 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
{group}
|
||||
</div>
|
||||
<ul>
|
||||
{defs.map((def) => (
|
||||
<li key={def.key} className="flex items-center gap-2 border-b px-3 py-2 text-sm">
|
||||
<span className="font-medium">{labelText(def.labels, lang)}</span>
|
||||
<span className="text-xs text-neutral-400">{def.key}</span>
|
||||
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
|
||||
{t(`fields.types.${def.data_type}`)}
|
||||
</span>
|
||||
{def.required && <span className="text-xs text-red-600">*</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `FieldForm`** — create `web/src/fields/field-form.tsx`. Native `<select>`s (matches `web/src/objects/field-input.tsx`). Reuses `LabelEditor` (sv/en, EN-required) and `useVocabularies`.
|
||||
|
||||
```tsx
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useCreateFieldDefinition, useVocabularies } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
|
||||
const KINDS = ["person", "organisation", "place"] as const;
|
||||
|
||||
export function FieldForm() {
|
||||
const { t } = useTranslation();
|
||||
const create = useCreateFieldDefinition();
|
||||
const { data: vocabularies } = useVocabularies();
|
||||
|
||||
const [key, setKey] = useState("");
|
||||
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||
const [dataType, setDataType] = useState<string>("text");
|
||||
const [vocabularyId, setVocabularyId] = useState("");
|
||||
const [authorityKind, setAuthorityKind] = useState(""); // "" == any
|
||||
const [group, setGroup] = useState("");
|
||||
const [required, setRequired] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setKey("");
|
||||
setLabels([]);
|
||||
setDataType("text");
|
||||
setVocabularyId("");
|
||||
setAuthorityKind("");
|
||||
setGroup("");
|
||||
setRequired(false);
|
||||
};
|
||||
|
||||
const onSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const hasEn = labels.some((l) => l.lang === "en" && l.label);
|
||||
const termNeedsVocab = dataType === "term" && !vocabularyId;
|
||||
|
||||
if (!key.trim() || !hasEn || termNeedsVocab) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(false);
|
||||
create.mutate(
|
||||
{
|
||||
key: key.trim(),
|
||||
data_type: dataType,
|
||||
vocabulary_id: dataType === "term" ? vocabularyId : null,
|
||||
authority_kind: dataType === "authority" ? authorityKind || null : null,
|
||||
required,
|
||||
group: group.trim() || null,
|
||||
labels,
|
||||
},
|
||||
{ onSuccess: reset },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
|
||||
<div className="text-sm font-medium">{t("fields.newField")}</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-key">{t("fields.key")}</Label>
|
||||
<Input id="field-key" value={key} onChange={(e) => setKey(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-type">{t("fields.type")}</Label>
|
||||
<select
|
||||
id="field-type"
|
||||
value={dataType}
|
||||
onChange={(e) => setDataType(e.target.value)}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
{TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`fields.types.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{dataType === "term" && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
|
||||
<select
|
||||
id="field-vocab"
|
||||
value={vocabularyId}
|
||||
onChange={(e) => setVocabularyId(e.target.value)}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">{t("form.selectPlaceholder")}</option>
|
||||
{vocabularies?.map((vocab) => (
|
||||
<option key={vocab.id} value={vocab.id}>
|
||||
{vocab.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataType === "authority" && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
|
||||
<select
|
||||
id="field-kind"
|
||||
value={authorityKind}
|
||||
onChange={(e) => setAuthorityKind(e.target.value)}
|
||||
className="w-full rounded border px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">{t("fields.anyKind")}</option>
|
||||
{KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{t(`authorities.${kind}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="field-group">{t("fields.group")}</Label>
|
||||
<Input id="field-group" value={group} onChange={(e) => setGroup(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={required} onCheckedChange={(checked) => setRequired(checked === true)} />
|
||||
{t("fields.required")}
|
||||
</label>
|
||||
|
||||
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||||
{create.isError && <p role="alert" className="text-xs text-red-600">{t("form.rejected")}</p>}
|
||||
|
||||
<Button type="submit" size="sm" disabled={create.isPending}>
|
||||
{t("fields.create")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
Before finishing: open `web/src/components/ui/checkbox.tsx` and confirm the controlled API is `checked` + `onCheckedChange(checked: boolean)` (base-ui). If the signature differs, adapt the `<Checkbox>` usage (no `any`). Also confirm `@/components/ui/label` exports `Label` (the vocab/object forms use it).
|
||||
|
||||
- [ ] **Step 4: Implement `FieldsPage`** — create `web/src/fields/fields-page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { FieldList } from "./field-list";
|
||||
import { FieldForm } from "./field-form";
|
||||
|
||||
export function FieldsPage() {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||
<div className="overflow-hidden border-r">
|
||||
<FieldList />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<FieldForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire the route** — in `web/src/app.tsx`, import `import { FieldsPage } from "./fields/fields-page";` and add inside the `<AppShell>` group:
|
||||
```tsx
|
||||
<Route path="/fields" element={<FieldsPage />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Enable the Fields nav** — in `web/src/shell/app-shell.tsx`:
|
||||
- change `const DISABLED_NAV = ["fields"] as const;` to `const DISABLED_NAV = [] as const;`
|
||||
- add a Fields `NavLink` after the Search NavLink (before the `DISABLED_NAV.map(...)`):
|
||||
```tsx
|
||||
<NavLink
|
||||
to="/fields"
|
||||
className={({ isActive }) =>
|
||||
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||
}
|
||||
>
|
||||
{t("nav.fields")}
|
||||
</NavLink>
|
||||
```
|
||||
The `DISABLED_NAV.map(...)` block now renders nothing (empty array) — that is fine; leave it, or remove it if eslint flags an unused `nav.soon`. (`nav.soon` may become unused — if `pnpm lint`/parity complains, leave the key in both i18n files; an unused i18n key is harmless and keeps parity.)
|
||||
|
||||
- [ ] **Step 7: Write the integration test** — create `web/src/fields/fields.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { expect, test } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import { server } from "../test/server";
|
||||
import { renderApp } from "../test/render";
|
||||
import { FieldsPage } from "./fields-page";
|
||||
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/fields" element={<FieldsPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
test("lists field definitions grouped, with an Other heading for ungrouped", async () => {
|
||||
renderApp(tree(), { route: "/fields" });
|
||||
// grouped fixture entry (group "Description") and an ungrouped one ("Other")
|
||||
expect(await screen.findByText("Inscription")).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Description$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Other$/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("creates a text field — posts the body and clears the key input", async () => {
|
||||
let body: { key: string; data_type: string } | undefined;
|
||||
server.use(
|
||||
http.post("/api/admin/field-definitions", async ({ request }) => {
|
||||
body = (await request.json()) as { key: string; data_type: string };
|
||||
return HttpResponse.json({ key: "notes" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
renderApp(tree(), { route: "/fields" });
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^key$/i), "notes");
|
||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Notes");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||
|
||||
await waitFor(() => expect(body?.key).toBe("notes"));
|
||||
expect(body?.data_type).toBe("text");
|
||||
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue(""));
|
||||
});
|
||||
|
||||
test("selecting Term reveals the vocabulary picker and blocks submit until chosen", async () => {
|
||||
let posted = false;
|
||||
server.use(
|
||||
http.post("/api/admin/field-definitions", () => {
|
||||
posted = true;
|
||||
return HttpResponse.json({ key: "x" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
renderApp(tree(), { route: "/fields" });
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^key$/i), "material");
|
||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Material");
|
||||
await userEvent.selectOptions(screen.getByLabelText(/^type$/i), "term");
|
||||
|
||||
// Vocabulary select now present.
|
||||
const vocab = await screen.findByLabelText(/^vocabulary$/i);
|
||||
expect(vocab).toBeInTheDocument();
|
||||
|
||||
// Submit without choosing a vocabulary → blocked, alert shown, no POST.
|
||||
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(posted).toBe(false);
|
||||
|
||||
// Choose one (fixture vocabularies: v-material/material, v-technique/technique) → posts.
|
||||
await userEvent.selectOptions(vocab, "v-material");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create field/i }));
|
||||
await waitFor(() => expect(posted).toBe(true));
|
||||
});
|
||||
```
|
||||
Run `pnpm test src/fields/fields.test.tsx`. If `getByLabelText(/^key$/i)` is ambiguous (the EN/SV label inputs from `LabelEditor` use `labels.en`/`labels.sv` text), the anchored `/^key$/i` should match only the "Key" `<Label htmlFor="field-key">`; if not, scope with the field id. The `vocabularies` fixture is the existing one (`v-material`/`material`, `v-technique`/`technique`).
|
||||
|
||||
- [ ] **Step 8: Update the app-shell test** — open `web/src/shell/app-shell.test.tsx`. It currently asserts `fields` (and/or `search`) is a disabled button. Update so **Fields is now a link** (`getByRole("link", { name: /fields/i })`); there are no disabled nav buttons left — if a test asserted a disabled button exists, remove/replace that assertion. Run `pnpm test src/shell/app-shell.test.tsx` → PASS.
|
||||
|
||||
- [ ] **Step 9: Full verify** — `pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. Report the bundle gz number. If `check:size` > 150 KB gz, lazy-load `/fields` in `app.tsx` (mirror the `ObjectNewPage` lazy pattern: `const FieldsPage = lazy(() => import("./fields/fields-page").then((m) => ({ default: m.FieldsPage })))` + wrap the route element in `<Suspense fallback={<FormFallback />}>`), then re-run check:size.
|
||||
|
||||
- [ ] **Step 10: Commit** —
|
||||
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "feat(web): /fields two-pane screen (grouped list + create form) + nav (no stubs left)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: i18n parity + full verification
|
||||
|
||||
**Files:** none expected (verification); fix-ups only if a check fails.
|
||||
|
||||
- [ ] **Step 1: i18n parity** —
|
||||
|
||||
```bash
|
||||
cd web
|
||||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||
```
|
||||
Expected `PARITY OK`; fix any mismatch.
|
||||
|
||||
- [ ] **Step 2: Full frontend verification** —
|
||||
```bash
|
||||
cd web
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||
```
|
||||
Expected clean; all tests pass; bundle ≤150 KB gz (report the number).
|
||||
|
||||
- [ ] **Step 3: Full backend verification** —
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \
|
||||
MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \
|
||||
cargo test -p api
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo +nightly fmt --check
|
||||
```
|
||||
Expected: all pass; clippy clean; fmt clean.
|
||||
|
||||
- [ ] **Step 4: Commit** — only if Steps 1–2 required a fix:
|
||||
```bash
|
||||
git add web
|
||||
git commit -m "chore(web): fields management verification fix-ups"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage:**
|
||||
- `POST /api/admin/field-definitions`, `EditCatalogue`, `from_parts` validation (422), dup key (409), malformed binding (400), auth → Task 1. ✓
|
||||
- OpenAPI registration + regenerated client → Task 1. ✓
|
||||
- `useCreateFieldDefinition` invalidating `["field-definitions"]` (shared with M2 editor) → Task 2. ✓
|
||||
- Two-pane `/fields`: grouped list (+ "Other"), create form with conditional vocabulary/kind, native selects, LabelEditor reuse, EN-required + term-needs-vocab client validation, `form.rejected` on backend error → Task 3. ✓
|
||||
- Nav enabled, `DISABLED_NAV = []` (no stubs) → Task 3. ✓
|
||||
- i18n sv/en parity, bundle ≤150 KB, full backend+frontend verification → Task 4. ✓
|
||||
- Create + list only (no edit/delete) — respected. ✓
|
||||
|
||||
**Placeholder scan:** none — every code step is complete; the two "confirm the Checkbox API / Label export" notes are concrete verification instructions against named files.
|
||||
|
||||
**Type consistency:** `NewFieldDefinitionRequest`/`CreatedField` (api) ↔ `components["schemas"]["NewFieldDefinitionRequest"]` (web `useCreateFieldDefinition` arg) consistent; `FieldDefinitionView` reused for the list; `data_type` string values (`text|localized_text|integer|date|boolean|term|authority`) match the `TYPES` tuple and the `fields.types.*` i18n keys; the `["field-definitions"]` query key matches `useFieldDefinitions`; `AuthorityKind::from_db`, `FieldType::from_parts`, `db::fields::create_field_definition(&mut tx, &new)`, and `VocabularyId` parse usage all match the confirmed backend signatures.
|
||||
|
||||
## Notes for follow-on
|
||||
- Edit/delete field definitions — needs new `db::fields` update/delete + a referential-integrity policy (block/handle deleting a field objects reference or that is required). File a backend follow-up when this lands.
|
||||
- Per-field validation rules (min/max/regex) — #11. Field/group reordering and renaming. Immutable `key`/`type` after creation.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
||||
# Frontend SPA — Milestone 3 (Publishing Workflow) 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:** Drive a record through the stepwise Draft→Internal→Public visibility pipeline from the SPA via a segmented stepper on the object detail, with confirm-on-publish and the publish-gate surfaced.
|
||||
|
||||
**Architecture:** A pure `adjacentTransitions(visibility)` helper encodes the legal one-step moves; a `useSetVisibility` mutation POSTs to the existing `/api/admin/objects/{id}/visibility` endpoint (throwing a status-carrying error so the UI can branch 422-gate vs 409-illegal); a `PublishControl` component renders a 3-segment stepper + the legal step buttons, confirms only on →Public (reusing the M2 AlertDialog), surfaces the gate/illegal errors inline, and relies on query invalidation to refresh. Rendered on the object detail read view.
|
||||
|
||||
**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, openapi-fetch typed client, shadcn AlertDialog, react-i18next, Vitest + RTL + MSW.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md`
|
||||
|
||||
**Baseline (M1+M2, merged @ `f206ee8`):** `web/src/api/queries.ts` has the object/authoring hooks (`useObject`, `useObjectsPage`, mutations) and the `api` typed client; `web/src/objects/object-detail.tsx` renders the read view with a `VisibilityBadge` in its header; `web/src/objects/visibility-badge.tsx` maps `draft|internal|public` → an i18n'd badge; `renderApp` helper (MemoryRouter + QueryClient); MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`); i18n `web/src/i18n/{en,sv}.json` with `visibility.{draft,internal,public}`, `form.cancel`, `form.rejected`; shadcn AlertDialog at `@/components/ui/alert-dialog`. 34 tests green; bundle ~140 KB gz (budget 150). Run web commands from `web/`.
|
||||
|
||||
**Conventions:** i18n every user-facing string via `t()`, en/sv key parity; NO `any`/`eslint-disable`/`@ts-ignore` (codebase has none); codename "biggus"/"dickus" NOWHERE; each task ends green (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`).
|
||||
|
||||
**Backend contract (verify against `web/src/api/schema.d.ts`):**
|
||||
- `POST /api/admin/objects/{id}/visibility` body `VisibilityRequest { visibility }` → `204`; `404` missing; `409` illegal transition; `422` publish-gate (missing required fields, bare body).
|
||||
- State machine: `Draft↔Internal`, `Internal↔Public` (one step); `Draft→Public`/`Public→Draft` illegal. Gate (422) only on `Internal→Public`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `adjacentTransitions` helper + `useSetVisibility` hook + MSW handler
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/objects/transitions.ts`, `web/src/objects/transitions.test.ts`
|
||||
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`
|
||||
- Test: `web/src/api/queries.visibility.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the failing transitions test** `web/src/objects/transitions.test.ts`
|
||||
|
||||
```ts
|
||||
import { expect, test } from "vitest";
|
||||
import { adjacentTransitions } from "./transitions";
|
||||
|
||||
test("draft can only go forward to internal", () => {
|
||||
expect(adjacentTransitions("draft")).toEqual({ forward: "internal" });
|
||||
});
|
||||
|
||||
test("internal can go forward to public and back to draft", () => {
|
||||
expect(adjacentTransitions("internal")).toEqual({ forward: "public", back: "draft" });
|
||||
});
|
||||
|
||||
test("public can only go back to internal", () => {
|
||||
expect(adjacentTransitions("public")).toEqual({ back: "internal" });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails** — `pnpm test src/objects/transitions.test.ts` → FAIL (no module).
|
||||
|
||||
- [ ] **Step 3: Implement** `web/src/objects/transitions.ts`
|
||||
|
||||
```ts
|
||||
export type Visibility = "draft" | "internal" | "public";
|
||||
|
||||
/** The legal one-step visibility moves from `v`, per the backend state machine
|
||||
* (Draft<->Internal, Internal<->Public; no skipping). */
|
||||
export function adjacentTransitions(v: Visibility): { forward?: Visibility; back?: Visibility } {
|
||||
switch (v) {
|
||||
case "draft":
|
||||
return { forward: "internal" };
|
||||
case "internal":
|
||||
return { forward: "public", back: "draft" };
|
||||
case "public":
|
||||
return { back: "internal" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** — `pnpm test src/objects/transitions.test.ts` → PASS (3).
|
||||
|
||||
- [ ] **Step 5: Add the MSW handler** — append to the `handlers` array in `web/src/test/handlers.ts`:
|
||||
|
||||
```ts
|
||||
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write the failing hook test** `web/src/api/queries.visibility.test.tsx`
|
||||
|
||||
```tsx
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { ReactNode } from "react";
|
||||
import { server } from "../test/server";
|
||||
import { useSetVisibility } from "./queries";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("useSetVisibility", () => {
|
||||
test("POSTs the target visibility and resolves on 204", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useSetVisibility(), { wrapper });
|
||||
await result.current.mutateAsync({ id: "o1", visibility: "internal" });
|
||||
expect((body as { visibility: string }).visibility).toBe("internal");
|
||||
});
|
||||
|
||||
test("throws a status-carrying error on 422 (publish gate)", async () => {
|
||||
server.use(
|
||||
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
|
||||
);
|
||||
const { result } = renderHook(() => useSetVisibility(), { wrapper });
|
||||
await expect(
|
||||
result.current.mutateAsync({ id: "o1", visibility: "public" }),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run to verify it fails** — `pnpm test src/api/queries.visibility.test.tsx` → FAIL (no `useSetVisibility`).
|
||||
|
||||
- [ ] **Step 8: Implement** — append to `web/src/api/queries.ts`:
|
||||
|
||||
```ts
|
||||
import type { Visibility } from "../objects/transitions";
|
||||
|
||||
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
||||
export class VisibilityError extends Error {
|
||||
constructor(public status: number) {
|
||||
super(`visibility change failed (${status})`);
|
||||
this.name = "VisibilityError";
|
||||
}
|
||||
}
|
||||
|
||||
export function useSetVisibility() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
|
||||
const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
|
||||
params: { path: { id } },
|
||||
body: { visibility },
|
||||
});
|
||||
if (response.status !== 204) throw new VisibilityError(response.status);
|
||||
},
|
||||
onSuccess: (_result, { id }) => {
|
||||
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
(Confirm the generated body type for `VisibilityRequest`: if `visibility` is typed as the `Visibility` union the literal works directly; if it's typed as a bare `string`, the union is still assignable. The path key is literally `/api/admin/objects/{id}/visibility`. Reuse the existing `useMutation`/`useQueryClient`/`api`/`components` imports at the top of queries.ts. If importing `Visibility` from `../objects/transitions` creates an undesirable api→objects import direction, instead define the union inline as `"draft" | "internal" | "public"` in queries.ts and keep `transitions.ts`'s `Visibility` separate — pick whichever keeps imports clean; the union value is the contract.)
|
||||
|
||||
- [ ] **Step 9: Run** — `pnpm test src/api/queries.visibility.test.tsx` → PASS (2). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): useSetVisibility hook + adjacentTransitions helper + MSW handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `PublishControl` stepper component
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/objects/publish-control.tsx`, `web/src/objects/publish-control.test.tsx`
|
||||
- Modify: `web/src/i18n/{en,sv}.json`
|
||||
|
||||
- [ ] **Step 1: Add i18n `publish.*` keys** — merge into `web/src/i18n/en.json`:
|
||||
|
||||
```json
|
||||
"publish": {
|
||||
"heading": "Visibility",
|
||||
"advanceInternal": "Advance to internal",
|
||||
"publish": "Publish →",
|
||||
"backToDraft": "← Back to draft",
|
||||
"unpublishInternal": "Unpublish to internal",
|
||||
"confirmTitle": "Publish to public?",
|
||||
"confirmBody": "This will make the record visible on the public API.",
|
||||
"confirm": "Publish",
|
||||
"gateError": "Can't publish — required fields are missing.",
|
||||
"editLink": "Edit the record",
|
||||
"illegalError": "That visibility change isn't allowed."
|
||||
}
|
||||
```
|
||||
and `web/src/i18n/sv.json`:
|
||||
```json
|
||||
"publish": {
|
||||
"heading": "Synlighet",
|
||||
"advanceInternal": "Gör intern",
|
||||
"publish": "Publicera →",
|
||||
"backToDraft": "← Tillbaka till utkast",
|
||||
"unpublishInternal": "Avpublicera till intern",
|
||||
"confirmTitle": "Publicera publikt?",
|
||||
"confirmBody": "Detta gör posten synlig via det publika API:et.",
|
||||
"confirm": "Publicera",
|
||||
"gateError": "Kan inte publicera — obligatoriska fält saknas.",
|
||||
"editLink": "Redigera posten",
|
||||
"illegalError": "Den synlighetsändringen är inte tillåten."
|
||||
}
|
||||
```
|
||||
(Stepper segment labels reuse the existing `visibility.{draft,internal,public}` keys; the dialog Cancel reuses `form.cancel`; the generic error reuses `form.rejected`. Keep en/sv parity.)
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `web/src/objects/publish-control.test.tsx`
|
||||
|
||||
```tsx
|
||||
import { expect, test } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/server";
|
||||
import { renderApp } from "../test/render";
|
||||
import { PublishControl } from "./publish-control";
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||
|
||||
function objectWith(visibility: string): AdminObjectView {
|
||||
return {
|
||||
id: "o-1", object_number: "A-1", object_name: "Amphora", number_of_objects: 1,
|
||||
brief_description: null, current_location: null, current_owner: null,
|
||||
recorder: null, recording_date: null, visibility, fields: {},
|
||||
} as AdminObjectView;
|
||||
}
|
||||
|
||||
test("internal: shows publish (forward) and back-to-draft buttons", async () => {
|
||||
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||
expect(screen.getByRole("button", { name: /publish/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /back to draft/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("draft: forward to internal posts immediately (no confirm)", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
renderApp(<PublishControl object={objectWith("draft")} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /advance to internal/i }));
|
||||
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal"));
|
||||
});
|
||||
|
||||
test("public: back to internal posts immediately", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
renderApp(<PublishControl object={objectWith("public")} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /unpublish to internal/i }));
|
||||
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal"));
|
||||
});
|
||||
|
||||
test("internal -> public requires confirmation, then posts public", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /publish/i }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /publish/i }));
|
||||
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("public"));
|
||||
});
|
||||
|
||||
test("publish gate (422) shows an inline error with an edit link", async () => {
|
||||
server.use(
|
||||
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
|
||||
);
|
||||
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /publish/i }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /publish/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/required fields are missing/i)).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /edit the record/i })).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails** — `pnpm test src/objects/publish-control.test.tsx` → FAIL (no component).
|
||||
|
||||
- [ ] **Step 4: Implement** — `web/src/objects/publish-control.tsx`
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { components } from "../api/schema";
|
||||
import { useSetVisibility, VisibilityError } from "../api/queries";
|
||||
import { adjacentTransitions, type Visibility } from "./transitions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||
const STEPS: Visibility[] = ["draft", "internal", "public"];
|
||||
|
||||
export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||
const { t } = useTranslation();
|
||||
const current = object.visibility as Visibility;
|
||||
const { forward, back } = adjacentTransitions(current);
|
||||
const setVisibility = useSetVisibility();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [errorKind, setErrorKind] = useState<"gate" | "illegal" | "other" | null>(null);
|
||||
|
||||
const go = (visibility: Visibility) => {
|
||||
setErrorKind(null);
|
||||
setVisibility.mutate(
|
||||
{ id: object.id, visibility },
|
||||
{
|
||||
onError: (err) => {
|
||||
const status = err instanceof VisibilityError ? err.status : 0;
|
||||
setErrorKind(status === 422 ? "gate" : status === 409 ? "illegal" : "other");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const currentIndex = STEPS.indexOf(current);
|
||||
|
||||
return (
|
||||
<section className="border-t p-4">
|
||||
<div className="mb-2 text-xs font-medium uppercase text-neutral-500">{t("publish.heading")}</div>
|
||||
|
||||
<div className="mb-3 flex">
|
||||
{STEPS.map((step, i) => (
|
||||
<div key={step}
|
||||
className={`flex-1 border px-2 py-1 text-center text-xs ${
|
||||
i === currentIndex ? "bg-neutral-800 font-semibold text-white"
|
||||
: i < currentIndex ? "bg-neutral-100 text-neutral-600" : "text-neutral-400"}`}>
|
||||
{t(`visibility.${step}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{back && (
|
||||
<Button variant="ghost" size="sm" disabled={setVisibility.isPending}
|
||||
onClick={() => go(back)}>
|
||||
{back === "draft" ? t("publish.backToDraft") : t("publish.unpublishInternal")}
|
||||
</Button>
|
||||
)}
|
||||
{forward === "internal" && (
|
||||
<Button size="sm" disabled={setVisibility.isPending} onClick={() => go("internal")}>
|
||||
{t("publish.advanceInternal")}
|
||||
</Button>
|
||||
)}
|
||||
{forward === "public" && (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button size="sm" disabled={setVisibility.isPending}>{t("publish.publish")}</Button>
|
||||
}
|
||||
/>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t("publish.confirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("publish.confirmBody")}</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => go("public")}>{t("publish.confirm")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorKind === "gate" && (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||
{t("publish.gateError")}{" "}
|
||||
<Link to={`/objects/${object.id}/edit`} className="underline">{t("publish.editLink")}</Link>
|
||||
</p>
|
||||
)}
|
||||
{errorKind === "illegal" && (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">{t("publish.illegalError")}</p>
|
||||
)}
|
||||
{errorKind === "other" && (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">{t("form.rejected")}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
NOTES:
|
||||
- The AlertDialog is composed exactly like M2's `delete-object-dialog.tsx` (Base UI "base-nova" registry — `AlertDialogTrigger render={<Button>}`, controlled `open`/`onOpenChange`). Match that file's working composition; adapt names if the generated exports differ.
|
||||
- The confirm button text (`publish.confirm` = "Publish") and the trigger (`publish.publish` = "Publish →") both match `/publish/i`; the test scopes the confirm click with `within(dialog)`, same pattern as the delete dialog test.
|
||||
- `STEPS.indexOf(current)` drives done/current/pending styling.
|
||||
- The button label for `back` depends on whether it returns to draft or internal.
|
||||
- `VisibilityError` is imported from `queries.ts` (Task 1).
|
||||
|
||||
- [ ] **Step 5: Run** — `pnpm test src/objects/publish-control.test.tsx` → PASS (5). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): PublishControl stepper (legal one-step moves, confirm-on-public, gate/illegal errors)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire into the object detail + full verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/objects/object-detail.tsx`, `web/src/objects/object-detail.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Render `PublishControl` in the detail** — in `web/src/objects/object-detail.tsx`, import it and render it after the inventory-minimum + flexible-field sections (a new section at the bottom of the detail body). Keep the existing `VisibilityBadge` in the header:
|
||||
|
||||
```tsx
|
||||
import { PublishControl } from "./publish-control";
|
||||
// ... at the end of the detail body, after the flexible-fields block:
|
||||
<PublishControl object={object} />
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the detail test to assert the control shows** — append to `web/src/objects/object-detail.test.tsx`:
|
||||
|
||||
```tsx
|
||||
test("detail shows the publish control with the current visibility stepper", async () => {
|
||||
// default GET /api/admin/objects/:id handler returns amphora (visibility "public")
|
||||
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
||||
// the stepper renders all three stages; public => an unpublish (back) button is offered
|
||||
expect(await screen.findByText(/visibility/i)).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /unpublish to internal/i })).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
(Use the existing `tree()` / route + the default `amphora` fixture — confirm `amphora.visibility` is `"public"` in `fixtures.ts`; it is. If the detail test file's structure differs, adapt to render `ObjectDetail` at the amphora id and assert the stepper heading + the public→back button. The default MSW `POST .../visibility` handler returns 204 so no unhandled-request error even if not clicked.)
|
||||
|
||||
- [ ] **Step 3: Run** — `pnpm test src/objects/object-detail.test.tsx` → PASS (existing + new). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`.
|
||||
|
||||
- [ ] **Step 4: i18n parity + bundle check**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
|
||||
pnpm build && pnpm check:size
|
||||
```
|
||||
Expected: `PARITY OK`; bundle ≤150 KB gz (report the number; PublishControl is small — should stay well under).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): show the publish control on the object detail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage:**
|
||||
- Segmented stepper on the detail, current highlighted, legal one-step buttons → Tasks 2, 3. ✓
|
||||
- `adjacentTransitions` (draft→internal; internal↔public/draft; public→internal) → Task 1. ✓
|
||||
- `useSetVisibility` POST + status-carrying error (422/409/other) → Task 1. ✓
|
||||
- Confirm only on →Public (AlertDialog) → Task 2. ✓
|
||||
- 422 gate → inline message + Edit link; 409 illegal → inline (defensive); other → form.rejected → Task 2. ✓
|
||||
- Invalidate object + list on success (badge/stepper refresh) → Task 1. ✓
|
||||
- VisibilityBadge stays in header; control is a new detail section → Task 3. ✓
|
||||
- i18n sv/en parity → Tasks 2, 3. ✓
|
||||
- Testing Vitest+RTL+MSW (helper + component + detail) → Tasks 1–3. ✓
|
||||
- Bundle budget → Task 3. ✓
|
||||
|
||||
**Placeholder scan:** none — complete code in every step; the "adapt to generated VisibilityRequest type / base-nova AlertDialog exports" notes are verification instructions with fixed contracts.
|
||||
|
||||
**Type consistency:** `Visibility` union defined in `transitions.ts` (Task 1) and used by `useSetVisibility` + `PublishControl`; `VisibilityError` defined in `queries.ts` (Task 1) and consumed in `PublishControl` (Task 2); the `{ id, visibility }` mutation arg shape consistent; the AlertDialog composition mirrors the existing `delete-object-dialog.tsx`; route `/objects/:id/edit` (the Edit link) matches the M2 route.
|
||||
|
||||
## Notes for follow-on
|
||||
- Per-field gate detail needs the backend 422 to carry field info (#28) — until then the gate message is generic.
|
||||
- A visibility-change history/audit view is a later milestone (the backend already audits transitions).
|
||||
@@ -0,0 +1,727 @@
|
||||
# Frontend SPA — Milestone 4 (Vocabulary & Authority Management) 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:** Enable the Vocabularies and Authorities admin screens — create/list controlled vocabularies (+ their terms) and authority records (by kind) — with a shared sv/en label editor.
|
||||
|
||||
**Architecture:** Two new screens under the app shell (the previously-disabled nav stubs become active). Vocabularies is a two-pane master–detail (vocab list + create on the left; the selected vocab's terms + add-term on the right) via nested routes like Objects. Authorities is a kind-tabbed list + create at `/authorities/:kind`. A shared controlled `LabelEditor` (sv/en) produces `LabelInput[]`. Four new TanStack Query hooks (one list query + three create mutations) consume the existing admin endpoints; create mutations invalidate the matching list query keys. Create-only (the backend exposes no update/delete for these). Lean forms use local `useState` + inline validation (EN label / vocab key required).
|
||||
|
||||
**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, openapi-fetch typed client, react-i18next, Vitest + RTL + MSW. (No new deps.)
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md`
|
||||
|
||||
**Baseline (M1–M3 merged @ `684b544`):** `web/src/api/queries.ts` has `useTerms(vocabularyId)` (key `["terms",vocabularyId]`) + `useAuthorities(kind)` (key `["authorities",kind]`) plus the object/visibility hooks and the `api` client; nested-route two-pane pattern in `web/src/objects/{objects-page,object-detail}.tsx` + `web/src/objects/select-prompt.tsx`; `web/src/shell/app-shell.tsx` renders Objects as a `NavLink` and `["vocabularies","authorities","fields","search"]` as **disabled** buttons; `renderApp` helper (MemoryRouter + QueryClient); MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`); i18n `web/src/i18n/{en,sv}.json` with `nav.*`, `form.cancel`, `form.rejected`, `visibility.*`. shadcn Button/Input/Label. 45 tests green, ~141 KB gz. Run web commands from `web/`.
|
||||
|
||||
**Conventions:** i18n every user-facing string via `t()`, en/sv key parity; NO `any`/`eslint-disable`/`@ts-ignore`; codename "biggus"/"dickus" NOWHERE; each task ends green (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`).
|
||||
|
||||
**Backend contract (verify against `web/src/api/schema.d.ts`):**
|
||||
- `GET /api/admin/vocabularies` → `VocabularyView[]` (`{id,key}`); `POST` body `NewVocabularyRequest {key}` → `201 VocabularyView`.
|
||||
- `GET /api/admin/vocabularies/{id}/terms` → `TermView[]`; `POST` body `NewTermRequest {external_uri?,labels}` → `201 CreatedId`.
|
||||
- `GET /api/admin/authorities?kind=` → `AuthorityView[]`; `POST` body `NewAuthorityRequest {kind,external_uri?,labels}` → `201 CreatedId`.
|
||||
- `LabelInput`/`LabelView` = `{lang,label}`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Data layer — list + 3 create hooks + MSW handlers + fixture
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`, `web/src/test/fixtures.ts`
|
||||
- Test: `web/src/api/queries.vocab.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add a vocabularies fixture** — append to `web/src/test/fixtures.ts`:
|
||||
```ts
|
||||
import type { components } from "../api/schema";
|
||||
export type VocabularyView = components["schemas"]["VocabularyView"];
|
||||
|
||||
export const vocabularies: VocabularyView[] = [
|
||||
{ id: "v-material", key: "material" },
|
||||
{ id: "v-technique", key: "technique" },
|
||||
];
|
||||
```
|
||||
(`materialTerms` and `personAuthorities` already exist from M2.)
|
||||
|
||||
- [ ] **Step 2: Add the MSW handlers** — in `web/src/test/handlers.ts`, add a GET for the vocabularies list and POST handlers (the GET terms/authorities handlers already exist from M2; do NOT duplicate them). Add:
|
||||
```ts
|
||||
import { vocabularies } from "./fixtures";
|
||||
// in the handlers array:
|
||||
http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)),
|
||||
http.post("/api/admin/vocabularies", () =>
|
||||
HttpResponse.json({ id: "v-new", key: "new" }, { status: 201 })),
|
||||
http.post("/api/admin/vocabularies/:id/terms", () =>
|
||||
HttpResponse.json({ id: "t-new" }, { status: 201 })),
|
||||
http.post("/api/admin/authorities", () =>
|
||||
HttpResponse.json({ id: "a-new" }, { status: 201 })),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the failing hook test** `web/src/api/queries.vocab.test.tsx`
|
||||
```tsx
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import type { ReactNode } from "react";
|
||||
import { server } from "../test/server";
|
||||
import { useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority } from "./queries";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("vocab/authority hooks", () => {
|
||||
test("useVocabularies lists vocabularies", async () => {
|
||||
const { result } = renderHook(() => useVocabularies(), { wrapper });
|
||||
await waitFor(() => expect(result.current.data?.length).toBe(2));
|
||||
expect(result.current.data?.[0].key).toBe("material");
|
||||
});
|
||||
|
||||
test("useCreateVocabulary POSTs the key", async () => {
|
||||
let body: unknown;
|
||||
server.use(http.post("/api/admin/vocabularies", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "v-x", key: "colour" }, { status: 201 });
|
||||
}));
|
||||
const { result } = renderHook(() => useCreateVocabulary(), { wrapper });
|
||||
await result.current.mutateAsync({ key: "colour" });
|
||||
expect((body as { key: string }).key).toBe("colour");
|
||||
});
|
||||
|
||||
test("useAddTerm POSTs labels to the vocabulary", async () => {
|
||||
let body: unknown;
|
||||
server.use(http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "t-x" }, { status: 201 });
|
||||
}));
|
||||
const { result } = renderHook(() => useAddTerm(), { wrapper });
|
||||
await result.current.mutateAsync({
|
||||
vocabularyId: "v-material", external_uri: null,
|
||||
labels: [{ lang: "en", label: "Red" }],
|
||||
});
|
||||
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Red");
|
||||
});
|
||||
|
||||
test("useCreateAuthority POSTs kind + labels", async () => {
|
||||
let body: unknown;
|
||||
server.use(http.post("/api/admin/authorities", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "a-x" }, { status: 201 });
|
||||
}));
|
||||
const { result } = renderHook(() => useCreateAuthority(), { wrapper });
|
||||
await result.current.mutateAsync({
|
||||
kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada" }],
|
||||
});
|
||||
expect((body as { kind: string }).kind).toBe("person");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it fails** — `pnpm test src/api/queries.vocab.test.tsx` → FAIL (hooks missing).
|
||||
|
||||
- [ ] **Step 5: Implement the hooks** — append to `web/src/api/queries.ts`:
|
||||
```ts
|
||||
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
export function useVocabularies() {
|
||||
return useQuery({
|
||||
queryKey: ["vocabularies"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET("/api/admin/vocabularies");
|
||||
if (error || !data) throw new Error("failed to load vocabularies");
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVocabulary() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: NewVocabularyRequest) => {
|
||||
const { data, error } = await api.POST("/api/admin/vocabularies", { body });
|
||||
if (error || !data) throw new Error("create vocabulary failed");
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTerm() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ vocabularyId, external_uri, labels }: {
|
||||
vocabularyId: string; external_uri: string | null; labels: LabelInput[];
|
||||
}) => {
|
||||
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
|
||||
params: { path: { id: vocabularyId } },
|
||||
body: { external_uri, labels },
|
||||
});
|
||||
if (response.status !== 201) throw new Error("add term failed");
|
||||
},
|
||||
onSuccess: (_r, { vocabularyId }) =>
|
||||
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAuthority() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ kind, external_uri, labels }: {
|
||||
kind: string; external_uri: string | null; labels: LabelInput[];
|
||||
}) => {
|
||||
const { response } = await api.POST("/api/admin/authorities", {
|
||||
body: { kind, external_uri, labels },
|
||||
});
|
||||
if (response.status !== 201) throw new Error("create authority failed");
|
||||
},
|
||||
onSuccess: (_r, { kind }) =>
|
||||
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
|
||||
});
|
||||
}
|
||||
```
|
||||
(Verify path keys + body types against `schema.d.ts`. `useQuery`/`useMutation`/`useQueryClient`/`api`/`components` are already imported. The `["terms",vocabularyId]`/`["authorities",kind]` keys MUST match the existing `useTerms`/`useAuthorities` keys so invalidation refetches — confirm by reading those two hooks. If `NewTermRequest`/`NewAuthorityRequest` require non-null `external_uri`, pass `null` is fine since they're `string | null`.)
|
||||
|
||||
- [ ] **Step 6: Run** — `pnpm test src/api/queries.vocab.test.tsx` → PASS (4). Full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): vocabulary/term/authority list+create hooks + MSW handlers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Shared `LabelEditor` (sv/en)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/label-editor.tsx`, `web/src/components/label-editor.test.tsx`
|
||||
- Modify: `web/src/i18n/{en,sv}.json`
|
||||
|
||||
- [ ] **Step 1: i18n** — merge a `labels` namespace into `en.json`: `"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }`; `sv.json`: `"labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }`. Keep parity.
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `web/src/components/label-editor.test.tsx`
|
||||
```tsx
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderApp } from "../test/render";
|
||||
import { LabelEditor } from "./label-editor";
|
||||
import type { components } from "../api/schema";
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||
return <LabelEditor value={[]} onChange={onChange} />;
|
||||
}
|
||||
|
||||
test("typing EN and SV emits both labels; empty langs are omitted", async () => {
|
||||
const seen: LabelInput[][] = [];
|
||||
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
|
||||
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
|
||||
const last = seen.at(-1)!;
|
||||
expect(last).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ lang: "en", label: "Bronze" },
|
||||
{ lang: "sv", label: "Brons" },
|
||||
]),
|
||||
);
|
||||
// an editor with only EN filled emits just the EN entry
|
||||
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement** — `web/src/components/label-editor.tsx`
|
||||
```tsx
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { components } from "../api/schema";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */
|
||||
export function LabelEditor({
|
||||
value, onChange,
|
||||
}: {
|
||||
value: LabelInput[];
|
||||
onChange: (labels: LabelInput[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? "";
|
||||
|
||||
const set = (lang: string, label: string) => {
|
||||
const others = value.filter((l) => l.lang !== lang);
|
||||
onChange(label ? [...others, { lang, label }] : others);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label-en">{t("labels.en")}</Label>
|
||||
<Input id="label-en" value={valueFor("en")} onChange={(e) => set("en", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
|
||||
<Input id="label-sv" value={valueFor("sv")} onChange={(e) => set("sv", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
(Controlled: parent owns the `value` array. `set` replaces the entry for that lang or drops it when cleared, so empty langs never appear in the emitted array.)
|
||||
|
||||
- [ ] **Step 4: Run** — `pnpm test src/components/label-editor.test.tsx` → PASS. Full `pnpm test`/typecheck/lint/build clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): shared sv/en LabelEditor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Vocabularies screen (two-pane) + route + nav enable
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/vocab/vocabularies-page.tsx`, `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/vocabularies.test.tsx`
|
||||
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
|
||||
|
||||
- [ ] **Step 1: i18n** — merge a `vocab` namespace into `en.json`:
|
||||
```json
|
||||
"vocab": {
|
||||
"title": "Vocabularies", "newVocabulary": "New vocabulary", "key": "Key",
|
||||
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
||||
"terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet",
|
||||
"noTerms": "No terms yet", "loadError": "Could not load"
|
||||
}
|
||||
```
|
||||
`sv.json`:
|
||||
```json
|
||||
"vocab": {
|
||||
"title": "Vokabulär", "newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
||||
"terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu",
|
||||
"noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda"
|
||||
}
|
||||
```
|
||||
Keep parity.
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `web/src/vocab/vocabularies.test.tsx`
|
||||
```tsx
|
||||
import { expect, test } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { server } from "../test/server";
|
||||
import { renderApp } from "../test/render";
|
||||
import { VocabulariesPage } from "./vocabularies-page";
|
||||
import { VocabularyTerms } from "./vocabulary-terms";
|
||||
import { SelectPrompt } from "../objects/select-prompt";
|
||||
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||
<Route index element={<div>pick a vocabulary</div>} />
|
||||
<Route path=":id" element={<VocabularyTerms />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
test("lists vocabularies and creates one", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/vocabularies", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "v-c", key: "colour" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
renderApp(tree(), { route: "/vocabularies" });
|
||||
expect(await screen.findByText("material")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/key/i), "colour");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => expect((body as { key: string })?.key).toBe("colour"));
|
||||
});
|
||||
|
||||
test("selecting a vocabulary shows its terms and adds one", async () => {
|
||||
let termBody: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
|
||||
termBody = await request.json();
|
||||
return HttpResponse.json({ id: "t-c" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
renderApp(tree(), { route: "/vocabularies/v-material" });
|
||||
// material terms come from the default MSW handler (materialTerms: Bronze, Wood)
|
||||
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone");
|
||||
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
||||
await waitFor(() =>
|
||||
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `VocabulariesPage`** — `web/src/vocab/vocabularies-page.tsx`
|
||||
```tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { VocabularyList } from "./vocabulary-list";
|
||||
|
||||
export function VocabulariesPage() {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||
<div className="overflow-hidden border-r">
|
||||
<VocabularyList />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement `VocabularyList`** — `web/src/vocab/vocabulary-list.tsx`
|
||||
```tsx
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVocabularies, useCreateVocabulary } from "../api/queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function VocabularyList() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = useVocabularies();
|
||||
const create = useCreateVocabulary();
|
||||
const [key, setKey] = useState("");
|
||||
|
||||
const onCreate = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!key.trim()) return;
|
||||
create.mutate({ key: key.trim() }, { onSuccess: () => setKey("") });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<form onSubmit={onCreate} className="space-y-1 border-b p-3">
|
||||
<Label htmlFor="vocab-key">{t("vocab.key")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="vocab-key" value={key} onChange={(e) => setKey(e.target.value)} />
|
||||
<Button type="submit" size="sm" disabled={create.isPending}>{t("vocab.create")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{isLoading && <li className="p-3 text-sm text-neutral-400">…</li>}
|
||||
{isError && <li className="p-3 text-sm text-red-600">{t("vocab.loadError")}</li>}
|
||||
{data?.length === 0 && <li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li>}
|
||||
{data?.map((v) => (
|
||||
<li key={v.id}>
|
||||
<NavLink to={`/vocabularies/${v.id}`}
|
||||
className={({ isActive }) =>
|
||||
`block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
|
||||
{v.key}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Implement `VocabularyTerms`** — `web/src/vocab/vocabulary-terms.tsx`
|
||||
```tsx
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { components } from "../api/schema";
|
||||
import { useTerms, useAddTerm } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
type LabelView = components["schemas"]["LabelView"];
|
||||
|
||||
function labelText(labels: LabelView[], lang: string): string {
|
||||
return labels.find((l) => l.lang === lang)?.label ?? labels.find((l) => l.lang === "en")?.label ?? labels[0]?.label ?? "";
|
||||
}
|
||||
|
||||
export function VocabularyTerms() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { id } = useParams();
|
||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||
const { data: terms } = useTerms(id);
|
||||
const addTerm = useAddTerm();
|
||||
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||
const [uri, setUri] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const onAdd = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!labels.some((l) => l.lang === "en" && l.label)) { setError(true); return; }
|
||||
setError(false);
|
||||
addTerm.mutate(
|
||||
{ vocabularyId: id!, external_uri: uri.trim() || null, labels },
|
||||
{ onSuccess: () => { setLabels([]); setUri(""); } },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
<h3 className="mb-2 text-sm font-medium uppercase text-neutral-500">{t("vocab.terms")}</h3>
|
||||
<ul className="mb-4">
|
||||
{terms?.length === 0 && <li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>}
|
||||
{terms?.map((term) => (
|
||||
<li key={term.id} className="border-b py-1 text-sm">{labelText(term.labels, lang)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
||||
<div className="text-sm font-medium">{t("vocab.addTerm")}</div>
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="term-uri">{t("labels.externalUri")}</Label>
|
||||
<Input id="term-uri" value={uri} onChange={(e) => setUri(e.target.value)} />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||||
<Button type="submit" size="sm" disabled={addTerm.isPending}>{t("vocab.addTerm")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
(`form.required` exists from M2. The EN-required check reads the `labels` array. `useTerms(id)` reuses the existing hook + key.)
|
||||
|
||||
- [ ] **Step 6: Wire the route + enable the Vocabularies nav**
|
||||
|
||||
In `web/src/app.tsx`, add inside the protected `AppShell` group:
|
||||
```tsx
|
||||
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||
<Route index element={<SelectVocabularyPrompt />} />
|
||||
<Route path=":id" element={<VocabularyTerms />} />
|
||||
</Route>
|
||||
```
|
||||
For the index prompt, reuse a small prompt — either import the Objects `SelectPrompt` or add a `vocab`-specific one. Simplest: create `web/src/vocab/select-vocabulary-prompt.tsx` rendering `t("vocab.selectPrompt")` (mirror `objects/select-prompt.tsx`), import as `SelectVocabularyPrompt`. (Adjust the test's index element to match if you reference it.)
|
||||
|
||||
In `web/src/shell/app-shell.tsx`, change the nav so `vocabularies` is an active `NavLink` to `/vocabularies` (like the Objects link), removing it from the disabled `FUTURE` list. Keep `authorities`, `fields`, `search` disabled for now (authorities is enabled in Task 4). E.g. render Objects + Vocabularies as `NavLink`s and `["authorities","fields","search"]` as disabled buttons.
|
||||
|
||||
- [ ] **Step 7: Run** — `pnpm test src/vocab/vocabularies.test.tsx` → PASS (2). Update the app-shell test if it asserted `vocabularies` was a disabled button (it asserted `search` is disabled — unaffected; but if it checked vocabularies specifically, update it). Full `pnpm test`, typecheck, lint, build clean.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): vocabularies two-pane screen (list/create + terms/add) + nav"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Authorities screen (kind tabs) + route + nav enable
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/authorities/authorities-page.tsx`, `web/src/authorities/authorities.test.tsx`
|
||||
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
|
||||
|
||||
- [ ] **Step 1: i18n** — merge an `authorities` namespace into `en.json`:
|
||||
```json
|
||||
"authorities": {
|
||||
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
|
||||
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
|
||||
}
|
||||
```
|
||||
`sv.json`:
|
||||
```json
|
||||
"authorities": {
|
||||
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
|
||||
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
|
||||
}
|
||||
```
|
||||
Keep parity.
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `web/src/authorities/authorities.test.tsx`
|
||||
```tsx
|
||||
import { expect, test } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { server } from "../test/server";
|
||||
import { renderApp } from "../test/render";
|
||||
import { AuthoritiesPage } from "./authorities-page";
|
||||
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
test("lists authorities for the kind and creates one", async () => {
|
||||
let body: unknown;
|
||||
server.use(
|
||||
http.post("/api/admin/authorities", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "a-c" }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
renderApp(tree(), { route: "/authorities/person" });
|
||||
// default MSW handler returns personAuthorities (Ada Lovelace) for kind=person
|
||||
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné");
|
||||
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
||||
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
||||
});
|
||||
|
||||
test("kind tabs link to the other kinds", async () => {
|
||||
renderApp(tree(), { route: "/authorities/person" });
|
||||
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `AuthoritiesPage`** — `web/src/authorities/authorities-page.tsx`
|
||||
```tsx
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { NavLink, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { components } from "../api/schema";
|
||||
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||
import { LabelEditor } from "../components/label-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
type LabelView = components["schemas"]["LabelView"];
|
||||
const KINDS = ["person", "organisation", "place"] as const;
|
||||
|
||||
function labelText(labels: LabelView[], lang: string): string {
|
||||
return labels.find((l) => l.lang === lang)?.label ?? labels.find((l) => l.lang === "en")?.label ?? labels[0]?.label ?? "";
|
||||
}
|
||||
|
||||
export function AuthoritiesPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { kind = "person" } = useParams();
|
||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||
const { data: authorities } = useAuthorities(kind);
|
||||
const create = useCreateAuthority();
|
||||
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const onCreate = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!labels.some((l) => l.lang === "en" && l.label)) { setError(true); return; }
|
||||
setError(false);
|
||||
create.mutate(
|
||||
{ kind, external_uri: null, labels },
|
||||
{ onSuccess: () => setLabels([]) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto p-4">
|
||||
<div className="mb-3 flex gap-2">
|
||||
{KINDS.map((k) => (
|
||||
<NavLink key={k} to={`/authorities/${k}`}
|
||||
className={({ isActive }) =>
|
||||
`rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}`}>
|
||||
{t(`authorities.${k}`)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<ul className="mb-4">
|
||||
{authorities?.length === 0 && <li className="text-sm text-neutral-500">{t("authorities.empty")}</li>}
|
||||
{authorities?.map((a) => (
|
||||
<li key={a.id} className="border-b py-1 text-sm">{labelText(a.labels, lang)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
|
||||
<div className="text-sm font-medium">{t("authorities.new")} · {t(`authorities.${kind}`)}</div>
|
||||
<LabelEditor value={labels} onChange={setLabels} />
|
||||
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||||
<Button type="submit" size="sm" disabled={create.isPending}>{t("authorities.create")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
(`useAuthorities(kind)` reuses the existing hook + key. The kind comes from the route param. Unknown-kind validation is handled by the route redirect in Step 4.)
|
||||
|
||||
- [ ] **Step 4: Wire routes + enable the Authorities nav**
|
||||
|
||||
In `web/src/app.tsx`, add inside `AppShell`:
|
||||
```tsx
|
||||
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
||||
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||
```
|
||||
(`Navigate` is already imported in app.tsx.)
|
||||
|
||||
In `web/src/shell/app-shell.tsx`, make `authorities` an active `NavLink` to `/authorities` (alongside Objects + Vocabularies); keep `fields` + `search` disabled.
|
||||
|
||||
- [ ] **Step 5: Run** — `pnpm test src/authorities/authorities.test.tsx` → PASS (2). Full `pnpm test`, typecheck, lint, build clean. (Update the app-shell test if it asserted authorities was disabled.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "feat(web): authorities kind-tabbed screen (list/create) + nav"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: i18n parity + full verification
|
||||
|
||||
**Files:** none expected (verification); fix-ups only if a check fails.
|
||||
|
||||
- [ ] **Step 1: i18n parity check** —
|
||||
```bash
|
||||
cd web
|
||||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
|
||||
```
|
||||
Expected `PARITY OK`; fix any mismatch.
|
||||
|
||||
- [ ] **Step 2: app-shell nav test** — confirm `web/src/shell/app-shell.test.tsx` still passes; the Vocabularies + Authorities items are now `NavLink`s (role=link) and `fields`/`search` remain disabled buttons. If the existing test asserted vocabularies/authorities were disabled, update those assertions to expect links; keep asserting `search`/`fields` disabled.
|
||||
|
||||
- [ ] **Step 3: Full verification** —
|
||||
```bash
|
||||
cd web
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||
```
|
||||
Expected: clean; all tests pass; bundle ≤150 KB gz (report the number — the new screens are small; if it exceeds, lazy-load the vocab/authorities routes via `React.lazy` in `app.tsx` like the M2 forms, and re-verify).
|
||||
|
||||
- [ ] **Step 4: Commit** — only if Steps 1–2 required a fix:
|
||||
```bash
|
||||
cd ..
|
||||
git add web
|
||||
git commit -m "chore(web): m4 i18n parity + nav test updates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage:**
|
||||
- Nav stubs enabled + routes → Tasks 3, 4. ✓
|
||||
- Vocabularies list/create + terms list/add (two-pane) → Task 3. ✓
|
||||
- Authorities kind-tabbed list/create → Task 4. ✓
|
||||
- Shared sv/en `LabelEditor`, EN-required → Task 2 (+ EN-required enforced in Tasks 3, 4 forms). ✓
|
||||
- 4 new hooks + invalidation of the existing `["terms",id]`/`["authorities",kind]`/`["vocabularies"]` keys → Task 1. ✓
|
||||
- Create-only (no edit/delete) → respected throughout. ✓
|
||||
- Error/loading/empty states → Tasks 3, 4. ✓
|
||||
- i18n sv/en parity → Tasks 2–4 + Task 5 check. ✓
|
||||
- Testing Vitest+RTL+MSW → Tasks 1–4. ✓
|
||||
- Bundle budget → Task 5. ✓
|
||||
|
||||
**Placeholder scan:** none — complete code in every step; the "verify path/body types against schema.d.ts" and "reuse SelectPrompt or add a vocab prompt" notes are concrete verification/choice instructions.
|
||||
|
||||
**Type consistency:** `LabelInput`/`LabelView` used consistently; hooks `useVocabularies`/`useCreateVocabulary`/`useAddTerm`/`useCreateAuthority` defined in Task 1 and consumed in Tasks 3–4; `useAddTerm` takes `{vocabularyId, external_uri, labels}` and `useCreateAuthority` `{kind, external_uri, labels}` consistently across plan + tests; `LabelEditor` `value`/`onChange` contract consistent; invalidation keys (`["terms",vocabularyId]`, `["authorities",kind]`, `["vocabularies"]`) match the existing read hooks; routes (`/vocabularies`, `/vocabularies/:id`, `/authorities/:kind`) consistent across Tasks 3–4 + app.tsx.
|
||||
|
||||
## Notes for follow-on
|
||||
- Edit/delete of vocab/term/authority needs backend endpoints — file a backend follow-up when M4 lands.
|
||||
- Audit of vocab/authority creation (#21); searchable pickers (#27); enum typing (#29).
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user