Compare commits
352 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 |
@@ -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
+677
-10
File diff suppressed because it is too large
Load Diff
+9
-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"
|
||||
@@ -13,7 +13,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres",
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = { version = "0.3", features = ["serde"] }
|
||||
time = { version = "0.3", features = ["serde", "macros", "parsing", "formatting"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
utoipa = { version = "5", features = ["uuid"] }
|
||||
anyhow = "1"
|
||||
@@ -23,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);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ 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,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));
|
||||
+133
-3
@@ -1,16 +1,25 @@
|
||||
//! Authority records (person / organisation / place).
|
||||
|
||||
use domain::{Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuthority};
|
||||
use domain::{
|
||||
AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel,
|
||||
NewAuditEvent, NewAuthority,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::audit;
|
||||
|
||||
const AUTHORITY_ENTITY_TYPE: &str = "authority";
|
||||
|
||||
/// Labels aggregated per row as JSON, to read an authority and its labels in one query.
|
||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \
|
||||
ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)";
|
||||
|
||||
/// Insert an authority and its labels. Multiple statements — pass a transaction
|
||||
/// connection (`&mut *tx`) for atomicity.
|
||||
/// Insert an authority and its labels, then record a `created` audit entry. Multiple
|
||||
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
|
||||
/// atomically.
|
||||
pub async fn create_authority(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
new: &NewAuthority,
|
||||
) -> Result<AuthorityId, sqlx::Error> {
|
||||
let id = AuthorityId::new();
|
||||
@@ -31,6 +40,18 @@ pub async fn create_authority(
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Created,
|
||||
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -103,6 +124,115 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+19
-3
@@ -2,10 +2,25 @@
|
||||
|
||||
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 {
|
||||
@@ -13,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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+259
-10
@@ -1,23 +1,45 @@
|
||||
//! Controlled vocabularies and terms.
|
||||
|
||||
use domain::{LocalizedLabel, NewTerm, Term, TermId, TermRef, Vocabulary, VocabularyId};
|
||||
use domain::{
|
||||
AuditAction, AuditActor, LocalizedLabel, NewAuditEvent, NewTerm, Term, TermId, TermRef,
|
||||
Vocabulary, VocabularyId,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::audit;
|
||||
|
||||
const VOCABULARY_ENTITY_TYPE: &str = "vocabulary";
|
||||
const TERM_ENTITY_TYPE: &str = "term";
|
||||
|
||||
/// Labels aggregated per row as JSON, to read a term and its labels in one query.
|
||||
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', tl.lang, 'label', tl.label) \
|
||||
ORDER BY tl.lang) FILTER (WHERE tl.term_id IS NOT NULL), '[]'::json)";
|
||||
|
||||
/// Create a vocabulary with the given key.
|
||||
pub async fn create_vocabulary<'e, E>(executor: E, key: &str) -> Result<Vocabulary, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
/// Create a vocabulary with the given key and record a `created` audit entry, both on
|
||||
/// `conn` (pass a transaction connection `&mut *tx` so they commit atomically).
|
||||
pub async fn create_vocabulary(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
key: &str,
|
||||
) -> Result<Vocabulary, sqlx::Error> {
|
||||
let id = VocabularyId::new();
|
||||
|
||||
sqlx::query("INSERT INTO vocabulary (id, key) VALUES ($1, $2)")
|
||||
.bind(id.to_uuid())
|
||||
.bind(key)
|
||||
.execute(executor)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Created,
|
||||
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Vocabulary {
|
||||
@@ -26,6 +48,18 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -42,9 +76,14 @@ where
|
||||
row.map(map_vocabulary).transpose()
|
||||
}
|
||||
|
||||
/// Insert a term and its labels. Multiple statements — pass a transaction
|
||||
/// connection (`&mut *tx`) so the term and its labels commit atomically.
|
||||
pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<TermId, sqlx::Error> {
|
||||
/// Insert a term and its labels, then record a `created` audit entry. Multiple
|
||||
/// statements — pass a transaction connection (`&mut *tx`) so everything commits
|
||||
/// atomically.
|
||||
pub async fn add_term(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
actor: AuditActor,
|
||||
new: &NewTerm,
|
||||
) -> Result<TermId, sqlx::Error> {
|
||||
let id = TermId::new();
|
||||
|
||||
sqlx::query("INSERT INTO term (id, vocabulary_id, external_uri) VALUES ($1, $2, $3)")
|
||||
@@ -63,6 +102,18 @@ pub async fn add_term(conn: &mut sqlx::PgConnection, new: &NewTerm) -> Result<Te
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit::record(
|
||||
&mut *conn,
|
||||
&NewAuditEvent {
|
||||
actor,
|
||||
action: AuditAction::Created,
|
||||
entity_type: TERM_ENTITY_TYPE.to_owned(),
|
||||
entity_id: id.to_uuid(),
|
||||
changes: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
@@ -126,6 +177,204 @@ where
|
||||
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")?),
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
use db::{Db, authority};
|
||||
use domain::{AuthorityKind, LocalizedLabel, NewAuthority};
|
||||
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,
|
||||
@@ -24,7 +40,11 @@ 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"))
|
||||
let id = authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&new_person("Carl Larsson", "Carl Larsson"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
@@ -47,11 +67,12 @@ async fn list_by_kind_filters(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
authority::create_authority(&mut tx, &new_person("A", "A"))
|
||||
authority::create_authority(&mut tx, AuditActor::System, &new_person("A", "A"))
|
||||
.await
|
||||
.unwrap();
|
||||
authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Place,
|
||||
external_uri: None,
|
||||
@@ -83,7 +104,7 @@ async fn resolve_authority_returns_kind(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = authority::create_authority(&mut tx, &new_person("X", "X"))
|
||||
let id = authority::create_authority(&mut tx, AuditActor::System, &new_person("X", "X"))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
@@ -108,6 +129,7 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = authority::create_authority(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewAuthority {
|
||||
kind: AuthorityKind::Organisation,
|
||||
external_uri: None,
|
||||
@@ -125,3 +147,117 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,18 @@ async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) {
|
||||
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);
|
||||
@@ -41,3 +53,34 @@ async fn migrate_creates_vocabulary_and_authority_tables(pool: PgPool) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
+258
-9
@@ -1,13 +1,18 @@
|
||||
use db::{Db, vocab};
|
||||
use domain::{LocalizedLabel, NewTerm};
|
||||
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 v = vocab::create_vocabulary(db.pool(), "material")
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let found = vocab::vocabulary_by_key(db.pool(), "material")
|
||||
.await
|
||||
@@ -27,13 +32,16 @@ async fn vocabulary_create_and_lookup(pool: PgPool) {
|
||||
#[sqlx::test]
|
||||
async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: v.id,
|
||||
external_uri: Some("http://vocab.getty.edu/aat/300011914".into()),
|
||||
@@ -76,13 +84,16 @@ async fn term_with_multilingual_labels_round_trips(pool: PgPool) {
|
||||
#[sqlx::test]
|
||||
async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let v = vocab::create_vocabulary(db.pool(), "material")
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: v.id,
|
||||
external_uri: None,
|
||||
@@ -103,10 +114,14 @@ async fn term_with_no_labels_round_trips_empty(pool: PgPool) {
|
||||
#[sqlx::test]
|
||||
async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
vocab::create_vocabulary(db.pool(), "material")
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
let err = vocab::create_vocabulary(db.pool(), "material")
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let err = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
@@ -118,16 +133,19 @@ async fn duplicate_vocabulary_key_is_rejected(pool: PgPool) {
|
||||
#[sqlx::test]
|
||||
async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let material = vocab::create_vocabulary(db.pool(), "material")
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let material = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
|
||||
.await
|
||||
.unwrap();
|
||||
let technique = vocab::create_vocabulary(db.pool(), "technique")
|
||||
let technique = vocab::create_vocabulary(&mut tx, AuditActor::System, "technique")
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let term_id = vocab::add_term(
|
||||
&mut tx,
|
||||
AuditActor::System,
|
||||
&NewTerm {
|
||||
vocabulary_id: material.id,
|
||||
external_uri: None,
|
||||
@@ -154,3 +172,234 @@ async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ uuid.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
time.workspace = true
|
||||
utoipa.workspace = true
|
||||
|
||||
@@ -4,6 +4,10 @@ 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 {
|
||||
|
||||
@@ -3,7 +3,11 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{AuthorityId, LocalizedLabel};
|
||||
|
||||
/// The kind of authority record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// 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,
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,18 @@ 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 {
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
mod audit;
|
||||
mod authority;
|
||||
mod field_definition;
|
||||
mod id;
|
||||
mod label;
|
||||
mod object;
|
||||
mod user;
|
||||
mod vocabulary;
|
||||
|
||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
||||
pub use id::{AuthorityId, OrgId, TermId, VocabularyId};
|
||||
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,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,
|
||||
}
|
||||
|
||||
+153
-3
@@ -2,39 +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
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.
|
||||
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
@@ -0,0 +1,217 @@
|
||||
# Tier 2 Papercuts Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Clear a batch of small, well-specified correctness/observability/UX fixes from the issue tracker (#22, #18, #9, #4, #34, #31, #32, #37) — no new features.
|
||||
|
||||
**Architecture:** Independent small fixes grouped by area into four tasks: backend API behaviour (#22, #18), backend cleanup (#9, #4), frontend states/a11y (#34, #31, #32, #37), then verification.
|
||||
|
||||
**Tech Stack:** Rust (axum, sqlx, tracing), React + TS, TanStack Query, react-i18next, Vitest + RTL + MSW.
|
||||
|
||||
**Conventions (every task):** nightly `cargo +nightly fmt`; `cargo clippy`. Frontend: no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; no codename "biggus"/"dickus". Test infra via compose: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev` (this machine's override port), `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend API — 404 for missing vocabulary (#22) + log public 500s (#18)
|
||||
|
||||
**Files:** Modify `crates/api/src/admin_vocab.rs`, `crates/api/src/public.rs`; Test in the existing `crates/api/tests/admin_catalog.rs` (vocab/authority harness).
|
||||
|
||||
### #22 — `add_term` returns 404 when the vocabulary doesn't exist
|
||||
Today `db::vocab::add_term(...)` maps every error to 500; a well-formed `{id}` for a missing vocabulary triggers a foreign-key violation (SQLSTATE 23503) that should be **404**.
|
||||
|
||||
- [ ] **Step 1: Failing test** — add to `crates/api/tests/admin_catalog.rs` (mirror its existing seed-editor/login/oneshot harness). Read the file first to reuse its helpers:
|
||||
```rust
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn add_term_to_missing_vocabulary_is_404(pool: PgPool) {
|
||||
// (use this file's existing migrate_sessions + seed editor + login helpers)
|
||||
let app = /* build_app with state */;
|
||||
let cookie = /* login as editor */;
|
||||
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);
|
||||
}
|
||||
```
|
||||
(Match the exact helper names/signatures already in `admin_catalog.rs`. If that file doesn't have a login helper, copy the pattern from `crates/api/tests/admin_fields.rs`.)
|
||||
|
||||
- [ ] **Step 2: Run → fails** (currently 500): `cargo test -p api --test admin_catalog add_term_to_missing_vocabulary`.
|
||||
|
||||
- [ ] **Step 3: Fix** — in `crates/api/src/admin_vocab.rs` `add_term`, replace:
|
||||
```rust
|
||||
let term_id = db::vocab::add_term(&mut tx, &new)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let term_id = db::vocab::add_term(&mut tx, &new).await.map_err(|err| {
|
||||
// A well-formed id for a missing vocabulary hits the FK constraint (23503).
|
||||
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
tracing::error!(?err, "adding term");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run → passes**, and confirm adding a term to an existing vocab still returns 201 (existing tests cover this).
|
||||
|
||||
### #18 — log the discarded `sqlx::Error` on public 500 paths
|
||||
`crates/api/src/public.rs` discards errors via `.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)` (lines ~74, ~78) and `Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response()` (line ~109). `tracing` is already a dependency of the `api` crate — just log.
|
||||
|
||||
- [ ] **Step 5:** In `list_objects`, change both `.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?` to:
|
||||
```rust
|
||||
.map_err(|err| {
|
||||
tracing::error!(?err, "listing public objects");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
```
|
||||
(use a message specific to each call site — e.g. "listing public objects" and "counting public objects" — match what each query does).
|
||||
|
||||
- [ ] **Step 6:** In `get_object`, change the `Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response()` arm to bind and log the error:
|
||||
```rust
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "fetching public object");
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify** — `cargo +nightly fmt`, `cargo clippy -p api --all-targets`, `cargo test -p api`. Commit:
|
||||
```bash
|
||||
git add crates/api
|
||||
git commit -m "fix(api): 404 when adding a term to a missing vocabulary (#22); log public 500s (#18)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend cleanup — enum/CHECK cross-refs (#9) + dead clone & test handle (#4)
|
||||
|
||||
**Files:** Modify `crates/domain/src/authority.rs`, `crates/domain/src/audit.rs`, `crates/server/src/lib.rs`, `crates/server/tests/serve.rs`.
|
||||
|
||||
> Do **not** edit any file under `crates/db/migrations/` — `sqlx::migrate!()` checksums applied migrations, so editing them (even a comment) breaks existing databases. The cross-reference comments go in the Rust enums only.
|
||||
|
||||
- [ ] **Step 1: #9 — cross-reference comments.**
|
||||
- In `crates/domain/src/authority.rs`, above `pub enum AuthorityKind`, add:
|
||||
```rust
|
||||
/// Allowed kinds. NOTE: kept in sync by hand with the
|
||||
/// `CHECK (kind IN ('person','organisation','place'))` constraint in
|
||||
/// `crates/db/migrations/0002_vocabularies_authorities.sql` — update both together.
|
||||
```
|
||||
- In `crates/domain/src/audit.rs`, above `pub enum AuditAction`, add an equivalent comment pointing at the `action` CHECK in `crates/db/migrations/0001_*.sql` (open the migration to name the exact file + values).
|
||||
|
||||
- [ ] **Step 2: #4 — remove the dead clone.** In `crates/server/src/lib.rs` `run`, the `AppState` is built with `app_name: config.app_name.clone()`. Since `config.app_name` is a `String` and the only later use of `config` is the disjoint field `config.bind_addr`, change it to a move:
|
||||
```rust
|
||||
app_name: config.app_name,
|
||||
```
|
||||
Confirm it still compiles (partial move of one field; `&config.bind_addr` afterward is fine).
|
||||
|
||||
- [ ] **Step 3: #4 — smoke-test handle.** Open `crates/server/tests/serve.rs`. The spawned `serve(...)` task's `.unwrap()` swallows server errors as a task panic, surfacing as a confusing client error. Capture the `JoinHandle` and, after the assertions, either abort it cleanly or check it didn't error — make a server-start failure surface as a clear test failure rather than a `reqwest` error. Read the file and apply the minimal change that propagates/surfaces the server error (e.g. keep the handle, assert it hasn't finished-with-error, or `handle.abort()` at the end). Keep the test green.
|
||||
|
||||
- [ ] **Step 4: Verify** — `cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, `cargo test -p server -p domain`. Commit:
|
||||
```bash
|
||||
git add crates/domain crates/server
|
||||
git commit -m "chore: cross-ref enum/CHECK constraints (#9); drop dead clone + harden smoke test (#4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend — search 503 (#34), list error states (#31), a11y + dead keys (#32), authority-kind test (#37)
|
||||
|
||||
**Files:** Modify `web/src/api/queries.ts`, `web/src/search/search-panel.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/authorities/authorities-page.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/fields/fields.test.tsx`; Tests in `web/src/search/search.test.tsx`, plus the vocab/authorities test files.
|
||||
|
||||
### #34 — distinguish search 503 ("unavailable") from a generic error
|
||||
- [ ] **Step 1:** In `web/src/api/queries.ts`, add a tiny typed error and have `useSearch` throw it with the HTTP status (so the UI can branch without `any`). Near the top:
|
||||
```ts
|
||||
export class HttpError extends Error {
|
||||
constructor(public readonly status: number) {
|
||||
super(`HTTP ${status}`);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
```
|
||||
In `useSearch`'s `queryFn`, replace `if (error || !data) throw new Error("search failed");` with:
|
||||
```ts
|
||||
if (error || !data) throw new HttpError(response.status);
|
||||
```
|
||||
(`response` is already destructured from `api.GET`; if not, add it.)
|
||||
|
||||
- [ ] **Step 2: i18n** — add `search.unavailable` to BOTH `en.json` and `sv.json` (parity):
|
||||
- en: `"unavailable": "Search is not available on this server"`
|
||||
- sv: `"unavailable": "Sök är inte tillgängligt på den här servern"`
|
||||
|
||||
- [ ] **Step 3:** In `web/src/search/search-panel.tsx`, where `search.isError` renders `t("search.loadError")`, branch on a 503:
|
||||
```tsx
|
||||
{hasQuery && search.isError && (
|
||||
<p className="p-4 text-sm text-red-600">
|
||||
{search.error instanceof HttpError && search.error.status === 503
|
||||
? t("search.unavailable")
|
||||
: t("search.loadError")}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
Import `HttpError` from `../api/queries`.
|
||||
|
||||
- [ ] **Step 4: Tests** — in `web/src/search/search.test.tsx`, add: a `503` response → renders `search.unavailable`; a `500` response → renders `search.loadError`. (Use `server.use(http.get("/api/admin/search", () => new HttpResponse(null, { status: 503 })))` etc., then type a query and assert the text.)
|
||||
|
||||
### #31 — loading/error states on the terms + authorities lists
|
||||
- [ ] **Step 5:** In `web/src/vocab/vocabulary-terms.tsx`, the terms list uses `useTerms(id)` but renders empty/data only. Add `isLoading` (skeleton or `…`) and `isError` (`t("vocab.loadError")`) branches before the empty/data render, mirroring `vocabulary-list.tsx`'s state ladder.
|
||||
- [ ] **Step 6:** In `web/src/authorities/authorities-page.tsx`, the list uses `useAuthorities(kind)`; add an `isError` branch rendering `t("authorities.loadError")` (currently a dead key — this uses it) and a loading branch. Keep the existing empty/data render.
|
||||
- [ ] **Step 7: Tests** — add an error-state test to the vocab and authorities test files: MSW returns 500 for the terms / authorities GET → the respective `loadError` text appears. (Override the default handler with `server.use(...)`.)
|
||||
|
||||
### #32 — ARIA tab semantics + remove dead i18n keys
|
||||
- [ ] **Step 8:** In `web/src/authorities/authorities-page.tsx`, the kind tabs are `NavLink`s. Add tab semantics: wrap them in a container with `role="tablist"`, give each `role="tab"` and `aria-selected={isActive}` (the `NavLink` className callback already exposes `isActive` — use the render-prop form to set `aria-selected`). Keep the existing styling.
|
||||
- [ ] **Step 9:** Remove the unused keys `vocab.title` and `authorities.title` from BOTH `en.json` and `sv.json` (grep first: `grep -rn "vocab.title\|authorities.title\|\.title" web/src` — confirm only the i18n definitions match; nothing references them).
|
||||
|
||||
### #37 — frontend authority-kind reveal test
|
||||
- [ ] **Step 10:** In `web/src/fields/fields.test.tsx`, add a test mirroring the existing Term test: type a key + EN label, `selectOptions(type, "authority")`, assert the authority-kind `<select>` (label `/authority kind/i`) appears, `selectOptions` it to `"person"`, submit, and assert the POST body's `authority_kind === "person"` (use a `server.use` POST handler that captures the body, like the Term test does).
|
||||
|
||||
- [ ] **Step 11: Verify** — `cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. Commit:
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "fix(web): search 503 vs error (#34); terms/authorities list error states (#31); authority-tab a11y + dead keys (#32); authority-kind test (#37)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1: i18n parity** —
|
||||
```bash
|
||||
cd web
|
||||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||
```
|
||||
Expected `PARITY OK`.
|
||||
|
||||
- [ ] **Step 2: Frontend** — `pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||
|
||||
- [ ] **Step 3: Backend** —
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
|
||||
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey \
|
||||
cargo test -p api -p domain -p server
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo +nightly fmt --check
|
||||
```
|
||||
All pass; clippy + fmt clean.
|
||||
|
||||
- [ ] **Step 4:** No codename: `git grep -in 'biggus\|dickus' -- crates web/src` → no matches.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
- **Spec coverage:** #22 (404), #18 (log 500s) → Task 1; #9 (Rust cross-ref comments), #4 (clone + smoke test) → Task 2; #34, #31, #32, #37 → Task 3; parity + suites → Task 4. ✓
|
||||
- **Scope adjustments baked in:** #8 already closed (thiserror is used); #37 backend-403 omitted (no non-EditCatalogue role exists); #9 Rust-side only (migration checksums). ✓
|
||||
- **Placeholder scan:** none — code is concrete; the "match the existing harness" notes are verification instructions against named files.
|
||||
- **Type consistency:** `HttpError` defined in queries.ts and imported in search-panel; the 23503/FK pattern matches the field-def handler; `authorities.loadError` (existing key) now consumed; `search.unavailable` added at parity.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Tier 3 — Typed-Client Quality Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Tighten the generated OpenAPI/TypeScript contract so the frontend drops its `as`-casts — type the free-form `fields` map as an open map (#24) and the enum-valued fields (`visibility`, `data_type`, authority `kind`) as string enums (#29). Architecture decision #3 = **Option A** (allow `utoipa::ToSchema` in `domain`).
|
||||
|
||||
**Architecture:** `domain`'s already-serde enums gain `ToSchema`; a new `DataType` enum is added to `domain` for the `data_type` discriminant. The `api` View DTOs reference these via `#[schema(value_type = …)]` (fields stay `String`/`Value` at runtime; only the *schema description* changes). Regenerate `schema.d.ts`; remove the now-redundant frontend casts.
|
||||
|
||||
**Tech Stack:** Rust (utoipa 5, sqlx), React + TS, openapi-typescript.
|
||||
|
||||
**Conventions:** nightly fmt; clippy; no `any`/`eslint-disable`/`@ts-ignore`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — `ToSchema` on enums + new `DataType`
|
||||
|
||||
**Files:** `crates/domain/Cargo.toml`, `crates/domain/src/object.rs`, `crates/domain/src/authority.rs`, `crates/domain/src/field_definition.rs`.
|
||||
|
||||
- [ ] **Step 1: Add the utoipa dep.** In `crates/domain/Cargo.toml` `[dependencies]`, add:
|
||||
```toml
|
||||
utoipa.workspace = true
|
||||
```
|
||||
(The workspace already defines `utoipa = { version = "5", features = ["uuid"] }`.)
|
||||
|
||||
- [ ] **Step 2: Derive `ToSchema` on `Visibility`** (`crates/domain/src/object.rs:7-9`). Add `utoipa::ToSchema` to the derive list (keep everything else):
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Visibility {
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Derive `ToSchema` on `AuthorityKind`** (`crates/domain/src/authority.rs:10-12`):
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthorityKind {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add a `DataType` enum** to `crates/domain/src/field_definition.rs` (it describes the `data_type` discriminant string that `FieldType::kind_str()` produces). NOTE: **`snake_case`**, so `LocalizedText` → `"localized_text"` (matching `kind_str`):
|
||||
```rust
|
||||
/// The stored `data_type` discriminant of a field definition. This mirrors the strings
|
||||
/// produced by [`FieldType::kind_str`]; it exists so the OpenAPI schema can describe
|
||||
/// `data_type` as a closed string enum (consumed by the typed web client). Kept in sync
|
||||
/// by hand with `FieldType::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,
|
||||
}
|
||||
```
|
||||
(If `serde::{Serialize, Deserialize}` are already imported at the top of the file, use the bare derive names; otherwise the fully-qualified `serde::Serialize` forms above are fine.)
|
||||
|
||||
- [ ] **Step 5: Verify** — `cargo +nightly fmt`, `cargo build -p domain`, `cargo clippy -p domain --all-targets`. The existing `field_type_round_trips` etc. tests still pass: `cargo test -p domain`. Add a tiny test asserting `DataType` serializes correctly (it must match `kind_str`):
|
||||
```rust
|
||||
#[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"));
|
||||
}
|
||||
```
|
||||
(place it in the existing `#[cfg(test)] mod tests` in `field_definition.rs`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add crates/domain
|
||||
git commit -m "feat(domain): derive ToSchema on Visibility/AuthorityKind; add DataType enum (#3 Option A)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `api` — enum + open-map schema annotations + regenerate client
|
||||
|
||||
**Files:** `crates/api/src/admin_objects.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/admin.rs`, `crates/api/src/openapi.rs`; regenerate `web/src/api/schema.d.ts`.
|
||||
|
||||
> The View fields keep their runtime types (`String` / `serde_json::Value`); only the `#[schema(value_type = …)]` annotation changes what the OpenAPI document says. No handler/construction logic changes.
|
||||
|
||||
- [ ] **Step 1: #24 — open-map `fields`.** In `crates/api/src/admin_objects.rs:45`, change `AdminObjectView.fields`:
|
||||
```rust
|
||||
#[schema(value_type = std::collections::HashMap<String, serde_json::Value>)]
|
||||
pub fields: serde_json::Value,
|
||||
```
|
||||
(This is the only `value_type = Object` site — confirmed by `grep -rn "value_type = Object" crates/api/src`.) This makes utoipa emit `additionalProperties`, which `openapi-typescript` renders as `{ [key: string]: unknown }` instead of `Record<string, never>`.
|
||||
|
||||
- [ ] **Step 2: #29 — `visibility` enums.**
|
||||
- `AdminObjectView.visibility` (`admin_objects.rs:43`, currently `pub visibility: String`): add above it `#[schema(value_type = domain::Visibility)]`.
|
||||
- `ObjectCreateRequest.visibility` (`admin_objects.rs:165-166`): **remove** the `#[schema(value_type = String)]` line so the field (`pub visibility: Visibility`) emits the enum.
|
||||
- `VisibilityRequest.visibility` (`crates/api/src/admin.rs`, field is `pub visibility: Visibility`): if it has a `#[schema(value_type = String)]` override, **remove** it so it emits the enum. (Check — it may or may not have one.)
|
||||
|
||||
- [ ] **Step 3: #29 — `data_type` + `authority_kind` enums.** In `crates/api/src/admin_objects.rs`, `FieldDefinitionView` (~lines 360-366):
|
||||
- `data_type` (line 363): add `#[schema(value_type = domain::DataType)]`.
|
||||
- `authority_kind` (line 365): add `#[schema(value_type = Option<domain::AuthorityKind>)]`.
|
||||
- The `NewFieldDefinitionRequest` (~lines 374-377) `data_type`/`authority_kind` are request inputs parsed as free strings by the handler — **leave these as `String`** (typing them would force handler conversion; out of scope, and the create form posts plain strings).
|
||||
|
||||
- [ ] **Step 4: #29 — authority `kind`.** In `crates/api/src/admin_authorities.rs`, `AuthorityView.kind` (line 23, `pub kind: String`): add `#[schema(value_type = domain::AuthorityKind)]`. Leave `NewAuthorityRequest.kind` (line 31) as `String` (request input parsed via `from_db`).
|
||||
|
||||
- [ ] **Step 5: Register the domain enums as OpenAPI components.** In `crates/api/src/openapi.rs` `components(schemas(...))`, add:
|
||||
```rust
|
||||
domain::Visibility,
|
||||
domain::AuthorityKind,
|
||||
domain::DataType,
|
||||
```
|
||||
(utoipa generates `$ref`s to these from the `value_type` annotations; they must be registered. The `api` crate already depends on `domain`.)
|
||||
|
||||
- [ ] **Step 6: Build + backend tests.**
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy -p api --all-targets
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
|
||||
```
|
||||
All green (serialized values are unchanged — `visibility` still serializes "draft" etc., `data_type` still "text"/"localized_text").
|
||||
|
||||
- [ ] **Step 7: Regenerate the typed client.**
|
||||
```bash
|
||||
cargo build -p server
|
||||
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
( cd web && pnpm gen:api )
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
Verify the generated types:
|
||||
```bash
|
||||
grep -n "Visibility:\|AuthorityKind:\|DataType:" web/src/api/schema.d.ts
|
||||
grep -n "additionalProperties\|\[key: string\]: unknown" web/src/api/schema.d.ts | head
|
||||
```
|
||||
Expect `Visibility: "draft" | "internal" | "public"`, `AuthorityKind: "person" | "organisation" | "place"`, `DataType: "text" | "localized_text" | ...`, and `AdminObjectView.fields` as `{ [key: string]: unknown }`. Then `cd web && pnpm typecheck` — it may now report errors at the cast sites (expected; Task 3 fixes them) OR pass (casts on a now-compatible type are just redundant). Either way, do NOT edit web source in this task beyond the regenerated `schema.d.ts`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add crates/api web/src/api/schema.d.ts
|
||||
git commit -m "feat(api): enum-typed visibility/data_type/kind + open-map fields in OpenAPI (#24 #29)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend — drop the now-redundant casts
|
||||
|
||||
**Files:** `web/src/objects/object-detail.tsx`, `web/src/objects/object-form.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/objects/publish-control.tsx` (+ check `visibility-badge.tsx`, `field-input.tsx`). Plus any local `Visibility` type alias.
|
||||
|
||||
- [ ] **Step 1: Remove the `fields` casts (#24).** `fields` is now `{ [key: string]: unknown }`:
|
||||
- `object-detail.tsx:55`: `Object.entries(object.fields as Record<string, unknown>)` → `Object.entries(object.fields)`.
|
||||
- `object-form.tsx:181`: `Object.entries(value as Record<string, unknown>)` → `Object.entries(value)` (only if `value` is the typed `fields`; if `value` is a generic RHF value, the cast may still be needed — verify the type and remove only if redundant).
|
||||
- `object-edit-form.tsx:37`: `fields: object.fields as Record<string, unknown>` → `fields: object.fields` (if the target type accepts the open map; otherwise leave).
|
||||
Remove a cast only when the typecheck confirms it's now redundant. Keep the code `any`-free.
|
||||
|
||||
- [ ] **Step 2: Remove the `visibility` cast (#29).** `publish-control.tsx:26`: `const current = object.visibility as Visibility;` → `const current = object.visibility;` (it's now the `"draft" | "internal" | "public"` union). If a local `type Visibility = ...` alias exists and is now identical to the schema union, prefer referencing `components["schemas"]["Visibility"]` or keep the alias if it's used as a shared name — but drop the cast. Check `visibility-badge.tsx`: if its prop is `visibility: string`, you may tighten it to the union or leave it (a union is assignable to `string`); do NOT introduce errors.
|
||||
|
||||
- [ ] **Step 3: `data_type` (#29).** `field-input.tsx` switches on `data_type` — now a union. No cast was present; confirm the switch still typechecks (a union improves exhaustiveness). If there's a `data_type as ...` cast anywhere, remove it.
|
||||
|
||||
- [ ] **Step 4: Verify.**
|
||||
```bash
|
||||
cd web
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||
```
|
||||
All green; no `any`/`@ts-ignore` introduced; bundle ≤150 KB. Grep to confirm the casts are gone:
|
||||
```bash
|
||||
grep -rn "as Record<string, unknown>\|as Visibility" web/src/objects | grep -v ".test."
|
||||
```
|
||||
(Test-file `as Record<string, unknown>` defaults may remain — they're test scaffolding, not contract casts; leaving them is fine.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "refactor(web): drop redundant fields/visibility casts now the client is typed (#24 #29)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1:** `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||
- [ ] **Step 2:**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p domain
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo +nightly fmt --check
|
||||
```
|
||||
- [ ] **Step 3:** i18n parity check (unchanged keys, but run it); `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||
- [ ] **Step 4:** Confirm acceptance: OpenAPI `fields` has `additionalProperties`; `visibility`/`data_type`/`kind` are string enums in `schema.d.ts`; the `as Record<string, unknown>`/`as Visibility` contract casts are gone.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
- **Spec coverage:** #3 decided (Option A, documented + closed) → this plan's architecture; #24 (open-map fields) → T2 Step 1 + T3 Step 1; #29 (visibility/data_type/kind enums) → T1 + T2 Steps 2-5 + T3 Steps 2-3. ✓
|
||||
- **Placeholder scan:** none — exact files/lines/annotations given; the "remove cast only if typecheck confirms redundant" notes are correct verification guards (the generated types determine redundancy).
|
||||
- **Type consistency:** `DataType` uses `snake_case` to match `FieldType::kind_str` (`localized_text`); `value_type = domain::X` references match the enums registered in `openapi.rs` components; runtime serialization is unchanged (backend tests prove it), so only the schema/TS types tighten.
|
||||
|
||||
## Notes
|
||||
- Request-side enums (`NewFieldDefinitionRequest.data_type`/`authority_kind`, `NewAuthorityRequest.kind`) intentionally stay `String` — the handlers parse/validate them; typing them is a separate, larger change (would need handler conversion) and isn't required by #24/#29.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Tier 4 Hardening — Batch 1 (#1, #2, #21) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** The mechanical, well-specified hardening items — graceful HTTP shutdown (#1), configurable DB pool size (#2), and audit logging for vocabulary/term/authority creation (#21). (The design-heavy Tier 4 items #20/#5/#7 are handled separately.)
|
||||
|
||||
**Tech Stack:** Rust (axum 0.8, sqlx, tokio, anyhow). Backend-only.
|
||||
|
||||
**Conventions:** nightly fmt; clippy `-D warnings`; no codename. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey` (`#[sqlx::test]` provisions its own DB).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: #1 — graceful shutdown
|
||||
|
||||
**Files:** `crates/server/src/lib.rs`, `crates/server/Cargo.toml` (tokio `signal` feature if missing).
|
||||
|
||||
- [ ] **Step 1: Ensure tokio `signal` feature.** Check `crates/server/Cargo.toml`'s `tokio` dependency features include `"signal"`. If the workspace `tokio` is `features = ["full"]` it's already included; otherwise add `"signal"` (and `"macros"`/`"rt-multi-thread"` if not already). Verify with `cargo build -p server`.
|
||||
|
||||
- [ ] **Step 2: Add a shutdown-signal future** in `crates/server/src/lib.rs` (above `serve`):
|
||||
```rust
|
||||
/// Resolves when the process receives SIGINT (Ctrl-C) or SIGTERM, so the server can
|
||||
/// drain in-flight requests before exiting.
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("install Ctrl-C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
tracing::info!("shutdown signal received; draining");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire it into `serve`.** Change the `axum::serve(...)` call:
|
||||
```rust
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.context("running the HTTP server")?;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy -p server --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server` (the existing `serve.rs` smoke test still passes — it aborts the handle, which is unaffected). Commit:
|
||||
```bash
|
||||
git add crates/server
|
||||
git commit -m "feat(server): graceful shutdown on SIGINT/SIGTERM (#1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: #2 — configurable DB pool size
|
||||
|
||||
**Files:** `crates/db/src/lib.rs`, `crates/server/src/config.rs`, `crates/server/src/lib.rs`.
|
||||
|
||||
`Db::connect` currently hardcodes `.max_connections(5)`.
|
||||
|
||||
- [ ] **Step 1: Parameterize `Db::connect`.** In `crates/db/src/lib.rs`:
|
||||
```rust
|
||||
/// Connect to the database at `database_url`, opening a connection pool with at most
|
||||
/// `max_connections` connections.
|
||||
pub async fn connect(database_url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the config knob.** In `crates/server/src/config.rs`, add a field to `Config`:
|
||||
```rust
|
||||
/// Maximum size of the PostgreSQL connection pool.
|
||||
#[arg(long = "db-max-connections", env = "DB_MAX_CONNECTIONS", default_value_t = 5)]
|
||||
pub db_max_connections: u32,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Thread it through the two `Db::connect` call sites** in `crates/server/src/lib.rs`:
|
||||
- In `run`: `Db::connect(&config.database_url, config.db_max_connections)`.
|
||||
- In `create_user` (the CLI one-shot — it has only `database_url: &str`, no `Config`): pass a small fixed default, `Db::connect(database_url, 2)` (a one-shot CLI needs minimal connections), and add a brief comment.
|
||||
|
||||
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p server`. Confirm `cargo run -p server -- --help` shows the new `--db-max-connections` flag (optional). Commit:
|
||||
```bash
|
||||
git add crates/db crates/server
|
||||
git commit -m "feat(server): configurable DB pool size via --db-max-connections/DB_MAX_CONNECTIONS (#2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: #21 — audit vocabulary/term/authority creation
|
||||
|
||||
**Files:** `crates/db/src/vocab.rs`, `crates/db/src/authority.rs`, `crates/api/src/admin_vocab.rs`, `crates/api/src/admin_authorities.rs`; Test in `crates/api/tests/admin_catalog.rs`.
|
||||
|
||||
The three admin create paths (`create_vocabulary`, `add_term`, `create_authority`) take no `AuditActor` and write no audit entry. The catalogue object writes do — **`db::catalog::create_object` is the template**: it takes `actor: AuditActor` and calls `audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Created, entity_type, entity_id, ... })` inside the same transaction. READ `create_object` (`crates/db/src/catalog.rs`) and `audit::record` / `NewAuditEvent` (`crates/db/src/audit.rs`, `domain::NewAuditEvent`) first to copy the exact shape.
|
||||
|
||||
- [ ] **Step 1: Add `actor` + audit to the db functions.** Each must run the insert **and** the audit record in one transaction (so they're atomic), mirroring `create_object`:
|
||||
- `db::vocab::create_vocabulary` — currently `(executor: E, key: &str)`. Change to `(conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str)` (tx-connection like `add_term`), insert the vocabulary, then `audit::record(&mut *conn, &NewAuditEvent { actor, action: Created, entity_type: "vocabulary", entity_id: <new vocab id>, ... })`. Return the `Vocabulary` as before.
|
||||
- `db::vocab::add_term` — currently `(conn: &mut PgConnection, new: &NewTerm)`. Add `actor: AuditActor`; after inserting the term, record an audit entry (`entity_type: "term"`, `entity_id: <term id>`).
|
||||
- `db::authority::create_authority` — add `actor: AuditActor`; record (`entity_type: "authority"`, `entity_id: <authority id>`).
|
||||
Match `create_object`'s `NewAuditEvent` field names exactly (e.g. `changes`/`metadata` may be empty/None — copy whatever `create_object` passes for a creation with no field diff).
|
||||
|
||||
- [ ] **Step 2: Thread the actor through the handlers.** In `crates/api/src/admin_vocab.rs` (`create_vocabulary`, `add_term`) and `crates/api/src/admin_authorities.rs` (`create_authority`):
|
||||
- Change `_auth: Authorized<EditCatalogue>` → `auth: Authorized<EditCatalogue>`.
|
||||
- Build the actor as the object handlers do: `AuditActor::User(auth.user.id.to_uuid())`. To avoid duplicating the helper, either make `admin_objects::actor` `pub(crate)` and import it, or inline `AuditActor::User(auth.user.id.to_uuid())` at each site (it's a one-liner — pick the cleaner option; if you make the helper shared, take `&AuthUser`).
|
||||
- `create_vocabulary` handler currently calls `db::vocab::create_vocabulary(state.db.pool(), &req.key)` on the **pool** — change it to open a transaction (`let mut tx = state.db.pool().begin().await...`), call the new `create_vocabulary(&mut tx, actor, &req.key)`, then `tx.commit()` (like `add_term`'s handler already does). `add_term`/`create_authority` handlers already use a tx — just pass the actor.
|
||||
|
||||
- [ ] **Step 3: Test** — add to `crates/api/tests/admin_catalog.rs` (it already seeds an editor + logs in). After creating a vocabulary (or term/authority) via the API, assert an audit row exists attributing the user. Use `db::audit::history_for` (or a direct `SELECT` on `audit_log`) to find the entry — read the file for how existing tests inspect audit rows (the object tests likely already do this; mirror them). Minimal: create a vocabulary, then query `audit_log` for `entity_type='vocabulary'` with the created id and assert `actor_kind='user'` + the right `actor_id`. Name it e.g. `creating_a_vocabulary_writes_an_audit_entry`.
|
||||
|
||||
- [ ] **Step 4: Verify.** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets`; `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db`. All green. Commit:
|
||||
```bash
|
||||
git add crates/db crates/api
|
||||
git commit -m "feat: audit vocabulary/term/authority creation, attributing the acting user (#21)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1:** `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace` — all green.
|
||||
- [ ] **Step 2:** `cargo clippy --workspace --all-targets` and `cargo +nightly fmt --check` — clean.
|
||||
- [ ] **Step 3:** `git grep -in 'biggus\|dickus' -- crates` → none.
|
||||
- [ ] **Step 4:** Confirm `Cargo.lock` is committed if any dependency/feature changed (e.g. tokio `signal` feature does not add a new lockfile entry, but verify `git status` is clean after the commits — no dangling `M Cargo.lock`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
- **Spec coverage:** #1 (graceful shutdown) → T1; #2 (configurable pool) → T2; #21 (audit 3 admin creates) → T3. ✓
|
||||
- **Placeholder scan:** none — concrete code for #1/#2; #21 points at `create_object`/`audit::record` as the exact template to mirror (the audit-event field names live there and must match, so copying beats guessing).
|
||||
- **Type consistency:** `Db::connect(url, max: u32)` updated at both call sites (run + create_user); `db_max_connections: u32` matches `max_connections(u32)`; the three db create fns gain `actor: AuditActor` and the handlers pass `AuditActor::User(auth.user.id.to_uuid())` consistently with `admin_objects::actor`.
|
||||
|
||||
## Notes
|
||||
- #21 keeps within the current audit model (`AuditAction::Created` + non-null `entity_type`/`entity_id`) — no schema change needed (the auth-event model extension is the separate #7).
|
||||
- Watch the `Cargo.lock`: if the tokio `signal` feature pulls a new transitive crate, stage the root `Cargo.lock` in the same commit (don't leave it dangling).
|
||||
@@ -0,0 +1,317 @@
|
||||
# Follow-ups Batch (#38, #28, #41, #26) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Four small, well-specified follow-ups: enum-type `SearchHitView.visibility` (#38); carry the offending field in the `set_fields` 422 so the UI can highlight it (#28); normalize `localized_text` to the default language on save (#41); pin the pnpm version (#26).
|
||||
|
||||
**Tech Stack:** Rust (axum, utoipa), React + TS, react-hook-form, Vitest + RTL + MSW.
|
||||
|
||||
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — `SearchHitView.visibility` enum (#38) + `set_fields` field-level 422 (#28)
|
||||
|
||||
**Files:** Modify `crates/api/src/admin_search.rs`, `crates/api/src/admin_objects.rs`, `crates/api/src/openapi.rs`; Test `crates/api/tests/admin_objects.rs`; Regenerate `web/src/api/schema.d.ts`.
|
||||
|
||||
### #38 — enum-type the search hit visibility
|
||||
- [ ] **Step 1:** In `crates/api/src/admin_search.rs`, `SearchHitView.visibility` (line ~31, `pub visibility: String`): add the attribute above it:
|
||||
```rust
|
||||
#[schema(value_type = domain::Visibility)]
|
||||
pub visibility: String,
|
||||
```
|
||||
(`domain::Visibility` already derives `ToSchema` and is registered in `openapi.rs` from #29 — no further registration needed.)
|
||||
|
||||
### #28 — carry the offending field in the 422
|
||||
The db `FieldError` already names the field (`UnknownField(String)`, `TypeMismatch { field, .. }`, `Unresolved { field, .. }`). Surface it.
|
||||
|
||||
- [ ] **Step 2:** In `crates/api/src/admin_objects.rs`, add a response DTO near the other views:
|
||||
```rust
|
||||
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(crate) struct FieldErrorView {
|
||||
/// The flexible-field key that was rejected.
|
||||
pub field: String,
|
||||
/// Machine code: "unknown" | "type_mismatch" | "unresolved".
|
||||
pub code: String,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** Change the `set_fields` handler to return a body on the field-error 422s. Its signature is `-> Result<StatusCode, StatusCode>`; change to `-> axum::response::Response` and build responses (import `axum::response::IntoResponse`):
|
||||
```rust
|
||||
) -> 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(),
|
||||
}
|
||||
}
|
||||
```
|
||||
Update the `#[utoipa::path(...)]` on `set_fields`: the 422 response now has a body — change/add `(status = 422, body = FieldErrorView, description = "A field was rejected")` in its `responses(...)`.
|
||||
|
||||
- [ ] **Step 4:** Register `admin_objects::FieldErrorView` in `crates/api/src/openapi.rs` `components(schemas(...))`.
|
||||
|
||||
- [ ] **Step 5: Test** — add to `crates/api/tests/admin_objects.rs` (reuse its harness: seed editor, login, create an object). Create an object, then PUT `/api/admin/objects/{id}/fields` with an **unknown** field key → assert `422` and the body `{ field: "<that key>", code: "unknown" }`. (Mirror an existing set-fields test if present; if a field-definition is needed for a type_mismatch case, the `unknown` case needs none — simplest.) Read the file for the exact request/parse helpers.
|
||||
|
||||
- [ ] **Step 6: Build + backend tests:**
|
||||
```bash
|
||||
cargo +nightly fmt
|
||||
cargo clippy --workspace --all-targets
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api
|
||||
```
|
||||
All green (existing set_fields tests still pass — success path still 204; the failure path now carries a body but the status is unchanged at 422).
|
||||
|
||||
- [ ] **Step 7: Regenerate client:**
|
||||
```bash
|
||||
cargo build -p server
|
||||
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
( cd web && pnpm gen:api )
|
||||
kill "$SERVER_PID"
|
||||
grep -n "FieldErrorView" web/src/api/schema.d.ts
|
||||
# confirm SearchHitView.visibility now references the Visibility union:
|
||||
grep -n "SearchHitView" web/src/api/schema.d.ts
|
||||
```
|
||||
`FieldErrorView` present; `SearchHitView.visibility` → `components["schemas"]["Visibility"]`. `cd web && pnpm typecheck` clean. Diff additive.
|
||||
|
||||
- [ ] **Step 8: Commit:**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add crates/api web/src/api/schema.d.ts
|
||||
git commit -m "feat(api): field-level set_fields 422 body (#28); enum-type SearchHitView.visibility (#38)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend — surface the rejected field & highlight it (#28)
|
||||
|
||||
**Files:** Modify `web/src/api/queries.ts`, `web/src/objects/object-form.tsx`, `web/src/objects/object-new-page.tsx`, `web/src/objects/object-edit-form.tsx`, `web/src/i18n/{en,sv}.json`; Test `web/src/objects/object-form.test.tsx` or the relevant existing object test.
|
||||
|
||||
- [ ] **Step 1: i18n** — add `form.fieldRejected` to BOTH `en.json` and `sv.json` (interpolated):
|
||||
- en `form`: `"fieldRejected": "The field \"{{field}}\" was rejected — check its value"`
|
||||
- sv `form`: `"fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet"`
|
||||
|
||||
- [ ] **Step 2: A typed rejection in `useSetFields`** — in `web/src/api/queries.ts`, add near the other errors:
|
||||
```ts
|
||||
export class FieldRejection extends Error {
|
||||
constructor(public readonly field: string, public readonly code: string) {
|
||||
super(`field rejected: ${field}`);
|
||||
this.name = "FieldRejection";
|
||||
}
|
||||
}
|
||||
```
|
||||
Update `useSetFields`'s `mutationFn` to parse the 422 body and throw `FieldRejection`:
|
||||
```ts
|
||||
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
|
||||
const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||||
params: { path: { id } },
|
||||
body: fields as Record<string, never>,
|
||||
});
|
||||
|
||||
if (response.status === 204) return;
|
||||
|
||||
if (response.status === 422 && error && typeof error === "object" && "field" in error) {
|
||||
const detail = error as { field: string; code: string };
|
||||
throw new FieldRejection(detail.field, detail.code);
|
||||
}
|
||||
|
||||
throw new Error("set fields failed");
|
||||
},
|
||||
```
|
||||
(openapi-fetch puts the 422 body in `error` because the operation declares a 422 body schema. If `error` typing is awkward, narrow defensively as above — no `any`.)
|
||||
|
||||
- [ ] **Step 3: Thread a field-error into the form** — `object-form.tsx` owns the react-hook-form instance. Add an optional prop `fieldErrorKey?: string | null` and, via `useEffect`, set/clear the RHF error so the field highlights:
|
||||
```tsx
|
||||
// in the ObjectForm props type:
|
||||
fieldErrorKey?: string | null;
|
||||
// inside the component (form is the useForm instance; t available):
|
||||
useEffect(() => {
|
||||
if (fieldErrorKey) {
|
||||
form.setError(`fields.${fieldErrorKey}` as never, {
|
||||
type: "server",
|
||||
message: t("form.fieldRejected", { field: fieldErrorKey }),
|
||||
});
|
||||
}
|
||||
}, [fieldErrorKey, form, t]);
|
||||
```
|
||||
(The `as never` is to satisfy RHF's path typing for a dynamic flexible-field path; if a cleaner typed path is available without `any`, use it — `as never` is acceptable here and is NOT `as any`. Confirm lint accepts it; if `react-hooks/exhaustive-deps` complains, include the listed deps.)
|
||||
|
||||
- [ ] **Step 4: Parent catch sets the field key** — in `object-new-page.tsx` and `object-edit-form.tsx`, the `catch` currently does `setError(t("form.rejected"))`. Capture the rejected field too:
|
||||
- Add state `const [fieldErrorKey, setFieldErrorKey] = useState<string | null>(null);`
|
||||
- In the catch: `if (e instanceof FieldRejection) { setFieldErrorKey(e.field); setError(t("form.fieldRejected", { field: e.field })); } else { setError(t("form.rejected")); }` (import `FieldRejection` from `../api/queries`).
|
||||
- Pass `fieldErrorKey={fieldErrorKey}` to `<ObjectForm>`.
|
||||
- Clear `setFieldErrorKey(null)` at the top of `onSubmit` (alongside `setError(null)`).
|
||||
(For `object-edit-form.tsx`, which also reads a `location.state.fieldsError` flag, keep that path but layer the new typed handling on top.)
|
||||
|
||||
- [ ] **Step 5: Test** — add a test (in the object form/new-page test file, MSW) where PUT `/api/admin/objects/:id/fields` returns `422` with `{ field: "dimensions", code: "type_mismatch" }`. Submit the form; assert the field-rejected message appears (`/dimensions/i` + "rejected") and, if practical, that the field's input is marked invalid (`aria-invalid` or an error message near it). Use the existing object-form test setup; read it for the render/submit pattern.
|
||||
|
||||
- [ ] **Step 6: Verify + commit:**
|
||||
```bash
|
||||
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "feat(web): highlight the offending field on a set_fields 422 (#28)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend — visibility-badge typing (#38) + localized_text normalize-on-save (#41)
|
||||
|
||||
**Files:** Modify `web/src/objects/visibility-badge.tsx`, `web/src/objects/object-form.tsx`; Test the object-form/field tests.
|
||||
|
||||
### #38 — tighten the VisibilityBadge prop
|
||||
- [ ] **Step 1:** `web/src/objects/visibility-badge.tsx` — change the prop from `string` to the schema union (now that all callers pass it, incl. search hits after Task 1):
|
||||
```tsx
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
type Visibility = components["schemas"]["Visibility"];
|
||||
|
||||
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Badge variant="outline" className={STYLES[visibility] ?? ""}>
|
||||
{t(`visibility.${visibility}`)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
```
|
||||
Run `pnpm typecheck` — every caller (`object-list`, `object-detail`, `search-result-row`) now passes the union (object/search hit `visibility` are the union post-#29/#38). Fix any caller that still has a widened `string` (there should be none).
|
||||
|
||||
### #41 — normalize localized_text to the default language on save
|
||||
The edit path seeds `defaultValues.fields` from `object.fields` verbatim, so a `localized_text` value authored under another language keeps that key. Normalize in `pruneFields` so only the default-language key is saved.
|
||||
|
||||
- [ ] **Step 2:** In `web/src/objects/object-form.tsx`:
|
||||
- Add `import { useConfig } from "../config/config-context";` and inside the component: `const { default_language } = useConfig();`.
|
||||
- Compute the set of localized_text field keys from the loaded definitions:
|
||||
```tsx
|
||||
const localizedTextKeys = new Set(
|
||||
(definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key),
|
||||
);
|
||||
```
|
||||
- Pass both into `pruneFields` at its call site (`const fields = pruneFields(data.fields, localizedTextKeys, default_language);`).
|
||||
- Update `pruneFields` to accept them and, for a localized_text key, keep only the default-language sub-value:
|
||||
```tsx
|
||||
function pruneFields(
|
||||
fields: Record<string, unknown>,
|
||||
localizedTextKeys: Set<string>,
|
||||
defaultLang: string,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
const map = value as Record<string, unknown>;
|
||||
// Single-language authoring: a localized_text value keeps only the default lang.
|
||||
const entries = localizedTextKeys.has(key)
|
||||
? Object.entries(map).filter(([lang]) => lang === defaultLang)
|
||||
: Object.entries(map);
|
||||
|
||||
const inner = Object.fromEntries(
|
||||
entries.filter(([, v]) => v !== undefined && v !== null && v !== ""),
|
||||
);
|
||||
|
||||
if (Object.keys(inner).length > 0) out[key] = inner;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
out[key] = value;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test** — add/extend a test: an object whose `localized_text` field value is `{ en: "Old", sv: "Ny" }`, edited on an `sv`-default instance, submits `fields` containing only `{ <key>: { sv: "Ny" } }` (the `en` key stripped). Use the object-form test harness (the `definitions` fixture has a `localized_text` field — `title_ml`). Assert the pruned payload via the submit handler / the PUT body.
|
||||
|
||||
- [ ] **Step 4: Verify + commit:**
|
||||
```bash
|
||||
cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "fix(web): VisibilityBadge typed to the union (#38); normalize localized_text to default language on save (#41)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Pin pnpm (#26) + verification
|
||||
|
||||
**Files:** Modify `web/package.json`, `.gitea/workflows/ci.yaml`.
|
||||
|
||||
- [ ] **Step 1: Pin pnpm** — add a `packageManager` field to `web/package.json` matching the dev/CI version. The local pnpm is `11.5.1`; CI's `pnpm/action-setup` is pinned to `9` — a mismatch. Unify on the local version:
|
||||
- In `web/package.json`, add (top level): `"packageManager": "pnpm@11.5.1"`.
|
||||
- In `.gitea/workflows/ci.yaml`, change the `pnpm/action-setup@v4` `version: 9` → `version: 11` (matching the major).
|
||||
- [ ] **Step 2: Confirm the lockfile is consistent** — run `cd web && pnpm install --frozen-lockfile`. If it passes, the committed `pnpm-lock.yaml` is compatible — done. If it FAILS (lockfile format/version mismatch from the pnpm-9→11 change), run `pnpm install` once to update the lockfile, confirm only the lockfile changed (`git status`), and include `web/pnpm-lock.yaml` in the commit. Report which case occurred.
|
||||
- [ ] **Step 3: Commit:**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web/package.json .gitea/workflows/ci.yaml web/pnpm-lock.yaml
|
||||
git commit -m "build(web): pin pnpm via packageManager + align CI (#26)"
|
||||
```
|
||||
|
||||
### Final verification
|
||||
- [ ] **Step 4: i18n parity** —
|
||||
```bash
|
||||
cd web
|
||||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||
```
|
||||
Expected `PARITY OK`.
|
||||
- [ ] **Step 5: Full suites** —
|
||||
```bash
|
||||
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
|
||||
cargo clippy --workspace --all-targets && cargo +nightly fmt --check
|
||||
```
|
||||
All green; bundle ≤150 KB; clippy/fmt clean.
|
||||
- [ ] **Step 6:** `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
- **Spec coverage:** #38 (search visibility enum → T1 backend + T3 prop tighten); #28 (422 field body → T1 backend, T2 FE highlight); #41 (localized_text normalize → T3); #26 (pin pnpm → T4). ✓
|
||||
- **Placeholder scan:** none — concrete code; the "read the test harness" notes are verification steps against named files. The `as never` in T2 Step 3 is a typed-RHF-path escape (NOT `as any`/ts-ignore) and is flagged for lint confirmation.
|
||||
- **Type consistency:** `FieldErrorView { field, code }` (Rust) ↔ `components["schemas"]["FieldErrorView"]` (the 422 body openapi-fetch surfaces as `error`) ↔ `FieldRejection{field,code}`; `SearchHitView.visibility` union flows into the tightened `VisibilityBadge` prop; `pruneFields` new signature `(fields, localizedTextKeys, defaultLang)` updated at its single call site.
|
||||
|
||||
## Notes
|
||||
- #28 changes the `set_fields` handler return type from `Result<StatusCode, StatusCode>` to `Response`; the success status (204) and the field-error status (422) are unchanged — only a body is added to the 422, so existing status-only tests still pass.
|
||||
- #26: if `pnpm install --frozen-lockfile` forces a lockfile regen, that's expected and the regenerated `pnpm-lock.yaml` is committed; flag if dependency versions shifted.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Instance Locale + Single-Language Content Authoring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
|
||||
|
||||
**Goal:** Drive instance UI/content language + display timezone from environment variables (no settings table), surface them to the SPA via a public `GET /api/config`, default the UI language from it, and collapse content authoring (`LabelEditor` + `LocalizedText` field input) to a single language — **without touching the multilingual content schema** (dormant, re-enabled by UI alone).
|
||||
|
||||
**Architecture:** Two `server::Config` env knobs (`DEFAULT_LANGUAGE`, `DEFAULT_TIMEZONE`) flow into `AppState` and a public `ConfigView` endpoint. A frontend `ConfigProvider` fetches it once, sets the i18n language (when no per-browser override), and feeds the default language to the simplified content inputs. Storage stays UTC; timezone is exposed but has no frontend formatter yet (no timestamp displays exist — deferred to its first consumer).
|
||||
|
||||
**Tech Stack:** Rust (axum, utoipa, clap), React + TS, react-i18next, TanStack Query, Vitest + RTL + MSW.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-05-instance-locale-and-content-authoring-design.md`
|
||||
|
||||
**Conventions:** nightly fmt; clippy `-D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; codename ban; bundle ≤150 KB gz. Test infra: `DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev`, `MEILI_URL=http://localhost:7700`, `MEILI_MASTER_KEY=masterKey`. cargo from repo root; web from `web/`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — config knobs + `AppState` + public `GET /api/config` + regen client
|
||||
|
||||
**Files:** Modify `crates/server/src/config.rs`, `crates/server/src/lib.rs`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; Create `crates/api/src/config.rs`; Modify all `AppState { … }` construction sites (server + api test harnesses); Test `crates/api/tests/config.rs`; Regenerate `web/src/api/schema.d.ts`.
|
||||
|
||||
- [ ] **Step 1: Config knobs.** In `crates/server/src/config.rs`, add to `Config` (clap derive, matching `app_name`'s style):
|
||||
```rust
|
||||
/// Default UI + content-authoring language for this instance (i18n key, e.g. "sv").
|
||||
#[arg(long = "default-language", env = "DEFAULT_LANGUAGE", default_value = "sv")]
|
||||
pub default_language: String,
|
||||
|
||||
/// Default display timezone (IANA name, e.g. "Europe/Stockholm"). Storage stays UTC;
|
||||
/// this is a display hint surfaced to clients (and, later, server-side renderers).
|
||||
#[arg(long = "default-timezone", env = "DEFAULT_TIMEZONE", default_value = "Europe/Stockholm")]
|
||||
pub default_timezone: String,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `AppState` fields.** In `crates/api/src/lib.rs`, add to `pub struct AppState`:
|
||||
```rust
|
||||
/// Instance default UI/content language (from config).
|
||||
pub default_language: String,
|
||||
/// Instance default display timezone, IANA name (from config). Storage stays UTC.
|
||||
pub default_timezone: String,
|
||||
```
|
||||
In `crates/server/src/lib.rs` `run`, populate them when building `AppState`:
|
||||
```rust
|
||||
default_language: config.default_language,
|
||||
default_timezone: config.default_timezone,
|
||||
```
|
||||
(place after `app_name: config.app_name,` — note these are moves; `config` fields are disjoint.)
|
||||
|
||||
- [ ] **Step 3: Update every other `AppState { … }` site.** Run `grep -rn "AppState {" crates/` — besides `crates/api/src/lib.rs` (the struct def) and `server/src/lib.rs` (done above), there are ~9 test `state(...)` helpers (`crates/server/tests/serve.rs`, `crates/api/tests/{admin,admin_objects,admin_search,public,reindex,admin_catalog,admin_fields,health}.rs`). Add to each literal:
|
||||
```rust
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
```
|
||||
(The build will fail to compile until all are updated — that's the checklist.)
|
||||
|
||||
- [ ] **Step 4: Write the failing API test** — create `crates/api/tests/config.rs`:
|
||||
```rust
|
||||
use api::{AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state(pool: PgPool) -> AppState {
|
||||
AppState {
|
||||
db: db::Db::from_pool(pool),
|
||||
app_name: "Test Museum".into(),
|
||||
cookie_secure: false,
|
||||
search: None,
|
||||
default_language: "sv".into(),
|
||||
default_timezone: "Europe/Stockholm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../db/migrations")]
|
||||
async fn config_is_public_and_reflects_state(pool: PgPool) {
|
||||
let app = build_app(state(pool));
|
||||
let resp = app
|
||||
.oneshot(Request::builder().uri("/api/config").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
|
||||
assert_eq!(body["app_name"], "Test Museum");
|
||||
assert_eq!(body["default_language"], "sv");
|
||||
assert_eq!(body["default_timezone"], "Europe/Stockholm");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run → fails** (`/api/config` 404): `cargo test -p api --test config`.
|
||||
|
||||
- [ ] **Step 6: Implement the endpoint** — create `crates/api/src/config.rs` (mirror `health.rs`):
|
||||
```rust
|
||||
use axum::{Json, Router, extract::State, routing::get};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// Public, non-sensitive instance configuration the SPA needs before login.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ConfigView {
|
||||
/// User-facing product name.
|
||||
pub app_name: String,
|
||||
/// Default UI/content language (i18n key, e.g. "sv").
|
||||
pub default_language: String,
|
||||
/// Default display timezone (IANA name). Storage is UTC; this is a display hint.
|
||||
pub default_timezone: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/config", responses((status = 200, body = ConfigView)))]
|
||||
pub(crate) async fn get_config(State(state): State<AppState>) -> Json<ConfigView> {
|
||||
Json(ConfigView {
|
||||
app_name: state.app_name.clone(),
|
||||
default_language: state.default_language.clone(),
|
||||
default_timezone: state.default_timezone.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/config", get(get_config))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Register the module + route + schema.**
|
||||
- `crates/api/src/lib.rs`: add `mod config;` (alphabetical with other `mod`s) and `.merge(config::routes())` in `build_app` (next to `health::routes()`).
|
||||
- `crates/api/src/openapi.rs`: add `config` to the `use crate::{…}` import; add `config::get_config` to `paths(…)`; add `config::ConfigView` to `components(schemas(…))`.
|
||||
|
||||
- [ ] **Step 8: Run → passes.** `cargo test -p api --test config`, then `cargo +nightly fmt`, `cargo clippy --workspace --all-targets`, and full `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo test -p api -p server` (the AppState field additions compile everywhere).
|
||||
|
||||
- [ ] **Step 9: Regenerate the typed client.**
|
||||
```bash
|
||||
cargo build -p server
|
||||
lsof -ti :8080 | xargs kill 2>/dev/null
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey ./target/debug/server &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
( cd web && pnpm gen:api )
|
||||
kill "$SERVER_PID"
|
||||
grep -n "ConfigView\|api/config" web/src/api/schema.d.ts
|
||||
```
|
||||
Both must appear; diff additive. `cd web && pnpm typecheck` clean.
|
||||
|
||||
- [ ] **Step 10: Commit.**
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add crates/server crates/api web/src/api/schema.d.ts
|
||||
git commit -m "feat: DEFAULT_LANGUAGE/DEFAULT_TIMEZONE config + public GET /api/config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend — config provider + i18n default wiring
|
||||
|
||||
**Files:** Create `web/src/config/config-context.tsx`; Modify `web/src/main.tsx`, `web/src/test/handlers.ts`; Test `web/src/config/config-context.test.tsx`.
|
||||
|
||||
- [ ] **Step 1: MSW handler.** In `web/src/test/handlers.ts`, add to the `handlers` array a default config response:
|
||||
```ts
|
||||
http.get("/api/config", () =>
|
||||
HttpResponse.json({
|
||||
app_name: "Test Museum",
|
||||
default_language: "sv",
|
||||
default_timezone: "Europe/Stockholm",
|
||||
}),
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Failing provider test** — create `web/src/config/config-context.test.tsx`:
|
||||
```tsx
|
||||
import { expect, test, beforeEach } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import i18n from "../i18n";
|
||||
import { LOCALE_KEY } from "../i18n";
|
||||
import { ConfigProvider, useConfig } from "./config-context";
|
||||
|
||||
function Probe() {
|
||||
const config = useConfig();
|
||||
return <span data-testid="lang">{config.default_language}</span>;
|
||||
}
|
||||
|
||||
function renderProvider() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ConfigProvider><Probe /></ConfigProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
void i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
test("exposes config and applies default language when no stored preference", async () => {
|
||||
renderProvider();
|
||||
expect(await screen.findByText("sv")).toBeInTheDocument();
|
||||
await waitFor(() => expect(i18n.language).toBe("sv"));
|
||||
});
|
||||
|
||||
test("a stored locale preference wins over the instance default", async () => {
|
||||
localStorage.setItem(LOCALE_KEY, "en");
|
||||
void i18n.changeLanguage("en");
|
||||
renderProvider();
|
||||
await screen.findByText("sv"); // config still loads
|
||||
await waitFor(() => expect(i18n.language).toBe("en")); // but language stays en
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run → fails** (module missing): `cd web && pnpm test src/config/config-context.test.tsx`.
|
||||
|
||||
- [ ] **Step 4: Implement the provider** — create `web/src/config/config-context.tsx`:
|
||||
```tsx
|
||||
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { api } from "../api/client";
|
||||
import i18n, { LOCALE_KEY } from "../i18n";
|
||||
|
||||
type ConfigView = components["schemas"]["ConfigView"];
|
||||
|
||||
const DEFAULTS: ConfigView = {
|
||||
app_name: "Collection Management System",
|
||||
default_language: "sv",
|
||||
default_timezone: "Europe/Stockholm",
|
||||
};
|
||||
|
||||
const ConfigContext = createContext<ConfigView>(DEFAULTS);
|
||||
|
||||
export function useConfig(): ConfigView {
|
||||
return useContext(ConfigContext);
|
||||
}
|
||||
|
||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async (): Promise<ConfigView> => {
|
||||
const { data, error } = await api.GET("/api/config");
|
||||
|
||||
if (error || !data) throw new Error("failed to load config");
|
||||
|
||||
return data;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Default the UI language to the instance default, unless the user has chosen one
|
||||
// for this browser (LangSwitch persists to localStorage[LOCALE_KEY]).
|
||||
useEffect(() => {
|
||||
if (data && !localStorage.getItem(LOCALE_KEY)) {
|
||||
void i18n.changeLanguage(data.default_language);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return <ConfigContext.Provider value={data ?? DEFAULTS}>{children}</ConfigContext.Provider>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run → passes.** `pnpm test src/config/config-context.test.tsx`.
|
||||
|
||||
- [ ] **Step 6: Mount the provider.** In `web/src/main.tsx`, wrap `<App />` (inside `QueryClientProvider`, since the provider uses TanStack Query):
|
||||
```tsx
|
||||
import { ConfigProvider } from "./config/config-context";
|
||||
// ...
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify + commit.** `pnpm test && pnpm typecheck && pnpm lint && pnpm build`. All green (existing tests unaffected — MSW now answers `/api/config` so `onUnhandledRequest:"error"` stays happy app-wide).
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "feat(web): config provider — fetch /api/config, default UI language from instance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend — single-language content authoring
|
||||
|
||||
**Files:** Modify `web/src/components/label-editor.tsx`, `web/src/objects/field-input.tsx`, `web/src/i18n/{en,sv}.json`, `web/src/components/label-editor.test.tsx`, `web/src/vocab/vocabularies.test.tsx`, `web/src/fields/fields.test.tsx`, `web/src/authorities/authorities.test.tsx`.
|
||||
|
||||
> The content schema, DTOs (`LabelInput`/`LabelView`), DB tables, `LocalizedLabel`, and `FieldType::LocalizedText` are **unchanged**. Only the input components collapse to one language. Reading/display (`labelText`/`pick_label`) already falls back (UI lang → en → first), so single-language data still renders — no change to the read path.
|
||||
|
||||
- [ ] **Step 1: i18n key.** Add `labels.label` to BOTH `web/src/i18n/en.json` and `sv.json`:
|
||||
- en `labels`: `"label": "Label"`
|
||||
- sv `labels`: `"label": "Etikett"`
|
||||
(Keep the existing `labels.en`/`labels.sv`/`labels.externalUri` keys — `externalUri` is still used; `labels.en`/`labels.sv` may become unused after this task — if `pnpm lint`/grep shows them unreferenced, remove them from BOTH files to keep parity, else leave.)
|
||||
|
||||
- [ ] **Step 2: Collapse `LabelEditor`** — replace `web/src/components/label-editor.tsx` body:
|
||||
```tsx
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { components } from "../api/schema";
|
||||
import { useConfig } from "../config/config-context";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
/** Single-language label editor. Authors one label at the instance default language;
|
||||
* emits a one-entry LabelInput[] (empty array when blank). The multilingual data model
|
||||
* is unchanged — this only simplifies authoring. */
|
||||
export function LabelEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: LabelInput[];
|
||||
onChange: (labels: LabelInput[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { default_language } = useConfig();
|
||||
|
||||
const current =
|
||||
value.find((l) => l.lang === default_language)?.label ?? value[0]?.label ?? "";
|
||||
|
||||
const set = (label: string) =>
|
||||
onChange(label.trim() ? [{ lang: default_language, label }] : []);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label">{t("labels.label")}</Label>
|
||||
<Input id="label" value={current} onChange={(e) => set(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `LabelEditor`'s own test** — `web/src/components/label-editor.test.tsx` currently types into `/label \(en\)/i` + `/label \(sv\)/i` and asserts both langs. Rewrite it for the single input (it must render under a `ConfigProvider` so `useConfig` works — wrap with the test's existing `renderApp`/provider, adding `ConfigProvider`; the MSW `/api/config` handler returns `default_language: "sv"`). New test:
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { expect, test } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderApp } from "../test/render";
|
||||
import { ConfigProvider } from "../config/config-context";
|
||||
import { LabelEditor } from "./label-editor";
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||
const [value, setValue] = useState<LabelInput[]>([]);
|
||||
return <LabelEditor value={value} onChange={(v) => { setValue(v); onChange(v); }} />;
|
||||
}
|
||||
|
||||
test("emits a single label at the instance default language", async () => {
|
||||
const seen: LabelInput[][] = [];
|
||||
renderApp(<ConfigProvider><Harness onChange={(v) => seen.push(v)} /></ConfigProvider>);
|
||||
// config (default_language "sv") must load before the editor authors
|
||||
await screen.findByLabelText(/^label$/i);
|
||||
await userEvent.type(screen.getByLabelText(/^label$/i), "Brons");
|
||||
await waitFor(() => {
|
||||
const last = seen[seen.length - 1]!;
|
||||
expect(last).toEqual([{ lang: "sv", label: "Brons" }]);
|
||||
});
|
||||
});
|
||||
```
|
||||
NOTE: if `renderApp` doesn't already provide a `QueryClientProvider` that `ConfigProvider` needs, check `web/src/test/render.tsx` — it does wrap `QueryClientProvider` (the vocab/search tests rely on it). The MSW `/api/config` default handler (Task 2) supplies the config.
|
||||
|
||||
- [ ] **Step 4: Update the consumer tests.** The forms that use `LabelEditor` have tests typing into `/label \(en\)/i`. They now render a single `/^label$/i` input writing `sv`. Update each:
|
||||
- `web/src/vocab/vocabularies.test.tsx:48` — `getByLabelText(/label \(en\)/i)` → `getByLabelText(/^label$/i)`. These tests render the full app/route tree which must include `ConfigProvider` for `useConfig` — check `renderApp`/the test tree; if the tree doesn't wrap `ConfigProvider`, wrap the rendered subtree in `<ConfigProvider>` (the MSW `/api/config` handler answers). Adjust any assertion expecting an EN/SV pair to the single `sv` label.
|
||||
- `web/src/fields/fields.test.tsx` (3 sites: lines ~38, ~58, ~79) — same `getByLabelText(/^label$/i)` swap + wrap `ConfigProvider` if needed.
|
||||
- `web/src/authorities/authorities.test.tsx:28` — same.
|
||||
Run each file and fix selector/provider issues until green.
|
||||
|
||||
- [ ] **Step 5: Collapse the `LocalizedText` field input** — in `web/src/objects/field-input.tsx`, the `case "localized_text":` block renders `${key}.en` + `${key}.sv` inputs. Replace with a single input registering `${key}.${default_language}`. Add `const { default_language } = useConfig();` near the top of the `FieldInput` component (alongside the existing `const lang = …`). New case:
|
||||
```tsx
|
||||
case "localized_text":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={definition.key}>{label}</Label>
|
||||
<Input
|
||||
id={definition.key}
|
||||
{...form.register(fieldPath<TValues>(`${definition.key}.${default_language}`), {
|
||||
required: definition.required,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
(Imports: `useConfig` from `../config/config-context`.) The stored value remains a `{ lang: text }` map — now `{ [default_language]: text }`. The `field-input.test.tsx` may reference the EN/SV localized inputs — update it to the single input (register path `${key}.${default_language}`), wrapping with `ConfigProvider` if the test renders the component directly.
|
||||
|
||||
- [ ] **Step 6: Verify + commit.** `cd web && pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm check:size`. All green; bundle ≤150 KB. en/sv parity holds.
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
git add web
|
||||
git commit -m "feat(web): single-language content authoring (LabelEditor + localized_text at default lang)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1: i18n parity** —
|
||||
```bash
|
||||
cd web
|
||||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const k=o=>Object.entries(o).flatMap(([K,v])=>typeof v==='object'?k(v).map(s=>K+'.'+s):[K]);const ka=k(a).sort(),kb=k(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH '+JSON.stringify({onlyEn:ka.filter(x=>!kb.includes(x)),onlySv:kb.filter(x=>!ka.includes(x))}))"
|
||||
```
|
||||
Expected `PARITY OK`.
|
||||
|
||||
- [ ] **Step 2: Frontend** — `pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (report bundle gz).
|
||||
|
||||
- [ ] **Step 3: Backend** —
|
||||
```bash
|
||||
cd /Users/olsson/Laboratory/biggus-dickus
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo +nightly fmt --check
|
||||
```
|
||||
All pass; clippy + fmt clean.
|
||||
|
||||
- [ ] **Step 4: Acceptance spot-checks.**
|
||||
- `cargo run -p server -- --help | grep -E "default-language|default-timezone"` shows both flags.
|
||||
- Content schema untouched: `git diff main..HEAD -- crates/db/migrations crates/domain/src/label.rs` is empty (no schema/domain label changes).
|
||||
- `git grep -in 'biggus\|dickus' -- crates web/src` → none.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
- **Spec coverage:** env knobs + AppState → T1; public `/api/config` → T1; config provider + i18n default → T2; single-language `LabelEditor` + `LocalizedText` → T3; UTC storage unchanged (no timestamp code touched); timezone exposed (no formatter — no consumer, per spec's "forward-ready if none"); parity/bundle → T4. ✓ Per-account UI language + da/no + server-side tz are out of scope (issue #40 / #39). ✓
|
||||
- **Placeholder scan:** none — concrete code; the "wrap ConfigProvider if the test tree doesn't already" notes are real verification steps against named files (the provider dependency is new, so tests that mount label-authoring components need it).
|
||||
- **Type consistency:** `ConfigView { app_name, default_language, default_timezone }` is the single shape across the Rust struct, the `components["schemas"]["ConfigView"]` TS type, the provider `DEFAULTS`, and the MSW handler; `LabelEditor` still emits `LabelInput[]` (one entry); `default_language` threaded from `useConfig()` consistently in both the editor and the field input.
|
||||
|
||||
## Notes
|
||||
- **Timezone has no frontend consumer yet** (no timestamp is displayed — only `recording_date`, a plain DATE). The value is exposed via `/api/config` + `useConfig` so PDF export (#39) and any future audit/timestamp view can format with it; building a `formatTimestamp` helper now would be unused (YAGNI).
|
||||
- **`AppState` gained two fields** → every `AppState { … }` literal (incl. all api/server test harnesses) must add them or the workspace won't compile; Task 1 Step 3 enumerates this.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
# Objects Data-Overview Table + Responsive Shell — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Turn `/objects` into a full-width, sortable, filterable data table (backed by Postgres sort/filter + exposed timestamps), with a collapsible icon sidebar and a responsive detail pane/drawer at a canonical `/objects/:id` URL.
|
||||
|
||||
**Architecture:** Phase 1 adds backend `sort`/`order`/`visibility`/`q` params (injection-safe) + a filtered count + exposes `created_at`/`updated_at`. Phase 2 replaces the narrow `ObjectList` with a full-width `ObjectsTable` whose state lives in the URL. Phase 3 makes the shell sidebar collapsible (lucide icons + Base UI tooltip) and renders detail as a right pane (wide) / Base UI `Drawer` (narrow) via the existing nested `/objects/:id` route.
|
||||
|
||||
**Tech Stack:** Rust (axum, sqlx/Postgres, utoipa), React 19 + TS + pnpm, `@base-ui/react` (drawer/collapsible/tooltip — already a dep), `lucide-react` 1.17 (already a dep), react-router 7, TanStack Query, Vitest+RTL+MSW, Storybook 10.
|
||||
|
||||
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity for new keys; **no codename**; portal queries in tests via `within(document.body)`; `pnpm check:size` budget **165 KB gz**. Test infra: Postgres 5442, Meili 7700; `#[sqlx::test(migrations="../db/migrations")]`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
**Backend:** `crates/db/src/catalog.rs` (filtered list+count, sort enum), `crates/api/src/admin_objects.rs` (query params, `AdminObjectView` timestamps), `crates/api/src/openapi.rs` (if new schema types). **Frontend:** `web/src/api/queries.ts` (`useObjectsPage` params), new `web/src/objects/objects-table.tsx` (+ `.stories.tsx`, `.test.tsx`), `web/src/objects/objects-page.tsx` (restructure to table + responsive detail), `web/src/shell/app-shell.tsx` (collapsible sidebar), new `web/src/components/ui/tooltip.tsx`, new `web/src/lib/use-media-query.ts`, `web/src/i18n/{en,sv}.json`. `web/src/objects/object-list.tsx` is removed (replaced by the table).
|
||||
|
||||
---
|
||||
|
||||
# PHASE 1 — Backend
|
||||
|
||||
## Task 1: Expose `created_at` / `updated_at` on `AdminObjectView`
|
||||
**Files:** `crates/api/src/admin_objects.rs`; test `crates/api/tests/admin_catalog.rs`.
|
||||
|
||||
The domain `CatalogueObject` already carries `created_at`/`updated_at` (`time::OffsetDateTime`); only the API view omits them. No migration.
|
||||
|
||||
- [ ] **Step 1: Failing API test** in `admin_catalog.rs`: create an object, `GET /api/admin/objects`, assert the item has non-empty `created_at` and `updated_at` (RFC3339 strings). Run → fails (fields absent).
|
||||
- [ ] **Step 2: Add fields.** In `AdminObjectView` add:
|
||||
```rust
|
||||
/// RFC3339 UTC timestamp.
|
||||
pub created_at: String,
|
||||
/// RFC3339 UTC timestamp.
|
||||
pub updated_at: String,
|
||||
```
|
||||
In `from_object`, map them (the file already has a `format_date` for the `DATE`; for timestamps use RFC3339):
|
||||
```rust
|
||||
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(),
|
||||
```
|
||||
(Confirm `time` is a dep of the `api` crate; it is used transitively — if not in `Cargo.toml`, add `time.workspace = true`. Verify the `CatalogueObject` field names `created_at`/`updated_at` and their `OffsetDateTime` type in `crates/db/src/catalog.rs:210-211`.)
|
||||
- [ ] **Step 3:** `cargo +nightly fmt`; `cargo clippy -p api`; run the test (compose up):
|
||||
```
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run -p api -E 'test(admin_catalog)'
|
||||
```
|
||||
- [ ] **Step 4: Commit** `feat(api): expose object created_at/updated_at in AdminObjectView (#44)`.
|
||||
|
||||
## Task 2: Server-side sort / order / visibility / quick-filter for the object list
|
||||
**Files:** `crates/db/src/catalog.rs`, `crates/api/src/admin_objects.rs`; tests in `crates/db/tests/object.rs` (or wherever catalog list is tested) + `crates/api/tests/admin_catalog.rs`.
|
||||
|
||||
- [ ] **Step 1: Define a sort enum + filtered db functions** in `crates/db/src/catalog.rs`. Add:
|
||||
```rust
|
||||
/// Whitelisted, injection-safe sort columns for the object list.
|
||||
#[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.
|
||||
pub struct ObjectQuery<'a> {
|
||||
pub sort: ObjectSort,
|
||||
pub descending: bool,
|
||||
pub visibility: Option<&'a str>,
|
||||
pub q: Option<&'a str>,
|
||||
}
|
||||
```
|
||||
Add `list_objects_query` + `count_objects_query` that build SQL from the **enum** (never a raw client string). Both share a WHERE builder. Example:
|
||||
```rust
|
||||
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)
|
||||
}
|
||||
|
||||
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 q = sqlx::query(&sql);
|
||||
for b in &binds { q = q.bind(b); }
|
||||
let rows = q.bind(limit).bind(offset).fetch_all(pool).await?;
|
||||
rows.into_iter().map(map_object).collect()
|
||||
}
|
||||
|
||||
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 query = sqlx::query(&sql);
|
||||
for b in &binds { query = query.bind(b); }
|
||||
query.fetch_one(pool).await?.try_get("n")
|
||||
}
|
||||
```
|
||||
Keep the existing `list_objects_paged`/`count_objects` if other callers use them (grep; if only the handler calls them, you may replace — verify). The `ObjectColumns`/`map_object` already include the timestamp columns.
|
||||
- [ ] **Step 2: db tests** in the catalog test file: seed objects with distinct names/visibilities; assert `list_objects_query` orders by `object_name DESC`, filters by `visibility="draft"`, and `q` ILIKE matches number/name; `count_objects_query` returns the filtered count.
|
||||
- [ ] **Step 3: Handler query params.** In `admin_objects.rs`, add a deserialize struct (don't overload the shared `Pagination`):
|
||||
```rust
|
||||
#[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>,
|
||||
}
|
||||
```
|
||||
Parse `sort` → `ObjectSort` (unknown → default `ObjectNumber`), `order` → `descending = order == "desc"`, clamp limit (1..=200, default 50) / offset (>=0) like `Pagination`. Validate `visibility` against `domain::Visibility` (unknown → 422 or ignore — pick ignore-with-default for resilience to hand-edited URLs). Build `ObjectQuery`, call `list_objects_query` + `count_objects_query`. Update the `#[utoipa::path]` `params(...)` to document `sort`/`order`/`visibility`/`q`.
|
||||
- [ ] **Step 4: API test** — `GET /api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo` returns filtered+sorted items and a matching `total`; no params → unchanged default (object_number asc).
|
||||
- [ ] **Step 5:** fmt + clippy + `cargo nextest run -p api -p db`. **Commit** `feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)`.
|
||||
|
||||
## Task 3: Regenerate web API types
|
||||
- [ ] Start the built server on an alt port (8080 may be taken): `BIND_ADDR=127.0.0.1:8090 DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… ./target/debug/server`, then `cd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts`. Verify `created_at`/`updated_at` appear on `AdminObjectView`; `pnpm typecheck`. Stop the server. **Commit** `chore(web): regenerate API types (object list params + timestamps)`.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 2 — The table
|
||||
|
||||
## Task 4: `useObjectsPage` gains sort/filter params
|
||||
**Files:** `web/src/api/queries.ts`.
|
||||
- [ ] Replace the `(limit, offset)` signature with a params object and `keepPreviousData`:
|
||||
```ts
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
|
||||
export type ObjectListParams = {
|
||||
limit: number; offset: number;
|
||||
sort?: string; order?: "asc" | "desc";
|
||||
visibility?: string; q?: string;
|
||||
};
|
||||
|
||||
export function useObjectsPage(params: ObjectListParams) {
|
||||
return useQuery({
|
||||
queryKey: ["objects", params],
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET("/api/admin/objects", {
|
||||
params: { query: {
|
||||
limit: params.limit, offset: params.offset,
|
||||
sort: params.sort, order: params.order,
|
||||
visibility: params.visibility, q: params.q,
|
||||
} },
|
||||
});
|
||||
if (error || !data) throw new Error("failed to load objects");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
(openapi-fetch drops `undefined` query params, so omit-by-undefined is fine.) Update the existing call site in `object-list.tsx` — but that file is being replaced in Task 5; if Task 5 lands in the same branch, just ensure typecheck passes after Task 5. **Commit with Task 5** (or standalone if you prefer). Keep `useObject` unchanged.
|
||||
|
||||
## Task 5: `ObjectsTable` — full-width table, URL-synced state, pagination, sort headers
|
||||
**Files:** create `web/src/objects/objects-table.tsx`, `objects-table.stories.tsx`, `objects-table.test.tsx`; delete `web/src/objects/object-list.tsx`.
|
||||
|
||||
Behavior: reads all state from the URL (`useSearchParams`) — `sort`, `order`, `q`, `visibility`, `offset`, `limit` (default sort `object_number`/`asc`, limit 50, offset 0). Renders a real `<table>`; reuses `VisibilityBadge`; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with `aria-sort`); a row is a `<tr>` whose click navigates to `/objects/:id` **preserving the current search string** (so back restores state); pagination footer with prev/next + page-size `<select>` (or the future `ui/select`); a debounced quick-filter `Input` (`q`) and visibility chips live in a toolbar (Task 6 may own the toolbar — implement them here together to keep the table coherent).
|
||||
|
||||
- [ ] **Step 1: Component.** Concrete core (fill routine markup/classes to match the app; use token classes per #49 where easy, else existing patterns):
|
||||
```tsx
|
||||
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { components } from "../api/schema";
|
||||
import { useObjectsPage } from "../api/queries";
|
||||
import { useDebouncedValue } from "../lib/use-debounced-value";
|
||||
import { VisibilityBadge } from "./visibility-badge";
|
||||
// + ui/button, ui/input, ui/skeleton, lucide chevrons
|
||||
|
||||
const SORTABLE = ["object_number", "object_name", "updated_at"] as const;
|
||||
const PAGE_SIZES = [25, 50, 100, 200];
|
||||
const VIS = ["all", "draft", "internal", "public"] as const;
|
||||
|
||||
export function ObjectsTable() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id: selectedId } = useParams(); // highlight the open row
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const sort = params.get("sort") ?? "object_number";
|
||||
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
|
||||
const visibility = params.get("visibility") ?? "all";
|
||||
const limit = Number(params.get("limit")) || 50;
|
||||
const offset = Number(params.get("offset")) || 0;
|
||||
const qParam = params.get("q") ?? "";
|
||||
const [qText, setQText] = useState(qParam);
|
||||
const q = useDebouncedValue(qText, 300);
|
||||
|
||||
// sync debounced q → URL (reset offset)
|
||||
useEffect(() => {
|
||||
setParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
const term = q.trim();
|
||||
if (term) next.set("q", term); else next.delete("q");
|
||||
next.delete("offset");
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [q, setParams]);
|
||||
|
||||
const { data, isLoading, isError } = useObjectsPage({
|
||||
limit, offset, sort, order,
|
||||
visibility: visibility === "all" ? undefined : visibility,
|
||||
q: q.trim() || undefined,
|
||||
});
|
||||
|
||||
const setParam = (mutate: (n: URLSearchParams) => void) =>
|
||||
setParams((prev) => { const n = new URLSearchParams(prev); mutate(n); return n; }, { replace: true });
|
||||
|
||||
const toggleSort = (col: string) =>
|
||||
setParam((n) => {
|
||||
const curOrder = n.get("order") === "desc" ? "desc" : "asc";
|
||||
const curSort = n.get("sort") ?? "object_number";
|
||||
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
|
||||
n.set("sort", col); n.set("order", nextOrder); n.delete("offset");
|
||||
});
|
||||
|
||||
// header cell: aria-sort = col===sort ? (order==='asc'?'ascending':'descending') : 'none'
|
||||
// row: <tr onClick={() => navigate(`/objects/${o.id}?${params}`)} aria-selected={o.id===selectedId} ...>
|
||||
// pagination: prev disabled offset===0; next disabled offset+limit>=total; page-size select sets limit + deletes offset
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Render loading via `Skeleton` rows; error → `objects.loadError`; empty → `objects.empty`. Visibility chips mirror the search-panel `<button aria-pressed>` pattern (set `visibility` param, delete `offset`). The "Updated" cell: format `o.updated_at` with `Intl.DateTimeFormat(i18n.language, { dateStyle:'medium', timeZone: useConfig().default_timezone })` (or a relative-time helper) — keep it a small local helper. **No `any`** (cast page items as `components["schemas"]["AdminObjectView"]`).
|
||||
- [ ] **Step 2: i18n** — add `objects.columns.{number,name,visibility,location,count,updated}`, `objects.filter` (quick-filter placeholder), `objects.pageSize`, `objects.all` (or reuse `search.all`) to **both** `en.json` and `sv.json`.
|
||||
- [ ] **Step 3: Stories** `objects-table.stories.tsx` — render inside a `MemoryRouter` (the preview provides providers; add a router if needed) with MSW returning a small page: `Default` (rows render), `Sorted` (assert `aria-sort` on the active header), `Empty`. Mirror the visibility-badge story format.
|
||||
- [ ] **Step 4: Unit test** `objects-table.test.tsx` (RTL + MSW + MemoryRouter): rows render the columns; clicking a sortable header updates the URL `sort`/`order` and sets `aria-sort`; typing in the filter (debounced) sets `q`; a visibility chip sets `visibility`; pagination next/prev change `offset`; page-size sets `limit`. Use the search-panel test as a reference for MSW + router wiring.
|
||||
- [ ] **Step 5:** `pnpm typecheck && pnpm lint && pnpm test -- objects-table`. **Commit** `feat(web): full-width sortable/filterable objects table with URL state (#44)`.
|
||||
|
||||
## Task 6: Wire the table into the page (table full-width; detail via Outlet placeholder)
|
||||
**Files:** `web/src/objects/objects-page.tsx` (interim — full restructure in Phase 3).
|
||||
- [ ] Make `ObjectsPage` render `ObjectsTable` full-width for now, keeping the nested `<Outlet/>` available but not as a fixed 20rem column (Phase 3 makes it a pane/drawer). Interim acceptable state: table fills the area; if a `:id` child route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove the `index → SelectPrompt` route's visual prominence (the table is the landing view). **Verify** `pnpm test && pnpm build`. **Commit** `feat(web): objects table as the /objects landing view (#44)`.
|
||||
|
||||
> Note: Tasks 5–6 can be one commit if cleaner. The key is the table renders at `/objects` and row-click deep-links to `/objects/:id` with preserved query state.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 3 — Shell & responsive detail
|
||||
|
||||
## Task 7: `useMediaQuery` hook + `ui/tooltip.tsx` wrapper
|
||||
**Files:** create `web/src/lib/use-media-query.ts`, `web/src/components/ui/tooltip.tsx`.
|
||||
- [ ] **`use-media-query.ts`** (tiny, SSR-safe, mirrors `use-debounced-value`):
|
||||
```ts
|
||||
import { useEffect, useState } from "react";
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window !== "undefined" ? window.matchMedia(query).matches : false);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const on = () => setMatches(mql.matches);
|
||||
on(); mql.addEventListener("change", on);
|
||||
return () => mql.removeEventListener("change", on);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
```
|
||||
- [ ] **`ui/tooltip.tsx`** — wrap `@base-ui/react/tooltip` parts (Provider/Root/Trigger/Portal/Positioner/Popup) in the established `ui/*` style (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn`, `render=` where a trigger delegates). Export a simple `<Tooltip content=…>{trigger}</Tooltip>` convenience plus the raw parts. **RUN a quick story/test** to confirm the Base UI composition (first tooltip in the repo — verify the part tree by running, like the combobox was). No `any`.
|
||||
- [ ] Typecheck/lint. **Commit** `feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)`.
|
||||
|
||||
## Task 8: Collapsible icon sidebar
|
||||
**Files:** `web/src/shell/app-shell.tsx` (+ optional `sidebar.stories.tsx`).
|
||||
- [ ] Add lucide icons to each nav item (e.g. `Boxes`/`BookMarked`/`Users`/`Search`/`Tags` — pick sensible icons). Add a collapse toggle button; persist `collapsed` to `localStorage` (`sidebar-collapsed`); auto-collapse when `useMediaQuery("(max-width: 768px)")`. Expanded: icon + label (`w-44`). Collapsed: icon only (`~w-14`) with the label via the `ui/tooltip` (and `aria-label`/`title`). Preserve `NavLink` active styling; add `focus-visible` rings.
|
||||
- [ ] **Story** `app-shell` sidebar or a extracted `Sidebar` component: `Expanded` / `Collapsed` (assert labels hidden + tooltips/`aria-label` present). If extracting a `Sidebar` component from `app-shell` makes it testable/storyable, do so (keep `app-shell` thin).
|
||||
- [ ] Typecheck/lint/test. **Commit** `feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58)`.
|
||||
|
||||
## Task 9: Responsive detail — right pane (wide) / Drawer (narrow) at canonical `/objects/:id`
|
||||
**Files:** `web/src/objects/objects-page.tsx`; possibly a small `object-detail-panel.tsx`.
|
||||
- [ ] Restructure `ObjectsPage`: always render `ObjectsTable`; detect an active detail child with `useMatch("/objects/:id")` / `useMatch("/objects/:id/edit")`. When matched:
|
||||
- **Wide** (`useMediaQuery("(min-width: 1024px)")`): render a right-hand pane (e.g. `grid-cols-[1fr_28rem]` when open, else `1fr`) containing `<Outlet/>`, with a close control (`navigate("/objects?"+params)`).
|
||||
- **Narrow:** render `<Outlet/>` inside a Base UI `Drawer` (`swipeDirection="right"`, edge = right) over the table; closing the drawer navigates back to `/objects` (preserve query). **RUN to confirm** the Drawer part tree (Root/Portal/Backdrop/Popup/Close) — first Drawer in the repo; mirror the alert-dialog wrapper conventions.
|
||||
- Remove the `index → SelectPrompt` route (the table is the landing view); `SelectPrompt` can be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index).
|
||||
- `:id/edit` continues to render through the same `<Outlet/>` (pane/drawer), preserving today's "edit in the right area" behavior.
|
||||
- [ ] **Test:** with a mocked `matchMedia`, `/objects/:id` renders detail in a pane (wide) and in a portaled Drawer (narrow, query via `within(document.body)`); closing returns to `/objects` with the table's query string intact; deep-linking `/objects/:id` directly renders table + open detail.
|
||||
- [ ] Typecheck/lint/test/build. **Commit** `feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)`.
|
||||
|
||||
---
|
||||
|
||||
# PHASE 4 — Verification
|
||||
|
||||
## Task 10: Final verification
|
||||
- [ ] Backend: `cargo +nightly fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo nextest run --workspace` (single clean run — don't run two concurrently; sqlx temp-DB contention produces fake failures).
|
||||
- [ ] Web: `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` (index ≤ **165 KB gz** — lucide/tooltip/drawer land in the always-loaded shell; tree-shaken — verify and report the number).
|
||||
- [ ] `pnpm test -- i18n` (en/sv parity for the new `objects.columns.*` etc.); `git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN`; `git status --short` clean.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
**Spec coverage:** sort/filter/q + filtered total + timestamps (T1–T3); full-width table with columns/sort/filter/pagination/URL-state (T4–T6); collapsible icon sidebar (T8); responsive pane/drawer + canonical `/objects/:id` (T7,T9); stories (T5,T7,T8); bundle/parity/codename (T10). ✓ Out of scope (Meili unification, detail-content #45, multi-select) not included. ✓
|
||||
**Placeholder scan:** load-bearing logic (SQL builder, sort enum, URL-state wiring, sort toggle, responsive routing, media-query/tooltip) is concrete; routine table markup/classes are described to match existing patterns; the two novel Base UI primitives (Tooltip, Drawer) carry explicit "verify the part tree by running" steps (same approach that worked for the combobox), with canonical trees from the spec. No "TBD"/"add error handling".
|
||||
**Type consistency:** `ObjectSort` enum + `ObjectQuery` (db) ↔ `ObjectListParams` (api) ↔ `useObjectsPage(ObjectListParams)` (web) align on sort/order/visibility/q; `AdminObjectView` gains `created_at`/`updated_at` (T1) consumed by the table's Updated column (T5). URL param names (`sort`/`order`/`visibility`/`q`/`limit`/`offset`) consistent across table read/write and the hook.
|
||||
|
||||
## Notes
|
||||
- `lucide-react` + Base UI tooltip/drawer/collapsible are already deps → no `pnpm-lock` churn.
|
||||
- No DB migration (timestamps already exist).
|
||||
- Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if `check:size` exceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.
|
||||
@@ -0,0 +1,451 @@
|
||||
# Searchable Term/Authority Picker 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:** Replace the native `<select>` for term/authority object fields with a searchable combobox (type-to-filter by active-locale label, client-side), built on Base UI's `combobox` primitive — keeping the `value = id` contract.
|
||||
|
||||
**Architecture:** A styled wrapper `ui/combobox.tsx` over `@base-ui/react/combobox` (mirroring the existing `ui/alert-dialog.tsx` Base UI wrapper), consumed by a focused `OptionsCombobox` component with the **same prop contract** as today's `OptionsSelect`, dropped into `TermField`/`AuthorityField`. No backend change; `useTerms`/`useAuthorities` unchanged.
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + pnpm, `@base-ui/react` v1.5.0 (already a dependency), Tailwind v4, react-hook-form, Vitest + RTL + MSW, Storybook 10.
|
||||
|
||||
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source = double quotes + semicolons, stories = single quotes + no semicolons; en/sv parity for any new keys; no codename ("biggus"/"dickus"); per-test portal queries use `within(document.body)`. Tests: `cd web && pnpm test` (vitest, jsdom + storybook projects), `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm check:size`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-06-searchable-term-authority-picker-design.md`
|
||||
|
||||
**Base UI Combobox — canonical single-select composition** (import `@base-ui/react/combobox`; `value` is the **item object** or `null`; `onValueChange(item)` gives the item; filtering is built-in against `itemToStringLabel`):
|
||||
```tsx
|
||||
<Combobox.Root items={items} value={value} onValueChange={setValue}
|
||||
itemToStringLabel={(it) => it.label} isItemEqualToValue={(a, b) => a?.id === b?.id}>
|
||||
<Combobox.InputGroup>
|
||||
<Combobox.Input placeholder="…" id={id} />
|
||||
<Combobox.Clear aria-label="Clear" />
|
||||
<Combobox.Trigger aria-label="Open" />
|
||||
</Combobox.InputGroup>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Positioner sideOffset={4}>
|
||||
<Combobox.Popup>
|
||||
<Combobox.Empty>No matches.</Combobox.Empty>
|
||||
<Combobox.List>
|
||||
{(item) => (
|
||||
<Combobox.Item key={item.id} value={item}>
|
||||
<Combobox.ItemIndicator>✓</Combobox.ItemIndicator>
|
||||
{item.label}
|
||||
</Combobox.Item>
|
||||
)}
|
||||
</Combobox.List>
|
||||
</Combobox.Popup>
|
||||
</Combobox.Positioner>
|
||||
</Combobox.Portal>
|
||||
</Combobox.Root>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `web/src/components/ui/combobox.tsx` (new) — styled passthrough wrappers over the Base UI `Combobox.*` parts (mirror `ui/alert-dialog.tsx`'s conventions: `data-slot`, `cn()`, re-export composed parts).
|
||||
- `web/src/objects/options-combobox.tsx` (new) — `OptionsCombobox`, the drop-in picker (same prop contract as `OptionsSelect`), composing the wrapper parts for `{ id, labels }` options. Extracted to its own file so it is focused, unit-testable, and storyable.
|
||||
- `web/src/objects/options-combobox.stories.tsx` (new) — Storybook stories.
|
||||
- `web/src/objects/options-combobox.test.tsx` (new) — unit test (open/filter/select/clear).
|
||||
- `web/src/objects/field-input.tsx` (modify) — `TermField`/`AuthorityField` render `OptionsCombobox`; delete `OptionsSelect`.
|
||||
- `web/src/objects/field-input.test.tsx` (modify) — update the term/authority cases for the combobox.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Combobox component (`ui/combobox.tsx` + `OptionsCombobox` + story + unit test)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/components/ui/combobox.tsx`, `web/src/objects/options-combobox.tsx`, `web/src/objects/options-combobox.stories.tsx`, `web/src/objects/options-combobox.test.tsx`
|
||||
|
||||
**Before coding:** READ `web/src/components/ui/alert-dialog.tsx` (the Base UI wrapper conventions: `import { X as XPrimitive } from "@base-ui/react/x"`, `data-slot` attributes, `cn()` class merge, `render={…}` trigger style). The exact Base UI `Combobox.*` part prop types are in `node_modules/@base-ui/react/combobox/` — consult them if a passthrough type is unclear.
|
||||
|
||||
- [ ] **Step 1: Write the styled wrapper** `web/src/components/ui/combobox.tsx`. Wrap the Base UI parts the picker needs, with Tailwind classes consistent with the existing inputs/menus (the native `<select>` used `w-full rounded border px-2 py-1 text-sm`; the popup should look like a menu surface). Concrete starting implementation (adjust class details to match the app's look; keep the structure):
|
||||
|
||||
```tsx
|
||||
import * as React from "react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ComboboxRoot<Value>(props: ComboboxPrimitive.Root.Props<Value>) {
|
||||
return <ComboboxPrimitive.Root data-slot="combobox" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxInputGroup({ className, ...props }: ComboboxPrimitive.InputGroup.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.InputGroup
|
||||
data-slot="combobox-input-group"
|
||||
className={cn("relative flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-input"
|
||||
className={cn("w-full rounded border px-2 py-1 pr-12 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
className={cn(
|
||||
"absolute right-6 text-neutral-400 hover:text-neutral-700",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxTrigger({ className, ...props }: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("absolute right-1 text-neutral-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxPopup({ className, ...props }: ComboboxPrimitive.Popup.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner sideOffset={4} className="z-50">
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-popup"
|
||||
className={cn(
|
||||
"max-h-64 w-[var(--anchor-width)] overflow-auto rounded border bg-white p-1 text-sm shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList(props: ComboboxPrimitive.List.Props) {
|
||||
return <ComboboxPrimitive.List data-slot="combobox-list" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded px-2 py-1 data-[highlighted]:bg-indigo-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn("px-2 py-1 text-neutral-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ComboboxRoot,
|
||||
ComboboxInputGroup,
|
||||
ComboboxInput,
|
||||
ComboboxClear,
|
||||
ComboboxTrigger,
|
||||
ComboboxPopup,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
};
|
||||
```
|
||||
If a part's `.Props` type path differs (verify against the d.ts), adjust the type annotation — do **not** fall back to `any`. (`--anchor-width` is Base UI's positioner CSS var for matching the input width; if it isn't exposed under that name, use `min-w-[12rem]` instead — confirm when you run the story.)
|
||||
|
||||
- [ ] **Step 2: Write `OptionsCombobox`** `web/src/objects/options-combobox.tsx` — the drop-in with the exact contract of the old `OptionsSelect`. It converts between the rhf `value` (id string) and the Base UI item object, and filters/displays by active-locale label.
|
||||
|
||||
```tsx
|
||||
import type { components } from "../api/schema";
|
||||
import {
|
||||
ComboboxRoot,
|
||||
ComboboxInputGroup,
|
||||
ComboboxInput,
|
||||
ComboboxClear,
|
||||
ComboboxTrigger,
|
||||
ComboboxPopup,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
type LabelView = components["schemas"]["LabelView"];
|
||||
|
||||
export type Option = { id: string; labels: LabelView[] };
|
||||
|
||||
function labelIn(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 OptionsCombobox({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
lang,
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: Option[];
|
||||
lang: string;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const selected = options.find((o) => o.id === value) ?? null;
|
||||
|
||||
return (
|
||||
<ComboboxRoot<Option | null>
|
||||
items={options}
|
||||
value={selected}
|
||||
onValueChange={(option) => onChange(option?.id ?? "")}
|
||||
itemToStringLabel={(option) => (option ? labelIn(option.labels, lang) : "")}
|
||||
isItemEqualToValue={(a, b) => a?.id === b?.id}
|
||||
>
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxInput id={id} placeholder={placeholder} />
|
||||
<ComboboxClear aria-label={placeholder} />
|
||||
<ComboboxTrigger aria-label={placeholder} />
|
||||
</ComboboxInputGroup>
|
||||
|
||||
<ComboboxPopup>
|
||||
<ComboboxEmpty>{placeholder}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: Option) => (
|
||||
<ComboboxItem key={option.id} value={option}>
|
||||
{labelIn(option.labels, lang)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxPopup>
|
||||
</ComboboxRoot>
|
||||
);
|
||||
}
|
||||
```
|
||||
Notes:
|
||||
- `labelIn` is duplicated here from `field-input.tsx`. In Task 2 you will **export `labelIn` from a shared spot** (see Task 2 Step 3) and import it in both — for now define it locally so this file compiles standalone; Task 2 dedupes.
|
||||
- Confirm the generic on `ComboboxRoot<Option | null>` matches the wrapper's `Root.Props<Value>` signature; if Base UI's `value`/`onValueChange`/`itemToStringLabel`/`isItemEqualToValue` prop names differ from the canonical example, adjust to the real names from the d.ts (you already have: `items`, `value`, `onValueChange`, `itemToStringLabel`, `isItemEqualToValue`).
|
||||
|
||||
- [ ] **Step 3: Write the unit test** `web/src/objects/options-combobox.test.tsx`. Render with two options, exercise open → filter → select → clear. The popup is portaled — query via `within(document.body)`.
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { OptionsCombobox, type Option } from "./options-combobox";
|
||||
|
||||
const options: Option[] = [
|
||||
{ id: "t1", labels: [{ lang: "en", label: "Wood" }] },
|
||||
{ id: "t2", labels: [{ lang: "en", label: "Bronze" }] },
|
||||
];
|
||||
|
||||
function setup(value = "") {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<OptionsCombobox
|
||||
id="material"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
lang="en"
|
||||
placeholder="Select…"
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
describe("OptionsCombobox", () => {
|
||||
it("filters by label and selects the option id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = setup();
|
||||
|
||||
const input = screen.getByPlaceholderText("Select…");
|
||||
await user.click(input);
|
||||
await user.type(input, "bro");
|
||||
|
||||
const body = within(document.body);
|
||||
// Only the matching option is listed.
|
||||
expect(body.queryByText("Wood")).toBeNull();
|
||||
await user.click(await body.findByText("Bronze"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("t2");
|
||||
});
|
||||
|
||||
it("shows the selected option's label", () => {
|
||||
setup("t1");
|
||||
expect(screen.getByDisplayValue("Wood")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
(If `getByDisplayValue` doesn't match how Base UI renders the selected label in the input, assert via the input's `value` attribute instead — confirm by running. Run the test before finalizing the assertions.)
|
||||
|
||||
- [ ] **Step 4: Run the unit test.**
|
||||
```
|
||||
cd web && pnpm test -- options-combobox
|
||||
```
|
||||
Expected: PASS. If the Base UI composition needs adjustment (portal target, prop names, selected-label display), fix the wrapper/component and re-run until green. **Do not** weaken assertions to pass — the test must genuinely prove filter + select-by-id + selected-label.
|
||||
|
||||
- [ ] **Step 5: Write the Storybook story** `web/src/objects/options-combobox.stories.tsx` (mirror `web/src/objects/visibility-badge.stories.tsx` format: `@storybook/react-vite`, `storybook/test`, `tags: ['ai-generated']`, single quotes, no semicolons). Stories: `Default` (placeholder visible), `Selected` (value set → label shown), and `FiltersOnType` (type → only the match shows; portal → `within(document.body)`).
|
||||
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { expect, userEvent, fn, within } from 'storybook/test'
|
||||
|
||||
import { OptionsCombobox, type Option } from './options-combobox'
|
||||
|
||||
const options: Option[] = [
|
||||
{ id: 't1', labels: [{ lang: 'en', label: 'Wood' }] },
|
||||
{ id: 't2', labels: [{ lang: 'en', label: 'Bronze' }] },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
component: OptionsCombobox,
|
||||
tags: ['ai-generated'],
|
||||
args: { id: 'material', value: '', onChange: fn(), options, lang: 'en', placeholder: 'Select…' },
|
||||
} satisfies Meta<typeof OptionsCombobox>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvas }) => {
|
||||
await expect(canvas.getByPlaceholderText('Select…')).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const Selected: Story = {
|
||||
args: { value: 't1' },
|
||||
}
|
||||
|
||||
export const FiltersOnType: Story = {
|
||||
play: async ({ canvas, args }) => {
|
||||
const input = canvas.getByPlaceholderText('Select…')
|
||||
await userEvent.click(input)
|
||||
await userEvent.type(input, 'bro')
|
||||
const body = within(document.body)
|
||||
await userEvent.click(await body.findByText('Bronze'))
|
||||
await expect(args.onChange).toHaveBeenCalledWith('t2')
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the stories + typecheck + lint.**
|
||||
```
|
||||
cd web && pnpm test -- options-combobox && pnpm typecheck && pnpm lint
|
||||
```
|
||||
Expected: PASS, no `any`/disable.
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add web/src/components/ui/combobox.tsx web/src/objects/options-combobox.tsx web/src/objects/options-combobox.stories.tsx web/src/objects/options-combobox.test.tsx
|
||||
git commit -m "feat(web): searchable combobox (Base UI) for term/authority options (#27)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire into the object form
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/objects/field-input.tsx`
|
||||
- Modify: `web/src/objects/field-input.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Use `OptionsCombobox` in `TermField`/`AuthorityField`** (`field-input.tsx`). Replace the `<OptionsSelect … />` rendered inside each `Controller` with `<OptionsCombobox … />` (the props are identical: `id`, `value`, `onChange`, `options`, `lang`, `placeholder`). Add the import:
|
||||
```tsx
|
||||
import { OptionsCombobox } from "./options-combobox";
|
||||
```
|
||||
Then **delete the now-unused `OptionsSelect` function** and the stale comment above it ("A native `<select>` keeps the bundle lean…").
|
||||
|
||||
- [ ] **Step 2: Verify no other references to `OptionsSelect`.**
|
||||
```
|
||||
cd web && grep -rn "OptionsSelect" src
|
||||
```
|
||||
Expected: no matches (it's removed).
|
||||
|
||||
- [ ] **Step 3: Dedupe `labelIn`.** `field-input.tsx` and `options-combobox.tsx` both define `labelIn`. Export it from `options-combobox.tsx` (add `export` to its `labelIn`) and import it in `field-input.tsx`, removing `field-input.tsx`'s local copy:
|
||||
```tsx
|
||||
// field-input.tsx
|
||||
import { OptionsCombobox, labelIn } from "./options-combobox";
|
||||
```
|
||||
Confirm `field-input.tsx` still uses `labelIn` for its `definition.labels` rendering (it does, in `FieldInput`). (If you prefer not to couple `field-input` to `options-combobox` for a helper, instead move `labelIn` to `web/src/lib/labels.ts` if that module exists — check `web/src/lib/` — and import from there in both. Pick one; do not leave two copies.)
|
||||
|
||||
- [ ] **Step 4: Update `field-input.test.tsx`** for the combobox. Find the existing term and/or authority test cases (they currently interact with a native `<select>` — e.g. `selectOptions` or asserting `<option>`s) and rewrite them to drive the combobox: render the object form (or the field), open the combobox, type to filter, click the option, and assert the submitted/registered value is the term/authority **id**. Use `within(document.body)` for the portaled popup. Leave the text/integer/date/boolean/localized_text cases unchanged.
|
||||
- Read the current `field-input.test.tsx` to see exactly how the term/authority cases are set up (MSW handlers for `useTerms`/`useAuthorities`, the form wrapper) and adapt those specific cases; do not rewrite the whole file.
|
||||
|
||||
- [ ] **Step 5: Run the field-input tests + full web suite.**
|
||||
```
|
||||
cd web && pnpm test -- field-input && pnpm test && pnpm typecheck && pnpm lint
|
||||
```
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add web/src/objects/field-input.tsx web/src/objects/field-input.test.tsx
|
||||
git commit -m "feat(web): use searchable combobox for term/authority fields on the object form (#27)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full web gate.**
|
||||
```
|
||||
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||
```
|
||||
Expected: all green; `check:size` reports the index chunk ≤ 150 KB gz (the combobox lands in the lazy object-form chunk — confirm the index didn't materially grow).
|
||||
|
||||
- [ ] **Step 2: en/sv parity + codename + no leftover select.**
|
||||
```
|
||||
cd web && pnpm test -- i18n
|
||||
git grep -in 'biggus\|dickus' -- web/src || echo "CODENAME CLEAN"
|
||||
grep -rn "OptionsSelect" web/src || echo "OptionsSelect removed"
|
||||
```
|
||||
Expected: parity passes; codename clean; `OptionsSelect` gone.
|
||||
|
||||
- [ ] **Step 3: Manual smoke (recommended).** `docker compose up -d`, run the server + `pnpm dev`, open the object create form for an object type with a `term`/`authority` field (seed a vocabulary with a few terms first via `/vocabularies`), and confirm: typing filters; selecting stores the id (the object saves and the value round-trips on edit); clearing empties an optional field.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Searchable combobox filtering by active-locale label, value=id, clearable → Task 1 (`OptionsCombobox`) + Task 2 (wired). ✓
|
||||
- Base UI `combobox` primitive, no new dep → Task 1 `ui/combobox.tsx`. ✓
|
||||
- `useTerms`/`useAuthorities` unchanged (client-side) → Task 2 leaves them untouched. ✓
|
||||
- Tests open/filter/select/clear + story → Task 1 Steps 3/5, Task 2 Step 4. ✓
|
||||
- Bundle ≤150 KB gz index, typecheck/lint/test/build/parity/codename → Task 3. ✓
|
||||
- Out of scope (server-side `?q=`, selected-id→label resolution, multi-select) → not implemented; filed as follow-up by the controller. ✓
|
||||
|
||||
**2. Placeholder scan:** No "TBD"/"handle errors"/"similar to". Concrete code for the wrapper, the component, the test, and the story. The few "verify against the d.ts / confirm by running" notes target the one genuinely novel piece (the repo's first Base UI Combobox) and are verification steps, not deferred implementation — the canonical composition is given in the header.
|
||||
|
||||
**3. Type consistency:** `Option = { id, labels }`; `OptionsCombobox` prop contract matches the old `OptionsSelect` exactly (`id/value/onChange/options/lang/placeholder`); `value` (id string) ↔ Base UI item object via `find`/`?.id`. `labelIn` is defined once after Task 2 (exported from `options-combobox.tsx` or `lib/labels.ts`). Wrapper part names match the canonical Base UI tree (Root/InputGroup/Input/Clear/Trigger/Portal/Positioner/Popup/List/Item/Empty).
|
||||
|
||||
## Notes
|
||||
- No new npm dependency (`@base-ui/react` already present) → no `pnpm-lock.yaml` churn expected.
|
||||
- The popup is portaled — every test/story that interacts with options must query `within(document.body)`, not `canvas` alone.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Wire the Spectrum Seed into Runtime 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:** Expose the existing idempotent `db::seed::seed_spectrum_cataloguing` as a `server seed` CLI subcommand (plus a `just seed` recipe and README note), so an operator can seed an instance's baseline cataloguing fields.
|
||||
|
||||
**Architecture:** Mirror the existing `create-user` one-shot exactly — add a `Seed` variant to the clap `Command` enum, dispatch it to a new `server::seed(database_url)` that connects with a tiny pool, applies migrations (idempotent, so it works on a fresh DB), runs the seed inside a transaction, commits, and exits. The seed content and its idempotency are already tested at the db layer; the new code is thin glue.
|
||||
|
||||
**Tech Stack:** Rust (clap derive, sqlx/Postgres, anyhow, tokio). Backend-only + docs.
|
||||
|
||||
**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; never write the codename ("biggus"/"dickus"). Test infra: compose Postgres on host **5442**, Meili **7700**; `#[sqlx::test(migrations = "../db/migrations")]` provisions its own temp DB. Env for manual runs comes from `.env` via the justfile's `set dotenv-load`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `crates/server/src/main.rs` — add a `Seed` variant to the `Command` enum + a dispatch arm.
|
||||
- `crates/server/src/lib.rs` — add `pub async fn seed(database_url: &str) -> anyhow::Result<()>` (modeled on `create_user`, but with a `db.migrate()` step).
|
||||
- `crates/server/tests/seed.rs` (new) — a server-crate building-block regression test mirroring `crates/server/tests/create_user.rs` (seed twice via the test pool; assert a known seeded vocabulary + field).
|
||||
- `justfile` — add a `seed` recipe.
|
||||
- `README.md` — add a seed step to the "Running locally" setup sequence.
|
||||
|
||||
The seed *content* + idempotency stay covered by the existing `crates/db/tests/seed.rs` (unchanged).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `server seed` subcommand
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/server/src/main.rs`
|
||||
- Modify: `crates/server/src/lib.rs`
|
||||
- Create: `crates/server/tests/seed.rs`
|
||||
|
||||
**Reference (the template to mirror) — `server::create_user` in `crates/server/src/lib.rs`:**
|
||||
```rust
|
||||
pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> {
|
||||
// ...email parse + password hash...
|
||||
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 { /* ... */ }).await
|
||||
.context("creating the user (is the email already taken?)")?;
|
||||
tx.commit().await?;
|
||||
println!("created user {id} ({role:?})");
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
`Db::connect(url, n)`, `db.migrate()`, `db.pool()` all already exist (`run` calls `db.migrate()` at `lib.rs:22`). The seed fn `db::seed::seed_spectrum_cataloguing(conn: &mut sqlx::PgConnection)` is idempotent and uses `AuditActor::System` internally — no actor plumbing needed.
|
||||
|
||||
- [ ] **Step 1: Write the server-crate building-block test.** Create `crates/server/tests/seed.rs`. Mirror the harness comment + pool approach from `crates/server/tests/create_user.rs` (the temp-DB URL isn't exposed, so we exercise the building block the command composes — `db::seed::seed_spectrum_cataloguing` — against the test pool, including a second run to prove idempotency):
|
||||
|
||||
```rust
|
||||
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"
|
||||
);
|
||||
}
|
||||
```
|
||||
(Confirm the seeded keys by reading `crates/db/src/seed.rs` — it seeds vocabularies `material`/`object_name`/`technique` and a field def `title`; adjust the asserted keys if they differ.)
|
||||
|
||||
- [ ] **Step 2: Run the test — it should PASS immediately.**
|
||||
```
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo nextest run -p server -E 'test(seed_is_idempotent_via_building_block)'
|
||||
```
|
||||
Expected: PASS. (Unlike classic TDD, this guards an already-working building block the new command depends on — there is no failing-first state because `db::seed` already exists. The genuinely new code is the glue in Steps 3–4, verified by build + the manual smoke in Step 6.)
|
||||
|
||||
- [ ] **Step 3: Add the `Seed` command variant + dispatch** in `crates/server/src/main.rs`. Add to the `Command` enum (after `CreateUser { … }`):
|
||||
```rust
|
||||
/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
|
||||
Seed,
|
||||
```
|
||||
And add a match arm in `main` (the `match cli.command { … }`), after the `CreateUser` arm:
|
||||
```rust
|
||||
Some(Command::Seed) => seed(&cli.config.database_url).await,
|
||||
```
|
||||
Update the import at the top of `main.rs` from `use server::{Config, create_user, run};` to:
|
||||
```rust
|
||||
use server::{Config, create_user, run, seed};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `seed` one-shot** in `crates/server/src/lib.rs`, next to `create_user`:
|
||||
```rust
|
||||
/// 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(())
|
||||
}
|
||||
```
|
||||
(`Db`, `anyhow::Context`/`context` are already imported in `lib.rs` — verify the `use` lines; `create_user` already uses `.context(...)` and `Db::connect`, so the imports exist.)
|
||||
|
||||
- [ ] **Step 5: Build, fmt, clippy, and run the server tests.**
|
||||
```
|
||||
cargo +nightly fmt
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo nextest run -p server
|
||||
```
|
||||
Expected: builds clean, clippy clean, all server tests pass (including the existing `create_user` + `config` + `serve` + `embed` tests and the new seed test). Also confirm the subcommand is wired:
|
||||
```
|
||||
cargo run -p server -- --help
|
||||
```
|
||||
Expected: the help output lists a `seed` subcommand alongside `create-user`.
|
||||
|
||||
- [ ] **Step 6: Manual smoke — verify the real command (connect + migrate + commit glue).** With compose up (`docker compose up -d`):
|
||||
```
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo run -p server -- seed
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo run -p server -- seed
|
||||
```
|
||||
Expected: both print `seeded Spectrum cataloguing baseline (idempotent)` and exit 0 (the second run is a no-op). (This exercises the URL-connect + migrate + commit path that `#[sqlx::test]` can't.)
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/server
|
||||
git commit -m "feat(server): 'seed' subcommand wiring the Spectrum cataloguing seed (#14)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `just seed` recipe + README note
|
||||
|
||||
**Files:**
|
||||
- Modify: `justfile`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Add the `seed` recipe** to `justfile`. Insert after the `run` recipe (keeping the existing comment style), before `test`:
|
||||
```
|
||||
# Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent)
|
||||
seed:
|
||||
cargo run -p server -- seed
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify just parses it.**
|
||||
```
|
||||
just --list
|
||||
```
|
||||
Expected: `seed` appears in the recipe list with its description.
|
||||
|
||||
- [ ] **Step 3: Add a seed step to the README "Running locally" setup sequence.** Open `README.md`, find the "Running locally" section and the step that creates the admin user (the `create-user` instruction). Immediately after it, add a step:
|
||||
```markdown
|
||||
4. Seed the baseline cataloguing fields (idempotent):
|
||||
|
||||
```bash
|
||||
just seed # or: cargo run -p server -- seed
|
||||
```
|
||||
```
|
||||
(Match the surrounding numbering/formatting of the existing steps — renumber subsequent steps if the section is numbered. Read the section first and adapt the wording to its style; the content is: run `just seed` once after creating the admin user to populate the baseline Spectrum vocabularies + field definitions.)
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add justfile README.md
|
||||
git commit -m "docs: 'just seed' recipe + README seed step (#14)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Final verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Full suite + lints.**
|
||||
```
|
||||
cargo +nightly fmt --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 2: Codename scan + tree hygiene.**
|
||||
```
|
||||
git grep -in 'biggus\|dickus' -- crates README.md justfile || echo "CLEAN"
|
||||
git status --short
|
||||
```
|
||||
Expected: `CLEAN`; working tree clean after the task commits.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**1. Spec coverage:**
|
||||
- `server seed` subcommand → Task 1 (main.rs variant + dispatch). ✓
|
||||
- `server::seed` one-shot mirroring create_user, migrate-first → Task 1 Step 4. ✓
|
||||
- Idempotent / safe to re-run → asserted in Task 1 Step 1 test + Step 6 smoke. ✓
|
||||
- `just seed` recipe + README note → Task 2. ✓
|
||||
- Testing: existing db-layer seed tests unchanged + new server-crate building-block test + manual glue smoke → Task 1. ✓
|
||||
- Acceptance: nextest green / fmt / clippy / no codename → Task 3. ✓
|
||||
- Out of scope (no `--seed` flag, no auto-boot, no provisioning, no term seeding, create_user unchanged) → respected; only the four files above change. ✓
|
||||
|
||||
**2. Placeholder scan:** No TBD/“handle errors”/“similar to”. The two “confirm the seeded keys / read the section first” notes are verification steps against real files, not deferred implementation; concrete code is given for every code step.
|
||||
|
||||
**3. Type consistency:** `seed(database_url: &str) -> anyhow::Result<()>` is defined in Task 1 Step 4 and imported/dispatched in Step 3 (`use server::{… seed}`, `Some(Command::Seed) => seed(&cli.config.database_url).await`). The test uses `db::seed::seed_spectrum_cataloguing(&mut tx)` + `vocab::vocabulary_by_key` + `fields::field_definition_by_key`, all existing signatures (mirrored from `crates/db/tests/seed.rs` and `create_user.rs`).
|
||||
|
||||
## Notes
|
||||
- No new dependencies → no `Cargo.lock` churn expected.
|
||||
- `Command::Seed` has no clap args; it reuses the flattened `Config.database_url`, exactly like `CreateUser` does.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user