Compare commits
468 Commits
ccf1661768
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f93c556a | ||
|
|
ce1b4c748e | ||
|
|
4a6fd770d7 | ||
|
|
732651959e | ||
|
|
7902f4e6ac | ||
|
|
fee519b8a9 | ||
|
|
b501592156 | ||
|
|
852fb71ee7 | ||
|
|
6f32299255 | ||
|
|
dbef0e1e60 | ||
|
|
588314f862 | ||
|
|
f9ddcf0374 | ||
|
|
5bff428954 | ||
|
|
bea0e0d056 | ||
|
|
e75448ba14 | ||
|
|
b031f2736b | ||
|
|
e25001f7c9 | ||
|
|
6a35e8510b | ||
|
|
607112afc2 | ||
|
|
4e119f098d | ||
|
|
f34d42a09f | ||
|
|
1dc3b91458 | ||
|
|
1348255ae3 | ||
|
|
590b00d2d7 | ||
|
|
1de314f49b | ||
|
|
5017d17b11 | ||
|
|
3a174dd91b | ||
|
|
afd1f0b86b | ||
|
|
f08b09faeb | ||
|
|
de30f66a2d | ||
|
|
184fc9814a | ||
|
|
6b593a7bc6 | ||
|
|
033001559d | ||
|
|
c66d83cfc6 | ||
|
|
7810ca7dd7 | ||
|
|
4245b821b9 | ||
|
|
663ffad49b | ||
|
|
b05990fffb | ||
|
|
fa8a734f96 | ||
|
|
6d81471294 | ||
|
|
956a23d0a8 | ||
|
|
f46f153f33 | ||
|
|
b32cc5be7e | ||
|
|
e93e5ec4d1 | ||
|
|
87b199a772 | ||
|
|
dc25b77a1c | ||
|
|
d50e239a2f | ||
|
|
c160ab3223 | ||
|
|
fa6677a7c5 | ||
|
|
a401e595d7 | ||
|
|
a904590843 | ||
|
|
fdc3e4ffa9 | ||
|
|
e186a3f646 | ||
|
|
210dde6562 | ||
|
|
3de4ff55ea | ||
|
|
96e04dbda9 | ||
|
|
bb0639b324 | ||
|
|
d7f8abd6c4 | ||
|
|
209f223b9f | ||
|
|
34146d7309 | ||
|
|
390ab30260 | ||
|
|
c51fc5e79f | ||
|
|
b7a5cd7b53 | ||
|
|
0eea19c0d4 | ||
|
|
262568f577 | ||
| 83ca2eb34d | |||
|
|
bde1237358 | ||
|
|
788a804810 | ||
|
|
62b96f718f | ||
|
|
6ed5151e50 | ||
|
|
3a7c86fc87 | ||
|
|
1226bd0a07 | ||
|
|
00a00b2c87 | ||
|
|
cc841a7a4c | ||
|
|
513cdb7a4d | ||
|
|
595007213c | ||
|
|
45001f042a | ||
|
|
d11378c254 | ||
|
|
f64acbc697 | ||
|
|
75e48f2922 | ||
|
|
ad344db2bf | ||
|
|
3626cd1a6d | ||
|
|
fe4e2d97d0 | ||
|
|
e712477d2b | ||
|
|
4419c434a1 | ||
|
|
687353a819 | ||
|
|
e4e277219e | ||
|
|
a75c46351f | ||
|
|
65a34d48b4 | ||
|
|
0e7095fee6 | ||
|
|
adac1b1f99 | ||
|
|
29ada9b681 | ||
|
|
92a2feba1e | ||
|
|
ba7e8ca6f5 | ||
|
|
f408f60631 | ||
| 38a6d6b0fc | |||
| b33d0eb850 | |||
|
|
4bcf568ed4 | ||
|
|
ddb1ec4df8 | ||
| d650b6c066 | |||
|
|
e63eaadc33 | ||
|
|
d4a25e34d8 | ||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 | ||
|
|
6878419156 | ||
|
|
09b77e9b36 | ||
|
|
9d202b042b | ||
|
|
8429b1e9f8 | ||
|
|
6959651b36 | ||
|
|
0ef4f4f07c | ||
|
|
f1bb9d3a69 | ||
|
|
ca52145556 | ||
|
|
9a26bf75b0 | ||
|
|
9c616f9fb8 | ||
|
|
0fe0ae5235 | ||
|
|
2c909f49a8 | ||
|
|
87fd0f39bb | ||
|
|
7f3ad8ce89 | ||
|
|
aa1f6436cc | ||
|
|
b825076733 | ||
|
|
01df815bad | ||
|
|
dcd0e725a7 | ||
|
|
39ff63921d | ||
|
|
5a09cd4cb4 | ||
|
|
4e0ebc72c8 | ||
|
|
0f0d89702d | ||
|
|
fb41affd4c | ||
|
|
dc366ed403 | ||
|
|
64b7b2315d | ||
|
|
2a7e133717 | ||
|
|
5387bc9247 | ||
|
|
847874abb3 | ||
|
|
573bca4986 | ||
|
|
86690fdbb6 | ||
|
|
6cb1025881 | ||
|
|
fc557bd9ae | ||
|
|
e94414b81a | ||
|
|
7eee688ce9 | ||
|
|
8905135006 | ||
|
|
8bd8390891 | ||
|
|
ed98729f75 | ||
|
|
db87a64cc0 | ||
|
|
d7d6d0638c | ||
|
|
a2f37f85a6 | ||
|
|
f22a1a1cfa | ||
|
|
2a0863cf3e | ||
|
|
9e97687d0f | ||
|
|
b665e1132d | ||
|
|
87af9ab446 | ||
|
|
0058b297d8 | ||
|
|
230f23e37c | ||
|
|
e604967a3f | ||
|
|
169e1ad9de | ||
|
|
f2f42ed415 | ||
|
|
5945824b54 | ||
|
|
fa41394e66 | ||
|
|
fb00c7818e | ||
|
|
8ed65f8602 | ||
|
|
9e425c98a1 | ||
|
|
ddce268113 | ||
| 4a43962c98 | |||
|
|
9a9e1c4c40 | ||
|
|
62c8ce4cb2 | ||
|
|
4c620619d4 | ||
|
|
44baff9c9c | ||
|
|
4634da9865 | ||
|
|
79e4a3f9db | ||
|
|
70e8a6e6ad | ||
|
|
3af1095d13 | ||
|
|
8c835e957a | ||
|
|
fe8fcba7a7 | ||
|
|
e0c80ac193 | ||
|
|
005265b5a8 | ||
|
|
684c6e63de | ||
|
|
e27d52b9ee | ||
|
|
6f5497c7bf | ||
|
|
e0fac783e8 | ||
|
|
202ea85a58 | ||
|
|
7679596c70 | ||
|
|
3d5dcd1f18 | ||
|
|
52fca38f0f | ||
|
|
662a8f3e80 | ||
|
|
cbba95c3f8 | ||
|
|
3536ed884c | ||
|
|
5a939d9222 | ||
|
|
93e90424ab | ||
|
|
e8f3004c4f | ||
|
|
9637ebbca2 | ||
|
|
df10a42069 | ||
|
|
64120a30b5 | ||
|
|
25252fc709 | ||
|
|
1f379a161d | ||
|
|
c0d034c85d | ||
|
|
ca93cde06e | ||
|
|
7629e35897 | ||
|
|
cd741b9f57 | ||
|
|
ddf378aaac | ||
|
|
20cfe41f21 | ||
|
|
43601a3770 | ||
|
|
6603bc5333 | ||
|
|
6753d115f9 | ||
|
|
73dd6c80fa | ||
|
|
9ade36dd3b | ||
|
|
378da60ae8 | ||
|
|
6d267f2269 | ||
|
|
ff76a3784f | ||
|
|
534665459f | ||
|
|
fd792f6d78 | ||
|
|
bafbf609eb | ||
|
|
2710f2e233 | ||
|
|
80f6468d52 | ||
|
|
a58378e8f0 | ||
|
|
d000170f52 | ||
|
|
d1ed9c022f | ||
|
|
1e5e8e43e8 | ||
|
|
8c198f22be | ||
|
|
6fd05e08d8 | ||
|
|
ab469b744c | ||
|
|
f07527158c | ||
|
|
9f75de0350 | ||
|
|
8a9fbc6aef | ||
|
|
0336d07980 | ||
|
|
61256942e1 | ||
|
|
6aaf8ddb9e | ||
|
|
1b9707c6cd | ||
|
|
8353e71eed | ||
|
|
0693cfddd1 | ||
|
|
f656f7c1ff | ||
|
|
7316c51d4a | ||
|
|
cf457cb96f | ||
|
|
83e0afb466 | ||
|
|
12db7b3596 | ||
|
|
26b45f1c78 | ||
|
|
e6ce00035e | ||
|
|
b1f77bcfb6 | ||
|
|
4d1a5862d0 | ||
|
|
4e8a430dc3 | ||
|
|
e1d404609e | ||
|
|
b36addde22 | ||
|
|
456e019c3d | ||
|
|
d3bb08e7ff | ||
|
|
6703347468 | ||
|
|
1d55901388 | ||
|
|
0cd4882ef4 | ||
|
|
a85b22efcf | ||
|
|
7627589844 | ||
|
|
96a1afe09a | ||
|
|
c1b125bdb2 | ||
|
|
e4a9999f2f | ||
|
|
e48c794c12 | ||
|
|
add619d81d | ||
|
|
a46c3b416b | ||
|
|
7e8b90c8ee | ||
|
|
fc5c837d2c | ||
|
|
4f874bf4e9 | ||
|
|
28997fc391 | ||
|
|
003bc9b8cb | ||
|
|
485e13cfea | ||
|
|
439a386a37 | ||
|
|
23006a6562 | ||
|
|
c35f51d209 | ||
|
|
5297c70453 | ||
|
|
ad820955fd | ||
|
|
27b6d58632 | ||
|
|
4db2e97490 | ||
|
|
25b23843c9 | ||
|
|
ad067d2e0e | ||
|
|
29015ee864 | ||
|
|
b1b8505b93 | ||
|
|
abe860bec7 | ||
|
|
ec9d46da7a | ||
|
|
e562b3bbea | ||
|
|
e725910402 | ||
|
|
782a34e34b | ||
|
|
30f450b0d1 | ||
|
|
d4c0287e92 | ||
|
|
301cfc5c9e | ||
|
|
724c3881e4 | ||
|
|
fab2930ca8 | ||
|
|
d83707ec3b | ||
|
|
caea0d5633 | ||
|
|
2bf14aeab9 | ||
|
|
5b565d5271 | ||
|
|
df0f4879b8 | ||
|
|
98d081397e | ||
|
|
4e68b81bf7 | ||
|
|
985b31f71f | ||
|
|
3fb312b1c6 | ||
|
|
e2ec45f819 | ||
|
|
7d9526440a | ||
|
|
13bbfa7abd | ||
|
|
975223c972 | ||
|
|
403a043d51 | ||
|
|
e259908d6a | ||
|
|
7d37e610da | ||
|
|
9c1eb7608b | ||
|
|
9bba5e4a7a | ||
|
|
751a48b22c | ||
|
|
58a30a6e2e | ||
|
|
2430092e43 | ||
|
|
4a93543645 | ||
|
|
b453c13bae | ||
|
|
599c3977fb | ||
|
|
03e2615fa7 | ||
|
|
3db6a3bf8f | ||
|
|
0e06626eef | ||
|
|
a47564934d | ||
|
|
02fb16a0bd | ||
|
|
4757a174c9 | ||
|
|
75293c6aa8 | ||
|
|
4e9b13c0e4 | ||
|
|
ad27c1f757 | ||
|
|
0e30e5c570 | ||
|
|
a6a8552a48 | ||
|
|
b0d28c1e0b | ||
|
|
420c0e3e10 | ||
|
|
cb61e63b02 | ||
|
|
8eb321ccea | ||
|
|
e16b7402bd | ||
|
|
229c1b0539 | ||
|
|
f24c415b04 | ||
|
|
4c57a2262f | ||
|
|
b8e01f997d | ||
|
|
e8e57d2712 | ||
|
|
817835fd6a | ||
|
|
c361b3cd45 | ||
|
|
5c8034d298 | ||
|
|
8b1b070254 | ||
|
|
4ca1c967d2 | ||
|
|
24d9d975d1 | ||
|
|
8a1cc2d1f0 | ||
|
|
d5bf401085 | ||
|
|
4944918692 | ||
|
|
bf90427bfa | ||
|
|
50f554680c | ||
|
|
1dd162f1be | ||
|
|
ff7cfd4b1a | ||
|
|
88600d54cd | ||
|
|
654ac1478c | ||
|
|
3a4c2c6225 | ||
|
|
73f614bc3a | ||
|
|
6c5e5273bb | ||
|
|
a574d96351 | ||
|
|
246568301a | ||
|
|
aab4fe37ae | ||
|
|
4ebebe1e07 | ||
|
|
81224829a2 | ||
|
|
7cc2ddc6ad | ||
|
|
da3067150d | ||
|
|
10249c33be | ||
|
|
9c12f62345 | ||
|
|
e5784caa9d | ||
|
|
4583ee2c4d | ||
|
|
0a7b4fa265 | ||
|
|
a3858b6c80 | ||
|
|
9f5d7b8570 | ||
|
|
f6da95014e | ||
|
|
7a655ce6f4 | ||
|
|
3b594c0b0b | ||
|
|
2e44cab614 | ||
|
|
4c2f036de0 | ||
|
|
dcb57ffacd | ||
|
|
1c961619f1 | ||
|
|
2cc43c3c44 | ||
|
|
6c4d10d12f | ||
|
|
2cdb48f4a4 | ||
|
|
6be7413ba4 | ||
|
|
33aeefbb5b | ||
|
|
4bbdd33344 | ||
|
|
f4f853be8b | ||
|
|
44b5934fa7 | ||
|
|
78cc537f0e | ||
|
|
fc69758a92 | ||
|
|
f55efda0d2 | ||
|
|
77eddfc599 | ||
|
|
a76999c3d4 | ||
|
|
6d4aa8bd5c | ||
|
|
1fc74f8892 | ||
|
|
29ea27319a | ||
|
|
16f1fe7616 | ||
|
|
5ea47d4ec7 | ||
|
|
2f1538754e | ||
|
|
138bf446e4 | ||
|
|
944370dcfd | ||
|
|
5edefdd082 | ||
|
|
97274beba0 | ||
|
|
c3652f5b57 | ||
|
|
397fc3c7e4 | ||
|
|
5d8d85057d | ||
|
|
58254b492b | ||
|
|
8cc6031ef0 | ||
|
|
ecae789be2 | ||
|
|
95d35c20b2 | ||
|
|
11dc25ef31 | ||
|
|
b1309db8db | ||
|
|
01b902e885 | ||
|
|
20db3d0d8f | ||
|
|
0306023610 | ||
|
|
8f836dfefb | ||
|
|
b170085311 | ||
|
|
d5a7974f3a | ||
|
|
53660eadc9 | ||
|
|
f4b631e1bc | ||
|
|
c1dd6d299f | ||
|
|
a458d3508b | ||
|
|
bb2a89da58 | ||
|
|
578bebbd8b | ||
|
|
7e859252a3 | ||
|
|
ba053b3c23 | ||
|
|
80f5e0b147 | ||
|
|
11b70d814f | ||
|
|
1dffb430ac | ||
|
|
1e5a45a027 | ||
|
|
ccc37fe1bb | ||
|
|
289c3bbfb5 | ||
|
|
8d29bb10e2 | ||
|
|
396c87f8ab | ||
|
|
7a6c2e877f | ||
|
|
ffc14dd2ff | ||
|
|
3827a9d059 | ||
|
|
c8931071ba | ||
|
|
da1984b916 | ||
|
|
0422af8980 | ||
|
|
197b668f20 | ||
|
|
5d752fcc0f | ||
|
|
0170f79690 | ||
|
|
369a0213e5 | ||
|
|
a7d0e96613 | ||
|
|
5458ca9bae | ||
|
|
23d93d492d | ||
|
|
2097dddf3a | ||
|
|
585f28cd23 | ||
|
|
2c18cb8b0d | ||
|
|
655f0c3531 | ||
|
|
e7931335ce | ||
|
|
89bb0b5d65 | ||
|
|
b8ad64dd13 | ||
|
|
9bdd9fb3a5 | ||
|
|
52e48a6b8c | ||
|
|
fd624f6ec8 | ||
|
|
6d8655bad1 | ||
|
|
5167a2ae18 | ||
|
|
4f07527b0f | ||
|
|
0c5f56e9d1 | ||
|
|
652100a9c2 | ||
|
|
557f37be54 | ||
|
|
2a462d0a7c | ||
|
|
36bd7e0414 | ||
|
|
6970cc95fb | ||
|
|
a5e3205520 | ||
|
|
f124529ee8 | ||
|
|
61ca5a6e40 | ||
|
|
516a0a3814 | ||
|
|
39276b179d | ||
|
|
577dd3fcb1 | ||
|
|
c0b500b692 | ||
|
|
cb8c85a742 | ||
|
|
c93d3b03ed | ||
|
|
8f163f9b77 | ||
|
|
40511535eb | ||
|
|
a68a822c13 | ||
|
|
df0037cba2 | ||
|
|
dcb5585c64 | ||
|
|
1e77d6d98c | ||
|
|
f22508ca91 | ||
|
|
1cb05697cc |
@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
|
|||||||
```
|
```
|
||||||
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
|
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
|
||||||
|
|
||||||
3. **Upgrading VPS tier before profiling**
|
3. **Upgrading hardware before profiling**
|
||||||
```
|
```
|
||||||
# "The app feels slow" → upgrade from CX32 to CX42
|
# "The app feels slow" → order more RAM / a faster CPU
|
||||||
# Actual cause: unindexed query scanning 100k rows
|
# Actual cause: unindexed query scanning 100k rows
|
||||||
```
|
```
|
||||||
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
|
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
|
||||||
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
|
|||||||
Prometheus + Loki + Alertmanager
|
Prometheus + Loki + Alertmanager
|
||||||
```
|
```
|
||||||
|
|
||||||
### Monthly Cost: ~23 EUR
|
### Monthly Cost: ~6 EUR (excl. server)
|
||||||
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||||
|
|
||||||
### Reference Documentation
|
### Reference Documentation
|
||||||
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`
|
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`
|
||||||
|
|||||||
99
.claude/skills/draft-spec/SKILL.md
Normal file
99
.claude/skills/draft-spec/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
name: draft-spec
|
||||||
|
description: Requirements-engineer-led authoring of a new feature spec. Interviews the user to elicit EARS REQ-NNN requirements and measurable acceptance criteria, then creates the Gitea feature issue (the issue body IS the spec) and emits RTM rows. Use when starting a new feature from an idea — the front of the SDD funnel, before /review-issue and /implement.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Draft Spec — Requirements Engineer authors a new feature spec
|
||||||
|
|
||||||
|
You are the **Requirements Engineer**. Read your full persona from
|
||||||
|
[`.claude/personas/req_engineer.md`](../../personas/req_engineer.md) and adopt its voice and
|
||||||
|
priorities. Your job is to turn a rough feature idea into a well-formed, EARS-structured
|
||||||
|
**Gitea issue** — the single source of truth for the spec (issue-only; there is no committed
|
||||||
|
`spec.md`). You *author* the spec; you do **not** approve it — that's `/review-issue`'s job.
|
||||||
|
|
||||||
|
## Argument
|
||||||
|
|
||||||
|
A free-text feature idea, e.g. `users should be able to upload a profile picture`. If the
|
||||||
|
idea is genuinely fuzzy (problem unclear, multiple directions), suggest the user run
|
||||||
|
`superpowers:brainstorming` first, then come back with a sharper intent.
|
||||||
|
|
||||||
|
## Phase 0 — Load the SDD ground truth
|
||||||
|
|
||||||
|
Read before interviewing:
|
||||||
|
- [`.specify/constitution.md`](../../../.specify/constitution.md) and [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — the rules the spec must respect
|
||||||
|
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the section structure and the five EARS patterns
|
||||||
|
- [`.specify/personas/requirements-engineer.md`](../../../.specify/personas/requirements-engineer.md) — **your own checklist; apply it as you write, not after**
|
||||||
|
- [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
|
||||||
|
- [`docs/GLOSSARY.md`](../../../docs/GLOSSARY.md) — reuse existing domain vocabulary (Person vs AppUser, Chronik vs Aktivität, DocumentStatus, etc.)
|
||||||
|
|
||||||
|
Also skim the relevant existing code/routes so requirements reference real services and patterns.
|
||||||
|
|
||||||
|
## Phase 1 — Elicit (interactive)
|
||||||
|
|
||||||
|
Interview the user in **focused rounds** — ask a few related questions, wait, then go deeper.
|
||||||
|
Do not dump one giant questionnaire. Cover, in roughly this order:
|
||||||
|
|
||||||
|
1. **Why & who** — the business motivation and the role(s) involved. Drives the issue title
|
||||||
|
`As a <role> I want <capability> so <reason>`.
|
||||||
|
2. **User journey** — the plain-prose happy path, from the user's perspective. This bounds scope.
|
||||||
|
3. **Happy-path behaviors** — what the system does on success. Each becomes a Ubiquitous,
|
||||||
|
Event-driven, or State-driven requirement.
|
||||||
|
4. **The unwanted paths — probe hard, this is where specs fail.** For every mutating action
|
||||||
|
ask: what if the caller is unauthenticated? unauthorized? what input is invalid, and what's
|
||||||
|
the limit (size, count, length)? what's the exact response (`ErrorCode` + HTTP status)?
|
||||||
|
Each answer is an Unwanted-behavior (`If …`) requirement. (Checklist item #7 is your prompt bank.)
|
||||||
|
5. **Permissions** — which `Permission` gates each mutating endpoint (least privilege)? Each
|
||||||
|
gate is an Optional-feature (`Where …`) requirement.
|
||||||
|
6. **Data model** — new tables/columns/constraints? the next free Flyway `V<n>` (you'll verify on disk)?
|
||||||
|
7. **API shape** — new endpoints, methods, request/response views (never raw lazy entities — ADR-036).
|
||||||
|
8. **Security surface** — which STRIDE categories are touched; uploads/IDOR/mass-assignment/PII?
|
||||||
|
9. **Out of scope** — name the nearest tempting scope creep and exclude it.
|
||||||
|
10. **Open questions** — anything you cannot decide; these block until resolved.
|
||||||
|
|
||||||
|
Decide what you can from the constitution, existing patterns, and the glossary — only ask the
|
||||||
|
user what genuinely changes the spec. Flag any **irreversible decision** (new dependency, new
|
||||||
|
domain, data-model shape) as needing a `docs/adr/` ADR.
|
||||||
|
|
||||||
|
## Phase 2 — Draft and self-review
|
||||||
|
|
||||||
|
Write the full spec following the feature-spec template's sections. Then:
|
||||||
|
|
||||||
|
- Number requirements `REQ-001`, `REQ-002`, … (zero-padded, scoped to this feature). Each uses
|
||||||
|
exactly one EARS pattern. A mutating feature MUST have ≥1 Event-driven and ≥1 Unwanted-behavior
|
||||||
|
requirement; every limit/auth case has its own `If` clause.
|
||||||
|
- Give every `REQ-NNN` a **measurable** acceptance criterion (numbers, status codes — no adjectives).
|
||||||
|
- Run your `requirements-engineer.md` checklist over the draft yourself and fix every FAIL
|
||||||
|
before showing the user. (You're allowed to block your own draft.)
|
||||||
|
- Present the full draft to the user. Refine until they confirm. **Do not create the issue
|
||||||
|
until the user approves the draft text.**
|
||||||
|
|
||||||
|
## Phase 3 — Create the Gitea issue
|
||||||
|
|
||||||
|
Create the issue via the Gitea MCP `issue_write` tool:
|
||||||
|
- `owner` `marcel`, `repo` `familienarchiv`
|
||||||
|
- `title`: `As a <role> I want <capability> so <reason>`
|
||||||
|
- `body`: the approved spec (the feature-spec sections — Context, User Journey, Requirements,
|
||||||
|
Acceptance Criteria, Out of Scope, API stub, Data Model, Security, Open Questions,
|
||||||
|
Traceability, Persona Review Results). Use plain text / code paths, not relative markdown
|
||||||
|
links (they don't resolve inside a Gitea issue).
|
||||||
|
- **Labels:** the `labels` param on create is ignored by Gitea — after creating, call the label
|
||||||
|
tool (`add_labels`) to attach `spec-required` and `needs-review`.
|
||||||
|
|
||||||
|
## Phase 4 — Emit RTM rows + flag ADRs
|
||||||
|
|
||||||
|
- Emit ready-to-paste [`.specify/rtm.md`](../../../.specify/rtm.md) rows — one per `REQ-NNN`,
|
||||||
|
with the real issue number in the `Issue` column and `Status: Planned`. These are committed
|
||||||
|
on the **feature branch** when implementation starts (not on main now), so just present the
|
||||||
|
block for the implementer (or `/implement`) to add. If you're already on the feature's
|
||||||
|
worktree/branch, append them to `rtm.md` directly.
|
||||||
|
- List any decision that needs a `docs/adr/` ADR (next free number, verify on disk) before
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Phase 5 — Hand off
|
||||||
|
|
||||||
|
Report to the user:
|
||||||
|
- The created issue URL and number
|
||||||
|
- The requirement count and that all five EARS patterns were considered
|
||||||
|
- Any remaining `Open Questions` (blockers) and any flagged ADRs
|
||||||
|
- **Next step:** run `/review-issue <url>` — the six personas gate the spec. You authored it;
|
||||||
|
you don't self-approve. After it passes and Open Questions are empty, run `/implement <url>`.
|
||||||
@@ -3,10 +3,17 @@ name: implement
|
|||||||
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
|
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Implement — Felix Brandt's Issue/PR-Driven TDD Workflow
|
# Implement — Felix Brandt's Spec-Driven TDD Workflow
|
||||||
|
|
||||||
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
|
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
|
||||||
|
|
||||||
|
Then load the SDD ground truth you must obey throughout:
|
||||||
|
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, executable constraints, workflow rules, do-not-touch list
|
||||||
|
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules AGENTS.md references
|
||||||
|
|
||||||
|
The feature's `spec.md` (its `REQ-NNN` requirements) is the contract. Implement exactly what
|
||||||
|
the requirements say — no more, no less.
|
||||||
|
|
||||||
## Argument
|
## Argument
|
||||||
|
|
||||||
The user provides a Gitea issue **or** pull request URL, e.g.:
|
The user provides a Gitea issue **or** pull request URL, e.g.:
|
||||||
@@ -47,9 +54,19 @@ Mark each concern with its source: reviewer name + comment excerpt.
|
|||||||
|
|
||||||
Also read:
|
Also read:
|
||||||
- `CLAUDE.md` for project conventions
|
- `CLAUDE.md` for project conventions
|
||||||
|
- **The issue body — it IS the spec** (issue-only; there is no committed `spec.md`). Extract its
|
||||||
|
`REQ-NNN` requirements, acceptance criteria, API stub, data-model delta, and any inline
|
||||||
|
STRIDE/threat notes. These are your contract.
|
||||||
|
- [`.specify/rtm.md`](../../../.specify/rtm.md) — note each `REQ-NNN`'s current Status (rows are
|
||||||
|
keyed by this issue number)
|
||||||
- Any relevant existing source files mentioned in the issue/comments
|
- Any relevant existing source files mentioned in the issue/comments
|
||||||
- The current branch state (`git status`, `git log --oneline -10`)
|
- The current branch state (`git status`, `git log --oneline -10`)
|
||||||
|
|
||||||
|
> **If the issue is NOT a well-formed SDD spec** (free-prose, no `REQ-NNN`, missing sections),
|
||||||
|
> stop before Phase 2 and tell the user: it should go through `/review-issue` (the SDD
|
||||||
|
> spec-review gate) first. Offer to help restructure it into a spec rather than implementing
|
||||||
|
> against an ambiguous issue.
|
||||||
|
|
||||||
Do not start Phase 2 until you have read everything.
|
Do not start Phase 2 until you have read everything.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -58,10 +75,12 @@ Do not start Phase 2 until you have read everything.
|
|||||||
|
|
||||||
### Issue mode
|
### Issue mode
|
||||||
|
|
||||||
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
|
First, check the spec's `## Open Questions` — **any unresolved item there is a blocker** and
|
||||||
- Scope questions (is X in or out of this issue?)
|
must be answered before implementation (SDD step 5). Then identify any further point that is
|
||||||
- Design decisions with multiple valid approaches where the choice affects architecture
|
genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
|
||||||
- Missing acceptance criteria (how do we know when this is done?)
|
- Scope questions (is X in or out? — check `## Out of Scope` first)
|
||||||
|
- A `REQ-NNN` that is not testable as written, or has no measurable acceptance criterion
|
||||||
|
- Design decisions with multiple valid approaches where the choice affects architecture (if it's an irreversible choice, it may need an ADR — flag it)
|
||||||
- Conflicting statements between the issue body and the comments
|
- Conflicting statements between the issue body and the comments
|
||||||
- Dependencies on external things (backend changes needed? migration required?)
|
- Dependencies on external things (backend changes needed? migration required?)
|
||||||
|
|
||||||
@@ -81,12 +100,15 @@ Wait for the user to answer before continuing.
|
|||||||
|
|
||||||
## Phase 3 — Implementation Plan
|
## Phase 3 — Implementation Plan
|
||||||
|
|
||||||
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be:
|
Once clarifications are resolved, present a numbered implementation plan as a task list,
|
||||||
|
**derived from the issue's `REQ-NNN` requirements** (one or more tasks per requirement, in
|
||||||
|
red/green order). Each item must be:
|
||||||
|
|
||||||
- A single atomic unit of work (one behavior, one file change, one migration)
|
- A single atomic unit of work (one behavior, one file change, one migration)
|
||||||
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
|
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
|
||||||
- Ordered so each item builds on the previous ones
|
- Ordered so each item builds on the previous ones (red/green order — a failing test precedes its implementation)
|
||||||
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
|
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
|
||||||
|
- **In issue/SDD mode, tagged with the `REQ-NNN` it satisfies** so every requirement is covered and nothing extra is built. Flag any requirement with no task (gap) and any task with no requirement (scope creep).
|
||||||
|
|
||||||
**In PR mode**, each task must reference the reviewer concern it addresses, e.g.:
|
**In PR mode**, each task must reference the reviewer concern it addresses, e.g.:
|
||||||
```
|
```
|
||||||
@@ -97,10 +119,10 @@ Format:
|
|||||||
```
|
```
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|
||||||
1. [backend] PersonController returns 404 when person id does not exist
|
1. [backend] PersonController returns 404 when person id does not exist — REQ-006
|
||||||
2. [migration] Add index on documents.sender_id for performance
|
2. [migration] V<n> add index on documents.sender_id (verify next free number on disk) — REQ-002
|
||||||
3. [frontend] PersonCard renders full name from firstName + lastName props
|
3. [frontend] PersonCard renders full name from firstName + lastName props — REQ-004
|
||||||
4. [frontend] PersonCard shows placeholder when both names are null
|
4. [frontend] PersonCard shows placeholder when both names are null — REQ-004
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -145,12 +167,22 @@ Check the current branch.
|
|||||||
2. Apply any needed clean-up — no new behavior
|
2. Apply any needed clean-up — no new behavior
|
||||||
3. Run the full suite again to confirm still green
|
3. Run the full suite again to confirm still green
|
||||||
|
|
||||||
|
**Sync (SDD):**
|
||||||
|
1. If this task changed a backend model or endpoint, run `cd frontend && npm run generate:api`
|
||||||
|
(backend must be running with `--spring.profiles.active=dev`) and stage the regenerated types.
|
||||||
|
2. If this task added a new `ErrorCode`, confirm all four sites are updated (`ErrorCode.java`,
|
||||||
|
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`).
|
||||||
|
3. Flip the task's `REQ-NNN` Status in [`.specify/rtm.md`](../../../.specify/rtm.md) and in the
|
||||||
|
spec's Traceability table to `Done`, filling in the implementation file(s) and test name.
|
||||||
|
|
||||||
**Commit:**
|
**Commit:**
|
||||||
Commit atomically after each task using the project's commit conventions:
|
Commit atomically after each task using the project's commit conventions, referencing the
|
||||||
|
issue (`Refs #n` / `Closes #n`) on the last line:
|
||||||
```
|
```
|
||||||
feat(scope): short imperative description
|
feat(scope): short imperative description
|
||||||
|
|
||||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Refs #<n>
|
||||||
|
Co-Authored-By: <model> <noreply@anthropic.com>
|
||||||
```
|
```
|
||||||
|
|
||||||
Move to the next task immediately.
|
Move to the next task immediately.
|
||||||
@@ -164,8 +196,10 @@ Move to the next task immediately.
|
|||||||
|
|
||||||
### Rules during autonomous implementation
|
### Rules during autonomous implementation
|
||||||
|
|
||||||
|
- Obey the constitution and AGENTS.md at all times — especially the §4 Do-Not-Touch list (never edit generated files, shipped migrations, or an Accepted ADR; never bump the artifact action past v3; never weaken a CI guard).
|
||||||
- Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code
|
- Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code
|
||||||
- Never add behavior beyond what the current task requires
|
- Never add behavior beyond what the current task requires — and never add behavior with no backing `REQ-NNN`. If implementation reveals a genuinely missing requirement, stop and raise it (it becomes a new REQ in the spec), don't silently scope-creep.
|
||||||
|
- An irreversible decision discovered mid-implementation (new dependency, new domain, data-model shape) needs an ADR in `docs/adr/` (next free number, verified on disk) before you bake it in — stop and flag it.
|
||||||
- Never bundle two tasks into one commit
|
- Never bundle two tasks into one commit
|
||||||
- If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests
|
- If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests
|
||||||
- If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently
|
- If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently
|
||||||
@@ -178,10 +212,16 @@ After all tasks are done:
|
|||||||
|
|
||||||
1. Run the full test suite one final time and confirm all green
|
1. Run the full test suite one final time and confirm all green
|
||||||
2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors
|
2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors
|
||||||
|
3. **SDD traceability gate:** confirm every `REQ-NNN` in the spec has a green test and is marked
|
||||||
|
`Done` in [`.specify/rtm.md`](../../../.specify/rtm.md). Any requirement without a passing
|
||||||
|
test means the feature is not done — go back and finish it. Confirm `generate:api` was run
|
||||||
|
if any backend model/endpoint changed.
|
||||||
|
|
||||||
### Issue mode
|
### Issue mode
|
||||||
3. Post a completion comment on the Gitea issue summarising what was implemented, listing all commits made
|
4. Post a completion comment on the Gitea issue summarising what was implemented, mapping each
|
||||||
4. Report back to the user: every task ✅, any skipped/deferred tasks (with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
|
`REQ-NNN` to its commit and test, and listing all commits made
|
||||||
|
5. Report back to the user: every task ✅, the REQ→test coverage, any skipped/deferred tasks
|
||||||
|
(with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
|
||||||
|
|
||||||
### PR mode
|
### PR mode
|
||||||
3. Push the updated branch
|
3. Push the updated branch
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: review-issue
|
name: review-issue
|
||||||
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment.
|
description: Multi-persona SDD spec review of a Gitea feature issue. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, walks it PASS/FAIL/QUESTION against the EARS requirements, and posts findings as a separate Gitea comment before implementation starts.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Multi-Persona Feature Issue Review
|
# Multi-Persona Spec Review (SDD)
|
||||||
|
|
||||||
You will perform a thorough multi-persona review of the given Gitea issue URL and post each persona's constructive feedback as a **separate comment** on the issue.
|
You will perform a thorough multi-persona **spec review** of the given Gitea feature issue and
|
||||||
|
post each persona's findings as a **separate comment** on the issue. This is the SDD
|
||||||
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts.
|
spec-review gate (step 4 of [SPEC_DRIVEN_DEVELOPMENT.md](../../../SPEC_DRIVEN_DEVELOPMENT.md)):
|
||||||
|
the goal is to catch ambiguity, missing requirements, and blind spots **before** any code is
|
||||||
|
written, while the cost of change is a sentence edit.
|
||||||
|
|
||||||
## Argument
|
## Argument
|
||||||
|
|
||||||
@@ -19,57 +21,83 @@ Parse it to extract:
|
|||||||
- `repo` — e.g. `familienarchiv`
|
- `repo` — e.g. `familienarchiv`
|
||||||
- `issue_number` — e.g. `161`
|
- `issue_number` — e.g. `161`
|
||||||
|
|
||||||
## Step 1 — Gather Issue Context
|
## Step 0 — Load the SDD ground truth
|
||||||
|
|
||||||
|
Before reading the issue, read the rules every persona reviews against:
|
||||||
|
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules
|
||||||
|
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, constraints, workflow
|
||||||
|
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the expected spec shape and the five EARS patterns
|
||||||
|
- The worked example [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
|
||||||
|
|
||||||
|
## Step 1 — Gather issue context
|
||||||
|
|
||||||
Use the Gitea MCP tools to collect:
|
Use the Gitea MCP tools to collect:
|
||||||
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
|
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
|
||||||
2. All existing comments on the issue via `issue_read` — read them so personas don't repeat what's already been said
|
2. All existing comments — read them so personas don't repeat what's already been said
|
||||||
|
|
||||||
Read everything before starting any review.
|
Read everything before starting any review.
|
||||||
|
|
||||||
## Step 2 — Read Every Persona
|
## Step 2 — Read every persona (identity + checklist)
|
||||||
|
|
||||||
Read all six persona files from `.claude/personas/`:
|
Each persona is its **character identity** (`.claude/personas/`) **plus** its **SDD spec-review
|
||||||
- `developer.md` → Felix Brandt
|
checklist** (`.specify/personas/`). Adopt the voice from the former; gate the spec with the latter.
|
||||||
- `architect.md` → architect persona
|
|
||||||
- `tester.md` → tester persona
|
|
||||||
- `security_expert.md` → security persona
|
|
||||||
- `ui_expert.md` → UI/UX persona
|
|
||||||
- `devops.md` → DevOps persona
|
|
||||||
|
|
||||||
## Step 3 — Write Each Review
|
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) |
|
||||||
|
|---|---|---|
|
||||||
|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` |
|
||||||
|
| Developer (Felix Brandt) | `developer.md` | `developer.md` |
|
||||||
|
| Security (Nora "NullX" Steiner) | `security_expert.md` | `security.md` |
|
||||||
|
| DevOps | `devops.md` | `devops.md` |
|
||||||
|
| UI/UX | `ui_expert.md` | `ui-ux.md` |
|
||||||
|
| Architect | `architect.md` | `architect.md` |
|
||||||
|
|
||||||
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that:
|
The tester lens (acceptance-criteria quality, edge cases) is carried by the Requirements
|
||||||
|
Engineer checklist (testable, measurable criteria) — no separate tester comment at spec time.
|
||||||
|
|
||||||
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps
|
## Step 3 — Run each checklist against the spec
|
||||||
- Asks clarifying questions the persona would genuinely want answered before or during implementation
|
|
||||||
- Points out risks, edge cases, or gaps the persona sees from their domain
|
|
||||||
- Offers concrete suggestions or alternative approaches where relevant
|
|
||||||
- References the issue text specifically — don't write generic advice
|
|
||||||
- Stays focused on what the persona would actually care about (e.g. Felix asks about test strategy and naming; the architect asks about layer boundaries and coupling; the security expert asks about auth, input validation, and data exposure; the tester asks about acceptance criteria and edge cases; the UI expert asks about interaction patterns and accessibility; DevOps asks about deployment, config, and observability)
|
|
||||||
|
|
||||||
Format each comment in Markdown with a persona header, e.g.:
|
For each persona, walk **every item** in its `.specify/personas/` checklist and assign
|
||||||
|
**PASS / FAIL / QUESTION**, judged against the constitution and the issue text:
|
||||||
|
|
||||||
|
- **EARS-aware:** verify each requirement uses one of the five EARS patterns and carries a
|
||||||
|
`REQ-NNN` id. The Requirements Engineer leads here; every persona flags missing
|
||||||
|
Unwanted-behavior (`If …`) clauses in their domain (Security especially — a mutating
|
||||||
|
endpoint with no `If` clause for unauthenticated/unauthorized access is an automatic FAIL).
|
||||||
|
- **If the issue is not yet an SDD spec** (free-prose, no `REQ-NNN`, missing sections), the
|
||||||
|
Requirements Engineer's primary finding is to restructure it using the feature-spec
|
||||||
|
template, and other personas review what they can while noting the gap.
|
||||||
|
- Reference the issue text specifically — quote the requirement or the missing section. No
|
||||||
|
generic advice.
|
||||||
|
|
||||||
|
## Step 4 — Write and post each comment
|
||||||
|
|
||||||
|
Each persona posts a **separate** comment via the Gitea MCP `issue_write` tool, in the format
|
||||||
|
its checklist's "Output format" section defines — a header, the checklist table, and a verdict:
|
||||||
|
|
||||||
```
|
```
|
||||||
## 👨💻 Felix Brandt — Senior Fullstack Developer
|
### 🔐 Security — Spec Review
|
||||||
|
|
||||||
### Questions & Observations
|
| # | Item | Status | Note |
|
||||||
...
|
|---|------|--------|------|
|
||||||
|
| 1 | All mutating endpoints have authn + authz `If` clauses | FAIL | REQ-004 POST has no 401 clause (CWE-...) |
|
||||||
|
| 2 | ... | PASS | |
|
||||||
|
|
||||||
### Suggestions
|
**Verdict: CHANGES REQUESTED** — blocking FAIL: #1. Resolve before implementation.
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep each comment focused and scannable. Use bullet points. Avoid walls of text.
|
Post all six comments. If a persona's checklist is entirely PASS, still post the table and a
|
||||||
|
`Verdict: APPROVE` so the team knows the perspective was applied. Keep comments scannable.
|
||||||
|
|
||||||
## Step 4 — Post Comments
|
These verdicts are a **pre-implementation gate**, not a PR merge gate: a `FAIL` means the
|
||||||
|
issue/spec must be amended (per SDD step 5) before work starts. Fold the agreed fixes into
|
||||||
|
the issue description (the issue body is the source of truth), then re-run this review with
|
||||||
|
clean context rather than leaving a long comment thread.
|
||||||
|
|
||||||
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool.
|
## Step 5 — Report back
|
||||||
|
|
||||||
Post all six comments. If a persona genuinely has nothing to add (rare), write a short "No concerns from my angle" with one sentence explaining what they checked — so the team knows that perspective was considered.
|
|
||||||
|
|
||||||
## Step 5 — Report Back
|
|
||||||
|
|
||||||
After all comments are posted, tell the user:
|
After all comments are posted, tell the user:
|
||||||
- Which personas posted feedback
|
- Each persona's verdict (APPROVE / CHANGES REQUESTED)
|
||||||
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged)
|
- The consolidated list of blocking FAILs (these must be resolved before implementation)
|
||||||
|
- Cross-cutting themes multiple personas flagged
|
||||||
|
- Whether the issue is a well-formed SDD spec yet, or needs restructuring first
|
||||||
|
- A reminder to mirror the agreed `REQ-NNN` rows into [`.specify/rtm.md`](../../../.specify/rtm.md)
|
||||||
|
|||||||
@@ -1,74 +1,95 @@
|
|||||||
---
|
---
|
||||||
name: review-pr
|
name: review-pr
|
||||||
description: Multi-persona PR review. Each persona from .claude/personas/ reviews the PR and posts their findings as a separate Gitea comment.
|
description: Multi-persona SDD code review of a Gitea PR. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, verifies the diff against the constitution and the feature spec's REQ-NNN (every requirement implemented and tested), and posts findings as a separate Gitea comment.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Multi-Persona PR Review
|
# Multi-Persona PR Review (SDD)
|
||||||
|
|
||||||
You will perform a thorough multi-persona code review of the given PR URL and post each persona's findings as a **separate comment** on the PR.
|
You will perform a thorough multi-persona code review of the given PR and post each persona's
|
||||||
|
findings as a **separate comment**. Under SDD, the review verifies the diff against two
|
||||||
|
contracts: the project [constitution](../../../.specify/constitution.md) and the feature's
|
||||||
|
spec (the linked **Gitea issue body** — every `REQ-NNN` must be implemented **and** covered by a test).
|
||||||
|
|
||||||
## Argument
|
## Argument
|
||||||
|
|
||||||
The user provides a Gitea PR URL, e.g.:
|
The user provides a Gitea PR URL, e.g.:
|
||||||
`http://heim-nas:3005/marcel/familienarchiv/pulls/160`
|
`http://heim-nas:3005/marcel/familienarchiv/pulls/160`
|
||||||
|
|
||||||
Parse it to extract:
|
Parse it to extract `owner`, `repo`, and `pull_number`.
|
||||||
- `owner` — e.g. `marcel`
|
|
||||||
- `repo` — e.g. `familienarchiv`
|
|
||||||
- `pull_number` — e.g. `160`
|
|
||||||
|
|
||||||
## Step 1 — Gather PR Context
|
## Step 0 — Load the SDD ground truth
|
||||||
|
|
||||||
|
Read before reviewing:
|
||||||
|
- [`.specify/constitution.md`](../../../.specify/constitution.md) — rules the code must obey (esp. §4 Do-Not-Touch)
|
||||||
|
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — constraints
|
||||||
|
- The feature's spec — the **Gitea issue** the PR closes (`Closes #n`). Read its body for the
|
||||||
|
`REQ-NNN` requirements, acceptance criteria, inline API stub, and any STRIDE/threat notes.
|
||||||
|
- [`.specify/rtm.md`](../../../.specify/rtm.md) — the requirement→test→status matrix
|
||||||
|
|
||||||
|
## Step 1 — Gather PR context
|
||||||
|
|
||||||
Use the Gitea MCP tools to collect:
|
Use the Gitea MCP tools to collect:
|
||||||
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
|
1. PR metadata (title, description, base/head branch) via `pull_request_read`
|
||||||
2. The list of changed files via `get_dir_contents` or the PR files endpoint
|
2. The list of changed files
|
||||||
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents`
|
3. The full content of every changed file at the head commit via `get_file_contents`
|
||||||
|
|
||||||
Read ALL changed files completely before starting any review. Do not skip files.
|
Read ALL changed files completely before starting. Do not skip files.
|
||||||
|
|
||||||
## Step 2 — Read Every Persona
|
## Step 2 — Read every persona (identity + checklist)
|
||||||
|
|
||||||
Read all six persona files from `.claude/personas/`:
|
Adopt each persona's voice from `.claude/personas/`; apply its review lens. For the SDD
|
||||||
- `developer.md` → Felix Brandt
|
personas, also re-read the matching `.specify/personas/` checklist — at PR time the same
|
||||||
- `architect.md` → architect persona
|
checklist items are verified against the **code** rather than the spec.
|
||||||
- `tester.md` → tester persona
|
|
||||||
- `security_expert.md` → security persona
|
|
||||||
- `ui_expert.md` → UI/UX persona
|
|
||||||
- `devops.md` → DevOps persona
|
|
||||||
|
|
||||||
## Step 3 — Write Each Review
|
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) | PR-time focus |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` | Traceability: every `REQ-NNN` implemented; RTM updated |
|
||||||
|
| Developer (Felix Brandt) | `developer.md` | `developer.md` | Clean code, layering, generate:api run, ErrorCode four-site |
|
||||||
|
| Tester | `tester.md` | — (uses identity) | Test quality: each REQ has a real failing-first test; edge cases; levels right |
|
||||||
|
| Security (Nora "NullX") | `security_expert.md` | `security.md` | authn/authz, IDOR, mass-assignment, `{@html}`, secrets/PII |
|
||||||
|
| DevOps | `devops.md` | `devops.md` | migration rollback, env vars, CI guards intact, artifact pin |
|
||||||
|
| UI/UX | `ui_expert.md` | `ui-ux.md` | states, i18n, a11y, design tokens |
|
||||||
|
| Architect | `architect.md` | `architect.md` | boundaries, ADR present for irreversible choices, no superseded-ADR violation |
|
||||||
|
|
||||||
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that:
|
## Step 3 — Write each review
|
||||||
|
|
||||||
|
For each persona, write a review that:
|
||||||
|
|
||||||
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
|
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
|
||||||
- Lists concrete findings with file paths and line references where relevant
|
- Lists concrete findings with file paths and line references; cite the constitution rule
|
||||||
- Distinguishes blockers (must fix) from suggestions (nice to have)
|
(e.g. "violates §2.4 — `updatedBy` bound from request body") or the `REQ-NNN` at issue
|
||||||
- Uses the persona's voice and priorities (e.g. Felix cares about TDD and clean code; the security expert checks for injection, auth, and data exposure; the architect checks layer boundaries and coupling)
|
- Distinguishes **blockers** (must fix) from **suggestions** (nice to have)
|
||||||
- Stays focused — only comment on what the persona would actually care about
|
- **Requirements Engineer specifically** produces a traceability table — for each `REQ-NNN`:
|
||||||
|
is it implemented? is there a test? is `rtm.md` updated to `Done`? Any unimplemented or
|
||||||
Format each comment in Markdown with a persona header, e.g.:
|
untested REQ is a blocker. Any code behavior with no backing requirement is flagged
|
||||||
|
(scope creep — should it be a new REQ, or removed?).
|
||||||
|
- A constitution **Do-Not-Touch** violation (edited generated file, edited shipped migration,
|
||||||
|
edited an Accepted ADR, bumped the artifact action past v3, weakened a CI guard) is always
|
||||||
|
a blocker.
|
||||||
|
|
||||||
```
|
```
|
||||||
## 👨💻 Felix Brandt — Senior Fullstack Developer
|
### 🔐 Security — PR Review
|
||||||
|
|
||||||
**Verdict: ⚠️ Approved with concerns**
|
**Verdict: ⚠️ Approved with concerns**
|
||||||
|
|
||||||
### Blockers
|
### Blockers
|
||||||
...
|
- `UserAvatarController.java:42` — REQ-009's 403 path has no test (constitution §2.8)
|
||||||
|
|
||||||
### Suggestions
|
### Suggestions
|
||||||
...
|
- ...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 4 — Post Comments
|
## Step 4 — Post comments
|
||||||
|
|
||||||
Post each persona's review as a **separate comment** on the PR using the Gitea MCP `issue_write` tool (issues and PRs share the comment API in Gitea).
|
Post each persona's review as a **separate comment** via the Gitea MCP `issue_write` tool
|
||||||
|
(issues and PRs share the comment API). Post all personas; if one has nothing to flag, post a
|
||||||
|
brief "LGTM" naming what they checked.
|
||||||
|
|
||||||
Post all six comments. Do not skip any persona even if their domain has nothing to flag — in that case write a brief "LGTM" with a short explanation of what they checked.
|
## Step 5 — Report back
|
||||||
|
|
||||||
## Step 5 — Report Back
|
Summarize to the user:
|
||||||
|
- Each persona's verdict and the overall verdict (worst-case wins: any "Changes requested" → overall "Changes requested")
|
||||||
After all comments are posted, summarize to the user:
|
- The full list of blockers, grouped by persona
|
||||||
- Which personas posted comments
|
- **Traceability status:** which `REQ-NNN` are implemented+tested vs. missing, and whether
|
||||||
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested")
|
`rtm.md` is in sync
|
||||||
- A bullet list of the top blockers found (if any)
|
- Any constitution Do-Not-Touch violations (called out explicitly)
|
||||||
|
|||||||
19
.env.example
19
.env.example
@@ -72,6 +72,25 @@ VITE_SENTRY_DSN=
|
|||||||
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
||||||
SENTRY_AUTH_TOKEN=
|
SENTRY_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# NL search — Ollama LLM inference
|
||||||
|
# Leave APP_OLLAMA_BASE_URL empty to disable NL search (safe default for CX32 / CI).
|
||||||
|
# Set to http://ollama:11434 to enable. Requires CX42 (16 GB RAM) to run alongside OCR.
|
||||||
|
APP_OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
|
||||||
|
# CPU limit: 4.0 is safe on both CX32 (4 vCPUs) and CX42 (8 vCPUs).
|
||||||
|
# Raise to 7.5 on CX42 for full throughput.
|
||||||
|
OLLAMA_CPU_LIMIT=4.0
|
||||||
|
|
||||||
|
# Memory limit: requires CX42 (16 GB) to run alongside OCR.
|
||||||
|
# Reduce or set APP_OLLAMA_BASE_URL= on smaller hosts.
|
||||||
|
OLLAMA_MEM_LIMIT=8g
|
||||||
|
|
||||||
|
# Ollama API key — set on the Ollama service to restrict inference API access on archiv-net.
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
# NOTE: Empirically verified that OLLAMA_API_KEY is NOT enforced in Ollama 0.6.5 or 0.30.6 (ADR-028 §7).
|
||||||
|
# archiv-net network isolation is the only effective access control. Retained for forward compatibility.
|
||||||
|
OLLAMA_API_KEY=
|
||||||
|
|
||||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||||
# APP_BASE_URL=https://your-domain.example.com
|
# APP_BASE_URL=https://your-domain.example.com
|
||||||
# MAIL_HOST=smtp.example.com
|
# MAIL_HOST=smtp.example.com
|
||||||
|
|||||||
40
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
40
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Something is broken. Describe user-facing impact, not the technical cause."
|
||||||
|
title: "<What breaks> when <trigger>"
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Title format (COLLABORATING.md): "<What breaks> when <trigger>", e.g.
|
||||||
|
"Upload fails silently when file exceeds 50MB". Keep it focused — a bug is small and direct.
|
||||||
|
A failing test is written first, then the fix (red/green TDD).
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What happens
|
||||||
|
|
||||||
|
<The observed broken behavior, from the user's perspective.>
|
||||||
|
|
||||||
|
## Expected
|
||||||
|
|
||||||
|
<What should happen instead.>
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Originating requirement (if known)
|
||||||
|
|
||||||
|
<REQ-NNN + feature this regresses, from .specify/rtm.md — e.g. "REQ-008 (profile-picture-upload)". Helps target the failing test. Write "unknown" if not traceable.>
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
<Browser / role / data state / deploy (local vs prod) as relevant.>
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<Logs, GlitchTip link, screenshots. Redact PII.>
|
||||||
81
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
81
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: "Feature (SDD spec)"
|
||||||
|
about: "Spec-driven feature request. Fill in EARS requirements before implementation starts."
|
||||||
|
title: "As a <role> I want <capability> so <reason>"
|
||||||
|
labels:
|
||||||
|
- spec-required
|
||||||
|
- needs-review
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
This issue body IS the spec (issue-only — there is no committed spec.md). Every requirement
|
||||||
|
uses an EARS pattern + a REQ-NNN id. Reference: .specify/templates/feature-spec.md and the
|
||||||
|
worked example .specify/features/_example/. Delete the placeholder hints as you fill each section.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Context & Why
|
||||||
|
|
||||||
|
<Who needs this and why now (2–4 sentences). Link the constitution principle(s) this depends on: .specify/constitution.md>
|
||||||
|
|
||||||
|
## User Journey
|
||||||
|
|
||||||
|
<Plain-prose steps the user takes to get value, from the user's perspective. Anything not here is out of scope.>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
<!-- One per line, each REQ-NNN + one EARS pattern. A mutating feature needs at least one Event-driven and one Unwanted-behavior requirement. -->
|
||||||
|
|
||||||
|
- **REQ-001** (Ubiquitous) — The `<component>` shall `<always-true behavior>`.
|
||||||
|
- **REQ-002** (Event-driven) — When `<trigger>`, the `<component>` shall `<response>`.
|
||||||
|
- **REQ-003** (State-driven) — While `<state>`, the `<component>` shall `<behavior>`.
|
||||||
|
- **REQ-004** (Optional-feature) — Where `<caller has Permission.X / flag set>`, the `<component>` shall `<behavior>`.
|
||||||
|
- **REQ-005** (Unwanted-behavior) — If `<undesired condition>`, then the `<component>` shall `<safe response / ErrorCode>`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- One measurable criterion per REQ-NNN: numbers, limits, status codes — not adjectives. -->
|
||||||
|
|
||||||
|
- **REQ-001** — <measurable>.
|
||||||
|
- **REQ-002** — <measurable>.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- <The nearest tempting scope creep, named and excluded.>
|
||||||
|
|
||||||
|
## API / Contract Stub
|
||||||
|
|
||||||
|
<Inline OpenAPI stub (use .specify/templates/api-contract-stub.md as a writing aid). Name new paths/methods/status codes and the @RequirePermission on each mutating endpoint.>
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
<Schema delta + next free Flyway V<n> (verify on disk) + rollback note. "none" if not applicable.>
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
<STRIDE categories touched (+ ASTRIDE if an AI agent/tool is involved). Link a threat-model.md if the attack surface is non-trivial.>
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
<!-- Each item BLOCKS implementation until resolved. -->
|
||||||
|
|
||||||
|
- [ ] <question> — owner: <name>
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| REQ-001 | | | Planned |
|
||||||
|
|
||||||
|
<!-- Mirror these rows into .specify/rtm.md. -->
|
||||||
|
|
||||||
|
## Persona Review Results
|
||||||
|
|
||||||
|
| Persona | Status | Key Findings | Resolved |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Requirements Engineer | PENDING | | |
|
||||||
|
| Developer | PENDING | | |
|
||||||
|
| Security | PENDING | | |
|
||||||
|
| DevOps | PENDING | | |
|
||||||
|
| UI/UX | PENDING | | |
|
||||||
|
| Architect | PENDING | | |
|
||||||
127
.gitea/actions/deploy-obs/action.yml
Normal file
127
.gitea/actions/deploy-obs/action.yml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
name: Deploy observability stack
|
||||||
|
description: >-
|
||||||
|
Deploy observability configs + secrets to /opt/familienarchiv, validate the
|
||||||
|
compose config, start the stack, and assert the five healthchecked services
|
||||||
|
are healthy. Per-environment values arrive as inputs.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
grafana_admin_password:
|
||||||
|
description: Grafana admin password (secret)
|
||||||
|
required: true
|
||||||
|
grafana_db_password:
|
||||||
|
description: Read-only grafana_reader DB role password (secret, issue #651)
|
||||||
|
required: true
|
||||||
|
glitchtip_secret_key:
|
||||||
|
description: GlitchTip Django secret key (secret)
|
||||||
|
required: true
|
||||||
|
postgres_password:
|
||||||
|
description: PostgreSQL password for the environment (secret)
|
||||||
|
required: true
|
||||||
|
postgres_host:
|
||||||
|
description: >-
|
||||||
|
Compose project + service hostname, e.g. archiv-staging-db-1. Derived
|
||||||
|
from the Compose project name and service name — a project rename
|
||||||
|
requires updating the caller's value. Plain input, not a secret.
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Deploy observability configs
|
||||||
|
shell: bash
|
||||||
|
# Copies the compose file and config tree from the workspace checkout
|
||||||
|
# into /opt/familienarchiv/ — the permanent location that persists
|
||||||
|
# between CI runs. Containers started in the next step bind-mount
|
||||||
|
# from there, so a future workspace wipe cannot corrupt a running
|
||||||
|
# config file.
|
||||||
|
#
|
||||||
|
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||||
|
# Gitea is always the single source of truth for secret rotation.
|
||||||
|
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||||
|
#
|
||||||
|
# secrets.* is NOT available inside a composite action, so the values
|
||||||
|
# arrive as inputs mapped to env: below and are referenced as $VAR in
|
||||||
|
# the heredoc. The delimiter MUST stay unquoted (<<EOF, not <<'EOF') so
|
||||||
|
# the shell expands $VAR — a quoted delimiter would write the literal
|
||||||
|
# string "$GRAFANA_ADMIN_PASSWORD" and `config --quiet` would still pass
|
||||||
|
# (the var is present, just wrong). Do not stage these into intermediate
|
||||||
|
# variables either, or Gitea log masking can be lost.
|
||||||
|
env:
|
||||||
|
GRAFANA_ADMIN_PASSWORD: ${{ inputs.grafana_admin_password }}
|
||||||
|
GRAFANA_DB_PASSWORD: ${{ inputs.grafana_db_password }}
|
||||||
|
GLITCHTIP_SECRET_KEY: ${{ inputs.glitchtip_secret_key }}
|
||||||
|
POSTGRES_PASSWORD: ${{ inputs.postgres_password }}
|
||||||
|
POSTGRES_HOST: ${{ inputs.postgres_host }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf /opt/familienarchiv/infra/observability
|
||||||
|
mkdir -p /opt/familienarchiv/infra/observability
|
||||||
|
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||||
|
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||||
|
cat > /opt/familienarchiv/obs-secrets.env <<EOF
|
||||||
|
GRAFANA_ADMIN_PASSWORD=$GRAFANA_ADMIN_PASSWORD
|
||||||
|
GRAFANA_DB_PASSWORD=$GRAFANA_DB_PASSWORD
|
||||||
|
GLITCHTIP_SECRET_KEY=$GLITCHTIP_SECRET_KEY
|
||||||
|
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||||
|
POSTGRES_HOST=$POSTGRES_HOST
|
||||||
|
EOF
|
||||||
|
# Five-key non-empty guard: a bare presence check matches an empty
|
||||||
|
# `KEY=` line, so assert each key has a value. Fail loudly on any
|
||||||
|
# missing/empty key rather than starting the stack with broken auth.
|
||||||
|
for key in GRAFANA_ADMIN_PASSWORD GRAFANA_DB_PASSWORD GLITCHTIP_SECRET_KEY POSTGRES_PASSWORD POSTGRES_HOST; do
|
||||||
|
grep -Eq "^${key}=.+" /opt/familienarchiv/obs-secrets.env \
|
||||||
|
|| { echo "::error::obs-secrets.env missing or empty: ${key}"; exit 1; }
|
||||||
|
done
|
||||||
|
# chmod 600 MUST be the final operation: the ordering is the security
|
||||||
|
# property — there is no window where the file is world-readable.
|
||||||
|
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||||
|
|
||||||
|
- name: Validate observability compose config
|
||||||
|
shell: bash
|
||||||
|
# Dry-run: resolves all variable substitutions and reports any missing
|
||||||
|
# required keys before containers start. Catches undefined variables and
|
||||||
|
# YAML errors in config files updated by the previous step.
|
||||||
|
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||||
|
# second (CI-written secrets). Later files win on duplicate keys. POSTGRES_HOST
|
||||||
|
# is environment-specific and supplied only by obs-secrets.env — obs.env
|
||||||
|
# documents it but deliberately does not set a value.
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||||
|
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||||
|
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||||
|
config --quiet
|
||||||
|
|
||||||
|
- name: Start observability stack
|
||||||
|
shell: bash
|
||||||
|
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||||
|
# that survive workspace wipes between runs (see ADR-016).
|
||||||
|
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||||
|
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||||
|
# obs-secrets.env second — later file wins on duplicate keys.
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||||
|
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||||
|
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||||
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
|
- name: Assert observability stack health
|
||||||
|
shell: bash
|
||||||
|
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||||
|
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||||
|
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||||
|
# This step explicitly asserts the five healthchecked critical services are
|
||||||
|
# healthy before the smoke test proceeds.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
unhealthy=""
|
||||||
|
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||||
|
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||||
|
if [ "$status" != "healthy" ]; then
|
||||||
|
echo "::error::$svc is not healthy (status: $status)"
|
||||||
|
unhealthy="$unhealthy $svc"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -z "$unhealthy" ] || exit 1
|
||||||
|
echo "All critical observability services are healthy"
|
||||||
41
.gitea/actions/reload-caddy/action.yml
Normal file
41
.gitea/actions/reload-caddy/action.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Reload Caddy
|
||||||
|
description: >-
|
||||||
|
Reload the host Caddy service from a DooD job container via a privileged
|
||||||
|
sibling container and nsenter. No inputs.
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Reload Caddy
|
||||||
|
shell: bash
|
||||||
|
# Apply any committed Caddyfile changes before smoke-testing the
|
||||||
|
# public surface. Without this step, a Caddyfile edit lands in the
|
||||||
|
# repo but Caddy keeps serving the previous config until someone
|
||||||
|
# reloads it manually — the smoke test would then catch a stale
|
||||||
|
# header or a still-proxied /actuator route rather than confirming
|
||||||
|
# the current config is live.
|
||||||
|
#
|
||||||
|
# The runner executes job steps inside Docker containers (DooD).
|
||||||
|
# `systemctl` is not present in container images and cannot reach
|
||||||
|
# the host's systemd directly. We use the Docker socket (mounted
|
||||||
|
# into every job container via runner-config.yaml) to spin up a
|
||||||
|
# privileged sibling container in the host PID namespace; nsenter
|
||||||
|
# then enters the host's namespaces so systemctl talks to the real
|
||||||
|
# host systemd daemon. No sudoers entry is required — the Docker
|
||||||
|
# socket already grants root-equivalent host access.
|
||||||
|
#
|
||||||
|
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
|
||||||
|
# tooling, and the digest is pinned so any upstream change requires
|
||||||
|
# an explicit bump PR. util-linux (which ships nsenter) is installed
|
||||||
|
# at run time; apk add takes ~1 s on the warm VPS cache.
|
||||||
|
#
|
||||||
|
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
|
||||||
|
# config in-process without dropping TLS connections. `restart`
|
||||||
|
# would briefly stop the service, losing in-flight requests.
|
||||||
|
#
|
||||||
|
# If Caddy is not running this step fails fast before the smoke test
|
||||||
|
# issues a misleading "port 443 refused" error.
|
||||||
|
run: |
|
||||||
|
docker run --rm --privileged --pid=host \
|
||||||
|
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||||
|
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||||
58
.gitea/actions/smoke-test/action.yml
Normal file
58
.gitea/actions/smoke-test/action.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: Smoke test
|
||||||
|
description: >-
|
||||||
|
Verify the deployed public surface (login reachable, HSTS pinned,
|
||||||
|
Permissions-Policy present, /actuator blocked) against a given vhost.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
host:
|
||||||
|
description: Public vhost to smoke-test, e.g. staging.raddatz.cloud
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Smoke test deployed environment
|
||||||
|
shell: bash
|
||||||
|
# Healthchecks confirm containers are healthy; they do NOT confirm the
|
||||||
|
# public surface works. This step catches: Caddy not reloaded, HSTS
|
||||||
|
# header dropped, /actuator block bypassed.
|
||||||
|
#
|
||||||
|
# --resolve pins the public host to the Docker bridge gateway IP
|
||||||
|
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
||||||
|
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
||||||
|
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
||||||
|
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
||||||
|
# and is therefore reachable from the container via that IP.
|
||||||
|
# SNI still uses the public hostname so the TLS cert validates correctly.
|
||||||
|
#
|
||||||
|
# --resolve is stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||||
|
# separate arguments; a quoted string would pass the flag and its value
|
||||||
|
# as one token and curl would reject it as an unknown option.
|
||||||
|
#
|
||||||
|
# Gateway detection reads /proc/net/route (always present, no package
|
||||||
|
# required) instead of `ip route` to avoid a dependency on iproute2.
|
||||||
|
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
||||||
|
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
||||||
|
env:
|
||||||
|
HOST: ${{ inputs.host }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
URL="https://$HOST"
|
||||||
|
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||||
|
[ -n "$HOST_IP" ] || { echo "::error::could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
|
# fail this check rather than pass it silently.
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
|
# header now fails the smoke step.
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
|
[ "$status" = "404" ] || { echo "::error::expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
|
echo "All smoke checks passed"
|
||||||
@@ -108,6 +108,32 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Assert deploy-obs writes obs-secrets.env via an unquoted heredoc (#603)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Inside a composite action, secrets arrive as $VAR from env: (secrets.*
|
||||||
|
# is unavailable there), so the obs-secrets.env heredoc MUST use an
|
||||||
|
# unquoted delimiter (<<EOF) for $VAR to expand. A quoted delimiter
|
||||||
|
# (<<'EOF') would write the literal string "$GRAFANA_ADMIN_PASSWORD",
|
||||||
|
# and the action's five-key non-empty guard would STILL pass (the line
|
||||||
|
# is present, just wrong). This guard enforces the invariant in CI so a
|
||||||
|
# future re-quote cannot ship broken obs auth green. See ADR-029 / #603.
|
||||||
|
action='.gitea/actions/deploy-obs/action.yml'
|
||||||
|
quoted='obs-secrets\.env\s*<<-?\s*[\x27\x22]'
|
||||||
|
# Self-test: the regex must catch a quoted delimiter and ignore the unquoted one.
|
||||||
|
printf "obs-secrets.env <<'EOF'\n" | grep -qP "$quoted" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex missed the quoted <<'EOF' form"; exit 1; }
|
||||||
|
printf 'obs-secrets.env <<EOF\n' | grep -qvP "$quoted" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex wrongly flagged the unquoted <<EOF form"; exit 1; }
|
||||||
|
# Positive: the unquoted heredoc must be present at all.
|
||||||
|
grep -qP 'obs-secrets\.env\s*<<-?EOF\b' "$action" \
|
||||||
|
|| { echo "::error::$action no longer writes obs-secrets.env via an unquoted <<EOF heredoc (ADR-029 / #603)"; exit 1; }
|
||||||
|
# Negative: never a quoted delimiter on the obs-secrets.env heredoc.
|
||||||
|
if grep -nP "$quoted" "$action"; then
|
||||||
|
echo "::error::$action writes obs-secrets.env with a quoted heredoc delimiter — secrets would be written as literal \$VAR strings. Use unquoted <<EOF (ADR-029 / #603)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run unit and component tests with coverage
|
- name: Run unit and component tests with coverage
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ name: nightly
|
|||||||
# - host ports: backend 8081, frontend 3001
|
# - host ports: backend 8081, frontend 3001
|
||||||
# - profile: staging (starts mailpit instead of a real SMTP relay)
|
# - profile: staging (starts mailpit instead of a real SMTP relay)
|
||||||
#
|
#
|
||||||
|
# The obs-stack deploy, Caddy reload, and smoke test are shared with
|
||||||
|
# release.yml via the composite actions under .gitea/actions/ (ADR-029).
|
||||||
|
# actions/checkout MUST stay the first step: a local `uses: ./…` action
|
||||||
|
# only exists on disk after checkout.
|
||||||
|
#
|
||||||
# Required Gitea secrets:
|
# Required Gitea secrets:
|
||||||
# STAGING_POSTGRES_PASSWORD
|
# STAGING_POSTGRES_PASSWORD
|
||||||
# STAGING_MINIO_PASSWORD
|
# STAGING_MINIO_PASSWORD
|
||||||
@@ -55,6 +60,8 @@ jobs:
|
|||||||
# for the same repo is within that boundary.
|
# for the same repo is within that boundary.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
# MUST be first: the composite actions below live under .gitea/actions/
|
||||||
|
# and only exist on disk once the repo is checked out (ADR-029).
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Write staging env file
|
- name: Write staging env file
|
||||||
@@ -92,6 +99,7 @@ jobs:
|
|||||||
# `compose config` renders both shorthand and longform mounts as
|
# `compose config` renders both shorthand and longform mounts as
|
||||||
# `target: /import` + `read_only: true`, so we assert against
|
# `target: /import` + `read_only: true`, so we assert against
|
||||||
# the rendered form rather than the raw source YAML.
|
# the rendered form rather than the raw source YAML.
|
||||||
|
# App-compose check (not obs), nightly-only — stays inline.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -128,150 +136,21 @@ jobs:
|
|||||||
--profile staging \
|
--profile staging \
|
||||||
up -d --wait --remove-orphans
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Deploy observability configs
|
# POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||||
# Copies the compose file and config tree from the workspace checkout
|
# and service name (db). A project rename requires updating this value.
|
||||||
# into /opt/familienarchiv/ — the permanent location that persists
|
- uses: ./.gitea/actions/deploy-obs
|
||||||
# between CI runs. Containers started in the next step bind-mount
|
with:
|
||||||
# from there, so a future workspace wipe cannot corrupt a running
|
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
# config file.
|
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
#
|
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
postgres_password: ${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||||
# Gitea is always the single source of truth for secret rotation.
|
postgres_host: archiv-staging-db-1
|
||||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
|
||||||
run: |
|
|
||||||
rm -rf /opt/familienarchiv/infra/observability
|
|
||||||
mkdir -p /opt/familienarchiv/infra/observability
|
|
||||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
|
||||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
|
||||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
|
||||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
|
||||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
|
||||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
|
||||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
|
||||||
POSTGRES_HOST=archiv-staging-db-1
|
|
||||||
EOF
|
|
||||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
|
||||||
# and service name (db). A project rename requires updating this value.
|
|
||||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
|
||||||
|
|
||||||
- name: Validate observability compose config
|
- uses: ./.gitea/actions/reload-caddy
|
||||||
# Dry-run: resolves all variable substitutions and reports any missing
|
|
||||||
# required keys before containers start. Catches undefined variables and
|
|
||||||
# YAML errors in config files updated by the previous step.
|
|
||||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
|
||||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
|
||||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
|
||||||
run: |
|
|
||||||
docker compose \
|
|
||||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
|
||||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
|
||||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
|
||||||
config --quiet
|
|
||||||
|
|
||||||
- name: Start observability stack
|
- uses: ./.gitea/actions/smoke-test
|
||||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
with:
|
||||||
# that survive workspace wipes between nightly runs (see ADR-016).
|
host: staging.raddatz.cloud
|
||||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
|
||||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
|
||||||
# obs-secrets.env second — later file wins on duplicate keys.
|
|
||||||
run: |
|
|
||||||
docker compose \
|
|
||||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
|
||||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
|
||||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
|
||||||
up -d --wait --remove-orphans
|
|
||||||
|
|
||||||
- name: Assert observability stack health
|
|
||||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
|
||||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
|
||||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
|
||||||
# This step explicitly asserts the five healthchecked critical services are
|
|
||||||
# healthy before the smoke test proceeds.
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
unhealthy=""
|
|
||||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
|
||||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
|
||||||
if [ "$status" != "healthy" ]; then
|
|
||||||
echo "::error::$svc is not healthy (status: $status)"
|
|
||||||
unhealthy="$unhealthy $svc"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ -z "$unhealthy" ] || exit 1
|
|
||||||
echo "All critical observability services are healthy"
|
|
||||||
|
|
||||||
- name: Reload Caddy
|
|
||||||
# Apply any committed Caddyfile changes before smoke-testing the
|
|
||||||
# public surface. Without this step, a Caddyfile edit lands in the
|
|
||||||
# repo but Caddy keeps serving the previous config until someone
|
|
||||||
# reloads it manually — the smoke test would then catch a stale
|
|
||||||
# header or a still-proxied /actuator route rather than confirming
|
|
||||||
# the current config is live.
|
|
||||||
#
|
|
||||||
# The runner executes job steps inside Docker containers (DooD).
|
|
||||||
# `systemctl` is not present in container images and cannot reach
|
|
||||||
# the host's systemd directly. We use the Docker socket (mounted
|
|
||||||
# into every job container via runner-config.yaml) to spin up a
|
|
||||||
# privileged sibling container in the host PID namespace; nsenter
|
|
||||||
# then enters the host's namespaces so systemctl talks to the real
|
|
||||||
# host systemd daemon. No sudoers entry is required — the Docker
|
|
||||||
# socket already grants root-equivalent host access.
|
|
||||||
#
|
|
||||||
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
|
|
||||||
# tooling, and the digest is pinned so any upstream change requires
|
|
||||||
# an explicit bump PR. util-linux (which ships nsenter) is installed
|
|
||||||
# at run time; apk add takes ~1 s on the warm VPS cache.
|
|
||||||
#
|
|
||||||
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
|
|
||||||
# config in-process without dropping TLS connections. `restart`
|
|
||||||
# would briefly stop the service, losing in-flight requests.
|
|
||||||
#
|
|
||||||
# If Caddy is not running this step fails fast before the smoke test
|
|
||||||
# issues a misleading "port 443 refused" error.
|
|
||||||
run: |
|
|
||||||
docker run --rm --privileged --pid=host \
|
|
||||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
|
||||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
|
||||||
|
|
||||||
- name: Smoke test deployed environment
|
|
||||||
# Healthchecks confirm containers are healthy; they do NOT confirm the
|
|
||||||
# public surface works. This step catches: Caddy not reloaded, HSTS
|
|
||||||
# header dropped, /actuator block bypassed.
|
|
||||||
#
|
|
||||||
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP
|
|
||||||
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
|
||||||
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
|
||||||
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
|
||||||
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
|
||||||
# and is therefore reachable from the container via that IP.
|
|
||||||
# SNI still uses the public hostname so the TLS cert validates correctly.
|
|
||||||
#
|
|
||||||
# Gateway detection reads /proc/net/route (always present, no package
|
|
||||||
# required) instead of `ip route` to avoid a dependency on iproute2.
|
|
||||||
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
|
||||||
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
HOST="staging.raddatz.cloud"
|
|
||||||
URL="https://$HOST"
|
|
||||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
|
||||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
|
||||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
|
||||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
|
||||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
|
||||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
|
||||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
|
||||||
# fail this check rather than pass it silently.
|
|
||||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
|
||||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
|
||||||
# Permissions-Policy denies APIs the app does not use (camera,
|
|
||||||
# microphone, geolocation). A regression that loosens or drops the
|
|
||||||
# header now fails the smoke step.
|
|
||||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
|
||||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
|
||||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
|
||||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
|
||||||
echo "All smoke checks passed"
|
|
||||||
|
|
||||||
- name: Cleanup env file
|
- name: Cleanup env file
|
||||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||||
@@ -282,3 +161,147 @@ jobs:
|
|||||||
# without first re-evaluating ADR-011.
|
# without first re-evaluating ADR-011.
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f .env.staging
|
run: rm -f .env.staging
|
||||||
|
|
||||||
|
npm-audit:
|
||||||
|
# Independent parallel job — a deploy failure cannot mask the audit signal
|
||||||
|
# and a clean audit cannot hide a broken deploy. Intentionally no `needs:`.
|
||||||
|
#
|
||||||
|
# Scans dev deps too (no --omit=dev), which is deliberately broader than the
|
||||||
|
# PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader
|
||||||
|
# result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild,
|
||||||
|
# Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate.
|
||||||
|
#
|
||||||
|
# Required Gitea secrets:
|
||||||
|
# NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token
|
||||||
|
# means a leak via logs/process-args cannot push
|
||||||
|
# branches, open PRs, or read repo contents (ADR-041).
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Assert jq is available
|
||||||
|
run: which jq || sudo apt-get install -y jq
|
||||||
|
|
||||||
|
- name: Run npm audit and file tracking issue on findings
|
||||||
|
# Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs.
|
||||||
|
env:
|
||||||
|
NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
MARKER="Nightly npm audit: high-severity advisory"
|
||||||
|
GITEA_URL="${{ github.server_url }}"
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
||||||
|
|
||||||
|
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
||||||
|
# Tests the exact jq test() call used in the dedupe step, before any
|
||||||
|
# API call, so a broken matcher fails loudly early rather than silently
|
||||||
|
# opening duplicate issues. Proves the regex only — create-vs-update
|
||||||
|
# decision is exercised by the workflow_dispatch AC.
|
||||||
|
echo "{\"title\": \"${MARKER}\"}" \
|
||||||
|
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
||||||
|
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
||||||
|
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
||||||
|
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
||||||
|
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
||||||
|
echo "Self-test passed."
|
||||||
|
|
||||||
|
# --- Run audit ---
|
||||||
|
# No npm ci — audit reads only the lockfile (no network, no install).
|
||||||
|
set +e
|
||||||
|
(cd frontend && npm audit --audit-level=high --json > /tmp/audit.json)
|
||||||
|
AUDIT_EXIT=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$AUDIT_EXIT" -ne 0 ]; then
|
||||||
|
# --- Build issue body with jq (never string-concat advisory text) ---
|
||||||
|
# Advisory overview/title text is registry-controlled; string-concat
|
||||||
|
# would be an injection/escaping vector into the API body. Truncate
|
||||||
|
# raw excerpt to 500 chars so a pathological overview can't produce
|
||||||
|
# a multi-MB PATCH body.
|
||||||
|
ISSUE_BODY=$(jq -r \
|
||||||
|
--arg run_url "$RUN_URL" \
|
||||||
|
'
|
||||||
|
(.vulnerabilities // {}) as $vulns |
|
||||||
|
($vulns | to_entries |
|
||||||
|
map(select(.value.severity == "high" or .value.severity == "critical")) |
|
||||||
|
map("- **" + .key + "** (" + .value.severity + ")") |
|
||||||
|
if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list |
|
||||||
|
"## npm audit: high/critical advisories\n\n" + $pkg_list +
|
||||||
|
"\n\n**Run:** " + $run_url +
|
||||||
|
"\n\n<details><summary>Raw audit excerpt (first 500 chars)</summary>\n\n```\n" +
|
||||||
|
(tostring | .[0:500]) +
|
||||||
|
"\n```\n\n</details>"
|
||||||
|
' /tmp/audit.json)
|
||||||
|
|
||||||
|
# --- Dedupe: fetch open security issues, match by title marker ---
|
||||||
|
# Renovate vuln PRs also carry the "security" label, so >1 open
|
||||||
|
# "security" issue WILL occur. Title-match (not just label) ensures
|
||||||
|
# we deduplicate only our own tracking issue.
|
||||||
|
OPEN_ISSUES=$(curl -sf \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
||||||
|
|
||||||
|
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
||||||
|
--arg m "$MARKER" \
|
||||||
|
'[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)')
|
||||||
|
MATCH_COUNT=$(echo "$MATCHED" | jq 'length')
|
||||||
|
|
||||||
|
if [ "$MATCH_COUNT" -gt 0 ]; then
|
||||||
|
# Patch the oldest matched issue (append run URL to body).
|
||||||
|
ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number')
|
||||||
|
EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body')
|
||||||
|
NEW_BODY=$(jq -n \
|
||||||
|
--arg existing "$EXISTING_BODY" \
|
||||||
|
--arg run_url "$RUN_URL" \
|
||||||
|
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
||||||
|
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
||||||
|
curl -sf -X PATCH \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
|
||||||
|
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
||||||
|
else
|
||||||
|
# Closed prior issue that recurs → new issue (not reopened).
|
||||||
|
# A re-opened issue would obscure when the advisory was re-discovered.
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg title "$MARKER" \
|
||||||
|
--arg body "$ISSUE_BODY" \
|
||||||
|
'{"title": $title, "body": $body}')
|
||||||
|
CREATED=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
|
||||||
|
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
||||||
|
echo "Opened new tracking issue #${NEW_NUMBER}"
|
||||||
|
|
||||||
|
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
||||||
|
LABEL_IDS=$(curl -sf \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
||||||
|
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"labels\": $LABEL_IDS}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$AUDIT_EXIT"
|
||||||
|
|
||||||
|
else
|
||||||
|
# --- Heartbeat: proves the job ran and found nothing ---
|
||||||
|
# "No issue created" is only meaningful evidence when paired with a
|
||||||
|
# visible positive signal. Without this, a never-ran job is
|
||||||
|
# indistinguishable from a clean run.
|
||||||
|
#
|
||||||
|
# $GITHUB_STEP_SUMMARY availability is unproven on this runner
|
||||||
|
# (act_runner populates it, but this is the first run to verify it).
|
||||||
|
# Guard before use so an unset variable does not fail the clean-path.
|
||||||
|
MSG="✅ npm audit clean $(date -u)"
|
||||||
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
echo "$MSG" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
echo "$MSG"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ name: release
|
|||||||
# - host ports: backend 8080, frontend 3000
|
# - host ports: backend 8080, frontend 3000
|
||||||
# - profile: (none) — mailpit is excluded; real SMTP relay is used
|
# - profile: (none) — mailpit is excluded; real SMTP relay is used
|
||||||
#
|
#
|
||||||
|
# The obs-stack deploy, Caddy reload, and smoke test are shared with
|
||||||
|
# nightly.yml via the composite actions under .gitea/actions/ (ADR-029).
|
||||||
|
# actions/checkout MUST stay the first step: a local `uses: ./…` action
|
||||||
|
# only exists on disk after checkout.
|
||||||
|
#
|
||||||
# Required Gitea secrets:
|
# Required Gitea secrets:
|
||||||
# PROD_POSTGRES_PASSWORD
|
# PROD_POSTGRES_PASSWORD
|
||||||
# PROD_MINIO_PASSWORD
|
# PROD_MINIO_PASSWORD
|
||||||
@@ -53,6 +58,8 @@ jobs:
|
|||||||
# advertised label of our single-tenant self-hosted runner.
|
# advertised label of our single-tenant self-hosted runner.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
# MUST be first: the composite actions below live under .gitea/actions/
|
||||||
|
# and only exist on disk once the repo is checked out (ADR-029).
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Write production env file
|
- name: Write production env file
|
||||||
@@ -100,117 +107,21 @@ jobs:
|
|||||||
--env-file .env.production \
|
--env-file .env.production \
|
||||||
up -d --wait --remove-orphans
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Deploy observability configs
|
# POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||||
# Mirrors the nightly approach: copies obs compose file and config tree
|
# and service name (db). A project rename requires updating this value.
|
||||||
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
- uses: ./.gitea/actions/deploy-obs
|
||||||
# then writes obs-secrets.env fresh from Gitea secrets.
|
with:
|
||||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
run: |
|
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
rm -rf /opt/familienarchiv/infra/observability
|
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
mkdir -p /opt/familienarchiv/infra/observability
|
postgres_password: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
postgres_host: archiv-production-db-1
|
||||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
|
||||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
|
||||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
|
||||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
|
||||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
|
||||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
|
||||||
POSTGRES_HOST=archiv-production-db-1
|
|
||||||
EOF
|
|
||||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
|
||||||
# and service name (db). A project rename requires updating this value.
|
|
||||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
|
||||||
|
|
||||||
- name: Validate observability compose config
|
- uses: ./.gitea/actions/reload-caddy
|
||||||
# Dry-run: resolves all variable substitutions and reports any missing
|
|
||||||
# required keys before containers start. Catches undefined variables and
|
|
||||||
# YAML errors in config files updated by the previous step.
|
|
||||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
|
||||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
|
||||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
|
||||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
|
||||||
run: |
|
|
||||||
docker compose \
|
|
||||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
|
||||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
|
||||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
|
||||||
config --quiet
|
|
||||||
|
|
||||||
- name: Start observability stack
|
- uses: ./.gitea/actions/smoke-test
|
||||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
with:
|
||||||
# that survive workspace wipes between runs (see ADR-016).
|
host: archiv.raddatz.cloud
|
||||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
|
||||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
|
||||||
# obs-secrets.env second — later file wins on duplicate keys.
|
|
||||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
|
||||||
run: |
|
|
||||||
docker compose \
|
|
||||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
|
||||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
|
||||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
|
||||||
up -d --wait --remove-orphans
|
|
||||||
|
|
||||||
- name: Assert observability stack health
|
|
||||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
|
||||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
|
||||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
|
||||||
# This step explicitly asserts the five healthchecked critical services are
|
|
||||||
# healthy before the smoke test proceeds.
|
|
||||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
unhealthy=""
|
|
||||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
|
||||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
|
||||||
if [ "$status" != "healthy" ]; then
|
|
||||||
echo "::error::$svc is not healthy (status: $status)"
|
|
||||||
unhealthy="$unhealthy $svc"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ -z "$unhealthy" ] || exit 1
|
|
||||||
echo "All critical observability services are healthy"
|
|
||||||
|
|
||||||
- name: Reload Caddy
|
|
||||||
# See nightly.yml — same rationale and mechanism: DooD job containers
|
|
||||||
# cannot call systemctl directly; nsenter via a privileged sibling
|
|
||||||
# container reaches the host systemd. Must run after deploy (so the
|
|
||||||
# latest Caddyfile is on disk) and before the smoke test (so the
|
|
||||||
# public surface reflects the current config). Alpine with pinned
|
|
||||||
# digest; reload not restart — see nightly.yml for full rationale.
|
|
||||||
run: |
|
|
||||||
docker run --rm --privileged --pid=host \
|
|
||||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
|
||||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
|
||||||
|
|
||||||
- name: Smoke test deployed environment
|
|
||||||
# See nightly.yml — same three checks, against the prod vhost.
|
|
||||||
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
|
||||||
# separate arguments; a quoted string would pass the flag and its value
|
|
||||||
# as one token and curl would reject it as an unknown option.
|
|
||||||
# Gateway detection via /proc/net/route — no iproute2 dependency.
|
|
||||||
# See nightly.yml for the full network topology explanation.
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
HOST="archiv.raddatz.cloud"
|
|
||||||
URL="https://$HOST"
|
|
||||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
|
||||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
|
||||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
|
||||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
|
||||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
|
||||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
|
||||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
|
||||||
# fail this check rather than pass it silently.
|
|
||||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
|
||||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
|
||||||
# Permissions-Policy denies APIs the app does not use (camera,
|
|
||||||
# microphone, geolocation). A regression that loosens or drops the
|
|
||||||
# header now fails the smoke step.
|
|
||||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
|
||||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
|
||||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
|
||||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
|
||||||
echo "All smoke checks passed"
|
|
||||||
|
|
||||||
- name: Cleanup env file
|
- name: Cleanup env file
|
||||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||||
|
|||||||
44
.gitea/workflows/renovate.yml
Normal file
44
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
# Runs Renovate daily to surface newly-published advisories via OSV.dev
|
||||||
|
# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch
|
||||||
|
# schedule (see renovate.json §schedule). Security/vulnerability PRs are
|
||||||
|
# raised immediately regardless of the weekly schedule window.
|
||||||
|
#
|
||||||
|
# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md):
|
||||||
|
# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues
|
||||||
|
# Belongs to a dedicated bot account. Branch protection
|
||||||
|
# on main must forbid this bot pushing directly.
|
||||||
|
#
|
||||||
|
# Platform config is injected via env vars below; the renovate.json in the
|
||||||
|
# repo root carries only dependency rules (no platform/endpoint/repos).
|
||||||
|
#
|
||||||
|
# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd
|
||||||
|
# corresponds to release v46.1.15. Update by bumping both the digest and the
|
||||||
|
# renovate-version when Renovate publishes a new release. Renovate itself
|
||||||
|
# will open a PR to bump this digest once it runs.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run Renovate
|
||||||
|
# Pinned by digest — this action holds contents+pull_request+issues
|
||||||
|
# scopes; an unpinned tag is a supply-chain risk (see ADR-041).
|
||||||
|
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
|
||||||
|
with:
|
||||||
|
configurationFile: renovate.json
|
||||||
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
renovate-version: "46.1.15"
|
||||||
|
env:
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://git.raddatz.cloud
|
||||||
|
RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]'
|
||||||
|
LOG_LEVEL: info
|
||||||
169
.gitea/workflows/sdd-gate.yml
Normal file
169
.gitea/workflows/sdd-gate.yml
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
name: SDD Gate
|
||||||
|
|
||||||
|
# Spec-Driven Development quality gate. Runs on PRs.
|
||||||
|
#
|
||||||
|
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
|
||||||
|
# spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
|
||||||
|
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
|
||||||
|
#
|
||||||
|
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
|
||||||
|
# workflow without CI immediately failing.
|
||||||
|
#
|
||||||
|
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
|
||||||
|
# once SDD adoption has settled — target: after the first 5 features have shipped through
|
||||||
|
# the workflow. Tracked in ADR-042.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ─── RTM check ────────────────────────────────────────────────────────────────
|
||||||
|
# The Requirements Traceability Matrix is the one per-feature SDD artifact in git. Every
|
||||||
|
# data row must point at a Gitea issue (`#n`) and name at least one test. Warn otherwise.
|
||||||
|
# Pure awk — no external tooling. Columns: | REQ-ID | Summary | Issue | Feature | Impl | Test | Status |
|
||||||
|
rtm-check:
|
||||||
|
name: RTM Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true # TODO: remove to make blocking (see header)
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate .specify/rtm.md rows
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
rtm=".specify/rtm.md"
|
||||||
|
test -f "$rtm" || { echo "::error::$rtm is missing"; exit 1; }
|
||||||
|
|
||||||
|
# Self-test: a good row passes, a row with an empty Issue or Test is flagged.
|
||||||
|
check_row() { awk -F'|' '{
|
||||||
|
issue=$4; test_col=$7;
|
||||||
|
gsub(/^[ \t]+|[ \t]+$/,"",issue); gsub(/^[ \t]+|[ \t]+$/,"",test_col);
|
||||||
|
if (issue !~ /#/ || test_col=="") exit 1; else exit 0 }'; }
|
||||||
|
echo '| REQ-001 | x | #42 | f | impl | SomeTest#works | Done |' | check_row \
|
||||||
|
|| { echo "FAIL: rtm-check self-test rejected a valid row"; exit 1; }
|
||||||
|
echo '| REQ-002 | x | | f | impl | | Planned |' | check_row \
|
||||||
|
&& { echo "FAIL: rtm-check self-test accepted an empty row"; exit 1; }
|
||||||
|
|
||||||
|
bad=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "$line" | check_row || {
|
||||||
|
req=$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}')
|
||||||
|
echo "::warning file=$rtm::row $req is missing an Issue (#n) or a Test"
|
||||||
|
bad=$((bad+1))
|
||||||
|
}
|
||||||
|
done < <(grep -E '^\| REQ-[0-9]{3} ' "$rtm")
|
||||||
|
echo "$bad RTM row(s) incomplete (warning only)."
|
||||||
|
|
||||||
|
# ─── Contract validation ──────────────────────────────────────────────────────
|
||||||
|
# Validate any committed OpenAPI contract with Spectral (OpenAPI 3.1). REST stack — no
|
||||||
|
# GraphQL. Contracts are optional and ride a feature branch when present; the _example one
|
||||||
|
# is always linted. Skips cleanly when none changed.
|
||||||
|
contract-validate:
|
||||||
|
name: Contract Validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true # TODO: remove to make blocking (see header)
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
|
||||||
|
# Cache the npm/npx download so Spectral isn't re-fetched every run. The key is pinned to
|
||||||
|
# the exact Spectral version below, so a version bump busts the cache deterministically.
|
||||||
|
- name: Cache Spectral (npm cache)
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: spectral-cli-6.16.0
|
||||||
|
restore-keys: spectral-cli-
|
||||||
|
|
||||||
|
- name: Lint changed OpenAPI contracts
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SPECTRAL: "@stoplight/spectral-cli@6.16.0" # pinned — keep in sync with the cache key above
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
base="origin/${{ github.event.pull_request.base.ref }}"
|
||||||
|
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
|
||||||
|
# Any *.yaml under .specify/ or any file named like a contract.
|
||||||
|
changed="$(git diff --name-only "$base"...HEAD -- '.specify/**/*.yaml' '**/api-contract.yaml' '**/*.openapi.yaml' || true)"
|
||||||
|
if [ -z "$changed" ]; then
|
||||||
|
echo "No OpenAPI contract changed — nothing to validate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rc=0
|
||||||
|
for f in $changed; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
echo "── spectral lint $f"
|
||||||
|
npx --yes "$SPECTRAL" lint "$f" || rc=1
|
||||||
|
done
|
||||||
|
exit $rc
|
||||||
|
|
||||||
|
# ─── Constitution change impact ───────────────────────────────────────────────
|
||||||
|
# When .specify/constitution.md is modified, list every file that references it (and so
|
||||||
|
# may need a Sync Impact update) and post it as a PR comment. Best-effort: if no token is
|
||||||
|
# available the list is only echoed to the log. This job is informational, never blocking.
|
||||||
|
constitution-diff:
|
||||||
|
name: Constitution Impact
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: List files referencing the constitution
|
||||||
|
id: impact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
base="origin/${{ github.event.pull_request.base.ref }}"
|
||||||
|
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
|
||||||
|
if ! git diff --name-only "$base"...HEAD -- '.specify/constitution.md' | grep -q .; then
|
||||||
|
echo "constitution.md not modified — skipping."
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Files referencing constitution.md (review for Sync Impact):"
|
||||||
|
grep -rIl --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=target \
|
||||||
|
-e 'constitution.md' -e 'constitution §' . \
|
||||||
|
| grep -v '^\./.specify/constitution.md$' | sort > /tmp/refs.txt || true
|
||||||
|
cat /tmp/refs.txt
|
||||||
|
{
|
||||||
|
echo "body<<EOF"
|
||||||
|
echo "### ⚠️ Constitution changed — Sync Impact review"
|
||||||
|
echo ""
|
||||||
|
echo "\`.specify/constitution.md\` was modified in this PR. Per its §6 Sync Impact rule, re-read and reconcile every file below, and confirm the semantic version bump:"
|
||||||
|
echo ""
|
||||||
|
while IFS= read -r line; do echo "- \`${line#./}\`"; done < /tmp/refs.txt
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Post PR comment (best-effort)
|
||||||
|
if: steps.impact.outputs.changed == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SERVER: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR: ${{ github.event.pull_request.number }}
|
||||||
|
BODY: ${{ steps.impact.outputs.body }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
|
echo "No token available — printing impact list to log only:"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
payload="$(jq -n --arg b "$BODY" '{body:$b}')"
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments" \
|
||||||
|
-d "$payload" >/dev/null \
|
||||||
|
&& echo "Posted Sync Impact comment to PR #${PR}." \
|
||||||
|
|| { echo "Comment POST failed (non-fatal); impact list:"; echo "$BODY"; }
|
||||||
77
.specify/AGENTS.md
Normal file
77
.specify/AGENTS.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Machine-readable rules for AI coding agents (Claude Code, Copilot, Cursor, …) working in
|
||||||
|
this repository. Read this on every invocation. These are **executable constraints**, not
|
||||||
|
aspirations. The full rationale lives in [constitution.md](./constitution.md) and the docs
|
||||||
|
it links — this file does not duplicate it, it points to it.
|
||||||
|
|
||||||
|
If anything here conflicts with the user's explicit instruction, the user wins. Otherwise,
|
||||||
|
constitution > this file > convenience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack & Versions
|
||||||
|
|
||||||
|
| Layer | Tech | Version |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend | Spring Boot (Java, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Session JDBC) | Boot 4.0.6 / Java 21 |
|
||||||
|
| API docs | springdoc-openapi (webmvc-ui), served at `/v3/api-docs` (dev profile only) | — |
|
||||||
|
| Frontend | SvelteKit / Svelte | 2.60 / 5.43 |
|
||||||
|
| Frontend lang/style | TypeScript / Tailwind CSS / Paraglide i18n (de/en/es) | TS 5.9 / TW 4.1 |
|
||||||
|
| API client | `openapi-fetch` + `openapi-typescript` (types generated from the live spec) | — |
|
||||||
|
| DB | PostgreSQL | 16 |
|
||||||
|
| Object storage | MinIO (S3-compatible) | — |
|
||||||
|
| Sidecars | `ocr-service`, `nlp-service` (Python / FastAPI) | Python 3.11 |
|
||||||
|
| Tests | JUnit + Mockito + `@WebMvcTest` + Testcontainers (backend); Vitest + `vitest-browser-svelte` + Playwright (frontend); Pytest (services) | — |
|
||||||
|
| Lint/format | ESLint 9 (+ `eslint-plugin-boundaries`) + Prettier; Semgrep (backend) | — |
|
||||||
|
| CI | Gitea Actions (`.gitea/workflows/`) | — |
|
||||||
|
|
||||||
|
App port `8080`; management port `8081`. Backend app id: `org.raddatz.familienarchiv` / `0.0.1-SNAPSHOT`.
|
||||||
|
|
||||||
|
## Architectural Constraints
|
||||||
|
|
||||||
|
- Controllers call services only — never a repository. (constitution §1.2)
|
||||||
|
- A service uses only its own domain's repository; reach other domains via their service. (constitution §1.3)
|
||||||
|
- A new backend domain goes in its own package AND is added to `ArchitectureTest`'s allow-lists in the same change. (constitution §1.7)
|
||||||
|
- Frontend cross-domain imports are allowed only where `frontend/eslint.config.js` permits; otherwise move shared code to `$lib/shared/`. (constitution §1.4)
|
||||||
|
- Never serialize a lazy-collection entity across the controller boundary — assemble a view in-transaction. (constitution §1.6 / ADR-036)
|
||||||
|
- `Person` ≠ `AppUser`; do not add account guards to Person-domain operations. (constitution §1.5)
|
||||||
|
- Every `POST/PUT/PATCH/DELETE` endpoint has `@RequirePermission(Permission.X)`. Use the enum, never `@PreAuthorize`. (constitution §2.1–2.2)
|
||||||
|
- Throw only `DomainException.notFound/forbidden/conflict/internal()` from services, each with an `ErrorCode`. (CONTRIBUTING §Error handling)
|
||||||
|
- Set `createdBy`/`updatedBy` from the session principal in the service — never bind them from a request body. (constitution §2.4)
|
||||||
|
- Add an `@Schema(requiredMode = REQUIRED)` to every always-populated field. (constitution §3.5)
|
||||||
|
- Never introduce a new runtime dependency without an ADR in `Accepted` status. (constitution §5.1)
|
||||||
|
- Render untrusted text with `{...}`; never `{@html}` on user/import data. (constitution §2.5)
|
||||||
|
- Build dates from ISO strings with a `T12:00:00` suffix. (constitution §3.7)
|
||||||
|
|
||||||
|
## Workflow Rules
|
||||||
|
|
||||||
|
- Always write a failing test before implementation code; confirm it fails, then make it pass, then refactor. (constitution §3.1)
|
||||||
|
- Run only the specific test file/class locally — never the full suite (it crashes the machine); leave the full sweep to CI.
|
||||||
|
- Run `npm run generate:api` (in `frontend/`) after ANY backend model or endpoint change — most common cause of TS errors.
|
||||||
|
- Run `npm run lint` before every commit; a fresh frontend worktree needs `npm install` first or the pre-commit hook fails.
|
||||||
|
- When adding a new `ErrorCode`, update all four sites at once (constitution §3.6).
|
||||||
|
- One logical change per commit; reference the Gitea issue (`Closes #n` / `Refs #n`) on the last line.
|
||||||
|
- Create a git worktree for new issue work — never `git checkout -b` in the main repo while another branch has in-flight work. Avoid `+` in worktree/branch names (breaks vitest browser mode).
|
||||||
|
- Pull `main` as a separate explicit step before creating a branch.
|
||||||
|
- Track work as Gitea issues (`http://192.168.178.71:3005`, repo `marcel/familienarchiv`), not todo files.
|
||||||
|
- Verify ADR and Flyway migration numbers against disk before using one — parallel worktrees make issue-body numbers go stale.
|
||||||
|
|
||||||
|
## Do Not Touch
|
||||||
|
|
||||||
|
- Generated: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
|
||||||
|
- Shipped Flyway migrations — add a new forward-only migration instead.
|
||||||
|
- An `Accepted` ADR — supersede it with a new one.
|
||||||
|
- `actions/(upload|download)-artifact` version — stays at `@v3` (ADR-014).
|
||||||
|
- CI guard steps — do not remove/weaken without an ADR.
|
||||||
|
- `main` — never commit directly; branch + PR only.
|
||||||
|
- Worktree copies (`familienarchiv-*`, `.worktrees/`) and `data/` — never commit.
|
||||||
|
|
||||||
|
## Spec-Driven Development
|
||||||
|
|
||||||
|
A feature's spec is its **Gitea issue body** — there is no committed `spec.md`. The issue's
|
||||||
|
EARS requirements (`REQ-NNN`) and acceptance criteria are the contract; each maps to a test,
|
||||||
|
traced in [`.specify/rtm.md`](./rtm.md) (`REQ-ID → issue # → test`). Read the issue before
|
||||||
|
implementing. The committed [`.specify/features/_example/`](./features/_example/) is a
|
||||||
|
template/reference showing the full artifact set, not a live feature. Full workflow:
|
||||||
|
[SPEC_DRIVEN_DEVELOPMENT.md](../SPEC_DRIVEN_DEVELOPMENT.md).
|
||||||
25
.specify/adrs/README.md
Normal file
25
.specify/adrs/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# ADR archive — see `docs/adr/`
|
||||||
|
|
||||||
|
This project already keeps a mature, permanent ADR archive at
|
||||||
|
[`../../docs/adr/`](../../docs/adr/) (40+ records, format `NNN-kebab-title.md`). SDD does
|
||||||
|
**not** introduce a second archive — that would split the project's decision history in two.
|
||||||
|
|
||||||
|
## Where ADRs live
|
||||||
|
|
||||||
|
- **Project-wide decisions** → [`docs/adr/NNN-kebab-title.md`](../../docs/adr/). Use the
|
||||||
|
next free `NNN` (verify against the directory on disk — parallel worktrees make
|
||||||
|
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
|
||||||
|
- **The decision to adopt SDD itself** →
|
||||||
|
[`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
|
||||||
|
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
|
||||||
|
- **Feature-local decisions** that are only meaningful within one in-flight feature →
|
||||||
|
beside that feature's spec, e.g.
|
||||||
|
[`../features/_example/adr-001-avatars-reuse-archive-bucket.md`](../features/_example/adr-001-avatars-reuse-archive-bucket.md).
|
||||||
|
Promote one to `docs/adr/` if its reach turns out to be project-wide.
|
||||||
|
|
||||||
|
## Rules (unchanged from the existing convention)
|
||||||
|
|
||||||
|
- An ADR is **immutable once `Accepted`** — supersede it with a new, higher-numbered ADR;
|
||||||
|
set the old one's status to `Superseded by ADR-MMM`.
|
||||||
|
- Header style matches the existing archive: `# ADR-NNN — Title`, then
|
||||||
|
`**Status:** / **Date:** / **Issue:**`.
|
||||||
80
.specify/constitution.md
Normal file
80
.specify/constitution.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Familienarchiv Constitution
|
||||||
|
|
||||||
|
**Version:** v1.0.0
|
||||||
|
**Status:** Ratified
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
|
||||||
|
|
||||||
|
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
|
||||||
|
> bound by this document. Rules here are deliberately few and absolute — guidance and
|
||||||
|
> rationale live in [CLAUDE.md](../CLAUDE.md), [COLLABORATING.md](../COLLABORATING.md),
|
||||||
|
> [CODESTYLE.md](../CODESTYLE.md), [CONTRIBUTING.md](../CONTRIBUTING.md), and the ADR
|
||||||
|
> archive ([docs/adr/](../docs/adr/)). When this file conflicts with any of those, **this
|
||||||
|
> file wins** — open an ADR to change it.
|
||||||
|
>
|
||||||
|
> Versioning is semantic: **MAJOR** = a rule removed or weakened (existing code may now
|
||||||
|
> violate the constitution), **MINOR** = a rule added or tightened, **PATCH** = wording
|
||||||
|
> only. Any change requires the Sync Impact review in the last section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Principles
|
||||||
|
|
||||||
|
1. The backend is organised package-by-domain under `org.raddatz.familienarchiv`; a new domain lives in its own package, never spread across layer packages.
|
||||||
|
2. Controllers never call repositories directly — a controller calls only services.
|
||||||
|
3. A service accesses only its own domain's repository; cross-domain data is fetched through the other domain's service, never its repository.
|
||||||
|
4. The frontend mirrors the backend domain split under `frontend/src/lib/<domain>/`, and cross-domain imports are allowed only where `frontend/eslint.config.js` (`boundaries/dependencies`) permits them.
|
||||||
|
5. A `Person` (historical subject) and an `AppUser` (login account) are distinct domains and never share an identity or an account guard.
|
||||||
|
6. Lazy-collection-bearing entities are never serialized across the controller boundary; the owning service assembles an explicit view inside the transaction (see [ADR-036](../docs/adr/036-geschichte-responses-are-views-not-entities.md)).
|
||||||
|
7. A new backend domain package is added to `ArchitectureTest`'s package allow-lists in the same change that introduces it.
|
||||||
|
8. Synchronous cross-domain side effects use in-transaction domain events, not direct service-to-service write calls (see [ADR-006](../docs/adr/006-synchronous-domain-events-in-transaction.md)).
|
||||||
|
|
||||||
|
## 2. Security Defaults
|
||||||
|
|
||||||
|
1. Every `POST`, `PUT`, `PATCH`, and `DELETE` endpoint carries `@RequirePermission(Permission.X)` — there is no unguarded mutating endpoint.
|
||||||
|
2. Authorization uses the typed `Permission` enum and `@RequirePermission`, never magic-string `@PreAuthorize`.
|
||||||
|
3. All user input is validated at the system boundary (controller / form action), and validation failures return a typed `ErrorCode`, never a raw exception.
|
||||||
|
4. Audit fields (`createdBy`/`updatedBy`) are set from the session principal inside the service and are never bound from a request body.
|
||||||
|
5. Untrusted text is rendered through Svelte's default `{...}` escaping; `{@html}` is never used on user- or import-derived strings.
|
||||||
|
6. Secrets are read only from environment variables (see `.env.example`); no secret, token, password, or DSN is ever committed to the repository or written to a log.
|
||||||
|
7. Logs never contain PII beyond a stable user/entity UUID — no names, email addresses, document contents, or transcription text.
|
||||||
|
8. Every state-mutating endpoint is covered by an Unwanted-behavior requirement (EARS `If`) describing the unauthenticated/unauthorized response.
|
||||||
|
9. A dependency security audit runs on every CI run (`npm audit --audit-level=high` frontend, Semgrep `.semgrep/security.yml` backend) and nightly; a `high` finding blocks merge.
|
||||||
|
|
||||||
|
## 3. Code Quality Rules
|
||||||
|
|
||||||
|
1. All new behavior is driven by a failing test written before the implementation (Red → Green → Refactor); a passing-on-first-run test proves nothing and is rejected.
|
||||||
|
2. KISS beats DRY — no premature abstraction; an abstraction is introduced only on the third real caller.
|
||||||
|
3. Each commit does exactly one logical thing and references its Gitea issue (`Closes #n` / `Refs #n`) on the last line of the body.
|
||||||
|
4. No backwards-compatibility shims are added for code that has no callers.
|
||||||
|
5. Every entity/DTO field the backend always populates carries `@Schema(requiredMode = REQUIRED)`, and `npm run generate:api` is run after any backend model or endpoint change.
|
||||||
|
6. A new `ErrorCode` is added in all four places at once: `ErrorCode.java`, `frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, and `messages/{de,en,es}.json`.
|
||||||
|
7. Dates built from an ISO date string append `T12:00:00` to avoid UTC off-by-one.
|
||||||
|
8. `npm run lint` (Prettier + ESLint, including the domain boundary rule) passes before every commit.
|
||||||
|
|
||||||
|
## 4. Do-Not-Touch List
|
||||||
|
|
||||||
|
1. Do not edit generated artifacts: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
|
||||||
|
2. Do not edit an `Accepted` ADR — supersede it with a new, higher-numbered ADR.
|
||||||
|
3. Do not upgrade `actions/upload-artifact` / `download-artifact` past `@v3` (Gitea act_runner lacks the v4 protocol — [ADR-014](../docs/adr/014-upload-artifact-v3-pin.md)).
|
||||||
|
4. Do not remove or weaken a CI guard step (banned-pattern greps, self-tested regexes) without an ADR recording why.
|
||||||
|
5. Do not commit to `main` directly — all work flows through a branch and a PR.
|
||||||
|
6. Do not edit a Flyway migration that has shipped; add a new forward-only migration instead.
|
||||||
|
7. Do not commit the worktree copy directories (`familienarchiv-*`, `.worktrees/`) or `data/`.
|
||||||
|
|
||||||
|
## 5. Dependency Policy
|
||||||
|
|
||||||
|
1. A new runtime dependency (backend `pom.xml` or frontend `dependencies`) requires an ADR in `Accepted` status before it is merged.
|
||||||
|
2. A new dependency must be version-pinned in the manifest, and any exact pin (no caret) carries a comment stating why it cannot float (see the `@vitest/browser-playwright` pin).
|
||||||
|
3. Renovate manages dependency-update PRs; a major-version bump is treated as a feature requiring its own spec and review, not an auto-merge.
|
||||||
|
4. A dependency with an unresolved `high`+ advisory is not merged; it is pinned to a safe version or replaced.
|
||||||
|
|
||||||
|
## 6. Sync Impact
|
||||||
|
|
||||||
|
When this constitution changes, the author MUST, in the same PR:
|
||||||
|
|
||||||
|
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
|
||||||
|
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
|
||||||
|
3. Update any `.specify/templates/*` section that quotes a changed rule.
|
||||||
|
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.
|
||||||
|
5. Announce the version bump in the PR description so reviewers re-read the constitution before approving.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-001 (feature-local) — Avatars reuse the archive bucket under an `avatars/` prefix
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Issue:** #<example> (profile picture upload)
|
||||||
|
|
||||||
|
> **Feature-local ADR.** This decision is scoped to the avatar feature and lives with its
|
||||||
|
> spec. A decision with project-wide reach is promoted to the permanent archive at
|
||||||
|
> `docs/adr/` with the next free number. (For the worked example, it stays local.)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Avatars are small binary objects keyed per user. The project already runs MinIO with a
|
||||||
|
single archive bucket and a `FileService` abstraction used by document uploads. We must
|
||||||
|
decide where avatar bytes live without adding operational surface that the self-hosted
|
||||||
|
Compose deployment has to learn about.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Store each avatar in the **existing archive bucket** under the deterministic key
|
||||||
|
`avatars/{userId}`, written and read through the existing `FileService`. No new bucket, no
|
||||||
|
new env var, no new Compose service or bucket-bootstrap step.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Option | Pros | Cons | Reason rejected |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Reuse archive bucket, `avatars/` prefix | No infra change; reuses `FileService`; idempotent overwrite | Mixes avatars with documents in one bucket | **Chosen** — least operational cost; prefix keeps them logically separate |
|
||||||
|
| Dedicated `avatars` bucket | Clean separation; independent lifecycle/policy | New bucket + bootstrap step + env var + Compose idempotency test | Operational overhead not justified for small, low-value objects |
|
||||||
|
| Store bytes in PostgreSQL (`bytea`) | One datastore; transactional with the row | Bloats the DB and backups; streaming images via JPA is awkward | Wrong tool; MinIO already exists for blobs |
|
||||||
|
| External CDN / object store | Offloads bandwidth | New third-party dependency + secret + ADR; conflicts with self-hosted goal | Contradicts the self-hosted infrastructure stance |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- No deployment change ships with this feature — only a Flyway column and code.
|
||||||
|
- Avatars and documents share a bucket; any future per-object lifecycle policy must filter
|
||||||
|
by the `avatars/` prefix.
|
||||||
|
- The deterministic key (`avatars/{userId}`, no random suffix) makes replace an overwrite,
|
||||||
|
so there is no orphan-cleanup obligation (REQ-001).
|
||||||
|
- If avatars later need independent retention or a public CDN, this ADR is superseded by a
|
||||||
|
project-wide ADR in `docs/adr/`.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [`./spec.md`](./spec.md), [`./design.md`](./design.md)
|
||||||
|
- [constitution §5 Dependency Policy](../../constitution.md#5-dependency-policy)
|
||||||
140
.specify/features/_example/api-contract.yaml
Normal file
140
.specify/features/_example/api-contract.yaml
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Familienarchiv API — Profile picture upload
|
||||||
|
version: 0.0.1-SNAPSHOT
|
||||||
|
description: >
|
||||||
|
Design-time contract for the avatar feature (.specify/features/_example).
|
||||||
|
Source of truth once shipped is the generated /v3/api-docs.
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
description: Local backend (dev profile)
|
||||||
|
- url: https://archiv.raddatz.cloud
|
||||||
|
description: Production (behind Caddy)
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
cookieAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: SESSION
|
||||||
|
schemas:
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
example: AVATAR_TOO_LARGE
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
UserProfileView:
|
||||||
|
type: object
|
||||||
|
required: [id, displayName]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
avatarUrl:
|
||||||
|
type: [string, "null"]
|
||||||
|
description: Authenticated proxy path (/api/users/{id}/avatar) when an avatar exists, else null.
|
||||||
|
example: /api/users/3f1c.../avatar
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
paths:
|
||||||
|
/api/users/me/avatar:
|
||||||
|
post:
|
||||||
|
summary: Upload or replace the current user's avatar
|
||||||
|
tags: [Users]
|
||||||
|
operationId: uploadMyAvatar
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file]
|
||||||
|
properties:
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
description: PNG or JPEG, max 2 MB.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Avatar stored; updated profile returned.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/UserProfileView' }
|
||||||
|
'400':
|
||||||
|
description: Unsupported type (UNSUPPORTED_FILE_TYPE) or too large (AVATAR_TOO_LARGE).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||||
|
'401':
|
||||||
|
description: Unauthenticated (UNAUTHORIZED).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||||
|
delete:
|
||||||
|
summary: Remove the current user's avatar
|
||||||
|
tags: [Users]
|
||||||
|
operationId: deleteMyAvatar
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Avatar removed; profile returned with avatarUrl null.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/UserProfileView' }
|
||||||
|
'401':
|
||||||
|
description: Unauthenticated (UNAUTHORIZED).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||||
|
/api/users/{id}/avatar:
|
||||||
|
get:
|
||||||
|
summary: Stream a user's avatar image (authenticated proxy)
|
||||||
|
tags: [Users]
|
||||||
|
operationId: getUserAvatar
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Image bytes.
|
||||||
|
content:
|
||||||
|
image/png: { schema: { type: string, format: binary } }
|
||||||
|
image/jpeg: { schema: { type: string, format: binary } }
|
||||||
|
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||||
|
'404': { description: User has no avatar, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||||
|
delete:
|
||||||
|
summary: Remove another user's avatar (admin only)
|
||||||
|
tags: [Users]
|
||||||
|
operationId: deleteUserAvatar
|
||||||
|
description: Requires Permission.ADMIN_USER (enforced by @RequirePermission on the controller).
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Avatar removed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/UserProfileView' }
|
||||||
|
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||||
|
'403':
|
||||||
|
description: Caller lacks ADMIN_USER (FORBIDDEN).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||||
76
.specify/features/_example/checklist-results.md
Normal file
76
.specify/features/_example/checklist-results.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Persona Review Results — Profile picture upload
|
||||||
|
|
||||||
|
> Captured from the six persona spec reviews (the comments that, in a real feature, are
|
||||||
|
> posted on the Gitea issue). This is the worked example of what a completed review round
|
||||||
|
> looks like. All personas APPROVE; the two findings raised were folded into the spec
|
||||||
|
> before approval.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Persona | Verdict | Blocking FAILs | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Requirements Engineer | APPROVE | none | — |
|
||||||
|
| Developer | APPROVE | none | — |
|
||||||
|
| Security | APPROVE | none (2 resolved) | See F-SEC-1, F-SEC-2 |
|
||||||
|
| DevOps | APPROVE | none | — |
|
||||||
|
| UI/UX | APPROVE | none (1 resolved) | See F-UX-1 |
|
||||||
|
| Architect | APPROVE | none (1 resolved) | See F-ARCH-1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ### Security — Spec Review
|
||||||
|
|
||||||
|
| # | Item | Status | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
|
||||||
|
| 2 | Each mutating endpoint names least-privilege `Permission` | PASS | `me` = authenticated; `{id}` = ADMIN_USER |
|
||||||
|
| 3 | Audit fields server-set, forbidden in body | PASS | `avatarObjectKey` server-set (design.md) |
|
||||||
|
| 4 | IDOR surfaces addressed | PASS | `/{id}` gated by ADMIN_USER + ownership |
|
||||||
|
| 5 | Untrusted content rendered safely | PASS | image bytes via proxy + `nosniff` |
|
||||||
|
| 6 | Upload: type allow-list + size + bytes | PASS | REQ-007 (PNG/JPEG), REQ-008 (2 MB) |
|
||||||
|
| 7 | No entity internals leaked | PASS | `UserProfileView`, not `AppUser` |
|
||||||
|
| 8 | Conflicts → 409 not raw 500 | N/A | no optimistic-lock surface here |
|
||||||
|
| 9 | threat-model.md present & STRIDE-complete | PASS | [threat-model.md](./threat-model.md) |
|
||||||
|
| 10 | ASTRIDE if AI tool used | N/A | no AI agent |
|
||||||
|
| 11 | Secrets from env only | PASS | none introduced |
|
||||||
|
| 12 | Logs PII-free | PASS | user UUID only |
|
||||||
|
| 13 | New dependency has ADR + clean audit | N/A | no new dependency |
|
||||||
|
|
||||||
|
**F-SEC-1 (resolved):** initial draft exposed a public S3 URL for `avatarUrl` →
|
||||||
|
information disclosure. Resolved: authenticated proxy `GET /api/users/{id}/avatar`.
|
||||||
|
**F-SEC-2 (resolved):** initial draft bound `avatarObjectKey` from the request body →
|
||||||
|
mass-assignment. Resolved: server-set only.
|
||||||
|
**Verdict: APPROVE.**
|
||||||
|
|
||||||
|
## ### UI/UX — Spec Review
|
||||||
|
|
||||||
|
| # | Item | Status | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Every interaction state described | PASS | idle/preview/uploading/error/done (T-10) |
|
||||||
|
| 2 | Strings via Paraglide i18n | PASS | T-8 |
|
||||||
|
| 3 | Reuses design tokens/components | PASS | placeholder uses existing initials pattern |
|
||||||
|
| 4 | Responsive per device split | PASS | control usable on phone + laptop |
|
||||||
|
| 5 | Errors via `getErrorMessage(code)` | PASS | UNSUPPORTED_FILE_TYPE / AVATAR_TOO_LARGE |
|
||||||
|
| 6 | Keyboard + screen-reader | PASS | labelled file input, alt text on image |
|
||||||
|
| 7 | Acceptance criteria measurable | PASS | sizes, status codes |
|
||||||
|
| 8 | E2E scenario per journey | PASS | T-12 |
|
||||||
|
| 9 | Confirmation for destructive action | PASS | remove asks to confirm |
|
||||||
|
| 10 | Safe rendering + image dims | PASS | fixed dims avoid layout shift |
|
||||||
|
| 11 | Live routes verified | PASS | `/profile`, `/users/[id]` exist |
|
||||||
|
| 12 | Token theming respected | PASS | semantic tokens |
|
||||||
|
|
||||||
|
**F-UX-1 (resolved):** no loading state in first draft → spinner during upload added (REQ-... covered by state set in T-10).
|
||||||
|
**Verdict: APPROVE.**
|
||||||
|
|
||||||
|
## ### Architect — Spec Review
|
||||||
|
|
||||||
|
Key items PASS. **F-ARCH-1 (resolved):** bucket choice was undocumented → captured in
|
||||||
|
[adr-001-avatars-reuse-archive-bucket.md](./adr-001-avatars-reuse-archive-bucket.md). No new
|
||||||
|
domain, no boundary crossing, Person/AppUser separation intact. **Verdict: APPROVE.**
|
||||||
|
|
||||||
|
## ### Requirements Engineer / Developer / DevOps — Spec Review
|
||||||
|
|
||||||
|
All checklist items PASS (see each persona's checklist in `.specify/personas/`). RE: 9 REQ
|
||||||
|
ids, all EARS-formed, every limit has an `If`. Developer: reuses `FileService`/`UserService`,
|
||||||
|
`AVATAR_TOO_LARGE` four-site update is T-1. DevOps: V78 forward-only + rollback note, no new
|
||||||
|
bucket/env var, idempotent overwrite. **All three: APPROVE.**
|
||||||
63
.specify/features/_example/design.md
Normal file
63
.specify/features/_example/design.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Design — Profile picture upload
|
||||||
|
|
||||||
|
> Companion to [`./spec.md`](./spec.md). The spec says *what*; this says *how*, and records
|
||||||
|
> the alternatives weighed for the non-obvious choices.
|
||||||
|
|
||||||
|
## Component overview
|
||||||
|
|
||||||
|
```
|
||||||
|
ProfileSettings.svelte ──► +page.server.ts (form action)
|
||||||
|
(preview, validate) │ POST /api/users/me/avatar (multipart)
|
||||||
|
▼
|
||||||
|
UserAvatarController ── @RequirePermission(authenticated)
|
||||||
|
│ ownership/admin check for /{id}
|
||||||
|
▼
|
||||||
|
UserService.setAvatar(userId, MultipartFile)
|
||||||
|
│ validate type+size → ErrorCode
|
||||||
|
├──► FileService.put("avatars/{userId}", bytes) (MinIO)
|
||||||
|
└──► userRepository.save(user.avatarObjectKey=key)
|
||||||
|
▼
|
||||||
|
UserProfileView { …, avatarUrl }
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads: `GET /api/users/{id}/avatar` streams the object through the authenticated API
|
||||||
|
(`FileService.get`), so no public S3 URL is ever exposed. `avatarUrl` in the view is simply
|
||||||
|
`/api/users/{id}/avatar` when a key exists, else `null`.
|
||||||
|
|
||||||
|
## Key decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Where avatars live | Existing archive bucket, `avatars/{userId}` prefix | No new bucket/env var/Compose change — see [ADR-001](./adr-001-avatars-reuse-archive-bucket.md). |
|
||||||
|
| URL exposure | Authenticated proxy endpoint, not a signed/public URL | Same auth surface as the rest of the API; no key leakage (Information disclosure). |
|
||||||
|
| Object key | Deterministic `avatars/{userId}` (no random suffix) | A new upload overwrites the old object — no orphan-cleanup job needed (REQ-001). |
|
||||||
|
| `avatarObjectKey` binding | Server-set in `UserService` only | Never bound from request body — prevents pointing a user's avatar at an arbitrary object (Tampering / CWE-639). |
|
||||||
|
| Validation site | `UserService`, boundary-only | Type + size checked once, at the service boundary, mapped to `ErrorCode` (constitution §2.3). |
|
||||||
|
|
||||||
|
## Layering & conventions
|
||||||
|
|
||||||
|
- Controller → `UserService` only; `UserService` owns `userRepository` and calls
|
||||||
|
`FileService` (its public API), never another domain's repository. (constitution §1.2–1.3)
|
||||||
|
- New `ErrorCode.AVATAR_TOO_LARGE` requires the four-site update (see `tasks.md` T-1).
|
||||||
|
- `UserProfileView.avatarUrl` is `String` (nullable) with `@Schema` describing the proxy
|
||||||
|
path; not marked `requiredMode = REQUIRED` because it is legitimately null (REQ-004).
|
||||||
|
- After backend changes: `npm run generate:api` regenerates `avatarUrl` into the TS types.
|
||||||
|
|
||||||
|
## Non-functional notes
|
||||||
|
|
||||||
|
- Size cap (2 MB, REQ-008) is enforced **before** the object touches MinIO — the multipart
|
||||||
|
is read into a bounded buffer; Spring's `spring.servlet.multipart.max-file-size` is set to
|
||||||
|
a matching ceiling so an oversized body is rejected at the container edge too.
|
||||||
|
- No N+1 risk: the profile view derives `avatarUrl` from the already-loaded `avatarObjectKey`
|
||||||
|
column; no extra query, no S3 round-trip on list/read paths.
|
||||||
|
- The proxy `GET` streams bytes (no full-buffer) and sets a short `Cache-Control` so an
|
||||||
|
updated avatar propagates quickly.
|
||||||
|
|
||||||
|
## Test strategy (maps to tasks.md)
|
||||||
|
|
||||||
|
| Level | What | Tooling |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit | `UserService.setAvatar` validation + storage interactions | JUnit + Mockito (mock `FileService`) |
|
||||||
|
| Slice | controller auth, status codes, error codes | `@WebMvcTest` |
|
||||||
|
| E2E | upload → preview → confirm → avatar visible; remove → initials | Playwright |
|
||||||
|
| Component | initials placeholder when `avatarUrl` is null | `vitest-browser-svelte` (`*.svelte.spec.ts`) |
|
||||||
118
.specify/features/_example/spec.md
Normal file
118
.specify/features/_example/spec.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# As a user I want to upload a profile picture so other family members recognise me
|
||||||
|
|
||||||
|
> **This is the canonical worked example for SDD in this repo.** It is fictional but
|
||||||
|
> realistic, chosen because no real avatar feature exists in the codebase. Use it as the
|
||||||
|
> reference shape for a real `spec.md`. Every section is filled — no placeholders.
|
||||||
|
|
||||||
|
## Context & Why
|
||||||
|
|
||||||
|
Readers and transcribers collaborate in threads and on document comments, but every user is
|
||||||
|
currently represented by initials only. Letting a user upload a small profile picture makes
|
||||||
|
the activity feed, comments, and the public user profile page (`/users/[id]`) more personal
|
||||||
|
and easier to scan — directly serving the family-archive product goal of feeling like a
|
||||||
|
shared family space, not a database.
|
||||||
|
|
||||||
|
Constitution principles this feature depends on:
|
||||||
|
- [§2 Security Defaults](../../constitution.md#2-security-defaults) — upload validation, permission gating, no PII in logs.
|
||||||
|
- [§1.3 services own their repository](../../constitution.md#1-architecture-principles) — avatar storage goes through `UserService` + `FileService`, not a controller.
|
||||||
|
- [§3.6 ErrorCode four-site rule](../../constitution.md#3-code-quality-rules) — introduces `AVATAR_TOO_LARGE`.
|
||||||
|
|
||||||
|
Related: builds on the existing `FileService` (MinIO) used by `Document` uploads.
|
||||||
|
|
||||||
|
## User Journey
|
||||||
|
|
||||||
|
A logged-in user opens their profile settings (`/profile`), clicks "Profilbild ändern",
|
||||||
|
selects a PNG or JPEG from their device, sees an instant preview, and confirms. The picture
|
||||||
|
replaces their initials everywhere their name appears. They can later remove it and fall
|
||||||
|
back to initials. An admin (with `ADMIN_USER`) can remove an inappropriate picture from
|
||||||
|
another user's account from the admin user view.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **REQ-001** (Ubiquitous) — The user service shall store each profile picture as a single object in the existing archive bucket under the key `avatars/{userId}`, overwriting any previous object for that user.
|
||||||
|
- **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar` with a valid image, the user service shall store the image, set the user's `avatarObjectKey`, and return the updated profile view including a non-null `avatarUrl`.
|
||||||
|
- **REQ-003** (Event-driven) — When an authenticated user sends `DELETE /api/users/me/avatar`, the user service shall delete the stored object, clear `avatarObjectKey`, and return the profile view with `avatarUrl = null`.
|
||||||
|
- **REQ-004** (State-driven) — While a user has no stored avatar, the profile view for that user shall return `avatarUrl = null` and the frontend shall render the initials placeholder.
|
||||||
|
- **REQ-005** (Optional-feature) — Where the caller holds `Permission.ADMIN_USER`, the user service shall allow `DELETE /api/users/{id}/avatar` to remove another user's avatar.
|
||||||
|
- **REQ-006** (Unwanted-behavior) — If the request to any avatar endpoint is unauthenticated, then the system shall return `401` with `ErrorCode.UNAUTHORIZED` and store or delete nothing.
|
||||||
|
- **REQ-007** (Unwanted-behavior) — If the uploaded file's content type is not `image/png` or `image/jpeg`, then the user service shall return `400 ErrorCode.UNSUPPORTED_FILE_TYPE` and store nothing.
|
||||||
|
- **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
|
||||||
|
- **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets another user's avatar via `/api/users/{id}/avatar`, then the system shall return `403 ErrorCode.FORBIDDEN` and modify nothing.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- **REQ-001** — After a successful upload, exactly one object exists at `avatars/{userId}`; a second upload leaves exactly one object (no orphan), verified by a `FileService` interaction test.
|
||||||
|
- **REQ-002** — `POST /api/users/me/avatar` with a 100 KB PNG returns `200` and a body whose `avatarUrl` is a non-null string; the persisted `app_users.avatar_object_key` equals `avatars/{userId}`.
|
||||||
|
- **REQ-003** — `DELETE /api/users/me/avatar` returns `200`, the object is gone, and the response `avatarUrl` is `null`.
|
||||||
|
- **REQ-004** — `GET` profile view for a user with `avatar_object_key IS NULL` returns `avatarUrl: null`; the rendered component shows a 2-letter initials placeholder (Playwright).
|
||||||
|
- **REQ-005** — An `ADMIN_USER` caller deleting another user's avatar returns `200`; the target's `avatar_object_key` becomes `NULL`.
|
||||||
|
- **REQ-006** — An unauthenticated `POST`/`DELETE` returns `401`; bucket object count is unchanged.
|
||||||
|
- **REQ-007** — A `text/plain` or `application/pdf` upload returns `400 UNSUPPORTED_FILE_TYPE`; bucket object count is unchanged.
|
||||||
|
- **REQ-008** — A 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count is unchanged.
|
||||||
|
- **REQ-009** — A non-admin caller targeting another user's id returns `403 FORBIDDEN`; the target's `avatar_object_key` is unchanged.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Image cropping, resizing, or transformation — the client sends a final image; the server stores it verbatim within the size limit.
|
||||||
|
- Avatars for historical `Person` entities — this feature is for `AppUser` accounts only (Person ≠ AppUser).
|
||||||
|
- Gravatar / external avatar providers.
|
||||||
|
- Animated formats (GIF/WebP) — PNG and JPEG only in v1.
|
||||||
|
|
||||||
|
## API / Contract Stub
|
||||||
|
|
||||||
|
See [`./api-contract.yaml`](./api-contract.yaml). Endpoints:
|
||||||
|
`POST /api/users/me/avatar` (multipart), `DELETE /api/users/me/avatar`,
|
||||||
|
`DELETE /api/users/{id}/avatar` (ADMIN_USER). The profile view gains an optional
|
||||||
|
`avatarUrl: string | null`. All mutating endpoints carry `@RequirePermission` — `me`
|
||||||
|
endpoints require an authenticated session; the `{id}` delete requires `ADMIN_USER`.
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
- Add nullable `avatar_object_key VARCHAR(512)` to `app_users`.
|
||||||
|
- Flyway `V78__add_app_user_avatar_object_key.sql` (next free number — verify against
|
||||||
|
`backend/src/main/resources/db/migration/` on disk before committing).
|
||||||
|
- **Rollback:** forward-only. Reverse manually with `ALTER TABLE app_users DROP COLUMN avatar_object_key;`. The MinIO `avatars/` objects are orphaned but harmless on rollback and can be pruned with `mc rm --recursive`.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
STRIDE categories touched: **Tampering** (mass-assignment of `avatarObjectKey` if bound from
|
||||||
|
body), **Elevation of privilege** (a non-admin modifying another user's avatar — REQ-009),
|
||||||
|
**Denial of service** (oversized upload — REQ-008), **Information disclosure** (avatar URL
|
||||||
|
must not expose a signed key that bypasses auth). No AI agent involved, so ASTRIDE does not
|
||||||
|
apply. Full analysis: [`./threat-model.md`](./threat-model.md).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
> All resolved before implementation.
|
||||||
|
|
||||||
|
- [x] Public or signed avatar URL? — **Resolved:** served through an authenticated
|
||||||
|
`GET /api/users/{id}/avatar` proxy (same auth as the rest of the API), not a public S3 URL.
|
||||||
|
- [x] New bucket or reuse archive bucket? — **Resolved:** reuse the archive bucket under an
|
||||||
|
`avatars/` prefix; see [`./adr-001-avatars-reuse-archive-bucket.md`](./adr-001-avatars-reuse-archive-bucket.md).
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| REQ-001 | T-3 | `UserServiceAvatarTest#storesUnderUserKey`, `…#replaceLeavesNoOrphan` | Planned |
|
||||||
|
| REQ-002 | T-4 | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
|
||||||
|
| REQ-003 | T-5 | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
|
||||||
|
| REQ-004 | T-7 | `avatar-placeholder.svelte.spec.ts` | Planned |
|
||||||
|
| REQ-005 | T-6 | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
|
||||||
|
| REQ-006 | T-2 | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
|
||||||
|
| REQ-007 | T-2 | `UserAvatarControllerTest#rejectsNonImage` | Planned |
|
||||||
|
| REQ-008 | T-2 | `UserAvatarControllerTest#rejectsOversize` | Planned |
|
||||||
|
| REQ-009 | T-6 | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
|
||||||
|
|
||||||
|
Mirrored in [`.specify/rtm.md`](../../rtm.md).
|
||||||
|
|
||||||
|
## Persona Review Results
|
||||||
|
|
||||||
|
| Persona | Status | Key Findings | Resolved |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Requirements Engineer | APPROVE | All 9 REQ ids EARS-formed; every limit has an `If` clause. | — |
|
||||||
|
| Developer | APPROVE | Reuses `FileService`/`UserService`; `AVATAR_TOO_LARGE` four-site update listed (T-1). | — |
|
||||||
|
| Security | APPROVE | REQ-006/008/009 cover authn/DoS/EoP; `avatarObjectKey` server-set only (see threat model T-1). | Yes |
|
||||||
|
| DevOps | APPROVE | V78 forward-only with rollback note; no new bucket/env var. | — |
|
||||||
|
| UI/UX | APPROVE | Placeholder + loading/error states specified; strings via i18n (T-8). | — |
|
||||||
|
| Architect | APPROVE | Bucket-reuse decision captured in ADR-001; no new domain, no boundary crossing. | Yes |
|
||||||
47
.specify/features/_example/tasks.md
Normal file
47
.specify/features/_example/tasks.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Tasks — Profile picture upload
|
||||||
|
|
||||||
|
> Red/Green TDD order: each implementation task is preceded by the failing test that
|
||||||
|
> requires it. Task IDs are referenced from `spec.md` → Traceability and from `.specify/rtm.md`.
|
||||||
|
> Check off as work lands; reference the issue in each commit (`Refs #<n>`).
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- [ ] **T-1** Add `ErrorCode.AVATAR_TOO_LARGE` in all four sites at once: `ErrorCode.java`,
|
||||||
|
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`.
|
||||||
|
*(No new behavior yet — enables REQ-008's error.)* → covers REQ-008 (error plumbing)
|
||||||
|
- [ ] **T-2** `@WebMvcTest` `UserAvatarControllerTest`: write failing slice tests —
|
||||||
|
`unauthenticatedReturns401`, `rejectsNonImage` (400 UNSUPPORTED_FILE_TYPE),
|
||||||
|
`rejectsOversize` (400 AVATAR_TOO_LARGE). Then implement `UserAvatarController` +
|
||||||
|
`@RequirePermission` to green. → REQ-006, REQ-007, REQ-008
|
||||||
|
- [ ] **T-3** Unit `UserServiceAvatarTest`: failing tests `storesUnderUserKey`,
|
||||||
|
`replaceLeavesNoOrphan`, validation maps to `DomainException`. Then implement
|
||||||
|
`UserService.setAvatar`/`removeAvatar` (mock `FileService`) to green. → REQ-001, REQ-002, REQ-003
|
||||||
|
- [ ] **T-4** Flyway `V78__add_app_user_avatar_object_key.sql` (verify next free number on
|
||||||
|
disk) adding nullable `avatar_object_key VARCHAR(512)`; add the column + `@Schema` to
|
||||||
|
`AppUser` / `UserProfileView` (`avatarUrl` derived). Test: repository round-trip. → REQ-002
|
||||||
|
- [ ] **T-5** `deleteMyAvatar` controller test + impl (clears key, deletes object, returns
|
||||||
|
`avatarUrl: null`). → REQ-003
|
||||||
|
- [ ] **T-6** Admin path: failing tests `adminDeletesOthersAvatar` (200),
|
||||||
|
`nonAdminForbiddenOnOthers` (403). Implement ownership/`ADMIN_USER` check to green. → REQ-005, REQ-009
|
||||||
|
- [ ] **T-7** Authenticated proxy `getUserAvatar` streaming endpoint + `Content-Type` +
|
||||||
|
`X-Content-Type-Options: nosniff`; test 200 bytes / 404 when no avatar. → REQ-004 (view side)
|
||||||
|
- [ ] **T-A** Run `npm run generate:api` after T-4/T-7 so `avatarUrl` lands in `api.ts`.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- [ ] **T-8** i18n keys for the new strings in `messages/{de,en,es}.json` (button labels,
|
||||||
|
validation errors mapped via `getErrorMessage`). → REQ-007, REQ-008 (UX)
|
||||||
|
- [ ] **T-9** Component test `avatar-placeholder.svelte.spec.ts`: failing test asserting
|
||||||
|
initials render when `avatarUrl` is null; implement the placeholder. → REQ-004
|
||||||
|
- [ ] **T-10** `/profile` upload control: file picker, client-side type/size pre-check,
|
||||||
|
instant preview, confirm/remove. States: idle/preview/uploading/error/done. → REQ-002, REQ-003
|
||||||
|
- [ ] **T-11** Render avatar where names appear (comments, activity feed, `/users/[id]`),
|
||||||
|
falling back to the placeholder. → REQ-004
|
||||||
|
- [ ] **T-12** E2E `avatar.spec.ts`: upload → preview → confirm → avatar visible; remove →
|
||||||
|
initials return. → REQ-002, REQ-003, REQ-004
|
||||||
|
|
||||||
|
## Cross-cutting
|
||||||
|
|
||||||
|
- [ ] **T-13** Set `spring.servlet.multipart.max-file-size` to a 2 MB-matching ceiling so an
|
||||||
|
oversized body is rejected at the container edge (defense in depth for REQ-008).
|
||||||
|
- [ ] **T-14** Update `.specify/rtm.md` Status column to `Done` per REQ as each test goes green.
|
||||||
45
.specify/features/_example/threat-model.md
Normal file
45
.specify/features/_example/threat-model.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Threat Model — Profile picture upload
|
||||||
|
|
||||||
|
**Feature spec:** [./spec.md](./spec.md)
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Author:** Security persona (worked example)
|
||||||
|
|
||||||
|
## Data Flow Diagram (text)
|
||||||
|
|
||||||
|
**Actors**
|
||||||
|
- Anonymous visitor (unauthenticated)
|
||||||
|
- Authenticated user (uploads their own avatar)
|
||||||
|
- Admin (`Permission.ADMIN_USER` — may remove others' avatars)
|
||||||
|
|
||||||
|
**Trust boundaries**
|
||||||
|
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
|
||||||
|
- TB-2: Caddy ⇄ Backend `:8080` (DMZ ⇄ app)
|
||||||
|
- TB-3: Backend ⇄ MinIO + PostgreSQL (app ⇄ data plane)
|
||||||
|
|
||||||
|
**Data flows**
|
||||||
|
- F-1: Browser → [TB-1,TB-2] → `UserAvatarController` : multipart image
|
||||||
|
- F-2: `UserService` → [TB-3] → MinIO : object at `avatars/{userId}`
|
||||||
|
- F-3: `UserService` → [TB-3] → PostgreSQL : `app_users.avatar_object_key`
|
||||||
|
- F-4: Browser → [TB-1,TB-2,TB-3] → MinIO (via proxy GET) : image bytes
|
||||||
|
|
||||||
|
## STRIDE
|
||||||
|
|
||||||
|
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **S**poofing | F-1 | Unauthenticated caller uploads/deletes an avatar | Session auth required; `@RequirePermission` (REQ-006) | Low × Med | Mitigated |
|
||||||
|
| **T**ampering | F-3 | Caller sets `avatarObjectKey` via request body to point at an arbitrary stored object | `avatarObjectKey` is server-set in `UserService` only, never bound from body (CWE-639) | Med × High | Mitigated |
|
||||||
|
| **R**epudiation | F-2/F-3 | No record of who changed an avatar | Standard request logging by user UUID (no PII); admin deletions auditable via existing logs | Low × Low | Accepted |
|
||||||
|
| **I**nformation disclosure | F-4 | A public/signed S3 URL would let anyone fetch any avatar without auth | Avatars served only through the authenticated proxy `GET /api/users/{id}/avatar`; no public URL | Med × Med | Mitigated |
|
||||||
|
| **I**nformation disclosure | F-1 | Malicious file (polyglot) served back with a sniffed content type → stored XSS | Store with a fixed `image/png`/`image/jpeg` content type; proxy sets `Content-Type` + `X-Content-Type-Options: nosniff`; only PNG/JPEG accepted (REQ-007) | Low × High | Mitigated |
|
||||||
|
| **D**enial of service | F-1/F-2 | Oversized or many uploads exhaust storage/memory | 2 MB cap enforced before MinIO write + `multipart.max-file-size` ceiling (REQ-008); deterministic key means one object per user | Med × Med | Mitigated |
|
||||||
|
| **E**levation of privilege | F-1 | Non-admin removes/replaces another user's avatar via `/{id}` | Ownership check; `ADMIN_USER` required for `/{id}` (REQ-005/REQ-009, 403) | Low × Med | Mitigated |
|
||||||
|
|
||||||
|
## ASTRIDE
|
||||||
|
|
||||||
|
Not applicable — this feature invokes no AI agent, model, or tool.
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
|
||||||
|
- **Repudiation (Accepted):** avatar changes are not written to a dedicated audit table.
|
||||||
|
Accepted because the asset is low-value (a self-chosen picture) and request logs already
|
||||||
|
attribute the action to a user UUID. Revisit if avatars ever become trust signals.
|
||||||
40
.specify/personas/architect.md
Normal file
40
.specify/personas/architect.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Persona — Architect (spec review)
|
||||||
|
|
||||||
|
> Concise spec-review checklist. Full character persona:
|
||||||
|
> [`.claude/personas/architect.md`](../../.claude/personas/architect.md). This file gates a
|
||||||
|
> `spec.md` and its `design.md`/ADRs for systemic fit and long-term consequence.
|
||||||
|
|
||||||
|
## Role summary
|
||||||
|
|
||||||
|
I check that a feature fits the system's domain boundaries and decision history, and that
|
||||||
|
any irreversible choice it makes is captured in an ADR before code is written. I block specs
|
||||||
|
that quietly contradict an Accepted ADR, blur a domain boundary, or bake in a decision with
|
||||||
|
no recorded rationale.
|
||||||
|
|
||||||
|
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||||
|
|
||||||
|
1. Does the feature respect the package-by-domain structure — new code in the right domain, no logic smeared across layer packages?
|
||||||
|
2. Does it honor the layering rule and the frontend boundary rule, or does it justify and record any new cross-domain edge?
|
||||||
|
3. Does any irreversible or contentious decision (new dependency, new domain, data-model shape, response-as-view vs entity, sync vs async side effect) have an ADR in `Proposed`/`Accepted` status under `docs/adr/`?
|
||||||
|
4. Does the spec contradict any existing Accepted ADR — and if a change is intended, does it **supersede** that ADR rather than silently diverge?
|
||||||
|
5. Is the ADR number the next free one verified against `docs/adr/` on disk?
|
||||||
|
6. Does the design reuse an established pattern (in-transaction views per ADR-036, domain events per ADR-006, DatePrecision sharing per ADR-039/040) instead of a novel mechanism for a solved problem?
|
||||||
|
7. Are domain terms used per [docs/GLOSSARY.md](../../docs/GLOSSARY.md), keeping the ubiquitous language consistent?
|
||||||
|
8. Is the blast radius bounded — does the change avoid forcing edits across unrelated domains, or is the coupling explicitly justified?
|
||||||
|
9. Does the data model choose the right precision/constraint level deliberately (e.g. NOT NULL audit fields, CHECK constraints) rather than by default, and is the choice recorded?
|
||||||
|
10. Does the spec keep `Person`/`AppUser` (and other established separations) distinct?
|
||||||
|
11. Are non-functional consequences (performance of the lazy-fetch path, N+1 risk, index needs) named in `design.md`?
|
||||||
|
12. Does `design.md` list the alternatives considered and why they were rejected, not just the chosen path?
|
||||||
|
|
||||||
|
## EARS patterns to watch for
|
||||||
|
|
||||||
|
- **Ubiquitous** requirements (`The <system> shall <invariant>`) encode architectural invariants — confirm each invariant is enforced at the right layer (DB CHECK, service guard, or type) and not merely asserted in prose.
|
||||||
|
- **Optional-feature** requirements signal a new seam/extension point — verify it does not become an unbounded plugin surface without an ADR.
|
||||||
|
- Watch for requirements that imply a second source of truth for data that already has an owning domain.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
A Gitea comment titled **`### Architect — Spec Review`** with the checklist table
|
||||||
|
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
|
||||||
|
blocking `FAIL` numbers and, for any decision lacking one, the specific ADR that must be
|
||||||
|
written before implementation.
|
||||||
39
.specify/personas/developer.md
Normal file
39
.specify/personas/developer.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Persona — Developer (spec review)
|
||||||
|
|
||||||
|
> Concise spec-review checklist. Full character persona:
|
||||||
|
> [`.claude/personas/developer.md`](../../.claude/personas/developer.md). This file gates a
|
||||||
|
> `spec.md` for implementability against the real codebase.
|
||||||
|
|
||||||
|
## Role summary
|
||||||
|
|
||||||
|
I check that a spec can actually be built in *this* codebase without fighting its
|
||||||
|
architecture: that it reuses existing services, layers, and error machinery, and that its
|
||||||
|
requirements decompose cleanly into red/green TDD tasks. I block specs that invent parallel
|
||||||
|
structures or hand-wave the hard integration points.
|
||||||
|
|
||||||
|
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||||
|
|
||||||
|
1. Does the spec reference existing service interfaces (e.g. `DocumentService`, `FileService`, `UserService`) rather than inventing new ones inconsistent with the current layer structure?
|
||||||
|
2. Does it respect the layering rule — no requirement implies a controller touching a repository or a service reaching into another domain's repository?
|
||||||
|
3. If it adds a backend domain, does it commit to adding the package to `ArchitectureTest`'s allow-lists?
|
||||||
|
4. Are new error conditions expressed as named `ErrorCode`s, with the four-site update (`ErrorCode.java`, `errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`) called out as tasks?
|
||||||
|
5. Does every entity/DTO field the spec adds get `@Schema(requiredMode = REQUIRED)` where always-populated, and is `npm run generate:api` listed as a task after backend changes?
|
||||||
|
6. Are frontend changes inside the correct `$lib/<domain>/` boundary, with any cross-domain import either pre-allowed in `eslint.config.js` or flagged for an explicit allow-entry?
|
||||||
|
7. Does each `REQ-NNN` imply a concrete test at the right level (unit / `@WebMvcTest` slice / Playwright E2E per COLLABORATING.md's table) — i.e. is it specified concretely enough to write that test?
|
||||||
|
8. Is lazy-loading handled — does any returned entity with a lazy collection get a view (ADR-036) instead of being serialized raw?
|
||||||
|
9. Does the design avoid premature abstraction (KISS over DRY) — no new base class/util introduced before a third caller exists?
|
||||||
|
10. Are data-model changes expressed as a single forward-only Flyway migration with the next free `V<n>` number verified against disk?
|
||||||
|
11. Does the spec avoid backwards-compat shims for code paths that have no existing callers?
|
||||||
|
12. Are the requirements decomposable into a red/green-ordered task list — each behavior small enough that a failing test can precede its implementation?
|
||||||
|
|
||||||
|
## EARS patterns to watch for
|
||||||
|
|
||||||
|
- **Event-driven** requirements must name the exact endpoint/method so the test target is unambiguous (`When POST /api/users/{id}/avatar receives a valid image, the user service shall …`).
|
||||||
|
- **Unwanted-behavior** requirements are the ones that become `@WebMvcTest` error-path cases — flag any that lack a stated `ErrorCode` and HTTP status.
|
||||||
|
- **Optional-feature** (`Where …`) requirements map to a `@RequirePermission` gate — confirm the permission already exists or is added.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
A Gitea comment titled **`### Developer — Spec Review`** with the checklist table
|
||||||
|
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing the
|
||||||
|
blocking `FAIL` numbers and the single most important integration risk in one sentence.
|
||||||
39
.specify/personas/devops.md
Normal file
39
.specify/personas/devops.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Persona — DevOps (spec review)
|
||||||
|
|
||||||
|
> Concise spec-review checklist. Full character persona:
|
||||||
|
> [`.claude/personas/devops.md`](../../.claude/personas/devops.md). This file gates a
|
||||||
|
> `spec.md` for deployability, migration safety, and CI/observability impact.
|
||||||
|
|
||||||
|
## Role summary
|
||||||
|
|
||||||
|
I check that a feature can ship to the self-hosted Gitea-Actions / Docker-Compose
|
||||||
|
environment without breaking deploys, migrations, or observability. I block specs that add
|
||||||
|
a migration with no rollback story, a new env var nobody documented, or a CI step that the
|
||||||
|
act_runner cannot execute.
|
||||||
|
|
||||||
|
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||||
|
|
||||||
|
1. Does the spec include a rollback strategy for any database migration it introduces (forward-only `V<n>` plus the manual DDL to reverse it, or an explicit "no rollback, forward-fix only" statement)?
|
||||||
|
2. Is the Flyway migration number the next free `V<n>` verified against disk, not copied from a stale issue body?
|
||||||
|
3. Are all new configuration values introduced as documented env vars (added to `.env.example`) and read via env, never hard-coded?
|
||||||
|
4. Does any new CI step avoid `actions/(upload|download)-artifact@v4+` and other features the Gitea `act_runner` does not support?
|
||||||
|
5. If the spec adds a CI guard, is it self-testing (the regex proves it catches the bad form and ignores the good form), matching the existing guard style?
|
||||||
|
6. Does the feature keep the management port (`8081`) / app port (`8080`) separation intact, and not require Caddy to proxy `/actuator/*`?
|
||||||
|
7. Are new dependencies pinned, and does the change keep `npm audit --audit-level=high` and Semgrep green?
|
||||||
|
8. Does a new external service or sidecar come with a healthcheck and a documented Compose entry, and is bucket/bootstrap logic idempotent (re-deploy must not fail)?
|
||||||
|
9. Are new metrics/logs/traces routed through the existing observability stack (Prometheus scrape, Promtail/Loki, Tempo, GlitchTip) rather than a new ad-hoc channel?
|
||||||
|
10. Does logging added by the feature stay PII-free and structured (JSON), consistent with the existing log pipeline?
|
||||||
|
11. Is the feature backwards-compatible across a rolling deploy, or does the spec state the required downtime/ordering (migrate-then-deploy)?
|
||||||
|
12. Does the spec avoid committing secrets, and does any composite-action secret flow follow the unquoted-heredoc env convention (ADR-029)?
|
||||||
|
|
||||||
|
## EARS patterns to watch for
|
||||||
|
|
||||||
|
- **State-driven** (`While a migration is in progress, the system shall …`) and **Unwanted-behavior** (`If the OCR service is unavailable, then the system shall return OCR_SERVICE_UNAVAILABLE`) requirements encode operational resilience — flag mutating/processing features that lack them.
|
||||||
|
- **Optional-feature** (`Where the observability stack is enabled …`) requirements gate optional infra — confirm the feature degrades cleanly when it is off.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
A Gitea comment titled **`### DevOps — Spec Review`** with the checklist table
|
||||||
|
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
|
||||||
|
blocking `FAIL` numbers, with the migration/rollback line called out explicitly when
|
||||||
|
relevant.
|
||||||
43
.specify/personas/requirements-engineer.md
Normal file
43
.specify/personas/requirements-engineer.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Persona — Requirements Engineer (spec review)
|
||||||
|
|
||||||
|
> Concise spec-review checklist. The full character persona (used for issue/PR review via
|
||||||
|
> the `review-issue` / `review-pr` skills) lives at
|
||||||
|
> [`.claude/personas/req_engineer.md`](../../.claude/personas/req_engineer.md). This file is
|
||||||
|
> scoped to one job: gate a `spec.md` before implementation starts.
|
||||||
|
|
||||||
|
## Role summary
|
||||||
|
|
||||||
|
I own requirement quality: every requirement must be atomic, testable, uniquely identified,
|
||||||
|
and written in EARS so an engineer and an AI agent read it the same way. I block specs that
|
||||||
|
are ambiguous, unmeasurable, or untraceable — vague requirements become vague code.
|
||||||
|
|
||||||
|
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||||
|
|
||||||
|
1. Does every requirement have a unique zero-padded `REQ-NNN` ID, scoped to this feature?
|
||||||
|
2. Is every requirement written in one of the five EARS patterns (no free-prose "shall" sentences)?
|
||||||
|
3. Is each requirement atomic — exactly one testable behavior, no "and"-joined clauses hiding two requirements?
|
||||||
|
4. Does every requirement name a concrete system actor (e.g. `the document service`, `the upload form`) rather than a vague "system"?
|
||||||
|
5. Does each `REQ-NNN` have at least one matching, **measurable** acceptance criterion (numbers/limits, not adjectives like "fast" or "user-friendly")?
|
||||||
|
6. Are all five EARS patterns considered, and is each used where appropriate (not every requirement forced into Ubiquitous)?
|
||||||
|
7. Is there an Unwanted-behavior (`If …`) requirement for every error, limit, and rejected input the happy path implies?
|
||||||
|
8. Does the `## Out of Scope` section explicitly fence off the nearest tempting scope creep?
|
||||||
|
9. Are all `## Open Questions` resolved (or explicitly deferred with an owner) — none left as silent blockers?
|
||||||
|
10. Does the spec link the constitution principle(s) it depends on in `## Context & Why`?
|
||||||
|
11. Is every `REQ-NNN` present in `.specify/rtm.md` with a Feature, Test, and Status column filled (even if Status = Planned)?
|
||||||
|
12. Does the spec reuse existing domain vocabulary from [docs/GLOSSARY.md](../../docs/GLOSSARY.md) (e.g. Person vs AppUser, Chronik vs Aktivität) rather than inventing terms?
|
||||||
|
13. Are the User Journey and E2E Scenarios (per COLLABORATING.md) present and consistent with the EARS requirements?
|
||||||
|
|
||||||
|
## EARS patterns to watch for (common violations)
|
||||||
|
|
||||||
|
- **Ubiquitous** — `The <system> shall <behavior>.` Violation: an invariant written as prose with no "shall".
|
||||||
|
- **Event-driven** — `When <trigger>, the <system> shall <behavior>.` Violation: a trigger described but the response left implicit.
|
||||||
|
- **State-driven** — `While <state>, the <system> shall <behavior>.` Violation: a state precondition buried inside an Event-driven clause.
|
||||||
|
- **Optional-feature** — `Where <feature is present>, the <system> shall <behavior>.` Violation: a permission-/flag-gated behavior written as Ubiquitous, so it appears mandatory.
|
||||||
|
- **Unwanted-behavior** — `If <undesired condition>, then the <system> shall <response>.` Violation: missing entirely — the single most common gap. Every limit and rejected input needs one.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
A Gitea comment titled **`### Requirements Engineer — Spec Review`** containing the
|
||||||
|
checklist as a table `| # | Item | Status | Note |` with `PASS` / `FAIL` / `QUESTION` per
|
||||||
|
row, then a short verdict line: `Verdict: APPROVE` or `Verdict: CHANGES REQUESTED` with the
|
||||||
|
blocking `FAIL` numbers listed.
|
||||||
42
.specify/personas/security.md
Normal file
42
.specify/personas/security.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Persona — Security (spec review)
|
||||||
|
|
||||||
|
> Concise spec-review checklist. Full character persona (Nora "NullX" Steiner):
|
||||||
|
> [`.claude/personas/security_expert.md`](../../.claude/personas/security_expert.md). This
|
||||||
|
> file gates a `spec.md` and its `threat-model.md` before implementation.
|
||||||
|
|
||||||
|
## Role summary
|
||||||
|
|
||||||
|
I read every spec adversarially: I assume the requirement will be hit by an unauthenticated
|
||||||
|
attacker, a logged-in user attacking another user's data, and malicious input. I block specs
|
||||||
|
whose mutating endpoints, file handling, or audit trails leave a hole that the happy-path
|
||||||
|
requirements never mention.
|
||||||
|
|
||||||
|
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||||
|
|
||||||
|
1. Are **all** state-mutating endpoints (`POST/PUT/PATCH/DELETE`) covered by an Unwanted-behavior EARS clause for unauthenticated **and** unauthorized access, each naming the `Permission` and the response code?
|
||||||
|
2. Does every mutating endpoint name the `@RequirePermission(Permission.X)` it will carry — and is that permission the least privilege that works?
|
||||||
|
3. Are audit fields (`createdBy`/`updatedBy`) specified as server-set from the session principal, with an explicit requirement forbidding them in the request body (mass-assignment / authorship-forgery, CWE-639)?
|
||||||
|
4. Is every IDOR surface addressed — does fetching/mutating a child resource verify it belongs to the caller's accessible parent (e.g. JourneyItem → Geschichte), with a requirement and a test?
|
||||||
|
5. Is all untrusted text (user input, OCR/import-derived) specified to render via default escaping, never `{@html}` (CWE-79)?
|
||||||
|
6. For file uploads: are content-type allow-list, size limit, and magic-byte/extension validation specified as requirements with concrete numbers and an `ErrorCode`?
|
||||||
|
7. Does the spec avoid leaking entity internals (email, password hash, group graph) in any response — i.e. does it use a view, not a raw `AppUser`/entity?
|
||||||
|
8. Are concurrency conflicts (optimistic locking) specified to surface as `conflict()` (409), never a raw 500 exposing Hibernate internals (CWE-209)?
|
||||||
|
9. Does the `threat-model.md` exist and cover the relevant STRIDE categories for each new data flow and trust boundary?
|
||||||
|
10. If the feature invokes an AI agent/tool (OCR/NLP/LLM), does the threat model cover the ASTRIDE extensions (prompt injection, context poisoning, unsafe tool invocation, reasoning subversion)?
|
||||||
|
11. Are secrets (tokens, DSNs, passwords) sourced only from env vars, with none introduced into the repo, config, or logs?
|
||||||
|
12. Does logging for this feature exclude PII beyond a stable UUID (no names, emails, document/transcription content)?
|
||||||
|
13. Does a new runtime dependency (if any) have an ADR and a clean `npm audit` / Semgrep status?
|
||||||
|
|
||||||
|
## EARS patterns to watch for
|
||||||
|
|
||||||
|
- The **Unwanted-behavior** pattern (`If <attacker condition>, then the <system> shall <safe response>`) is *the* security pattern. Every auth, authz, validation, and limit case must appear as one. A spec with zero `If` requirements on a mutating endpoint is an automatic `FAIL`.
|
||||||
|
- **Optional-feature** (`Where the caller has Permission.X …`) requirements encode the authorization model — verify the gate is on the *write*, not just the read.
|
||||||
|
- Watch for **Ubiquitous** requirements that quietly assume trust ("The system shall store the uploaded file") with no companion `If` clause validating it first.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
A Gitea comment titled **`### Security — Spec Review`** with the checklist table
|
||||||
|
`| # | Item | Status | Note |`, each `FAIL` tagged with its CWE where applicable, then
|
||||||
|
`Verdict: APPROVE` / `CHANGES REQUESTED` listing blocking `FAIL` numbers. Security `FAIL`s
|
||||||
|
are hard blockers — a spec does not proceed until each is resolved or risk-accepted in the
|
||||||
|
threat model.
|
||||||
39
.specify/personas/ui-ux.md
Normal file
39
.specify/personas/ui-ux.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Persona — UI/UX (spec review)
|
||||||
|
|
||||||
|
> Concise spec-review checklist. Full character persona:
|
||||||
|
> [`.claude/personas/ui_expert.md`](../../.claude/personas/ui_expert.md). This file gates a
|
||||||
|
> `spec.md` for user-facing features against the project's design system and audience split.
|
||||||
|
|
||||||
|
## Role summary
|
||||||
|
|
||||||
|
I check that a user-facing feature is usable by *this* audience — older transcribers on
|
||||||
|
laptops/tablets and younger readers on phones — and that it uses the established design
|
||||||
|
tokens, components, and i18n rather than reinventing them. I block specs whose UI is
|
||||||
|
described in adjectives instead of states, or that ignore accessibility and responsiveness.
|
||||||
|
|
||||||
|
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||||
|
|
||||||
|
1. Does the spec describe every interaction **state** (loading, empty, error, success, disabled), not just the happy path?
|
||||||
|
2. Are user-facing strings specified to go through Paraglide i18n with keys added to `messages/{de,en,es}.json` — no hard-coded German/English literals?
|
||||||
|
3. Does it reuse the established component library and patterns (`BackButton`, the card pattern, `brand-navy`/`brand-mint` tokens, `font-serif`/`font-sans`) rather than introducing new one-off styles?
|
||||||
|
4. Is the responsive behavior specified per the device split — Critical for the reader/phone path, at least Minor for the author/laptop path — with concrete breakpoints, not "responsive"?
|
||||||
|
5. Are error states mapped to `getErrorMessage(code)` output so the user sees a localized message, never a raw code or stack?
|
||||||
|
6. Is every interactive element keyboard-reachable and screen-reader-labeled (the project runs `@axe-core/playwright`)?
|
||||||
|
7. Are acceptance criteria measurable (e.g. "image preview appears within 1 of selection", "tap target ≥ 44px"), not adjectival ("looks clean")?
|
||||||
|
8. Does the spec define an E2E Playwright scenario (per COLLABORATING.md) for each primary user journey step?
|
||||||
|
9. For destructive or irreversible actions, is a confirmation/undo affordance specified?
|
||||||
|
10. Does any uploaded/derived content render through default escaping (no `{@html}`), and are images given alt text / dimensions to avoid layout shift?
|
||||||
|
11. Does the feature respect existing navigation (live DOM nav, real routes — verify route names against the running app, since CLAUDE.md route lists can be stale)?
|
||||||
|
12. Is dark-mode / token theming respected (uses semantic tokens like `bg-surface`/`text-ink-3`, not raw palette constants)?
|
||||||
|
|
||||||
|
## EARS patterns to watch for
|
||||||
|
|
||||||
|
- **State-driven** (`While the upload is in progress, the upload form shall show a progress indicator`) requirements capture UI states — a UI spec with no `While` requirements usually means the loading/disabled states were forgotten.
|
||||||
|
- **Event-driven** (`When the user selects an image, the form shall render a preview`) requirements map directly to Playwright steps — confirm each has a measurable acceptance criterion.
|
||||||
|
- **Unwanted-behavior** (`If the selected file exceeds the size limit, then the form shall show a localized error and not upload`) requirements cover client-side validation feedback.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
A Gitea comment titled **`### UI/UX — Spec Review`** with the checklist table
|
||||||
|
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
|
||||||
|
blocking `FAIL` numbers and the single biggest usability/accessibility gap in one sentence.
|
||||||
108
.specify/rtm.md
Normal file
108
.specify/rtm.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Requirements Traceability Matrix (RTM)
|
||||||
|
|
||||||
|
> Living document. One row per `REQ-NNN` across all in-flight and shipped features. The spec
|
||||||
|
> itself lives in the **Gitea issue** (issue-only — there is no committed `spec.md`); this
|
||||||
|
> matrix is the part of the spec that *is* committed: it links each requirement to its issue,
|
||||||
|
> the code that implements it, and the test(s) that prove it — so any requirement traces end
|
||||||
|
> to end, and any orphan (a requirement with no test) is visible on `main`.
|
||||||
|
|
||||||
|
## How to update
|
||||||
|
|
||||||
|
1. When a feature's issue is approved (via `/review-issue`), add one row per `REQ-NNN` with the
|
||||||
|
`Issue` set to the Gitea issue number and `Status: Planned`. Commit these rows on the feature
|
||||||
|
branch (they merge with the feature's PR).
|
||||||
|
2. As tasks land, fill `Implementation File(s)` + `Test(s)` and flip `Status` →
|
||||||
|
`In progress` → `Done`.
|
||||||
|
3. `REQ-ID`s are **scoped per feature**, so always read them together with the `Issue` column —
|
||||||
|
`REQ-001` for issue #142 is not `REQ-001` for issue #150.
|
||||||
|
4. The `sdd-gate.yml` CI job (`rtm-check`) warns (non-blocking, for now) when a row is missing
|
||||||
|
its `Issue` or `Test(s)`. It flips to blocking once adoption settles (see the workflow's TODO).
|
||||||
|
|
||||||
|
## Status legend
|
||||||
|
|
||||||
|
`Planned` · `In progress` · `Done` · `Deferred`
|
||||||
|
|
||||||
|
## Matrix
|
||||||
|
|
||||||
|
| REQ-ID | Requirement Summary | Issue | Feature | Implementation File(s) | Test(s) | Status |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| REQ-001 | Store avatar at `avatars/{userId}`, overwrite | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserServiceAvatarTest#storesUnderUserKey`, `#replaceLeavesNoOrphan` | Planned |
|
||||||
|
| REQ-002 | Upload self avatar → 200 + avatarUrl | #example | profile-picture-upload (_example) | `UserAvatarController`, `UserService` (planned) | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
|
||||||
|
| REQ-003 | Delete self avatar → avatarUrl null | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
|
||||||
|
| REQ-004 | No avatar → null + initials placeholder | #example | profile-picture-upload (_example) | `UserProfileView`, avatar component (planned) | `avatar-placeholder.svelte.spec.ts` | Planned |
|
||||||
|
| REQ-005 | ADMIN_USER may delete others' avatar | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
|
||||||
|
| REQ-006 | Unauthenticated → 401, store nothing | #example | profile-picture-upload (_example) | `SecurityConfig`, controller (planned) | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
|
||||||
|
| REQ-007 | Non-image → 400 UNSUPPORTED_FILE_TYPE | #example | profile-picture-upload (_example) | `UserService` (planned) | `UserAvatarControllerTest#rejectsNonImage` | Planned |
|
||||||
|
| REQ-008 | Over 2 MB → 400 AVATAR_TOO_LARGE | #example | profile-picture-upload (_example) | `UserService`, `ErrorCode` (planned) | `UserAvatarControllerTest#rejectsOversize` | Planned |
|
||||||
|
| REQ-009 | Non-admin on others → 403 FORBIDDEN | #example | profile-picture-upload (_example) | `UserAvatarController` (planned) | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
|
||||||
|
| REQ-001 | Render dated entry via shared `formatDocumentDate` (de/en/es) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a SEASON date with the German season word`, `delegates a same-year RANGE to formatDocumentDate` | Done |
|
||||||
|
| REQ-002 | Non-UNKNOWN + non-empty date → shared label, `raw=null`, `getLocale()` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `renders a DAY date localized in German`, `renders a DAY date localized in English` | Done |
|
||||||
|
| REQ-003 | `UNKNOWN` → `null` (no chip) | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for UNKNOWN precision even with a date`, `returns null for UNKNOWN precision without a date` | Done |
|
||||||
|
| REQ-004 | `null`/`undefined`/`''` eventDate → `null`, no formatter call | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` | `dateLabel.spec.ts` › `returns null for APPROX with a null eventDate, without calling the formatter`, `returns null for DAY with an empty-string eventDate`, `treats undefined eventDateEnd identically to null for RANGE` | Done |
|
||||||
|
| REQ-005 | No rendering logic outside `documentDate.ts` | #778 | timeline-date-label | `frontend/src/lib/timeline/dateLabel.ts` (façade) | `dateLabel.spec.ts` › `delegates a same-year RANGE to formatDocumentDate` (asserts byte-identical delegation) | Done |
|
||||||
|
| REQ-006 | `timeline` in coverage `include` (80% branch gate) | #778 | timeline-date-label | `frontend/vite.config.ts` | coverage `include` now lists `src/lib/timeline/**`; covered by all `dateLabel.spec.ts` cases | Done |
|
||||||
|
|
||||||
|
<!-- Append real features below this line, one row per REQ-NNN, with the real issue number. Keep the header row above. -->
|
||||||
|
| REQ-001 | family-member Person with birthDate → 1 BIRTH event, precision passed through | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_one_geburt_for_person_with_birthdate`, `#should_pass_birth_precision_through_unchanged` | Done |
|
||||||
|
| REQ-002 | family-member Person with deathDate → 1 DEATH event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_one_tod_for_person_with_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
|
||||||
|
| REQ-003 | null birthDate → 0 Geburt events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents` | `DerivedEventsAssemblyTest#should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate`, `#should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only` | Done |
|
||||||
|
| REQ-004 | null deathDate → 0 Tod events; no dates → 0 events | #776 | derive-person-life-events | `timeline/TimelineEventService#buildDeathEvents` | `DerivedEventsAssemblyTest#should_emit_no_events_for_person_with_neither_date` | Done |
|
||||||
|
| REQ-005 | SPOUSE_OF edge with fromYear → MARRIAGE event, eventDate={fromYear}-01-01, precision=YEAR | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_one_heirat_for_spouse_edge_with_fromYear` | Done |
|
||||||
|
| REQ-006 | SPOUSE_OF edge with null fromYear → MARRIAGE emitted, eventDate=null, precision=UNKNOWN | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_unknown_precision_heirat_when_fromYear_is_null` | Done |
|
||||||
|
| REQ-007 | exactly one MARRIAGE per SPOUSE_OF row (dedup on relationship id) | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_exactly_one_heirat_when_both_spouses_in_scope`, `#should_emit_two_heirat_for_person_married_to_two_partners` | Done |
|
||||||
|
| REQ-008 | synthetic prefixed ids (birth:/death:/marriage:), never parseable as UUID | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_mint_prefixed_synthetic_ids_never_uuid` | Done |
|
||||||
|
| REQ-009 | every derived event: derived=true, type=PERSONAL, non-null derivedType, non-UUID id | #776 | derive-person-life-events | `timeline/TimelineEventService` | structural invariants asserted inline in every event test | Done |
|
||||||
|
| REQ-010 | every derived event: non-null non-blank primaryPersonName; Heirat also non-null non-blank relatedPersonName | #776 | derive-person-life-events | `timeline/TimelineEventService#buildBirthEvents,#buildDeathEvents,#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_with_displayname_for_both_spouses` | Done |
|
||||||
|
| REQ-011 | exactly one call to findAllFamilyMembers() and one to findAllSpouseEdges() — no N+1 | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents`, `relationship/RelationshipService#findAllSpouseEdges` | test structure: only batch-fetch mocks used (no per-person stubs) | Done |
|
||||||
|
| REQ-012 | familyMember=false persons excluded from Geburt/Tod assembly | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` (via PersonService.findAllFamilyMembers) | `DerivedEventsAssemblyTest#should_exclude_non_family_member_persons_from_derived_events` | Done |
|
||||||
|
| REQ-013 | SPOUSE_OF edge with one non-family-member spouse still emits 1 MARRIAGE event | #776 | derive-person-life-events | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_heirat_when_one_spouse_is_not_family_member` | Done |
|
||||||
|
| REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done |
|
||||||
|
| REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done |
|
||||||
|
| REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done |
|
||||||
|
| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done |
|
||||||
|
| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done |
|
||||||
|
| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done |
|
||||||
|
| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done |
|
||||||
|
| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done |
|
||||||
|
| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
|
||||||
|
| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done |
|
||||||
|
| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
|
||||||
|
| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done |
|
||||||
|
| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done |
|
||||||
|
| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done |
|
||||||
|
| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done |
|
||||||
|
| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done |
|
||||||
|
| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done |
|
||||||
|
| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done |
|
||||||
|
| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done |
|
||||||
|
| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done |
|
||||||
|
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
|
||||||
|
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
|
||||||
|
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
|
||||||
|
| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
|
||||||
|
| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
|
||||||
|
| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>` | Done |
|
||||||
|
| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done |
|
||||||
|
| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done |
|
||||||
|
| REQ-006 | single `<ol>` chronological; each band a `<section>` with sticky `<h2>` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single <ol>`, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done |
|
||||||
|
| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done |
|
||||||
|
| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done |
|
||||||
|
| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 1914–1918 with a Zeitraum aria-label` | Done |
|
||||||
|
| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done |
|
||||||
|
| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done |
|
||||||
|
| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done |
|
||||||
|
| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done |
|
||||||
|
| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done |
|
||||||
|
| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done |
|
||||||
|
| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done |
|
||||||
|
| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done |
|
||||||
|
| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done |
|
||||||
|
| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done |
|
||||||
|
| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done |
|
||||||
|
| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done |
|
||||||
|
| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done |
|
||||||
|
| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done |
|
||||||
|
| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, Paraglide compile | Done |
|
||||||
|
| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
|
||||||
|
| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
|
||||||
|
| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |
|
||||||
42
.specify/templates/adr.md
Normal file
42
.specify/templates/adr.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
ADR template. ADRs live in the existing archive: docs/adr/NNN-kebab-title.md.
|
||||||
|
Verify the next free NNN against `ls docs/adr/` on disk (parallel worktrees make
|
||||||
|
issue-body numbers stale). An ADR is IMMUTABLE once Status = Accepted — to change a
|
||||||
|
decision, write a NEW higher-numbered ADR and set this one's Status to Superseded.
|
||||||
|
This header mirrors the existing archive style (see docs/adr/040-*.md). Delete this comment.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# ADR-NNN — <Short decision title>
|
||||||
|
|
||||||
|
**Status:** Proposed <!-- Proposed | Accepted | Deprecated | Superseded by ADR-MMM -->
|
||||||
|
**Date:** <YYYY-MM-DD>
|
||||||
|
**Issue:** #<n> <!-- the Gitea issue / feature this decision serves -->
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
<The forces at play: what problem demands a decision now, the constraints from the
|
||||||
|
constitution and existing ADRs, and why the status quo is insufficient. State facts, not
|
||||||
|
the chosen answer.>
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
<The decision, stated in active voice as something the project now does. Number sub-decisions
|
||||||
|
(### 1, ### 2, …) if the ADR commits several related choices, matching the existing archive.>
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Option | Pros | Cons | Reason rejected |
|
||||||
|
|---|---|---|---|
|
||||||
|
| <chosen — name it> | <pros> | <cons> | **Chosen** |
|
||||||
|
| <alternative A> | <pros> | <cons> | <why not> |
|
||||||
|
| <alternative B> | <pros> | <cons> | <why not> |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
<What becomes easier and what becomes harder. Include the obligations this decision places
|
||||||
|
on future work (migrations forward-only, tests that must exist, guards that must hold), and
|
||||||
|
any new coupling introduced.>
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- <constitution §, related ADRs, issue links, external docs>
|
||||||
97
.specify/templates/api-contract-stub.md
Normal file
97
.specify/templates/api-contract-stub.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# API Contract Stub
|
||||||
|
|
||||||
|
This project is **REST + OpenAPI**. The backend serves the live spec via springdoc at
|
||||||
|
`http://localhost:8080/v3/api-docs` (dev profile only), and the frontend generates its
|
||||||
|
TypeScript client from it with `npm run generate:api` (`openapi-typescript` →
|
||||||
|
`frontend/src/lib/generated/api.ts`). There is no GraphQL in this stack.
|
||||||
|
|
||||||
|
> **The live spec is generated from the Java controllers — it is the source of truth.** A
|
||||||
|
> hand-written stub is a *design artifact*: it pins the intended shape during spec review.
|
||||||
|
> Issue-only: paste the stub inline into the issue's `## API / Contract Stub` section. Keep it
|
||||||
|
> OpenAPI **3.1**, and keep `@Schema(requiredMode = REQUIRED)` on the Java side as the real
|
||||||
|
> driver of `required`.
|
||||||
|
|
||||||
|
## How to use this stub
|
||||||
|
|
||||||
|
1. Fill in the skeleton below with the paths/methods/schemas your feature adds, and paste it
|
||||||
|
into the issue's `## API / Contract Stub` section.
|
||||||
|
2. Every mutating path documents the `403`/`401` responses and the `cookieAuth` security
|
||||||
|
requirement (matching the real `@RequirePermission` gate).
|
||||||
|
3. If you prefer a standalone, lintable file (e.g. for a large contract), commit it on the
|
||||||
|
**feature branch** as `<feature>.openapi.yaml` — the `sdd-gate.yml` CI job lints any
|
||||||
|
committed OpenAPI contract with Spectral (`npx @stoplight/spectral-cli lint`). It never
|
||||||
|
needs to predate the issue.
|
||||||
|
4. After the endpoint ships, run `npm run generate:api` and diff the generated types against
|
||||||
|
this contract; reconcile any drift (the generated spec wins — update the contract).
|
||||||
|
|
||||||
|
## OpenAPI 3.1 skeleton
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Familienarchiv API — <feature name>
|
||||||
|
version: 0.0.1-SNAPSHOT
|
||||||
|
description: Design-time contract for <feature>. Source of truth is the generated /v3/api-docs.
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
description: Local backend (dev profile)
|
||||||
|
- url: https://archiv.raddatz.cloud
|
||||||
|
description: Production (behind Caddy)
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
cookieAuth: # Spring Session JDBC — opaque session id in the SESSION cookie
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: SESSION
|
||||||
|
schemas:
|
||||||
|
ErrorResponse: # shape produced by GlobalExceptionHandler
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable ErrorCode (see ErrorCode.java / errors.ts).
|
||||||
|
example: FORBIDDEN
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
# <YourResponseView>: # always a view, never a lazy-collection entity (ADR-036)
|
||||||
|
# type: object
|
||||||
|
# required: [id]
|
||||||
|
# properties:
|
||||||
|
# id: { type: string, format: uuid }
|
||||||
|
security:
|
||||||
|
- cookieAuth: [] # default: every path requires a session unless overridden to []
|
||||||
|
paths:
|
||||||
|
/api/<resource>:
|
||||||
|
post:
|
||||||
|
summary: <create …>
|
||||||
|
operationId: <createResource>
|
||||||
|
security:
|
||||||
|
- cookieAuth: [] # plus @RequirePermission(Permission.X) on the controller
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/<CreateDTO>' }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/<YourResponseView>' }
|
||||||
|
'400': { description: Validation failed, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||||
|
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||||
|
'403': { description: Missing permission, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validating the contract in CI
|
||||||
|
|
||||||
|
The `sdd-gate.yml` `contract-validate` job lints any committed OpenAPI file changed in the PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @stoplight/spectral-cli lint <your-contract>.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The ruleset is `.spectral.yaml` at the repo root (extends `spectral:oas`; documentation-only
|
||||||
|
warnings relaxed for design-time stubs). Spectral auto-discovers it. It catches malformed
|
||||||
|
specs, undefined `$ref`s, and duplicate `operationId`s; tune `.spectral.yaml` to adjust.
|
||||||
89
.specify/templates/feature-spec.md
Normal file
89
.specify/templates/feature-spec.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<!--
|
||||||
|
Feature Spec template — paste this into the Gitea issue body (issue-only: this IS the spec;
|
||||||
|
there is no committed spec.md). The .gitea/ISSUE_TEMPLATE/feature.md mirror gives the same
|
||||||
|
structure with the right labels. Replace every <placeholder>. Delete this comment before submitting.
|
||||||
|
EARS = Easy Approach to Requirements Syntax. Every requirement uses one of the five patterns
|
||||||
|
shown in ## Requirements and carries a unique REQ-NNN id (three-digit, scoped to THIS feature).
|
||||||
|
Use plain code-path references (not relative markdown links) — links don't resolve inside a Gitea issue.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# <Feature title — match the Gitea issue: "As a <role> I want <capability> so <reason>">
|
||||||
|
|
||||||
|
## Context & Why
|
||||||
|
|
||||||
|
<Business motivation in 2–4 sentences: who needs this and why now.>
|
||||||
|
|
||||||
|
Constitution principles this feature depends on (see `.specify/constitution.md`):
|
||||||
|
- §<n> <principle name> — <why it applies>
|
||||||
|
|
||||||
|
Related: <links to prior issues / ADRs>.
|
||||||
|
|
||||||
|
## User Journey
|
||||||
|
|
||||||
|
<Plain-prose steps the user takes to get value, from the user's perspective — per COLLABORATING.md. Anything not in this journey is out of scope.>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
> One requirement per line, each with a `REQ-NNN` id and one EARS pattern. Include the
|
||||||
|
> patterns the feature actually needs — do not force all five, but a mutating feature almost
|
||||||
|
> always needs at least one Event-driven and one Unwanted-behavior requirement.
|
||||||
|
|
||||||
|
- **REQ-001** (Ubiquitous) — The `<system component>` shall `<always-true behavior>`.
|
||||||
|
- **REQ-002** (Event-driven) — When `<trigger / endpoint receives X>`, the `<system component>` shall `<response>`.
|
||||||
|
- **REQ-003** (State-driven) — While `<system is in state X>`, the `<system component>` shall `<behavior>`.
|
||||||
|
- **REQ-004** (Optional-feature) — Where `<the caller has Permission.X / a feature flag is set>`, the `<system component>` shall `<behavior>`.
|
||||||
|
- **REQ-005** (Unwanted-behavior) — If `<undesired condition, e.g. caller is unauthenticated / input invalid>`, then the `<system component>` shall `<safe response, e.g. return 401 / ErrorCode.X>`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
> One measurable criterion per REQ-NNN. Numbers, limits, status codes — never adjectives.
|
||||||
|
|
||||||
|
- **REQ-001** — <measurable, e.g. "the response always includes a non-null `id` (UUID)">.
|
||||||
|
- **REQ-002** — <measurable, e.g. "POST returns 201 and the persisted row within the same request">.
|
||||||
|
- **REQ-003** — <measurable>.
|
||||||
|
- **REQ-004** — <measurable, e.g. "a caller without Permission.X receives 403 with ErrorCode.FORBIDDEN">.
|
||||||
|
- **REQ-005** — <measurable, e.g. "an unauthenticated request receives 401 and nothing is persisted">.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- <Explicit boundary statement — the nearest tempting scope creep, named and excluded.>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## API / Contract Stub
|
||||||
|
|
||||||
|
<Inline OpenAPI stub. Name the new/changed paths, methods, request/response shapes, status codes, and `@RequirePermission`. Use the `.specify/templates/api-contract-stub.md` skeleton as a writing aid.>
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
<Entity/schema delta: new tables/columns, constraints, the next free Flyway `V<n>`, and the rollback note. Write "none" if not applicable.>
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
<STRIDE categories touched (Spoofing/Tampering/Repudiation/Information disclosure/DoS/Elevation). For AI-agent/tool features, also ASTRIDE. Include an inline STRIDE table (use `.specify/templates/threat-model.md`) if the feature has a non-trivial attack surface.>
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
> Each item is a BLOCKER until resolved. Empty this list before implementation starts.
|
||||||
|
|
||||||
|
- [ ] <question> — owner: <name>
|
||||||
|
- [ ] <question> — owner: <name>
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| REQ-001 | <T-1> | <test name> | Planned |
|
||||||
|
| REQ-002 | <T-2> | <test name> | Planned |
|
||||||
|
|
||||||
|
<After approval, add one committed row per REQ-NNN to `.specify/rtm.md` with this issue's number. Fill Task/Test IDs as work progresses.>
|
||||||
|
|
||||||
|
## Persona Review Results
|
||||||
|
|
||||||
|
| Persona | Status | Key Findings | Resolved |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Requirements Engineer | PENDING | | |
|
||||||
|
| Developer | PENDING | | |
|
||||||
|
| Security | PENDING | | |
|
||||||
|
| DevOps | PENDING | | |
|
||||||
|
| UI/UX | PENDING | | |
|
||||||
|
| Architect | PENDING | | |
|
||||||
53
.specify/templates/threat-model.md
Normal file
53
.specify/templates/threat-model.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<!--
|
||||||
|
Threat model template — STRIDE + ASTRIDE. WRITING AID: fill this in and paste the result into
|
||||||
|
the issue's "## Security Considerations" section (issue-only — the threat model lives in the
|
||||||
|
issue body, not a committed file). Required when a feature adds a new trust boundary, handles
|
||||||
|
uploads, exposes a new mutating endpoint, or invokes an AI agent/tool. The Security persona
|
||||||
|
gates it during /review-issue. Delete this comment.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Threat Model — <Feature name>
|
||||||
|
|
||||||
|
**Feature spec:** Gitea issue #<n>
|
||||||
|
**Date:** <YYYY-MM-DD>
|
||||||
|
**Author:** <name>
|
||||||
|
|
||||||
|
## Data Flow Diagram (text)
|
||||||
|
|
||||||
|
**Actors**
|
||||||
|
- <e.g. Anonymous visitor, Authenticated reader, Authenticated transcriber, Admin, OCR sidecar>
|
||||||
|
|
||||||
|
**Trust boundaries**
|
||||||
|
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
|
||||||
|
- TB-2: Caddy ⇄ Backend (`:8080`) (DMZ ⇄ app)
|
||||||
|
- TB-3: Backend ⇄ PostgreSQL / MinIO / sidecars (app ⇄ data plane)
|
||||||
|
- <add feature-specific boundaries>
|
||||||
|
|
||||||
|
**Data flows** (source → [boundary] → sink : data)
|
||||||
|
- F-1: Browser → [TB-1,TB-2] → Backend : <request payload>
|
||||||
|
- F-2: Backend → [TB-3] → MinIO : <stored object>
|
||||||
|
- <…>
|
||||||
|
|
||||||
|
## STRIDE
|
||||||
|
|
||||||
|
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **S**poofing | <asset> | <e.g. unauthenticated caller forges a request> | <session auth + @RequirePermission> | Low × High | <Open/Mitigated/Accepted> |
|
||||||
|
| **T**ampering | <asset> | <e.g. mass-assignment of createdBy> | <server-set audit fields, no body binding> | Med × High | |
|
||||||
|
| **R**epudiation | <asset> | <e.g. no record of who changed what> | <NOT NULL createdBy/updatedBy audit trail> | Low × Med | |
|
||||||
|
| **I**nformation disclosure | <asset> | <e.g. entity leaks email/hash; raw 500 leaks Hibernate internals> | <view not entity; DomainException.conflict> | Med × High | |
|
||||||
|
| **D**enial of service | <asset> | <e.g. oversized upload / unbounded list> | <size limit, batch cap, pagination> | Med × Med | |
|
||||||
|
| **E**levation of privilege | <asset> | <e.g. reader reaches a write endpoint / IDOR> | <least-privilege Permission, ownership check> | Low × High | |
|
||||||
|
|
||||||
|
## ASTRIDE (only if the feature invokes an AI agent / tool — OCR, NLP, LLM)
|
||||||
|
|
||||||
|
| Threat | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Prompt Injection | <input to the model> | <untrusted document text steers the model> | <treat model output as untrusted; no auto-exec> | | |
|
||||||
|
| Context Poisoning | <retrieved/shared context> | <attacker plants data that biases later runs> | <scope/provenance of context; validation> | | |
|
||||||
|
| Unsafe Tool Invocation | <tool the agent can call> | <model triggers a privileged action> | <allow-list tools; human-in-loop on mutations> | | |
|
||||||
|
| Reasoning Subversion | <decision the model makes> | <crafted input flips a classification/decision> | <confidence threshold; deterministic guardrail> | | |
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
|
||||||
|
<Threats marked Accepted, who accepted them, and why the residual risk is tolerable.>
|
||||||
15
.spectral.yaml
Normal file
15
.spectral.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Spectral ruleset for OpenAPI contract linting (SDD api-contract files).
|
||||||
|
# Spectral v6 ships no implicit ruleset — this enables the built-in OpenAPI rules.
|
||||||
|
# Used by .gitea/workflows/sdd-gate.yml (contract-validate) and locally:
|
||||||
|
# npx @stoplight/spectral-cli lint <contract>.yaml
|
||||||
|
extends: ["spectral:oas"]
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# Design-time SDD stubs are not full published API docs — relax the documentation-completeness
|
||||||
|
# warnings that would otherwise fire on a focused contract. The structural/correctness rules
|
||||||
|
# (oas3-schema, valid $refs, duplicate operationId, etc.) stay on.
|
||||||
|
info-contact: off
|
||||||
|
info-description: off
|
||||||
|
operation-description: off
|
||||||
|
operation-tag-defined: off
|
||||||
|
oas3-unused-component: off
|
||||||
34
CLAUDE.md
34
CLAUDE.md
@@ -16,6 +16,10 @@ See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking wo
|
|||||||
|
|
||||||
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
||||||
|
|
||||||
|
## Spec-Driven Development
|
||||||
|
|
||||||
|
This project uses Spec-Driven Development. **Before implementing a feature, read [`.specify/AGENTS.md`](./.specify/AGENTS.md)** (the short, machine-readable agent rules) and obey the [`.specify/constitution.md`](./.specify/constitution.md) it references. A feature's contract is its **Gitea issue body** (EARS `REQ-NNN` requirements) — there is no committed `spec.md`; the RTM ([`.specify/rtm.md`](./.specify/rtm.md)) traces each `REQ-ID → issue # → test`. Full workflow: [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md); template/reference: [`.specify/features/_example/`](./.specify/features/_example/). The LLM reminders below restate constitution rules — the constitution and AGENTS.md are authoritative if they ever diverge.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
@@ -86,7 +90,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ FileService (S3/MinIO)
|
├── filestorage/ FileService (S3/MinIO)
|
||||||
├── geschichte/ Geschichte (story) domain
|
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||||
|
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||||
├── notification/ Notification domain + SseEmitterRegistry
|
├── notification/ Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||||
@@ -94,6 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── relationship/ PersonRelationship sub-domain
|
│ └── relationship/ PersonRelationship sub-domain
|
||||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||||
├── tag/ Tag domain
|
├── tag/ Tag domain
|
||||||
|
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
|
||||||
└── user/ User domain — AppUser, UserGroup, UserService
|
└── user/ User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,13 +111,17 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
|
|
||||||
### Domain Model
|
### Domain Model
|
||||||
|
|
||||||
| Entity | Table | Key relationships |
|
| Entity | Table | Key relationships |
|
||||||
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
|
||||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||||
|
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||||
|
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||||
|
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
|
||||||
|
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
|
||||||
|
|
||||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||||
|
|
||||||
@@ -152,7 +162,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
|||||||
|
|
||||||
### DTOs
|
### DTOs
|
||||||
|
|
||||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
|
||||||
|
|
||||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||||
|
|
||||||
@@ -160,7 +170,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -194,10 +204,10 @@ frontend/src/routes/
|
|||||||
│ ├── [id]/edit/ Person edit form
|
│ ├── [id]/edit/ Person edit form
|
||||||
│ ├── new/ Create person form
|
│ ├── new/ Create person form
|
||||||
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
||||||
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
|
||||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||||
├── stammbaum/ Family tree (Stammbaum)
|
├── stammbaum/ Family tree (Stammbaum)
|
||||||
|
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
|
||||||
├── themen/ Topics directory — browsable tag index
|
├── themen/ Topics directory — browsable tag index
|
||||||
├── enrich/ Enrichment workflow — [id], done
|
├── enrich/ Enrichment workflow — [id], done
|
||||||
├── admin/ User, group, tag, OCR, system management
|
├── admin/ User, group, tag, OCR, system management
|
||||||
@@ -269,7 +279,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ Evaluate all suggestions on their technical merits. No sycophancy — if somethi
|
|||||||
|
|
||||||
## Core Workflow: Research → Plan → Implement → Validate
|
## Core Workflow: Research → Plan → Implement → Validate
|
||||||
|
|
||||||
|
> **Spec-Driven Development.** Feature work is front-ended by an SDD spec: EARS-formatted
|
||||||
|
> `REQ-NNN` requirements, persona spec-review checklists, and the project constitution. The
|
||||||
|
> sequence below is unchanged — SDD formalises its *inputs* (the issue body becomes a
|
||||||
|
> structured spec; the User Journey + E2E Scenarios below feed it). See
|
||||||
|
> [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md) and
|
||||||
|
> [`.specify/`](./.specify/) ([constitution](./.specify/constitution.md),
|
||||||
|
> [AGENTS.md](./.specify/AGENTS.md)).
|
||||||
|
|
||||||
Every non-trivial feature or bug fix follows this sequence:
|
Every non-trivial feature or bug fix follows this sequence:
|
||||||
|
|
||||||
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
|
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Contributing to Familienarchiv
|
# Contributing to Familienarchiv
|
||||||
|
|
||||||
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
|
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
|
||||||
|
For the Spec-Driven Development workflow (EARS specs, persona review, the constitution, and `.specify/`) see [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md).
|
||||||
For coding style see [CODESTYLE.md](./CODESTYLE.md).
|
For coding style see [CODESTYLE.md](./CODESTYLE.md).
|
||||||
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
|
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
|
||||||
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
|
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
|
||||||
|
|||||||
235
SPEC_DRIVEN_DEVELOPMENT.md
Normal file
235
SPEC_DRIVEN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Spec-Driven Development (SDD)
|
||||||
|
|
||||||
|
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
|
||||||
|
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
|
||||||
|
multi-persona review → red/green TDD). It does not replace any of that — see
|
||||||
|
[ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
|
||||||
|
|
||||||
|
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
|
||||||
|
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
|
||||||
|
- **The templates** live in [`.specify/templates/`](./.specify/templates/).
|
||||||
|
- **The worked example** is [`.specify/features/_example/`](./.specify/features/_example/) — read it first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The whole workflow at a glance
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
idea([Feature idea]):::start --> draft
|
||||||
|
|
||||||
|
subgraph author["✍️ Author"]
|
||||||
|
draft[/"/draft-spec<br/>(Requirements Engineer)"/]:::skill --> issue[("Gitea issue = the SPEC<br/>EARS REQ-NNN + acceptance criteria")]:::spec
|
||||||
|
end
|
||||||
|
|
||||||
|
issue --> ri[/"/review-issue"/]:::skill
|
||||||
|
ri --> g1{"GATE 1 · spec review<br/>6 personas APPROVE?<br/>Open Questions empty?"}:::gate
|
||||||
|
g1 -- "FAIL / question" --> amend["Amend the issue body"]:::work --> ri
|
||||||
|
g1 -- "APPROVE" --> rtm["Seed RTM rows<br/>REQ-ID → issue #"]:::work
|
||||||
|
|
||||||
|
rtm --> wt["Create git worktree<br/>(pull main first)"]:::work --> impl[/"/implement"/]:::skill
|
||||||
|
|
||||||
|
subgraph build["🔁 Build · TDD per REQ-NNN"]
|
||||||
|
impl --> red["Red: failing test"]:::work --> green["Green: minimal code"]:::work --> sync["Refactor + sync<br/>generate:api · flip RTM → Done"]:::work --> commit["Commit · Refs #n"]:::work
|
||||||
|
commit -- "next REQ" --> red
|
||||||
|
end
|
||||||
|
|
||||||
|
build --> pr[["Open PR · Closes #n"]]:::work --> g2{"GATE 2 · CI green?<br/>ci.yml + sdd-gate.yml"}:::gate
|
||||||
|
g2 -- "red" --> fixci["Fix on branch"]:::work --> g2
|
||||||
|
g2 -- "green" --> rp[/"/review-pr"/]:::skill
|
||||||
|
|
||||||
|
rp --> g3{"GATE 3 · PR review<br/>all personas APPROVE?<br/>every REQ implemented + tested?<br/>no Do-Not-Touch violation?"}:::gate
|
||||||
|
g3 -- "changes requested" --> fixpr["Fix on branch"]:::work --> rp
|
||||||
|
g3 -- "APPROVE" --> merge([Merge → main<br/>closed issue = archived spec]):::start
|
||||||
|
|
||||||
|
rules["📐 constitution.md + AGENTS.md<br/>(bind every step)"]:::rules -.-> draft
|
||||||
|
rules -.-> impl
|
||||||
|
rules -.-> rp
|
||||||
|
|
||||||
|
classDef start fill:#1d3b53,color:#fff,stroke:#1d3b53;
|
||||||
|
classDef skill fill:#e8f5f0,stroke:#3aa884,color:#13352b;
|
||||||
|
classDef gate fill:#fff3cd,stroke:#d39e00,color:#5a4500;
|
||||||
|
classDef spec fill:#eef2ff,stroke:#5b6ee1,color:#1e2a5a;
|
||||||
|
classDef work fill:#f6f6f6,stroke:#bbb,color:#222;
|
||||||
|
classDef rules fill:#fdecea,stroke:#d9534f,color:#611a15;
|
||||||
|
```
|
||||||
|
|
||||||
|
> `/deliver-issue` runs **GATE 1 → discuss → build → GATE 3 (loop)** end-to-end in one go.
|
||||||
|
|
||||||
|
### Prerequisites (one-time setup)
|
||||||
|
|
||||||
|
Before the workflow runs cleanly, confirm these exist (most ship with this repo):
|
||||||
|
|
||||||
|
- [ ] **Gitea labels** `spec-required` and `needs-review` exist (the feature template + `/draft-spec` attach them; the `labels` create-param is ignored, so they must pre-exist).
|
||||||
|
- [ ] **Gitea MCP** server configured (`gitea`) — the skills read/write issues and PRs through it.
|
||||||
|
- [ ] **`.spectral.yaml`** at the repo root (extends `spectral:oas`) — the CI contract check needs it.
|
||||||
|
- [ ] **Personas present**: identities in [`.claude/personas/`](./.claude/personas/) + checklists in [`.specify/personas/`](./.specify/personas/).
|
||||||
|
- [ ] **`.specify/constitution.md` + `AGENTS.md`** committed on `main` (so every branch inherits them).
|
||||||
|
- [ ] **Worktrees + hooks**: new feature work goes in a `git worktree` (plus-free name); run `npm install` in `frontend/` once per worktree so the pre-commit lint hook works.
|
||||||
|
|
||||||
|
### The three gates
|
||||||
|
|
||||||
|
| Gate | When | Mechanism | Blocks on |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1 · Spec review** | after `/draft-spec`, before any code | `/review-issue` (6 persona checklists) | any persona `CHANGES REQUESTED`, or an unresolved `## Open Question` |
|
||||||
|
| **2 · CI** | on every PR | `ci.yml` (tests · lint · semgrep) + `sdd-gate.yml` (rtm-check · contract-validate · constitution-diff) | `ci.yml` failure (hard); `sdd-gate` jobs are non-blocking during adoption — see the workflow TODO |
|
||||||
|
| **3 · PR review** | before merge | `/review-pr` (7 personas + traceability) | any persona `Changes requested`, an unimplemented/untested `REQ-NNN`, or a constitution Do-Not-Touch violation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The workflow in 8 steps
|
||||||
|
|
||||||
|
| # | Step | Who | Artifacts created / touched |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Idea → Gitea issue** using the Feature template | author | Gitea issue (labels `spec-required`, `needs-review`) from `.gitea/ISSUE_TEMPLATE/feature.md` |
|
||||||
|
| 2 | **Write the spec _in the issue body_** — Context, User Journey, EARS `REQ-NNN` requirements, measurable acceptance criteria, Out of Scope | author | the Gitea issue body **is** the spec (single source of truth — no committed `spec.md`) |
|
||||||
|
| 3 | **Capture durable design decisions** as needed | author | a `docs/adr/` ADR for any project-wide/irreversible decision; an OpenAPI contract and a STRIDE threat model inline in the issue (use the `.specify/templates/` as the writing aid) |
|
||||||
|
| 4 | **Persona spec review** — the six checklists gate the spec | RE, Developer, Security, DevOps, UI/UX, Architect | `/review-issue` posts each persona's checklist verdict as a Gitea comment; findings folded into the issue body |
|
||||||
|
| 5 | **Resolve Open Questions & blocking FAILs** — spec does not proceed while any remain | author | issue body updated; `Open Questions` emptied |
|
||||||
|
| 6 | **Seed the RTM** — one row per `REQ-NNN`, pointing at the issue | author | rows added to [`.specify/rtm.md`](./.specify/rtm.md) (`Issue: #n`, `Status: Planned`) — committed with the feature branch |
|
||||||
|
| 7 | **Implement** in a worktree, TDD per task (failing test → green → refactor → commit); agent reads `AGENTS.md` + the **issue body** (the spec) | implementer (often an AI agent) | code + tests; `npm run generate:api` after backend changes; RTM `Status` → `Done` |
|
||||||
|
| 8 | **PR → multi-persona PR review → merge** | reviewers | PR (`Closes #n`); the closed issue is the archived spec, the RTM rows record what shipped |
|
||||||
|
|
||||||
|
The personas at step 4 review the **spec (the issue)**; the same personas at step 8 (via the
|
||||||
|
existing `review-pr` / `deliver-issue` skills) review the **code**. Step 4 catches at spec time
|
||||||
|
what used to surface only at step 8.
|
||||||
|
|
||||||
|
**Skills that drive this:** `/draft-spec` (requirements engineer authors steps 1–2 → creates
|
||||||
|
the issue) → `/review-issue` (step 4 gate) → `/implement` (steps 6–7) → `/review-pr` (step 8).
|
||||||
|
`/deliver-issue` runs review → discuss → implement → review-loop end-to-end.
|
||||||
|
|
||||||
|
> **Why issue-only?** The Gitea issue body is the single source of truth for a spec — there is
|
||||||
|
> no committed per-feature `spec.md` to drift out of sync with it. The only SDD artifact that
|
||||||
|
> lives in git per feature is the RTM row (`REQ-ID → issue # → test`). The worked example under
|
||||||
|
> [`.specify/features/_example/`](./.specify/features/_example/) is a **template/reference**, not
|
||||||
|
> a live feature — it shows the full artifact set in one place; real features keep the spec in
|
||||||
|
> the issue.
|
||||||
|
|
||||||
|
## 2. How a Gitea issue becomes a spec
|
||||||
|
|
||||||
|
**Before (free-form issue):**
|
||||||
|
|
||||||
|
> **Title:** Add profile pictures
|
||||||
|
> Users should be able to upload a picture for their profile. Make sure it's not too big and
|
||||||
|
> only admins can remove other people's. Show initials if there's no picture.
|
||||||
|
|
||||||
|
Ambiguous: how big? which formats? what status code on rejection? what about unauthenticated
|
||||||
|
callers? No identifiers to trace, no measurable criteria.
|
||||||
|
|
||||||
|
**After (SDD-structured issue — excerpt):**
|
||||||
|
|
||||||
|
> **Title:** As a user I want to upload a profile picture so other family members recognise me
|
||||||
|
>
|
||||||
|
> **## Requirements**
|
||||||
|
> - **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar`
|
||||||
|
> with a valid image, the user service shall store it and return a profile view with a
|
||||||
|
> non-null `avatarUrl`.
|
||||||
|
> - **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service
|
||||||
|
> shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
|
||||||
|
> - **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets
|
||||||
|
> another user's avatar, then the system shall return `403 ErrorCode.FORBIDDEN`.
|
||||||
|
>
|
||||||
|
> **## Acceptance Criteria**
|
||||||
|
> - **REQ-008** — a 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count unchanged.
|
||||||
|
|
||||||
|
Every behavior is now a uniquely-identified, testable, EARS-formed requirement with a
|
||||||
|
measurable acceptance criterion. See the full version in
|
||||||
|
[`.specify/features/_example/spec.md`](./.specify/features/_example/spec.md).
|
||||||
|
|
||||||
|
## 3. How to run a persona review
|
||||||
|
|
||||||
|
Each persona reads the spec, walks its checklist in `.specify/personas/<persona>.md`, and
|
||||||
|
posts a Gitea comment with **PASS / FAIL / QUESTION** per
|
||||||
|
item and a verdict. A `FAIL` from Security or Architect is a hard block. Concrete example:
|
||||||
|
|
||||||
|
> ### Security — Spec Review
|
||||||
|
>
|
||||||
|
> | # | Item | Status | Note |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
|
||||||
|
> | 3 | Audit fields server-set, forbidden in body | **FAIL** | `avatarObjectKey` is bound from the request body → mass-assignment (CWE-639). Make it server-set in `UserService`. |
|
||||||
|
> | 6 | Upload type allow-list + size | PASS | REQ-007 / REQ-008 |
|
||||||
|
> | 9 | threat-model.md present & STRIDE-complete | **QUESTION** | Is the avatar URL public or proxied? If public S3, that's information disclosure. |
|
||||||
|
>
|
||||||
|
> **Verdict: CHANGES REQUESTED** — blocking FAIL: #3. Resolve #9 in the threat model.
|
||||||
|
|
||||||
|
The author folds the fix into the spec (here: server-set key + authenticated proxy URL),
|
||||||
|
empties the finding, and the persona re-reviews until `APPROVE`. This mirrors the existing
|
||||||
|
`review-issue` skill — the persona checklists just make the spec pass/fail explicit.
|
||||||
|
|
||||||
|
## 4. How the AI agent uses the spec
|
||||||
|
|
||||||
|
Once the spec is `APPROVE`d and tasks are seeded, the implementer points the agent at the
|
||||||
|
artifacts. Example prompt:
|
||||||
|
|
||||||
|
> Implement Gitea issue #142 (profile picture upload). Read `.specify/AGENTS.md` and obey the
|
||||||
|
> constitution it references. The contract is the issue body — its EARS requirements
|
||||||
|
> REQ-001…REQ-009 and acceptance criteria. Build a red/green task list from them, write the
|
||||||
|
> failing test for each REQ first, confirm it fails, then make it pass. After backend model
|
||||||
|
> changes run `npm run generate:api`. Do not mark a REQ done until its test is green; flip its
|
||||||
|
> row in `.specify/rtm.md` to Done as you go.
|
||||||
|
|
||||||
|
The agent now has: the rules (`AGENTS.md` → constitution) and the exact requirements with ids
|
||||||
|
from the issue — so its output is bounded and verifiable. (The `/implement` skill fetches the
|
||||||
|
issue body for you via the Gitea API.)
|
||||||
|
|
||||||
|
## 5. Maintenance rules
|
||||||
|
|
||||||
|
- **Constitution** ([`.specify/constitution.md`](./.specify/constitution.md)) — change it only
|
||||||
|
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
|
||||||
|
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
|
||||||
|
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
|
||||||
|
in ADR-042's revision log (or a superseding ADR for MAJOR).
|
||||||
|
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
|
||||||
|
duplicate or contradict it.
|
||||||
|
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free
|
||||||
|
`NNN`, verify on disk). Immutable once `Accepted`; supersede, don't edit.
|
||||||
|
- **Feature specs** — the spec is the Gitea issue body; there is no committed `spec.md`.
|
||||||
|
"Archiving" is just closing the issue (`Closes #n` on merge). The closed issue + the RTM
|
||||||
|
rows are the record of what shipped.
|
||||||
|
- **RTM** ([`.specify/rtm.md`](./.specify/rtm.md)) — append one row per `REQ-NNN` when a spec
|
||||||
|
is approved, each pointing at its issue (`#n`); flip `Status` as tests go green; never delete
|
||||||
|
a shipped requirement's row.
|
||||||
|
- **Personas** — update `.specify/personas/*.md` checklists when a recurring blind spot
|
||||||
|
appears; keep them aligned with the richer `.claude/personas/`.
|
||||||
|
|
||||||
|
## 6. Quick-start cheatsheet
|
||||||
|
|
||||||
|
**EARS patterns** (every requirement is one of these + a `REQ-NNN` id):
|
||||||
|
|
||||||
|
| Pattern | Shape |
|
||||||
|
|---|---|
|
||||||
|
| Ubiquitous | `The <system> shall <behavior>.` |
|
||||||
|
| Event-driven | `When <trigger>, the <system> shall <behavior>.` |
|
||||||
|
| State-driven | `While <state>, the <system> shall <behavior>.` |
|
||||||
|
| Optional-feature | `Where <feature/permission present>, the <system> shall <behavior>.` |
|
||||||
|
| Unwanted-behavior | `If <undesired condition>, then the <system> shall <response>.` |
|
||||||
|
|
||||||
|
**File locations:**
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| Non-negotiable rules | `.specify/constitution.md` |
|
||||||
|
| Agent rules (read every time) | `.specify/AGENTS.md` |
|
||||||
|
| Templates (writing aids) | `.specify/templates/{feature-spec,adr,threat-model,api-contract-stub}.md` |
|
||||||
|
| Persona checklists | `.specify/personas/*.md` |
|
||||||
|
| In-flight feature spec | the **Gitea issue body** (not a committed file) |
|
||||||
|
| Worked example (template/reference) | `.specify/features/_example/` |
|
||||||
|
| Traceability matrix | `.specify/rtm.md` (`REQ-ID → issue # → test`) |
|
||||||
|
| ADR archive | `docs/adr/NNN-*.md` |
|
||||||
|
| Issue templates | `.gitea/ISSUE_TEMPLATE/{feature,bug}.md` |
|
||||||
|
| CI gate | `.gitea/workflows/sdd-gate.yml` |
|
||||||
|
|
||||||
|
**Before you mark a feature done:** every `REQ-NNN` has a green test, the RTM Status is
|
||||||
|
`Done`, all six personas APPROVE, `npm run lint` and the targeted tests pass, and
|
||||||
|
`npm run generate:api` has been run if the backend model changed.
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# validate an OpenAPI contract locally (if you drafted one — same as CI)
|
||||||
|
npx @stoplight/spectral-cli lint <your-contract>.yaml
|
||||||
|
|
||||||
|
# regenerate the TS client after a backend model/endpoint change
|
||||||
|
cd frontend && npm run generate:api # backend must run with --spring.profiles.active=dev
|
||||||
|
```
|
||||||
@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ # FileService (S3/MinIO)
|
├── filestorage/ # FileService (S3/MinIO)
|
||||||
├── geschichte/ # Geschichte (story) domain
|
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||||
|
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||||
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||||
├── notification/ # Notification domain + SseEmitterRegistry
|
├── notification/ # Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||||
@@ -41,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── relationship/ # PersonRelationship sub-domain
|
│ └── relationship/ # PersonRelationship sub-domain
|
||||||
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||||
├── tag/ # Tag domain — Tag, TagService, TagController
|
├── tag/ # Tag domain — Tag, TagService, TagController
|
||||||
|
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||||
└── user/ # User domain — AppUser, UserGroup, UserService
|
└── user/ # User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ For per-domain ownership and public surface, see each domain's `README.md`.
|
|||||||
| `Comment` | `document_comments` | Threaded comments with mentions |
|
| `Comment` | `document_comments` | Threaded comments with mentions |
|
||||||
| `Notification` | `notifications` | User notification feed |
|
| `Notification` | `notifications` | User notification feed |
|
||||||
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
||||||
|
| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy |
|
||||||
|
|
||||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,17 @@ Authorization: Basic Gast_User gast
|
|||||||
#GET
|
#GET
|
||||||
GET http://localhost:8080/api/admin/tags
|
GET http://localhost:8080/api/admin/tags
|
||||||
Authorization: Basic admin admin123
|
Authorization: Basic admin admin123
|
||||||
|
|
||||||
|
### One-time backfill: re-sync already-stale auto-titles (#726)
|
||||||
|
# RUNBOOK: a one-shot ADMIN maintenance call, NOT part of normal operation. Run it ONCE
|
||||||
|
# after deploying #726 to clean the existing backlog of stale titles (e.g. a title still
|
||||||
|
# showing "2028" after the date was corrected to "1928"). It is synchronous and idempotent
|
||||||
|
# — a second run returns {"count": 0} and writes nothing. Hit the backend DIRECTLY on
|
||||||
|
# port 8080 (NOT through the SvelteKit proxy) so the sweep can't trip the proxy timeout.
|
||||||
|
# Returns {"count": <documents rewritten>}.
|
||||||
|
POST http://localhost:8080/api/admin/backfill-titles
|
||||||
|
Authorization: Basic admin admin123
|
||||||
|
|
||||||
|
### NEGATIV-TEST: ein Nicht-Admin darf den Backfill NICHT auslösen -> 403 Forbidden
|
||||||
|
POST http://localhost:8080/api/admin/backfill-titles
|
||||||
|
Authorization: Basic Gast_User gast
|
||||||
@@ -41,6 +41,27 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-servlet</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-servlets</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||||
|
<artifactId>jetty-ee10-webapp</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.eclipse.jetty</groupId>
|
||||||
|
<artifactId>jetty-ee</artifactId>
|
||||||
|
<version>12.1.8</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -137,6 +158,12 @@
|
|||||||
<artifactId>archunit-junit5</artifactId>
|
<artifactId>archunit-junit5</artifactId>
|
||||||
<version>1.3.0</version>
|
<version>1.3.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.wiremock</groupId>
|
||||||
|
<artifactId>wiremock-jetty12</artifactId>
|
||||||
|
<version>3.9.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Excel Bearbeitung (Apache POI) -->
|
<!-- Excel Bearbeitung (Apache POI) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -50,10 +50,30 @@ public enum AuditKind {
|
|||||||
ADMIN_FORCE_LOGOUT,
|
ADMIN_FORCE_LOGOUT,
|
||||||
|
|
||||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||||
LOGIN_RATE_LIMITED;
|
LOGIN_RATE_LIMITED,
|
||||||
|
|
||||||
|
// --- Documents ---
|
||||||
|
|
||||||
|
/** Payload: none — the deleted document's id is carried in the documentId column */
|
||||||
|
DOCUMENT_DELETED,
|
||||||
|
|
||||||
|
// --- Reading Journeys (Lesereisen) ---
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||||
|
JOURNEY_ITEM_ADDED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||||
|
JOURNEY_ITEM_REMOVED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||||
|
JOURNEY_ITEM_NOTE_UPDATED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||||
|
JOURNEY_ITEMS_REORDERED;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||||
|
JOURNEY_ITEMS_REORDERED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,13 @@ public class Document {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
|
// Not persisted — computed per detail fetch so read-only users can tell at first
|
||||||
|
// paint whether there is a transcription to read (DocumentService.getDocumentById).
|
||||||
|
@Transient
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean hasTranscription = false;
|
||||||
|
|
||||||
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.document;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -47,9 +46,7 @@ import org.raddatz.familienarchiv.document.DocumentService;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.http.CacheControl;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -138,7 +135,7 @@ public class DocumentController {
|
|||||||
// --- METADATA ---
|
// --- METADATA ---
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Document getDocument(@PathVariable UUID id) {
|
public Document getDocument(@PathVariable UUID id) {
|
||||||
return documentService.getDocumentById(id);
|
return documentService.getDocumentDetail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@@ -171,8 +168,8 @@ public class DocumentController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, requireUserId(authentication));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +313,8 @@ public class DocumentController {
|
|||||||
@RequestParam(required = false) Boolean undated,
|
@RequestParam(required = false) Boolean undated,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
|
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
|
||||||
|
List<UUID> ids = documentService.findIdsForFilter(filters);
|
||||||
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
||||||
@@ -388,8 +386,9 @@ public class DocumentController {
|
|||||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
|
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
|
||||||
Pageable pageable = PageRequest.of(page, size);
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, Boolean.TRUE.equals(undated), pageable));
|
return ResponseEntity.ok(documentService.searchDocuments(filters, sort, dir, pageable));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
@@ -404,9 +403,7 @@ public class DocumentController {
|
|||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
DocumentDensityResult result = documentService.getDensity(
|
DocumentDensityResult result = documentService.getDensity(
|
||||||
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok(result);
|
||||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
|
|
||||||
.body(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TRAINING LABELS ---
|
// --- TRAINING LABELS ---
|
||||||
@@ -445,17 +442,6 @@ public class DocumentController {
|
|||||||
return documentVersionService.getVersion(id, versionId);
|
return documentVersionService.getVersion(id, versionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/conversation")
|
|
||||||
public List<Document> getConversation(
|
|
||||||
@RequestParam UUID senderId,
|
|
||||||
@RequestParam(required = false) UUID receiverId,
|
|
||||||
@RequestParam(required = false) LocalDate from,
|
|
||||||
@RequestParam(required = false) LocalDate to,
|
|
||||||
@RequestParam(defaultValue = "DESC") String dir) {
|
|
||||||
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
|
|
||||||
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID requireUserId(Authentication authentication) {
|
private UUID requireUserId(Authentication authentication) {
|
||||||
return SecurityUtils.requireUserId(authentication, userService);
|
return SecurityUtils.requireUserId(authentication, userService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
|
||||||
|
* before documentRepository.deleteById fires. Listeners run synchronously in the
|
||||||
|
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
|
||||||
|
* see ADR-038.
|
||||||
|
*/
|
||||||
|
public record DocumentDeletingEvent(UUID documentId) {}
|
||||||
@@ -15,7 +15,6 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -37,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@EntityGraph("Document.list")
|
@EntityGraph("Document.list")
|
||||||
Page<Document> findAll(Pageable pageable);
|
Page<Document> findAll(Pageable pageable);
|
||||||
|
|
||||||
|
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||||
|
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||||
|
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||||
|
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
List<Document> findByIdIn(Collection<UUID> ids);
|
||||||
|
|
||||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
@@ -50,6 +56,11 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||||
boolean existsByOriginalFilename(String originalFilename);
|
boolean existsByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
|
// Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded.
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
@Query("SELECT d FROM Document d")
|
||||||
|
List<Document> findAllForTimeline();
|
||||||
|
|
||||||
// lazy – @BatchSize(50) fallback active; see ADR-022
|
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||||
@EntityGraph("Document.full")
|
@EntityGraph("Document.full")
|
||||||
List<Document> findBySenderId(UUID senderId);
|
List<Document> findBySenderId(UUID senderId);
|
||||||
@@ -58,6 +69,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@EntityGraph("Document.full")
|
@EntityGraph("Document.full")
|
||||||
List<Document> findByReceiversId(UUID receiverId);
|
List<Document> findByReceiversId(UUID receiverId);
|
||||||
|
|
||||||
|
|
||||||
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
@@ -81,32 +93,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||||
|
|
||||||
@EntityGraph("Document.full")
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
|
||||||
"JOIN d.receivers r " +
|
|
||||||
"WHERE " +
|
|
||||||
"((d.sender.id = :person1 AND r.id = :person2) " +
|
|
||||||
" OR " +
|
|
||||||
" (d.sender.id = :person2 AND r.id = :person1)) " +
|
|
||||||
"AND d.documentDate BETWEEN :from AND :to")
|
|
||||||
List<Document> findConversation(
|
|
||||||
@Param("person1") UUID person1,
|
|
||||||
@Param("person2") UUID person2,
|
|
||||||
@Param("from") LocalDate from,
|
|
||||||
@Param("to") LocalDate to,
|
|
||||||
Sort sort);
|
|
||||||
|
|
||||||
@EntityGraph("Document.full")
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
|
||||||
"LEFT JOIN d.receivers r " +
|
|
||||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
|
||||||
"AND d.documentDate BETWEEN :from AND :to")
|
|
||||||
List<Document> findSinglePersonCorrespondence(
|
|
||||||
@Param("personId") UUID personId,
|
|
||||||
@Param("from") LocalDate from,
|
|
||||||
@Param("to") LocalDate to,
|
|
||||||
Sort sort);
|
|
||||||
|
|
||||||
@Query(nativeQuery = true, value = """
|
@Query(nativeQuery = true, value = """
|
||||||
SELECT d.id FROM documents d
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
|||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import jakarta.persistence.criteria.JoinType;
|
||||||
|
import jakarta.persistence.criteria.Predicate;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -68,6 +71,7 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
|
|||||||
public class DocumentService {
|
public class DocumentService {
|
||||||
|
|
||||||
private final DocumentRepository documentRepository;
|
private final DocumentRepository documentRepository;
|
||||||
|
private final DocumentTitleFactory documentTitleFactory;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
@@ -77,6 +81,7 @@ public class DocumentService {
|
|||||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
private final AuditLogQueryService auditLogQueryService;
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
@@ -137,8 +142,10 @@ public class DocumentService {
|
|||||||
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
||||||
* because the existing {@link Specification} predicates compose easily
|
* because the existing {@link Specification} predicates compose easily
|
||||||
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
||||||
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
|
* well under the 200ms p95 target. The controller sets no explicit
|
||||||
* controller layer absorbs repeated browse loads.
|
* Cache-Control, so the response is served fresh on every load (issue
|
||||||
|
* #709) — the recompute is imperceptible and stale month counts after an
|
||||||
|
* edit would be misleading on an interactive chart.
|
||||||
*
|
*
|
||||||
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
|
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
|
||||||
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
|
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
|
||||||
@@ -167,11 +174,13 @@ public class DocumentService {
|
|||||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||||
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
||||||
boolean hasFts = ftsIds != null;
|
boolean hasFts = ftsIds != null;
|
||||||
Specification<Document> spec = buildSearchSpec(
|
// Density and search keep separate filter records (DensityFilters has no
|
||||||
hasFts, ftsIds, null, null,
|
// date/undated fields); adapt to SearchFilters here to reuse buildSearchSpec.
|
||||||
filters.sender(), filters.receiver(),
|
// Date bounds stay null and undated=false — the density path never filters by date.
|
||||||
filters.tags(), filters.tagQ(),
|
SearchFilters searchFilters = new SearchFilters(
|
||||||
filters.status(), filters.tagOperator(), false);
|
filters.text(), null, null, filters.sender(), filters.receiver(),
|
||||||
|
filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
|
||||||
|
Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
|
||||||
return documentRepository.findAll(spec).stream()
|
return documentRepository.findAll(spec).stream()
|
||||||
.map(Document::getDocumentDate)
|
.map(Document::getDocumentDate)
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
@@ -375,10 +384,17 @@ public class DocumentService {
|
|||||||
|
|
||||||
DocumentStatus statusBefore = doc.getStatus();
|
DocumentStatus statusBefore = doc.getStatus();
|
||||||
|
|
||||||
|
// Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
|
||||||
|
// BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
|
||||||
|
// skips nulls, so the old state must be read first. The submitted title is the catalog
|
||||||
|
// auto-title iff it equals this; only then does it follow date/location forward.
|
||||||
|
String autoTitleBefore = documentTitleFactory.build(doc);
|
||||||
|
|
||||||
// 1. Einfache Felder Update
|
// 1. Einfache Felder Update
|
||||||
doc.setTitle(dto.getTitle());
|
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
|
||||||
doc.setDocumentDate(dto.getDocumentDate());
|
doc.setDocumentDate(dto.getDocumentDate());
|
||||||
applyDatePrecision(doc, dto);
|
applyDatePrecision(doc, dto);
|
||||||
|
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
|
||||||
doc.setLocation(dto.getLocation());
|
doc.setLocation(dto.getLocation());
|
||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
@@ -419,7 +435,11 @@ public class DocumentService {
|
|||||||
doc.setScriptType(dto.getScriptType());
|
doc.setScriptType(dto.getScriptType());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
|
||||||
|
// NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
|
||||||
|
// segment is originalFilename, so after a replace the stored title no longer matches
|
||||||
|
// build(currentState) and the row is treated as manual — neither save-time nor backfill
|
||||||
|
// rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
|
||||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||||
if (fileReplaced) {
|
if (fileReplaced) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
@@ -448,21 +468,92 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the three date-precision fields only when the DTO carries them.
|
* Decides the title to persist on an edit (#726). The submitted title is the catalog
|
||||||
* A null field means "not submitted" — overwriting the stored value with null
|
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
|
||||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
* comparison with no heuristic, relying on the edit form round-tripping the stored title
|
||||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
|
||||||
* an unrelated edit (e.g. a location typo) is saved.
|
* date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
|
||||||
|
* submission is never persisted (title is always present) — it falls back to the rebuilt
|
||||||
|
* auto-title, which always carries at least the index.
|
||||||
|
*/
|
||||||
|
private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
|
||||||
|
if (submitted == null || submitted.isBlank()) {
|
||||||
|
return documentTitleFactory.build(projectedState(doc, dto));
|
||||||
|
}
|
||||||
|
if (!Objects.equals(submitted, autoBefore)) {
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
|
return documentTitleFactory.build(projectedState(doc, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document state the regenerated title is built from. It is composed from the SAME
|
||||||
|
* resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
|
||||||
|
* DTO (a null value clears the field), precision/end/raw resolved skip-null via
|
||||||
|
* {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
|
||||||
|
* the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
|
||||||
|
* is never touched by a metadata edit.
|
||||||
|
*/
|
||||||
|
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
return Document.builder()
|
||||||
|
.originalFilename(doc.getOriginalFilename())
|
||||||
|
.documentDate(dto.getDocumentDate())
|
||||||
|
.location(dto.getLocation())
|
||||||
|
.metaDatePrecision(effectivePrecision(doc, dto))
|
||||||
|
.metaDateEnd(effectiveMetaDateEnd(doc, dto))
|
||||||
|
.metaDateRaw(effectiveMetaDateRaw(doc, dto))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
|
||||||
|
* so the stored value is kept rather than overwritten with null — which would fabricate a
|
||||||
|
* precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
|
||||||
|
* the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
|
||||||
|
* the stored value back when the DTO omits a field is a harmless no-op).
|
||||||
*/
|
*/
|
||||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||||
if (dto.getMetaDatePrecision() != null) {
|
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
|
||||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
|
||||||
|
doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
|
||||||
|
// projectedState (the title projection) — the single rule keeps them from diverging (#726).
|
||||||
|
private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Friendly guard for the two V69 date-range CHECK constraints, run before save so a
|
||||||
|
* user date typo returns a clean 400 INVALID_DATE_RANGE instead of falling through to
|
||||||
|
* the generic handler (HTTP 500 + Sentry + ERROR log). Validates the post-apply {@code doc}
|
||||||
|
* state, not the DTO, because precision/end may have been carried over from the stored row
|
||||||
|
* when the DTO field was null. The DB CHECK remains the backstop; this never weakens it.
|
||||||
|
*/
|
||||||
|
private void validateDateRange(Document doc) {
|
||||||
|
// Mirrors chk_meta_date_end_after_start: end >= start, with null start allowed.
|
||||||
|
// Use isBefore (equal dates are valid) — never !isAfter, which would contradict the DB's >=.
|
||||||
|
if (doc.getMetaDatePrecision() == DatePrecision.RANGE
|
||||||
|
&& doc.getDocumentDate() != null
|
||||||
|
&& doc.getMetaDateEnd() != null
|
||||||
|
&& doc.getMetaDateEnd().isBefore(doc.getDocumentDate())) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
||||||
|
"meta_date_end must not be before meta_date");
|
||||||
}
|
}
|
||||||
if (dto.getMetaDateEnd() != null) {
|
// Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the
|
||||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
// end field off-RANGE, so this branch closes the same 500 class for direct clients.
|
||||||
}
|
if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) {
|
||||||
if (dto.getMetaDateRaw() != null) {
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
||||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
"meta_date_end is only allowed when meta_date_precision is RANGE");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,18 +591,15 @@ public class DocumentService {
|
|||||||
* round-trip.
|
* round-trip.
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
public List<UUID> findIdsForFilter(SearchFilters filters) {
|
||||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator,
|
boolean hasText = StringUtils.hasText(filters.text());
|
||||||
boolean undated) {
|
|
||||||
boolean hasText = StringUtils.hasText(text);
|
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
|
||||||
if (rankedIds.isEmpty()) return List.of();
|
if (rankedIds.isEmpty()) return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
Specification<Document> spec = buildSearchSpec(
|
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
|
||||||
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,23 +609,18 @@ public class DocumentService {
|
|||||||
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
||||||
* full-text query returned no rows.
|
* full-text query returned no rows.
|
||||||
*/
|
*/
|
||||||
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
|
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
|
||||||
LocalDate from, LocalDate to,
|
boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
|
||||||
UUID sender, UUID receiver,
|
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
|
||||||
List<String> tags, String tagQ,
|
|
||||||
DocumentStatus status, TagOperator tagOperator,
|
|
||||||
boolean undated) {
|
|
||||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
|
||||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
|
||||||
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||||
return Specification.where(textSpec)
|
return Specification.where(textSpec)
|
||||||
.and(isBetween(from, to))
|
.and(isBetween(filters.from(), filters.to()))
|
||||||
.and(hasSender(sender))
|
.and(hasSender(filters.sender()))
|
||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(filters.receiver()))
|
||||||
.and(hasTags(expandedTagSets, useOrLogic))
|
.and(hasTags(expandedTagSets, useOrLogic))
|
||||||
.and(hasTagPartial(tagQ))
|
.and(hasTagPartial(filters.tagQ()))
|
||||||
.and(hasStatus(status))
|
.and(hasStatus(filters.status()))
|
||||||
.and(undatedOnly(undated));
|
.and(undatedOnly(filters.undated()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -666,8 +749,8 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) {
|
public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
|
||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(filters.text());
|
||||||
|
|
||||||
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
|
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
|
||||||
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
|
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
|
||||||
@@ -677,13 +760,13 @@ public class DocumentService {
|
|||||||
// no date/sender/receiver/tag/status filters, and undated documents are valid
|
// no date/sender/receiver/tag/status filters, and undated documents are valid
|
||||||
// FTS hits already folded into the ranked page, so there is no separate undated
|
// FTS hits already folded into the ranked page, so there is no separate undated
|
||||||
// count to report here.
|
// count to report here.
|
||||||
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
|
||||||
return relevanceSortedPageFromSql(text, pageable);
|
return relevanceSortedPageFromSql(filters.text(), pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
|
||||||
// FTS matched nothing → no results and, by definition, no undated matches either.
|
// FTS matched nothing → no results and, by definition, no undated matches either.
|
||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
}
|
}
|
||||||
@@ -691,37 +774,32 @@ public class DocumentService {
|
|||||||
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
||||||
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
|
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
|
||||||
// it never collapses to the page slice and never double-counts (issue #668).
|
// it never collapses to the page slice and never double-counts (issue #668).
|
||||||
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
long undatedCount = countUndatedForFilter(hasText, rankedIds, filters.withUndated(true));
|
||||||
|
|
||||||
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
|
return runSearch(hasText, rankedIds, filters, sort, dir, pageable)
|
||||||
.withUndatedCount(undatedCount);
|
.withUndatedCount(undatedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
||||||
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
|
* across all pages, independent of the undated toggle. The caller passes
|
||||||
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
|
* {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
|
||||||
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
|
* regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
|
||||||
* so the count is legitimately 0 inside a date range.
|
* excludes undated rows by the collision rule (#668), so the count is legitimately 0
|
||||||
|
* inside a date range.
|
||||||
*/
|
*/
|
||||||
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds,
|
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
|
||||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
|
||||||
Specification<Document> undatedSpec = buildSearchSpec(
|
|
||||||
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
|
|
||||||
return documentRepository.count(undatedSpec);
|
return documentRepository.count(undatedSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The original search dispatch — produces the page slice + totals, sans undated count. */
|
/** The original search dispatch — produces the page slice + totals, sans undated count. */
|
||||||
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
|
private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
DocumentSort sort, String dir, Pageable pageable) {
|
||||||
List<String> tags, String tagQ, DocumentStatus status,
|
|
||||||
DocumentSort sort, String dir, TagOperator tagOperator,
|
|
||||||
boolean undated, Pageable pageable) {
|
|
||||||
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
|
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
|
||||||
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
|
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
|
||||||
Specification<Document> spec = buildSearchSpec(
|
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
String text = filters.text();
|
||||||
|
|
||||||
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
||||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
@@ -755,12 +833,12 @@ public class DocumentService {
|
|||||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
|
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
|
||||||
List<String> tags, String tagQ, DocumentStatus status) {
|
|
||||||
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
||||||
&& from == null && to == null && sender == null && receiver == null
|
&& filters.from() == null && filters.to() == null
|
||||||
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
|
&& filters.sender() == null && filters.receiver() == null
|
||||||
|
&& (filters.tags() == null || filters.tags().isEmpty())
|
||||||
|
&& (filters.tagQ() == null || filters.tagQ().isBlank()) && filters.status() == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -775,14 +853,14 @@ public class DocumentService {
|
|||||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
|
|
||||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||||
List<UUID> pageIds = new ArrayList<>();
|
List<UUID> pageIds = new ArrayList<>();
|
||||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||||
pageIds.add(ftsPage.hits().get(i).id());
|
pageIds.add(ftsPage.hits().get(i).id());
|
||||||
}
|
}
|
||||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||||
@@ -901,22 +979,6 @@ public class DocumentService {
|
|||||||
.orElse("");
|
.orElse("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
|
||||||
// Findet alle Briefe ZWISCHEN zwei Personen (egal wer Sender/Empfänger war)
|
|
||||||
public List<Document> getConversation(UUID personA, UUID personB) {
|
|
||||||
|
|
||||||
// Fall 1: A schreibt an B
|
|
||||||
Specification<Document> aToB = Specification.where(hasSender(personA)).and(hasReceiver(personB));
|
|
||||||
|
|
||||||
// Fall 2: B schreibt an A
|
|
||||||
Specification<Document> bToA = Specification.where(hasSender(personB)).and(hasReceiver(personA));
|
|
||||||
|
|
||||||
// Wir wollen (A->B) ODER (B->A)
|
|
||||||
Specification<Document> conversation = aToB.or(bToA);
|
|
||||||
|
|
||||||
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateScriptType(UUID documentId, ScriptType scriptType) {
|
public void updateScriptType(UUID documentId, ScriptType scriptType) {
|
||||||
Document doc = getDocumentById(documentId);
|
Document doc = getDocumentById(documentId);
|
||||||
@@ -946,6 +1008,41 @@ public class DocumentService {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||||
|
*
|
||||||
|
* <p><strong>Security contract — read before calling:</strong>
|
||||||
|
* <ol>
|
||||||
|
* <li>This method intentionally bypasses per-document scope checks and
|
||||||
|
* tag-colour resolution. It must only be invoked after
|
||||||
|
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||||
|
* the controller layer, guaranteeing the caller is an authenticated
|
||||||
|
* author.</li>
|
||||||
|
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||||
|
* JOURNEY-type check that fires before this call — so the method is never
|
||||||
|
* reached for STORY-type Geschichten.</li>
|
||||||
|
* </ol>
|
||||||
|
* Under the current single-tenant model every authenticated author shares the
|
||||||
|
* same document scope, so skipping per-document scope checks is safe.
|
||||||
|
*/
|
||||||
|
public Document findSummaryByIdInternal(UUID id) {
|
||||||
|
return documentRepository.findById(id)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a document for the detail view, additionally flagging whether it has any
|
||||||
|
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||||
|
* existence query only runs for the single-document detail endpoint, not for the
|
||||||
|
* many internal callers that never read the flag.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Document getDocumentDetail(UUID id) {
|
||||||
|
Document doc = getDocumentById(id);
|
||||||
|
doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id));
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
||||||
return documentRepository.findAllById(ids);
|
return documentRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
@@ -954,6 +1051,10 @@ public class DocumentService {
|
|||||||
return documentRepository.findDocumentsWithoutVersions();
|
return documentRepository.findDocumentsWithoutVersions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Document> getAllForTimeline() {
|
||||||
|
return documentRepository.findAllForTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||||
return documentRepository.findBySenderId(senderId);
|
return documentRepository.findBySenderId(senderId);
|
||||||
}
|
}
|
||||||
@@ -962,13 +1063,26 @@ public class DocumentService {
|
|||||||
return documentRepository.findByReceiversId(receiverId);
|
return documentRepository.findByReceiversId(receiverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
|
||||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
Person person = personService.getById(personId);
|
||||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
Specification<Document> spec = buildPersonSpec(person, from, to);
|
||||||
if (receiverId == null) {
|
Page<Document> page = documentRepository.findAll(spec, pageable);
|
||||||
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
|
List<DocumentListItem> items = enrichItems(page.getContent(), null);
|
||||||
}
|
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
|
||||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
}
|
||||||
|
|
||||||
|
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
|
||||||
|
return (root, query, cb) -> {
|
||||||
|
if (query != null) query.distinct(true);
|
||||||
|
var receiversJoin = root.join("receivers", JoinType.LEFT);
|
||||||
|
var senderPredicate = cb.equal(root.get("sender"), person);
|
||||||
|
var receiverPredicate = cb.equal(receiversJoin, person);
|
||||||
|
var personPredicate = cb.or(senderPredicate, receiverPredicate);
|
||||||
|
var predicates = new ArrayList<>(List.of(personPredicate));
|
||||||
|
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||||
|
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||||
|
return cb.and(predicates.toArray(new Predicate[0]));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getIncompleteCount() {
|
public long getIncompleteCount() {
|
||||||
@@ -989,11 +1103,13 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteDocument(UUID id) {
|
public void deleteDocument(UUID id, UUID actorId) {
|
||||||
if (!documentRepository.existsById(id)) {
|
if (!documentRepository.existsById(id)) {
|
||||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||||
}
|
}
|
||||||
|
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||||
documentRepository.deleteById(id);
|
documentRepository.deleteById(id);
|
||||||
|
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -1005,6 +1121,43 @@ public class DocumentService {
|
|||||||
tagService.delete(tagId);
|
tagService.delete(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time cleanup of already-stale auto-titles (#726, FR-003). For every document whose
|
||||||
|
* stored title passes the {@link DocumentTitleBackfillMatcher} overwrite heuristic, rebuilds
|
||||||
|
* the title from the row's current state and persists it only when it actually changed.
|
||||||
|
* Idempotent: a second run rebuilds the same value and saves nothing. Hand-written prose is
|
||||||
|
* left untouched.
|
||||||
|
*
|
||||||
|
* <p>Saves via {@code documentRepository.save} directly — it must NOT route through
|
||||||
|
* {@link #updateDocument} (which versions every write), following the {@link #backfillFileHashes}
|
||||||
|
* precedent: a mechanical rename must not snapshot the whole corpus into {@code document_versions}.
|
||||||
|
*
|
||||||
|
* @return the number of documents whose title was rewritten
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int backfillTitles() {
|
||||||
|
List<Document> docs = documentRepository.findAll();
|
||||||
|
int updated = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
for (Document doc : docs) {
|
||||||
|
if (!DocumentTitleBackfillMatcher.isOverwritable(
|
||||||
|
doc.getTitle(), doc.getOriginalFilename(), doc.getLocation())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String rebuilt = documentTitleFactory.build(doc);
|
||||||
|
if (rebuilt.equals(doc.getTitle())) {
|
||||||
|
skipped++; // already correct — keep idempotent, no write
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
doc.setTitle(rebuilt);
|
||||||
|
documentRepository.save(doc); // direct save, no recordVersion (mechanical rename)
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
log.info("Title backfill complete: scanned={} updated={} skipped={}", docs.size(), updated, skipped);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public int backfillFileHashes() {
|
public int backfillFileHashes() {
|
||||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic overwrite test for the one-time title backfill (#726, FR-004): decides whether a
|
||||||
|
* STORED title is a machine-generated auto-title (and so may be rebuilt from the row's current
|
||||||
|
* state) versus hand-written prose (left untouched). Used ONLY by the backfill — save-time
|
||||||
|
* regeneration uses an exact old-vs-new comparison instead, with no heuristic.
|
||||||
|
*
|
||||||
|
* <p>A stored title is overwritable iff, after stripping the literal {@code index} prefix:
|
||||||
|
* <ol>
|
||||||
|
* <li>it is exactly {@code {index}}, or</li>
|
||||||
|
* <li>{@code {index} – {dateLabel}} with an optional trailing {@code – {location}} segment
|
||||||
|
* (any location — a present, valid date label is itself strong evidence of a machine
|
||||||
|
* title), or</li>
|
||||||
|
* <li>{@code {index} – {location}} where the segment equals the document's current location
|
||||||
|
* (no date label, so the segment must match the known location to be distinguished from
|
||||||
|
* prose).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Security: the {@code index} is compared <em>literally</em> via {@link String#startsWith}
|
||||||
|
* (never compiled into a regex) because {@code originalFilename} is user-controlled and may carry
|
||||||
|
* regex metacharacters — an unquoted pattern would be a ReDoS / regex-injection vector
|
||||||
|
* (CWE-1333 / CWE-625). The date-label sub-patterns use only bounded, non-nested quantifiers over
|
||||||
|
* short tokens, so there is no catastrophic backtracking. Fail-closed: any null/blank index or
|
||||||
|
* structural surprise returns {@code false}.
|
||||||
|
*/
|
||||||
|
final class DocumentTitleBackfillMatcher {
|
||||||
|
|
||||||
|
private static final String SEPARATOR = " – ";
|
||||||
|
|
||||||
|
// German month tokens derived from the SAME Locale.GERMAN formatters DocumentTitleFormatter
|
||||||
|
// uses, so the matcher's accepted spellings cannot drift from what the factory emits (full
|
||||||
|
// names "Januar"…"Dezember"; abbreviations "Jan."…"Dez." — note May/June/July/März carry no
|
||||||
|
// period). Pattern.quote each so a "." in an abbreviation is literal, never a wildcard.
|
||||||
|
private static final String FULL_MONTH = monthAlternation("MMMM");
|
||||||
|
private static final String ABBR_MONTH = monthAlternation("MMM");
|
||||||
|
private static final String SEASON = "(?:Frühling|Sommer|Herbst|Winter)";
|
||||||
|
private static final String YEAR = "\\d{1,4}";
|
||||||
|
private static final String DAY_NUM = "\\d{1,2}";
|
||||||
|
|
||||||
|
// One complete date label, anchored, optionally followed by a free-form trailing location
|
||||||
|
// segment. Only bounded/non-nested quantifiers over short tokens plus a single trailing
|
||||||
|
// ".+" → linear, no catastrophic backtracking (FR-004 ReDoS guard).
|
||||||
|
private static final Pattern DATE_LABEL_WITH_OPTIONAL_LOCATION = Pattern.compile(
|
||||||
|
"^(?:" + String.join("|",
|
||||||
|
YEAR, // 1916
|
||||||
|
"ca\\. " + YEAR, // ca. 1920
|
||||||
|
FULL_MONTH + " " + YEAR, // Juni 1916
|
||||||
|
DAY_NUM + "\\. " + FULL_MONTH + " " + YEAR, // 24. Dezember 1943
|
||||||
|
SEASON + " " + YEAR, // Sommer 1916
|
||||||
|
"Datum unbekannt",
|
||||||
|
DAY_NUM + "\\.–" + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10.–11. Jan. 1917
|
||||||
|
DAY_NUM + "\\. " + ABBR_MONTH + " – " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Jan. – 2. Feb. 1917
|
||||||
|
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR + " – " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Dez. 1916 – 2. Jan. 1917
|
||||||
|
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10. Jan. 1917 (range end == start)
|
||||||
|
"ab " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR) // ab 10. Jan. 1917
|
||||||
|
+ ")(?: – .+)?$");
|
||||||
|
|
||||||
|
private DocumentTitleBackfillMatcher() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isOverwritable(String title, String index, String location) {
|
||||||
|
if (title == null || index == null || index.isBlank()) {
|
||||||
|
return false; // fail closed
|
||||||
|
}
|
||||||
|
if (!title.startsWith(index)) {
|
||||||
|
return false; // index is matched LITERALLY, never as a regex
|
||||||
|
}
|
||||||
|
String tail = title.substring(index.length());
|
||||||
|
if (tail.isEmpty()) {
|
||||||
|
return true; // exactly {index}
|
||||||
|
}
|
||||||
|
if (!tail.startsWith(SEPARATOR)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String body = tail.substring(SEPARATOR.length());
|
||||||
|
if (DATE_LABEL_WITH_OPTIONAL_LOCATION.matcher(body).matches()) {
|
||||||
|
return true; // {dateLabel} (+ optional trailing location)
|
||||||
|
}
|
||||||
|
// No date label: the lone segment must equal the document's current location to be
|
||||||
|
// distinguished from hand-written prose.
|
||||||
|
return location != null && !location.isBlank() && body.equals(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String monthAlternation(String pattern) {
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, Locale.GERMAN);
|
||||||
|
Set<String> tokens = new LinkedHashSet<>();
|
||||||
|
for (int month = 1; month <= 12; month++) {
|
||||||
|
tokens.add(formatter.format(LocalDate.of(2000, month, 15)));
|
||||||
|
}
|
||||||
|
return tokens.stream().map(Pattern::quote).collect(Collectors.joining("|", "(?:", ")"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the auto-generated document title
|
||||||
|
* {@code {index} – {dateLabel} – {location}}.
|
||||||
|
*
|
||||||
|
* <p>The {@code document} package owns this formula; {@code importing} consumes it
|
||||||
|
* (see ADR for issue #726). The leading {@code index} is the document's
|
||||||
|
* {@code originalFilename}; the date label is the honest German label produced by
|
||||||
|
* {@link DocumentTitleFormatter} (the Java half of the #666 date-label split); the
|
||||||
|
* trailing location is the {@code meta_location} verbatim, omitted when blank.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DocumentTitleFactory {
|
||||||
|
|
||||||
|
static final String SEPARATOR = " – ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the auto-title from the document's current state. The date segment is
|
||||||
|
* dropped for UNKNOWN precision or a null date (the honest "no date" case); the
|
||||||
|
* location segment is dropped when blank.
|
||||||
|
*/
|
||||||
|
public String build(Document doc) {
|
||||||
|
// originalFilename is NOT NULL in production; guard only so a synthetic/partial entity
|
||||||
|
// never trips StringBuilder(null) with an opaque NPE.
|
||||||
|
StringBuilder title = new StringBuilder(doc.getOriginalFilename() == null ? "" : doc.getOriginalFilename());
|
||||||
|
if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) {
|
||||||
|
title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate(
|
||||||
|
doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||||
|
doc.getMetaDateEnd(), doc.getMetaDateRaw()));
|
||||||
|
}
|
||||||
|
if (doc.getLocation() != null && !doc.getLocation().isBlank()) {
|
||||||
|
title.append(SEPARATOR).append(doc.getLocation());
|
||||||
|
}
|
||||||
|
return title.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter predicates honoured by {@link DocumentService#searchDocuments} and
|
||||||
|
* {@link DocumentService#findIdsForFilter}. Sort, direction, and pagination are
|
||||||
|
* deliberately excluded — they are not filter predicates, and {@code findIdsForFilter}
|
||||||
|
* needs none of them; they are passed as separate arguments instead.
|
||||||
|
*
|
||||||
|
* Kept as a record so the ten values are passed as one named bundle instead of a
|
||||||
|
* positional argument list where two UUIDs (sender vs. receiver) or two dates
|
||||||
|
* (from vs. to) can be swapped by accident at the call site — a transposition that
|
||||||
|
* compiles cleanly and silently returns the wrong rows.
|
||||||
|
*
|
||||||
|
* Sibling of {@link DensityFilters} (= these fields minus from/to/undated); kept
|
||||||
|
* separate on purpose, so the density call path never reasons about date/undated
|
||||||
|
* fields it deliberately excludes.
|
||||||
|
*/
|
||||||
|
public record SearchFilters(
|
||||||
|
String text,
|
||||||
|
LocalDate from,
|
||||||
|
LocalDate to,
|
||||||
|
UUID sender,
|
||||||
|
UUID receiver,
|
||||||
|
List<String> tags,
|
||||||
|
String tagQ,
|
||||||
|
DocumentStatus status,
|
||||||
|
TagOperator tagOperator,
|
||||||
|
boolean undated) {
|
||||||
|
|
||||||
|
/** Returns a copy with {@code undated} overridden — used by the undated-count path. */
|
||||||
|
public SearchFilters withUndated(boolean undated) {
|
||||||
|
return new SearchFilters(text, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService {
|
|||||||
|
|
||||||
private final TranscriptionBlockRepository blockRepository;
|
private final TranscriptionBlockRepository blockRepository;
|
||||||
|
|
||||||
|
public boolean hasBlocks(UUID documentId) {
|
||||||
|
return blockRepository.existsByDocumentId(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
if (documentIds.isEmpty()) return Map.of();
|
||||||
Map<UUID, Integer> result = new HashMap<>();
|
Map<UUID, Integer> result = new HashMap<>();
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
|
|
||||||
int countByDocumentId(UUID documentId);
|
int countByDocumentId(UUID documentId);
|
||||||
|
|
||||||
|
boolean existsByDocumentId(UUID documentId);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT b FROM TranscriptionBlock b
|
SELECT b FROM TranscriptionBlock b
|
||||||
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
||||||
|
|||||||
@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
|
|||||||
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DomainException serviceUnavailable(ErrorCode code, String message) {
|
||||||
|
return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public enum ErrorCode {
|
|||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
INVALID_PERSON_TYPE,
|
INVALID_PERSON_TYPE,
|
||||||
|
/** A person's birth date is after their death date. 400 */
|
||||||
|
BIRTH_AFTER_DEATH,
|
||||||
|
/** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */
|
||||||
|
INVALID_DATE_PRECISION,
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
DOCUMENT_NOT_FOUND,
|
||||||
@@ -26,6 +30,8 @@ public enum ErrorCode {
|
|||||||
FILE_UPLOAD_FAILED,
|
FILE_UPLOAD_FAILED,
|
||||||
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
||||||
UNSUPPORTED_FILE_TYPE,
|
UNSUPPORTED_FILE_TYPE,
|
||||||
|
/** A RANGE date is invalid: meta_date_end is before meta_date, or an end date is set without RANGE precision. 400 */
|
||||||
|
INVALID_DATE_RANGE,
|
||||||
|
|
||||||
// --- Users ---
|
// --- Users ---
|
||||||
/** A user with the given ID or username does not exist. 404 */
|
/** A user with the given ID or username does not exist. 404 */
|
||||||
@@ -120,6 +126,22 @@ public enum ErrorCode {
|
|||||||
// --- Geschichten (Stories) ---
|
// --- Geschichten (Stories) ---
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||||
GESCHICHTE_NOT_FOUND,
|
GESCHICHTE_NOT_FOUND,
|
||||||
|
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||||
|
JOURNEY_ITEM_NOT_FOUND,
|
||||||
|
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||||
|
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||||
|
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||||
|
JOURNEY_AT_CAPACITY,
|
||||||
|
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||||
|
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
|
||||||
|
GESCHICHTE_TYPE_IMMUTABLE,
|
||||||
|
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
||||||
|
JOURNEY_NOTE_TOO_LONG,
|
||||||
|
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||||
|
GESCHICHTE_TITLE_TOO_LONG,
|
||||||
|
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
|
||||||
|
GESCHICHTE_INTRO_TOO_LONG,
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
@@ -133,6 +155,14 @@ public enum ErrorCode {
|
|||||||
/** The merge target is a descendant of the source tag. 400 */
|
/** The merge target is a descendant of the source tag. 400 */
|
||||||
TAG_MERGE_INVALID_TARGET,
|
TAG_MERGE_INVALID_TARGET,
|
||||||
|
|
||||||
|
// --- Timeline (Zeitstrahl) ---
|
||||||
|
/** A timeline event with the given ID does not exist. 404 */
|
||||||
|
TIMELINE_EVENT_NOT_FOUND,
|
||||||
|
/** Optimistic-locking conflict — the timeline event was modified by another curator. 409 */
|
||||||
|
TIMELINE_EVENT_CONFLICT,
|
||||||
|
/** A timeline event title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||||
|
TIMELINE_TITLE_TOO_LONG,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
@@ -140,6 +170,8 @@ public enum ErrorCode {
|
|||||||
BATCH_TOO_LARGE,
|
BATCH_TOO_LARGE,
|
||||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
||||||
BULK_EDIT_TOO_MANY_IDS,
|
BULK_EDIT_TOO_MANY_IDS,
|
||||||
|
/** A concurrent modification was detected (generic optimistic-lock backstop). 409 */
|
||||||
|
CONFLICT,
|
||||||
/** An unexpected server-side error occurred. 500 */
|
/** An unexpected server-side error occurred. 500 */
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.sentry.Sentry;
|
|||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
@@ -64,6 +65,69 @@ public class GlobalExceptionHandler {
|
|||||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
|
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backstop for any database integrity violation that slips past the explicit upstream
|
||||||
|
* guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into
|
||||||
|
* a clean 400 instead of a 500 + Sentry alert. The known date-range cases are caught upstream
|
||||||
|
* and never reach here; this only catches the unanticipated ones — so it logs the constraint
|
||||||
|
* NAME at WARN to stay debuggable, without re-leaking SQL and without branching the response
|
||||||
|
* on it (the response stays generic, which is the non-brittle part).
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
|
||||||
|
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
|
||||||
|
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
|
||||||
|
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
|
||||||
|
String constraint = constraintNameOf(ex);
|
||||||
|
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
|
||||||
|
if ("uq_journey_items_geschichte_position".equals(constraint)) {
|
||||||
|
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
|
||||||
|
return ResponseEntity.status(409)
|
||||||
|
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
|
||||||
|
"A position conflict was detected — another request modified this journey simultaneously"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the offending constraint's name from the cause chain, or {@code "unknown"}.
|
||||||
|
* Reads only the name (a non-sensitive schema identifier) — never the SQL or the values.
|
||||||
|
*/
|
||||||
|
private static String constraintNameOf(Throwable ex) {
|
||||||
|
for (Throwable t = ex; t != null && t != t.getCause(); t = t.getCause()) {
|
||||||
|
if (t instanceof org.hibernate.exception.ConstraintViolationException cve
|
||||||
|
&& cve.getConstraintName() != null) {
|
||||||
|
return cve.getConstraintName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic backstop for optimistic-locking conflicts that escape a service-level catch. A
|
||||||
|
* conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it
|
||||||
|
* must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries
|
||||||
|
* only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no
|
||||||
|
* version, no persistent-class name.
|
||||||
|
*
|
||||||
|
* <p>Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map
|
||||||
|
* back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on
|
||||||
|
* stable schema constraint NAMES, persistent-class names are not a contract. The precise,
|
||||||
|
* code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only
|
||||||
|
* the net that keeps any current or future write path from regressing to a 500.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleOptimisticLock(
|
||||||
|
org.springframework.orm.ObjectOptimisticLockingFailureException ex) {
|
||||||
|
// Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` /
|
||||||
|
// ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409.
|
||||||
|
log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName());
|
||||||
|
return ResponseEntity.status(409)
|
||||||
|
.body(new ErrorResponse(ErrorCode.CONFLICT,
|
||||||
|
"The resource was modified concurrently. Please reload and try again."));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||||
Sentry.captureException(ex);
|
Sentry.captureException(ex);
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import jakarta.persistence.*;
|
|||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private GeschichteType type = GeschichteType.STORY;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "author_id")
|
@JoinColumn(name = "author_id")
|
||||||
private AppUser author;
|
private AppUser author;
|
||||||
@@ -51,12 +59,18 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Person> persons = new HashSet<>();
|
private Set<Person> persons = new HashSet<>();
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||||
@JoinTable(name = "geschichten_documents",
|
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
// explicitly initialized inside the service transaction. getById() is
|
||||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
|
||||||
|
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
|
||||||
|
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
|
||||||
|
// fetch-joined List here will throw MultipleBagFetchException at boot.
|
||||||
|
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
|
||||||
|
fetch = FetchType.LAZY)
|
||||||
|
@OrderBy("position ASC")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Document> documents = new HashSet<>();
|
private List<JourneyItem> items = new ArrayList<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -28,12 +32,17 @@ import java.util.UUID;
|
|||||||
public class GeschichteController {
|
public class GeschichteController {
|
||||||
|
|
||||||
private final GeschichteService geschichteService;
|
private final GeschichteService geschichteService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Geschichte> list(
|
public List<GeschichteSummary> list(
|
||||||
|
@Parameter(description = "Filter by status. Callers without BLOG_WRITE always receive PUBLISHED results regardless of the value passed. Callers with BLOG_WRITE requesting DRAFT receive only their own unpublished stories.")
|
||||||
@RequestParam(required = false) GeschichteStatus status,
|
@RequestParam(required = false) GeschichteStatus status,
|
||||||
|
@Parameter(description = "AND-filter: story must include all supplied person IDs.")
|
||||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||||
|
@Parameter(description = "Filter to stories containing this document.")
|
||||||
@RequestParam(required = false) UUID documentId,
|
@RequestParam(required = false) UUID documentId,
|
||||||
|
@Parameter(description = "Maximum results to return. Values ≤ 0 default to 50. Clamped at 200.")
|
||||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||||
return geschichteService.list(
|
return geschichteService.list(
|
||||||
status,
|
status,
|
||||||
@@ -43,20 +52,20 @@ public class GeschichteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Geschichte getById(@PathVariable UUID id) {
|
public GeschichteView getById(@PathVariable UUID id) {
|
||||||
return geschichteService.getById(id);
|
return geschichteService.getView(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||||
Geschichte created = geschichteService.create(dto);
|
GeschichteView created = geschichteService.create(dto);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||||
return geschichteService.update(id, dto);
|
return geschichteService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +75,45 @@ public class GeschichteController {
|
|||||||
geschichteService.delete(id);
|
geschichteService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@PostMapping("/{id}/items")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public ResponseEntity<JourneyItemView> appendItem(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody JourneyItemCreateDTO dto) {
|
||||||
|
JourneyItemView view = journeyItemService.append(id, dto);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/items/{itemId}")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public JourneyItemView updateItemNote(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID itemId,
|
||||||
|
@RequestBody JourneyItemUpdateDTO dto) {
|
||||||
|
return journeyItemService.updateNote(id, itemId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/items/{itemId}")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
public ResponseEntity<Void> deleteItem(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@PathVariable UUID itemId) {
|
||||||
|
journeyItemService.delete(id, itemId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/items/reorder")
|
||||||
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
|
@Operation(
|
||||||
|
summary = "Reorder journey items",
|
||||||
|
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||||
|
)
|
||||||
|
public List<JourneyItemView> reorderItems(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@RequestBody JourneyReorderDTO dto) {
|
||||||
|
return journeyItemService.reorder(id, dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin read-only service owning {@link GeschichteRepository}.
|
||||||
|
* Exists so that {@code JourneyItemService} can check Geschichte existence
|
||||||
|
* and load Geschichte instances without holding a direct reference to the
|
||||||
|
* Geschichte repository (cross-domain repository access is not allowed per
|
||||||
|
* layering rules).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GeschichteQueryService {
|
||||||
|
|
||||||
|
private final GeschichteRepository geschichteRepository;
|
||||||
|
|
||||||
|
public boolean existsById(UUID id) {
|
||||||
|
return geschichteRepository.existsById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Geschichte> findById(UUID id) {
|
||||||
|
return geschichteRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||||
|
*
|
||||||
|
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||||
|
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||||
|
*
|
||||||
|
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||||
|
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||||
|
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||||
|
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||||
|
FROM Geschichte g
|
||||||
|
WHERE g.status = :effectiveStatus
|
||||||
|
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||||
|
AND (:personCount = 0 OR
|
||||||
|
(SELECT COUNT(DISTINCT p.id)
|
||||||
|
FROM Geschichte g2 JOIN g2.persons p
|
||||||
|
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||||
|
AND (:documentId IS NULL OR
|
||||||
|
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||||
|
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||||
|
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||||
|
""")
|
||||||
|
List<GeschichteSummary> findSummaries(
|
||||||
|
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||||
|
@Param("authorId") UUID authorId,
|
||||||
|
@Param("personIds") Collection<UUID> personIds,
|
||||||
|
@Param("personCount") long personCount,
|
||||||
|
@Param("documentId") UUID documentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.owasp.html.HtmlPolicyBuilder;
|
import org.owasp.html.HtmlPolicyBuilder;
|
||||||
import org.owasp.html.PolicyFactory;
|
import org.owasp.html.PolicyFactory;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
|||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||||
@@ -54,12 +50,26 @@ public class GeschichteService {
|
|||||||
private static final int DEFAULT_LIMIT = 50;
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
private static final int MAX_LIMIT = 200;
|
private static final int MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
|
||||||
|
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
|
||||||
|
|
||||||
|
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
|
||||||
|
// turns what would be a DB-level 500 into a friendly 400.
|
||||||
|
static final int MAX_TITLE_LENGTH = 255;
|
||||||
|
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
|
||||||
|
// same three-layer bound as journey notes: frontend maxlength, this check, and
|
||||||
|
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
|
||||||
|
// unbounded on purpose.
|
||||||
|
static final int MAX_INTRO_LENGTH = 4000;
|
||||||
|
|
||||||
// ─── Read API ────────────────────────────────────────────────────────────
|
// ─── Read API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public long countPublished() {
|
public long countPublished() {
|
||||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readOnly = true: lazy collections resolve within the same tx when called from getView()
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public Geschichte getById(UUID id) {
|
public Geschichte getById(UUID id) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
@@ -72,24 +82,62 @@ public class GeschichteService {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public GeschichteView getView(UUID id) {
|
||||||
|
Geschichte g = getById(id);
|
||||||
|
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||||
|
return toView(g, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||||
|
AppUser author = g.getAuthor();
|
||||||
|
GeschichteView.AuthorView authorView = null;
|
||||||
|
if (author != null) {
|
||||||
|
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||||
|
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||||
|
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||||
|
}
|
||||||
|
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||||
|
for (Person p : g.getPersons()) {
|
||||||
|
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||||
|
}
|
||||||
|
return new GeschichteView(
|
||||||
|
g.getId(), g.getTitle(), g.getBody(),
|
||||||
|
g.getStatus(), g.getType(),
|
||||||
|
authorView, personViews,
|
||||||
|
items,
|
||||||
|
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||||
* must be associated with every person id supplied. An empty or null list applies no
|
* must be associated with every person id supplied. An empty or null list applies no
|
||||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||||
|
*
|
||||||
|
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||||
|
* LazyInitializationException on the non-transactional list path.
|
||||||
|
*
|
||||||
|
* <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
|
||||||
|
* Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
|
||||||
|
* This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
|
||||||
*/
|
*/
|
||||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
|
||||||
|
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
|
||||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||||
|
|
||||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
UUID authorId = isDraftRequest ? currentUser().getId() : null;
|
||||||
Specification<Geschichte> spec = Specification.allOf(
|
|
||||||
GeschichteSpecifications.hasStatus(effective),
|
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||||
GeschichteSpecifications.hasAuthor(authorId),
|
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||||
GeschichteSpecifications.hasAllPersons(personIds),
|
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||||
GeschichteSpecifications.hasDocument(documentId),
|
? List.of(NIL_UUID)
|
||||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
: personIds;
|
||||||
);
|
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
|
||||||
|
return geschichteRepository
|
||||||
|
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||||
.stream()
|
.stream()
|
||||||
.limit(safeLimit)
|
.limit(safeLimit)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -97,46 +145,57 @@ public class GeschichteService {
|
|||||||
|
|
||||||
// ─── Write API ───────────────────────────────────────────────────────────
|
// ─── Write API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||||
|
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||||
|
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||||
requireTitle(dto.getTitle());
|
requireTitle(dto.getTitle());
|
||||||
|
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||||
Geschichte g = Geschichte.builder()
|
Geschichte g = Geschichte.builder()
|
||||||
.title(dto.getTitle().trim())
|
.title(dto.getTitle().trim())
|
||||||
.body(sanitize(dto.getBody()))
|
.body(bodyForType(type, dto.getBody()))
|
||||||
.status(GeschichteStatus.DRAFT)
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(type)
|
||||||
.author(currentUser())
|
.author(currentUser())
|
||||||
.persons(resolvePersons(dto.getPersonIds()))
|
.persons(resolvePersons(dto.getPersonIds()))
|
||||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
|
||||||
.build();
|
.build();
|
||||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
g.setPublishedAt(LocalDateTime.now());
|
g.setPublishedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
return geschichteRepository.save(g);
|
Geschichte saved = geschichteRepository.save(g);
|
||||||
|
// A freshly created Geschichte has no items by construction — items are only
|
||||||
|
// addable via the separate /items endpoints. Revisit if a create DTO ever
|
||||||
|
// accepts initial items.
|
||||||
|
return toView(saved, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||||
|
if (dto.getType() != null && dto.getType() != g.getType()) {
|
||||||
|
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
|
||||||
|
"The type of a Geschichte cannot be changed after creation");
|
||||||
|
}
|
||||||
if (dto.getTitle() != null) {
|
if (dto.getTitle() != null) {
|
||||||
requireTitle(dto.getTitle());
|
requireTitle(dto.getTitle());
|
||||||
g.setTitle(dto.getTitle().trim());
|
g.setTitle(dto.getTitle().trim());
|
||||||
}
|
}
|
||||||
if (dto.getBody() != null) {
|
if (dto.getBody() != null) {
|
||||||
g.setBody(sanitize(dto.getBody()));
|
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||||
}
|
}
|
||||||
if (dto.getPersonIds() != null) {
|
if (dto.getPersonIds() != null) {
|
||||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||||
}
|
}
|
||||||
if (dto.getDocumentIds() != null) {
|
|
||||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
|
||||||
}
|
|
||||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||||
applyStatusTransition(g, dto.getStatus());
|
applyStatusTransition(g, dto.getStatus());
|
||||||
}
|
}
|
||||||
return geschichteRepository.save(g);
|
Geschichte saved = geschichteRepository.save(g);
|
||||||
|
return toView(saved, journeyItemService.getItems(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -164,6 +223,27 @@ public class GeschichteService {
|
|||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||||
}
|
}
|
||||||
|
if (title.trim().length() > MAX_TITLE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
|
||||||
|
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
|
||||||
|
* JOURNEY intros are plain text: the reader renders them via Svelte text
|
||||||
|
* interpolation (never {@code {@html}}), so entity-encoding them here would
|
||||||
|
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
||||||
|
*/
|
||||||
|
private String bodyForType(GeschichteType type, String body) {
|
||||||
|
if (type != GeschichteType.JOURNEY) {
|
||||||
|
return sanitize(body);
|
||||||
|
}
|
||||||
|
if (body != null && body.length() > MAX_INTRO_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
|
||||||
|
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String sanitize(String body) {
|
private String sanitize(String body) {
|
||||||
@@ -176,15 +256,6 @@ public class GeschichteService {
|
|||||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
|
||||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
|
||||||
Set<Document> out = new LinkedHashSet<>();
|
|
||||||
for (UUID id : ids) {
|
|
||||||
out.add(documentService.getDocumentById(id));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppUser currentUser() {
|
private AppUser currentUser() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
|
|||||||
import jakarta.persistence.criteria.Predicate;
|
import jakarta.persistence.criteria.Predicate;
|
||||||
import jakarta.persistence.criteria.Root;
|
import jakarta.persistence.criteria.Root;
|
||||||
import jakarta.persistence.criteria.Subquery;
|
import jakarta.persistence.criteria.Subquery;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
|
|||||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
|
||||||
return (root, query, cb) -> {
|
|
||||||
if (documentId == null) return null;
|
|
||||||
return cb.exists(documentSubquery(root, query, cb, documentId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||||
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
|
|||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Subquery<UUID> documentSubquery(
|
|
||||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
|
||||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
|
||||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
|
||||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
|
||||||
sub.select(subRoot.get("id"))
|
|
||||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
|
||||||
cb.equal(documents.get("id"), documentId));
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List-projection for the /api/geschichten grid. Never carries items — avoids
|
||||||
|
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
|
||||||
|
* Mirrors the PersonSummaryDTO precedent.
|
||||||
|
*
|
||||||
|
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
|
||||||
|
* publishedAt, status, type). Does NOT carry items or persons.
|
||||||
|
*/
|
||||||
|
public interface GeschichteSummary {
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
UUID getId();
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
String getTitle();
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
GeschichteStatus getStatus();
|
||||||
|
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
GeschichteType getType();
|
||||||
|
|
||||||
|
/** Nested closed projection — exposes only the fields the grid card needs. */
|
||||||
|
AuthorSummary getAuthor();
|
||||||
|
|
||||||
|
LocalDateTime getPublishedAt();
|
||||||
|
|
||||||
|
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
LocalDateTime getUpdatedAt();
|
||||||
|
|
||||||
|
String getBody();
|
||||||
|
|
||||||
|
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
|
||||||
|
interface AuthorSummary {
|
||||||
|
String getFirstName();
|
||||||
|
String getLastName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
public enum GeschichteType {
|
||||||
|
STORY,
|
||||||
|
JOURNEY
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
|
|||||||
private String title;
|
private String title;
|
||||||
private String body;
|
private String body;
|
||||||
private GeschichteStatus status;
|
private GeschichteStatus status;
|
||||||
|
private GeschichteType type;
|
||||||
private List<UUID> personIds;
|
private List<UUID> personIds;
|
||||||
private List<UUID> documentIds;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail-view response for GET /api/geschichten/{id}. Assembled by
|
||||||
|
* GeschichteService — never the raw entity (author AppUser graph must not leak).
|
||||||
|
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
|
||||||
|
*/
|
||||||
|
public record GeschichteView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||||
|
String body,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
|
||||||
|
AuthorView author,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
|
||||||
|
LocalDateTime publishedAt,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
|
||||||
|
) {
|
||||||
|
/** Summarised author — exposes only id and displayName, never email or group memberships. */
|
||||||
|
public record AuthorView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
|
||||||
|
public record PersonView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for joining a person's first and last name into a display string.
|
||||||
|
* Centralises the logic that was previously duplicated across GeschichteService
|
||||||
|
* and JourneyItemService.
|
||||||
|
*/
|
||||||
|
public class PersonNameFormatter {
|
||||||
|
|
||||||
|
private PersonNameFormatter() {
|
||||||
|
// utility class — no instances
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String join(String firstName, String lastName) {
|
||||||
|
String first = firstName != null ? firstName.trim() : "";
|
||||||
|
String last = lastName != null ? lastName.trim() : "";
|
||||||
|
if (first.isEmpty() && last.isEmpty()) return "";
|
||||||
|
if (first.isEmpty()) return last;
|
||||||
|
if (last.isEmpty()) return first;
|
||||||
|
return first + " " + last;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lean read-model view of a Document for embedding in JourneyItemView.
|
||||||
|
* Built by JourneyItemService.toSummary(Document) — never serialised from
|
||||||
|
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
|
||||||
|
*/
|
||||||
|
public record DocumentSummary(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||||
|
LocalDate documentDate,
|
||||||
|
LocalDate documentDateEnd,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
|
||||||
|
String senderName,
|
||||||
|
String receiverName,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "journey_items")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class JourneyItem {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "geschichte_id", nullable = false)
|
||||||
|
@JsonIgnore
|
||||||
|
private Geschichte geschichte;
|
||||||
|
|
||||||
|
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
|
||||||
|
// — the editor is responsible for keeping them distinct.
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "document_id")
|
||||||
|
@JsonIgnore
|
||||||
|
private Document document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
|
||||||
|
*
|
||||||
|
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
|
||||||
|
*/
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String note;
|
||||||
|
|
||||||
|
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
|
||||||
|
// Exposing only the UUID prevents circular references and large nested payloads.
|
||||||
|
public UUID getDocumentId() {
|
||||||
|
return document != null ? document.getId() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
|
||||||
|
@Data
|
||||||
|
public class JourneyItemCreateDTO {
|
||||||
|
private UUID documentId;
|
||||||
|
private String note;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
class JourneyItemDocumentDeleteListener {
|
||||||
|
|
||||||
|
private final JourneyItemRepository journeyItemRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
|
||||||
|
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
|
||||||
|
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
|
||||||
|
* See ADR-038. DocumentService cannot call JourneyItemService directly because
|
||||||
|
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
|
||||||
|
*/
|
||||||
|
@EventListener
|
||||||
|
void onDocumentDeleting(DocumentDeletingEvent event) {
|
||||||
|
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||||
|
|
||||||
|
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||||
|
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||||
|
|
||||||
|
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||||
|
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||||
|
|
||||||
|
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||||
|
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||||
|
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||||
|
|
||||||
|
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||||
|
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||||
|
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||||
|
|
||||||
|
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||||
|
long countByGeschichteId(UUID geschichteId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedup guard: true when the document is already linked to this journey.
|
||||||
|
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
|
||||||
|
* getter on JourneyItem makes Spring Data resolve the derived path as a
|
||||||
|
* direct {@code documentId} attribute, which Hibernate cannot map.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(i) > 0 FROM JourneyItem i
|
||||||
|
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
|
||||||
|
""")
|
||||||
|
boolean existsByGeschichteIdAndDocumentId(
|
||||||
|
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
|
||||||
|
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
|
||||||
|
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
|
||||||
|
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
|
||||||
|
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
|
||||||
|
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
|
||||||
|
* assertion never reads a stale entity. flushAutomatically = true makes the
|
||||||
|
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
|
||||||
|
*/
|
||||||
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
|
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||||
|
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||||
|
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||||
|
* lazily for each item. Items without a document (note-only) are included via
|
||||||
|
* LEFT JOIN. Ordered by position ASC.
|
||||||
|
*/
|
||||||
|
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||||
|
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class JourneyItemService {
|
||||||
|
|
||||||
|
static final int MAX_ITEMS = 100;
|
||||||
|
static final int POSITION_STEP = 10;
|
||||||
|
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
|
||||||
|
static final int MAX_NOTE_LENGTH = 2000;
|
||||||
|
|
||||||
|
private final JourneyItemRepository journeyItemRepository;
|
||||||
|
private final GeschichteQueryService geschichteQueryService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||||
|
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||||
|
"Geschichte not found: " + geschichteId));
|
||||||
|
|
||||||
|
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||||
|
if (count >= MAX_ITEMS) {
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||||
|
"Journey has reached the maximum of 100 items");
|
||||||
|
}
|
||||||
|
|
||||||
|
String note = normalizeNote(dto.getNote());
|
||||||
|
|
||||||
|
if (dto.getDocumentId() == null && note == null) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"At least one of documentId or note must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||||
|
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
Document doc = null;
|
||||||
|
if (dto.getDocumentId() != null) {
|
||||||
|
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
"Document already in journey: " + dto.getDocumentId());
|
||||||
|
}
|
||||||
|
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||||
|
.map(max -> max + POSITION_STEP)
|
||||||
|
.orElse(POSITION_STEP);
|
||||||
|
|
||||||
|
JourneyItem item = JourneyItem.builder()
|
||||||
|
.geschichte(g)
|
||||||
|
.position(nextPosition)
|
||||||
|
.document(doc)
|
||||||
|
.note(note)
|
||||||
|
.build();
|
||||||
|
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
|
||||||
|
// fires here, not at commit — two concurrent appends can both pass the
|
||||||
|
// exists() pre-check above, and the index is the atomic backstop (V74).
|
||||||
|
JourneyItem saved;
|
||||||
|
try {
|
||||||
|
saved = journeyItemRepository.saveAndFlush(item);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
// Only the dedup index earns the friendly 409 — any other integrity
|
||||||
|
// failure (e.g. an FK violation on a concurrently deleted document)
|
||||||
|
// must not be mislabeled as "already added".
|
||||||
|
if (!isDuplicateDocumentViolation(e)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
"Document already in journey: " + dto.getDocumentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||||
|
|
||||||
|
return toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||||
|
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||||
|
"Journey item not found: " + itemId));
|
||||||
|
|
||||||
|
// null = field absent from JSON → no-op
|
||||||
|
Optional<String> noteField = dto.getNote();
|
||||||
|
if (noteField == null) {
|
||||||
|
return toView(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
String note = normalizeNote(noteField.orElse(null));
|
||||||
|
|
||||||
|
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||||
|
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note == null && item.getDocumentId() == null) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Cannot clear note on an item that has no linked document");
|
||||||
|
}
|
||||||
|
|
||||||
|
item.setNote(note);
|
||||||
|
JourneyItem saved = journeyItemRepository.save(item);
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||||
|
|
||||||
|
return toView(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID geschichteId, UUID itemId) {
|
||||||
|
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||||
|
"Journey item not found: " + itemId));
|
||||||
|
|
||||||
|
journeyItemRepository.delete(item);
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||||
|
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||||
|
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||||
|
"Geschichte not found: " + geschichteId);
|
||||||
|
}
|
||||||
|
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||||
|
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||||
|
|
||||||
|
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Duplicate item IDs in reorder request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Requested item IDs do not match the journey's existing items");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||||
|
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||||
|
for (JourneyItem item : items) {
|
||||||
|
itemMap.put(item.getId(), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||||
|
for (int i = 0; i < requestedIds.size(); i++) {
|
||||||
|
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||||
|
item.setPosition((i + 1) * POSITION_STEP);
|
||||||
|
toSave.add(item);
|
||||||
|
}
|
||||||
|
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||||
|
|
||||||
|
UUID actorId = currentUser().getId();
|
||||||
|
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||||
|
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||||
|
|
||||||
|
return reordered.stream().map(this::toView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||||
|
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||||
|
.stream().map(this::toView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentSummary toSummary(Document doc) {
|
||||||
|
String senderName = buildSenderName(doc);
|
||||||
|
Set<Person> receivers = doc.getReceivers();
|
||||||
|
String receiverName = buildCanonicalReceiverName(receivers);
|
||||||
|
|
||||||
|
return new DocumentSummary(
|
||||||
|
doc.getId(),
|
||||||
|
doc.getTitle(),
|
||||||
|
doc.getDocumentDate(),
|
||||||
|
doc.getMetaDateEnd(),
|
||||||
|
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||||
|
senderName,
|
||||||
|
receiverName,
|
||||||
|
receivers != null ? receivers.size() : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JourneyItemView toView(JourneyItem item) {
|
||||||
|
DocumentSummary docSummary = null;
|
||||||
|
Document doc = item.getDocument();
|
||||||
|
if (doc != null) {
|
||||||
|
docSummary = toSummary(doc);
|
||||||
|
}
|
||||||
|
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildSenderName(Document doc) {
|
||||||
|
Person sender = doc.getSender();
|
||||||
|
if (sender != null) {
|
||||||
|
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||||
|
if (!name.isBlank()) return name;
|
||||||
|
}
|
||||||
|
String senderText = doc.getSenderText();
|
||||||
|
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||||
|
if (receivers == null || receivers.isEmpty()) return null;
|
||||||
|
return receivers.stream()
|
||||||
|
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||||
|
.map(p -> {
|
||||||
|
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||||
|
return name.isBlank() ? null : name;
|
||||||
|
})
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
||||||
|
Throwable cause = e.getCause();
|
||||||
|
if (cause instanceof java.sql.SQLException sql) {
|
||||||
|
return "23505".equals(sql.getSQLState());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeNote(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sortKey(String s) {
|
||||||
|
return s != null ? s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser currentUser() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
throw DomainException.unauthorized("Authentication required");
|
||||||
|
}
|
||||||
|
return userService.findByEmail(auth.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
|
||||||
|
* Three-way semantics via Optional<String>:
|
||||||
|
* null → field absent from JSON → leave note unchanged
|
||||||
|
* Optional.empty() → {"note": null} → clear the note
|
||||||
|
* Optional.of("x") → {"note": "x"} → set the note
|
||||||
|
*
|
||||||
|
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class JourneyItemUpdateDTO {
|
||||||
|
private Optional<String> note = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||||
|
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||||
|
*/
|
||||||
|
public record JourneyItemView(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||||
|
DocumentSummary document,
|
||||||
|
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
|
||||||
|
String note
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Input for PUT /api/geschichten/{id}/items/reorder. */
|
||||||
|
@Data
|
||||||
|
public class JourneyReorderDTO {
|
||||||
|
private List<UUID> itemIds;
|
||||||
|
}
|
||||||
@@ -4,13 +4,21 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
||||||
@@ -34,6 +42,7 @@ public class CanonicalImportOrchestrator {
|
|||||||
private final PersonRegisterImporter personRegisterImporter;
|
private final PersonRegisterImporter personRegisterImporter;
|
||||||
private final PersonTreeImporter personTreeImporter;
|
private final PersonTreeImporter personTreeImporter;
|
||||||
private final DocumentImporter documentImporter;
|
private final DocumentImporter documentImporter;
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
|
||||||
@Value("${app.import.dir:/import}")
|
@Value("${app.import.dir:/import}")
|
||||||
private String canonicalDir;
|
private String canonicalDir;
|
||||||
@@ -67,6 +76,7 @@ public class CanonicalImportOrchestrator {
|
|||||||
tagTreeImporter.load(tagTree);
|
tagTreeImporter.load(tagTree);
|
||||||
personRegisterImporter.load(persons);
|
personRegisterImporter.load(persons);
|
||||||
personTreeImporter.load(personsTree);
|
personTreeImporter.load(personsTree);
|
||||||
|
warnOnGenerationMonotonicityViolations();
|
||||||
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
||||||
|
|
||||||
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
||||||
@@ -91,4 +101,31 @@ public class CanonicalImportOrchestrator {
|
|||||||
}
|
}
|
||||||
return artifact;
|
return artifact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks every PARENT_OF edge in the family graph and logs a WARN whenever a child's
|
||||||
|
* generation is not strictly deeper than its parent's. Soft check only — the import
|
||||||
|
* is never aborted; the warning is a forensic signal for the curator. Reads through
|
||||||
|
* {@link RelationshipService} so the orchestrator stays within the layering rule
|
||||||
|
* (no direct repository access).
|
||||||
|
*/
|
||||||
|
private void warnOnGenerationMonotonicityViolations() {
|
||||||
|
NetworkDTO network = relationshipService.getFamilyNetwork();
|
||||||
|
Map<UUID, PersonNodeDTO> byId = new HashMap<>(network.nodes().size());
|
||||||
|
for (PersonNodeDTO node : network.nodes()) {
|
||||||
|
byId.put(node.id(), node);
|
||||||
|
}
|
||||||
|
for (RelationshipDTO edge : network.edges()) {
|
||||||
|
if (edge.relationType() != RelationType.PARENT_OF) continue;
|
||||||
|
PersonNodeDTO parent = byId.get(edge.personId());
|
||||||
|
PersonNodeDTO child = byId.get(edge.relatedPersonId());
|
||||||
|
if (parent == null || child == null) continue;
|
||||||
|
Integer pg = parent.generation();
|
||||||
|
Integer cg = child.generation();
|
||||||
|
if (pg != null && cg != null && cg <= pg) {
|
||||||
|
log.warn("Generation monotonicity violation: parent {} (G{}) -> child {} (G{})",
|
||||||
|
parent.displayName(), pg, child.displayName(), cg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -74,6 +75,7 @@ public class DocumentImporter {
|
|||||||
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
private final DocumentTitleFactory documentTitleFactory;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
@@ -181,7 +183,7 @@ public class DocumentImporter {
|
|||||||
applyAttribution(doc, row);
|
applyAttribution(doc, row);
|
||||||
applyDates(doc, row);
|
applyDates(doc, row);
|
||||||
applyAuthoritativeAssociations(doc, row);
|
applyAuthoritativeAssociations(doc, row);
|
||||||
applyFileMetadata(doc, s3Key, contentType, status, index);
|
applyFileMetadata(doc, s3Key, contentType, status);
|
||||||
applyComputedFlags(doc);
|
applyComputedFlags(doc);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -217,14 +219,15 @@ public class DocumentImporter {
|
|||||||
attachTag(doc, row.get("tags"));
|
attachTag(doc, row.get("tags"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3 key, content type, status, and the index-derived title.
|
// S3 key, content type, status, and the index-derived title. The title formula lives in
|
||||||
|
// the document package's DocumentTitleFactory (single source of truth, #726); by this point
|
||||||
|
// applyDates has populated the date/location and originalFilename carries the index.
|
||||||
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||||
DocumentStatus status, String index) {
|
DocumentStatus status) {
|
||||||
doc.setStatus(status);
|
doc.setStatus(status);
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(s3Key);
|
||||||
doc.setContentType(contentType);
|
doc.setContentType(contentType);
|
||||||
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
doc.setTitle(documentTitleFactory.build(doc));
|
||||||
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||||
@@ -235,20 +238,6 @@ public class DocumentImporter {
|
|||||||
|| !doc.getReceivers().isEmpty());
|
|| !doc.getReceivers().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The title carries the date at the HONEST precision (never a fabricated day) via the
|
|
||||||
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
|
|
||||||
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
|
|
||||||
LocalDate end, String raw, String location) {
|
|
||||||
StringBuilder title = new StringBuilder(index);
|
|
||||||
if (date != null && precision != DatePrecision.UNKNOWN) {
|
|
||||||
title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
|
|
||||||
}
|
|
||||||
if (location != null && !location.isBlank()) {
|
|
||||||
title.append(" – ").append(location);
|
|
||||||
}
|
|
||||||
return title.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||||
|
|
||||||
private Person resolveSender(String slug, String rawName) {
|
private Person resolveSender(String slug, String rawName) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
@@ -11,6 +12,8 @@ import java.io.File;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
||||||
@@ -25,6 +28,13 @@ public class PersonRegisterImporter {
|
|||||||
|
|
||||||
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
||||||
|
|
||||||
|
// Matches a leading optional G then a signed integer. Anchored at the
|
||||||
|
// start so noise can't slip in before the number, but tolerant of trailing
|
||||||
|
// commentary cells (e.g. "G 2 de Gruyter") since curated rows sometimes
|
||||||
|
// carry an inline note. Out-of-range values are caught by the post-parse
|
||||||
|
// range guard, not by the regex.
|
||||||
|
private static final Pattern GENERATION_PATTERN = Pattern.compile("^\\s*G?\\s*(-?\\d+)");
|
||||||
|
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
|
|
||||||
public int load(File artifact) {
|
public int load(File artifact) {
|
||||||
@@ -49,11 +59,31 @@ public class PersonRegisterImporter {
|
|||||||
.notes(blankToNull(row.get("notes")))
|
.notes(blankToNull(row.get("notes")))
|
||||||
.birthYear(yearOf(row.get("birth_date")))
|
.birthYear(yearOf(row.get("birth_date")))
|
||||||
.deathYear(yearOf(row.get("death_date")))
|
.deathYear(yearOf(row.get("death_date")))
|
||||||
|
.generation(parseGeneration(row.get("generation"), personId))
|
||||||
.personType(PersonType.PERSON)
|
.personType(PersonType.PERSON)
|
||||||
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an optional {@code G n} generation cell. Returns null for blanks,
|
||||||
|
* non-matching strings, and any value outside the {@link PersonGeneration}
|
||||||
|
* bounds (mirroring the V70 CHECK). Out-of-range values log a WARN but
|
||||||
|
* never abort the batch — REQ-IMP-001.
|
||||||
|
*/
|
||||||
|
static Integer parseGeneration(String raw, String personId) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
Matcher m = GENERATION_PATTERN.matcher(raw);
|
||||||
|
if (!m.find()) return null;
|
||||||
|
int parsed = Integer.parseInt(m.group(1));
|
||||||
|
if (parsed < PersonGeneration.MIN_GENERATION || parsed > PersonGeneration.MAX_GENERATION) {
|
||||||
|
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.debug("Parsed generation '{}' for person {}", raw, personId);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
private static Integer yearOf(String isoDate) {
|
private static Integer yearOf(String isoDate) {
|
||||||
if (isoDate == null || isoDate.isBlank()) return null;
|
if (isoDate == null || isoDate.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
@@ -79,12 +80,29 @@ public class PersonTreeImporter {
|
|||||||
.notes(blankToNull(text(node, "notes")))
|
.notes(blankToNull(text(node, "notes")))
|
||||||
.birthYear(intOrNull(node, "birthYear"))
|
.birthYear(intOrNull(node, "birthYear"))
|
||||||
.deathYear(intOrNull(node, "deathYear"))
|
.deathYear(intOrNull(node, "deathYear"))
|
||||||
|
.generation(generationOrNull(node, personId))
|
||||||
.familyMember(node.path("familyMember").asBoolean(false))
|
.familyMember(node.path("familyMember").asBoolean(false))
|
||||||
.personType(PersonType.PERSON)
|
.personType(PersonType.PERSON)
|
||||||
.provisional(false)
|
.provisional(false)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JSON {@code generation} value if present and within the
|
||||||
|
* {@link PersonGeneration} bounds; null otherwise. Out-of-range values
|
||||||
|
* log a WARN but never abort the batch — mirrors the register-importer
|
||||||
|
* skip-and-warn policy.
|
||||||
|
*/
|
||||||
|
private static Integer generationOrNull(JsonNode node, String personId) {
|
||||||
|
Integer raw = intOrNull(node, "generation");
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw < PersonGeneration.MIN_GENERATION || raw > PersonGeneration.MAX_GENERATION) {
|
||||||
|
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||||
int created = 0;
|
int created = 0;
|
||||||
for (JsonNode node : relationships) {
|
for (JsonNode node : relationships) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
|
||||||
|
* strength. {@code direct} = every query token is a whole-token match across the person's name
|
||||||
|
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
|
||||||
|
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
|
||||||
|
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
|
||||||
|
*/
|
||||||
|
public record NameMatches(List<Person> direct, List<Person> partial) {
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -49,8 +51,32 @@ public class Person {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
private Integer birthYear;
|
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
|
||||||
private Integer deathYear;
|
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
|
||||||
|
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
|
||||||
|
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
|
||||||
|
private LocalDate birthDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "birth_date_precision", nullable = false, length = 16)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
|
private LocalDate deathDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "death_date_precision", nullable = false, length = 16)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
|
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||||
|
// Nullable for persons outside the curated family graph. Drives the
|
||||||
|
// Stammbaum strict-rank seed (see #689) and re-import preserves human
|
||||||
|
// edits via PersonService.preferHuman (ADR-025).
|
||||||
|
@Column(name = "generation")
|
||||||
|
private Integer generation;
|
||||||
|
|
||||||
@Column(name = "family_member", nullable = false)
|
@Column(name = "family_member", nullable = false)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the {@code persons.generation} value range.
|
||||||
|
* The DB CHECK in V70, the {@code PersonUpdateDTO} Bean Validation annotations,
|
||||||
|
* and the canonical importers all reference these constants so a future widening
|
||||||
|
* (e.g. accepting {@code G −1} ancestors) happens in one place. Mirror this file
|
||||||
|
* by hand in the V70 migration comment when adjusting bounds.
|
||||||
|
*/
|
||||||
|
public final class PersonGeneration {
|
||||||
|
|
||||||
|
public static final int MIN_GENERATION = 0;
|
||||||
|
public static final int MAX_GENERATION = 10;
|
||||||
|
|
||||||
|
private PersonGeneration() {}
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
|
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||||
List<Person> searchByName(@Param("query") String query);
|
List<Person> searchByName(@Param("query") String query);
|
||||||
|
|
||||||
@@ -29,21 +30,46 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
// Lookup by full alias string, used during ODS mass import
|
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
|
||||||
|
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
|
||||||
|
// add a unique(lower(alias)) constraint — see ADR-033.
|
||||||
|
Optional<Person> findByAlias(String alias);
|
||||||
|
|
||||||
|
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
|
||||||
|
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
|
||||||
|
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
|
||||||
|
List<Person> findAllByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||||
Optional<Person> findBySourceRef(String sourceRef);
|
Optional<Person> findBySourceRef(String sourceRef);
|
||||||
|
|
||||||
// Exact first+last name match, used for filename-based sender lookup
|
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
||||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
|
||||||
|
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
|
||||||
|
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
|
||||||
|
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
|
||||||
|
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
|
||||||
|
@Param("lastName") String lastName);
|
||||||
|
|
||||||
|
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
|
||||||
|
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
|
||||||
|
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
|
||||||
|
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
|
||||||
|
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
|
||||||
|
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
|
||||||
|
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
|
||||||
|
@Param("lastName") String lastName);
|
||||||
|
|
||||||
// --- PersonSummaryDTO with document count ---
|
// --- PersonSummaryDTO with document count ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||||
|
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||||
|
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||||
|
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||||
p.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -56,7 +82,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||||
|
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||||
|
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||||
|
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||||
p.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -66,7 +95,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
|
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
|
||||||
ORDER BY p.last_name ASC, p.first_name ASC
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
@@ -77,7 +106,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||||
|
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||||
|
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||||
|
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||||
p.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -116,7 +148,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||||
|
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||||
|
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||||
|
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||||
p.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -189,18 +224,15 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||||
|
|
||||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||||
|
// clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk
|
||||||
|
// updates run beneath Hibernate, and mergePersons follows them with a deleteById whose
|
||||||
|
// ON DELETE CASCADE (V71) also fires beneath the session.
|
||||||
|
|
||||||
@Modifying
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
||||||
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
||||||
|
|
||||||
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
// delete cannot orphan a documents.sender_id FK (the column is nullable).
|
|
||||||
@Modifying
|
|
||||||
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
|
|
||||||
void reassignSenderToNull(@Param("source") UUID source);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
INSERT INTO document_receivers (document_id, person_id)
|
INSERT INTO document_receivers (document_id, person_id)
|
||||||
SELECT document_id, :target FROM document_receivers
|
SELECT document_id, :target FROM document_receivers
|
||||||
@@ -211,7 +243,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||||
|
|
||||||
@Modifying
|
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
|
||||||
@Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true)
|
List<Person> findByGeneration(Integer generation);
|
||||||
void deleteReceiverReferences(@Param("source") UUID source);
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -23,11 +32,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class PersonService {
|
public class PersonService {
|
||||||
|
|
||||||
|
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
|
||||||
|
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
|
||||||
|
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
|
||||||
|
// past position 10 among partials is never discarded.
|
||||||
|
private static final int MAX_TOKENS = 8;
|
||||||
|
private static final int MAX_CANDIDATES = 10;
|
||||||
|
|
||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
private final PersonNameAliasRepository aliasRepository;
|
private final PersonNameAliasRepository aliasRepository;
|
||||||
|
|
||||||
@@ -68,15 +86,13 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
|
||||||
* sent (nulls sender_id) and from any received-document references first, so the delete
|
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
|
||||||
* cannot orphan an FK and fail with a 500.
|
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deletePerson(UUID id) {
|
public void deletePerson(UUID id) {
|
||||||
getById(id);
|
getById(id);
|
||||||
personRepository.reassignSenderToNull(id);
|
|
||||||
personRepository.deleteReceiverReferences(id);
|
|
||||||
personRepository.deleteById(id);
|
personRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,10 +116,104 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Person> findByDisplayNameContaining(String fragment) {
|
||||||
|
return personRepository.searchByName(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
|
||||||
|
// drop empties. Applied symmetrically to the query and to every candidate name component so
|
||||||
|
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
|
||||||
|
static Set<String> tokenize(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> tokens = new LinkedHashSet<>();
|
||||||
|
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
|
||||||
|
if (!part.isEmpty()) {
|
||||||
|
tokens.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
|
||||||
|
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
|
||||||
|
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
|
||||||
|
* are reachable during classification (see ADR-022).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public NameMatches resolveByName(String name) {
|
||||||
|
Set<String> queryTokens = capTokens(tokenize(name));
|
||||||
|
if (queryTokens.isEmpty()) {
|
||||||
|
log.debug("resolveByName outcome=no-match tokens=0");
|
||||||
|
return new NameMatches(List.of(), List.of());
|
||||||
|
}
|
||||||
|
return classify(fetchPool(queryTokens), queryTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> capTokens(Set<String> tokens) {
|
||||||
|
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Person> fetchPool(Set<String> queryTokens) {
|
||||||
|
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
|
||||||
|
for (String token : queryTokens) {
|
||||||
|
for (Person candidate : findByDisplayNameContaining(token)) {
|
||||||
|
pool.putIfAbsent(candidate.getId(), candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(pool.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
|
||||||
|
List<Person> direct = new ArrayList<>();
|
||||||
|
List<Person> partial = new ArrayList<>();
|
||||||
|
for (Person candidate : pool) {
|
||||||
|
if (personTokens(candidate).containsAll(queryTokens)) {
|
||||||
|
direct.add(candidate);
|
||||||
|
} else {
|
||||||
|
partial.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Person> cappedDirect = cap(direct);
|
||||||
|
List<Person> cappedPartial = cap(partial);
|
||||||
|
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
|
||||||
|
return new NameMatches(cappedDirect, cappedPartial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> personTokens(Person person) {
|
||||||
|
Set<String> tokens = new LinkedHashSet<>();
|
||||||
|
tokens.addAll(tokenize(person.getFirstName()));
|
||||||
|
tokens.addAll(tokenize(person.getLastName()));
|
||||||
|
tokens.addAll(tokenize(person.getAlias()));
|
||||||
|
tokens.addAll(tokenize(person.getTitle()));
|
||||||
|
for (PersonNameAlias alias : person.getNameAliases()) {
|
||||||
|
tokens.addAll(tokenize(alias.getFirstName()));
|
||||||
|
tokens.addAll(tokenize(alias.getLastName()));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Person> cap(List<Person> people) {
|
||||||
|
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String outcome(List<Person> direct, List<Person> partial) {
|
||||||
|
if (direct.size() == 1) return "direct=1";
|
||||||
|
if (direct.size() >= 2) return "direct>=2";
|
||||||
|
if (!partial.isEmpty()) return "partial-only";
|
||||||
|
return "no-match";
|
||||||
|
}
|
||||||
|
|
||||||
public List<Person> findAllFamilyMembers() {
|
public List<Person> findAllFamilyMembers() {
|
||||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Person> getPersonsByGeneration(Integer generation) {
|
||||||
|
return personRepository.findByGeneration(generation);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||||
Person person = getById(personId);
|
Person person = getById(personId);
|
||||||
@@ -112,7 +222,19 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Person> findByName(String firstName, String lastName) {
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
// Same scope as findOrCreateByAlias (#731): a case-collision resolves without throwing;
|
||||||
|
// two byte-identical same-case persons are an out-of-scope data anomaly the exact
|
||||||
|
// Optional below would surface as the opaque INTERNAL_ERROR, not a wrong sender.
|
||||||
|
Optional<Person> exact = personRepository.findByFirstNameAndLastName(firstName, lastName);
|
||||||
|
if (exact.isPresent()) return exact;
|
||||||
|
List<Person> caseInsensitive =
|
||||||
|
personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
|
// Deliberate divergence from findOrCreateByAlias: an ambiguous filename leaves the sender
|
||||||
|
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
|
||||||
|
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
|
||||||
|
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
|
||||||
|
// lowest-id fallback. See ADR-033.
|
||||||
|
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
||||||
@@ -127,32 +249,45 @@ public class PersonService {
|
|||||||
PersonType type = PersonTypeClassifier.classify(alias);
|
PersonType type = PersonTypeClassifier.classify(alias);
|
||||||
if (type == PersonType.SKIP) return null;
|
if (type == PersonType.SKIP) return null;
|
||||||
|
|
||||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
||||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
||||||
return personRepository.save(Person.builder()
|
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
|
||||||
.alias(alias)
|
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
||||||
.lastName(alias)
|
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
||||||
.personType(type)
|
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
||||||
.build());
|
Optional<Person> exact = personRepository.findByAlias(alias);
|
||||||
}
|
if (exact.isPresent()) return exact.get(); // exact-case wins
|
||||||
|
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
|
||||||
|
if (!caseInsensitive.isEmpty()) {
|
||||||
|
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
|
||||||
|
}
|
||||||
|
|
||||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
// Create-when-absent: institution/group keep the full label in lastName; a person name
|
||||||
Person person = personRepository.save(Person.builder()
|
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
|
||||||
|
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||||
|
return personRepository.save(Person.builder()
|
||||||
.alias(alias)
|
.alias(alias)
|
||||||
.firstName(split.firstName())
|
.lastName(alias)
|
||||||
.lastName(split.lastName())
|
.personType(type)
|
||||||
.build());
|
.build());
|
||||||
if (split.maidenName() != null) {
|
}
|
||||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
|
||||||
aliasRepository.save(PersonNameAlias.builder()
|
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||||
.person(person)
|
Person person = personRepository.save(Person.builder()
|
||||||
.lastName(split.maidenName())
|
.alias(alias)
|
||||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
.firstName(split.firstName())
|
||||||
.sortOrder(nextSortOrder)
|
.lastName(split.lastName())
|
||||||
.build());
|
.build());
|
||||||
}
|
if (split.maidenName() != null) {
|
||||||
return person;
|
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||||
});
|
aliasRepository.save(PersonNameAlias.builder()
|
||||||
|
.person(person)
|
||||||
|
.lastName(split.maidenName())
|
||||||
|
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||||
|
.sortOrder(nextSortOrder)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return person;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,13 +305,21 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Person fromCanonical(PersonUpsertCommand cmd) {
|
private Person fromCanonical(PersonUpsertCommand cmd) {
|
||||||
|
DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||||
|
LifeDates dates = degradeIfConflicting(
|
||||||
|
yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef());
|
||||||
|
DatePrecisionPair birth = dates.birth();
|
||||||
|
DatePrecisionPair death = dates.death();
|
||||||
Person person = personRepository.save(Person.builder()
|
Person person = personRepository.save(Person.builder()
|
||||||
.sourceRef(cmd.sourceRef())
|
.sourceRef(cmd.sourceRef())
|
||||||
.firstName(blankToNull(cmd.firstName()))
|
.firstName(blankToNull(cmd.firstName()))
|
||||||
.lastName(cmd.lastName())
|
.lastName(cmd.lastName())
|
||||||
.notes(blankToNull(cmd.notes()))
|
.notes(blankToNull(cmd.notes()))
|
||||||
.birthYear(cmd.birthYear())
|
.birthDate(birth.date())
|
||||||
.deathYear(cmd.deathYear())
|
.birthDatePrecision(birth.precision())
|
||||||
|
.deathDate(death.date())
|
||||||
|
.deathDatePrecision(death.precision())
|
||||||
|
.generation(cmd.generation())
|
||||||
.familyMember(cmd.familyMember())
|
.familyMember(cmd.familyMember())
|
||||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||||
.provisional(cmd.provisional())
|
.provisional(cmd.provisional())
|
||||||
@@ -198,8 +341,17 @@ public class PersonService {
|
|||||||
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
||||||
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
||||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
LifeDates dates = degradeIfConflicting(
|
||||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()),
|
||||||
|
preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()),
|
||||||
|
new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()),
|
||||||
|
new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()),
|
||||||
|
cmd.sourceRef());
|
||||||
|
existing.setBirthDate(dates.birth().date());
|
||||||
|
existing.setBirthDatePrecision(dates.birth().precision());
|
||||||
|
existing.setDeathDate(dates.death().date());
|
||||||
|
existing.setDeathDatePrecision(dates.death().precision());
|
||||||
|
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||||
existing.setPersonType(cmd.personType());
|
existing.setPersonType(cmd.personType());
|
||||||
}
|
}
|
||||||
@@ -225,6 +377,48 @@ public class PersonService {
|
|||||||
return existing != null ? existing : canonical;
|
return existing != null ? existing : canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date + precision travel as one value so they can never go out of sync (ADR-039).
|
||||||
|
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
|
||||||
|
|
||||||
|
record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {}
|
||||||
|
|
||||||
|
// The canonical path skips validateLifeDates (the form-only guard), so a conflicting
|
||||||
|
// resolved pair would hit chk_person_birth_before_death at flush time and abort the
|
||||||
|
// whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the
|
||||||
|
// batch): keep the person's stored life dates — empty for a new person — and drop the
|
||||||
|
// conflicting canonical refresh. A hand-entered side is preserved by construction,
|
||||||
|
// since preferHumanDate returned it verbatim and it equals the stored value; two
|
||||||
|
// stored values can never conflict with each other (they already satisfied the CHECK).
|
||||||
|
static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death,
|
||||||
|
DatePrecisionPair existingBirth, DatePrecisionPair existingDeath,
|
||||||
|
String sourceRef) {
|
||||||
|
if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) {
|
||||||
|
return new LifeDates(birth, death);
|
||||||
|
}
|
||||||
|
log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values",
|
||||||
|
sourceRef, birth.date(), death.date());
|
||||||
|
return new LifeDates(existingBirth, existingDeath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
|
||||||
|
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
|
||||||
|
// YEAR-precision or absent date is refreshed from the canonical year.
|
||||||
|
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
|
||||||
|
Integer canonicalYear) {
|
||||||
|
boolean handEntered = existingDate != null && existingPrecision != null
|
||||||
|
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
|
||||||
|
if (handEntered) {
|
||||||
|
return new DatePrecisionPair(existingDate, existingPrecision);
|
||||||
|
}
|
||||||
|
return yearPair(canonicalYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DatePrecisionPair yearPair(Integer year) {
|
||||||
|
return year != null
|
||||||
|
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
|
||||||
|
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
private static String blankToNull(String s) {
|
private static String blankToNull(String s) {
|
||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
return (s == null || s.isBlank()) ? null : s.trim();
|
||||||
}
|
}
|
||||||
@@ -244,7 +438,8 @@ public class PersonService {
|
|||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||||
}
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||||
|
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
.personType(dto.getPersonType())
|
.personType(dto.getPersonType())
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
@@ -252,30 +447,49 @@ public class PersonService {
|
|||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
.birthYear(dto.getBirthYear())
|
.birthDate(dto.getBirthDate())
|
||||||
.deathYear(dto.getDeathYear())
|
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||||
|
.deathDate(dto.getDeathDate())
|
||||||
|
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||||
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateYears(Integer birthYear, Integer deathYear) {
|
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||||
if (birthYear != null && birthYear <= 0) {
|
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
|
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||||
|
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
|
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
||||||
|
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
||||||
|
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||||
|
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||||
}
|
}
|
||||||
if (deathYear != null && deathYear <= 0) {
|
}
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
|
|
||||||
|
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||||
|
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date is set but its precision is missing or UNKNOWN");
|
||||||
}
|
}
|
||||||
if (birthYear != null && deathYear != null && birthYear > deathYear) {
|
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date precision " + precision + " is set without a date");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||||
|
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||||
}
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||||
|
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
person.setPersonType(dto.getPersonType());
|
person.setPersonType(dto.getPersonType());
|
||||||
@@ -284,11 +498,22 @@ public class PersonService {
|
|||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthYear(dto.getBirthYear());
|
person.setBirthDate(dto.getBirthDate());
|
||||||
person.setDeathYear(dto.getDeathYear());
|
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||||
|
person.setDeathDate(dto.getDeathDate());
|
||||||
|
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||||
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
|
person.setGeneration(dto.getGeneration());
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the source person into the target, then deletes the source. Sender references move
|
||||||
|
* to the target; receiver references the target lacks are inserted. The source's leftover
|
||||||
|
* receiver join rows are not deleted explicitly — they cascade-drop via V71's
|
||||||
|
* {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void mergePersons(UUID sourceId, UUID targetId) {
|
public void mergePersons(UUID sourceId, UUID targetId) {
|
||||||
if (sourceId.equals(targetId)) {
|
if (sourceId.equals(targetId)) {
|
||||||
@@ -305,9 +530,7 @@ public class PersonService {
|
|||||||
// Add target as receiver where source is receiver but target is not yet
|
// Add target as receiver where source is receiver but target is not yet
|
||||||
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
||||||
|
|
||||||
// Remove all remaining source receiver references (duplicates already handled)
|
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
|
||||||
personRepository.deleteReceiverReferences(sourceId);
|
|
||||||
|
|
||||||
personRepository.deleteById(sourceId);
|
personRepository.deleteById(sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +19,13 @@ public interface PersonSummaryDTO {
|
|||||||
String getAlias();
|
String getAlias();
|
||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
|
// Full date + precision alongside the derived years: list consumers that render
|
||||||
|
// precise life dates (mention dropdown) read these; year-only consumers keep
|
||||||
|
// the cheaper getBirthYear/getDeathYear.
|
||||||
|
LocalDate getBirthDate();
|
||||||
|
DatePrecision getBirthDatePrecision();
|
||||||
|
LocalDate getDeathDate();
|
||||||
|
DatePrecision getDeathDatePrecision();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
boolean isFamilyMember();
|
boolean isFamilyMember();
|
||||||
boolean isProvisional();
|
boolean isProvisional();
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
@NotNull
|
@NotNull
|
||||||
@@ -19,6 +24,13 @@ public class PersonUpdateDTO {
|
|||||||
private String alias;
|
private String alias;
|
||||||
@Size(max = 5000)
|
@Size(max = 5000)
|
||||||
private String notes;
|
private String notes;
|
||||||
private Integer birthYear;
|
private LocalDate birthDate;
|
||||||
private Integer deathYear;
|
private DatePrecision birthDatePrecision;
|
||||||
|
private LocalDate deathDate;
|
||||||
|
private DatePrecision deathDatePrecision;
|
||||||
|
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||||
|
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||||
|
@Min(PersonGeneration.MIN_GENERATION)
|
||||||
|
@Max(PersonGeneration.MAX_GENERATION)
|
||||||
|
private Integer generation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public record PersonUpsertCommand(
|
|||||||
String notes,
|
String notes,
|
||||||
Integer birthYear,
|
Integer birthYear,
|
||||||
Integer deathYear,
|
Integer deathYear,
|
||||||
|
Integer generation,
|
||||||
boolean familyMember,
|
boolean familyMember,
|
||||||
PersonType personType,
|
PersonType personType,
|
||||||
boolean provisional
|
boolean provisional
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
|||||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||||
| `findAll(String q)` | document, dashboard | List all persons |
|
| `findAll(String q)` | document, dashboard | List all persons |
|
||||||
| `findByName(String firstName, String lastName)` | document | Typeahead search |
|
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
|
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
|
||||||
|
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||||
| `count()` | dashboard | Total person count for stats |
|
| `count()` | dashboard | Total person count for stats |
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ public class RelationshipInferenceService {
|
|||||||
if (p == null) continue;
|
if (p == null) continue;
|
||||||
List<RelationToken> path = shortestPaths.get(id);
|
List<RelationToken> path = shortestPaths.get(id);
|
||||||
PersonNodeDTO node = new PersonNodeDTO(
|
PersonNodeDTO node = new PersonNodeDTO(
|
||||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
|
p.getId(), p.getDisplayName(),
|
||||||
|
RelationshipService.yearOf(p.getBirthDate()),
|
||||||
|
RelationshipService.yearOf(p.getDeathDate()),
|
||||||
|
p.getGeneration(), p.isFamilyMember());
|
||||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||||
}
|
}
|
||||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -66,7 +67,9 @@ public class RelationshipService {
|
|||||||
for (Person p : familyMembers) {
|
for (Person p : familyMembers) {
|
||||||
familyIds.add(p.getId());
|
familyIds.add(p.getId());
|
||||||
nodes.add(new PersonNodeDTO(
|
nodes.add(new PersonNodeDTO(
|
||||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
|
p.getId(), p.getDisplayName(),
|
||||||
|
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
|
||||||
|
p.getGeneration(), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||||
@@ -83,6 +86,15 @@ public class RelationshipService {
|
|||||||
return new NetworkDTO(nodes, edges);
|
return new NetworkDTO(nodes, edges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all {@code SPOUSE_OF} edges with both person sides JOIN FETCHed.
|
||||||
|
* Used by {@code TimelineService.assembleDerivedEvents()} to build Heirat events
|
||||||
|
* without per-edge N+1 queries.
|
||||||
|
*/
|
||||||
|
public List<PersonRelationship> findAllSpouseEdges() {
|
||||||
|
return relationshipRepository.findAllByRelationTypeIn(List.of(RelationType.SPOUSE_OF));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||||
if (personId.equals(dto.relatedPersonId())) {
|
if (personId.equals(dto.relatedPersonId())) {
|
||||||
@@ -154,6 +166,13 @@ public class RelationshipService {
|
|||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
return (s == null || s.isBlank()) ? null : s.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
|
||||||
|
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
|
||||||
|
// so RelationshipInferenceService shares the same derivation.
|
||||||
|
static Integer yearOf(LocalDate date) {
|
||||||
|
return date != null ? date.getYear() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
@@ -169,11 +188,11 @@ public class RelationshipService {
|
|||||||
p.getId(),
|
p.getId(),
|
||||||
rp.getId(),
|
rp.getId(),
|
||||||
p.getDisplayName(),
|
p.getDisplayName(),
|
||||||
p.getBirthYear(),
|
yearOf(p.getBirthDate()),
|
||||||
p.getDeathYear(),
|
yearOf(p.getDeathDate()),
|
||||||
rp.getDisplayName(),
|
rp.getDisplayName(),
|
||||||
rp.getBirthYear(),
|
yearOf(rp.getBirthDate()),
|
||||||
rp.getDeathYear(),
|
yearOf(rp.getDeathDate()),
|
||||||
r.getRelationType(),
|
r.getRelationType(),
|
||||||
r.getFromYear(),
|
r.getFromYear(),
|
||||||
r.getToYear(),
|
r.getToYear(),
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public record PersonNodeDTO(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
||||||
Integer birthYear,
|
Integer birthYear,
|
||||||
Integer deathYear,
|
Integer deathYear,
|
||||||
|
Integer generation,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ Hierarchical document categories. Tags form a tree via a self-referencing `paren
|
|||||||
Entity: `Tag` (self-referencing `parent_id` tree).
|
Entity: `Tag` (self-referencing `parent_id` tree).
|
||||||
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
||||||
|
|
||||||
|
## Tag tree counts (`getTagTree`)
|
||||||
|
|
||||||
|
`GET /api/tags/tree` returns each node with **two** document counts, from two aggregate queries (no N+1):
|
||||||
|
|
||||||
|
- `documentCount` — documents tagged with that **exact** tag (direct). Read by the admin surfaces (sidebar tree, merge preview, delete-impact guard), which describe direct-document operations.
|
||||||
|
- `subtreeDocumentCount` — **distinct** documents tagged with that tag **or any descendant** (subtree rollup, recursive-CTE closure, depth guard ≤50). Read by the reader surfaces (`/themen` page, dashboard `ThemenWidget`) so the box number matches what `/documents?tag=X` actually finds.
|
||||||
|
|
||||||
## What this domain does NOT own
|
## What this domain does NOT own
|
||||||
|
|
||||||
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Optional<Tag> findByNameIgnoreCase(String name);
|
// Tag-name resolution (see TagService.findOrCreate). Names that collide case-insensitively across
|
||||||
|
// the canonical tree are VALID — a parent and its same-named lowercase child (e.g. "Geburt" /
|
||||||
|
// "Geburt/geburt") are distinct nodes with their own source_ref and document attachments. So
|
||||||
|
// resolution must be exact-case first, then a non-throwing list for the case-insensitive fallback.
|
||||||
|
// Do NOT add a unique(lower(name)) constraint — it would reject these legitimate rows. See #730.
|
||||||
|
Optional<Tag> findByName(String name);
|
||||||
|
|
||||||
|
List<Tag> findAllByNameIgnoreCase(String name);
|
||||||
|
|
||||||
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
||||||
Optional<Tag> findBySourceRef(String sourceRef);
|
Optional<Tag> findBySourceRef(String sourceRef);
|
||||||
@@ -126,4 +133,31 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
*/
|
*/
|
||||||
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
|
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
|
||||||
List<TagCount> findDocumentCountsPerTag();
|
List<TagCount> findDocumentCountsPerTag();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns (tagId, count) pairs where count is the number of <b>distinct</b> documents tagged
|
||||||
|
* with that tag <b>or any of its descendants</b> (full subtree rollup).
|
||||||
|
* <p>
|
||||||
|
* Builds a tag closure of (ancestor_id, descendant_id) pairs via a recursive CTE — each tag is
|
||||||
|
* its own ancestor at depth 0, then descends into children (depth guard of 50 levels prevents a
|
||||||
|
* cycle or pathological depth from running away) — joins it to {@code document_tags} on the
|
||||||
|
* descendant, and counts distinct documents per ancestor. A document tagged with several tags in
|
||||||
|
* the same subtree is therefore counted once. Tags whose entire subtree holds no documents do
|
||||||
|
* not appear in the result (they default to 0 in the tree). One aggregate query for all tags.
|
||||||
|
*/
|
||||||
|
@Query(value = """
|
||||||
|
WITH RECURSIVE closure AS (
|
||||||
|
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth FROM tag
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.ancestor_id, t.id AS descendant_id, c.depth + 1
|
||||||
|
FROM tag t
|
||||||
|
JOIN closure c ON t.parent_id = c.descendant_id
|
||||||
|
WHERE c.depth < 50
|
||||||
|
)
|
||||||
|
SELECT c.ancestor_id AS tagId, COUNT(DISTINCT dt.document_id) AS count
|
||||||
|
FROM closure c
|
||||||
|
JOIN document_tags dt ON dt.tag_id = c.descendant_id
|
||||||
|
GROUP BY c.ancestor_id
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<TagCount> findSubtreeDocumentCountsPerTag();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.tag;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -45,6 +46,10 @@ public class TagService {
|
|||||||
return enrichWithRelatives(matched);
|
return enrichWithRelatives(matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Tag> findByNameContaining(String fragment) {
|
||||||
|
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
public Tag getById(UUID id) {
|
public Tag getById(UUID id) {
|
||||||
return tagRepository.findById(id)
|
return tagRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||||
@@ -55,10 +60,21 @@ public class TagService {
|
|||||||
return tagRepository.findBySourceRef(sourceRef);
|
return tagRepository.findBySourceRef(sourceRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a tag name to a single tag, creating one when absent. Never throws on case-insensitive
|
||||||
|
* collisions: names that differ only by case are valid distinct nodes in the canonical tree (a
|
||||||
|
* parent and its same-named lowercase child), so resolution prefers an exact-case match, then
|
||||||
|
* falls back to the lowest-id case-insensitive match, then creates. See #730.
|
||||||
|
*/
|
||||||
public Tag findOrCreate(String name) {
|
public Tag findOrCreate(String name) {
|
||||||
String cleanName = name.trim();
|
String cleanName = name.trim();
|
||||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
Optional<Tag> exact = tagRepository.findByName(cleanName);
|
||||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
if (exact.isPresent()) return exact.get(); // exact-case wins (edit round-trip replays the stored name)
|
||||||
|
List<Tag> caseInsensitive = tagRepository.findAllByNameIgnoreCase(cleanName);
|
||||||
|
if (!caseInsensitive.isEmpty()) {
|
||||||
|
return caseInsensitive.stream().min(Comparator.comparing(Tag::getId)).orElseThrow(); // deterministic tie-break by id — list is non-empty, never throws
|
||||||
|
}
|
||||||
|
return tagRepository.save(Tag.builder().name(cleanName).build()); // create-when-absent (orphan tag: null sourceRef/parentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,19 +188,27 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all tags assembled into a tree with document counts per node.
|
* Returns all tags assembled into a tree, each node carrying two counts:
|
||||||
* Uses a single aggregate query to avoid N+1 behaviour.
|
* {@code documentCount} — documents tagged with that exact tag (direct) — and
|
||||||
* NOTE: document counts are global per tag, not scoped to any search filter.
|
* {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
|
||||||
* The tree endpoint is only used for the admin sidebar, so this is intentional.
|
* (subtree rollup). Each count comes from one aggregate query (no N+1).
|
||||||
|
* NOTE: counts are global per tag, not scoped to any search filter.
|
||||||
|
* Consumed by the reader surfaces (/themen page, dashboard ThemenWidget — which read the
|
||||||
|
* subtree rollup) as well as the admin sidebar and tag operation previews (which read the
|
||||||
|
* direct count).
|
||||||
*/
|
*/
|
||||||
public List<TagTreeNodeDTO> getTagTree() {
|
public List<TagTreeNodeDTO> getTagTree() {
|
||||||
List<Tag> all = tagRepository.findAll();
|
List<Tag> all = tagRepository.findAll();
|
||||||
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
|
Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
|
||||||
.collect(Collectors.toMap(
|
Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
|
||||||
TagRepository.TagCount::getTagId,
|
return buildTree(all, counts, subtreeCounts);
|
||||||
TagRepository.TagCount::getCount
|
}
|
||||||
));
|
|
||||||
return buildTree(all, counts);
|
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
|
||||||
|
return counts.stream().collect(Collectors.toMap(
|
||||||
|
TagRepository.TagCount::getTagId,
|
||||||
|
TagRepository.TagCount::getCount
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── private helpers ─────────────────────────────────────────────────────
|
// ─── private helpers ─────────────────────────────────────────────────────
|
||||||
@@ -259,12 +283,14 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) {
|
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
|
||||||
|
Map<UUID, Long> subtreeCounts) {
|
||||||
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
||||||
for (Tag tag : tags) {
|
for (Tag tag : tags) {
|
||||||
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
||||||
|
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
|
||||||
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
||||||
tag.getId(), tag.getName(), tag.getColor(), documentCount,
|
tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount,
|
||||||
new ArrayList<>(), tag.getParentId()
|
new ArrayList<>(), tag.getParentId()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ public record TagTreeNodeDTO(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
||||||
String color,
|
String color,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED,
|
||||||
|
description = "Distinct documents tagged with this tag or any descendant tag (subtree rollup)")
|
||||||
|
int subtreeDocumentCount,
|
||||||
List<TagTreeNodeDTO> children,
|
List<TagTreeNodeDTO> children,
|
||||||
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user