Compare commits
6 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1a36a0d6 | ||
|
|
d56a9eb401 | ||
|
|
5dc1bf6bfb | ||
|
|
935c8eadd2 | ||
|
|
453e709a7c | ||
|
|
74fdc0cef7 |
@@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
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,17 +3,10 @@ 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 Spec-Driven TDD Workflow
|
# Implement — Felix Brandt's Issue/PR-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.:
|
||||||
@@ -54,19 +47,9 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -75,12 +58,10 @@ Do not start Phase 2 until you have read everything.
|
|||||||
|
|
||||||
### Issue mode
|
### Issue mode
|
||||||
|
|
||||||
First, check the spec's `## Open Questions` — **any unresolved item there is a blocker** and
|
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
|
||||||
must be answered before implementation (SDD step 5). Then identify any further point that is
|
- Scope questions (is X in or out of this issue?)
|
||||||
genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
|
- Design decisions with multiple valid approaches where the choice affects architecture
|
||||||
- Scope questions (is X in or out? — check `## Out of Scope` first)
|
- Missing acceptance criteria (how do we know when this is done?)
|
||||||
- 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?)
|
||||||
|
|
||||||
@@ -100,15 +81,12 @@ 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,
|
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be:
|
||||||
**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 (red/green order — a failing test precedes its implementation)
|
- Ordered so each item builds on the previous ones
|
||||||
- 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.:
|
||||||
```
|
```
|
||||||
@@ -119,10 +97,10 @@ Format:
|
|||||||
```
|
```
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|
||||||
1. [backend] PersonController returns 404 when person id does not exist — REQ-006
|
1. [backend] PersonController returns 404 when person id does not exist
|
||||||
2. [migration] V<n> add index on documents.sender_id (verify next free number on disk) — REQ-002
|
2. [migration] Add index on documents.sender_id for performance
|
||||||
3. [frontend] PersonCard renders full name from firstName + lastName props — REQ-004
|
3. [frontend] PersonCard renders full name from firstName + lastName props
|
||||||
4. [frontend] PersonCard shows placeholder when both names are null — REQ-004
|
4. [frontend] PersonCard shows placeholder when both names are null
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -167,22 +145,12 @@ 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, referencing the
|
Commit atomically after each task using the project's commit conventions:
|
||||||
issue (`Refs #n` / `Closes #n`) on the last line:
|
|
||||||
```
|
```
|
||||||
feat(scope): short imperative description
|
feat(scope): short imperative description
|
||||||
|
|
||||||
Refs #<n>
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||||
Co-Authored-By: <model> <noreply@anthropic.com>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Move to the next task immediately.
|
Move to the next task immediately.
|
||||||
@@ -196,10 +164,8 @@ 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 — 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.
|
- Never add behavior beyond what the current task requires
|
||||||
- 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
|
||||||
@@ -212,16 +178,10 @@ 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
|
||||||
4. Post a completion comment on the Gitea issue summarising what was implemented, mapping each
|
3. Post a completion comment on the Gitea issue summarising what was implemented, listing all commits made
|
||||||
`REQ-NNN` to its commit and test, and listing all commits made
|
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.)
|
||||||
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,15 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: review-issue
|
name: review-issue
|
||||||
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.
|
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Multi-Persona Spec Review (SDD)
|
# Multi-Persona Feature Issue Review
|
||||||
|
|
||||||
You will perform a thorough multi-persona **spec review** of the given Gitea feature issue and
|
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.
|
||||||
post each persona's findings as a **separate comment** on the issue. This is the SDD
|
|
||||||
spec-review gate (step 4 of [SPEC_DRIVEN_DEVELOPMENT.md](../../../SPEC_DRIVEN_DEVELOPMENT.md)):
|
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts.
|
||||||
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
|
||||||
|
|
||||||
@@ -21,83 +19,57 @@ Parse it to extract:
|
|||||||
- `repo` — e.g. `familienarchiv`
|
- `repo` — e.g. `familienarchiv`
|
||||||
- `issue_number` — e.g. `161`
|
- `issue_number` — e.g. `161`
|
||||||
|
|
||||||
## Step 0 — Load the SDD ground truth
|
## Step 1 — Gather Issue Context
|
||||||
|
|
||||||
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 — read them so personas don't repeat what's already been said
|
2. All existing comments on the issue via `issue_read` — 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 (identity + checklist)
|
## Step 2 — Read Every Persona
|
||||||
|
|
||||||
Each persona is its **character identity** (`.claude/personas/`) **plus** its **SDD spec-review
|
Read all six persona files from `.claude/personas/`:
|
||||||
checklist** (`.specify/personas/`). Adopt the voice from the former; gate the spec with the latter.
|
- `developer.md` → Felix Brandt
|
||||||
|
- `architect.md` → architect persona
|
||||||
|
- `tester.md` → tester persona
|
||||||
|
- `security_expert.md` → security persona
|
||||||
|
- `ui_expert.md` → UI/UX persona
|
||||||
|
- `devops.md` → DevOps persona
|
||||||
|
|
||||||
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) |
|
## Step 3 — Write Each Review
|
||||||
|---|---|---|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
The tester lens (acceptance-criteria quality, edge cases) is carried by the Requirements
|
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that:
|
||||||
Engineer checklist (testable, measurable criteria) — no separate tester comment at spec time.
|
|
||||||
|
|
||||||
## Step 3 — Run each checklist against the spec
|
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps
|
||||||
|
- 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)
|
||||||
|
|
||||||
For each persona, walk **every item** in its `.specify/personas/` checklist and assign
|
Format each comment in Markdown with a persona header, e.g.:
|
||||||
**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:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
### 🔐 Security — Spec Review
|
## 👨💻 Felix Brandt — Senior Fullstack Developer
|
||||||
|
|
||||||
| # | Item | Status | Note |
|
### Questions & Observations
|
||||||
|---|------|--------|------|
|
...
|
||||||
| 1 | All mutating endpoints have authn + authz `If` clauses | FAIL | REQ-004 POST has no 401 clause (CWE-...) |
|
|
||||||
| 2 | ... | PASS | |
|
|
||||||
|
|
||||||
**Verdict: CHANGES REQUESTED** — blocking FAIL: #1. Resolve before implementation.
|
### Suggestions
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Post all six comments. If a persona's checklist is entirely PASS, still post the table and a
|
Keep each comment focused and scannable. Use bullet points. Avoid walls of text.
|
||||||
`Verdict: APPROVE` so the team knows the perspective was applied. Keep comments scannable.
|
|
||||||
|
|
||||||
These verdicts are a **pre-implementation gate**, not a PR merge gate: a `FAIL` means the
|
## Step 4 — Post Comments
|
||||||
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.
|
|
||||||
|
|
||||||
## Step 5 — Report back
|
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool.
|
||||||
|
|
||||||
|
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:
|
||||||
- Each persona's verdict (APPROVE / CHANGES REQUESTED)
|
- Which personas posted feedback
|
||||||
- The consolidated list of blocking FAILs (these must be resolved before implementation)
|
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged)
|
||||||
- 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,95 +1,74 @@
|
|||||||
---
|
---
|
||||||
name: review-pr
|
name: review-pr
|
||||||
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.
|
description: Multi-persona PR review. Each persona from .claude/personas/ reviews the PR and posts their findings as a separate Gitea comment.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Multi-Persona PR Review (SDD)
|
# Multi-Persona PR Review
|
||||||
|
|
||||||
You will perform a thorough multi-persona code review of the given PR and post each persona's
|
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.
|
||||||
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 `owner`, `repo`, and `pull_number`.
|
Parse it to extract:
|
||||||
|
- `owner` — e.g. `marcel`
|
||||||
|
- `repo` — e.g. `familienarchiv`
|
||||||
|
- `pull_number` — e.g. `160`
|
||||||
|
|
||||||
## Step 0 — Load the SDD ground truth
|
## Step 1 — Gather PR Context
|
||||||
|
|
||||||
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/head branch) via `pull_request_read`
|
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
|
||||||
2. The list of changed files
|
2. The list of changed files via `get_dir_contents` or the PR files endpoint
|
||||||
3. The full content of every changed file at the head commit via `get_file_contents`
|
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents`
|
||||||
|
|
||||||
Read ALL changed files completely before starting. Do not skip files.
|
Read ALL changed files completely before starting any review. Do not skip files.
|
||||||
|
|
||||||
## Step 2 — Read every persona (identity + checklist)
|
## Step 2 — Read Every Persona
|
||||||
|
|
||||||
Adopt each persona's voice from `.claude/personas/`; apply its review lens. For the SDD
|
Read all six persona files from `.claude/personas/`:
|
||||||
personas, also re-read the matching `.specify/personas/` checklist — at PR time the same
|
- `developer.md` → Felix Brandt
|
||||||
checklist items are verified against the **code** rather than the spec.
|
- `architect.md` → architect persona
|
||||||
|
- `tester.md` → tester persona
|
||||||
|
- `security_expert.md` → security persona
|
||||||
|
- `ui_expert.md` → UI/UX persona
|
||||||
|
- `devops.md` → DevOps persona
|
||||||
|
|
||||||
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) | PR-time focus |
|
## Step 3 — Write Each Review
|
||||||
|---|---|---|---|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## Step 3 — Write each review
|
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that:
|
||||||
|
|
||||||
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; cite the constitution rule
|
- Lists concrete findings with file paths and line references where relevant
|
||||||
(e.g. "violates §2.4 — `updatedBy` bound from request body") or the `REQ-NNN` at issue
|
- Distinguishes blockers (must fix) from suggestions (nice to have)
|
||||||
- Distinguishes **blockers** (must fix) from **suggestions** (nice to have)
|
- 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)
|
||||||
- **Requirements Engineer specifically** produces a traceability table — for each `REQ-NNN`:
|
- Stays focused — only comment on what the persona would actually care about
|
||||||
is it implemented? is there a test? is `rtm.md` updated to `Done`? Any unimplemented or
|
|
||||||
untested REQ is a blocker. Any code behavior with no backing requirement is flagged
|
Format each comment in Markdown with a persona header, e.g.:
|
||||||
(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.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
### 🔐 Security — PR Review
|
## 👨💻 Felix Brandt — Senior Fullstack Developer
|
||||||
|
|
||||||
**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** via the Gitea MCP `issue_write` tool
|
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).
|
||||||
(issues and PRs share the comment API). Post all personas; if one has nothing to flag, post a
|
|
||||||
brief "LGTM" naming what they checked.
|
|
||||||
|
|
||||||
## Step 5 — Report back
|
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.
|
||||||
|
|
||||||
Summarize to the user:
|
## Step 5 — Report Back
|
||||||
- Each persona's verdict and the overall verdict (worst-case wins: any "Changes requested" → overall "Changes requested")
|
|
||||||
- The full list of blockers, grouped by persona
|
After all comments are posted, summarize to the user:
|
||||||
- **Traceability status:** which `REQ-NNN` are implemented+tested vs. missing, and whether
|
- Which personas posted comments
|
||||||
`rtm.md` is in sync
|
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested")
|
||||||
- Any constitution Do-Not-Touch violations (called out explicitly)
|
- A bullet list of the top blockers found (if any)
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
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.>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
---
|
|
||||||
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 | | |
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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"; }
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 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:**`.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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' }
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# 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.**
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 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`) |
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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.
|
|
||||||
175
.specify/rtm.md
175
.specify/rtm.md
@@ -1,175 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done |
|
|
||||||
| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done |
|
|
||||||
| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
|
|
||||||
| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done |
|
|
||||||
| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done |
|
|
||||||
| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done |
|
|
||||||
| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done |
|
|
||||||
| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done |
|
|
||||||
| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done |
|
|
||||||
| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done |
|
|
||||||
| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done |
|
|
||||||
| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done |
|
|
||||||
| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done |
|
|
||||||
| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
|
||||||
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
|
|
||||||
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
|
|
||||||
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
|
||||||
| REQ-001 | `/zeitstrahl` wraps the timeline in a `.tl-canvas` surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | `frontend/src/routes/zeitstrahl/+page.svelte` | `routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border` | Done |
|
|
||||||
| REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/timelineMeta.ts`, `frontend/src/routes/zeitstrahl/+page.svelte` | `timelineMeta.spec.ts` (4 cases), `routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line`, `#omits the range segment`, `#omits the entire sub-line` | Done |
|
|
||||||
| REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#centers the year badge on the axis at desktop`, `#left-aligns the year badge at phone width`, `#keeps the sticky year heading at top:4rem` | Done |
|
|
||||||
| REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone` | Done |
|
|
||||||
| REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone` | Done |
|
|
||||||
| REQ-006 | axis gradient 3-stop mint→navy→slate via `--palette-mint`/`--palette-navy`/`--c-tag-slate` | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient` (+ REQ-013 grep) | Done |
|
|
||||||
| REQ-007 | EventPill subtitle `{date} · {provenance}` keyed off `entry.derived` (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#appends the "abgeleitet" provenance`, `#appends the "kuratiert" provenance`, `#never shows persönlich/SEASON` | Done |
|
|
||||||
| REQ-008 | LetterCard title prefixed with `aria-hidden` ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉`, `#renders an HTML-bearing title verbatim` | Done |
|
|
||||||
| REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#appends the inline "· historisch"`, `#follows the RANGE span pill with inline "· historisch"` | Done |
|
|
||||||
| REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉`, `#keeps the expand toggle and its label` | Done |
|
|
||||||
| REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels` | Done |
|
|
||||||
| REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count`, `#omits the "Ohne Datum" section when empty` | Done |
|
|
||||||
| REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | grep gate (REQ-013 form) → zero | Done |
|
|
||||||
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
|
|
||||||
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
|
|
||||||
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |
|
|
||||||
| REQ-001 | store relationship from/to as nullable LocalDate + NOT-NULL DatePrecision (default UNKNOWN) | #837 | relationship-edit-dates | `person/relationship/PersonRelationship.java`, `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#yearColumnsDropped_andNamedCheckConstraintsExist`, `RelationshipServiceTest#addRelationship_persists_with_storage_truth` | Done |
|
|
||||||
| REQ-002 | V78 backfills non-null years as `{year}-01-01`/YEAR, nulls → null/UNKNOWN, rows preserved | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#backfill_fromYearAndToYear_becomeYearPrecisionDates`, `#backfill_bothNull_leavesDatesNullAndPrecisionsUnknown`, `#backfill_preservesRowCount` | Done |
|
|
||||||
| REQ-003 | named DB CHECKs: coherence both ends + fromDate ≤ toDate | #837 | relationship-edit-dates | `V78__relationship_years_to_localdate.sql` | `RelationshipMigrationTest#orderCheckConstraint_rejectsToDateBeforeFromDate`, `#coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision` | Done |
|
|
||||||
| REQ-004 | PUT updates the relationship → 200 RelationshipDTO | #837 | relationship-edit-dates | `person/relationship/RelationshipController#updateRelationship`, `RelationshipService#updateRelationship` | `RelationshipControllerTest#updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user`, `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `page.server.spec.ts#updateRelationship PUTs to the relId path with the new body` | Done |
|
|
||||||
| REQ-005 | create + update rejected with 403 without WRITE_ALL | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (`@RequirePermission`) | `RelationshipControllerTest#updateRelationship_returns403_for_READ_ALL_only_user`, `#addRelationship_returns403_for_user_with_READ_ALL_only` | Done |
|
|
||||||
| REQ-006 | relId not existing / not owned by person → 404 RELATIONSHIP_NOT_FOUND | #837 | relationship-edit-dates | `person/relationship/RelationshipService#loadOwnedRelationship` | `RelationshipServiceTest#updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person`, `RelationshipServiceIntegrationTest#updateRelationship_throws_404_when_rel_belongs_to_different_person` | Done |
|
|
||||||
| REQ-007 | update with relatedPersonId == {id} → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_VALIDATION_ERROR_on_self_relation` | Done |
|
|
||||||
| REQ-008 | resulting (person, relatedPerson, type) duplicate → 409 DUPLICATE_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_DUPLICATE_when_db_constraint_violated` | Done |
|
|
||||||
| REQ-009 | update to PARENT_OF with reverse PARENT_OF present → 409 CIRCULAR_RELATIONSHIP | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists` | Done |
|
|
||||||
| REQ-010 | toDate before fromDate → 400 INVALID_RELATIONSHIP_DATES | #837 | relationship-edit-dates | `person/relationship/RelationshipService#validateRelationshipDates`, `exception/ErrorCode`, `frontend/src/lib/shared/errors.ts` | `RelationshipServiceTest#addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate`, `#updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate` | Done |
|
|
||||||
| REQ-011 | date+UNKNOWN precision, or precision without date → 400 INVALID_DATE_PRECISION | #837 | relationship-edit-dates | `person/relationship/RelationshipService#requireDatePrecisionCoherence` | `RelationshipServiceTest#addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown`, `#addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date` | Done |
|
|
||||||
| REQ-012 | invalid enum / missing relatedPersonId·relationType / notes > 2000 → 400 VALIDATION_ERROR | #837 | relationship-edit-dates | `person/relationship/RelationshipUpsertRequest` (Bean Validation), `RelationshipController` | `RelationshipControllerTest#updateRelationship_returns400_when_relationType_is_unknown_value`, `#addRelationship_returns400_when_relationType_is_unknown_value` | Done |
|
|
||||||
| REQ-013 | updating into a family type flags both endpoints (additive) | #837 | relationship-edit-dates | `person/relationship/RelationshipService#updateRelationship` | `RelationshipServiceTest#updateRelationship_marks_both_endpoints_family_when_updated_to_family_type` | Done |
|
|
||||||
| REQ-014 | persist + display notes on create, update, read and edit views | #837 | relationship-edit-dates | `person/relationship/RelationshipService`, `frontend/.../AddRelationshipForm.svelte`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `RelationshipServiceIntegrationTest#updateRelationship_persists_new_type_dates_and_notes`, `AddRelationshipForm.svelte.spec.ts#round-trips the notes into the textarea`, `PersonRelationshipsCard.svelte.test.ts#shows the notes line` | Done |
|
|
||||||
| REQ-015 | detail view shows the date range at its precision; no dates → no date line | #837 | relationship-edit-dates | `frontend/src/lib/person/relationshipDates.ts`, `routes/persons/[id]/PersonRelationshipsCard.svelte` | `relationshipDates.spec.ts`, `PersonRelationshipsCard.svelte.test.ts#renders the date range at its stored precision`, `#renders no date line when the relationship has no dates` | Done |
|
|
||||||
| REQ-016 | edit affordance opens a form pre-filled with type/person/dates+precision/notes; precision DAY/MONTH/YEAR | #837 | relationship-edit-dates | `frontend/.../AddRelationshipForm.svelte`, `RelationshipDateField.svelte`, `RelationshipChip.svelte` | `AddRelationshipForm.svelte.spec.ts#pre-fills the from-date as dd.mm.yyyy`, `#offers only DAY/MONTH/YEAR in each precision select`, `RelationshipChip.svelte.spec.ts#shows an Edit affordance with an accessible name when canWrite and onEdit` | Done |
|
|
||||||
| REQ-017 | derived Heirat sources SPOUSE_OF.fromDate + fromDatePrecision | #837 | relationship-edit-dates | `timeline/TimelineEventService#buildMarriageEvents` | `DerivedEventsAssemblyTest#should_emit_day_precision_heirat_from_spouse_fromDate` | Done |
|
|
||||||
| REQ-018 | unauthenticated PUT → 401, no row modified | #837 | relationship-edit-dates | `person/relationship/RelationshipController` (SecurityConfig) | `RelationshipControllerTest#updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service` | Done |
|
|
||||||
| REQ-019 | while a create/update request is in flight, submit is disabled + shows a progress indicator | #837 | relationship-edit-dates | `frontend/src/lib/person/relationship/AddRelationshipForm.svelte` | `AddRelationshipForm.svelte.spec.ts#disables the submit and shows a progress spinner while a submit is in flight` | Done |
|
|
||||||
| REQ-001 | TimelineEntryDTO carries rootTagId/rootTagName/rootTagColor for LETTER entries, assembled in-transaction (id+name+token only) | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `timeline/TimelineService#mapDocument` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag` | Done |
|
|
||||||
| REQ-002 | the three root-tag fields are nullable and not `@Schema(requiredMode = REQUIRED)` | #835 | zeitstrahl-tag-chips | `timeline/TimelineEntryDTO`, `frontend/src/lib/generated/api.ts` (optional) | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields` (+ regenerated `api.ts` shows `rootTag*?`) | Done |
|
|
||||||
| REQ-003 | primary tag = root ancestor of the alphabetically-first assigned tag, resolved via TagService | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `TagServiceTest#resolveRootTags_walksChildToRoot_withRootColor`, `TagServiceIntegrationTest#resolveRootTags_walksPersistedChainToRoot_withRootColor` | Done |
|
|
||||||
| REQ-004 | roots resolved in a single batched/memoized pass (≤ M findAncestorIds, no per-letter N+1); color from the root token | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `timeline/TimelineService#resolveLetterRootTags` | `TagServiceTest#resolveRootTags_memoizesPerDistinctTag_noNPlusOne`, `TimelineServiceTest#root_tags_resolved_in_a_single_batched_pass` | Done |
|
|
||||||
| REQ-005 | a letter with no tags → all three fields null; LetterCard renders no chip | #835 | zeitstrahl-tag-chips | `timeline/TimelineService#mapDocument`, `frontend/src/lib/timeline/LetterCard.svelte` | `TimelineServiceTest#untagged_letter_has_no_root_tag_fields`, `LetterCard.svelte.spec.ts#renders no chip when the letter has no root tag` | Done |
|
|
||||||
| REQ-006 | a letter with multiple tags → exactly one primary root (deterministic) | #835 | zeitstrahl-tag-chips | `timeline/TimelineService#primaryTag` | `TimelineServiceTest#letter_with_tags_carries_its_primary_root_tag`, `LetterCard.svelte.spec.ts#renders one root-tag chip beneath the meta line` | Done |
|
|
||||||
| REQ-007 | a colorless root → rootTagColor null; frontend renders a neutral chip, no `var(--c-tag-)` | #835 | zeitstrahl-tag-chips | `tag/TagService#resolveRootTags`, `frontend/src/lib/timeline/TagChip.svelte` | `TagServiceTest#resolveRootTags_returnsNullColor_whenRootHasNoColor`, `TimelineServiceTest#letter_primary_root_without_color_yields_null_color`, `TagChip.svelte.spec.ts#renders a neutral chip with no --c-tag- binding when color is null` | Done |
|
|
||||||
| REQ-008 | LetterCard with a rootTagName renders one §3 chip beneath the meta line | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte`, `LetterCard.svelte` | `TagChip.svelte.spec.ts#renders the tag name`, `LetterCard.svelte.spec.ts#renders one root-tag chip beneath the meta line` | Done |
|
|
||||||
| REQ-008a | a long name truncates with ellipsis, no horizontal overflow at 320px; full name in the chip title | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte`, `LetterCard.svelte` | `LetterCard.svelte.spec.ts#keeps a long tag name from overflowing the card at 320px`, `TagChip.svelte.spec.ts#exposes the full name as the chip title` | Done |
|
|
||||||
| REQ-009 | chip color applied via `var(--c-tag-{token})`, no raw hex | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#applies the color via var(--c-tag-{token}), never raw hex` | Done |
|
|
||||||
| REQ-010 | rootTagName rendered via `{...}` escaping; no `{@html}` in lib/timeline | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#renders an HTML-bearing name as inert text`, `grep -rn '@html' frontend/src/lib/timeline/` → zero | Done |
|
|
||||||
| REQ-011 | colored square aria-hidden; sr-only theme label prefix so color is never the only cue | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/TagChip.svelte` | `TagChip.svelte.spec.ts#prefixes the name with an sr-only theme label and a decorative square` | Done |
|
|
||||||
| REQ-012 | chip renders wherever a LetterCard renders (global timeline + expanded YearLetterStrip) | #835 | zeitstrahl-tag-chips | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the chip inside an expanded YearLetterStrip too` | Done |
|
|
||||||
| REQ-013 | sr-only theme label is a Paraglide key present in de/en/es | #835 | zeitstrahl-tag-chips | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl tag-chip label key is present in all locales` | Done |
|
|
||||||
| REQ-014 | GET /api/timeline stays read-only (READ_ALL); no new endpoint/ErrorCode/IDOR; assembly logs UUIDs only | #835 | zeitstrahl-tag-chips | `timeline/TimelineController` (unchanged), `timeline/TimelineService` | `TimelineControllerTest#returns_200_with_read_all_permission`, `#returns_403_when_authenticated_without_read_all` (unchanged path); no tag names logged (review) | Done |
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<!--
|
|
||||||
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>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<!--
|
|
||||||
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 | | |
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<!--
|
|
||||||
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.>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -16,10 +16,6 @@ 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
|
||||||
@@ -99,7 +95,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
|
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||||
└── user/ User domain — AppUser, UserGroup, UserService
|
└── user/ User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,7 +117,6 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
| `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` |
|
| `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 |
|
| `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`
|
||||||
|
|
||||||
@@ -170,7 +165,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); `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); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
**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).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -207,8 +202,6 @@ frontend/src/routes/
|
|||||||
├── 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)
|
|
||||||
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
|
|
||||||
├── 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
|
||||||
@@ -280,7 +273,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); `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); `INVALID_RELATIONSHIP_DATES` (relationship `toDate` before `fromDate`), plus a generic `CONFLICT` (409 optimistic-lock backstop).
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ 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,7 +1,6 @@
|
|||||||
# 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).
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-field validation and normalization shared by every domain that stores a
|
|
||||||
* {@link LocalDate} + {@link DatePrecision} pair — a person's life dates (ADR-039 / V76)
|
|
||||||
* and a relationship's from/to dates (ADR-044 / V78). Kept out of {@link DatePrecision}
|
|
||||||
* itself because that enum is a frozen contract mirror of the import normalizer (ADR-025)
|
|
||||||
* and must carry no behaviour.
|
|
||||||
*/
|
|
||||||
public final class DatePrecisionValidation {
|
|
||||||
|
|
||||||
private DatePrecisionValidation() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforces the date ⇔ precision coherence the V76/V78 CHECK constraints also enforce:
|
|
||||||
* a date requires a non-{@code UNKNOWN} precision, and a non-{@code UNKNOWN} precision
|
|
||||||
* requires a date. Validated in-service so the caller gets a structured 400 instead of
|
|
||||||
* the database constraint's raw 500.
|
|
||||||
*
|
|
||||||
* @param side human-readable field label woven into the error message ("birth", "from", …)
|
|
||||||
*/
|
|
||||||
public static void requireCoherence(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 (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
|
||||||
side + " date precision " + precision + " is set without a date");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A null precision means "no precision recorded" → {@link DatePrecision#UNKNOWN}. */
|
|
||||||
public static DatePrecision normalize(DatePrecision precision) {
|
|
||||||
return precision == null ? DatePrecision.UNKNOWN : precision;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,11 +56,6 @@ 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);
|
||||||
|
|||||||
@@ -1051,10 +1051,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,8 +122,6 @@ public enum ErrorCode {
|
|||||||
CIRCULAR_RELATIONSHIP,
|
CIRCULAR_RELATIONSHIP,
|
||||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||||
DUPLICATE_RELATIONSHIP,
|
DUPLICATE_RELATIONSHIP,
|
||||||
/** A relationship's toDate is before its fromDate. 400 */
|
|
||||||
INVALID_RELATIONSHIP_DATES,
|
|
||||||
|
|
||||||
// --- 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 */
|
||||||
@@ -157,14 +155,6 @@ 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,
|
||||||
@@ -172,8 +162,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,30 +104,6 @@ public class GlobalExceptionHandler {
|
|||||||
return "unknown";
|
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);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import org.raddatz.familienarchiv.person.PersonType;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
|
|||||||
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||||
try {
|
try {
|
||||||
relationshipService.addRelationship(person,
|
relationshipService.addRelationship(person,
|
||||||
new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
|
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
||||||
return true;
|
return true;
|
||||||
} catch (DomainException e) {
|
} catch (DomainException e) {
|
||||||
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||||
|
|||||||
@@ -242,7 +242,4 @@ 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);
|
||||||
|
|
||||||
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
|
|
||||||
List<Person> findByGeneration(Integer generation);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
|
||||||
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;
|
||||||
@@ -211,10 +210,6 @@ public class PersonService {
|
|||||||
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);
|
||||||
@@ -449,28 +444,41 @@ public class PersonService {
|
|||||||
.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())
|
||||||
.birthDate(dto.getBirthDate())
|
.birthDate(dto.getBirthDate())
|
||||||
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
|
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||||
.deathDate(dto.getDeathDate())
|
.deathDate(dto.getDeathDate())
|
||||||
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
|
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||||
.generation(dto.getGeneration())
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
|
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
||||||
// is shared with the relationship domain (DatePrecisionValidation); only the order check
|
|
||||||
// (and its BIRTH_AFTER_DEATH code) is life-date specific.
|
|
||||||
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||||
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
|
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
||||||
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
|
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
||||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
|
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) {
|
||||||
@@ -487,9 +495,9 @@ public class PersonService {
|
|||||||
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.setBirthDate(dto.getBirthDate());
|
person.setBirthDate(dto.getBirthDate());
|
||||||
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
|
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||||
person.setDeathDate(dto.getDeathDate());
|
person.setDeathDate(dto.getDeathDate());
|
||||||
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
|
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||||
// Form path: a human can clear generation back to null. Unlike the importer
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
// which routes through preferHuman, we write the DTO value verbatim.
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
person.setGeneration(dto.getGeneration());
|
person.setGeneration(dto.getGeneration());
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -41,25 +39,11 @@ public class PersonRelationship {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private RelationType relationType;
|
private RelationType relationType;
|
||||||
|
|
||||||
// Start/end of the relationship (wedding, employment start, …). The date column
|
@Column(name = "from_year")
|
||||||
// is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" —
|
private Integer fromYear;
|
||||||
// the V78 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN) and
|
|
||||||
// from_date <= to_date. Mirrors Person.{birth,death}Date (ADR-039 / ADR-044).
|
|
||||||
private LocalDate fromDate;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Column(name = "to_year")
|
||||||
@Column(name = "from_date_precision", nullable = false, length = 16)
|
private Integer toYear;
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private DatePrecision fromDatePrecision = DatePrecision.UNKNOWN;
|
|
||||||
|
|
||||||
private LocalDate toDate;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "to_date_precision", nullable = false, length = 16)
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private DatePrecision toDatePrecision = DatePrecision.UNKNOWN;
|
|
||||||
|
|
||||||
@Column(length = 2000)
|
@Column(length = 2000)
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -63,20 +63,11 @@ public class RelationshipController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
@Valid @RequestBody CreateRelationshipRequest dto) {
|
||||||
return ResponseEntity.status(HttpStatus.CREATED)
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
.body(relationshipService.addRelationship(id, dto));
|
.body(relationshipService.addRelationship(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/api/persons/{id}/relationships/{relId}")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public RelationshipDTO updateRelationship(
|
|
||||||
@PathVariable UUID id,
|
|
||||||
@PathVariable UUID relId,
|
|
||||||
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
|
||||||
return relationshipService.updateRelationship(id, relId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
|
||||||
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.relationship.dto.RelationshipUpsertRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
@@ -88,139 +86,66 @@ 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, RelationshipUpsertRequest dto) {
|
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||||
requireNotSelf(personId, dto.relatedPersonId());
|
if (personId.equals(dto.relatedPersonId())) {
|
||||||
|
throw DomainException.badRequest(
|
||||||
|
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||||
|
}
|
||||||
Person person = personService.getById(personId);
|
Person person = personService.getById(personId);
|
||||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||||
|
|
||||||
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
validateYears(dto.fromYear(), dto.toYear());
|
||||||
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
|
|
||||||
|
if (dto.relationType() == RelationType.PARENT_OF
|
||||||
|
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.CIRCULAR_RELATIONSHIP,
|
||||||
|
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
||||||
|
}
|
||||||
|
|
||||||
PersonRelationship rel = PersonRelationship.builder()
|
PersonRelationship rel = PersonRelationship.builder()
|
||||||
.person(person)
|
.person(person)
|
||||||
.relatedPerson(relatedPerson)
|
.relatedPerson(relatedPerson)
|
||||||
.relationType(dto.relationType())
|
.relationType(dto.relationType())
|
||||||
.fromDate(dto.fromDate())
|
.fromYear(dto.fromYear())
|
||||||
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
|
.toYear(dto.toYear())
|
||||||
.toDate(dto.toDate())
|
|
||||||
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
|
|
||||||
.notes(blankToNull(dto.notes()))
|
.notes(blankToNull(dto.notes()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
|
PersonRelationship saved;
|
||||||
flagFamilyMembership(dto.relationType(), person.getId(), relatedPerson.getId());
|
|
||||||
return toDTO(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public RelationshipDTO updateRelationship(UUID personId, UUID relId, RelationshipUpsertRequest dto) {
|
|
||||||
PersonRelationship rel = loadOwnedRelationship(personId, relId);
|
|
||||||
|
|
||||||
// The other party from {personId}'s viewpoint cannot be {personId} itself.
|
|
||||||
requireNotSelf(personId, dto.relatedPersonId());
|
|
||||||
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
|
||||||
|
|
||||||
// Preserve the directed orientation: {personId} keeps whichever role (subject or
|
|
||||||
// object) it already holds on the row, and the edited "related person" takes the
|
|
||||||
// other role. So a PARENT_OF edge stays parent→child whether the curator edits it
|
|
||||||
// from the parent's page or the child's.
|
|
||||||
boolean viewpointIsSubject = personId.equals(rel.getPerson().getId());
|
|
||||||
Person viewpoint = viewpointIsSubject ? rel.getPerson() : rel.getRelatedPerson();
|
|
||||||
Person other = personService.getById(dto.relatedPersonId());
|
|
||||||
Person newSubject = viewpointIsSubject ? viewpoint : other;
|
|
||||||
Person newObject = viewpointIsSubject ? other : viewpoint;
|
|
||||||
|
|
||||||
requireNoReverseParent(newSubject.getId(), newObject.getId(), dto.relationType());
|
|
||||||
|
|
||||||
rel.setPerson(newSubject);
|
|
||||||
rel.setRelatedPerson(newObject);
|
|
||||||
rel.setRelationType(dto.relationType());
|
|
||||||
rel.setFromDate(dto.fromDate());
|
|
||||||
rel.setFromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()));
|
|
||||||
rel.setToDate(dto.toDate());
|
|
||||||
rel.setToDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()));
|
|
||||||
rel.setNotes(blankToNull(dto.notes()));
|
|
||||||
|
|
||||||
PersonRelationship saved = persistOrConflict(rel, newSubject.getId(), newObject.getId(), dto.relationType());
|
|
||||||
flagFamilyMembership(dto.relationType(), newSubject.getId(), newObject.getId());
|
|
||||||
return toDTO(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- shared create/update invariants ---------------------------------------------
|
|
||||||
|
|
||||||
// A person cannot be related to themselves, from either viewpoint.
|
|
||||||
private static void requireNotSelf(UUID viewpointId, UUID relatedPersonId) {
|
|
||||||
if (viewpointId.equals(relatedPersonId)) {
|
|
||||||
throw DomainException.badRequest(
|
|
||||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A PARENT_OF edge must not already have its mirror (child PARENT_OF parent) stored —
|
|
||||||
// that would be a cycle. No-op for every other relation type.
|
|
||||||
private void requireNoReverseParent(UUID subjectId, UUID objectId, RelationType type) {
|
|
||||||
if (type == RelationType.PARENT_OF
|
|
||||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
|
||||||
objectId, subjectId, RelationType.PARENT_OF)) {
|
|
||||||
throw DomainException.conflict(
|
|
||||||
ErrorCode.CIRCULAR_RELATIONSHIP,
|
|
||||||
"Reverse PARENT_OF already exists between " + subjectId + " and " + objectId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveAndFlush so the unique_rel constraint violates synchronously and is caught here,
|
|
||||||
// inside the @Transactional boundary, not at commit time as a raw 500.
|
|
||||||
private PersonRelationship persistOrConflict(PersonRelationship rel, UUID subjectId, UUID objectId, RelationType type) {
|
|
||||||
try {
|
try {
|
||||||
return relationshipRepository.saveAndFlush(rel);
|
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
||||||
|
// caught here, not at commit time outside the @Transactional boundary.
|
||||||
|
saved = relationshipRepository.saveAndFlush(rel);
|
||||||
} catch (DataIntegrityViolationException e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
throw DomainException.conflict(
|
throw DomainException.conflict(
|
||||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||||
"Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
|
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
||||||
}
|
}
|
||||||
}
|
// Family-graph edges imply both endpoints are family members. Idempotent: the
|
||||||
|
// setter is a no-op when the person is already flagged, so re-imports stay clean.
|
||||||
// Family-graph edges imply both endpoints are family members. Idempotent (the setter is
|
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
|
||||||
// a no-op when already flagged, so re-imports stay clean) and additive — an edit never
|
personService.setFamilyMember(person.getId(), true);
|
||||||
// auto-unflags.
|
personService.setFamilyMember(relatedPerson.getId(), true);
|
||||||
private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
|
|
||||||
if (FAMILY_RELATION_TYPES.contains(type)) {
|
|
||||||
personService.setFamilyMember(subjectId, true);
|
|
||||||
personService.setFamilyMember(objectId, true);
|
|
||||||
}
|
}
|
||||||
|
return toDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteRelationship(UUID personId, UUID relId) {
|
public void deleteRelationship(UUID personId, UUID relId) {
|
||||||
PersonRelationship rel = loadOwnedRelationship(personId, relId);
|
|
||||||
relationshipRepository.delete(rel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loads the row and verifies {personId} is one of its endpoints. A mismatch is 404
|
|
||||||
// (not 403): an anti-enumeration choice so a curator cannot probe relationship ids
|
|
||||||
// belonging to people they cannot see. Shared by update + delete for consistency.
|
|
||||||
private PersonRelationship loadOwnedRelationship(UUID personId, UUID relId) {
|
|
||||||
PersonRelationship rel = relationshipRepository.findById(relId)
|
PersonRelationship rel = relationshipRepository.findById(relId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||||
|
|
||||||
UUID storageSubject = rel.getPerson().getId();
|
UUID storageSubject = rel.getPerson().getId();
|
||||||
UUID storageObject = rel.getRelatedPerson().getId();
|
UUID storageObject = rel.getRelatedPerson().getId();
|
||||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||||
throw DomainException.notFound(
|
throw DomainException.forbidden(
|
||||||
ErrorCode.RELATIONSHIP_NOT_FOUND,
|
|
||||||
"Relationship " + relId + " does not belong to person " + personId);
|
"Relationship " + relId + " does not belong to person " + personId);
|
||||||
}
|
}
|
||||||
return rel;
|
relationshipRepository.delete(rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -239,17 +164,10 @@ public class RelationshipService {
|
|||||||
return date != null ? date.getYear() : null;
|
return date != null ? date.getYear() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
|
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||||
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
|
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||||
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
|
throw DomainException.badRequest(
|
||||||
// check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
|
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
||||||
private static void validateRelationshipDates(LocalDate fromDate, DatePrecision fromPrecision,
|
|
||||||
LocalDate toDate, DatePrecision toPrecision) {
|
|
||||||
DatePrecisionValidation.requireCoherence(fromDate, fromPrecision, "from");
|
|
||||||
DatePrecisionValidation.requireCoherence(toDate, toPrecision, "to");
|
|
||||||
if (fromDate != null && toDate != null && toDate.isBefore(fromDate)) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_RELATIONSHIP_DATES,
|
|
||||||
"toDate " + toDate + " is before fromDate " + fromDate);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,10 +185,8 @@ public class RelationshipService {
|
|||||||
yearOf(rp.getBirthDate()),
|
yearOf(rp.getBirthDate()),
|
||||||
yearOf(rp.getDeathDate()),
|
yearOf(rp.getDeathDate()),
|
||||||
r.getRelationType(),
|
r.getRelationType(),
|
||||||
r.getFromDate(),
|
r.getFromYear(),
|
||||||
r.getFromDatePrecision(),
|
r.getToYear(),
|
||||||
r.getToDate(),
|
|
||||||
r.getToDatePrecision(),
|
|
||||||
r.getNotes());
|
r.getNotes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CreateRelationshipRequest(
|
||||||
|
@NotNull UUID relatedPersonId,
|
||||||
|
@NotNull RelationType relationType,
|
||||||
|
Integer fromYear,
|
||||||
|
Integer toYear,
|
||||||
|
@Size(max = 2000) String notes
|
||||||
|
) {}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,9 +26,7 @@ public record RelationshipDTO(
|
|||||||
Integer relatedPersonBirthYear,
|
Integer relatedPersonBirthYear,
|
||||||
Integer relatedPersonDeathYear,
|
Integer relatedPersonDeathYear,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||||
LocalDate fromDate,
|
Integer fromYear,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
|
Integer toYear,
|
||||||
LocalDate toDate,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
|
|
||||||
String notes
|
String notes
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for both creating and updating a relationship — the fields are
|
|
||||||
* identical, so one record serves {@code POST} and {@code PUT} (DRY). A null
|
|
||||||
* {@code *DatePrecision} is normalized to {@code UNKNOWN} by the service; the
|
|
||||||
* service then enforces coherence (date ⇔ non-UNKNOWN precision) and order
|
|
||||||
* (fromDate ≤ toDate).
|
|
||||||
*/
|
|
||||||
public record RelationshipUpsertRequest(
|
|
||||||
@NotNull UUID relatedPersonId,
|
|
||||||
@NotNull RelationType relationType,
|
|
||||||
LocalDate fromDate,
|
|
||||||
DatePrecision fromDatePrecision,
|
|
||||||
LocalDate toDate,
|
|
||||||
DatePrecision toDatePrecision,
|
|
||||||
@Size(max = 2000) String notes
|
|
||||||
) {}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.tag;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The root-ancestor view of a tag: its id, display name, and color token.
|
|
||||||
* Colors are stored only on root tags, so {@code color} is the authoritative token
|
|
||||||
* (one of {@link TagService#ALLOWED_TAG_COLORS}) or {@code null} when the root has none.
|
|
||||||
* Returned by {@link TagService#resolveRootTags} for read surfaces (the timeline chip,
|
|
||||||
* later the Thema buckets) that need a tag's theme without the entity graph.
|
|
||||||
*/
|
|
||||||
public record RootTag(UUID id, String name, String color) {
|
|
||||||
}
|
|
||||||
@@ -175,59 +175,6 @@ public class TagService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves each given tag to its root ancestor, returning a {@link RootTag} (id, name, color
|
|
||||||
* token) keyed by the input tag's id. A root tag maps to itself; a child is walked to the
|
|
||||||
* ancestor with no parent via {@link TagRepository#findAncestorIds} (one CTE per distinct
|
|
||||||
* non-root tag, memoized) plus a single batched {@code findAllById}, so a timeline of many
|
|
||||||
* letters sharing few tags costs O(distinct tags) queries, never O(letters). The color comes
|
|
||||||
* from the resolved root's stored token (null when the root has none). Safe on detached tags.
|
|
||||||
*/
|
|
||||||
public Map<UUID, RootTag> resolveRootTags(Collection<Tag> tags) {
|
|
||||||
if (tags == null || tags.isEmpty()) return Map.of();
|
|
||||||
|
|
||||||
Map<UUID, Tag> distinct = new LinkedHashMap<>();
|
|
||||||
for (Tag tag : tags) {
|
|
||||||
if (tag != null && tag.getId() != null) distinct.putIfAbsent(tag.getId(), tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<UUID, List<UUID>> ancestorIdsByTagId = new HashMap<>();
|
|
||||||
Set<UUID> idsToLoad = new HashSet<>();
|
|
||||||
for (Tag tag : distinct.values()) {
|
|
||||||
if (tag.getParentId() == null) continue;
|
|
||||||
List<UUID> ancestorIds = tagRepository.findAncestorIds(tag.getId());
|
|
||||||
ancestorIdsByTagId.put(tag.getId(), ancestorIds);
|
|
||||||
idsToLoad.addAll(ancestorIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<UUID, Tag> ancestorsById = idsToLoad.isEmpty() ? Map.of()
|
|
||||||
: tagRepository.findAllById(idsToLoad).stream()
|
|
||||||
.collect(Collectors.toMap(Tag::getId, t -> t));
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = new HashMap<>();
|
|
||||||
for (Tag tag : distinct.values()) {
|
|
||||||
Tag root = resolveRoot(tag, ancestorIdsByTagId.get(tag.getId()), ancestorsById);
|
|
||||||
result.put(tag.getId(), new RootTag(root.getId(), root.getName(), root.getColor()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Tag resolveRoot(Tag tag, List<UUID> ancestorIds, Map<UUID, Tag> ancestorsById) {
|
|
||||||
if (tag.getParentId() == null) return tag;
|
|
||||||
if (ancestorIds != null) {
|
|
||||||
for (UUID ancestorId : ancestorIds) {
|
|
||||||
Tag ancestor = ancestorsById.get(ancestorId);
|
|
||||||
if (ancestor != null && ancestor.getParentId() == null) return ancestor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No null-parent ancestor surfaced — the parent is orphaned or the chain is deeper than the
|
|
||||||
// findAncestorIds CTE's depth guard. Fall back to the tag as its own root, but surface it:
|
|
||||||
// a silently mislabeled root would otherwise be invisible. UUIDs only (no tag names logged).
|
|
||||||
log.warn("Tag {} has parent {} but no root surfaced from its ancestry; "
|
|
||||||
+ "treating it as its own root.", tag.getId(), tag.getParentId());
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For each tag name, returns the set of that tag's ID plus all descendant IDs.
|
* For each tag name, returns the set of that tag's ID plus all descendant IDs.
|
||||||
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.
|
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */
|
|
||||||
public enum DerivedEventType {
|
|
||||||
BIRTH,
|
|
||||||
DEATH,
|
|
||||||
MARRIAGE
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
|
|
||||||
public enum Kind {
|
|
||||||
EVENT,
|
|
||||||
LETTER
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/timeline")
|
|
||||||
@Validated
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class TimelineController {
|
|
||||||
|
|
||||||
private final TimelineService timelineService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public TimelineDTO getTimeline(
|
|
||||||
@RequestParam(required = false) UUID personId,
|
|
||||||
@RequestParam(required = false) @Min(0) Integer generation,
|
|
||||||
@RequestParam(required = false) EventType type,
|
|
||||||
@RequestParam(required = false) Integer fromYear,
|
|
||||||
@RequestParam(required = false) Integer toYear) {
|
|
||||||
return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assembled timeline response. Year bands are sorted ascending (oldest first).
|
|
||||||
* Undated entries have no usable date or {@code UNKNOWN} precision.
|
|
||||||
*/
|
|
||||||
public record TimelineDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineYearDTO> years,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
|
|
||||||
* life-events ({@link DerivedEventType}), and archive letters (Documents).
|
|
||||||
*
|
|
||||||
* <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
|
|
||||||
* means no edit link should be rendered by the frontend.
|
|
||||||
*
|
|
||||||
* <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
|
|
||||||
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
|
|
||||||
* {@link Kind#LETTER} entries.
|
|
||||||
*
|
|
||||||
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
|
|
||||||
* an event-type badge for letters.
|
|
||||||
*
|
|
||||||
* <p><b>Root-tag fields ({@code rootTagId}/{@code rootTagName}/{@code rootTagColor}):</b> the
|
|
||||||
* letter's primary root tag — the root ancestor of its alphabetically-first assigned tag (#835).
|
|
||||||
* All three are {@code null} for non-{@link Kind#LETTER} entries and for letters with no tags;
|
|
||||||
* {@code rootTagColor} is additionally {@code null} when the resolved root carries no color token.
|
|
||||||
* They are deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
|
||||||
* types stay optional.
|
|
||||||
*
|
|
||||||
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
|
||||||
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
|
||||||
*/
|
|
||||||
public record TimelineEntryDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
|
|
||||||
LocalDate eventDate,
|
|
||||||
LocalDate eventDateEnd,
|
|
||||||
String title,
|
|
||||||
EventType type,
|
|
||||||
UUID eventId,
|
|
||||||
UUID documentId,
|
|
||||||
List<UUID> linkedPersonIds,
|
|
||||||
DerivedEventType derivedType,
|
|
||||||
UUID rootTagId,
|
|
||||||
String rootTagName,
|
|
||||||
String rootTagColor
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
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.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/timeline/events")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class TimelineEventController {
|
|
||||||
|
|
||||||
private final TimelineEventService timelineEventService;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No {@code @RequirePermission} on GET by design: the global {@code anyRequest().authenticated()}
|
|
||||||
* rule is the READ_ALL baseline, consistent with {@code DocumentController.getDocument}. Do not
|
|
||||||
* "fix" the missing annotation.
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public TimelineEventView getEvent(@PathVariable UUID id) {
|
|
||||||
return timelineEventService.getEvent(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public TimelineEventView create(@Valid @RequestBody TimelineEventRequest request, Authentication authentication) {
|
|
||||||
return timelineEventService.create(request, requireUserId(authentication));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public TimelineEventView update(
|
|
||||||
@PathVariable UUID id,
|
|
||||||
@Valid @RequestBody TimelineEventRequest request,
|
|
||||||
Authentication authentication) {
|
|
||||||
return timelineEventService.update(id, request, requireUserId(authentication));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
|
||||||
timelineEventService.delete(id);
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID requireUserId(Authentication authentication) {
|
|
||||||
return SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flat input DTO for creating/updating a {@link TimelineEvent}. Bean Validation fires at the
|
|
||||||
* controller boundary (via {@code @Valid}) and produces a 400 {@code VALIDATION_ERROR} for the
|
|
||||||
* presence/size constraints below; cross-field rules (the RANGE invariant), date normalization,
|
|
||||||
* id dedupe, and the title-length structured-error guard live in {@code TimelineEventService}.
|
|
||||||
*
|
|
||||||
* <p><strong>{@code createdBy}/{@code updatedBy} are intentionally absent.</strong> Authorship is
|
|
||||||
* server-populated from the session principal only — accepting it from the body would be an
|
|
||||||
* authorship-forgery / mass-assignment vector (CWE-639; see ADR-040 §7).
|
|
||||||
*
|
|
||||||
* @param version optional optimistic-lock concurrency token (the {@code @Version} the client last
|
|
||||||
* saw), applied on <em>update</em> only. This is a concurrency token, <strong>not</strong>
|
|
||||||
* an authorship field, so it is deliberately exempt from the §7 server-only audit rule.
|
|
||||||
* Null on update means "no concurrency check" (last-write-wins). No range validation —
|
|
||||||
* a stale/negative value is simply a mismatch the lock rejects at flush; the lock, not
|
|
||||||
* a validator, is the control.
|
|
||||||
*/
|
|
||||||
public record TimelineEventRequest(
|
|
||||||
@NotBlank @Size(max = 255) String title,
|
|
||||||
@NotNull EventType type,
|
|
||||||
@NotNull LocalDate eventDate,
|
|
||||||
DatePrecision precision,
|
|
||||||
LocalDate eventDateEnd,
|
|
||||||
@Size(max = 5000) String description,
|
|
||||||
Long version,
|
|
||||||
@Size(max = 50) List<UUID> personIds,
|
|
||||||
@Size(max = 50) List<UUID> documentIds
|
|
||||||
) {}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
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.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
|
||||||
import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef;
|
|
||||||
import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView;
|
|
||||||
|
|
||||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own
|
|
||||||
* services (never their repositories). All four body-returning operations return a
|
|
||||||
* {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2).
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class TimelineEventService {
|
|
||||||
|
|
||||||
private static final int MAX_TITLE_LENGTH = 255;
|
|
||||||
|
|
||||||
private final TimelineEventRepository events;
|
|
||||||
private final PersonService personService;
|
|
||||||
private final DocumentService documentService;
|
|
||||||
private final RelationshipService relationshipService;
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public TimelineEventView create(TimelineEventRequest request, UUID actorId) {
|
|
||||||
DatePrecision precision = effectivePrecision(request);
|
|
||||||
validateRangeInvariant(request, precision);
|
|
||||||
validateTitleLength(request);
|
|
||||||
|
|
||||||
TimelineEvent event = TimelineEvent.builder()
|
|
||||||
.title(request.title())
|
|
||||||
.type(request.type())
|
|
||||||
.eventDate(normalizeEventDate(request.eventDate(), precision))
|
|
||||||
.precision(precision)
|
|
||||||
.eventDateEnd(request.eventDateEnd())
|
|
||||||
.description(request.description())
|
|
||||||
.persons(resolvePersons(request.personIds()))
|
|
||||||
.documents(resolveDocuments(request.documentIds()))
|
|
||||||
.createdBy(actorId)
|
|
||||||
.updatedBy(actorId)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return toView(events.saveAndFlush(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) {
|
|
||||||
TimelineEvent event = events.findById(id)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
|
|
||||||
"Timeline event not found: " + id));
|
|
||||||
requireVersionMatch(request, event);
|
|
||||||
DatePrecision precision = effectivePrecision(request);
|
|
||||||
validateRangeInvariant(request, precision);
|
|
||||||
validateTitleLength(request);
|
|
||||||
applyUpdate(event, request, precision, actorId);
|
|
||||||
|
|
||||||
// saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the
|
|
||||||
// try — a bare save() flushes at commit, after this method returns, so the exception would
|
|
||||||
// escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's.
|
|
||||||
try {
|
|
||||||
return toView(events.saveAndFlush(event));
|
|
||||||
} catch (ObjectOptimisticLockingFailureException ex) {
|
|
||||||
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
|
|
||||||
"Timeline event was modified concurrently: " + id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(UUID id) {
|
|
||||||
TimelineEvent event = events.findById(id)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
|
|
||||||
"Timeline event not found: " + id));
|
|
||||||
events.delete(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional:
|
|
||||||
* the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView}
|
|
||||||
* assembly, and under {@code open-in-view: false} a closed session there is a
|
|
||||||
* {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent).
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public TimelineEventView getEvent(UUID id) {
|
|
||||||
TimelineEvent event = events.findById(id)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND,
|
|
||||||
"Timeline event not found: " + id));
|
|
||||||
return toView(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- update mechanics: mutate the managed entity, never reassign collections ---
|
|
||||||
|
|
||||||
private void applyUpdate(TimelineEvent event, TimelineEventRequest request, DatePrecision precision, UUID actorId) {
|
|
||||||
event.setTitle(request.title());
|
|
||||||
event.setType(request.type());
|
|
||||||
event.setEventDate(normalizeEventDate(request.eventDate(), precision));
|
|
||||||
event.setPrecision(precision);
|
|
||||||
event.setEventDateEnd(request.eventDateEnd());
|
|
||||||
event.setDescription(request.description());
|
|
||||||
replaceLinks(event, request);
|
|
||||||
event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares the client's concurrency token against the freshly-loaded version (the Q1
|
|
||||||
* "last-seen version" token). A mismatch means the client edited stale data → 409.
|
|
||||||
*
|
|
||||||
* <p>This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
|
|
||||||
* flush. Setting {@code @Version} on a <em>managed</em> entity is silently ignored by Hibernate
|
|
||||||
* for the optimistic check: it uses its own loaded-version snapshot for the
|
|
||||||
* {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native
|
|
||||||
* {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch
|
|
||||||
* below remains the backstop for two transactions flushing concurrently; this guard is what
|
|
||||||
* catches the human-timescale "B submitted a form based on a version A already superseded" case.
|
|
||||||
* A null token means no check (last-write-wins) until #9 always sends it.
|
|
||||||
*/
|
|
||||||
private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) {
|
|
||||||
if (request.version() != null && !request.version().equals(event.getVersion())) {
|
|
||||||
throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT,
|
|
||||||
"Timeline event was modified concurrently: " + event.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces (set semantics) the link collections. Mutates the existing managed collections —
|
|
||||||
* Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join
|
|
||||||
* rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links.
|
|
||||||
*/
|
|
||||||
private void replaceLinks(TimelineEvent event, TimelineEventRequest request) {
|
|
||||||
event.getPersons().clear();
|
|
||||||
event.getPersons().addAll(resolvePersons(request.personIds()));
|
|
||||||
event.getDocuments().clear();
|
|
||||||
event.getDocuments().addAll(resolveDocuments(request.documentIds()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- validation / normalization ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the DB biconditional CHECK chk_timeline_event_range — both presence directions — and
|
|
||||||
* additionally enforces date ordering, which the DB CHECK does NOT: {@code eventDateEnd} may
|
|
||||||
* equal but never precede {@code eventDate}. Without this guard a reversed range (end before
|
|
||||||
* start) persists silently and renders as a negative span. Equal dates are a valid one-day
|
|
||||||
* closed range.
|
|
||||||
*/
|
|
||||||
private void validateRangeInvariant(TimelineEventRequest request, DatePrecision precision) {
|
|
||||||
boolean isRange = precision == DatePrecision.RANGE;
|
|
||||||
if (request.eventDateEnd() != null && !isRange) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
|
||||||
"eventDateEnd is only valid when precision is RANGE");
|
|
||||||
}
|
|
||||||
if (isRange && request.eventDateEnd() == null) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
|
||||||
"A RANGE event requires a non-null eventDateEnd");
|
|
||||||
}
|
|
||||||
if (isRange && request.eventDateEnd().isBefore(request.eventDate())) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
|
||||||
"eventDateEnd must not precede eventDate");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP
|
|
||||||
* callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255)
|
|
||||||
* column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as
|
|
||||||
* "duplicate validation".
|
|
||||||
*/
|
|
||||||
private void validateTitleLength(TimelineEventRequest request) {
|
|
||||||
if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG,
|
|
||||||
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private DatePrecision effectivePrecision(TimelineEventRequest request) {
|
|
||||||
return request.precision() != null ? request.precision() : DatePrecision.YEAR;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) {
|
|
||||||
return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- link resolution (fail-closed, dedupe-first) ---
|
|
||||||
|
|
||||||
private Set<Person> resolvePersons(List<UUID> ids) {
|
|
||||||
if (ids == null || ids.isEmpty()) {
|
|
||||||
return new HashSet<>();
|
|
||||||
}
|
|
||||||
// Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the
|
|
||||||
// resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious
|
|
||||||
// mismatch.
|
|
||||||
Set<UUID> distinct = new LinkedHashSet<>(ids);
|
|
||||||
List<Person> resolved = personService.getAllById(new ArrayList<>(distinct));
|
|
||||||
if (resolved.size() != distinct.size()) {
|
|
||||||
throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found");
|
|
||||||
}
|
|
||||||
return new HashSet<>(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
|
||||||
if (ids == null || ids.isEmpty()) {
|
|
||||||
return new HashSet<>();
|
|
||||||
}
|
|
||||||
// Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free
|
|
||||||
// DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this
|
|
||||||
// write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch.
|
|
||||||
Set<Document> resolved = new HashSet<>();
|
|
||||||
for (UUID documentId : new LinkedHashSet<>(ids)) {
|
|
||||||
resolved.add(documentService.getDocumentById(documentId));
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- derived event assembly ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assembles derived life-events (Geburt/Tod/Heirat) from curated Person and
|
|
||||||
* PersonRelationship data. Computed on read, never persisted.
|
|
||||||
*
|
|
||||||
* <p>Derived events are computed, never persisted, and cannot be mutated via the events API
|
|
||||||
* (enforced in #5). Ids produced by this method are structurally non-UUID
|
|
||||||
* ({@code birth:*}, {@code death:*}, {@code marriage:*}) and MUST be rejected by any
|
|
||||||
* write endpoint — enforced and tested in #5. Callers outside the #5 endpoint must
|
|
||||||
* independently enforce {@code READ_ALL} authorization before invoking this method
|
|
||||||
* (see ADR-043).
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<TimelineEntryDTO> assembleDerivedEvents() {
|
|
||||||
List<Person> persons = personService.findAllFamilyMembers();
|
|
||||||
List<PersonRelationship> spouseEdges = relationshipService.findAllSpouseEdges();
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
|
||||||
result.addAll(buildBirthEvents(persons));
|
|
||||||
result.addAll(buildDeathEvents(persons));
|
|
||||||
result.addAll(buildMarriageEvents(spouseEdges));
|
|
||||||
|
|
||||||
log.debug("Assembled {} derived events for {} persons", result.size(), persons.size());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TimelineEntryDTO> buildBirthEvents(List<Person> persons) {
|
|
||||||
return persons.stream()
|
|
||||||
.filter(p -> p.getBirthDate() != null)
|
|
||||||
.map(p -> new TimelineEntryDTO(
|
|
||||||
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
|
|
||||||
p.getBirthDate(), null,
|
|
||||||
p.getDisplayName(), EventType.PERSONAL,
|
|
||||||
null, null, List.of(p.getId()), DerivedEventType.BIRTH,
|
|
||||||
null, null, null))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TimelineEntryDTO> buildDeathEvents(List<Person> persons) {
|
|
||||||
return persons.stream()
|
|
||||||
.filter(p -> p.getDeathDate() != null)
|
|
||||||
.map(p -> new TimelineEntryDTO(
|
|
||||||
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
|
|
||||||
p.getDeathDate(), null,
|
|
||||||
p.getDisplayName(), EventType.PERSONAL,
|
|
||||||
null, null, List.of(p.getId()), DerivedEventType.DEATH,
|
|
||||||
null, null, null))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TimelineEntryDTO> buildMarriageEvents(List<PersonRelationship> spouseEdges) {
|
|
||||||
// DB constraint unique_spouse_pair (V55) is the authoritative enforcement;
|
|
||||||
// in-memory dedup on relationship row id is a defensive assertion.
|
|
||||||
Set<UUID> seen = new HashSet<>();
|
|
||||||
List<TimelineEntryDTO> result = new ArrayList<>();
|
|
||||||
for (PersonRelationship r : spouseEdges) {
|
|
||||||
if (seen.add(r.getId())) {
|
|
||||||
// JOIN FETCH in findAllSpouseEdges() guarantees person/relatedPerson are loaded.
|
|
||||||
// The marriage date is the relationship's from_date at its stored precision
|
|
||||||
// (ADR-044): a DAY-precision wedding now surfaces the exact day, not just the year.
|
|
||||||
LocalDate eventDate = r.getFromDate();
|
|
||||||
DatePrecision precision = r.getFromDatePrecision();
|
|
||||||
String title = r.getPerson().getDisplayName()
|
|
||||||
+ " & " + r.getRelatedPerson().getDisplayName();
|
|
||||||
result.add(new TimelineEntryDTO(
|
|
||||||
Kind.EVENT, precision, true, "", "",
|
|
||||||
eventDate, null,
|
|
||||||
title, EventType.PERSONAL,
|
|
||||||
null, null,
|
|
||||||
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
|
||||||
DerivedEventType.MARRIAGE,
|
|
||||||
null, null, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- view assembly (explicit allow-list; never the raw entity) ---
|
|
||||||
|
|
||||||
private TimelineEventView toView(TimelineEvent event) {
|
|
||||||
List<PersonView> persons = event.getPersons().stream()
|
|
||||||
.map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
|
|
||||||
.toList();
|
|
||||||
List<DocumentRef> documents = event.getDocuments().stream()
|
|
||||||
.map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate()))
|
|
||||||
.toList();
|
|
||||||
return new TimelineEventView(
|
|
||||||
event.getId(), event.getTitle(), event.getType(), event.getEventDate(),
|
|
||||||
event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(),
|
|
||||||
event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(),
|
|
||||||
persons, documents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response view for the timeline event endpoints — returned by GET, POST, and PUT alike.
|
|
||||||
* Assembled inside the service transaction (after {@code saveAndFlush} on the write paths, so
|
|
||||||
* {@code version} is non-null) from the managed entity's already-loaded collections. The raw
|
|
||||||
* {@link TimelineEvent} is never serialized: its LAZY {@code persons}/{@code documents}
|
|
||||||
* collections under {@code open-in-view: false} would otherwise 500 (ADR-036/ADR-040 §2), and
|
|
||||||
* splatting the entities would leak curator-internal fields ({@code Person.notes},
|
|
||||||
* {@code provisional}, transcription data) to every READ_ALL reader. The explicit field
|
|
||||||
* allow-list below is that guarantee.
|
|
||||||
*/
|
|
||||||
public record TimelineEventView(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDate eventDate,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
|
|
||||||
LocalDate eventDateEnd,
|
|
||||||
String description,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Long version,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID createdBy,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID updatedBy,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonView> persons,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<DocumentRef> documents
|
|
||||||
) {
|
|
||||||
/** Summarised person — exposes only id, firstName, and lastName. Mirrors GeschichteView.PersonView. */
|
|
||||||
public record PersonView(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
String firstName,
|
|
||||||
String lastName
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summarised linked document — id, title, and the eager {@code documentDate} only (no lazy
|
|
||||||
* sender/receiver hop, no person-name leak through the document side).
|
|
||||||
*
|
|
||||||
* <p>timeline-local by design; do not promote to {@code document/} — see #775 R7. Reusing
|
|
||||||
* {@code geschichte.journeyitem.DocumentSummary} would force a cross-domain import of a
|
|
||||||
* package-private mapper plus duplicated name-assembly logic; a 3-field local record is the
|
|
||||||
* lower-coupling choice.
|
|
||||||
*/
|
|
||||||
public record DocumentRef(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
|
||||||
LocalDate documentDate
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}.
|
|
||||||
* All fields are nullable — null means "no constraint on this dimension".
|
|
||||||
*/
|
|
||||||
public record TimelineFilter(
|
|
||||||
UUID personId,
|
|
||||||
Integer generation,
|
|
||||||
EventType type,
|
|
||||||
Integer fromYear,
|
|
||||||
Integer toYear
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
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.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
import org.raddatz.familienarchiv.tag.RootTag;
|
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.TreeMap;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
|
|
||||||
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
|
|
||||||
*
|
|
||||||
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
|
|
||||||
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
|
|
||||||
* (same domain — constitution §1.3).
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class TimelineService {
|
|
||||||
|
|
||||||
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
|
|
||||||
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
|
|
||||||
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
|
|
||||||
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
|
|
||||||
.thenComparing(e -> e.title() != null ? e.title() : "")
|
|
||||||
.thenComparing(e -> {
|
|
||||||
if (e.eventId() != null) return e.eventId().toString();
|
|
||||||
if (e.documentId() != null) return e.documentId().toString();
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
private final TimelineEventRepository eventRepository;
|
|
||||||
private final TimelineEventService timelineEventService;
|
|
||||||
private final DocumentService documentService;
|
|
||||||
private final PersonService personService;
|
|
||||||
private final TagService tagService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assembles the timeline for the given filter. All filters are ANDed.
|
|
||||||
* Throws {@link DomainException} (bad request) when fromYear > toYear.
|
|
||||||
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
|
|
||||||
*
|
|
||||||
* <p>{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads,
|
|
||||||
* this method accesses lazy collections ({@link TimelineEvent#getPersons()},
|
|
||||||
* {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the
|
|
||||||
* repository sub-transaction closes. Without this annotation those accesses throw
|
|
||||||
* {@link org.hibernate.LazyInitializationException} in production (constitution §1.6).
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public TimelineDTO assemble(TimelineFilter filter) {
|
|
||||||
if (filter.fromYear() != null && filter.toYear() != null
|
|
||||||
&& filter.fromYear() > filter.toYear()) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
|
||||||
"toYear must not be before fromYear");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve generation person IDs once — used across all three layers
|
|
||||||
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
|
||||||
|
|
||||||
// ── curated events ───────────────────────────────────────────────────
|
|
||||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
|
||||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
|
||||||
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
|
||||||
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
|
||||||
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
|
||||||
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
|
|
||||||
entries.add(mapEvent(ev));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── derived events ───────────────────────────────────────────────────
|
|
||||||
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
|
|
||||||
if (!passesTypeFilter(derived.type(), filter.type())) continue;
|
|
||||||
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
|
|
||||||
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
|
|
||||||
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
|
|
||||||
entries.add(derived);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── letters ─────────────────────────────────────────────────────────
|
|
||||||
List<Document> letters = new ArrayList<>();
|
|
||||||
for (Document doc : fetchDocuments(filter.personId())) {
|
|
||||||
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
|
|
||||||
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
|
|
||||||
letters.add(doc);
|
|
||||||
}
|
|
||||||
Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
|
|
||||||
for (Document doc : letters) {
|
|
||||||
entries.add(mapDocument(doc, rootByDocId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return bucket(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Bucketing ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
|
|
||||||
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
|
|
||||||
for (TimelineEntryDTO e : entries) {
|
|
||||||
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
|
|
||||||
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
|
|
||||||
List<TimelineEntryDTO> undated = entries.stream()
|
|
||||||
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
|
|
||||||
.sorted(WITHIN_BAND_ORDER)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
|
|
||||||
List<TimelineYearDTO> years = byYear.entrySet().stream()
|
|
||||||
.map(e -> new TimelineYearDTO(e.getKey(),
|
|
||||||
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return new TimelineDTO(years, undated);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Document fetch (global vs personId path) ────────────────────────────
|
|
||||||
|
|
||||||
private List<Document> fetchDocuments(UUID personId) {
|
|
||||||
if (personId == null) {
|
|
||||||
return documentService.getAllForTimeline();
|
|
||||||
}
|
|
||||||
// personId path: validate existence, then union sender+receiver (dedup by id)
|
|
||||||
personService.getById(personId);
|
|
||||||
Map<UUID, Document> seen = new LinkedHashMap<>();
|
|
||||||
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
|
|
||||||
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
|
|
||||||
return new ArrayList<>(seen.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Filter predicates ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
|
|
||||||
return filterType == null || filterType == entryType;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
|
|
||||||
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
|
|
||||||
int year = date.getYear();
|
|
||||||
if (filter.fromYear() != null && year < filter.fromYear()) return false;
|
|
||||||
if (filter.toYear() != null && year > filter.toYear()) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
|
|
||||||
if (personId == null) return true;
|
|
||||||
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
|
|
||||||
if (personId == null) return true;
|
|
||||||
return linkedIds != null && linkedIds.contains(personId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
|
|
||||||
if (generation == null) return null;
|
|
||||||
return personService.getPersonsByGeneration(generation).stream()
|
|
||||||
.map(Person::getId)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
|
|
||||||
if (genPersonIds == null) return true;
|
|
||||||
if (persons == null || persons.isEmpty()) return false;
|
|
||||||
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
|
|
||||||
if (genPersonIds == null) return true;
|
|
||||||
if (linkedIds == null || linkedIds.isEmpty()) return false;
|
|
||||||
return linkedIds.stream().anyMatch(genPersonIds::contains);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
|
|
||||||
if (genPersonIds == null) return true;
|
|
||||||
Person sender = doc.getSender();
|
|
||||||
if (sender != null && genPersonIds.contains(sender.getId())) return true;
|
|
||||||
Set<Person> receivers = doc.getReceivers();
|
|
||||||
if (receivers != null) {
|
|
||||||
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mapping ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
|
|
||||||
List<UUID> personIds = ev.getPersons() == null ? List.of()
|
|
||||||
: ev.getPersons().stream().map(Person::getId).toList();
|
|
||||||
return new TimelineEntryDTO(
|
|
||||||
Kind.EVENT,
|
|
||||||
ev.getPrecision(),
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
ev.getEventDate(),
|
|
||||||
ev.getEventDateEnd(),
|
|
||||||
ev.getTitle(),
|
|
||||||
ev.getType(),
|
|
||||||
ev.getId(),
|
|
||||||
null,
|
|
||||||
personIds,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
|
|
||||||
RootTag root = rootByDocId.get(doc.getId());
|
|
||||||
return new TimelineEntryDTO(
|
|
||||||
Kind.LETTER,
|
|
||||||
doc.getMetaDatePrecision(),
|
|
||||||
false,
|
|
||||||
resolveSenderName(doc),
|
|
||||||
resolveReceiverName(doc),
|
|
||||||
doc.getDocumentDate(),
|
|
||||||
null,
|
|
||||||
doc.getTitle(),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
doc.getId(),
|
|
||||||
List.of(),
|
|
||||||
null,
|
|
||||||
root == null ? null : root.id(),
|
|
||||||
root == null ? null : root.name(),
|
|
||||||
root == null ? null : root.color()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves each letter's primary root tag in one batched pass, keyed by document id — no
|
|
||||||
* per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
|
|
||||||
* so the {@code min()} scan over a letter's tag set runs exactly once here (not again at map
|
|
||||||
* time), and {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
|
|
||||||
*/
|
|
||||||
private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
|
|
||||||
Map<UUID, Tag> primaryByDocId = new LinkedHashMap<>();
|
|
||||||
for (Document doc : letters) {
|
|
||||||
Tag primary = primaryTag(doc);
|
|
||||||
if (primary != null) primaryByDocId.put(doc.getId(), primary);
|
|
||||||
}
|
|
||||||
if (primaryByDocId.isEmpty()) return Map.of();
|
|
||||||
|
|
||||||
Map<UUID, RootTag> rootByTagId =
|
|
||||||
tagService.resolveRootTags(new ArrayList<>(primaryByDocId.values()));
|
|
||||||
Map<UUID, RootTag> rootByDocId = new HashMap<>();
|
|
||||||
primaryByDocId.forEach((docId, primary) -> {
|
|
||||||
RootTag root = rootByTagId.get(primary.getId());
|
|
||||||
if (root != null) rootByDocId.put(docId, root);
|
|
||||||
});
|
|
||||||
return rootByDocId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */
|
|
||||||
private static Tag primaryTag(Document doc) {
|
|
||||||
Set<Tag> tags = doc.getTags();
|
|
||||||
if (tags == null || tags.isEmpty()) return null;
|
|
||||||
return tags.stream()
|
|
||||||
.filter(t -> t.getName() != null)
|
|
||||||
.min(Comparator.comparing(Tag::getName))
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveSenderName(Document doc) {
|
|
||||||
if (doc.getSender() != null) return doc.getSender().getDisplayName();
|
|
||||||
String text = doc.getSenderText();
|
|
||||||
return (text != null && !text.isBlank()) ? text : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveReceiverName(Document doc) {
|
|
||||||
Set<Person> receivers = doc.getReceivers();
|
|
||||||
if (receivers != null && !receivers.isEmpty()) {
|
|
||||||
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
|
|
||||||
}
|
|
||||||
String text = doc.getReceiverText();
|
|
||||||
return (text != null && !text.isBlank()) ? text : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int precisionRank(DatePrecision precision) {
|
|
||||||
if (precision == null) return 0;
|
|
||||||
return switch (precision) {
|
|
||||||
case DAY -> 5;
|
|
||||||
case MONTH -> 4;
|
|
||||||
case SEASON -> 3;
|
|
||||||
case YEAR -> 2;
|
|
||||||
case APPROX -> 1;
|
|
||||||
default -> 0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */
|
|
||||||
public record TimelineYearDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> entries
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
-- V78: person_relationships.from_year/to_year (integer) → from_date/to_date (date)
|
|
||||||
-- plus NOT NULL precision columns, mirroring persons.{birth,death}_date (V76 / ADR-039).
|
|
||||||
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-044).
|
|
||||||
-- One-way migration: rollback is a targeted pg_restore -t person_relationships from
|
|
||||||
-- the pre-deploy backup (see docs/DEPLOYMENT.md). The column drop is NOT
|
|
||||||
-- rolling-deploy-safe — stop the old JAR before running this migration.
|
|
||||||
|
|
||||||
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
|
|
||||||
-- before any DDL runs. Single-writer family archive, so no race window matters.
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year)
|
|
||||||
THEN RAISE EXCEPTION 'V78 aborted: % relationships have from_year > to_year — fix data before migrating',
|
|
||||||
(SELECT COUNT(*) FROM person_relationships WHERE from_year IS NOT NULL AND to_year IS NOT NULL AND from_year > to_year);
|
|
||||||
END IF;
|
|
||||||
IF EXISTS (SELECT 1 FROM person_relationships WHERE from_year = 0 OR to_year = 0)
|
|
||||||
THEN RAISE EXCEPTION 'V78 aborted: person_relationships table contains from_year=0 or to_year=0 rows — clean data before migrating';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE person_relationships ADD COLUMN from_date date;
|
|
||||||
ALTER TABLE person_relationships ADD COLUMN from_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
|
||||||
ALTER TABLE person_relationships ADD COLUMN to_date date;
|
|
||||||
ALTER TABLE person_relationships ADD COLUMN to_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
|
||||||
|
|
||||||
UPDATE person_relationships SET from_date = make_date(from_year, 1, 1), from_date_precision = 'YEAR'
|
|
||||||
WHERE from_year IS NOT NULL;
|
|
||||||
UPDATE person_relationships SET to_date = make_date(to_year, 1, 1), to_date_precision = 'YEAR'
|
|
||||||
WHERE to_year IS NOT NULL;
|
|
||||||
|
|
||||||
-- Named constraints: readable Postgres error messages when violated.
|
|
||||||
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_coherence
|
|
||||||
CHECK ((from_date IS NULL) = (from_date_precision = 'UNKNOWN'));
|
|
||||||
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_coherence
|
|
||||||
CHECK ((to_date IS NULL) = (to_date_precision = 'UNKNOWN'));
|
|
||||||
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_date_order
|
|
||||||
CHECK (from_date IS NULL OR to_date IS NULL OR from_date <= to_date);
|
|
||||||
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_from_precision_values
|
|
||||||
CHECK (from_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
|
||||||
ALTER TABLE person_relationships ADD CONSTRAINT chk_relationship_to_precision_values
|
|
||||||
CHECK (to_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
|
||||||
|
|
||||||
ALTER TABLE person_relationships DROP COLUMN from_year;
|
|
||||||
ALTER TABLE person_relationships DROP COLUMN to_year;
|
|
||||||
@@ -2943,17 +2943,4 @@ class DocumentServiceTest {
|
|||||||
assertThat(result.buckets()).isEmpty();
|
assertThat(result.buckets()).isEmpty();
|
||||||
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- getAllForTimeline ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getAllForTimeline_delegates_bulk_fetch_to_repository() {
|
|
||||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
|
|
||||||
when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
List<Document> result = documentService.getAllForTimeline();
|
|
||||||
|
|
||||||
assertThat(result).containsExactly(doc);
|
|
||||||
verify(documentRepository).findAllForTimeline();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.mockStatic;
|
import static org.mockito.Mockito.mockStatic;
|
||||||
@@ -106,49 +103,6 @@ class GlobalExceptionHandlerTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void handleOptimisticLock_returns409_genericConflict_noSentry_noLeak() {
|
|
||||||
// CWE-209 regression: an optimistic-lock failure escaping a service catch must become a
|
|
||||||
// generic 409, never a 500 + Sentry + Hibernate internals. The generic CONFLICT code keeps
|
|
||||||
// it entity-agnostic — NOT TIMELINE_EVENT_CONFLICT, or every future entity's conflict is
|
|
||||||
// mislabeled a timeline one.
|
|
||||||
UUID entityId = UUID.randomUUID();
|
|
||||||
ObjectOptimisticLockingFailureException ex =
|
|
||||||
new ObjectOptimisticLockingFailureException("com.example.SomeEntity", entityId);
|
|
||||||
|
|
||||||
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
|
||||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
||||||
appender.start();
|
|
||||||
handlerLogger.addAppender(appender);
|
|
||||||
|
|
||||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
|
||||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleOptimisticLock(ex);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode().value()).isEqualTo(409);
|
|
||||||
assertThat(response.getBody()).isNotNull();
|
|
||||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.CONFLICT);
|
|
||||||
// Body echoes no persistent-class name, no entity id, no version (enumeration aids).
|
|
||||||
assertThat(response.getBody().message())
|
|
||||||
.doesNotContain("SomeEntity")
|
|
||||||
.doesNotContain(entityId.toString());
|
|
||||||
|
|
||||||
// A conflict is not a system fault — no fabricated Sentry alert.
|
|
||||||
sentryMock.verifyNoInteractions();
|
|
||||||
} finally {
|
|
||||||
handlerLogger.detachAppender(appender);
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(appender.list)
|
|
||||||
.as("WARN names the persistent class for debuggability")
|
|
||||||
.anySatisfy(e -> {
|
|
||||||
assertThat(e.getLevel()).isEqualTo(Level.WARN);
|
|
||||||
assertThat(e.getFormattedMessage()).contains("com.example.SomeEntity");
|
|
||||||
});
|
|
||||||
assertThat(appender.list)
|
|
||||||
.as("but never the entity id (would leak via getMessage())")
|
|
||||||
.noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains(entityId.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
|
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
|
||||||
// Debuggability (DevOps): the WARN must name *which* constraint fired so an
|
// Debuggability (DevOps): the WARN must name *which* constraint fired so an
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
@@ -170,7 +169,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
RelationshipDTO edge = new RelationshipDTO(
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
UUID.randomUUID(), parentId, childId,
|
UUID.randomUUID(), parentId, childId,
|
||||||
"Parent", null, null, "Child", null, null,
|
"Parent", null, null, "Child", null, null,
|
||||||
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
RelationType.PARENT_OF, null, null, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import org.raddatz.familienarchiv.person.PersonService;
|
|||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
|
|||||||
new PersonTreeImporter(personService, relationshipService)
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
.load(json.toFile());
|
.load(json.toFile());
|
||||||
|
|
||||||
ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
|
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
||||||
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||||
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||||
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
|||||||
@@ -1105,25 +1105,4 @@ class PersonServiceTest {
|
|||||||
assertThat(result.direct()).hasSize(1);
|
assertThat(result.direct()).hasSize(1);
|
||||||
assertThat(result.partial()).isEmpty();
|
assertThat(result.partial()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- getPersonsByGeneration ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getPersonsByGeneration_delegates_to_repository() {
|
|
||||||
Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build();
|
|
||||||
when(personRepository.findByGeneration(2)).thenReturn(List.of(p));
|
|
||||||
|
|
||||||
List<Person> result = personService.getPersonsByGeneration(2);
|
|
||||||
|
|
||||||
assertThat(result).containsExactly(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getPersonsByGeneration_returns_emptyList_when_no_match() {
|
|
||||||
when(personRepository.findByGeneration(99)).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<Person> result = personService.getPersonsByGeneration(99);
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
package org.raddatz.familienarchiv.person.relationship;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
@@ -26,8 +25,6 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doNothing;
|
import static org.mockito.Mockito.doNothing;
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -101,7 +98,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", 1900, 1980,
|
"Alice Müller", 1900, 1980,
|
||||||
"Bob Müller", 1930, null,
|
"Bob Müller", 1930, null,
|
||||||
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
RelationType.PARENT_OF, null, null, null);
|
||||||
when(relationshipService.getFamilyNetwork())
|
when(relationshipService.getFamilyNetwork())
|
||||||
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||||
|
|
||||||
@@ -142,7 +139,7 @@ class RelationshipControllerTest {
|
|||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", null, null,
|
"Alice Müller", null, null,
|
||||||
"Bob Müller", null, null,
|
"Bob Müller", null, null,
|
||||||
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
RelationType.PARENT_OF, null, null, null);
|
||||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
@@ -161,51 +158,4 @@ class RelationshipControllerTest {
|
|||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id}/relationships/{relId} ──────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
|
||||||
void updateRelationship_returns200_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
RelationshipDTO updated = new RelationshipDTO(
|
|
||||||
relId, PERSON_ID, OTHER_ID,
|
|
||||||
"Alice Müller", null, null,
|
|
||||||
"Bob Müller", null, null,
|
|
||||||
RelationType.SPOUSE_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
|
||||||
when(relationshipService.updateRelationship(any(), any(), any())).thenReturn(updated);
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.relationType").value("SPOUSE_OF"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_returns401_whenUnauthenticated_and_does_not_touch_service() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
verify(relationshipService, never()).updateRelationship(any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
|
||||||
void updateRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"SPOUSE_OF\"}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
|
||||||
void updateRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.person.relationship;
|
|
||||||
|
|
||||||
import org.flywaydb.core.Flyway;
|
|
||||||
import org.junit.jupiter.api.AfterAll;
|
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies V78: person_relationships.from_year/to_year (integer) become
|
|
||||||
* from_date/to_date (date) + *_date_precision columns, with backfill to
|
|
||||||
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
|
|
||||||
* pre-check that aborts the migration on corrupt year data. Mirrors
|
|
||||||
* {@code PersonBirthDeathMigrationTest} (V76 / ADR-039).
|
|
||||||
*
|
|
||||||
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
|
|
||||||
* database so the staged migrate-to-V77 → seed → migrate-to-latest flow and
|
|
||||||
* the abort cases cannot interfere with each other. Uses a real Postgres
|
|
||||||
* container — H2 does not honour CHECK constraints.
|
|
||||||
*/
|
|
||||||
class RelationshipMigrationTest {
|
|
||||||
|
|
||||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
|
||||||
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
|
|
||||||
|
|
||||||
private String dbUrl;
|
|
||||||
|
|
||||||
@BeforeAll
|
|
||||||
static void startContainer() {
|
|
||||||
POSTGRES.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterAll
|
|
||||||
static void stopContainer() {
|
|
||||||
POSTGRES.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void createFreshDatabase() throws SQLException {
|
|
||||||
String dbName = "mig_v78_" + DB_COUNTER.incrementAndGet();
|
|
||||||
try (Connection conn = DriverManager.getConnection(
|
|
||||||
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
|
|
||||||
Statement stmt = conn.createStatement()) {
|
|
||||||
stmt.execute("CREATE DATABASE " + dbName);
|
|
||||||
}
|
|
||||||
dbUrl = baseUrl(dbName);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void precheck_abortsWhenFromYearAfterToYear() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
seedRelationship(a, b, "SPOUSE_OF", 1958, 1923);
|
|
||||||
|
|
||||||
assertThatThrownBy(this::migrateToLatest)
|
|
||||||
.hasMessageContaining("V78 aborted")
|
|
||||||
.hasMessageContaining("from_year > to_year");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void precheck_abortsWhenYearZeroPresent() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
seedRelationship(a, b, "FRIEND", 0, null);
|
|
||||||
|
|
||||||
assertThatThrownBy(this::migrateToLatest)
|
|
||||||
.hasMessageContaining("V78 aborted")
|
|
||||||
.hasMessageContaining("from_year=0 or to_year=0");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfill_fromYearAndToYear_becomeYearPrecisionDates() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
|
||||||
|
|
||||||
migrateToLatest();
|
|
||||||
|
|
||||||
RelationDates row = relationDates(a, b, "SPOUSE_OF");
|
|
||||||
assertThat(row.fromDate()).hasToString("1923-01-01");
|
|
||||||
assertThat(row.fromPrecision()).isEqualTo("YEAR");
|
|
||||||
assertThat(row.toDate()).hasToString("1958-01-01");
|
|
||||||
assertThat(row.toPrecision()).isEqualTo("YEAR");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
seedRelationship(a, b, "FRIEND", null, null);
|
|
||||||
|
|
||||||
migrateToLatest();
|
|
||||||
|
|
||||||
RelationDates row = relationDates(a, b, "FRIEND");
|
|
||||||
assertThat(row.fromDate()).isNull();
|
|
||||||
assertThat(row.fromPrecision()).isEqualTo("UNKNOWN");
|
|
||||||
assertThat(row.toDate()).isNull();
|
|
||||||
assertThat(row.toPrecision()).isEqualTo("UNKNOWN");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void backfill_preservesRowCount() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
UUID c = seedPerson("Gamma");
|
|
||||||
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
|
||||||
seedRelationship(a, c, "FRIEND", null, null);
|
|
||||||
|
|
||||||
migrateToLatest();
|
|
||||||
|
|
||||||
assertThat(countWhere("1 = 1")).isEqualTo(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void orderCheckConstraint_rejectsToDateBeforeFromDate() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
migrateToLatest();
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> insertDatedRelationship(
|
|
||||||
a, b, "FRIEND", "1958-01-01", "YEAR", "1923-01-01", "YEAR"))
|
|
||||||
.hasMessageContaining("chk_relationship_date_order");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void coherenceCheckConstraint_rejectsDatePresentWithUnknownPrecision() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
migrateToLatest();
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> insertDatedRelationship(
|
|
||||||
a, b, "FRIEND", "1923-01-01", "UNKNOWN", null, "UNKNOWN"))
|
|
||||||
.hasMessageContaining("chk_relationship_from_coherence");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
|
|
||||||
migrateTo("77");
|
|
||||||
UUID a = seedPerson("Alpha");
|
|
||||||
UUID b = seedPerson("Beta");
|
|
||||||
seedRelationship(a, b, "SPOUSE_OF", 1923, 1958);
|
|
||||||
|
|
||||||
migrateToLatest();
|
|
||||||
|
|
||||||
assertThat(columnExists("from_year")).isFalse();
|
|
||||||
assertThat(columnExists("to_year")).isFalse();
|
|
||||||
assertThat(columnExists("from_date")).isTrue();
|
|
||||||
assertThat(columnExists("to_date")).isTrue();
|
|
||||||
for (String constraint : new String[]{
|
|
||||||
"chk_relationship_from_coherence",
|
|
||||||
"chk_relationship_to_coherence",
|
|
||||||
"chk_relationship_date_order",
|
|
||||||
"chk_relationship_from_precision_values",
|
|
||||||
"chk_relationship_to_precision_values"}) {
|
|
||||||
assertThat(constraintExists(constraint)).as(constraint).isTrue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
|
||||||
|
|
||||||
private static String baseUrl(String dbName) {
|
|
||||||
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateTo(String targetVersion) {
|
|
||||||
flywayBuilder().target(targetVersion).load().migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void migrateToLatest() {
|
|
||||||
flywayBuilder().load().migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
|
|
||||||
return Flyway.configure()
|
|
||||||
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
|
|
||||||
.locations("classpath:db/migration")
|
|
||||||
.placeholders(Map.of("grafanaDbPassword", "test-only"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID seedPerson(String lastName) throws SQLException {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
try (Connection conn = connect();
|
|
||||||
PreparedStatement stmt = conn.prepareStatement(
|
|
||||||
"INSERT INTO persons (id, last_name, person_type, family_member, provisional) "
|
|
||||||
+ "VALUES (?, ?, 'PERSON', false, false)")) {
|
|
||||||
stmt.setObject(1, id);
|
|
||||||
stmt.setString(2, lastName);
|
|
||||||
stmt.executeUpdate();
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void seedRelationship(UUID personId, UUID relatedId, String type, Integer fromYear, Integer toYear)
|
|
||||||
throws SQLException {
|
|
||||||
try (Connection conn = connect();
|
|
||||||
PreparedStatement stmt = conn.prepareStatement(
|
|
||||||
"INSERT INTO person_relationships (id, person_id, related_person_id, relation_type, from_year, to_year) "
|
|
||||||
+ "VALUES (gen_random_uuid(), ?, ?, ?, ?, ?)")) {
|
|
||||||
stmt.setObject(1, personId);
|
|
||||||
stmt.setObject(2, relatedId);
|
|
||||||
stmt.setString(3, type);
|
|
||||||
stmt.setObject(4, fromYear);
|
|
||||||
stmt.setObject(5, toYear);
|
|
||||||
stmt.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void insertDatedRelationship(UUID personId, UUID relatedId, String type,
|
|
||||||
String fromDate, String fromPrecision,
|
|
||||||
String toDate, String toPrecision) throws SQLException {
|
|
||||||
try (Connection conn = connect();
|
|
||||||
PreparedStatement stmt = conn.prepareStatement(
|
|
||||||
"INSERT INTO person_relationships "
|
|
||||||
+ "(id, person_id, related_person_id, relation_type, from_date, from_date_precision, to_date, to_date_precision) "
|
|
||||||
+ "VALUES (gen_random_uuid(), ?, ?, ?, CAST(? AS date), ?, CAST(? AS date), ?)")) {
|
|
||||||
stmt.setObject(1, personId);
|
|
||||||
stmt.setObject(2, relatedId);
|
|
||||||
stmt.setString(3, type);
|
|
||||||
stmt.setObject(4, fromDate);
|
|
||||||
stmt.setString(5, fromPrecision);
|
|
||||||
stmt.setObject(6, toDate);
|
|
||||||
stmt.setString(7, toPrecision);
|
|
||||||
stmt.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record RelationDates(Object fromDate, String fromPrecision, Object toDate, String toPrecision) {}
|
|
||||||
|
|
||||||
private RelationDates relationDates(UUID personId, UUID relatedId, String type) throws SQLException {
|
|
||||||
try (Connection conn = connect();
|
|
||||||
PreparedStatement stmt = conn.prepareStatement(
|
|
||||||
"SELECT from_date, from_date_precision, to_date, to_date_precision "
|
|
||||||
+ "FROM person_relationships WHERE person_id = ? AND related_person_id = ? AND relation_type = ?")) {
|
|
||||||
stmt.setObject(1, personId);
|
|
||||||
stmt.setObject(2, relatedId);
|
|
||||||
stmt.setString(3, type);
|
|
||||||
try (ResultSet rs = stmt.executeQuery()) {
|
|
||||||
assertThat(rs.next()).as("relationship exists").isTrue();
|
|
||||||
return new RelationDates(
|
|
||||||
rs.getObject("from_date"),
|
|
||||||
rs.getString("from_date_precision"),
|
|
||||||
rs.getObject("to_date"),
|
|
||||||
rs.getString("to_date_precision"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private long countWhere(String condition) throws SQLException {
|
|
||||||
try (Connection conn = connect();
|
|
||||||
Statement stmt = conn.createStatement();
|
|
||||||
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM person_relationships WHERE " + condition)) {
|
|
||||||
rs.next();
|
|
||||||
return rs.getLong(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean columnExists(String columnName) throws SQLException {
|
|
||||||
try (Connection conn = connect();
|
|
||||||
PreparedStatement stmt = conn.prepareStatement(
|
|
||||||
"SELECT COUNT(*) FROM information_schema.columns "
|
|
||||||
+ "WHERE table_schema = 'public' AND table_name = 'person_relationships' AND column_name = ?")) {
|
|
||||||
stmt.setString(1, columnName);
|
|
||||||
try (ResultSet rs = stmt.executeQuery()) {
|
|
||||||
rs.next();
|
|
||||||
return rs.getInt(1) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean constraintExists(String constraintName) throws SQLException {
|
|
||||||
try (Connection conn = connect();
|
|
||||||
PreparedStatement stmt = conn.prepareStatement(
|
|
||||||
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
|
|
||||||
stmt.setString(1, constraintName);
|
|
||||||
try (ResultSet rs = stmt.executeQuery()) {
|
|
||||||
rs.next();
|
|
||||||
return rs.getInt(1) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Connection connect() throws SQLException {
|
|
||||||
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,10 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
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;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||||
@@ -21,7 +20,6 @@ import org.springframework.context.annotation.Import;
|
|||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -67,17 +65,13 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_stores_and_is_readable() {
|
void addRelationship_stores_and_is_readable() {
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
||||||
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
|
|
||||||
|
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(created.id()).isNotNull();
|
assertThat(created.id()).isNotNull();
|
||||||
assertThat(created.personId()).isEqualTo(alice.getId());
|
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
assertThat(created.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
|
||||||
assertThat(created.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
|
||||||
assertThat(created.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
|
||||||
|
|
||||||
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||||
assertThat(rels).hasSize(1);
|
assertThat(rels).hasSize(1);
|
||||||
@@ -86,7 +80,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_duplicate() {
|
void addRelationship_throws_409_when_duplicate() {
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
relationshipService.addRelationship(alice.getId(), dto);
|
relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||||
@@ -99,9 +93,9 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void addRelationship_throws_409_when_circular_parent() {
|
void addRelationship_throws_409_when_circular_parent() {
|
||||||
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -109,58 +103,28 @@ class RelationshipServiceIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_404_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
// Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a
|
// Charlie is unrelated to this row.
|
||||||
// curator cannot enumerate relationship ids belonging to people they can't see
|
|
||||||
// (anti-enumeration; aligned with the PUT endpoint — ADR-044).
|
|
||||||
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||||
|
|
||||||
// The row is still there.
|
// The row is still there.
|
||||||
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_persists_new_type_dates_and_notes() {
|
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null));
|
|
||||||
|
|
||||||
RelationshipDTO updated = relationshipService.updateRelationship(alice.getId(), created.id(),
|
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
|
|
||||||
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day"));
|
|
||||||
|
|
||||||
assertThat(updated.id()).isEqualTo(created.id());
|
|
||||||
assertThat(updated.relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
|
||||||
assertThat(updated.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
|
||||||
assertThat(updated.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
assertThat(updated.notes()).isEqualTo("wedding day");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_404_when_rel_belongs_to_different_person() {
|
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> relationshipService.updateRelationship(charlie.getId(), created.id(),
|
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null)))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||||
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||||
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||||
|
|
||||||
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
||||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -171,7 +135,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||||
// alice SPOUSE_OF bob. Bob deletes from his side.
|
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||||
|
|
||||||
relationshipService.deleteRelationship(bob.getId(), created.id());
|
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||||
|
|
||||||
@@ -184,7 +148,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
||||||
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
relationshipService.setFamilyMember(charlie.getId(), false);
|
relationshipService.setFamilyMember(charlie.getId(), false);
|
||||||
|
|
||||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
@@ -201,7 +165,7 @@ class RelationshipServiceIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void delete_person_cascades_to_relationships() {
|
void delete_person_cascades_to_relationships() {
|
||||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
UUID relId = created.id();
|
UUID relId = created.id();
|
||||||
assertThat(relationshipRepository.findById(relId)).isPresent();
|
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,16 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
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;
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -61,9 +59,9 @@ class RelationshipServiceTest {
|
|||||||
charlie = person("Charlie");
|
charlie = person("Charlie");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
|
// --- Nora blocker 1 ---
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
|
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
||||||
UUID relId = UUID.randomUUID();
|
UUID relId = UUID.randomUUID();
|
||||||
PersonRelationship rel = parentOf(alice, bob, relId);
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
@@ -71,7 +69,7 @@ class RelationshipServiceTest {
|
|||||||
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||||
verify(relationshipRepository, never()).delete(any());
|
verify(relationshipRepository, never()).delete(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ class RelationshipServiceTest {
|
|||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -100,7 +98,7 @@ class RelationshipServiceTest {
|
|||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -109,7 +107,7 @@ class RelationshipServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||||
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
@@ -118,42 +116,14 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
||||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
||||||
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
|
||||||
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting("code")
|
.extracting("code")
|
||||||
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addRelationship_throws_INVALID_DATE_PRECISION_when_date_present_but_precision_unknown() {
|
|
||||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
|
||||||
LocalDate.of(1950, 1, 1), DatePrecision.UNKNOWN, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addRelationship_throws_INVALID_DATE_PRECISION_when_precision_set_without_date() {
|
|
||||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
|
||||||
null, DatePrecision.DAY, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,16 +140,13 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
||||||
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
|
|
||||||
var result = service.addRelationship(alice.getId(), dto);
|
var result = service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
assertThat(result.personId()).isEqualTo(alice.getId());
|
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||||
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
assertThat(result.fromYear()).isEqualTo(1900);
|
||||||
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
|
||||||
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
|
||||||
assertThat(result.notes()).isEqualTo("first born");
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +166,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService).setFamilyMember(alice.getId(), true);
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
@@ -220,7 +187,7 @@ class RelationshipServiceTest {
|
|||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
|
||||||
service.addRelationship(alice.getId(), dto);
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
||||||
@@ -249,131 +216,6 @@ class RelationshipServiceTest {
|
|||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- updateRelationship (REQ-004/006/007/008/009/010/013) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_NOT_FOUND_when_relId_unknown() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_NOT_FOUND_when_rel_belongs_to_different_person() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = parentOf(alice, bob, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.updateRelationship(charlie.getId(), relId, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_VALIDATION_ERROR_on_self_relation() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND,
|
|
||||||
LocalDate.of(1950, 1, 1), DatePrecision.YEAR,
|
|
||||||
LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
|
||||||
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
|
||||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
|
||||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
|
||||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_throws_DUPLICATE_when_db_constraint_violated() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
|
||||||
assertThatThrownBy(() -> service.updateRelationship(alice.getId(), relId, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_updates_fields_and_returns_dto() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF,
|
|
||||||
LocalDate.of(1923, 5, 12), DatePrecision.DAY, null, null, "wedding day");
|
|
||||||
var result = service.updateRelationship(alice.getId(), relId, dto);
|
|
||||||
|
|
||||||
assertThat(result.relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
|
||||||
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
|
||||||
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
|
||||||
assertThat(result.notes()).isEqualTo("wedding day");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateRelationship_marks_both_endpoints_family_when_updated_to_family_type() {
|
|
||||||
UUID relId = UUID.randomUUID();
|
|
||||||
PersonRelationship rel = relOf(alice, bob, RelationType.FRIEND, relId);
|
|
||||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
|
||||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
|
||||||
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.SIBLING_OF, null, null, null, null, null);
|
|
||||||
service.updateRelationship(alice.getId(), relId, dto);
|
|
||||||
|
|
||||||
verify(personService).setFamilyMember(alice.getId(), true);
|
|
||||||
verify(personService).setFamilyMember(bob.getId(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
||||||
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
||||||
@@ -418,15 +260,11 @@ class RelationshipServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
||||||
return relOf(parent, child, RelationType.PARENT_OF, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PersonRelationship relOf(Person subject, Person object, RelationType type, UUID id) {
|
|
||||||
return PersonRelationship.builder()
|
return PersonRelationship.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.person(subject)
|
.person(parent)
|
||||||
.relatedPerson(object)
|
.relatedPerson(child)
|
||||||
.relationType(type)
|
.relationType(RelationType.PARENT_OF)
|
||||||
.createdAt(Instant.now())
|
.createdAt(Instant.now())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,13 +100,6 @@ class ArchitectureTest {
|
|||||||
.and().resideInAPackage("..audit..")
|
.and().resideInAPackage("..audit..")
|
||||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
|
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
|
||||||
|
|
||||||
@ArchTest
|
|
||||||
static final ArchRule services_only_access_own_domain_repositories_timeline =
|
|
||||||
noClasses()
|
|
||||||
.that().areAnnotatedWith(Service.class)
|
|
||||||
.and().resideInAPackage("..timeline..")
|
|
||||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("timeline"));
|
|
||||||
|
|
||||||
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
||||||
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||||
// where it can be audited and reasoned about independently.
|
// where it can be audited and reasoned about independently.
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.tag;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Real-Postgres proof that {@link TagService#resolveRootTags} walks a persisted tag chain to its
|
|
||||||
* true root through the recursive-CTE {@link TagRepository#findAncestorIds}. The CTE cannot run on
|
|
||||||
* H2, so this uses {@code postgres:16-alpine} via Testcontainers. Exhaustive case coverage lives in
|
|
||||||
* {@link TagServiceTest} (mocked); this pins the DB-dependent ancestry walk (issue #835, REQ-003/004).
|
|
||||||
*/
|
|
||||||
@DataJpaTest
|
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
||||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
|
||||||
class TagServiceIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired private TagRepository tagRepository;
|
|
||||||
private TagService tagService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
tagService = new TagService(tagRepository);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Tag tag(String name, String color, UUID parentId) {
|
|
||||||
return tagRepository.save(Tag.builder().name(name).color(color).parentId(parentId).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_walksPersistedChainToRoot_withRootColor() {
|
|
||||||
// leaf → mid → root resolves to the root's (id, name, color) via the real recursive CTE.
|
|
||||||
Tag root = tag("Krieg", "sienna", null);
|
|
||||||
Tag mid = tag("Feldpost", null, root.getId());
|
|
||||||
Tag leaf = tag("Briefe von der Front", null, mid.getId());
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(leaf));
|
|
||||||
|
|
||||||
assertThat(result.get(leaf.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_returnsRootItself_forPersistedRoot() {
|
|
||||||
Tag root = tag("Weihnachten", "amber", null);
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
|
|
||||||
|
|
||||||
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Weihnachten", "amber"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import org.raddatz.familienarchiv.tag.Tag;
|
|||||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -451,74 +450,6 @@ class TagServiceTest {
|
|||||||
assertThat(child2.getColor()).isEqualTo("sienna");
|
assertThat(child2.getColor()).isEqualTo("sienna");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── resolveRootTags ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_returnsTagItself_whenTagIsRoot() {
|
|
||||||
// REQ-003/004: a root tag (no parent) is its own primary root — no ancestry walk, no load.
|
|
||||||
Tag root = Tag.builder().id(UUID.randomUUID()).name("Krieg").color("sienna").build();
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(root));
|
|
||||||
|
|
||||||
assertThat(result.get(root.getId())).isEqualTo(new RootTag(root.getId(), "Krieg", "sienna"));
|
|
||||||
verify(tagRepository, never()).findAncestorIds(any());
|
|
||||||
verify(tagRepository, never()).findAllById(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_walksChildToRoot_withRootColor() {
|
|
||||||
// REQ-003/004: a nested child resolves to its root's id/name/color via one CTE + one batch.
|
|
||||||
UUID rootId = UUID.randomUUID();
|
|
||||||
UUID midId = UUID.randomUUID();
|
|
||||||
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
|
|
||||||
Tag mid = Tag.builder().id(midId).name("Feldpost").parentId(rootId).build();
|
|
||||||
Tag child = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(midId).build();
|
|
||||||
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(midId, rootId));
|
|
||||||
when(tagRepository.findAllById(Set.of(midId, rootId))).thenReturn(List.of(mid, rootTag));
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
|
|
||||||
|
|
||||||
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_memoizesPerDistinctTag_noNPlusOne() {
|
|
||||||
// REQ-004: two letters sharing one tag id ⇒ a single findAncestorIds + a single batch load.
|
|
||||||
UUID rootId = UUID.randomUUID();
|
|
||||||
UUID childId = UUID.randomUUID();
|
|
||||||
Tag rootTag = Tag.builder().id(rootId).name("Krieg").color("sienna").build();
|
|
||||||
Tag childA = Tag.builder().id(childId).name("Front").parentId(rootId).build();
|
|
||||||
Tag childB = Tag.builder().id(childId).name("Front").parentId(rootId).build();
|
|
||||||
when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId));
|
|
||||||
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(childA, childB));
|
|
||||||
|
|
||||||
assertThat(result.get(childId)).isEqualTo(new RootTag(rootId, "Krieg", "sienna"));
|
|
||||||
verify(tagRepository, times(1)).findAncestorIds(childId);
|
|
||||||
verify(tagRepository, times(1)).findAllById(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_returnsNullColor_whenRootHasNoColor() {
|
|
||||||
// REQ-007: a colorless root yields RootTag.color() == null (frontend renders a neutral chip).
|
|
||||||
UUID rootId = UUID.randomUUID();
|
|
||||||
Tag rootTag = Tag.builder().id(rootId).name("Allgemein").build();
|
|
||||||
Tag child = Tag.builder().id(UUID.randomUUID()).name("Notiz").parentId(rootId).build();
|
|
||||||
when(tagRepository.findAncestorIds(child.getId())).thenReturn(List.of(rootId));
|
|
||||||
when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(rootTag));
|
|
||||||
|
|
||||||
Map<UUID, RootTag> result = tagService.resolveRootTags(List.of(child));
|
|
||||||
|
|
||||||
assertThat(result.get(child.getId())).isEqualTo(new RootTag(rootId, "Allgemein", null));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resolveRootTags_returnsEmptyMap_forEmptyInput() {
|
|
||||||
assertThat(tagService.resolveRootTags(List.of())).isEmpty();
|
|
||||||
verify(tagRepository, never()).findAncestorIds(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── mergeTags ────────────────────────────────────────────────────────────
|
// ─── mergeTags ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,402 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.PersonRelationship;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
|
||||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class DerivedEventsAssemblyTest {
|
|
||||||
|
|
||||||
@Mock private TimelineEventRepository events;
|
|
||||||
@Mock private PersonService personService;
|
|
||||||
@Mock private DocumentService documentService;
|
|
||||||
@Mock private RelationshipService relationshipService;
|
|
||||||
|
|
||||||
@InjectMocks private TimelineEventService service;
|
|
||||||
|
|
||||||
// --- factory helpers ---
|
|
||||||
|
|
||||||
private Person makePerson(LocalDate birthDate, DatePrecision birthPrecision) {
|
|
||||||
return Person.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.firstName("Anna")
|
|
||||||
.lastName("Müller")
|
|
||||||
.familyMember(true)
|
|
||||||
.birthDate(birthDate)
|
|
||||||
.birthDatePrecision(birthPrecision)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Person makePersonWithDeath(LocalDate deathDate, DatePrecision deathPrecision) {
|
|
||||||
return Person.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.firstName("Hans")
|
|
||||||
.lastName("Raddatz")
|
|
||||||
.familyMember(true)
|
|
||||||
.deathDate(deathDate)
|
|
||||||
.deathDatePrecision(deathPrecision)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Person makePersonWithBoth(
|
|
||||||
LocalDate birthDate, DatePrecision birthPrecision,
|
|
||||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
|
||||||
return Person.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.firstName("Anna")
|
|
||||||
.lastName("Müller")
|
|
||||||
.familyMember(true)
|
|
||||||
.birthDate(birthDate)
|
|
||||||
.birthDatePrecision(birthPrecision)
|
|
||||||
.deathDate(deathDate)
|
|
||||||
.deathDatePrecision(deathPrecision)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Person makeNonFamilyPerson(LocalDate birthDate, DatePrecision precision) {
|
|
||||||
return Person.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.firstName("Anna")
|
|
||||||
.lastName("Müller")
|
|
||||||
.familyMember(false)
|
|
||||||
.birthDate(birthDate)
|
|
||||||
.birthDatePrecision(precision)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private PersonRelationship makeSpouseEdge(Person a, Person b, Integer fromYear) {
|
|
||||||
return makeSpouseEdgeWithDate(a, b,
|
|
||||||
fromYear != null ? LocalDate.of(fromYear, 1, 1) : null,
|
|
||||||
fromYear != null ? DatePrecision.YEAR : DatePrecision.UNKNOWN);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PersonRelationship makeSpouseEdgeWithDate(Person a, Person b, LocalDate fromDate, DatePrecision precision) {
|
|
||||||
return PersonRelationship.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.person(a)
|
|
||||||
.relatedPerson(b)
|
|
||||||
.relationType(RelationType.SPOUSE_OF)
|
|
||||||
.fromDate(fromDate)
|
|
||||||
.fromDatePrecision(precision)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-001: birth events ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_one_geburt_for_person_with_birthdate() {
|
|
||||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
TimelineEntryDTO event = result.get(0);
|
|
||||||
assertThat(event.derived()).isTrue();
|
|
||||||
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
|
|
||||||
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
|
|
||||||
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
|
|
||||||
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
assertThat(event.title()).isEqualTo(anna.getDisplayName());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-003: null birthDate → no Geburt event ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_zero_tod_when_person_has_birthdate_but_no_deathdate() {
|
|
||||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
long todCount = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.DEATH)
|
|
||||||
.count();
|
|
||||||
assertThat(todCount).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-004: null deathDate → no Tod event ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_no_events_for_person_with_neither_date() {
|
|
||||||
Person nobody = Person.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.firstName("Hans")
|
|
||||||
.lastName("Raddatz")
|
|
||||||
.familyMember(true)
|
|
||||||
.build();
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(nobody));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-002: death events ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_one_tod_for_person_with_deathdate() {
|
|
||||||
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
TimelineEntryDTO event = result.get(0);
|
|
||||||
assertThat(event.derived()).isTrue();
|
|
||||||
assertThat(event.type()).isEqualTo(EventType.PERSONAL);
|
|
||||||
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
|
|
||||||
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
|
|
||||||
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
assertThat(event.title()).isEqualTo(hans.getDisplayName());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-002 + REQ-003 combined ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_one_tod_and_zero_geburt_for_person_with_deathdate_only() {
|
|
||||||
Person hans = makePersonWithDeath(LocalDate.of(1965, 7, 4), DatePrecision.YEAR);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
assertThat(result.get(0).derivedType()).isEqualTo(DerivedEventType.DEATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-005: Heirat with fromYear ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_one_heirat_for_spouse_edge_with_fromYear() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> heiraten = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.toList();
|
|
||||||
assertThat(heiraten).hasSize(1);
|
|
||||||
TimelineEntryDTO heirat = heiraten.get(0);
|
|
||||||
assertThat(heirat.derived()).isTrue();
|
|
||||||
assertThat(heirat.type()).isEqualTo(EventType.PERSONAL);
|
|
||||||
assertThat(heirat.derivedType()).isEqualTo(DerivedEventType.MARRIAGE);
|
|
||||||
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1930, 1, 1));
|
|
||||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.YEAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-006: Heirat with null fromYear → emitted with UNKNOWN precision ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_unknown_precision_heirat_when_fromYear_is_null() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge = makeSpouseEdge(anna, hans, null);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> heiraten = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.toList();
|
|
||||||
assertThat(heiraten).hasSize(1);
|
|
||||||
TimelineEntryDTO heirat = heiraten.get(0);
|
|
||||||
assertThat(heirat.eventDate()).isNull();
|
|
||||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.UNKNOWN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-017 (#837): derived Heirat sources SPOUSE_OF.fromDate at its stored precision ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_day_precision_heirat_from_spouse_fromDate() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge = makeSpouseEdgeWithDate(anna, hans, LocalDate.of(1923, 5, 12), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
|
||||||
|
|
||||||
TimelineEntryDTO heirat = service.assembleDerivedEvents().stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.findFirst().orElseThrow();
|
|
||||||
|
|
||||||
assertThat(heirat.eventDate()).isEqualTo(LocalDate.of(1923, 5, 12));
|
|
||||||
assertThat(heirat.precision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-007: exactly one Heirat per SPOUSE_OF edge (dedup) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_exactly_one_heirat_when_both_spouses_in_scope() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person hans = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
long heiratCount = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.count();
|
|
||||||
assertThat(heiratCount).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_two_heirat_for_person_married_to_two_partners() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person hans = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person karl = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge1 = makeSpouseEdge(anna, hans, 1930);
|
|
||||||
PersonRelationship edge2 = makeSpouseEdge(anna, karl, 1945);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans, karl));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge1, edge2));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
long heiratCount = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.count();
|
|
||||||
assertThat(heiratCount).isEqualTo(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-001 precision pass-through ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_pass_birth_precision_through_unchanged() {
|
|
||||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
assertThat(result.get(0).precision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-008: synthetic prefixed ids, never UUID ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_mint_prefixed_synthetic_ids_never_uuid() {
|
|
||||||
Person anna = makePerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
TimelineEntryDTO entry = result.get(0);
|
|
||||||
assertThat(entry.derived()).isTrue();
|
|
||||||
assertThat(entry.eventId()).isNull();
|
|
||||||
assertThat(entry.documentId()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-010: display names on Heirat ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_heirat_with_displayname_for_both_spouses() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person hans = makePersonWithDeath(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge = makeSpouseEdge(anna, hans, 1930);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna, hans));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> heiraten = service.assembleDerivedEvents().stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
assertThat(heiraten).hasSize(1);
|
|
||||||
TimelineEntryDTO heirat = heiraten.get(0);
|
|
||||||
assertThat(heirat.title()).isNotNull().isNotBlank();
|
|
||||||
assertThat(heirat.linkedPersonIds()).hasSize(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-007 note: assumption/documentation test ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void self_spouse_edge_invariant_is_enforced_by_db_constraint() {
|
|
||||||
// Assumption test — documents that the DB constraint prevents self-edges;
|
|
||||||
// the service does not guard this itself.
|
|
||||||
// The unique_spouse_pair index (V55) using LEAST/GREATEST is the authoritative guard.
|
|
||||||
// This test verifies that if an edge were somehow inserted (impossible in prod),
|
|
||||||
// the service would still produce one event (not zero or an exception).
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship selfEdge = makeSpouseEdge(anna, anna, 1930);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(selfEdge));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
long heiratCount = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.count();
|
|
||||||
assertThat(heiratCount).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-012: non-family-member persons excluded from Geburt/Tod ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_exclude_non_family_member_persons_from_derived_events() {
|
|
||||||
Person nonMember = makeNonFamilyPerson(LocalDate.of(1901, 3, 12), DatePrecision.DAY);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of());
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-013: Heirat emitted even when one spouse has familyMember=false ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_heirat_when_one_spouse_is_not_family_member() {
|
|
||||||
Person anna = makePerson(null, DatePrecision.UNKNOWN);
|
|
||||||
Person nonMember = makeNonFamilyPerson(null, DatePrecision.UNKNOWN);
|
|
||||||
PersonRelationship edge = makeSpouseEdge(anna, nonMember, 1930);
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(anna));
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of(edge));
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
long heiratCount = result.stream()
|
|
||||||
.filter(e -> e.derivedType() == DerivedEventType.MARRIAGE)
|
|
||||||
.count();
|
|
||||||
assertThat(heiratCount).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REQ-014: empty family-member list → empty result, no error ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_emit_zero_events_when_no_family_members() {
|
|
||||||
when(personService.findAllFamilyMembers()).thenReturn(List.of());
|
|
||||||
when(relationshipService.findAllSpouseEdges()).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.is;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
@WebMvcTest(TimelineController.class)
|
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
|
||||||
class TimelineControllerTest {
|
|
||||||
|
|
||||||
@Autowired MockMvc mockMvc;
|
|
||||||
|
|
||||||
@MockitoBean TimelineService timelineService;
|
|
||||||
@MockitoBean UserService userService;
|
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
|
||||||
|
|
||||||
private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of());
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void resolveDefaultPrincipal() {
|
|
||||||
when(userService.findByEmail("user"))
|
|
||||||
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Security ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void returns_401_when_unauthenticated() throws Exception {
|
|
||||||
// REQ-014
|
|
||||||
mockMvc.perform(get("/api/timeline"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void returns_403_when_authenticated_without_read_all() throws Exception {
|
|
||||||
// REQ-015
|
|
||||||
mockMvc.perform(get("/api/timeline"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
|
||||||
void returns_200_with_read_all_permission() throws Exception {
|
|
||||||
// REQ-001
|
|
||||||
when(timelineService.assemble(any())).thenReturn(EMPTY);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/timeline"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.years").isArray())
|
|
||||||
.andExpect(jsonPath("$.undated").isArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Parameter binding ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
|
||||||
void valid_params_are_forwarded_to_service() throws Exception {
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
when(timelineService.assemble(any())).thenReturn(EMPTY);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/timeline")
|
|
||||||
.param("personId", personId.toString())
|
|
||||||
.param("generation", "2")
|
|
||||||
.param("type", "HISTORICAL")
|
|
||||||
.param("fromYear", "1914")
|
|
||||||
.param("toYear", "1918"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Validation errors ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
|
||||||
void returns_400_on_bad_type_value() throws Exception {
|
|
||||||
// REQ-018 — Spring enum binding rejects unknown value
|
|
||||||
mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
|
||||||
void returns_400_when_fromYear_greater_than_toYear() throws Exception {
|
|
||||||
// REQ-016 — service throws bad request, controller propagates it
|
|
||||||
when(timelineService.assemble(any()))
|
|
||||||
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
|
||||||
"toYear must not be before fromYear"));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/timeline")
|
|
||||||
.param("fromYear", "1920")
|
|
||||||
.param("toYear", "1914"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
|
||||||
void returns_400_when_generation_is_negative() throws Exception {
|
|
||||||
// REQ-017 — @Min(0) on generation parameter
|
|
||||||
mockMvc.perform(get("/api/timeline").param("generation", "-1"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
|
||||||
void returns_404_when_person_not_found() throws Exception {
|
|
||||||
// REQ-019
|
|
||||||
when(timelineService.assemble(any()))
|
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString()))
|
|
||||||
.andExpect(status().isNotFound())
|
|
||||||
.andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
@WebMvcTest(TimelineEventController.class)
|
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
|
||||||
class TimelineEventControllerTest {
|
|
||||||
|
|
||||||
@Autowired MockMvc mockMvc;
|
|
||||||
|
|
||||||
@MockitoBean TimelineEventService timelineEventService;
|
|
||||||
@MockitoBean UserService userService;
|
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
|
||||||
|
|
||||||
private static final String VALID_JSON =
|
|
||||||
"{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}";
|
|
||||||
|
|
||||||
/** Default principal resolution for @WithMockUser's "user"; capture tests override with a known id. */
|
|
||||||
@BeforeEach
|
|
||||||
void resolveDefaultPrincipal() {
|
|
||||||
when(userService.findByEmail("user"))
|
|
||||||
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimelineEventView sampleView() {
|
|
||||||
return new TimelineEventView(
|
|
||||||
UUID.randomUUID(), "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 1, 1),
|
|
||||||
org.raddatz.familienarchiv.document.DatePrecision.YEAR, null, null, 0L,
|
|
||||||
UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(),
|
|
||||||
List.of(), List.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /api/timeline/events ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void create_returns403_whenOnlyReadAll() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void create_returns201_andViewBody_whenWriteAll() throws Exception {
|
|
||||||
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
|
|
||||||
.andExpect(status().isCreated())
|
|
||||||
.andExpect(jsonPath("$.title").value("Hochzeit"))
|
|
||||||
.andExpect(jsonPath("$.version").value(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PUT /api/timeline/events/{id} ───────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void update_returns403_whenOnlyReadAll() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void update_returns200_andViewBody_whenWriteAll() throws Exception {
|
|
||||||
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.title").value("Hochzeit"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── DELETE /api/timeline/events/{id} ────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void delete_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void delete_returns403_whenOnlyReadAll() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void delete_returns204_whenWriteAll() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
|
|
||||||
.andExpect(status().isNoContent());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/timeline/events/{id} ───────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getEvent_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void getEvent_returns200_whenAuthenticated() throws Exception {
|
|
||||||
when(timelineEventService.getEvent(any())).thenReturn(sampleView());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.title").value("Hochzeit"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void getEvent_returns404_whenMissing() throws Exception {
|
|
||||||
when(timelineEventService.getEvent(any()))
|
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, "not found"));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isNotFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── service-thrown link errors map to status ────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void create_returns404_whenServiceThrowsPersonNotFound() throws Exception {
|
|
||||||
when(timelineEventService.create(any(), any()))
|
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[\""
|
|
||||||
+ UUID.randomUUID() + "\"]}"))
|
|
||||||
.andExpect(status().isNotFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Bean Validation 400s carry code VALIDATION_ERROR (R1) ───────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void create_returns400_VALIDATION_ERROR_whenTitleBlank() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\" \",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}"))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void create_returns400_VALIDATION_ERROR_whenTypeNull() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"Hochzeit\",\"eventDate\":\"1914-07-28\"}"))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void create_returns400_VALIDATION_ERROR_whenDescriptionTooLong() throws Exception {
|
|
||||||
String description = "x".repeat(5001);
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"description\":\""
|
|
||||||
+ description + "\"}"))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void create_returns400_VALIDATION_ERROR_whenTooManyPersonIds() throws Exception {
|
|
||||||
String ids = Stream.generate(() -> "\"" + UUID.randomUUID() + "\"").limit(51).collect(Collectors.joining(","));
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":["
|
|
||||||
+ ids + "]}"))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── actorId is the resolved session principal, not a body field (both write paths) ──
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
|
|
||||||
void create_passesResolvedPrincipalIdAsActor_ignoringBodyCreatedBy() throws Exception {
|
|
||||||
UUID principalId = UUID.randomUUID();
|
|
||||||
when(userService.findByEmail("curator@example.com"))
|
|
||||||
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
|
|
||||||
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
|
|
||||||
|
|
||||||
// body carries a forged createdBy — it must be ignored, actor comes from the principal
|
|
||||||
mockMvc.perform(post("/api/timeline/events").with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"createdBy\":\""
|
|
||||||
+ UUID.randomUUID() + "\"}"))
|
|
||||||
.andExpect(status().isCreated());
|
|
||||||
|
|
||||||
verify(timelineEventService).create(any(), eq(principalId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
|
|
||||||
void update_passesResolvedPrincipalIdAsActor_ignoringBodyUpdatedBy() throws Exception {
|
|
||||||
UUID principalId = UUID.randomUUID();
|
|
||||||
UUID eventId = UUID.randomUUID();
|
|
||||||
when(userService.findByEmail("curator@example.com"))
|
|
||||||
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
|
|
||||||
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/timeline/events/" + eventId).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"updatedBy\":\""
|
|
||||||
+ UUID.randomUUID() + "\"}"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(timelineEventService).update(eq(eventId), any(), eq(principalId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import jakarta.persistence.EntityManager;
|
|
||||||
import jakarta.persistence.PersistenceContext;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service-level integration scope the entity/DB tests ({@link TimelineEventTest}) don't reach: the
|
|
||||||
* in-transaction view assembly survives a context clear (the {@code @Transactional(readOnly = true)}
|
|
||||||
* LazyInit guard), the serialized view leaks no curator-internal fields, and the {@code @Version}
|
|
||||||
* optimistic lock engages end-to-end (the Mockito test only proves the catch branch). Real Postgres
|
|
||||||
* (V77 CHECK constraints are Postgres-specific) via {@link PostgresContainerConfig}.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
@Transactional
|
|
||||||
class TimelineEventServiceIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
|
||||||
@Autowired TimelineEventService service;
|
|
||||||
@Autowired PersonRepository personRepository;
|
|
||||||
@Autowired DocumentRepository documentRepository;
|
|
||||||
@PersistenceContext EntityManager em;
|
|
||||||
|
|
||||||
// Built locally — the webEnvironment=NONE context has no auto-configured ObjectMapper bean.
|
|
||||||
// findAndRegisterModules() pulls in JavaTimeModule so the view's LocalDate/LocalDateTime serialize.
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
|
||||||
|
|
||||||
private TimelineEventRequest request(String title, Long version, List<UUID> personIds, List<UUID> documentIds) {
|
|
||||||
return new TimelineEventRequest(title, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, version, personIds, documentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getEvent_after_context_clear_populates_links_and_leaks_no_internal_fields() throws Exception {
|
|
||||||
Person anna = personRepository.save(Person.builder()
|
|
||||||
.firstName("Anna").lastName("Müller").notes("GEHEIM-NOTIZ").provisional(true).build());
|
|
||||||
Document letter = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief an Anna").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
|
||||||
UUID eventId = service.create(
|
|
||||||
request("Hochzeit", null, List.of(anna.getId()), List.of(letter.getId())), UUID.randomUUID()).id();
|
|
||||||
em.flush();
|
|
||||||
em.clear();
|
|
||||||
|
|
||||||
// Fresh read — NOT the create return value (that view was assembled while the entity was
|
|
||||||
// managed). Only a separate read after the clear proves the readOnly LazyInit guard.
|
|
||||||
TimelineEventView fresh = service.getEvent(eventId);
|
|
||||||
|
|
||||||
assertThat(fresh.persons()).singleElement().satisfies(p -> {
|
|
||||||
assertThat(p.firstName()).isEqualTo("Anna");
|
|
||||||
assertThat(p.lastName()).isEqualTo("Müller");
|
|
||||||
});
|
|
||||||
assertThat(fresh.documents()).singleElement().satisfies(d ->
|
|
||||||
assertThat(d.title()).isEqualTo("Brief an Anna"));
|
|
||||||
|
|
||||||
// Assert on the SERIALIZED JSON: a getter re-introducing a leaked field later would slip
|
|
||||||
// past a field-level check.
|
|
||||||
String json = objectMapper.writeValueAsString(fresh);
|
|
||||||
assertThat(json)
|
|
||||||
.doesNotContain("GEHEIM-NOTIZ")
|
|
||||||
.doesNotContain("notes")
|
|
||||||
.doesNotContain("provisional")
|
|
||||||
.doesNotContain("password");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void concurrent_update_with_stale_version_yields_TIMELINE_EVENT_CONFLICT() {
|
|
||||||
UUID editorA = UUID.randomUUID();
|
|
||||||
UUID editorB = UUID.randomUUID();
|
|
||||||
UUID eventId = service.create(request("Original", null, null, null), editorA).id();
|
|
||||||
em.flush();
|
|
||||||
em.clear();
|
|
||||||
|
|
||||||
// Editor A saves with the version they last saw (0) → succeeds, version advances to 1.
|
|
||||||
service.update(eventId, request("Edit A", 0L, null, null), editorA);
|
|
||||||
em.flush();
|
|
||||||
em.clear();
|
|
||||||
|
|
||||||
// Editor B still holds the stale version 0 → the versioned UPDATE matches no row → 409.
|
|
||||||
assertThatThrownBy(() -> service.update(eventId, request("Edit B", 0L, null, null), editorB))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode())
|
|
||||||
.isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
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.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
|
|
||||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
import static org.mockito.AdditionalAnswers.returnsFirstArg;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class TimelineEventServiceTest {
|
|
||||||
|
|
||||||
@Mock TimelineEventRepository events;
|
|
||||||
@Mock PersonService personService;
|
|
||||||
@Mock DocumentService documentService;
|
|
||||||
@InjectMocks TimelineEventService service;
|
|
||||||
|
|
||||||
private final UUID actor = UUID.randomUUID();
|
|
||||||
private final UUID secondEditor = UUID.randomUUID();
|
|
||||||
|
|
||||||
/** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */
|
|
||||||
private TimelineEventRequest baseRequest() {
|
|
||||||
return new TimelineEventRequest(
|
|
||||||
"Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Person makePerson(UUID id) {
|
|
||||||
return Person.builder().id(id).firstName("Anna").lastName("Müller").build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Document makeDocument(UUID id) {
|
|
||||||
return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */
|
|
||||||
private TimelineEvent existingEvent(UUID id, UUID creator) {
|
|
||||||
return TimelineEvent.builder()
|
|
||||||
.id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1))
|
|
||||||
.precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */
|
|
||||||
private void stubFlushSetsVersion() {
|
|
||||||
when(events.saveAndFlush(any())).thenAnswer(inv -> {
|
|
||||||
TimelineEvent e = inv.getArgument(0);
|
|
||||||
if (e.getVersion() == null) e.setVersion(0L);
|
|
||||||
return e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimelineEvent captureSaved() {
|
|
||||||
ArgumentCaptor<TimelineEvent> captor = ArgumentCaptor.forClass(TimelineEvent.class);
|
|
||||||
verify(events).saveAndFlush(captor.capture());
|
|
||||||
return captor.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- create ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(baseRequest(), actor);
|
|
||||||
|
|
||||||
TimelineEvent persisted = captureSaved();
|
|
||||||
assertThat(persisted.getCreatedBy()).isEqualTo(actor);
|
|
||||||
assertThat(persisted.getUpdatedBy()).isEqualTo(actor);
|
|
||||||
assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto");
|
|
||||||
assertThat(view.version()).isEqualTo(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_defaults_precision_to_YEAR_when_omitted() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(baseRequest(), actor);
|
|
||||||
|
|
||||||
assertThat(view.precision()).isEqualTo(DatePrecision.YEAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
DatePrecision.YEAR, null, null, null, null, null);
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(request, actor);
|
|
||||||
|
|
||||||
assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_with_null_link_lists_yields_empty_collections_no_npe() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(baseRequest(), actor);
|
|
||||||
|
|
||||||
assertThat(view.persons()).isEmpty();
|
|
||||||
assertThat(view.documents()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_rejects_eventDateEnd_when_precision_is_not_RANGE() {
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
|
|
||||||
DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(request, actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_rejects_RANGE_with_null_eventDateEnd() {
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1),
|
|
||||||
DatePrecision.RANGE, null, null, null, null, null);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(request, actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_rejects_RANGE_with_eventDateEnd_before_eventDate() {
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
|
|
||||||
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(request, actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
LocalDate sameDay = LocalDate.of(1914, 7, 28);
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Eintägiges Ereignis", EventType.HISTORICAL, sameDay,
|
|
||||||
DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(request, actor);
|
|
||||||
|
|
||||||
assertThat(view.eventDate()).isEqualTo(sameDay);
|
|
||||||
assertThat(view.eventDateEnd()).isEqualTo(sameDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- title-length service guard ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() {
|
|
||||||
String overLong = "x".repeat(256);
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, null, null);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(request, actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- link resolution (fail-closed, dedupe-first) ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_resolves_persons_and_documents() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
|
|
||||||
when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, List.of(personId), List.of(docId));
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(request, actor);
|
|
||||||
|
|
||||||
assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId));
|
|
||||||
assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_with_duplicate_personIds_resolves_to_single_link_not_404() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId)));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, List.of(personId, personId), null);
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(request, actor);
|
|
||||||
|
|
||||||
assertThat(view.persons()).hasSize(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_with_duplicate_documentIds_resolves_to_single_link_not_404() {
|
|
||||||
stubFlushSetsVersion();
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, null, List.of(docId, docId));
|
|
||||||
|
|
||||||
TimelineEventView view = service.create(request, actor);
|
|
||||||
|
|
||||||
assertThat(view.documents()).hasSize(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() {
|
|
||||||
UUID known = UUID.randomUUID();
|
|
||||||
UUID unknown = UUID.randomUUID();
|
|
||||||
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, List.of(known, unknown), null);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(request, actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() {
|
|
||||||
UUID unknown = UUID.randomUUID();
|
|
||||||
when(documentService.getDocumentById(unknown))
|
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, null, List.of(unknown));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(request, actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- update ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
UUID creator = UUID.randomUUID();
|
|
||||||
UUID newPersonId = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, creator);
|
|
||||||
existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
|
|
||||||
when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId)));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, List.of(newPersonId), null);
|
|
||||||
|
|
||||||
service.update(id, request, secondEditor);
|
|
||||||
|
|
||||||
assertThat(existing.getCreatedBy()).isEqualTo(creator);
|
|
||||||
assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor);
|
|
||||||
assertThat(existing.getPersons()).singleElement()
|
|
||||||
.satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_with_empty_link_lists_clears_all_links() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
|
|
||||||
existing.getPersons().add(makePerson(UUID.randomUUID()));
|
|
||||||
existing.getDocuments().add(makeDocument(UUID.randomUUID()));
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, null, List.of(), List.of());
|
|
||||||
|
|
||||||
service.update(id, request, secondEditor);
|
|
||||||
|
|
||||||
assertThat(existing.getPersons()).isEmpty();
|
|
||||||
assertThat(existing.getDocuments()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
|
|
||||||
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.update(id, request, secondEditor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(events.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.update(id, baseRequest(), actor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- version / optimistic lock ---
|
|
||||||
|
|
||||||
// Note: the lock control is an explicit base-version compare (requireVersionMatch), NOT
|
|
||||||
// event.setVersion(clientVersion) — Hibernate silently ignores a manually-set @Version on a
|
|
||||||
// managed entity (proven by TimelineEventServiceIntegrationTest). The saveAndFlush+catch below
|
|
||||||
// is retained as the native backstop for two transactions flushing concurrently.
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_with_null_version_skips_the_check_and_saves_last_write_wins() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
|
|
||||||
|
|
||||||
service.update(id, baseRequest(), secondEditor); // baseRequest has null version
|
|
||||||
|
|
||||||
verify(events).saveAndFlush(existing); // no conflict despite an unknown client base
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_with_in_sync_version_succeeds_and_saves() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg());
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, 5L, null, null); // matches the loaded version
|
|
||||||
|
|
||||||
TimelineEventView view = service.update(id, request, secondEditor);
|
|
||||||
|
|
||||||
assertThat(view).isNotNull();
|
|
||||||
verify(events).saveAndFlush(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_with_stale_version_throws_conflict_without_saving() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, 2L, null, null); // stale: 2 != 5
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.update(id, request, secondEditor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
|
|
||||||
verify(events, never()).saveAndFlush(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_translates_concurrent_flush_lock_failure_to_TIMELINE_EVENT_CONFLICT() {
|
|
||||||
// Native @Version backstop: an in-sync token passes the explicit guard, but a genuinely
|
|
||||||
// concurrent flush makes saveAndFlush throw — it must still surface as a 409, not a 500.
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(events.saveAndFlush(any()))
|
|
||||||
.thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id));
|
|
||||||
TimelineEventRequest request = new TimelineEventRequest(
|
|
||||||
"Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28),
|
|
||||||
null, null, null, 5L, null, null); // in-sync, passes the guard
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.update(id, request, secondEditor))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- delete / getEvent ---
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void delete_removes_existing_event() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
|
|
||||||
service.delete(id);
|
|
||||||
|
|
||||||
verify(events).delete(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(events.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.delete(id))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getEvent_returns_view_for_existing_event() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
|
|
||||||
when(events.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
|
|
||||||
TimelineEventView view = service.getEvent(id);
|
|
||||||
|
|
||||||
assertThat(view.id()).isEqualTo(id);
|
|
||||||
assertThat(view.title()).isEqualTo("Original");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(events.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.getEvent(id))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
|
||||||
import jakarta.persistence.PersistenceContext;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration}
|
|
||||||
* against real Postgres. Verifies that assembled output reflects persisted curated events and
|
|
||||||
* that the generation query handles null-generation rows correctly.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
@Transactional
|
|
||||||
class TimelineServiceIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
|
||||||
|
|
||||||
@Autowired TimelineService timelineService;
|
|
||||||
@Autowired TimelineEventRepository timelineEventRepository;
|
|
||||||
@Autowired PersonRepository personRepository;
|
|
||||||
|
|
||||||
@PersistenceContext EntityManager em;
|
|
||||||
|
|
||||||
// ─── PersonRepository.findByGeneration ────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findByGeneration_returns_matching_persons() {
|
|
||||||
personRepository.save(Person.builder().lastName("Gen2A").generation(2).build());
|
|
||||||
personRepository.save(Person.builder().lastName("Gen2B").generation(2).build());
|
|
||||||
personRepository.save(Person.builder().lastName("Gen3").generation(3).build());
|
|
||||||
em.flush();
|
|
||||||
|
|
||||||
List<Person> result = personRepository.findByGeneration(2);
|
|
||||||
|
|
||||||
assertThat(result).extracting(Person::getLastName)
|
|
||||||
.containsExactlyInAnyOrder("Gen2A", "Gen2B");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findByGeneration_returns_empty_list_not_npe_when_no_match() {
|
|
||||||
personRepository.save(Person.builder().lastName("Gen1").generation(1).build());
|
|
||||||
em.flush();
|
|
||||||
|
|
||||||
List<Person> result = personRepository.findByGeneration(99);
|
|
||||||
|
|
||||||
assertThat(result).isNotNull().isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findByGeneration_does_not_return_null_generation_persons() {
|
|
||||||
personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null
|
|
||||||
em.flush();
|
|
||||||
|
|
||||||
List<Person> result = personRepository.findByGeneration(1);
|
|
||||||
|
|
||||||
assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── TimelineService.assemble end-to-end ─────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void assemble_includes_persisted_curated_event_in_correct_year_band() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder()
|
|
||||||
.title("Sarajevo")
|
|
||||||
.type(EventType.HISTORICAL)
|
|
||||||
.eventDate(LocalDate.of(1914, 6, 28))
|
|
||||||
.precision(DatePrecision.DAY)
|
|
||||||
.createdBy(actorId)
|
|
||||||
.updatedBy(actorId)
|
|
||||||
.build());
|
|
||||||
em.flush();
|
|
||||||
em.clear();
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).anySatisfy(y -> {
|
|
||||||
assertThat(y.year()).isEqualTo(1914);
|
|
||||||
assertThat(y.entries()).anySatisfy(e -> {
|
|
||||||
assertThat(e.title()).isEqualTo("Sarajevo");
|
|
||||||
assertThat(e.kind()).isEqualTo(Kind.EVENT);
|
|
||||||
assertThat(e.eventId()).isEqualTo(event.getId());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that {@link TimelineService#assemble} does not throw
|
|
||||||
* {@link org.hibernate.LazyInitializationException} when events have linked persons.
|
|
||||||
*
|
|
||||||
* <p>No class-level {@code @Transactional} — each test method runs without an outer
|
|
||||||
* transaction, matching production behaviour (controller has no {@code @Transactional}).
|
|
||||||
* If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing
|
|
||||||
* {@code ev.getPersons()} on detached entities throws LazyInitializationException.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
class TimelineServiceLazyLoadTest {
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
S3Client s3Client;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
TransactionTemplate transactionTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
TimelineService timelineService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
TimelineEventRepository timelineEventRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
PersonRepository personRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void assemble_does_not_throw_when_event_has_linked_persons() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
// Commit outside any test-managed transaction so entities are detached on return
|
|
||||||
transactionTemplate.execute(status -> {
|
|
||||||
Person person = personRepository.save(Person.builder().lastName("Müller").build());
|
|
||||||
timelineEventRepository.save(TimelineEvent.builder()
|
|
||||||
.title("Linked event")
|
|
||||||
.type(EventType.HISTORICAL)
|
|
||||||
.eventDate(LocalDate.of(1914, 7, 28))
|
|
||||||
.precision(DatePrecision.DAY)
|
|
||||||
.createdBy(actorId)
|
|
||||||
.updatedBy(actorId)
|
|
||||||
.persons(new HashSet<>(Set.of(person)))
|
|
||||||
.build());
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.timeline;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
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.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
import org.raddatz.familienarchiv.tag.RootTag;
|
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class TimelineServiceTest {
|
|
||||||
|
|
||||||
@Mock TimelineEventRepository eventRepository;
|
|
||||||
@Mock TimelineEventService timelineEventService;
|
|
||||||
@Mock DocumentService documentService;
|
|
||||||
@Mock PersonService personService;
|
|
||||||
@Mock TagService tagService;
|
|
||||||
|
|
||||||
@InjectMocks TimelineService timelineService;
|
|
||||||
|
|
||||||
// ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void within_band_order_day_precision_sorts_before_year() {
|
|
||||||
var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
|
|
||||||
var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
|
|
||||||
|
|
||||||
var sorted = List.of(yearEntry, dayEntry).stream()
|
|
||||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
assertThat(sorted).containsExactly(dayEntry, yearEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void within_band_order_same_precision_and_date_sorts_alphabetically() {
|
|
||||||
var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
|
|
||||||
var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
|
|
||||||
|
|
||||||
var sorted = List.of(entryZ, entryA).stream()
|
|
||||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
assertThat(sorted).containsExactly(entryA, entryZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void within_band_order_same_title_uses_document_id_as_tiebreak() {
|
|
||||||
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
|
||||||
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
|
||||||
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
|
||||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null,
|
|
||||||
null, null, null);
|
|
||||||
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
|
||||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null,
|
|
||||||
null, null, null);
|
|
||||||
|
|
||||||
var sorted = List.of(e2, e1).stream()
|
|
||||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
assertThat(sorted.get(0).documentId()).isEqualTo(id1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Assembly tests (issue-spec order) ──────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test1_empty_archive_returns_empty_dto() {
|
|
||||||
// REQ-013, REQ-007
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
assertThat(result.undated()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test2_one_year_letter_returns_one_year_band() {
|
|
||||||
// REQ-007
|
|
||||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
|
||||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
|
|
||||||
assertThat(result.undated()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test3a_null_date_letter_goes_to_undated() {
|
|
||||||
// REQ-003
|
|
||||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
assertThat(result.undated()).hasSize(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test3b_unknown_precision_letter_goes_to_undated() {
|
|
||||||
// REQ-003
|
|
||||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
assertThat(result.undated()).hasSize(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
|
|
||||||
// REQ-005
|
|
||||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(DatePrecision.YEAR)
|
|
||||||
.documentDate(LocalDate.of(1914, 1, 1))
|
|
||||||
.build(); // no sender, no senderText, no receivers, no receiverText
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
var entry = result.years().get(0).entries().get(0);
|
|
||||||
assertThat(entry.senderName()).isEqualTo("");
|
|
||||||
assertThat(entry.receiverName()).isEqualTo("");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test5_day_precision_sorts_before_year_in_same_year_band() {
|
|
||||||
// REQ-002
|
|
||||||
var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
|
|
||||||
var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
var entries = result.years().get(0).entries();
|
|
||||||
assertThat(entries).hasSize(2);
|
|
||||||
assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
|
|
||||||
assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test6_same_precision_same_date_sorted_alphabetically_by_title() {
|
|
||||||
// REQ-002
|
|
||||||
var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
|
|
||||||
var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
var entries = result.years().get(0).entries();
|
|
||||||
assertThat(entries).hasSize(2);
|
|
||||||
assertThat(entries.get(0).title()).isEqualTo("Adler");
|
|
||||||
assertThat(entries.get(1).title()).isEqualTo("Zimmer");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test7a_range_event_placed_only_in_start_year_band() {
|
|
||||||
// REQ-004
|
|
||||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
|
||||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
|
||||||
assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
|
|
||||||
// REQ-004
|
|
||||||
var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
|
|
||||||
LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test8_range_event_excluded_when_start_year_before_fromYear() {
|
|
||||||
// REQ-004
|
|
||||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
|
||||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
// fromYear=1915 → start year 1914 is outside → excluded
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
|
|
||||||
// REQ-009
|
|
||||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
|
|
||||||
var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
|
|
||||||
LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
|
|
||||||
var personalEvent = event("Geburt", EventType.PERSONAL,
|
|
||||||
LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
// filter: only HISTORICAL events
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));
|
|
||||||
|
|
||||||
long letters = result.years().stream().flatMap(y -> y.entries().stream())
|
|
||||||
.filter(e -> e.kind() == Kind.LETTER).count();
|
|
||||||
long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
|
|
||||||
.filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
|
|
||||||
assertThat(letters).isEqualTo(1);
|
|
||||||
assertThat(personalEvents).isEqualTo(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
|
|
||||||
// REQ-010
|
|
||||||
var sender = Person.builder().id(UUID.randomUUID())
|
|
||||||
.lastName("Mustermann").firstName("Max").generation(2).build();
|
|
||||||
var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
|
|
||||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
|
||||||
.sender(sender).build();
|
|
||||||
var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
|
|
||||||
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test9c_fromYear_toYear_inclusive_single_year_window() {
|
|
||||||
// REQ-011
|
|
||||||
var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
|
|
||||||
var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
|
|
||||||
var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
|
||||||
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test10_adversarial_and_logic_neither_event_passes_both_filters() {
|
|
||||||
// REQ-012 — type AND year must both pass
|
|
||||||
var wrongType = event("Personal", EventType.PERSONAL,
|
|
||||||
LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
|
|
||||||
var wrongYear = event("Historical outside", EventType.HISTORICAL,
|
|
||||||
LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
assertThat(result.undated()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
|
|
||||||
// REQ-008
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
var person = Person.builder().id(personId).lastName("Mustermann").build();
|
|
||||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
|
||||||
.sender(person)
|
|
||||||
.receivers(Set.of(person))
|
|
||||||
.build();
|
|
||||||
when(personService.getById(personId)).thenReturn(person);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
|
|
||||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));
|
|
||||||
|
|
||||||
long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
|
|
||||||
+ result.undated().size();
|
|
||||||
assertThat(total).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
|
|
||||||
// REQ-012
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
|
|
||||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
|
||||||
.sender(person).build();
|
|
||||||
var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
|
|
||||||
when(personService.getById(personId)).thenReturn(person);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
|
|
||||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
|
|
||||||
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
assertThat(result.undated()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test13_null_generation_sender_not_returned_by_generation_filter() {
|
|
||||||
// REQ-020 — both sender and receiver have null generation → excluded
|
|
||||||
var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
|
|
||||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
|
||||||
.sender(nullGenSender).build();
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).isEmpty();
|
|
||||||
assertThat(result.undated()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
|
|
||||||
var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test15_range_event_start_year_equal_to_fromYear_is_included() {
|
|
||||||
// REQ-004 — inclusive lower bound
|
|
||||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
|
||||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
|
|
||||||
// REQ-011
|
|
||||||
var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
|
|
||||||
var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
|
|
||||||
var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));
|
|
||||||
|
|
||||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));
|
|
||||||
|
|
||||||
assertThat(result.years()).hasSize(2);
|
|
||||||
assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void fromYear_greater_than_toYear_throws_bad_request() {
|
|
||||||
// REQ-016 (service-layer guard)
|
|
||||||
assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
|
|
||||||
.isInstanceOf(DomainException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// ─── root-tag chip enrichment (#835) ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void letter_with_tags_carries_its_primary_root_tag() {
|
|
||||||
// REQ-003/006: the primary tag is the root ancestor of the alphabetically-first
|
|
||||||
// assigned tag ("Briefe von der Front" < "Zeitung"), resolved to root "Krieg".
|
|
||||||
UUID kriegId = UUID.randomUUID();
|
|
||||||
Tag front = Tag.builder().id(UUID.randomUUID()).name("Briefe von der Front").parentId(kriegId).build();
|
|
||||||
Tag zeitung = Tag.builder().id(UUID.randomUUID()).name("Zeitung").build();
|
|
||||||
Document doc = docWithTags(LocalDate.of(1916, 5, 1), DatePrecision.MONTH, front, zeitung);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
when(tagService.resolveRootTags(anyList()))
|
|
||||||
.thenReturn(Map.of(front.getId(), new RootTag(kriegId, "Krieg", "sienna")));
|
|
||||||
|
|
||||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
|
||||||
|
|
||||||
assertThat(entry.rootTagId()).isEqualTo(kriegId);
|
|
||||||
assertThat(entry.rootTagName()).isEqualTo("Krieg");
|
|
||||||
assertThat(entry.rootTagColor()).isEqualTo("sienna");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void untagged_letter_has_no_root_tag_fields() {
|
|
||||||
// REQ-005: a letter with no tags carries null id/name/color — and never hits TagService.
|
|
||||||
Document doc = docWithDate(LocalDate.of(1909, 3, 1), DatePrecision.MONTH);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
|
||||||
|
|
||||||
assertThat(entry.rootTagId()).isNull();
|
|
||||||
assertThat(entry.rootTagName()).isNull();
|
|
||||||
assertThat(entry.rootTagColor()).isNull();
|
|
||||||
verify(tagService, never()).resolveRootTags(anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void letter_primary_root_without_color_yields_null_color() {
|
|
||||||
// REQ-007: a colorless root → rootTagColor null, id+name still present (neutral chip).
|
|
||||||
UUID rootId = UUID.randomUUID();
|
|
||||||
Tag allgemein = Tag.builder().id(rootId).name("Allgemein").build();
|
|
||||||
Document doc = docWithTags(LocalDate.of(1910, 2, 1), DatePrecision.MONTH, allgemein);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
|
||||||
when(tagService.resolveRootTags(anyList()))
|
|
||||||
.thenReturn(Map.of(rootId, new RootTag(rootId, "Allgemein", null)));
|
|
||||||
|
|
||||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
|
||||||
|
|
||||||
assertThat(entry.rootTagId()).isEqualTo(rootId);
|
|
||||||
assertThat(entry.rootTagName()).isEqualTo("Allgemein");
|
|
||||||
assertThat(entry.rootTagColor()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void root_tags_resolved_in_a_single_batched_pass() {
|
|
||||||
// REQ-004: many letters → exactly one resolveRootTags call (no per-letter N+1).
|
|
||||||
UUID kriegId = UUID.randomUUID();
|
|
||||||
Tag krieg = Tag.builder().id(kriegId).name("Krieg").color("sienna").build();
|
|
||||||
Tag weihnachten = Tag.builder().id(UUID.randomUUID()).name("Weihnachten").color("amber").build();
|
|
||||||
Document a = docWithTags(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, krieg);
|
|
||||||
Document b = docWithTags(LocalDate.of(1916, 12, 1), DatePrecision.MONTH, weihnachten);
|
|
||||||
Document c = docWithTags(LocalDate.of(1917, 1, 1), DatePrecision.YEAR, krieg);
|
|
||||||
when(eventRepository.findAll()).thenReturn(List.of());
|
|
||||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
|
||||||
when(documentService.getAllForTimeline()).thenReturn(List.of(a, b, c));
|
|
||||||
when(tagService.resolveRootTags(anyList())).thenReturn(Map.of(
|
|
||||||
kriegId, new RootTag(kriegId, "Krieg", "sienna"),
|
|
||||||
weihnachten.getId(), new RootTag(weihnachten.getId(), "Weihnachten", "amber")));
|
|
||||||
|
|
||||||
timelineService.assemble(noFilters());
|
|
||||||
|
|
||||||
verify(tagService, times(1)).resolveRootTags(anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TimelineEntryDTO onlyLetterEntry(TimelineDTO result) {
|
|
||||||
assertThat(result.years()).hasSize(1);
|
|
||||||
return result.years().get(0).entries().get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TimelineFilter noFilters() {
|
|
||||||
return new TimelineFilter(null, null, null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
|
||||||
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
|
||||||
date, null, title, null, null, UUID.randomUUID(), List.of(), null,
|
|
||||||
null, null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
|
||||||
return Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(precision).documentDate(date).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Document docWithTags(LocalDate date, DatePrecision precision, Tag... tags) {
|
|
||||||
return Document.builder().id(UUID.randomUUID()).title("Brief")
|
|
||||||
.metaDatePrecision(precision).documentDate(date)
|
|
||||||
.tags(new HashSet<>(Set.of(tags))).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
|
|
||||||
return Document.builder().id(UUID.randomUUID()).title(title)
|
|
||||||
.metaDatePrecision(precision).documentDate(date).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TimelineEvent event(String title, EventType type, LocalDate date,
|
|
||||||
DatePrecision precision, LocalDate endDate) {
|
|
||||||
return TimelineEvent.builder().id(UUID.randomUUID())
|
|
||||||
.title(title).type(type)
|
|
||||||
.eventDate(date).precision(precision).eventDateEnd(endDate)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
|||||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. Timeline domain codes: `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG`, plus a generic `CONFLICT` (409 optimistic-lock backstop). |
|
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
|
||||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||||
|
|||||||
@@ -538,29 +538,6 @@ pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
|||||||
|
|
||||||
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
||||||
|
|
||||||
### Deploy note — V78 (person_relationships from/to → date + precision, #837)
|
|
||||||
|
|
||||||
V78 drops `person_relationships.from_year`/`to_year` after backfilling the new
|
|
||||||
`from_date`/`to_date` + precision columns — a **one-way migration** (Flyway cannot roll
|
|
||||||
it back). Like V76 it runs its pre-check + DDL in one atomic Flyway transaction and
|
|
||||||
needs **no maintenance window** (single-writer archive, no concurrent importers).
|
|
||||||
|
|
||||||
It is, however, **not rolling-deploy-safe**: the previously-running JAR still maps the
|
|
||||||
`from_year`/`to_year` columns, so it would error against the migrated schema. Deploy in
|
|
||||||
this order (the default stop-then-start, single-instance deploy already satisfies it):
|
|
||||||
|
|
||||||
1. Take a manual `pg_dump` (see above) and confirm it completed.
|
|
||||||
2. **Stop the old JAR**, then **start the new JAR** — Flyway V78 runs first thing on the
|
|
||||||
new JAR's startup, before any request is served. Never run the old and new JARs
|
|
||||||
concurrently across this migration.
|
|
||||||
|
|
||||||
If post-deploy data issues are found, restore **only the person_relationships table**
|
|
||||||
from the pre-migration dump:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pg_restore -t person_relationships -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rollback
|
### Rollback
|
||||||
|
|
||||||
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
||||||
|
|||||||
@@ -168,18 +168,7 @@ _Not to be confused with a document item's optional note_ — a document item's
|
|||||||
|
|
||||||
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
|
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
|
||||||
|
|
||||||
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain.
|
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
|
||||||
|
|
||||||
**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view).
|
|
||||||
|
|
||||||
**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043).
|
|
||||||
_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database.
|
|
||||||
|
|
||||||
**DerivedEventType** (`DerivedEventType`) `[internal]` — enum with three values: `BIRTH`, `DEATH`, `MARRIAGE`. Carried on `TimelineEntryDTO.derivedType`; `null` on curated-event entries exposed through the same DTO.
|
|
||||||
|
|
||||||
**derivedType** (`TimelineEntryDTO.derivedType`) `[internal]` — the `DerivedEventType` field distinguishing a derived Geburt/Tod/Heirat event from a curated one. Always non-null on derived events; `null` on curated events.
|
|
||||||
|
|
||||||
**assembleDerivedEvents()** (`TimelineEventService.assembleDerivedEvents()`) `[internal]` — the public `@Transactional(readOnly=true)` method that computes all derived events in one call: one batch fetch of family-member `Person`s via `PersonService.findAllFamilyMembers()` and one batch fetch of `SPOUSE_OF` edges via `RelationshipService.findAllSpouseEdges()`. Result is never persisted. Synthetic ids produced by this method (`birth:{uuid}`, `death:{uuid}`, `marriage:{uuid}`) are structurally non-UUID and must be rejected by any write endpoint. See ADR-043.
|
|
||||||
|
|
||||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# ADR-042 — Adopt Spec-Driven Development (SDD)
|
|
||||||
|
|
||||||
**Status:** Accepted
|
|
||||||
**Date:** 2026-06-13
|
|
||||||
**Issue:** SDD integration (docs/sdd-integration branch)
|
|
||||||
|
|
||||||
> This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
|
|
||||||
> sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
|
|
||||||
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The project already runs a lightweight, multi-persona review loop: feature work is tracked
|
|
||||||
as dense Gitea issues, and the `review-issue` / `review-pr` / `deliver-issue` skills dispatch
|
|
||||||
the character personas in `.claude/personas/` (requirements engineer, developer, security,
|
|
||||||
devops, ui, architect, tester) to comment on issues and PRs. `COLLABORATING.md` already
|
|
||||||
mandates a Research → Plan → Implement → Validate cycle, red/green TDD, and a User-Journey +
|
|
||||||
E2E-Scenario section on every feature issue. Decisions are captured in this ADR archive.
|
|
||||||
|
|
||||||
What is missing is a **machine-readable, uniform front-end to that workflow**:
|
|
||||||
|
|
||||||
- Requirements are written in free prose, so two readers (and an AI agent) can interpret the
|
|
||||||
same issue differently. There is no enforced requirement grammar and no stable requirement
|
|
||||||
identifier to trace from spec → code → test.
|
|
||||||
- There is no single short file an AI coding agent reads on every invocation; the rules are
|
|
||||||
spread across `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, and `CONTRIBUTING.md`.
|
|
||||||
- Persona review is valuable but ad-hoc — there is no per-role checklist that gates a spec
|
|
||||||
*before* implementation, so blind spots surface during PR review instead of at spec time.
|
|
||||||
- There is no living traceability record and no CI signal that a spec is well-formed.
|
|
||||||
|
|
||||||
The project is solo + LLM-driven; spec density and machine-readability are exactly the
|
|
||||||
leverage points that make agent output reliable.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Adopt Spec-Driven Development, layered **on top of** the existing workflow, not replacing it:
|
|
||||||
|
|
||||||
1. **EARS requirements.** Every feature requirement is written in one of the five EARS
|
|
||||||
patterns and carries a per-feature `REQ-NNN` id. (constitution §3; templates/feature-spec.md)
|
|
||||||
2. **The spec lives in the Gitea issue body — issue-only, no committed `spec.md`.** A feature's
|
|
||||||
spec (EARS requirements, acceptance criteria, scope) is authored and reviewed in the Gitea
|
|
||||||
issue, the single source of truth (consistent with the established "issue body is the source
|
|
||||||
of truth / don't commit standalone spec files" practice). The only per-feature artifact in
|
|
||||||
git is the RTM row (`REQ-ID → issue # → test`). Durable design decisions still get a
|
|
||||||
`docs/adr/` ADR; an OpenAPI contract or STRIDE threat model is drafted inline in the issue
|
|
||||||
using the `.specify/templates/` as writing aids. `.specify/features/_example/` is a committed
|
|
||||||
template/reference, not a live feature.
|
|
||||||
3. **A constitution + AGENTS.md.** `.specify/constitution.md` records the few non-negotiable
|
|
||||||
rules (semantically versioned); `.specify/AGENTS.md` is the short machine-readable file AI
|
|
||||||
agents read every invocation, cross-referencing the constitution rather than duplicating it.
|
|
||||||
4. **Persona checklists.** `.specify/personas/*.md` turn the existing rich personas into
|
|
||||||
concise, EARS-aware, pass/fail spec-review checklists that gate a spec before code.
|
|
||||||
5. **Living RTM + CI gate.** `.specify/rtm.md` traces every `REQ-NNN` to its issue and test;
|
|
||||||
`.gitea/workflows/sdd-gate.yml` validates any committed OpenAPI contract, lints any committed
|
|
||||||
spec (e.g. the `_example`), reports constitution-change impact, and surfaces RTM drift
|
|
||||||
(non-blocking initially).
|
|
||||||
6. **Reuse, don't duplicate.** ADRs stay in `docs/adr/`; persona checklists reference
|
|
||||||
`.claude/personas/`; the Gitea issue templates mirror the feature-spec template.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
| Option | Pros | Cons | Reason rejected |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Adopt SDD (EARS requirements in the issue + AGENTS.md + constitution), integrated with existing docs | Adds requirement rigor & machine-readability; reuses ADR archive, personas, and the issue-as-spec practice; incremental | Per-feature spec authoring overhead | **Chosen** |
|
|
||||||
| No change (keep free-prose issues + persona review) | Zero new process | Ambiguous requirements; no traceability; no single agent-facing file; blind spots found late | Leaves the exact gaps that hurt agent output |
|
|
||||||
| Full GitHub Spec Kit | Mature, opinionated tooling | Heavy, opinionated structure that fights the existing Gitea/skill/persona workflow; redundant ADR/persona machinery | Too much to retrofit; conflicts with what already works |
|
|
||||||
| BMAD-METHOD | Rich agent-role framework | Large conceptual surface for a solo project; overlaps the existing persona system | Over-engineered for this team size |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Cost:** ~30–60 min of spec authoring + structured persona review per feature, before
|
|
||||||
implementation begins.
|
|
||||||
- **Gains:** unambiguous, testable requirements; a stable spec→code→test trace; one
|
|
||||||
authoritative agent-facing rules file; review blind spots caught at spec time; better and
|
|
||||||
more consistent AI-agent output (the project's primary delivery mechanism).
|
|
||||||
- **Obligations created:**
|
|
||||||
- The constitution is semantically versioned; any change triggers its Sync Impact review.
|
|
||||||
- A new feature follows the workflow in [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md).
|
|
||||||
- `rtm.md` is kept in sync as requirements land (CI warns on drift).
|
|
||||||
- The SDD CI jobs start non-blocking and flip to blocking once adoption settles (TODO noted
|
|
||||||
in the workflow).
|
|
||||||
- **Non-disruptive:** the existing Gitea-issue, branch/PR, TDD, and persona-review practices
|
|
||||||
are unchanged in spirit — SDD formalises their inputs, it does not replace them.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [.specify/constitution.md](../../.specify/constitution.md), [.specify/AGENTS.md](../../.specify/AGENTS.md)
|
|
||||||
- [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md)
|
|
||||||
- [COLLABORATING.md](../../COLLABORATING.md), [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
|
||||||
- EARS: Mavin et al., "Easy Approach to Requirements Syntax" (2009)
|
|
||||||
|
|
||||||
## Revision log
|
|
||||||
|
|
||||||
- 2026-06-13 — constitution v1.0.0 ratified with this ADR.
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# ADR-043 — Derived person life-events: on-read assembly strategy
|
|
||||||
|
|
||||||
**Status:** Proposed
|
|
||||||
**Date:** 2026-06-13
|
|
||||||
**Issue:** #776 — Timeline: derive person life-events (Geburt/Tod/Heirat)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The Zeitstrahl (family timeline) must surface births, deaths, and marriages alongside
|
|
||||||
manually curated `TimelineEvent` rows. This data already exists in the `Person` entity
|
|
||||||
(`birthDate`, `deathDate`, `birthDatePrecision`, `deathDatePrecision`) and in
|
|
||||||
`PersonRelationship` rows with `relationType = SPOUSE_OF`.
|
|
||||||
|
|
||||||
Three architectural decisions needed before implementation could start:
|
|
||||||
|
|
||||||
1. **Computation strategy:** should derived events be materialised to the `timeline_events`
|
|
||||||
table, or assembled on every read from the source tables?
|
|
||||||
2. **Id format:** how do we give derived events stable, unambiguous ids that cannot collide
|
|
||||||
with real `TimelineEvent` UUIDs and signal read-only semantics to consumers?
|
|
||||||
3. **Service contract:** where does the assembly method live, and what is its public API?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision 1 — On-read assembly, never persisted
|
|
||||||
|
|
||||||
Derived events are computed on every call to `assembleDerivedEvents()` and are never written
|
|
||||||
to any table.
|
|
||||||
|
|
||||||
**Alternatives rejected:**
|
|
||||||
|
|
||||||
| Alternative | Reason rejected |
|
|
||||||
|-------------|-----------------|
|
|
||||||
| Materialise to `timeline_events` | Requires a synchronisation job or domain-event wiring every time a `Person` or `PersonRelationship` is mutated. Adds complexity, drift risk, and a write path for data that is fundamentally derived. |
|
|
||||||
| Separate `derived_events` table | Same sync problem; adds schema migration for data that is a pure projection. |
|
|
||||||
| Cache in-process | Adds invalidation complexity for MVP scale (tens to low hundreds of persons). Can be added later if `findAllFamilyMembers()` exceeds ~500 rows. |
|
|
||||||
|
|
||||||
**Consequences:**
|
|
||||||
- No schema changes. No Flyway migration.
|
|
||||||
- The method must be `@Transactional(readOnly = true)` to keep the Hibernate session open
|
|
||||||
across the lazy-association reads that `buildMarriageEvents()` performs via JOIN FETCH.
|
|
||||||
- Every caller of `assembleDerivedEvents()` triggers two DB queries: one for family-member
|
|
||||||
persons, one for spouse edges with JOIN FETCH. Acceptable at MVP scale.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision 2 — Synthetic prefixed String ids
|
|
||||||
|
|
||||||
Derived events receive ids of the form `birth:{personId}`, `death:{personId}`,
|
|
||||||
`marriage:{relationshipId}`, where the suffix is the UUID of the source entity.
|
|
||||||
|
|
||||||
**Format rules:**
|
|
||||||
- `id` field on `TimelineEntryDTO` is typed `String`, NOT `UUID`.
|
|
||||||
- `UUID.fromString(derivedEvent.id())` always throws `IllegalArgumentException` — id is
|
|
||||||
structurally non-UUID by construction.
|
|
||||||
- The `unique_spouse_pair` DB index (V55) is the authoritative dedup guard for marriages;
|
|
||||||
the in-memory `Set<UUID>` used during assembly is a defensive assertion, not primary
|
|
||||||
enforcement.
|
|
||||||
|
|
||||||
**Alternatives rejected:**
|
|
||||||
|
|
||||||
| Alternative | Reason rejected |
|
|
||||||
|-------------|-----------------|
|
|
||||||
| Random UUID for each call | Not stable across calls — consumers (frontend, #5 sort/bucket) could not use ids as stable keys. |
|
|
||||||
| UUID typed field with a sentinel namespace (RFC 4122 v5) | Requires hashing; still looks like a UUID and could be confused with real event ids by write endpoints. |
|
|
||||||
| Numeric sequence | No natural source sequence; would require a counter, adding state. |
|
|
||||||
|
|
||||||
**Consequences:**
|
|
||||||
- `TimelineEntryDTO.id` must be `String`. The existing `TimelineEventView.id` is `UUID` and
|
|
||||||
serves a different purpose (CRUD admin view); it is not changed.
|
|
||||||
- Any write endpoint that accepts a timeline event id (`PUT`, `DELETE`) must reject ids that
|
|
||||||
do not parse as `UUID` — enforced and tested in issue #5, not here.
|
|
||||||
- Ids are deterministic and stable for the lifetime of the source entity, enabling client-side
|
|
||||||
caching and deduplication.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision 3 — `assembleDerivedEvents()` as the public cross-issue contract
|
|
||||||
|
|
||||||
The assembly method lives on `TimelineService` as a `public` method. Issue #5 (the
|
|
||||||
`GET /api/timeline` endpoint) calls it directly on the injected `TimelineService` bean.
|
|
||||||
|
|
||||||
**Domain boundary rules enforced by this decision:**
|
|
||||||
- `TimelineService` reaches `Person` and `PersonRelationship` data **only through
|
|
||||||
`PersonService.findAllFamilyMembers()` and `RelationshipService.findAllSpouseEdges()`**.
|
|
||||||
It never injects `PersonRepository` or `PersonRelationshipRepository`.
|
|
||||||
- The three private builder methods (`buildBirthEvents`, `buildDeathEvents`,
|
|
||||||
`buildMarriageEvents`) are implementation details; only `assembleDerivedEvents()` is public.
|
|
||||||
- **Authorization:** `assembleDerivedEvents()` performs no authorization check. The calling
|
|
||||||
endpoint in #5 must enforce `READ_ALL` before invoking this method. Any future caller
|
|
||||||
outside #5 must do the same — this obligation is documented in the Javadoc of the method.
|
|
||||||
|
|
||||||
**Alternatives rejected:**
|
|
||||||
|
|
||||||
| Alternative | Reason rejected |
|
|
||||||
|-------------|-----------------|
|
|
||||||
| Separate `DerivedEventService` | Adds a class for a cohesive set of methods that belong to the timeline domain. Timeline owns the DTO shape; splitting it out is premature. |
|
|
||||||
| Expose via `PersonService` | Person domain should not know about `TimelineEntryDTO`. Cross-cutting concern belongs in timeline. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related decisions
|
|
||||||
|
|
||||||
- ADR-039 — Person life-dates stored as `LocalDate` + `DatePrecision` (the source data this
|
|
||||||
issue reads)
|
|
||||||
- ADR-040 — Timeline domain data model (establishes the `timeline/` package and
|
|
||||||
`TimelineEvent` entity this issue extends)
|
|
||||||
- ADR-036 — Responses as views, never raw entities (why `assembleDerivedEvents()` returns
|
|
||||||
`List<TimelineEntryDTO>`, not raw `Person` or `PersonRelationship` entities)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# ADR-044 — Relationship dates become LocalDate + DatePrecision; relationships become editable
|
|
||||||
|
|
||||||
**Status:** Accepted
|
|
||||||
**Date:** 2026-06-14
|
|
||||||
**Issue:** #837 (Zeitstrahl milestone; deferred follow-up to #773 / ADR-039)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`PersonRelationship` stored its span as `Integer fromYear`/`toYear`. A wedding could
|
|
||||||
never be more precise than `1923`, while `Person` (ADR-039), `Document`, and
|
|
||||||
`TimelineEvent` already carry full `DatePrecision`. Relationships also supported only
|
|
||||||
create + delete: fixing a wrong type, a wrong person, or adding a date learned later
|
|
||||||
meant deleting and re-creating the edge — losing `createdAt`. A `notes` column existed
|
|
||||||
that no form set and nothing displayed.
|
|
||||||
|
|
||||||
V78 replaces the two integer columns with `from_date`/`to_date` (`DATE`, nullable) plus
|
|
||||||
`from_date_precision`/`to_date_precision` (`VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'`),
|
|
||||||
backfilling existing years as `YYYY-01-01` at `YEAR` precision — exactly the V76 / ADR-039
|
|
||||||
pattern applied to the relationship edge. A new `PUT /api/persons/{id}/relationships/{relId}`
|
|
||||||
makes relationships editable, and `notes` is activated end to end.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### 1. Mirror ADR-039 verbatim for the relationship edge
|
|
||||||
|
|
||||||
`DatePrecision` is imported cross-domain from `document/` (ADR-039 §1 — value-type
|
|
||||||
sharing, not a layering breach). The precision columns are NOT NULL default `UNKNOWN`,
|
|
||||||
guarded by five named CHECK constraints (`chk_relationship_from_coherence`,
|
|
||||||
`chk_relationship_to_coherence`, `chk_relationship_date_order`,
|
|
||||||
`chk_relationship_{from,to}_precision_values`). `RelationshipService.validateRelationshipDates`
|
|
||||||
enforces the same rules first, so the user gets a structured 400
|
|
||||||
(`INVALID_DATE_PRECISION` for coherence, the new `INVALID_RELATIONSHIP_DATES` for a
|
|
||||||
`toDate < fromDate` order violation) instead of a constraint-violation 500. The form
|
|
||||||
offers only **DAY / MONTH / YEAR**; storage still accepts all seven values, and a
|
|
||||||
stored non-offered precision seeds the edit select as `YEAR` (ADR-039 §2).
|
|
||||||
|
|
||||||
### 2. Update re-runs every create invariant
|
|
||||||
|
|
||||||
An edit can violate the same invariants as a create, so `updateRelationship` re-runs
|
|
||||||
all of them: self-relation (`VALIDATION_ERROR`), date coherence + order, reverse
|
|
||||||
`PARENT_OF` (`CIRCULAR_RELATIONSHIP`), and the `(person, relatedPerson, type)` unique
|
|
||||||
constraint via `saveAndFlush` (`DUPLICATE_RELATIONSHIP`). Editing into a family type
|
|
||||||
flags both endpoints as family members (additive; never auto-unflags). The directed
|
|
||||||
orientation is preserved per viewpoint — whichever endpoint `{personId}` already holds
|
|
||||||
on the row stays put — so a `PARENT_OF` edge remains parent→child whether edited from
|
|
||||||
either person's page.
|
|
||||||
|
|
||||||
### 3. No optimistic locking (`@Version`)
|
|
||||||
|
|
||||||
`PersonRelationship` gains no `@Version`; the edit is last-write-wins, matching the
|
|
||||||
person edit form. This is a single-writer family archive, and it avoids the managed-
|
|
||||||
`setVersion` pitfall (a `setVersion` on a managed entity is silently ignored by
|
|
||||||
Hibernate — see the integration-test note in #496-era work). If concurrent curation
|
|
||||||
ever becomes real, add `@Version` plus an explicit client-version compare then.
|
|
||||||
|
|
||||||
### 4. IDOR / anti-enumeration: ownership mismatch is 404, for PUT **and** DELETE
|
|
||||||
|
|
||||||
A `{relId}` that does not belong to `{personId}` returns 404 `RELATIONSHIP_NOT_FOUND`
|
|
||||||
(a shared `loadOwnedRelationship` helper), so a curator cannot probe relationship ids
|
|
||||||
belonging to people they cannot see. This **aligns `deleteRelationship`** from its
|
|
||||||
former 403 to 404 in the same change, so the two mutating endpoints behave identically
|
|
||||||
on the same mismatch.
|
|
||||||
|
|
||||||
### 5. Derived marriage events gain precision for free
|
|
||||||
|
|
||||||
`TimelineEventService.buildMarriageEvents` now sources the Heirat date from the
|
|
||||||
`SPOUSE_OF` row's `from_date` + `from_date_precision` (previously
|
|
||||||
`LocalDate.of(fromYear, 1, 1)` at hard-coded `YEAR`). A DAY-precision wedding now
|
|
||||||
surfaces the exact day on the Zeitstrahl. `RelationshipInferenceService` is unchanged
|
|
||||||
— it is time-ignorant and never read the year fields.
|
|
||||||
|
|
||||||
### 6. `relationshipDates.ts` lives in `$lib/person/`, no new boundary
|
|
||||||
|
|
||||||
`formatRelationshipDateRange` mirrors `personLifeDates.ts` and delegates entirely to the
|
|
||||||
already-tested `formatDocumentDate` (zero new precision logic). It sits in `$lib/person/`
|
|
||||||
next to `personLifeDates.ts`; its only cross-domain import is `formatDocumentDate` from
|
|
||||||
`$lib/shared/utils/`, which the existing `person → shared` rule in `eslint.config.js`
|
|
||||||
already permits — **no new eslint boundary rule is added**.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- V78 is one-way (columns dropped) and is **not** rolling-deploy-safe — the running JAR
|
|
||||||
maps `from_year` until redeploy. Deploy order: **stop old JAR → run Flyway V78 →
|
|
||||||
start new JAR**. Rollback = targeted `pg_restore -t person_relationships` from the
|
|
||||||
pre-deploy dump (see `docs/DEPLOYMENT.md` §8). No maintenance window needed
|
|
||||||
(single-writer archive).
|
|
||||||
- Relationships are fully editable (type, related person, dates, notes) and the read
|
|
||||||
view shows the date range + notes.
|
|
||||||
- `RelationshipDTO` drops `fromYear`/`toYear` for `fromDate`/`fromDatePrecision`/
|
|
||||||
`toDate`/`toDatePrecision`; the `personBirthYear`/`relatedPersonBirthYear` derived
|
|
||||||
fields are unaffected (ADR-039 §3).
|
|
||||||
@@ -6,28 +6,19 @@ title Component Diagram: API Backend — Timeline (Zeitstrahl)
|
|||||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||||
|
|
||||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||||
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).")
|
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in #777.")
|
||||||
|
|
||||||
Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).")
|
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.")
|
||||||
Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
||||||
|
|
||||||
Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.")
|
|
||||||
Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch")
|
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
|
||||||
System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
|
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
|
||||||
|
|
||||||
Rel(timelineRepo, db, "SQL queries", "JDBC")
|
Rel(timelineRepo, db, "SQL queries", "JDBC")
|
||||||
Rel(timelineSvc, timelineRepo, "Reads / writes events")
|
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
|
||||||
Rel(timelineCtrl, timelineSvc, "Delegates to")
|
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
|
||||||
Rel(timelineRepo, personDomain, "References persons via join table")
|
Rel(timelineRepo, personDomain, "References persons via join table")
|
||||||
Rel(timelineRepo, documentDomain, "References documents via join table")
|
Rel(timelineRepo, documentDomain, "References documents via join table")
|
||||||
Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly")
|
|
||||||
Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to")
|
|
||||||
Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events")
|
|
||||||
Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events")
|
|
||||||
Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters")
|
|
||||||
Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer")
|
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
|||||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
||||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||||
Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
|
|
||||||
Component(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.")
|
|
||||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||||
@@ -29,9 +27,6 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
|
|||||||
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
||||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||||
Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
|
|
||||||
Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
|
|
||||||
Rel(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "HTTP / JSON")
|
|
||||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@startuml db-orm
|
@startuml db-orm
|
||||||
' Schema source: Flyway V1–V78 (excl. V37, V43 — intentionally removed)
|
' Schema source: Flyway V1–V77 (excl. V37, V43 — intentionally removed)
|
||||||
' Schema as of: V78 (2026-06-14)
|
' Schema as of: V77 (2026-06-12)
|
||||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
@@ -211,10 +211,8 @@ package "Persons" {
|
|||||||
person_id : UUID <<FK>>
|
person_id : UUID <<FK>>
|
||||||
related_person_id : UUID <<FK>>
|
related_person_id : UUID <<FK>>
|
||||||
relation_type : VARCHAR(30) NOT NULL
|
relation_type : VARCHAR(30) NOT NULL
|
||||||
from_date : DATE
|
from_year : INTEGER
|
||||||
from_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
|
to_year : INTEGER
|
||||||
to_date : DATE
|
|
||||||
to_date_precision : VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN'
|
|
||||||
notes : VARCHAR(2000)
|
notes : VARCHAR(2000)
|
||||||
created_at : TIMESTAMPTZ NOT NULL
|
created_at : TIMESTAMPTZ NOT NULL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
||||||
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
||||||
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
||||||
' Note: V78 swaps person_relationships.from_year/to_year for from_date/to_date +
|
|
||||||
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
|
||||||
|
|
||||||
hide circle
|
hide circle
|
||||||
skinparam linetype ortho
|
skinparam linetype ortho
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ src/
|
|||||||
│ ├── api/ # Internal API proxies (server-side only)
|
│ ├── api/ # Internal API proxies (server-side only)
|
||||||
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
|
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
|
||||||
│ ├── stammbaum/ # Family tree
|
│ ├── stammbaum/ # Family tree
|
||||||
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline; events/new + events/[id]/edit curator editor (WRITE_ALL-gated)
|
|
||||||
│ ├── enrich/ # Enrichment workflow ([id], done)
|
│ ├── enrich/ # Enrichment workflow ([id], done)
|
||||||
│ ├── hilfe/transkription/ # Transcription help page
|
│ ├── hilfe/transkription/ # Transcription help page
|
||||||
│ ├── profile/ # User profile settings
|
│ ├── profile/ # User profile settings
|
||||||
@@ -50,7 +49,6 @@ src/
|
|||||||
│ │ ├── relationship/ # Relationship form + chip components
|
│ │ ├── relationship/ # Relationship form + chip components
|
||||||
│ │ └── genealogy/ # Stammbaum (family tree) components
|
│ │ └── genealogy/ # Stammbaum (family tree) components
|
||||||
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
|
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
|
||||||
│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
|
|
||||||
│ ├── geschichte/ # Geschichte (story) domain: editor + card
|
│ ├── geschichte/ # Geschichte (story) domain: editor + card
|
||||||
│ ├── notification/ # Notification bell + dropdown + store
|
│ ├── notification/ # Notification bell + dropdown + store
|
||||||
│ ├── activity/ # Activity feed (Chronik) components
|
│ ├── activity/ # Activity feed (Chronik) components
|
||||||
@@ -61,8 +59,8 @@ src/
|
|||||||
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
|
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
|
||||||
│ │ ├── server/ # Server-only utilities (locale, session)
|
│ │ ├── server/ # Server-only utilities (locale, session)
|
||||||
│ │ ├── services/ # Client-side service helpers
|
│ │ ├── services/ # Client-side service helpers
|
||||||
│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
|
│ │ ├── utils/ # Pure utility functions (date, search, etc.)
|
||||||
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
|
│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.)
|
||||||
│ │ ├── dashboard/ # Dashboard stat components
|
│ │ ├── dashboard/ # Dashboard stat components
|
||||||
│ │ ├── discussion/ # CommentThread + shared discussion UI
|
│ │ ├── discussion/ # CommentThread + shared discussion UI
|
||||||
│ │ ├── help/ # Help/FAQ page components
|
│ │ ├── help/ # Help/FAQ page components
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => {
|
|||||||
|
|
||||||
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
|
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
|
||||||
await page.locator('#documentDate').fill('15.01.1928');
|
await page.locator('#documentDate').fill('15.01.1928');
|
||||||
await page.locator('#documentDatePrecision').selectOption('YEAR');
|
await page.locator('#metaDatePrecision').selectOption('YEAR');
|
||||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
|
|
||||||
// 4. The detail page shows the regenerated title carrying the new year.
|
// 4. The detail page shows the regenerated title carrying the new year.
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Curator timeline event editor (#781) — intentionally thin. The component +
|
|
||||||
* server specs carry the real regression coverage (they run in CI's "Unit &
|
|
||||||
* Component Tests" job); ci.yml does NOT invoke test:e2e today, so this file
|
|
||||||
* runs only locally/manually against the full Docker Compose stack.
|
|
||||||
*
|
|
||||||
* Three checks: one critical create journey (→ HTTP 200 on /zeitstrahl; the full
|
|
||||||
* "sees the event card" assertion depends on #7), one security counterpart
|
|
||||||
* (logged-out → 403), and one 320px no-overflow guarantee for the 60+ author
|
|
||||||
* audience.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
test.describe('Curator creates a timeline event', () => {
|
|
||||||
test('fills the create form with precision RANGE and lands on /zeitstrahl (HTTP 200)', async ({
|
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await page.goto('/zeitstrahl/events/new');
|
|
||||||
|
|
||||||
await page.getByLabel(/Titel/i).fill(`E2E Ereignis ${stamp()}`);
|
|
||||||
await page.getByRole('radio', { name: /Historisch/i }).click();
|
|
||||||
|
|
||||||
// Date + RANGE end date via the shared German dd.mm.yyyy inputs.
|
|
||||||
await page.locator('#eventDate').fill('01.04.1925');
|
|
||||||
await page.locator('#eventDatePrecision').selectOption('RANGE');
|
|
||||||
await expect(page.getByLabel('Enddatum')).toBeVisible();
|
|
||||||
await page.locator('#eventDateEnd').fill('01.05.1925');
|
|
||||||
|
|
||||||
// Submitting redirects to the resolved nav target (/zeitstrahl) — assert the
|
|
||||||
// route responds 200, not a DOM card (card rendering is #7's concern).
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForURL(/\/zeitstrahl$/),
|
|
||||||
page.getByRole('button', { name: 'Speichern' }).click()
|
|
||||||
]);
|
|
||||||
const response = await page.goto('/zeitstrahl');
|
|
||||||
expect(response?.status()).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Logged-out user is blocked from the curator route', () => {
|
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
|
||||||
|
|
||||||
test('navigating to /zeitstrahl/events/new is blocked with 403', async ({ page }) => {
|
|
||||||
await page.goto('/zeitstrahl/events/new');
|
|
||||||
// The load guard throws 403 before any form renders.
|
|
||||||
await expect(page.getByLabel(/Titel/i)).not.toBeVisible({ timeout: 5000 });
|
|
||||||
await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Responsive — 60+ author audience', () => {
|
|
||||||
test('no horizontal overflow on the create form at 320px', async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 320, height: 900 });
|
|
||||||
await page.goto('/zeitstrahl/events/new');
|
|
||||||
await expect(page.getByLabel(/Titel/i)).toBeVisible();
|
|
||||||
|
|
||||||
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
|
||||||
expect(scrollWidth).toBe(320);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global /zeitstrahl timeline (#779). Runs against the real stack with the
|
|
||||||
* seeded admin session (auth.setup). Covers the primary journey (nav → page,
|
|
||||||
* timeline inside <main>) and the 320px no-overflow guarantee on a populated
|
|
||||||
* timeline seeded with 25+char correspondent names (REQ-005).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
|
|
||||||
const res = await request.post('/api/persons', {
|
|
||||||
data: { personType: 'PERSON', firstName, lastName }
|
|
||||||
});
|
|
||||||
if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
|
|
||||||
return (await res.json()).id as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seeds one dated letter with long sender/receiver names so it lands on the timeline. */
|
|
||||||
async function seedDatedLetter(request: APIRequestContext) {
|
|
||||||
const senderId = await createPerson(
|
|
||||||
request,
|
|
||||||
'Friedrich-Wilhelm',
|
|
||||||
`Maximilian von Habsburg ${stamp()}`
|
|
||||||
);
|
|
||||||
const receiverId = await createPerson(
|
|
||||||
request,
|
|
||||||
'Maria-Magdalena',
|
|
||||||
`Hohenzollern-Sigmaringen ${stamp()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const createRes = await request.post('/api/documents', {
|
|
||||||
multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` }
|
|
||||||
});
|
|
||||||
if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
|
|
||||||
const docId = (await createRes.json()).id as string;
|
|
||||||
|
|
||||||
const put = await request.put(`/api/documents/${docId}`, {
|
|
||||||
multipart: {
|
|
||||||
title: `E2E Zeitstrahl Brief ${stamp()}`,
|
|
||||||
documentDate: '1915-06-15',
|
|
||||||
metaDatePrecision: 'DAY',
|
|
||||||
senderId,
|
|
||||||
receiverIds: receiverId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Zeitstrahl — global timeline (#779)', () => {
|
|
||||||
test('nav link opens /zeitstrahl and the timeline lives in <main>', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click();
|
|
||||||
await expect(page).toHaveURL(/\/zeitstrahl$/);
|
|
||||||
await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible();
|
|
||||||
|
|
||||||
// The main landmark contains either the populated <ol> or the empty state.
|
|
||||||
const main = page.getByRole('main');
|
|
||||||
const ol = main.locator('ol');
|
|
||||||
const empty = main.getByText('Noch keine Ereignisse.');
|
|
||||||
await expect(async () => {
|
|
||||||
const populated = (await ol.count()) > 0;
|
|
||||||
const isEmpty = await empty.isVisible().catch(() => false);
|
|
||||||
expect(populated || isEmpty).toBe(true);
|
|
||||||
}).toPass();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({
|
|
||||||
page,
|
|
||||||
request
|
|
||||||
}) => {
|
|
||||||
await seedDatedLetter(request);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 320, height: 900 });
|
|
||||||
await page.goto('/zeitstrahl');
|
|
||||||
|
|
||||||
// Populated: the seeded letter puts the timeline <ol> in the DOM.
|
|
||||||
await expect(page.getByRole('main').locator('ol')).toHaveCount(1);
|
|
||||||
|
|
||||||
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
|
||||||
expect(scrollWidth).toBe(320);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -158,7 +158,6 @@ export default defineConfig(
|
|||||||
{ type: 'ocr', pattern: 'src/lib/ocr/**' },
|
{ type: 'ocr', pattern: 'src/lib/ocr/**' },
|
||||||
{ type: 'activity', pattern: 'src/lib/activity/**' },
|
{ type: 'activity', pattern: 'src/lib/activity/**' },
|
||||||
{ type: 'conversation', pattern: 'src/lib/conversation/**' },
|
{ type: 'conversation', pattern: 'src/lib/conversation/**' },
|
||||||
{ type: 'timeline', pattern: 'src/lib/timeline/**' },
|
|
||||||
{ type: 'shared', pattern: 'src/lib/shared/**' },
|
{ type: 'shared', pattern: 'src/lib/shared/**' },
|
||||||
{ type: 'routes', pattern: 'src/routes/**' }
|
{ type: 'routes', pattern: 'src/routes/**' }
|
||||||
]
|
]
|
||||||
@@ -199,12 +198,6 @@ export default defineConfig(
|
|||||||
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
||||||
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
|
||||||
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
|
||||||
// Timeline curator event editor selects persons and documents by
|
|
||||||
// design (mirrors the geschichte editor) — #781.
|
|
||||||
{
|
|
||||||
from: { type: 'timeline' },
|
|
||||||
allow: { to: { type: ['shared', 'person', 'document'] } }
|
|
||||||
},
|
|
||||||
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
|
||||||
{
|
{
|
||||||
from: { type: 'routes' },
|
from: { type: 'routes' },
|
||||||
@@ -220,7 +213,6 @@ export default defineConfig(
|
|||||||
'ocr',
|
'ocr',
|
||||||
'activity',
|
'activity',
|
||||||
'conversation',
|
'conversation',
|
||||||
'timeline',
|
|
||||||
'shared'
|
'shared'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -651,7 +651,6 @@
|
|||||||
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
||||||
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
||||||
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
||||||
"error_invalid_relationship_dates": "Das Ende-Datum darf nicht vor dem Beginn-Datum liegen.",
|
|
||||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
@@ -1033,56 +1032,6 @@
|
|||||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||||
"nav_stammbaum": "Stammbaum",
|
"nav_stammbaum": "Stammbaum",
|
||||||
"nav_geschichten": "Geschichten",
|
"nav_geschichten": "Geschichten",
|
||||||
"nav_zeitstrahl": "Zeitstrahl",
|
|
||||||
"timeline_heading": "Zeitstrahl",
|
|
||||||
"timeline_empty_state": "Noch keine Ereignisse.",
|
|
||||||
"timeline_undated_section": "Ohne Datum",
|
|
||||||
"timeline_unknown_person": "Unbekannt",
|
|
||||||
"timeline_gap_empty": "keine Einträge",
|
|
||||||
"timeline_letters_count": "{count} Briefe",
|
|
||||||
"timeline_strip_expand": "Briefe anzeigen",
|
|
||||||
"timeline_range_aria": "Zeitraum: {from} bis {to}",
|
|
||||||
"timeline_layer_world": "Weltgeschehen",
|
|
||||||
"timeline_layer_family": "Familie",
|
|
||||||
"timeline_derived_birth": "Geburt",
|
|
||||||
"timeline_derived_death": "Tod",
|
|
||||||
"timeline_derived_marriage": "Heirat",
|
|
||||||
"timeline_grouping_date": "Gruppierung: Datum",
|
|
||||||
"timeline_provenance_derived": "abgeleitet",
|
|
||||||
"timeline_provenance_curated": "kuratiert",
|
|
||||||
"timeline_letter_glyph_label": "Brief",
|
|
||||||
"timeline_tag_chip_label": "Thema",
|
|
||||||
"timeline_layer_historical_suffix": "historisch",
|
|
||||||
"timeline_strip_density_caption": "Monats-Dichte",
|
|
||||||
"timeline_events_count": "{count} Ereignisse",
|
|
||||||
"timeline_letters_count_singular": "1 Brief",
|
|
||||||
"timeline_events_count_singular": "1 Ereignis",
|
|
||||||
"event_editor_new_title": "Neues Ereignis",
|
|
||||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
|
||||||
"event_editor_section_when": "Wann",
|
|
||||||
"event_editor_section_persons": "Beteiligte Personen",
|
|
||||||
"event_editor_section_documents": "Verknüpfte Briefe",
|
|
||||||
"event_editor_section_description": "Beschreibung",
|
|
||||||
"event_editor_title_label": "Titel",
|
|
||||||
"event_editor_title_placeholder": "Titel des Ereignisses",
|
|
||||||
"event_editor_title_required": "Bitte einen Titel eingeben.",
|
|
||||||
"event_editor_date_required": "Bitte ein Datum eingeben.",
|
|
||||||
"event_editor_end_date_required": "Bitte ein Enddatum eingeben.",
|
|
||||||
"event_editor_type_label": "Typ",
|
|
||||||
"event_editor_persons_label": "Personen",
|
|
||||||
"event_editor_documents_label": "Briefe",
|
|
||||||
"event_editor_description_label": "Beschreibung",
|
|
||||||
"event_editor_description_placeholder": "Optionale Beschreibung",
|
|
||||||
"event_editor_persons_empty": "Noch keine Person verknüpft",
|
|
||||||
"event_editor_documents_empty": "Noch kein Dokument verknüpft",
|
|
||||||
"event_type_PERSONAL": "Persönlich",
|
|
||||||
"event_type_HISTORICAL": "Historisch",
|
|
||||||
"event_editor_save": "Speichern",
|
|
||||||
"event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.",
|
|
||||||
"event_editor_delete": "Löschen",
|
|
||||||
"event_editor_delete_confirm_title": "Ereignis löschen?",
|
|
||||||
"event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.",
|
|
||||||
"event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
|
||||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||||
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||||
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||||
@@ -1222,16 +1171,6 @@
|
|||||||
"relation_form_field_from_year": "Von Jahr",
|
"relation_form_field_from_year": "Von Jahr",
|
||||||
"relation_form_field_to_year": "Bis Jahr",
|
"relation_form_field_to_year": "Bis Jahr",
|
||||||
"relation_form_year_placeholder": "z.B. 1920",
|
"relation_form_year_placeholder": "z.B. 1920",
|
||||||
"relation_label_from_date": "Beginn (Datum)",
|
|
||||||
"relation_label_to_date": "Ende (Datum)",
|
|
||||||
"relation_label_date_precision": "Genauigkeit",
|
|
||||||
"relation_precision_day": "Genaues Datum (Tag)",
|
|
||||||
"relation_precision_month": "Monat bekannt",
|
|
||||||
"relation_precision_year": "Nur Jahreszahl",
|
|
||||||
"relation_label_notes": "Notizen",
|
|
||||||
"relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung",
|
|
||||||
"relation_date_placeholder_hint": "Leer lassen, wenn unbekannt",
|
|
||||||
"relation_edit": "Beziehung bearbeiten",
|
|
||||||
"person_relationships_heading": "Beziehungen",
|
"person_relationships_heading": "Beziehungen",
|
||||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||||
@@ -1298,10 +1237,6 @@
|
|||||||
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
||||||
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
|
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
|
||||||
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
|
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
|
||||||
"error_timeline_event_not_found": "Zeitleistenereignis nicht gefunden.",
|
|
||||||
"error_timeline_event_conflict": "Dieses Ereignis wurde zwischenzeitlich geändert. Bitte neu laden.",
|
|
||||||
"error_timeline_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
|
|
||||||
"error_conflict": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden.",
|
|
||||||
"person_unknown": "[Unbekannt]",
|
"person_unknown": "[Unbekannt]",
|
||||||
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
||||||
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
||||||
|
|||||||
@@ -651,7 +651,6 @@
|
|||||||
"error_invalid_date_range": "The end date must not be before the start date.",
|
"error_invalid_date_range": "The end date must not be before the start date.",
|
||||||
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
||||||
"error_invalid_date_precision": "Date and precision do not match.",
|
"error_invalid_date_precision": "Date and precision do not match.",
|
||||||
"error_invalid_relationship_dates": "The end date must not be before the start date.",
|
|
||||||
"validation_last_name_required": "Last name is required.",
|
"validation_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
@@ -1033,56 +1032,6 @@
|
|||||||
"bulk_edit_count_pill": "{count} will be edited",
|
"bulk_edit_count_pill": "{count} will be edited",
|
||||||
"nav_stammbaum": "Family tree",
|
"nav_stammbaum": "Family tree",
|
||||||
"nav_geschichten": "Stories",
|
"nav_geschichten": "Stories",
|
||||||
"nav_zeitstrahl": "Timeline",
|
|
||||||
"timeline_heading": "Timeline",
|
|
||||||
"timeline_empty_state": "No events yet.",
|
|
||||||
"timeline_undated_section": "Without Date",
|
|
||||||
"timeline_unknown_person": "Unknown",
|
|
||||||
"timeline_gap_empty": "no entries",
|
|
||||||
"timeline_letters_count": "{count} letters",
|
|
||||||
"timeline_strip_expand": "Show letters",
|
|
||||||
"timeline_range_aria": "Period: {from} to {to}",
|
|
||||||
"timeline_layer_world": "World events",
|
|
||||||
"timeline_layer_family": "Family",
|
|
||||||
"timeline_derived_birth": "Birth",
|
|
||||||
"timeline_derived_death": "Death",
|
|
||||||
"timeline_derived_marriage": "Marriage",
|
|
||||||
"timeline_grouping_date": "Grouping: Date",
|
|
||||||
"timeline_provenance_derived": "derived",
|
|
||||||
"timeline_provenance_curated": "curated",
|
|
||||||
"timeline_letter_glyph_label": "Letter",
|
|
||||||
"timeline_tag_chip_label": "Topic",
|
|
||||||
"timeline_layer_historical_suffix": "historical",
|
|
||||||
"timeline_strip_density_caption": "Monthly density",
|
|
||||||
"timeline_events_count": "{count} events",
|
|
||||||
"timeline_letters_count_singular": "1 letter",
|
|
||||||
"timeline_events_count_singular": "1 event",
|
|
||||||
"event_editor_new_title": "New event",
|
|
||||||
"event_editor_edit_title": "Edit event",
|
|
||||||
"event_editor_section_when": "When",
|
|
||||||
"event_editor_section_persons": "People involved",
|
|
||||||
"event_editor_section_documents": "Linked letters",
|
|
||||||
"event_editor_section_description": "Description",
|
|
||||||
"event_editor_title_label": "Title",
|
|
||||||
"event_editor_title_placeholder": "Event title",
|
|
||||||
"event_editor_title_required": "Please enter a title.",
|
|
||||||
"event_editor_date_required": "Please enter a date.",
|
|
||||||
"event_editor_end_date_required": "Please enter an end date.",
|
|
||||||
"event_editor_type_label": "Type",
|
|
||||||
"event_editor_persons_label": "People",
|
|
||||||
"event_editor_documents_label": "Letters",
|
|
||||||
"event_editor_description_label": "Description",
|
|
||||||
"event_editor_description_placeholder": "Optional description",
|
|
||||||
"event_editor_persons_empty": "No person linked yet",
|
|
||||||
"event_editor_documents_empty": "No document linked yet",
|
|
||||||
"event_type_PERSONAL": "Personal",
|
|
||||||
"event_type_HISTORICAL": "Historical",
|
|
||||||
"event_editor_save": "Save",
|
|
||||||
"event_editor_save_hint": "Events appear on the timeline.",
|
|
||||||
"event_editor_delete": "Delete",
|
|
||||||
"event_editor_delete_confirm_title": "Delete event?",
|
|
||||||
"event_editor_delete_confirm_body": "This event will be permanently removed.",
|
|
||||||
"event_editor_unsaved_changes": "You have unsaved changes — really leave?",
|
|
||||||
"error_geschichte_not_found": "The story was not found.",
|
"error_geschichte_not_found": "The story was not found.",
|
||||||
"error_journey_item_not_found": "The journey item was not found.",
|
"error_journey_item_not_found": "The journey item was not found.",
|
||||||
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
||||||
@@ -1222,16 +1171,6 @@
|
|||||||
"relation_form_field_from_year": "From year",
|
"relation_form_field_from_year": "From year",
|
||||||
"relation_form_field_to_year": "To year",
|
"relation_form_field_to_year": "To year",
|
||||||
"relation_form_year_placeholder": "e.g. 1920",
|
"relation_form_year_placeholder": "e.g. 1920",
|
||||||
"relation_label_from_date": "Start date",
|
|
||||||
"relation_label_to_date": "End date",
|
|
||||||
"relation_label_date_precision": "Precision",
|
|
||||||
"relation_precision_day": "Exact date (day)",
|
|
||||||
"relation_precision_month": "Month known",
|
|
||||||
"relation_precision_year": "Year only",
|
|
||||||
"relation_label_notes": "Notes",
|
|
||||||
"relation_notes_placeholder": "Optional note about this relationship",
|
|
||||||
"relation_date_placeholder_hint": "Leave empty if unknown",
|
|
||||||
"relation_edit": "Edit relationship",
|
|
||||||
"person_relationships_heading": "Relationships",
|
"person_relationships_heading": "Relationships",
|
||||||
"person_relationships_empty": "No relationships known yet.",
|
"person_relationships_empty": "No relationships known yet.",
|
||||||
"timeline_aria_label": "Document density timeline",
|
"timeline_aria_label": "Document density timeline",
|
||||||
@@ -1298,10 +1237,6 @@
|
|||||||
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
||||||
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
|
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
|
||||||
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
|
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
|
||||||
"error_timeline_event_not_found": "Timeline event not found.",
|
|
||||||
"error_timeline_event_conflict": "This event was changed in the meantime. Please reload.",
|
|
||||||
"error_timeline_title_too_long": "The title is too long (maximum 255 characters).",
|
|
||||||
"error_conflict": "The record was changed in the meantime. Please reload.",
|
|
||||||
"person_unknown": "[Unknown]",
|
"person_unknown": "[Unknown]",
|
||||||
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
||||||
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
||||||
|
|||||||
@@ -651,7 +651,6 @@
|
|||||||
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
||||||
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
||||||
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
||||||
"error_invalid_relationship_dates": "La fecha de fin no puede ser anterior a la de inicio.",
|
|
||||||
"validation_last_name_required": "El apellido es obligatorio.",
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
@@ -1033,56 +1032,6 @@
|
|||||||
"bulk_edit_count_pill": "Se editarán {count}",
|
"bulk_edit_count_pill": "Se editarán {count}",
|
||||||
"nav_stammbaum": "Árbol genealógico",
|
"nav_stammbaum": "Árbol genealógico",
|
||||||
"nav_geschichten": "Historias",
|
"nav_geschichten": "Historias",
|
||||||
"nav_zeitstrahl": "Línea de tiempo",
|
|
||||||
"timeline_heading": "Línea de tiempo",
|
|
||||||
"timeline_empty_state": "Aún no hay eventos.",
|
|
||||||
"timeline_undated_section": "Sin Fecha",
|
|
||||||
"timeline_unknown_person": "Desconocido",
|
|
||||||
"timeline_gap_empty": "sin entradas",
|
|
||||||
"timeline_letters_count": "{count} cartas",
|
|
||||||
"timeline_strip_expand": "Mostrar cartas",
|
|
||||||
"timeline_range_aria": "Período: {from} a {to}",
|
|
||||||
"timeline_layer_world": "Acontecimientos mundiales",
|
|
||||||
"timeline_layer_family": "Familia",
|
|
||||||
"timeline_derived_birth": "Nacimiento",
|
|
||||||
"timeline_derived_death": "Fallecimiento",
|
|
||||||
"timeline_derived_marriage": "Matrimonio",
|
|
||||||
"timeline_grouping_date": "Agrupación: Fecha",
|
|
||||||
"timeline_provenance_derived": "derivado",
|
|
||||||
"timeline_provenance_curated": "curado",
|
|
||||||
"timeline_letter_glyph_label": "Carta",
|
|
||||||
"timeline_tag_chip_label": "Tema",
|
|
||||||
"timeline_layer_historical_suffix": "histórico",
|
|
||||||
"timeline_strip_density_caption": "Densidad mensual",
|
|
||||||
"timeline_events_count": "{count} eventos",
|
|
||||||
"timeline_letters_count_singular": "1 carta",
|
|
||||||
"timeline_events_count_singular": "1 evento",
|
|
||||||
"event_editor_new_title": "Nuevo evento",
|
|
||||||
"event_editor_edit_title": "Editar evento",
|
|
||||||
"event_editor_section_when": "Cuándo",
|
|
||||||
"event_editor_section_persons": "Personas involucradas",
|
|
||||||
"event_editor_section_documents": "Cartas vinculadas",
|
|
||||||
"event_editor_section_description": "Descripción",
|
|
||||||
"event_editor_title_label": "Título",
|
|
||||||
"event_editor_title_placeholder": "Título del evento",
|
|
||||||
"event_editor_title_required": "Por favor, introduzca un título.",
|
|
||||||
"event_editor_date_required": "Por favor, introduzca una fecha.",
|
|
||||||
"event_editor_end_date_required": "Por favor, introduzca una fecha de fin.",
|
|
||||||
"event_editor_type_label": "Tipo",
|
|
||||||
"event_editor_persons_label": "Personas",
|
|
||||||
"event_editor_documents_label": "Cartas",
|
|
||||||
"event_editor_description_label": "Descripción",
|
|
||||||
"event_editor_description_placeholder": "Descripción opcional",
|
|
||||||
"event_editor_persons_empty": "Aún no hay ninguna persona vinculada",
|
|
||||||
"event_editor_documents_empty": "Aún no hay ningún documento vinculado",
|
|
||||||
"event_type_PERSONAL": "Personal",
|
|
||||||
"event_type_HISTORICAL": "Histórico",
|
|
||||||
"event_editor_save": "Guardar",
|
|
||||||
"event_editor_save_hint": "Los eventos aparecen en la cronología.",
|
|
||||||
"event_editor_delete": "Eliminar",
|
|
||||||
"event_editor_delete_confirm_title": "¿Eliminar evento?",
|
|
||||||
"event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.",
|
|
||||||
"event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?",
|
|
||||||
"error_geschichte_not_found": "No se encontró la historia.",
|
"error_geschichte_not_found": "No se encontró la historia.",
|
||||||
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
||||||
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
||||||
@@ -1222,16 +1171,6 @@
|
|||||||
"relation_form_field_from_year": "Desde año",
|
"relation_form_field_from_year": "Desde año",
|
||||||
"relation_form_field_to_year": "Hasta año",
|
"relation_form_field_to_year": "Hasta año",
|
||||||
"relation_form_year_placeholder": "ej. 1920",
|
"relation_form_year_placeholder": "ej. 1920",
|
||||||
"relation_label_from_date": "Fecha de inicio",
|
|
||||||
"relation_label_to_date": "Fecha de fin",
|
|
||||||
"relation_label_date_precision": "Precisión",
|
|
||||||
"relation_precision_day": "Fecha exacta (día)",
|
|
||||||
"relation_precision_month": "Mes conocido",
|
|
||||||
"relation_precision_year": "Solo año",
|
|
||||||
"relation_label_notes": "Notas",
|
|
||||||
"relation_notes_placeholder": "Nota opcional sobre esta relación",
|
|
||||||
"relation_date_placeholder_hint": "Dejar vacío si es desconocido",
|
|
||||||
"relation_edit": "Editar relación",
|
|
||||||
"person_relationships_heading": "Relaciones",
|
"person_relationships_heading": "Relaciones",
|
||||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||||
@@ -1298,10 +1237,6 @@
|
|||||||
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
||||||
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
|
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
|
||||||
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
|
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
|
||||||
"error_timeline_event_not_found": "Evento de la línea de tiempo no encontrado.",
|
|
||||||
"error_timeline_event_conflict": "Este evento se modificó mientras tanto. Vuelve a cargar.",
|
|
||||||
"error_timeline_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
|
|
||||||
"error_conflict": "El registro se modificó mientras tanto. Vuelve a cargar.",
|
|
||||||
"person_unknown": "[Desconocido]",
|
"person_unknown": "[Desconocido]",
|
||||||
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
||||||
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user