Compare commits
162 Commits
3a7c86fc87
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab50d124a | ||
|
|
42fde1c17d | ||
| fa510f3991 | |||
|
|
3ba5ae982b | ||
|
|
82979be705 | ||
|
|
a63b8115a1 | ||
|
|
ace9602f6e | ||
|
|
9716319aad | ||
|
|
afee9df8c0 | ||
| 49d8ab78b4 | |||
|
|
109202246e | ||
| 273a97046a | |||
|
|
f57e59b53c | ||
|
|
07771a7b34 | ||
|
|
4d4266ba99 | ||
|
|
446611e3cc | ||
|
|
9118a10e4b | ||
|
|
11bcaf7cdb | ||
|
|
cd238285ae | ||
|
|
ec0e4dfa45 | ||
|
|
d134990343 | ||
|
|
21b1b3b835 | ||
|
|
33aff36867 | ||
|
|
e18282318a | ||
|
|
c6fe61f06b | ||
|
|
182d014971 | ||
|
|
dc9d1d52b3 | ||
| 8558567688 | |||
|
|
6dae4fe428 | ||
|
|
1cd6ffd5ca | ||
|
|
095eeeb4d4 | ||
|
|
cf6a262a7a | ||
|
|
4859c77964 | ||
|
|
bbf2f96e28 | ||
|
|
8376a520c5 | ||
|
|
c19d4be3fe | ||
|
|
90e2b4d6c2 | ||
|
|
d33c1e5249 | ||
|
|
1114676ae3 | ||
|
|
0be0a524b3 | ||
|
|
239565ea20 | ||
|
|
0a235dc911 | ||
|
|
0bd6790b1f | ||
|
|
84938e1bf3 | ||
|
|
398babe584 | ||
|
|
9665c9c0fc | ||
|
|
f715f9ce9c | ||
|
|
15836ea9ca | ||
|
|
8029bdec92 | ||
|
|
217508ddb2 | ||
|
|
0a4f5c0a9d | ||
|
|
2ac4aa8f9c | ||
|
|
bfe66569d7 | ||
|
|
18934413bb | ||
|
|
e4da28d795 | ||
|
|
a1e57ff8cf | ||
|
|
e0b096f12c | ||
|
|
6382efa65a | ||
|
|
144719720f | ||
|
|
fc67dfc3d5 | ||
|
|
08d8896cd1 | ||
|
|
808d6efa1a | ||
|
|
b372b90ec9 | ||
|
|
bc02d22270 | ||
|
|
23f6bc284d | ||
|
|
9f2ae7bd2e | ||
|
|
4d5fa7a26f | ||
|
|
5f2cf5f2c2 | ||
|
|
b8c8fcb1fb | ||
|
|
6150fc7be5 | ||
|
|
0862d43ba3 | ||
|
|
9cb856b376 | ||
|
|
d330510777 | ||
|
|
719274ef88 | ||
|
|
d48a89ba5c | ||
|
|
4dc5e3278f | ||
|
|
c13baa4785 | ||
|
|
cd5649b96e | ||
|
|
9f17c4538f | ||
|
|
068c2ef256 | ||
|
|
94d7d8099f | ||
|
|
a50bdfa7f4 | ||
|
|
be26a2e1b3 | ||
|
|
5cfb4608f6 | ||
|
|
59d78150b3 | ||
|
|
15ff6db1d3 | ||
|
|
54f9d8fdd5 | ||
|
|
423aedcd87 | ||
|
|
0ed7fb4c0e | ||
|
|
62fcc53f5c | ||
|
|
36f7bdad45 | ||
|
|
696a86799d | ||
|
|
d3f93c556a | ||
|
|
ce1b4c748e | ||
|
|
4a6fd770d7 | ||
|
|
732651959e | ||
|
|
7902f4e6ac | ||
|
|
fee519b8a9 | ||
|
|
b501592156 | ||
|
|
852fb71ee7 | ||
|
|
6f32299255 | ||
|
|
dbef0e1e60 | ||
|
|
588314f862 | ||
|
|
f9ddcf0374 | ||
|
|
5bff428954 | ||
|
|
bea0e0d056 | ||
|
|
e75448ba14 | ||
|
|
b031f2736b | ||
|
|
e25001f7c9 | ||
|
|
6a35e8510b | ||
|
|
607112afc2 | ||
|
|
4e119f098d | ||
|
|
f34d42a09f | ||
|
|
1dc3b91458 | ||
|
|
1348255ae3 | ||
|
|
590b00d2d7 | ||
|
|
1de314f49b | ||
|
|
5017d17b11 | ||
|
|
3a174dd91b | ||
|
|
afd1f0b86b | ||
|
|
f08b09faeb | ||
|
|
de30f66a2d | ||
|
|
184fc9814a | ||
|
|
6b593a7bc6 | ||
|
|
033001559d | ||
|
|
c66d83cfc6 | ||
|
|
7810ca7dd7 | ||
|
|
4245b821b9 | ||
|
|
663ffad49b | ||
|
|
b05990fffb | ||
|
|
fa8a734f96 | ||
|
|
6d81471294 | ||
|
|
956a23d0a8 | ||
|
|
f46f153f33 | ||
|
|
b32cc5be7e | ||
|
|
e93e5ec4d1 | ||
|
|
87b199a772 | ||
|
|
dc25b77a1c | ||
|
|
d50e239a2f | ||
|
|
c160ab3223 | ||
|
|
fa6677a7c5 | ||
|
|
a401e595d7 | ||
|
|
a904590843 | ||
|
|
fdc3e4ffa9 | ||
|
|
e186a3f646 | ||
|
|
210dde6562 | ||
|
|
3de4ff55ea | ||
|
|
96e04dbda9 | ||
|
|
bb0639b324 | ||
|
|
d7f8abd6c4 | ||
|
|
209f223b9f | ||
|
|
34146d7309 | ||
|
|
390ab30260 | ||
|
|
c51fc5e79f | ||
|
|
b7a5cd7b53 | ||
|
|
0eea19c0d4 | ||
|
|
262568f577 | ||
| 83ca2eb34d | |||
|
|
bde1237358 | ||
|
|
788a804810 | ||
|
|
62b96f718f | ||
|
|
6ed5151e50 |
99
.claude/skills/draft-spec/SKILL.md
Normal file
99
.claude/skills/draft-spec/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: draft-spec
|
||||
description: Requirements-engineer-led authoring of a new feature spec. Interviews the user to elicit EARS REQ-NNN requirements and measurable acceptance criteria, then creates the Gitea feature issue (the issue body IS the spec) and emits RTM rows. Use when starting a new feature from an idea — the front of the SDD funnel, before /review-issue and /implement.
|
||||
---
|
||||
|
||||
# Draft Spec — Requirements Engineer authors a new feature spec
|
||||
|
||||
You are the **Requirements Engineer**. Read your full persona from
|
||||
[`.claude/personas/req_engineer.md`](../../personas/req_engineer.md) and adopt its voice and
|
||||
priorities. Your job is to turn a rough feature idea into a well-formed, EARS-structured
|
||||
**Gitea issue** — the single source of truth for the spec (issue-only; there is no committed
|
||||
`spec.md`). You *author* the spec; you do **not** approve it — that's `/review-issue`'s job.
|
||||
|
||||
## Argument
|
||||
|
||||
A free-text feature idea, e.g. `users should be able to upload a profile picture`. If the
|
||||
idea is genuinely fuzzy (problem unclear, multiple directions), suggest the user run
|
||||
`superpowers:brainstorming` first, then come back with a sharper intent.
|
||||
|
||||
## Phase 0 — Load the SDD ground truth
|
||||
|
||||
Read before interviewing:
|
||||
- [`.specify/constitution.md`](../../../.specify/constitution.md) and [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — the rules the spec must respect
|
||||
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the section structure and the five EARS patterns
|
||||
- [`.specify/personas/requirements-engineer.md`](../../../.specify/personas/requirements-engineer.md) — **your own checklist; apply it as you write, not after**
|
||||
- [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
|
||||
- [`docs/GLOSSARY.md`](../../../docs/GLOSSARY.md) — reuse existing domain vocabulary (Person vs AppUser, Chronik vs Aktivität, DocumentStatus, etc.)
|
||||
|
||||
Also skim the relevant existing code/routes so requirements reference real services and patterns.
|
||||
|
||||
## Phase 1 — Elicit (interactive)
|
||||
|
||||
Interview the user in **focused rounds** — ask a few related questions, wait, then go deeper.
|
||||
Do not dump one giant questionnaire. Cover, in roughly this order:
|
||||
|
||||
1. **Why & who** — the business motivation and the role(s) involved. Drives the issue title
|
||||
`As a <role> I want <capability> so <reason>`.
|
||||
2. **User journey** — the plain-prose happy path, from the user's perspective. This bounds scope.
|
||||
3. **Happy-path behaviors** — what the system does on success. Each becomes a Ubiquitous,
|
||||
Event-driven, or State-driven requirement.
|
||||
4. **The unwanted paths — probe hard, this is where specs fail.** For every mutating action
|
||||
ask: what if the caller is unauthenticated? unauthorized? what input is invalid, and what's
|
||||
the limit (size, count, length)? what's the exact response (`ErrorCode` + HTTP status)?
|
||||
Each answer is an Unwanted-behavior (`If …`) requirement. (Checklist item #7 is your prompt bank.)
|
||||
5. **Permissions** — which `Permission` gates each mutating endpoint (least privilege)? Each
|
||||
gate is an Optional-feature (`Where …`) requirement.
|
||||
6. **Data model** — new tables/columns/constraints? the next free Flyway `V<n>` (you'll verify on disk)?
|
||||
7. **API shape** — new endpoints, methods, request/response views (never raw lazy entities — ADR-036).
|
||||
8. **Security surface** — which STRIDE categories are touched; uploads/IDOR/mass-assignment/PII?
|
||||
9. **Out of scope** — name the nearest tempting scope creep and exclude it.
|
||||
10. **Open questions** — anything you cannot decide; these block until resolved.
|
||||
|
||||
Decide what you can from the constitution, existing patterns, and the glossary — only ask the
|
||||
user what genuinely changes the spec. Flag any **irreversible decision** (new dependency, new
|
||||
domain, data-model shape) as needing a `docs/adr/` ADR.
|
||||
|
||||
## Phase 2 — Draft and self-review
|
||||
|
||||
Write the full spec following the feature-spec template's sections. Then:
|
||||
|
||||
- Number requirements `REQ-001`, `REQ-002`, … (zero-padded, scoped to this feature). Each uses
|
||||
exactly one EARS pattern. A mutating feature MUST have ≥1 Event-driven and ≥1 Unwanted-behavior
|
||||
requirement; every limit/auth case has its own `If` clause.
|
||||
- Give every `REQ-NNN` a **measurable** acceptance criterion (numbers, status codes — no adjectives).
|
||||
- Run your `requirements-engineer.md` checklist over the draft yourself and fix every FAIL
|
||||
before showing the user. (You're allowed to block your own draft.)
|
||||
- Present the full draft to the user. Refine until they confirm. **Do not create the issue
|
||||
until the user approves the draft text.**
|
||||
|
||||
## Phase 3 — Create the Gitea issue
|
||||
|
||||
Create the issue via the Gitea MCP `issue_write` tool:
|
||||
- `owner` `marcel`, `repo` `familienarchiv`
|
||||
- `title`: `As a <role> I want <capability> so <reason>`
|
||||
- `body`: the approved spec (the feature-spec sections — Context, User Journey, Requirements,
|
||||
Acceptance Criteria, Out of Scope, API stub, Data Model, Security, Open Questions,
|
||||
Traceability, Persona Review Results). Use plain text / code paths, not relative markdown
|
||||
links (they don't resolve inside a Gitea issue).
|
||||
- **Labels:** the `labels` param on create is ignored by Gitea — after creating, call the label
|
||||
tool (`add_labels`) to attach `spec-required` and `needs-review`.
|
||||
|
||||
## Phase 4 — Emit RTM rows + flag ADRs
|
||||
|
||||
- Emit ready-to-paste [`.specify/rtm.md`](../../../.specify/rtm.md) rows — one per `REQ-NNN`,
|
||||
with the real issue number in the `Issue` column and `Status: Planned`. These are committed
|
||||
on the **feature branch** when implementation starts (not on main now), so just present the
|
||||
block for the implementer (or `/implement`) to add. If you're already on the feature's
|
||||
worktree/branch, append them to `rtm.md` directly.
|
||||
- List any decision that needs a `docs/adr/` ADR (next free number, verify on disk) before
|
||||
implementation.
|
||||
|
||||
## Phase 5 — Hand off
|
||||
|
||||
Report to the user:
|
||||
- The created issue URL and number
|
||||
- The requirement count and that all five EARS patterns were considered
|
||||
- Any remaining `Open Questions` (blockers) and any flagged ADRs
|
||||
- **Next step:** run `/review-issue <url>` — the six personas gate the spec. You authored it;
|
||||
you don't self-approve. After it passes and Open Questions are empty, run `/implement <url>`.
|
||||
@@ -3,10 +3,17 @@ name: implement
|
||||
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
|
||||
---
|
||||
|
||||
# Implement — Felix Brandt's Issue/PR-Driven TDD Workflow
|
||||
# Implement — Felix Brandt's Spec-Driven TDD Workflow
|
||||
|
||||
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
|
||||
|
||||
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
|
||||
|
||||
The user provides a Gitea issue **or** pull request URL, e.g.:
|
||||
@@ -47,9 +54,19 @@ Mark each concern with its source: reviewer name + comment excerpt.
|
||||
|
||||
Also read:
|
||||
- `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
|
||||
- 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.
|
||||
|
||||
---
|
||||
@@ -58,10 +75,12 @@ Do not start Phase 2 until you have read everything.
|
||||
|
||||
### Issue mode
|
||||
|
||||
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
|
||||
- Scope questions (is X in or out of this issue?)
|
||||
- Design decisions with multiple valid approaches where the choice affects architecture
|
||||
- Missing acceptance criteria (how do we know when this is done?)
|
||||
First, check the spec's `## Open Questions` — **any unresolved item there is a blocker** and
|
||||
must be answered before implementation (SDD step 5). Then identify any further point that is
|
||||
genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
|
||||
- Scope questions (is X in or out? — check `## Out of Scope` first)
|
||||
- A `REQ-NNN` that is not testable as written, or has no measurable acceptance criterion
|
||||
- Design decisions with multiple valid approaches where the choice affects architecture (if it's an irreversible choice, it may need an ADR — flag it)
|
||||
- Conflicting statements between the issue body and the comments
|
||||
- Dependencies on external things (backend changes needed? migration required?)
|
||||
|
||||
@@ -81,12 +100,15 @@ Wait for the user to answer before continuing.
|
||||
|
||||
## Phase 3 — Implementation Plan
|
||||
|
||||
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be:
|
||||
Once clarifications are resolved, present a numbered implementation plan as a task list,
|
||||
**derived from the issue's `REQ-NNN` requirements** (one or more tasks per requirement, in
|
||||
red/green order). Each item must be:
|
||||
|
||||
- A single atomic unit of work (one behavior, one file change, one migration)
|
||||
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
|
||||
- Ordered so each item builds on the previous ones
|
||||
- Ordered so each item builds on the previous ones (red/green order — a failing test precedes its implementation)
|
||||
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
|
||||
- **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.:
|
||||
```
|
||||
@@ -97,10 +119,10 @@ Format:
|
||||
```
|
||||
## Implementation Plan
|
||||
|
||||
1. [backend] PersonController returns 404 when person id does not exist
|
||||
2. [migration] Add index on documents.sender_id for performance
|
||||
3. [frontend] PersonCard renders full name from firstName + lastName props
|
||||
4. [frontend] PersonCard shows placeholder when both names are null
|
||||
1. [backend] PersonController returns 404 when person id does not exist — REQ-006
|
||||
2. [migration] V<n> add index on documents.sender_id (verify next free number on disk) — REQ-002
|
||||
3. [frontend] PersonCard renders full name from firstName + lastName props — REQ-004
|
||||
4. [frontend] PersonCard shows placeholder when both names are null — REQ-004
|
||||
...
|
||||
```
|
||||
|
||||
@@ -145,12 +167,22 @@ Check the current branch.
|
||||
2. Apply any needed clean-up — no new behavior
|
||||
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 atomically after each task using the project's commit conventions:
|
||||
Commit atomically after each task using the project's commit conventions, referencing the
|
||||
issue (`Refs #n` / `Closes #n`) on the last line:
|
||||
```
|
||||
feat(scope): short imperative description
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
Refs #<n>
|
||||
Co-Authored-By: <model> <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Move to the next task immediately.
|
||||
@@ -164,8 +196,10 @@ Move to the next task immediately.
|
||||
|
||||
### 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 add behavior beyond what the current task requires
|
||||
- Never add behavior beyond what the current task requires — and never add behavior with no backing `REQ-NNN`. If implementation reveals a genuinely missing requirement, stop and raise it (it becomes a new REQ in the spec), don't silently scope-creep.
|
||||
- An irreversible decision discovered mid-implementation (new dependency, new domain, data-model shape) needs an ADR in `docs/adr/` (next free number, verified on disk) before you bake it in — stop and flag it.
|
||||
- Never bundle two tasks into one commit
|
||||
- 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
|
||||
@@ -178,10 +212,16 @@ After all tasks are done:
|
||||
|
||||
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
|
||||
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
|
||||
3. Post a completion comment on the Gitea issue summarising what was implemented, 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.)
|
||||
4. Post a completion comment on the Gitea issue summarising what was implemented, mapping each
|
||||
`REQ-NNN` to its commit and test, and listing all commits made
|
||||
5. Report back to the user: every task ✅, the REQ→test coverage, any skipped/deferred tasks
|
||||
(with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
|
||||
|
||||
### PR mode
|
||||
3. Push the updated branch
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
---
|
||||
name: review-issue
|
||||
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment.
|
||||
description: Multi-persona SDD spec review of a Gitea feature issue. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, walks it PASS/FAIL/QUESTION against the EARS requirements, and posts findings as a separate Gitea comment before implementation starts.
|
||||
---
|
||||
|
||||
# Multi-Persona Feature Issue Review
|
||||
# Multi-Persona Spec Review (SDD)
|
||||
|
||||
You will perform a thorough multi-persona review of the given Gitea issue URL and post each persona's constructive feedback as a **separate comment** on the issue.
|
||||
|
||||
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts.
|
||||
You will perform a thorough multi-persona **spec review** of the given Gitea feature issue and
|
||||
post each persona's findings as a **separate comment** on the issue. This is the SDD
|
||||
spec-review gate (step 4 of [SPEC_DRIVEN_DEVELOPMENT.md](../../../SPEC_DRIVEN_DEVELOPMENT.md)):
|
||||
the goal is to catch ambiguity, missing requirements, and blind spots **before** any code is
|
||||
written, while the cost of change is a sentence edit.
|
||||
|
||||
## Argument
|
||||
|
||||
@@ -19,57 +21,83 @@ Parse it to extract:
|
||||
- `repo` — e.g. `familienarchiv`
|
||||
- `issue_number` — e.g. `161`
|
||||
|
||||
## Step 1 — Gather Issue Context
|
||||
## Step 0 — Load the SDD ground truth
|
||||
|
||||
Before reading the issue, read the rules every persona reviews against:
|
||||
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules
|
||||
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, constraints, workflow
|
||||
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the expected spec shape and the five EARS patterns
|
||||
- The worked example [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
|
||||
|
||||
## Step 1 — Gather issue context
|
||||
|
||||
Use the Gitea MCP tools to collect:
|
||||
1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
|
||||
2. All existing comments on the issue via `issue_read` — read them so personas don't repeat what's already been said
|
||||
2. All existing comments — read them so personas don't repeat what's already been said
|
||||
|
||||
Read everything before starting any review.
|
||||
|
||||
## Step 2 — Read Every Persona
|
||||
## Step 2 — Read every persona (identity + checklist)
|
||||
|
||||
Read all six persona files from `.claude/personas/`:
|
||||
- `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
|
||||
Each persona is its **character identity** (`.claude/personas/`) **plus** its **SDD spec-review
|
||||
checklist** (`.specify/personas/`). Adopt the voice from the former; gate the spec with the latter.
|
||||
|
||||
## Step 3 — Write Each Review
|
||||
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) |
|
||||
|---|---|---|
|
||||
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` |
|
||||
| Developer (Felix Brandt) | `developer.md` | `developer.md` |
|
||||
| Security (Nora "NullX" Steiner) | `security_expert.md` | `security.md` |
|
||||
| DevOps | `devops.md` | `devops.md` |
|
||||
| UI/UX | `ui_expert.md` | `ui-ux.md` |
|
||||
| Architect | `architect.md` | `architect.md` |
|
||||
|
||||
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that:
|
||||
The tester lens (acceptance-criteria quality, edge cases) is carried by the Requirements
|
||||
Engineer checklist (testable, measurable criteria) — no separate tester comment at spec time.
|
||||
|
||||
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps
|
||||
- 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)
|
||||
## Step 3 — Run each checklist against the spec
|
||||
|
||||
Format each comment in Markdown with a persona header, e.g.:
|
||||
For each persona, walk **every item** in its `.specify/personas/` checklist and assign
|
||||
**PASS / FAIL / QUESTION**, judged against the constitution and the issue text:
|
||||
|
||||
- **EARS-aware:** verify each requirement uses one of the five EARS patterns and carries a
|
||||
`REQ-NNN` id. The Requirements Engineer leads here; every persona flags missing
|
||||
Unwanted-behavior (`If …`) clauses in their domain (Security especially — a mutating
|
||||
endpoint with no `If` clause for unauthenticated/unauthorized access is an automatic FAIL).
|
||||
- **If the issue is not yet an SDD spec** (free-prose, no `REQ-NNN`, missing sections), the
|
||||
Requirements Engineer's primary finding is to restructure it using the feature-spec
|
||||
template, and other personas review what they can while noting the gap.
|
||||
- Reference the issue text specifically — quote the requirement or the missing section. No
|
||||
generic advice.
|
||||
|
||||
## Step 4 — Write and post each comment
|
||||
|
||||
Each persona posts a **separate** comment via the Gitea MCP `issue_write` tool, in the format
|
||||
its checklist's "Output format" section defines — a header, the checklist table, and a verdict:
|
||||
|
||||
```
|
||||
## 👨💻 Felix Brandt — Senior Fullstack Developer
|
||||
### 🔐 Security — Spec Review
|
||||
|
||||
### Questions & Observations
|
||||
...
|
||||
| # | Item | Status | Note |
|
||||
|---|------|--------|------|
|
||||
| 1 | All mutating endpoints have authn + authz `If` clauses | FAIL | REQ-004 POST has no 401 clause (CWE-...) |
|
||||
| 2 | ... | PASS | |
|
||||
|
||||
### Suggestions
|
||||
...
|
||||
**Verdict: CHANGES REQUESTED** — blocking FAIL: #1. Resolve before implementation.
|
||||
```
|
||||
|
||||
Keep each comment focused and scannable. Use bullet points. Avoid walls of text.
|
||||
Post all six comments. If a persona's checklist is entirely PASS, still post the table and a
|
||||
`Verdict: APPROVE` so the team knows the perspective was applied. Keep comments scannable.
|
||||
|
||||
## Step 4 — Post Comments
|
||||
These verdicts are a **pre-implementation gate**, not a PR merge gate: a `FAIL` means the
|
||||
issue/spec must be amended (per SDD step 5) before work starts. Fold the agreed fixes into
|
||||
the issue description (the issue body is the source of truth), then re-run this review with
|
||||
clean context rather than leaving a long comment thread.
|
||||
|
||||
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool.
|
||||
|
||||
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
|
||||
## Step 5 — Report back
|
||||
|
||||
After all comments are posted, tell the user:
|
||||
- Which personas posted feedback
|
||||
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged)
|
||||
- Each persona's verdict (APPROVE / CHANGES REQUESTED)
|
||||
- The consolidated list of blocking FAILs (these must be resolved before implementation)
|
||||
- Cross-cutting themes multiple personas flagged
|
||||
- Whether the issue is a well-formed SDD spec yet, or needs restructuring first
|
||||
- A reminder to mirror the agreed `REQ-NNN` rows into [`.specify/rtm.md`](../../../.specify/rtm.md)
|
||||
|
||||
@@ -1,74 +1,95 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Multi-persona PR review. Each persona from .claude/personas/ reviews the PR and posts their findings as a separate Gitea comment.
|
||||
description: Multi-persona SDD code review of a Gitea PR. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, verifies the diff against the constitution and the feature spec's REQ-NNN (every requirement implemented and tested), and posts findings as a separate Gitea comment.
|
||||
---
|
||||
|
||||
# Multi-Persona PR Review
|
||||
# Multi-Persona PR Review (SDD)
|
||||
|
||||
You will perform a thorough multi-persona code review of the given PR URL and post each persona's findings as a **separate comment** on the PR.
|
||||
You will perform a thorough multi-persona code review of the given PR and post each persona's
|
||||
findings as a **separate comment**. Under SDD, the review verifies the diff against two
|
||||
contracts: the project [constitution](../../../.specify/constitution.md) and the feature's
|
||||
spec (the linked **Gitea issue body** — every `REQ-NNN` must be implemented **and** covered by a test).
|
||||
|
||||
## Argument
|
||||
|
||||
The user provides a Gitea PR URL, e.g.:
|
||||
`http://heim-nas:3005/marcel/familienarchiv/pulls/160`
|
||||
|
||||
Parse it to extract:
|
||||
- `owner` — e.g. `marcel`
|
||||
- `repo` — e.g. `familienarchiv`
|
||||
- `pull_number` — e.g. `160`
|
||||
Parse it to extract `owner`, `repo`, and `pull_number`.
|
||||
|
||||
## Step 1 — Gather PR Context
|
||||
## Step 0 — Load the SDD ground truth
|
||||
|
||||
Read before reviewing:
|
||||
- [`.specify/constitution.md`](../../../.specify/constitution.md) — rules the code must obey (esp. §4 Do-Not-Touch)
|
||||
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — constraints
|
||||
- The feature's spec — the **Gitea issue** the PR closes (`Closes #n`). Read its body for the
|
||||
`REQ-NNN` requirements, acceptance criteria, inline API stub, and any STRIDE/threat notes.
|
||||
- [`.specify/rtm.md`](../../../.specify/rtm.md) — the requirement→test→status matrix
|
||||
|
||||
## Step 1 — Gather PR context
|
||||
|
||||
Use the Gitea MCP tools to collect:
|
||||
1. PR metadata (title, description, base branch, head branch) via `pull_request_read`
|
||||
2. The list of changed files via `get_dir_contents` or the PR files endpoint
|
||||
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents`
|
||||
1. PR metadata (title, description, base/head branch) via `pull_request_read`
|
||||
2. The list of changed files
|
||||
3. The full content of every changed file at the head commit via `get_file_contents`
|
||||
|
||||
Read ALL changed files completely before starting any review. Do not skip files.
|
||||
Read ALL changed files completely before starting. Do not skip files.
|
||||
|
||||
## Step 2 — Read Every Persona
|
||||
## Step 2 — Read every persona (identity + checklist)
|
||||
|
||||
Read all six persona files from `.claude/personas/`:
|
||||
- `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
|
||||
Adopt each persona's voice from `.claude/personas/`; apply its review lens. For the SDD
|
||||
personas, also re-read the matching `.specify/personas/` checklist — at PR time the same
|
||||
checklist items are verified against the **code** rather than the spec.
|
||||
|
||||
## Step 3 — Write Each Review
|
||||
| Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) | PR-time focus |
|
||||
|---|---|---|---|
|
||||
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` | Traceability: every `REQ-NNN` implemented; RTM updated |
|
||||
| Developer (Felix Brandt) | `developer.md` | `developer.md` | Clean code, layering, generate:api run, ErrorCode four-site |
|
||||
| Tester | `tester.md` | — (uses identity) | Test quality: each REQ has a real failing-first test; edge cases; levels right |
|
||||
| Security (Nora "NullX") | `security_expert.md` | `security.md` | authn/authz, IDOR, mass-assignment, `{@html}`, secrets/PII |
|
||||
| DevOps | `devops.md` | `devops.md` | migration rollback, env vars, CI guards intact, artifact pin |
|
||||
| UI/UX | `ui_expert.md` | `ui-ux.md` | states, i18n, a11y, design tokens |
|
||||
| Architect | `architect.md` | `architect.md` | boundaries, ADR present for irreversible choices, no superseded-ADR violation |
|
||||
|
||||
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that:
|
||||
## Step 3 — Write each review
|
||||
|
||||
For each persona, write a review that:
|
||||
|
||||
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
|
||||
- Lists concrete findings with file paths and line references where relevant
|
||||
- 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)
|
||||
- Stays focused — only comment on what the persona would actually care about
|
||||
|
||||
Format each comment in Markdown with a persona header, e.g.:
|
||||
- Lists concrete findings with file paths and line references; cite the constitution rule
|
||||
(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)
|
||||
- **Requirements Engineer specifically** produces a traceability table — for each `REQ-NNN`:
|
||||
is it implemented? is there a test? is `rtm.md` updated to `Done`? Any unimplemented or
|
||||
untested REQ is a blocker. Any code behavior with no backing requirement is flagged
|
||||
(scope creep — should it be a new REQ, or removed?).
|
||||
- A constitution **Do-Not-Touch** violation (edited generated file, edited shipped migration,
|
||||
edited an Accepted ADR, bumped the artifact action past v3, weakened a CI guard) is always
|
||||
a blocker.
|
||||
|
||||
```
|
||||
## 👨💻 Felix Brandt — Senior Fullstack Developer
|
||||
### 🔐 Security — PR Review
|
||||
|
||||
**Verdict: ⚠️ Approved with concerns**
|
||||
|
||||
### Blockers
|
||||
...
|
||||
- `UserAvatarController.java:42` — REQ-009's 403 path has no test (constitution §2.8)
|
||||
|
||||
### Suggestions
|
||||
...
|
||||
- ...
|
||||
```
|
||||
|
||||
## Step 4 — Post Comments
|
||||
## Step 4 — Post comments
|
||||
|
||||
Post each persona's review as a **separate comment** on the PR using the Gitea MCP `issue_write` tool (issues and PRs share the comment API in Gitea).
|
||||
Post each persona's review as a **separate comment** via the Gitea MCP `issue_write` tool
|
||||
(issues and PRs share the comment API). Post all personas; if one has nothing to flag, post a
|
||||
brief "LGTM" naming what they checked.
|
||||
|
||||
Post all six comments. Do not skip any persona even if their domain has nothing to flag — in that case write a brief "LGTM" with a short explanation of what they checked.
|
||||
## Step 5 — Report back
|
||||
|
||||
## Step 5 — Report Back
|
||||
|
||||
After all comments are posted, summarize to the user:
|
||||
- Which personas posted comments
|
||||
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested")
|
||||
- A bullet list of the top blockers found (if any)
|
||||
Summarize to the user:
|
||||
- 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
|
||||
- **Traceability status:** which `REQ-NNN` are implemented+tested vs. missing, and whether
|
||||
`rtm.md` is in sync
|
||||
- Any constitution Do-Not-Touch violations (called out explicitly)
|
||||
|
||||
40
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
40
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: "Bug"
|
||||
about: "Something is broken. Describe user-facing impact, not the technical cause."
|
||||
title: "<What breaks> when <trigger>"
|
||||
labels:
|
||||
- bug
|
||||
assignees: []
|
||||
---
|
||||
|
||||
<!--
|
||||
Title format (COLLABORATING.md): "<What breaks> when <trigger>", e.g.
|
||||
"Upload fails silently when file exceeds 50MB". Keep it focused — a bug is small and direct.
|
||||
A failing test is written first, then the fix (red/green TDD).
|
||||
-->
|
||||
|
||||
## What happens
|
||||
|
||||
<The observed broken behavior, from the user's perspective.>
|
||||
|
||||
## Expected
|
||||
|
||||
<What should happen instead.>
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Originating requirement (if known)
|
||||
|
||||
<REQ-NNN + feature this regresses, from .specify/rtm.md — e.g. "REQ-008 (profile-picture-upload)". Helps target the failing test. Write "unknown" if not traceable.>
|
||||
|
||||
## Environment
|
||||
|
||||
<Browser / role / data state / deploy (local vs prod) as relevant.>
|
||||
|
||||
## Notes
|
||||
|
||||
<Logs, GlitchTip link, screenshots. Redact PII.>
|
||||
81
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
81
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: "Feature (SDD spec)"
|
||||
about: "Spec-driven feature request. Fill in EARS requirements before implementation starts."
|
||||
title: "As a <role> I want <capability> so <reason>"
|
||||
labels:
|
||||
- spec-required
|
||||
- needs-review
|
||||
assignees: []
|
||||
---
|
||||
|
||||
<!--
|
||||
This issue body IS the spec (issue-only — there is no committed spec.md). Every requirement
|
||||
uses an EARS pattern + a REQ-NNN id. Reference: .specify/templates/feature-spec.md and the
|
||||
worked example .specify/features/_example/. Delete the placeholder hints as you fill each section.
|
||||
-->
|
||||
|
||||
## Context & Why
|
||||
|
||||
<Who needs this and why now (2–4 sentences). Link the constitution principle(s) this depends on: .specify/constitution.md>
|
||||
|
||||
## User Journey
|
||||
|
||||
<Plain-prose steps the user takes to get value, from the user's perspective. Anything not here is out of scope.>
|
||||
|
||||
## Requirements
|
||||
|
||||
<!-- One per line, each REQ-NNN + one EARS pattern. A mutating feature needs at least one Event-driven and one Unwanted-behavior requirement. -->
|
||||
|
||||
- **REQ-001** (Ubiquitous) — The `<component>` shall `<always-true behavior>`.
|
||||
- **REQ-002** (Event-driven) — When `<trigger>`, the `<component>` shall `<response>`.
|
||||
- **REQ-003** (State-driven) — While `<state>`, the `<component>` shall `<behavior>`.
|
||||
- **REQ-004** (Optional-feature) — Where `<caller has Permission.X / flag set>`, the `<component>` shall `<behavior>`.
|
||||
- **REQ-005** (Unwanted-behavior) — If `<undesired condition>`, then the `<component>` shall `<safe response / ErrorCode>`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- One measurable criterion per REQ-NNN: numbers, limits, status codes — not adjectives. -->
|
||||
|
||||
- **REQ-001** — <measurable>.
|
||||
- **REQ-002** — <measurable>.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- <The nearest tempting scope creep, named and excluded.>
|
||||
|
||||
## API / Contract Stub
|
||||
|
||||
<Inline OpenAPI stub (use .specify/templates/api-contract-stub.md as a writing aid). Name new paths/methods/status codes and the @RequirePermission on each mutating endpoint.>
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
<Schema delta + next free Flyway V<n> (verify on disk) + rollback note. "none" if not applicable.>
|
||||
|
||||
## Security Considerations
|
||||
|
||||
<STRIDE categories touched (+ ASTRIDE if an AI agent/tool is involved). Link a threat-model.md if the attack surface is non-trivial.>
|
||||
|
||||
## Open Questions
|
||||
|
||||
<!-- Each item BLOCKS implementation until resolved. -->
|
||||
|
||||
- [ ] <question> — owner: <name>
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|
||||
|---|---|---|---|
|
||||
| REQ-001 | | | Planned |
|
||||
|
||||
<!-- Mirror these rows into .specify/rtm.md. -->
|
||||
|
||||
## Persona Review Results
|
||||
|
||||
| Persona | Status | Key Findings | Resolved |
|
||||
|---|---|---|---|
|
||||
| Requirements Engineer | PENDING | | |
|
||||
| Developer | PENDING | | |
|
||||
| Security | PENDING | | |
|
||||
| DevOps | PENDING | | |
|
||||
| UI/UX | PENDING | | |
|
||||
| Architect | PENDING | | |
|
||||
@@ -229,9 +229,14 @@ jobs:
|
||||
name: Backend Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
||||
# CI runs against the root-server Docker daemon (29.x). This API pin is a harmless
|
||||
# carry-over from the old NAS runner (Docker 24.x, max API 1.43); safe to drop later.
|
||||
DOCKER_API_VERSION: "1.43"
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
# Ryuk (Testcontainers' out-of-process reaper) is intentionally LEFT ENABLED so it
|
||||
# removes each run's containers after the JVM exits. Disabling it forced the in-JVM
|
||||
# reaper, which hung at JVM shutdown and leaked Postgres containers run-over-run until
|
||||
# the daemon degraded and the fork timed out at teardown — see #848.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -161,3 +161,177 @@ jobs:
|
||||
# without first re-evaluating ADR-011.
|
||||
if: always()
|
||||
run: rm -f .env.staging
|
||||
|
||||
npm-audit:
|
||||
# Independent parallel job — a deploy failure cannot mask the audit signal
|
||||
# and a clean audit cannot hide a broken deploy. Intentionally no `needs:`.
|
||||
#
|
||||
# Scans dev deps too (no --omit=dev), which is deliberately broader than the
|
||||
# PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader
|
||||
# result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild,
|
||||
# Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token
|
||||
# means a leak via logs/process-args cannot push
|
||||
# branches, open PRs, or read repo contents (ADR-041).
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Assert jq is available
|
||||
run: which jq || sudo apt-get install -y jq
|
||||
|
||||
- name: Run npm audit and file tracking issue on findings
|
||||
# Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs.
|
||||
env:
|
||||
NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }}
|
||||
run: |
|
||||
MARKER="Nightly npm audit: high-severity advisory"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
REPO="${{ github.repository }}"
|
||||
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
# --- Gitea API helper ---
|
||||
# api METHOD URL [extra curl args...] — authenticated Gitea API call.
|
||||
# `curl -sf` collapses every HTTP >=400 into a bare "exit 22", which
|
||||
# surfaces as an opaque step failure (issue #839). Instead we read the
|
||||
# status code and, on a >=400 response, print an actionable ::error::
|
||||
# to stderr (so a calling command substitution does not swallow it) and
|
||||
# return 1 — `set -e` then still fails the step. The token is never
|
||||
# echoed (no set -x; never placed in the message).
|
||||
api() {
|
||||
local method="$1" url="$2"; shift 2
|
||||
local resp http
|
||||
resp=$(curl -s -w '\n%{http_code}' -X "$method" \
|
||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" "$@" -- "$url")
|
||||
http=${resp##*$'\n'}
|
||||
printf '%s' "${resp%$'\n'*}"
|
||||
case "$http" in
|
||||
2*|3*) return 0 ;;
|
||||
401|403)
|
||||
echo "::error::Gitea returned HTTP $http for $method ${url%%\?*} — the NIGHTLY_AUDIT_TOKEN secret is missing, expired, or lacks issue read+write scope; recreate the renovate_bot PAT and update the secret." >&2
|
||||
return 1 ;;
|
||||
*)
|
||||
echo "::error::Gitea returned HTTP ${http:-(none)} for $method ${url%%\?*}." >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
||||
# Runs before any real API call so broken logic fails loudly early:
|
||||
# (a) the jq title matcher used by the dedupe step — proves the regex
|
||||
# only; the create-vs-update decision is exercised by the
|
||||
# workflow_dispatch AC;
|
||||
# (b) the api helper's HTTP-status handling, driven by a mocked curl so
|
||||
# it needs no network — proves a 2xx returns the body and a >=400
|
||||
# fails with an ::error:: instead of an opaque exit 22.
|
||||
echo "{\"title\": \"${MARKER}\"}" \
|
||||
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
||||
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
||||
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
||||
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
||||
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
||||
( curl() { printf 'OK\n200'; }; [ "$(api GET selftest)" = "OK" ] ) \
|
||||
|| { echo "FAIL: self-test — api helper dropped body on HTTP 200"; exit 1; }
|
||||
( curl() { printf 'nope\n401'; }
|
||||
if api GET selftest >/dev/null 2>/tmp/api_selftest_err; then exit 1; fi
|
||||
grep -q '::error::' /tmp/api_selftest_err ) \
|
||||
|| { echo "FAIL: self-test — api helper did not emit ::error:: on HTTP 401"; exit 1; }
|
||||
echo "Self-test passed."
|
||||
|
||||
# --- Run audit ---
|
||||
# No npm ci — audit reads only the lockfile (no network, no install).
|
||||
set +e
|
||||
(cd frontend && npm audit --audit-level=high --json > /tmp/audit.json)
|
||||
AUDIT_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$AUDIT_EXIT" -ne 0 ]; then
|
||||
# --- Build issue body with jq (never string-concat advisory text) ---
|
||||
# Advisory overview/title text is registry-controlled; string-concat
|
||||
# would be an injection/escaping vector into the API body. Truncate
|
||||
# raw excerpt to 500 chars so a pathological overview can't produce
|
||||
# a multi-MB PATCH body.
|
||||
ISSUE_BODY=$(jq -r \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'
|
||||
(.vulnerabilities // {}) as $vulns |
|
||||
($vulns | to_entries |
|
||||
map(select(.value.severity == "high" or .value.severity == "critical")) |
|
||||
map("- **" + .key + "** (" + .value.severity + ")") |
|
||||
if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list |
|
||||
"## npm audit: high/critical advisories\n\n" + $pkg_list +
|
||||
"\n\n**Run:** " + $run_url +
|
||||
"\n\n<details><summary>Raw audit excerpt (first 500 chars)</summary>\n\n```\n" +
|
||||
(tostring | .[0:500]) +
|
||||
"\n```\n\n</details>"
|
||||
' /tmp/audit.json)
|
||||
|
||||
# --- Dedupe: fetch open security issues, match by title marker ---
|
||||
# Renovate vuln PRs also carry the "security" label, so >1 open
|
||||
# "security" issue WILL occur. Title-match (not just label) ensures
|
||||
# we deduplicate only our own tracking issue.
|
||||
OPEN_ISSUES=$(api GET \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
||||
|
||||
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
||||
--arg m "$MARKER" \
|
||||
'[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)')
|
||||
MATCH_COUNT=$(echo "$MATCHED" | jq 'length')
|
||||
|
||||
if [ "$MATCH_COUNT" -gt 0 ]; then
|
||||
# Patch the oldest matched issue (append run URL to body).
|
||||
ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number')
|
||||
EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body')
|
||||
NEW_BODY=$(jq -n \
|
||||
--arg existing "$EXISTING_BODY" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
||||
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
||||
api PATCH \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" > /dev/null
|
||||
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
||||
else
|
||||
# Closed prior issue that recurs → new issue (not reopened).
|
||||
# A re-opened issue would obscure when the advisory was re-discovered.
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$MARKER" \
|
||||
--arg body "$ISSUE_BODY" \
|
||||
'{"title": $title, "body": $body}')
|
||||
CREATED=$(api POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
||||
echo "Opened new tracking issue #${NEW_NUMBER}"
|
||||
|
||||
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
||||
LABEL_IDS=$(api GET \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
||||
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
||||
api POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"labels\": $LABEL_IDS}" > /dev/null
|
||||
fi
|
||||
|
||||
exit "$AUDIT_EXIT"
|
||||
|
||||
else
|
||||
# --- Heartbeat: proves the job ran and found nothing ---
|
||||
# "No issue created" is only meaningful evidence when paired with a
|
||||
# visible positive signal. Without this, a never-ran job is
|
||||
# indistinguishable from a clean run.
|
||||
#
|
||||
# $GITHUB_STEP_SUMMARY availability is unproven on this runner
|
||||
# (act_runner populates it, but this is the first run to verify it).
|
||||
# Guard before use so an unset variable does not fail the clean-path.
|
||||
MSG="✅ npm audit clean $(date -u)"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
echo "$MSG" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "$MSG"
|
||||
fi
|
||||
|
||||
44
.gitea/workflows/renovate.yml
Normal file
44
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Renovate
|
||||
|
||||
# Runs Renovate daily to surface newly-published advisories via OSV.dev
|
||||
# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch
|
||||
# schedule (see renovate.json §schedule). Security/vulnerability PRs are
|
||||
# raised immediately regardless of the weekly schedule window.
|
||||
#
|
||||
# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md):
|
||||
# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues
|
||||
# Belongs to a dedicated bot account. Branch protection
|
||||
# on main must forbid this bot pushing directly.
|
||||
#
|
||||
# Platform config is injected via env vars below; the renovate.json in the
|
||||
# repo root carries only dependency rules (no platform/endpoint/repos).
|
||||
#
|
||||
# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd
|
||||
# corresponds to release v46.1.15. Update by bumping both the digest and the
|
||||
# renovate-version when Renovate publishes a new release. Renovate itself
|
||||
# will open a PR to bump this digest once it runs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Renovate
|
||||
# Pinned by digest — this action holds contents+pull_request+issues
|
||||
# scopes; an unpinned tag is a supply-chain risk (see ADR-041).
|
||||
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
|
||||
with:
|
||||
configurationFile: renovate.json
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
renovate-version: "46.1.15"
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.raddatz.cloud
|
||||
RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]'
|
||||
LOG_LEVEL: info
|
||||
169
.gitea/workflows/sdd-gate.yml
Normal file
169
.gitea/workflows/sdd-gate.yml
Normal file
@@ -0,0 +1,169 @@
|
||||
name: SDD Gate
|
||||
|
||||
# Spec-Driven Development quality gate. Runs on PRs.
|
||||
#
|
||||
# This project is ISSUE-ONLY: a feature's spec lives in its Gitea issue body, not a committed
|
||||
# spec.md (see ADR-042). So CI cannot lint the spec text itself — instead it validates the SDD
|
||||
# artifacts that DO live in git: the RTM, any committed OpenAPI contract, and the constitution.
|
||||
#
|
||||
# The first two jobs are NON-BLOCKING for now (continue-on-error) so the team can adopt the
|
||||
# workflow without CI immediately failing.
|
||||
#
|
||||
# TODO: flip rtm-check and contract-validate to BLOCKING (remove `continue-on-error: true`)
|
||||
# once SDD adoption has settled — target: after the first 5 features have shipped through
|
||||
# the workflow. Tracked in ADR-042.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
# ─── RTM check ────────────────────────────────────────────────────────────────
|
||||
# The Requirements Traceability Matrix is the one per-feature SDD artifact in git. Every
|
||||
# data row must point at a Gitea issue (`#n`) and name at least one test. Warn otherwise.
|
||||
# Pure awk — no external tooling. Columns: | REQ-ID | Summary | Issue | Feature | Impl | Test | Status |
|
||||
rtm-check:
|
||||
name: RTM Check
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # TODO: remove to make blocking (see header)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate .specify/rtm.md rows
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
rtm=".specify/rtm.md"
|
||||
test -f "$rtm" || { echo "::error::$rtm is missing"; exit 1; }
|
||||
|
||||
# Self-test: a good row passes, a row with an empty Issue or Test is flagged.
|
||||
check_row() { awk -F'|' '{
|
||||
issue=$4; test_col=$7;
|
||||
gsub(/^[ \t]+|[ \t]+$/,"",issue); gsub(/^[ \t]+|[ \t]+$/,"",test_col);
|
||||
if (issue !~ /#/ || test_col=="") exit 1; else exit 0 }'; }
|
||||
echo '| REQ-001 | x | #42 | f | impl | SomeTest#works | Done |' | check_row \
|
||||
|| { echo "FAIL: rtm-check self-test rejected a valid row"; exit 1; }
|
||||
echo '| REQ-002 | x | | f | impl | | Planned |' | check_row \
|
||||
&& { echo "FAIL: rtm-check self-test accepted an empty row"; exit 1; }
|
||||
|
||||
bad=0
|
||||
while IFS= read -r line; do
|
||||
echo "$line" | check_row || {
|
||||
req=$(echo "$line" | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); print $2}')
|
||||
echo "::warning file=$rtm::row $req is missing an Issue (#n) or a Test"
|
||||
bad=$((bad+1))
|
||||
}
|
||||
done < <(grep -E '^\| REQ-[0-9]{3} ' "$rtm")
|
||||
echo "$bad RTM row(s) incomplete (warning only)."
|
||||
|
||||
# ─── Contract validation ──────────────────────────────────────────────────────
|
||||
# Validate any committed OpenAPI contract with Spectral (OpenAPI 3.1). REST stack — no
|
||||
# GraphQL. Contracts are optional and ride a feature branch when present; the _example one
|
||||
# is always linted. Skips cleanly when none changed.
|
||||
contract-validate:
|
||||
name: Contract Validate
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # TODO: remove to make blocking (see header)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
# Cache the npm/npx download so Spectral isn't re-fetched every run. The key is pinned to
|
||||
# the exact Spectral version below, so a version bump busts the cache deterministically.
|
||||
- name: Cache Spectral (npm cache)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: spectral-cli-6.16.0
|
||||
restore-keys: spectral-cli-
|
||||
|
||||
- name: Lint changed OpenAPI contracts
|
||||
shell: bash
|
||||
env:
|
||||
SPECTRAL: "@stoplight/spectral-cli@6.16.0" # pinned — keep in sync with the cache key above
|
||||
run: |
|
||||
set -uo pipefail
|
||||
base="origin/${{ github.event.pull_request.base.ref }}"
|
||||
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
|
||||
# Any *.yaml under .specify/ or any file named like a contract.
|
||||
changed="$(git diff --name-only "$base"...HEAD -- '.specify/**/*.yaml' '**/api-contract.yaml' '**/*.openapi.yaml' || true)"
|
||||
if [ -z "$changed" ]; then
|
||||
echo "No OpenAPI contract changed — nothing to validate."
|
||||
exit 0
|
||||
fi
|
||||
rc=0
|
||||
for f in $changed; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "── spectral lint $f"
|
||||
npx --yes "$SPECTRAL" lint "$f" || rc=1
|
||||
done
|
||||
exit $rc
|
||||
|
||||
# ─── Constitution change impact ───────────────────────────────────────────────
|
||||
# When .specify/constitution.md is modified, list every file that references it (and so
|
||||
# may need a Sync Impact update) and post it as a PR comment. Best-effort: if no token is
|
||||
# available the list is only echoed to the log. This job is informational, never blocking.
|
||||
constitution-diff:
|
||||
name: Constitution Impact
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: List files referencing the constitution
|
||||
id: impact
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
base="origin/${{ github.event.pull_request.base.ref }}"
|
||||
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" || true
|
||||
if ! git diff --name-only "$base"...HEAD -- '.specify/constitution.md' | grep -q .; then
|
||||
echo "constitution.md not modified — skipping."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Files referencing constitution.md (review for Sync Impact):"
|
||||
grep -rIl --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=target \
|
||||
-e 'constitution.md' -e 'constitution §' . \
|
||||
| grep -v '^\./.specify/constitution.md$' | sort > /tmp/refs.txt || true
|
||||
cat /tmp/refs.txt
|
||||
{
|
||||
echo "body<<EOF"
|
||||
echo "### ⚠️ Constitution changed — Sync Impact review"
|
||||
echo ""
|
||||
echo "\`.specify/constitution.md\` was modified in this PR. Per its §6 Sync Impact rule, re-read and reconcile every file below, and confirm the semantic version bump:"
|
||||
echo ""
|
||||
while IFS= read -r line; do echo "- \`${line#./}\`"; done < /tmp/refs.txt
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post PR comment (best-effort)
|
||||
if: steps.impact.outputs.changed == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
BODY: ${{ steps.impact.outputs.body }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "No token available — printing impact list to log only:"
|
||||
echo "$BODY"
|
||||
exit 0
|
||||
fi
|
||||
payload="$(jq -n --arg b "$BODY" '{body:$b}')"
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments" \
|
||||
-d "$payload" >/dev/null \
|
||||
&& echo "Posted Sync Impact comment to PR #${PR}." \
|
||||
|| { echo "Comment POST failed (non-fatal); impact list:"; echo "$BODY"; }
|
||||
77
.specify/AGENTS.md
Normal file
77
.specify/AGENTS.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# AGENTS.md
|
||||
|
||||
Machine-readable rules for AI coding agents (Claude Code, Copilot, Cursor, …) working in
|
||||
this repository. Read this on every invocation. These are **executable constraints**, not
|
||||
aspirations. The full rationale lives in [constitution.md](./constitution.md) and the docs
|
||||
it links — this file does not duplicate it, it points to it.
|
||||
|
||||
If anything here conflicts with the user's explicit instruction, the user wins. Otherwise,
|
||||
constitution > this file > convenience.
|
||||
|
||||
---
|
||||
|
||||
## Stack & Versions
|
||||
|
||||
| Layer | Tech | Version |
|
||||
|---|---|---|
|
||||
| Backend | Spring Boot (Java, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Session JDBC) | Boot 4.0.6 / Java 21 |
|
||||
| API docs | springdoc-openapi (webmvc-ui), served at `/v3/api-docs` (dev profile only) | — |
|
||||
| Frontend | SvelteKit / Svelte | 2.60 / 5.43 |
|
||||
| Frontend lang/style | TypeScript / Tailwind CSS / Paraglide i18n (de/en/es) | TS 5.9 / TW 4.1 |
|
||||
| API client | `openapi-fetch` + `openapi-typescript` (types generated from the live spec) | — |
|
||||
| DB | PostgreSQL | 16 |
|
||||
| Object storage | MinIO (S3-compatible) | — |
|
||||
| Sidecars | `ocr-service`, `nlp-service` (Python / FastAPI) | Python 3.11 |
|
||||
| Tests | JUnit + Mockito + `@WebMvcTest` + Testcontainers (backend); Vitest + `vitest-browser-svelte` + Playwright (frontend); Pytest (services) | — |
|
||||
| Lint/format | ESLint 9 (+ `eslint-plugin-boundaries`) + Prettier; Semgrep (backend) | — |
|
||||
| CI | Gitea Actions (`.gitea/workflows/`) | — |
|
||||
|
||||
App port `8080`; management port `8081`. Backend app id: `org.raddatz.familienarchiv` / `0.0.1-SNAPSHOT`.
|
||||
|
||||
## Architectural Constraints
|
||||
|
||||
- Controllers call services only — never a repository. (constitution §1.2)
|
||||
- A service uses only its own domain's repository; reach other domains via their service. (constitution §1.3)
|
||||
- A new backend domain goes in its own package AND is added to `ArchitectureTest`'s allow-lists in the same change. (constitution §1.7)
|
||||
- Frontend cross-domain imports are allowed only where `frontend/eslint.config.js` permits; otherwise move shared code to `$lib/shared/`. (constitution §1.4)
|
||||
- Never serialize a lazy-collection entity across the controller boundary — assemble a view in-transaction. (constitution §1.6 / ADR-036)
|
||||
- `Person` ≠ `AppUser`; do not add account guards to Person-domain operations. (constitution §1.5)
|
||||
- Every `POST/PUT/PATCH/DELETE` endpoint has `@RequirePermission(Permission.X)`. Use the enum, never `@PreAuthorize`. (constitution §2.1–2.2)
|
||||
- Throw only `DomainException.notFound/forbidden/conflict/internal()` from services, each with an `ErrorCode`. (CONTRIBUTING §Error handling)
|
||||
- Set `createdBy`/`updatedBy` from the session principal in the service — never bind them from a request body. (constitution §2.4)
|
||||
- Add an `@Schema(requiredMode = REQUIRED)` to every always-populated field. (constitution §3.5)
|
||||
- Never introduce a new runtime dependency without an ADR in `Accepted` status. (constitution §5.1)
|
||||
- Render untrusted text with `{...}`; never `{@html}` on user/import data. (constitution §2.5)
|
||||
- Build dates from ISO strings with a `T12:00:00` suffix. (constitution §3.7)
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
- Always write a failing test before implementation code; confirm it fails, then make it pass, then refactor. (constitution §3.1)
|
||||
- Run only the specific test file/class locally — never the full suite (it crashes the machine); leave the full sweep to CI.
|
||||
- Run `npm run generate:api` (in `frontend/`) after ANY backend model or endpoint change — most common cause of TS errors.
|
||||
- Run `npm run lint` before every commit; a fresh frontend worktree needs `npm install` first or the pre-commit hook fails.
|
||||
- When adding a new `ErrorCode`, update all four sites at once (constitution §3.6).
|
||||
- One logical change per commit; reference the Gitea issue (`Closes #n` / `Refs #n`) on the last line.
|
||||
- Create a git worktree for new issue work — never `git checkout -b` in the main repo while another branch has in-flight work. Avoid `+` in worktree/branch names (breaks vitest browser mode).
|
||||
- Pull `main` as a separate explicit step before creating a branch.
|
||||
- Track work as Gitea issues (`http://192.168.178.71:3005`, repo `marcel/familienarchiv`), not todo files.
|
||||
- Verify ADR and Flyway migration numbers against disk before using one — parallel worktrees make issue-body numbers go stale.
|
||||
|
||||
## Do Not Touch
|
||||
|
||||
- Generated: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
|
||||
- Shipped Flyway migrations — add a new forward-only migration instead.
|
||||
- An `Accepted` ADR — supersede it with a new one.
|
||||
- `actions/(upload|download)-artifact` version — stays at `@v3` (ADR-014).
|
||||
- CI guard steps — do not remove/weaken without an ADR.
|
||||
- `main` — never commit directly; branch + PR only.
|
||||
- Worktree copies (`familienarchiv-*`, `.worktrees/`) and `data/` — never commit.
|
||||
|
||||
## Spec-Driven Development
|
||||
|
||||
A feature's spec is its **Gitea issue body** — there is no committed `spec.md`. The issue's
|
||||
EARS requirements (`REQ-NNN`) and acceptance criteria are the contract; each maps to a test,
|
||||
traced in [`.specify/rtm.md`](./rtm.md) (`REQ-ID → issue # → test`). Read the issue before
|
||||
implementing. The committed [`.specify/features/_example/`](./features/_example/) is a
|
||||
template/reference showing the full artifact set, not a live feature. Full workflow:
|
||||
[SPEC_DRIVEN_DEVELOPMENT.md](../SPEC_DRIVEN_DEVELOPMENT.md).
|
||||
25
.specify/adrs/README.md
Normal file
25
.specify/adrs/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# ADR archive — see `docs/adr/`
|
||||
|
||||
This project already keeps a mature, permanent ADR archive at
|
||||
[`../../docs/adr/`](../../docs/adr/) (40+ records, format `NNN-kebab-title.md`). SDD does
|
||||
**not** introduce a second archive — that would split the project's decision history in two.
|
||||
|
||||
## Where ADRs live
|
||||
|
||||
- **Project-wide decisions** → [`docs/adr/NNN-kebab-title.md`](../../docs/adr/). Use the
|
||||
next free `NNN` (verify against the directory on disk — parallel worktrees make
|
||||
issue-body numbers stale). Template: [`../templates/adr.md`](../templates/adr.md).
|
||||
- **The decision to adopt SDD itself** →
|
||||
[`docs/adr/042-sdd-adoption.md`](../../docs/adr/042-sdd-adoption.md) (this is the
|
||||
"ADR-000" the SDD scaffold calls for, numbered to fit the existing sequence).
|
||||
- **Feature-local decisions** that are only meaningful within one in-flight feature →
|
||||
beside that feature's spec, e.g.
|
||||
[`../features/_example/adr-001-avatars-reuse-archive-bucket.md`](../features/_example/adr-001-avatars-reuse-archive-bucket.md).
|
||||
Promote one to `docs/adr/` if its reach turns out to be project-wide.
|
||||
|
||||
## Rules (unchanged from the existing convention)
|
||||
|
||||
- An ADR is **immutable once `Accepted`** — supersede it with a new, higher-numbered ADR;
|
||||
set the old one's status to `Superseded by ADR-MMM`.
|
||||
- Header style matches the existing archive: `# ADR-NNN — Title`, then
|
||||
`**Status:** / **Date:** / **Issue:**`.
|
||||
80
.specify/constitution.md
Normal file
80
.specify/constitution.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Familienarchiv Constitution
|
||||
|
||||
**Version:** v1.0.0
|
||||
**Status:** Ratified
|
||||
**Date:** 2026-06-13
|
||||
**Adoption ADR:** [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)
|
||||
|
||||
> The non-negotiable rules of this project. Every spec, every PR, and every AI agent is
|
||||
> bound by this document. Rules here are deliberately few and absolute — guidance and
|
||||
> rationale live in [CLAUDE.md](../CLAUDE.md), [COLLABORATING.md](../COLLABORATING.md),
|
||||
> [CODESTYLE.md](../CODESTYLE.md), [CONTRIBUTING.md](../CONTRIBUTING.md), and the ADR
|
||||
> archive ([docs/adr/](../docs/adr/)). When this file conflicts with any of those, **this
|
||||
> file wins** — open an ADR to change it.
|
||||
>
|
||||
> Versioning is semantic: **MAJOR** = a rule removed or weakened (existing code may now
|
||||
> violate the constitution), **MINOR** = a rule added or tightened, **PATCH** = wording
|
||||
> only. Any change requires the Sync Impact review in the last section.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Principles
|
||||
|
||||
1. The backend is organised package-by-domain under `org.raddatz.familienarchiv`; a new domain lives in its own package, never spread across layer packages.
|
||||
2. Controllers never call repositories directly — a controller calls only services.
|
||||
3. A service accesses only its own domain's repository; cross-domain data is fetched through the other domain's service, never its repository.
|
||||
4. The frontend mirrors the backend domain split under `frontend/src/lib/<domain>/`, and cross-domain imports are allowed only where `frontend/eslint.config.js` (`boundaries/dependencies`) permits them.
|
||||
5. A `Person` (historical subject) and an `AppUser` (login account) are distinct domains and never share an identity or an account guard.
|
||||
6. Lazy-collection-bearing entities are never serialized across the controller boundary; the owning service assembles an explicit view inside the transaction (see [ADR-036](../docs/adr/036-geschichte-responses-are-views-not-entities.md)).
|
||||
7. A new backend domain package is added to `ArchitectureTest`'s package allow-lists in the same change that introduces it.
|
||||
8. Synchronous cross-domain side effects use in-transaction domain events, not direct service-to-service write calls (see [ADR-006](../docs/adr/006-synchronous-domain-events-in-transaction.md)).
|
||||
|
||||
## 2. Security Defaults
|
||||
|
||||
1. Every `POST`, `PUT`, `PATCH`, and `DELETE` endpoint carries `@RequirePermission(Permission.X)` — there is no unguarded mutating endpoint.
|
||||
2. Authorization uses the typed `Permission` enum and `@RequirePermission`, never magic-string `@PreAuthorize`.
|
||||
3. All user input is validated at the system boundary (controller / form action), and validation failures return a typed `ErrorCode`, never a raw exception.
|
||||
4. Audit fields (`createdBy`/`updatedBy`) are set from the session principal inside the service and are never bound from a request body.
|
||||
5. Untrusted text is rendered through Svelte's default `{...}` escaping; `{@html}` is never used on user- or import-derived strings.
|
||||
6. Secrets are read only from environment variables (see `.env.example`); no secret, token, password, or DSN is ever committed to the repository or written to a log.
|
||||
7. Logs never contain PII beyond a stable user/entity UUID — no names, email addresses, document contents, or transcription text.
|
||||
8. Every state-mutating endpoint is covered by an Unwanted-behavior requirement (EARS `If`) describing the unauthenticated/unauthorized response.
|
||||
9. A dependency security audit runs on every CI run (`npm audit --audit-level=high` frontend, Semgrep `.semgrep/security.yml` backend) and nightly; a `high` finding blocks merge.
|
||||
|
||||
## 3. Code Quality Rules
|
||||
|
||||
1. All new behavior is driven by a failing test written before the implementation (Red → Green → Refactor); a passing-on-first-run test proves nothing and is rejected.
|
||||
2. KISS beats DRY — no premature abstraction; an abstraction is introduced only on the third real caller.
|
||||
3. Each commit does exactly one logical thing and references its Gitea issue (`Closes #n` / `Refs #n`) on the last line of the body.
|
||||
4. No backwards-compatibility shims are added for code that has no callers.
|
||||
5. Every entity/DTO field the backend always populates carries `@Schema(requiredMode = REQUIRED)`, and `npm run generate:api` is run after any backend model or endpoint change.
|
||||
6. A new `ErrorCode` is added in all four places at once: `ErrorCode.java`, `frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, and `messages/{de,en,es}.json`.
|
||||
7. Dates built from an ISO date string append `T12:00:00` to avoid UTC off-by-one.
|
||||
8. `npm run lint` (Prettier + ESLint, including the domain boundary rule) passes before every commit.
|
||||
|
||||
## 4. Do-Not-Touch List
|
||||
|
||||
1. Do not edit generated artifacts: `frontend/src/lib/generated/api.ts`, `frontend/src/lib/paraglide/`, `frontend/.svelte-kit/`, `frontend/build/`, `backend/target/`.
|
||||
2. Do not edit an `Accepted` ADR — supersede it with a new, higher-numbered ADR.
|
||||
3. Do not upgrade `actions/upload-artifact` / `download-artifact` past `@v3` (Gitea act_runner lacks the v4 protocol — [ADR-014](../docs/adr/014-upload-artifact-v3-pin.md)).
|
||||
4. Do not remove or weaken a CI guard step (banned-pattern greps, self-tested regexes) without an ADR recording why.
|
||||
5. Do not commit to `main` directly — all work flows through a branch and a PR.
|
||||
6. Do not edit a Flyway migration that has shipped; add a new forward-only migration instead.
|
||||
7. Do not commit the worktree copy directories (`familienarchiv-*`, `.worktrees/`) or `data/`.
|
||||
|
||||
## 5. Dependency Policy
|
||||
|
||||
1. A new runtime dependency (backend `pom.xml` or frontend `dependencies`) requires an ADR in `Accepted` status before it is merged.
|
||||
2. A new dependency must be version-pinned in the manifest, and any exact pin (no caret) carries a comment stating why it cannot float (see the `@vitest/browser-playwright` pin).
|
||||
3. Renovate manages dependency-update PRs; a major-version bump is treated as a feature requiring its own spec and review, not an auto-merge.
|
||||
4. A dependency with an unresolved `high`+ advisory is not merged; it is pinned to a safe version or replaced.
|
||||
|
||||
## 6. Sync Impact
|
||||
|
||||
When this constitution changes, the author MUST, in the same PR:
|
||||
|
||||
1. Bump the **Version** header per the semantic rule above and record the change in [docs/adr/042-sdd-adoption.md](../docs/adr/042-sdd-adoption.md)'s revision log (or a superseding ADR for a MAJOR change).
|
||||
2. Re-read and reconcile every file that restates a rule changed here: `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, `CONTRIBUTING.md`, `.specify/AGENTS.md`, and the affected `.specify/personas/*.md` checklists.
|
||||
3. Update any `.specify/templates/*` section that quotes a changed rule.
|
||||
4. Run the `constitution-diff` CI job locally (or read its PR comment) and resolve every file it lists.
|
||||
5. Announce the version bump in the PR description so reviewers re-read the constitution before approving.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-001 (feature-local) — Avatars reuse the archive bucket under an `avatars/` prefix
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-13
|
||||
**Issue:** #<example> (profile picture upload)
|
||||
|
||||
> **Feature-local ADR.** This decision is scoped to the avatar feature and lives with its
|
||||
> spec. A decision with project-wide reach is promoted to the permanent archive at
|
||||
> `docs/adr/` with the next free number. (For the worked example, it stays local.)
|
||||
|
||||
## Context
|
||||
|
||||
Avatars are small binary objects keyed per user. The project already runs MinIO with a
|
||||
single archive bucket and a `FileService` abstraction used by document uploads. We must
|
||||
decide where avatar bytes live without adding operational surface that the self-hosted
|
||||
Compose deployment has to learn about.
|
||||
|
||||
## Decision
|
||||
|
||||
Store each avatar in the **existing archive bucket** under the deterministic key
|
||||
`avatars/{userId}`, written and read through the existing `FileService`. No new bucket, no
|
||||
new env var, no new Compose service or bucket-bootstrap step.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Option | Pros | Cons | Reason rejected |
|
||||
|---|---|---|---|
|
||||
| Reuse archive bucket, `avatars/` prefix | No infra change; reuses `FileService`; idempotent overwrite | Mixes avatars with documents in one bucket | **Chosen** — least operational cost; prefix keeps them logically separate |
|
||||
| Dedicated `avatars` bucket | Clean separation; independent lifecycle/policy | New bucket + bootstrap step + env var + Compose idempotency test | Operational overhead not justified for small, low-value objects |
|
||||
| Store bytes in PostgreSQL (`bytea`) | One datastore; transactional with the row | Bloats the DB and backups; streaming images via JPA is awkward | Wrong tool; MinIO already exists for blobs |
|
||||
| External CDN / object store | Offloads bandwidth | New third-party dependency + secret + ADR; conflicts with self-hosted goal | Contradicts the self-hosted infrastructure stance |
|
||||
|
||||
## Consequences
|
||||
|
||||
- No deployment change ships with this feature — only a Flyway column and code.
|
||||
- Avatars and documents share a bucket; any future per-object lifecycle policy must filter
|
||||
by the `avatars/` prefix.
|
||||
- The deterministic key (`avatars/{userId}`, no random suffix) makes replace an overwrite,
|
||||
so there is no orphan-cleanup obligation (REQ-001).
|
||||
- If avatars later need independent retention or a public CDN, this ADR is superseded by a
|
||||
project-wide ADR in `docs/adr/`.
|
||||
|
||||
## References
|
||||
|
||||
- [`./spec.md`](./spec.md), [`./design.md`](./design.md)
|
||||
- [constitution §5 Dependency Policy](../../constitution.md#5-dependency-policy)
|
||||
140
.specify/features/_example/api-contract.yaml
Normal file
140
.specify/features/_example/api-contract.yaml
Normal file
@@ -0,0 +1,140 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Familienarchiv API — Profile picture upload
|
||||
version: 0.0.1-SNAPSHOT
|
||||
description: >
|
||||
Design-time contract for the avatar feature (.specify/features/_example).
|
||||
Source of truth once shipped is the generated /v3/api-docs.
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
description: Local backend (dev profile)
|
||||
- url: https://archiv.raddatz.cloud
|
||||
description: Production (behind Caddy)
|
||||
components:
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: SESSION
|
||||
schemas:
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: AVATAR_TOO_LARGE
|
||||
message:
|
||||
type: string
|
||||
UserProfileView:
|
||||
type: object
|
||||
required: [id, displayName]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
displayName:
|
||||
type: string
|
||||
avatarUrl:
|
||||
type: [string, "null"]
|
||||
description: Authenticated proxy path (/api/users/{id}/avatar) when an avatar exists, else null.
|
||||
example: /api/users/3f1c.../avatar
|
||||
security:
|
||||
- cookieAuth: []
|
||||
paths:
|
||||
/api/users/me/avatar:
|
||||
post:
|
||||
summary: Upload or replace the current user's avatar
|
||||
tags: [Users]
|
||||
operationId: uploadMyAvatar
|
||||
security:
|
||||
- cookieAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: PNG or JPEG, max 2 MB.
|
||||
responses:
|
||||
'200':
|
||||
description: Avatar stored; updated profile returned.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/UserProfileView' }
|
||||
'400':
|
||||
description: Unsupported type (UNSUPPORTED_FILE_TYPE) or too large (AVATAR_TOO_LARGE).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
'401':
|
||||
description: Unauthenticated (UNAUTHORIZED).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
delete:
|
||||
summary: Remove the current user's avatar
|
||||
tags: [Users]
|
||||
operationId: deleteMyAvatar
|
||||
security:
|
||||
- cookieAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Avatar removed; profile returned with avatarUrl null.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/UserProfileView' }
|
||||
'401':
|
||||
description: Unauthenticated (UNAUTHORIZED).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
/api/users/{id}/avatar:
|
||||
get:
|
||||
summary: Stream a user's avatar image (authenticated proxy)
|
||||
tags: [Users]
|
||||
operationId: getUserAvatar
|
||||
security:
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: Image bytes.
|
||||
content:
|
||||
image/png: { schema: { type: string, format: binary } }
|
||||
image/jpeg: { schema: { type: string, format: binary } }
|
||||
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||
'404': { description: User has no avatar, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||
delete:
|
||||
summary: Remove another user's avatar (admin only)
|
||||
tags: [Users]
|
||||
operationId: deleteUserAvatar
|
||||
description: Requires Permission.ADMIN_USER (enforced by @RequirePermission on the controller).
|
||||
security:
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: Avatar removed.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/UserProfileView' }
|
||||
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||
'403':
|
||||
description: Caller lacks ADMIN_USER (FORBIDDEN).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorResponse' }
|
||||
76
.specify/features/_example/checklist-results.md
Normal file
76
.specify/features/_example/checklist-results.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Persona Review Results — Profile picture upload
|
||||
|
||||
> Captured from the six persona spec reviews (the comments that, in a real feature, are
|
||||
> posted on the Gitea issue). This is the worked example of what a completed review round
|
||||
> looks like. All personas APPROVE; the two findings raised were folded into the spec
|
||||
> before approval.
|
||||
|
||||
## Summary
|
||||
|
||||
| Persona | Verdict | Blocking FAILs | Notes |
|
||||
|---|---|---|---|
|
||||
| Requirements Engineer | APPROVE | none | — |
|
||||
| Developer | APPROVE | none | — |
|
||||
| Security | APPROVE | none (2 resolved) | See F-SEC-1, F-SEC-2 |
|
||||
| DevOps | APPROVE | none | — |
|
||||
| UI/UX | APPROVE | none (1 resolved) | See F-UX-1 |
|
||||
| Architect | APPROVE | none (1 resolved) | See F-ARCH-1 |
|
||||
|
||||
---
|
||||
|
||||
## ### Security — Spec Review
|
||||
|
||||
| # | Item | Status | Note |
|
||||
|---|---|---|---|
|
||||
| 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
|
||||
| 2 | Each mutating endpoint names least-privilege `Permission` | PASS | `me` = authenticated; `{id}` = ADMIN_USER |
|
||||
| 3 | Audit fields server-set, forbidden in body | PASS | `avatarObjectKey` server-set (design.md) |
|
||||
| 4 | IDOR surfaces addressed | PASS | `/{id}` gated by ADMIN_USER + ownership |
|
||||
| 5 | Untrusted content rendered safely | PASS | image bytes via proxy + `nosniff` |
|
||||
| 6 | Upload: type allow-list + size + bytes | PASS | REQ-007 (PNG/JPEG), REQ-008 (2 MB) |
|
||||
| 7 | No entity internals leaked | PASS | `UserProfileView`, not `AppUser` |
|
||||
| 8 | Conflicts → 409 not raw 500 | N/A | no optimistic-lock surface here |
|
||||
| 9 | threat-model.md present & STRIDE-complete | PASS | [threat-model.md](./threat-model.md) |
|
||||
| 10 | ASTRIDE if AI tool used | N/A | no AI agent |
|
||||
| 11 | Secrets from env only | PASS | none introduced |
|
||||
| 12 | Logs PII-free | PASS | user UUID only |
|
||||
| 13 | New dependency has ADR + clean audit | N/A | no new dependency |
|
||||
|
||||
**F-SEC-1 (resolved):** initial draft exposed a public S3 URL for `avatarUrl` →
|
||||
information disclosure. Resolved: authenticated proxy `GET /api/users/{id}/avatar`.
|
||||
**F-SEC-2 (resolved):** initial draft bound `avatarObjectKey` from the request body →
|
||||
mass-assignment. Resolved: server-set only.
|
||||
**Verdict: APPROVE.**
|
||||
|
||||
## ### UI/UX — Spec Review
|
||||
|
||||
| # | Item | Status | Note |
|
||||
|---|---|---|---|
|
||||
| 1 | Every interaction state described | PASS | idle/preview/uploading/error/done (T-10) |
|
||||
| 2 | Strings via Paraglide i18n | PASS | T-8 |
|
||||
| 3 | Reuses design tokens/components | PASS | placeholder uses existing initials pattern |
|
||||
| 4 | Responsive per device split | PASS | control usable on phone + laptop |
|
||||
| 5 | Errors via `getErrorMessage(code)` | PASS | UNSUPPORTED_FILE_TYPE / AVATAR_TOO_LARGE |
|
||||
| 6 | Keyboard + screen-reader | PASS | labelled file input, alt text on image |
|
||||
| 7 | Acceptance criteria measurable | PASS | sizes, status codes |
|
||||
| 8 | E2E scenario per journey | PASS | T-12 |
|
||||
| 9 | Confirmation for destructive action | PASS | remove asks to confirm |
|
||||
| 10 | Safe rendering + image dims | PASS | fixed dims avoid layout shift |
|
||||
| 11 | Live routes verified | PASS | `/profile`, `/users/[id]` exist |
|
||||
| 12 | Token theming respected | PASS | semantic tokens |
|
||||
|
||||
**F-UX-1 (resolved):** no loading state in first draft → spinner during upload added (REQ-... covered by state set in T-10).
|
||||
**Verdict: APPROVE.**
|
||||
|
||||
## ### Architect — Spec Review
|
||||
|
||||
Key items PASS. **F-ARCH-1 (resolved):** bucket choice was undocumented → captured in
|
||||
[adr-001-avatars-reuse-archive-bucket.md](./adr-001-avatars-reuse-archive-bucket.md). No new
|
||||
domain, no boundary crossing, Person/AppUser separation intact. **Verdict: APPROVE.**
|
||||
|
||||
## ### Requirements Engineer / Developer / DevOps — Spec Review
|
||||
|
||||
All checklist items PASS (see each persona's checklist in `.specify/personas/`). RE: 9 REQ
|
||||
ids, all EARS-formed, every limit has an `If`. Developer: reuses `FileService`/`UserService`,
|
||||
`AVATAR_TOO_LARGE` four-site update is T-1. DevOps: V78 forward-only + rollback note, no new
|
||||
bucket/env var, idempotent overwrite. **All three: APPROVE.**
|
||||
63
.specify/features/_example/design.md
Normal file
63
.specify/features/_example/design.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Design — Profile picture upload
|
||||
|
||||
> Companion to [`./spec.md`](./spec.md). The spec says *what*; this says *how*, and records
|
||||
> the alternatives weighed for the non-obvious choices.
|
||||
|
||||
## Component overview
|
||||
|
||||
```
|
||||
ProfileSettings.svelte ──► +page.server.ts (form action)
|
||||
(preview, validate) │ POST /api/users/me/avatar (multipart)
|
||||
▼
|
||||
UserAvatarController ── @RequirePermission(authenticated)
|
||||
│ ownership/admin check for /{id}
|
||||
▼
|
||||
UserService.setAvatar(userId, MultipartFile)
|
||||
│ validate type+size → ErrorCode
|
||||
├──► FileService.put("avatars/{userId}", bytes) (MinIO)
|
||||
└──► userRepository.save(user.avatarObjectKey=key)
|
||||
▼
|
||||
UserProfileView { …, avatarUrl }
|
||||
```
|
||||
|
||||
Reads: `GET /api/users/{id}/avatar` streams the object through the authenticated API
|
||||
(`FileService.get`), so no public S3 URL is ever exposed. `avatarUrl` in the view is simply
|
||||
`/api/users/{id}/avatar` when a key exists, else `null`.
|
||||
|
||||
## Key decisions
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Where avatars live | Existing archive bucket, `avatars/{userId}` prefix | No new bucket/env var/Compose change — see [ADR-001](./adr-001-avatars-reuse-archive-bucket.md). |
|
||||
| URL exposure | Authenticated proxy endpoint, not a signed/public URL | Same auth surface as the rest of the API; no key leakage (Information disclosure). |
|
||||
| Object key | Deterministic `avatars/{userId}` (no random suffix) | A new upload overwrites the old object — no orphan-cleanup job needed (REQ-001). |
|
||||
| `avatarObjectKey` binding | Server-set in `UserService` only | Never bound from request body — prevents pointing a user's avatar at an arbitrary object (Tampering / CWE-639). |
|
||||
| Validation site | `UserService`, boundary-only | Type + size checked once, at the service boundary, mapped to `ErrorCode` (constitution §2.3). |
|
||||
|
||||
## Layering & conventions
|
||||
|
||||
- Controller → `UserService` only; `UserService` owns `userRepository` and calls
|
||||
`FileService` (its public API), never another domain's repository. (constitution §1.2–1.3)
|
||||
- New `ErrorCode.AVATAR_TOO_LARGE` requires the four-site update (see `tasks.md` T-1).
|
||||
- `UserProfileView.avatarUrl` is `String` (nullable) with `@Schema` describing the proxy
|
||||
path; not marked `requiredMode = REQUIRED` because it is legitimately null (REQ-004).
|
||||
- After backend changes: `npm run generate:api` regenerates `avatarUrl` into the TS types.
|
||||
|
||||
## Non-functional notes
|
||||
|
||||
- Size cap (2 MB, REQ-008) is enforced **before** the object touches MinIO — the multipart
|
||||
is read into a bounded buffer; Spring's `spring.servlet.multipart.max-file-size` is set to
|
||||
a matching ceiling so an oversized body is rejected at the container edge too.
|
||||
- No N+1 risk: the profile view derives `avatarUrl` from the already-loaded `avatarObjectKey`
|
||||
column; no extra query, no S3 round-trip on list/read paths.
|
||||
- The proxy `GET` streams bytes (no full-buffer) and sets a short `Cache-Control` so an
|
||||
updated avatar propagates quickly.
|
||||
|
||||
## Test strategy (maps to tasks.md)
|
||||
|
||||
| Level | What | Tooling |
|
||||
|---|---|---|
|
||||
| Unit | `UserService.setAvatar` validation + storage interactions | JUnit + Mockito (mock `FileService`) |
|
||||
| Slice | controller auth, status codes, error codes | `@WebMvcTest` |
|
||||
| E2E | upload → preview → confirm → avatar visible; remove → initials | Playwright |
|
||||
| Component | initials placeholder when `avatarUrl` is null | `vitest-browser-svelte` (`*.svelte.spec.ts`) |
|
||||
118
.specify/features/_example/spec.md
Normal file
118
.specify/features/_example/spec.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# As a user I want to upload a profile picture so other family members recognise me
|
||||
|
||||
> **This is the canonical worked example for SDD in this repo.** It is fictional but
|
||||
> realistic, chosen because no real avatar feature exists in the codebase. Use it as the
|
||||
> reference shape for a real `spec.md`. Every section is filled — no placeholders.
|
||||
|
||||
## Context & Why
|
||||
|
||||
Readers and transcribers collaborate in threads and on document comments, but every user is
|
||||
currently represented by initials only. Letting a user upload a small profile picture makes
|
||||
the activity feed, comments, and the public user profile page (`/users/[id]`) more personal
|
||||
and easier to scan — directly serving the family-archive product goal of feeling like a
|
||||
shared family space, not a database.
|
||||
|
||||
Constitution principles this feature depends on:
|
||||
- [§2 Security Defaults](../../constitution.md#2-security-defaults) — upload validation, permission gating, no PII in logs.
|
||||
- [§1.3 services own their repository](../../constitution.md#1-architecture-principles) — avatar storage goes through `UserService` + `FileService`, not a controller.
|
||||
- [§3.6 ErrorCode four-site rule](../../constitution.md#3-code-quality-rules) — introduces `AVATAR_TOO_LARGE`.
|
||||
|
||||
Related: builds on the existing `FileService` (MinIO) used by `Document` uploads.
|
||||
|
||||
## User Journey
|
||||
|
||||
A logged-in user opens their profile settings (`/profile`), clicks "Profilbild ändern",
|
||||
selects a PNG or JPEG from their device, sees an instant preview, and confirms. The picture
|
||||
replaces their initials everywhere their name appears. They can later remove it and fall
|
||||
back to initials. An admin (with `ADMIN_USER`) can remove an inappropriate picture from
|
||||
another user's account from the admin user view.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **REQ-001** (Ubiquitous) — The user service shall store each profile picture as a single object in the existing archive bucket under the key `avatars/{userId}`, overwriting any previous object for that user.
|
||||
- **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar` with a valid image, the user service shall store the image, set the user's `avatarObjectKey`, and return the updated profile view including a non-null `avatarUrl`.
|
||||
- **REQ-003** (Event-driven) — When an authenticated user sends `DELETE /api/users/me/avatar`, the user service shall delete the stored object, clear `avatarObjectKey`, and return the profile view with `avatarUrl = null`.
|
||||
- **REQ-004** (State-driven) — While a user has no stored avatar, the profile view for that user shall return `avatarUrl = null` and the frontend shall render the initials placeholder.
|
||||
- **REQ-005** (Optional-feature) — Where the caller holds `Permission.ADMIN_USER`, the user service shall allow `DELETE /api/users/{id}/avatar` to remove another user's avatar.
|
||||
- **REQ-006** (Unwanted-behavior) — If the request to any avatar endpoint is unauthenticated, then the system shall return `401` with `ErrorCode.UNAUTHORIZED` and store or delete nothing.
|
||||
- **REQ-007** (Unwanted-behavior) — If the uploaded file's content type is not `image/png` or `image/jpeg`, then the user service shall return `400 ErrorCode.UNSUPPORTED_FILE_TYPE` and store nothing.
|
||||
- **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
|
||||
- **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets another user's avatar via `/api/users/{id}/avatar`, then the system shall return `403 ErrorCode.FORBIDDEN` and modify nothing.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **REQ-001** — After a successful upload, exactly one object exists at `avatars/{userId}`; a second upload leaves exactly one object (no orphan), verified by a `FileService` interaction test.
|
||||
- **REQ-002** — `POST /api/users/me/avatar` with a 100 KB PNG returns `200` and a body whose `avatarUrl` is a non-null string; the persisted `app_users.avatar_object_key` equals `avatars/{userId}`.
|
||||
- **REQ-003** — `DELETE /api/users/me/avatar` returns `200`, the object is gone, and the response `avatarUrl` is `null`.
|
||||
- **REQ-004** — `GET` profile view for a user with `avatar_object_key IS NULL` returns `avatarUrl: null`; the rendered component shows a 2-letter initials placeholder (Playwright).
|
||||
- **REQ-005** — An `ADMIN_USER` caller deleting another user's avatar returns `200`; the target's `avatar_object_key` becomes `NULL`.
|
||||
- **REQ-006** — An unauthenticated `POST`/`DELETE` returns `401`; bucket object count is unchanged.
|
||||
- **REQ-007** — A `text/plain` or `application/pdf` upload returns `400 UNSUPPORTED_FILE_TYPE`; bucket object count is unchanged.
|
||||
- **REQ-008** — A 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count is unchanged.
|
||||
- **REQ-009** — A non-admin caller targeting another user's id returns `403 FORBIDDEN`; the target's `avatar_object_key` is unchanged.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Image cropping, resizing, or transformation — the client sends a final image; the server stores it verbatim within the size limit.
|
||||
- Avatars for historical `Person` entities — this feature is for `AppUser` accounts only (Person ≠ AppUser).
|
||||
- Gravatar / external avatar providers.
|
||||
- Animated formats (GIF/WebP) — PNG and JPEG only in v1.
|
||||
|
||||
## API / Contract Stub
|
||||
|
||||
See [`./api-contract.yaml`](./api-contract.yaml). Endpoints:
|
||||
`POST /api/users/me/avatar` (multipart), `DELETE /api/users/me/avatar`,
|
||||
`DELETE /api/users/{id}/avatar` (ADMIN_USER). The profile view gains an optional
|
||||
`avatarUrl: string | null`. All mutating endpoints carry `@RequirePermission` — `me`
|
||||
endpoints require an authenticated session; the `{id}` delete requires `ADMIN_USER`.
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
- Add nullable `avatar_object_key VARCHAR(512)` to `app_users`.
|
||||
- Flyway `V78__add_app_user_avatar_object_key.sql` (next free number — verify against
|
||||
`backend/src/main/resources/db/migration/` on disk before committing).
|
||||
- **Rollback:** forward-only. Reverse manually with `ALTER TABLE app_users DROP COLUMN avatar_object_key;`. The MinIO `avatars/` objects are orphaned but harmless on rollback and can be pruned with `mc rm --recursive`.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
STRIDE categories touched: **Tampering** (mass-assignment of `avatarObjectKey` if bound from
|
||||
body), **Elevation of privilege** (a non-admin modifying another user's avatar — REQ-009),
|
||||
**Denial of service** (oversized upload — REQ-008), **Information disclosure** (avatar URL
|
||||
must not expose a signed key that bypasses auth). No AI agent involved, so ASTRIDE does not
|
||||
apply. Full analysis: [`./threat-model.md`](./threat-model.md).
|
||||
|
||||
## Open Questions
|
||||
|
||||
> All resolved before implementation.
|
||||
|
||||
- [x] Public or signed avatar URL? — **Resolved:** served through an authenticated
|
||||
`GET /api/users/{id}/avatar` proxy (same auth as the rest of the API), not a public S3 URL.
|
||||
- [x] New bucket or reuse archive bucket? — **Resolved:** reuse the archive bucket under an
|
||||
`avatars/` prefix; see [`./adr-001-avatars-reuse-archive-bucket.md`](./adr-001-avatars-reuse-archive-bucket.md).
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|
||||
|---|---|---|---|
|
||||
| REQ-001 | T-3 | `UserServiceAvatarTest#storesUnderUserKey`, `…#replaceLeavesNoOrphan` | Planned |
|
||||
| REQ-002 | T-4 | `UserAvatarControllerTest#uploadReturnsAvatarUrl` | Planned |
|
||||
| REQ-003 | T-5 | `UserAvatarControllerTest#deleteClearsAvatar` | Planned |
|
||||
| REQ-004 | T-7 | `avatar-placeholder.svelte.spec.ts` | Planned |
|
||||
| REQ-005 | T-6 | `UserAvatarControllerTest#adminDeletesOthersAvatar` | Planned |
|
||||
| REQ-006 | T-2 | `UserAvatarControllerTest#unauthenticatedReturns401` | Planned |
|
||||
| REQ-007 | T-2 | `UserAvatarControllerTest#rejectsNonImage` | Planned |
|
||||
| REQ-008 | T-2 | `UserAvatarControllerTest#rejectsOversize` | Planned |
|
||||
| REQ-009 | T-6 | `UserAvatarControllerTest#nonAdminForbiddenOnOthers` | Planned |
|
||||
|
||||
Mirrored in [`.specify/rtm.md`](../../rtm.md).
|
||||
|
||||
## Persona Review Results
|
||||
|
||||
| Persona | Status | Key Findings | Resolved |
|
||||
|---|---|---|---|
|
||||
| Requirements Engineer | APPROVE | All 9 REQ ids EARS-formed; every limit has an `If` clause. | — |
|
||||
| Developer | APPROVE | Reuses `FileService`/`UserService`; `AVATAR_TOO_LARGE` four-site update listed (T-1). | — |
|
||||
| Security | APPROVE | REQ-006/008/009 cover authn/DoS/EoP; `avatarObjectKey` server-set only (see threat model T-1). | Yes |
|
||||
| DevOps | APPROVE | V78 forward-only with rollback note; no new bucket/env var. | — |
|
||||
| UI/UX | APPROVE | Placeholder + loading/error states specified; strings via i18n (T-8). | — |
|
||||
| Architect | APPROVE | Bucket-reuse decision captured in ADR-001; no new domain, no boundary crossing. | Yes |
|
||||
47
.specify/features/_example/tasks.md
Normal file
47
.specify/features/_example/tasks.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Tasks — Profile picture upload
|
||||
|
||||
> Red/Green TDD order: each implementation task is preceded by the failing test that
|
||||
> requires it. Task IDs are referenced from `spec.md` → Traceability and from `.specify/rtm.md`.
|
||||
> Check off as work lands; reference the issue in each commit (`Refs #<n>`).
|
||||
|
||||
## Backend
|
||||
|
||||
- [ ] **T-1** Add `ErrorCode.AVATAR_TOO_LARGE` in all four sites at once: `ErrorCode.java`,
|
||||
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`.
|
||||
*(No new behavior yet — enables REQ-008's error.)* → covers REQ-008 (error plumbing)
|
||||
- [ ] **T-2** `@WebMvcTest` `UserAvatarControllerTest`: write failing slice tests —
|
||||
`unauthenticatedReturns401`, `rejectsNonImage` (400 UNSUPPORTED_FILE_TYPE),
|
||||
`rejectsOversize` (400 AVATAR_TOO_LARGE). Then implement `UserAvatarController` +
|
||||
`@RequirePermission` to green. → REQ-006, REQ-007, REQ-008
|
||||
- [ ] **T-3** Unit `UserServiceAvatarTest`: failing tests `storesUnderUserKey`,
|
||||
`replaceLeavesNoOrphan`, validation maps to `DomainException`. Then implement
|
||||
`UserService.setAvatar`/`removeAvatar` (mock `FileService`) to green. → REQ-001, REQ-002, REQ-003
|
||||
- [ ] **T-4** Flyway `V78__add_app_user_avatar_object_key.sql` (verify next free number on
|
||||
disk) adding nullable `avatar_object_key VARCHAR(512)`; add the column + `@Schema` to
|
||||
`AppUser` / `UserProfileView` (`avatarUrl` derived). Test: repository round-trip. → REQ-002
|
||||
- [ ] **T-5** `deleteMyAvatar` controller test + impl (clears key, deletes object, returns
|
||||
`avatarUrl: null`). → REQ-003
|
||||
- [ ] **T-6** Admin path: failing tests `adminDeletesOthersAvatar` (200),
|
||||
`nonAdminForbiddenOnOthers` (403). Implement ownership/`ADMIN_USER` check to green. → REQ-005, REQ-009
|
||||
- [ ] **T-7** Authenticated proxy `getUserAvatar` streaming endpoint + `Content-Type` +
|
||||
`X-Content-Type-Options: nosniff`; test 200 bytes / 404 when no avatar. → REQ-004 (view side)
|
||||
- [ ] **T-A** Run `npm run generate:api` after T-4/T-7 so `avatarUrl` lands in `api.ts`.
|
||||
|
||||
## Frontend
|
||||
|
||||
- [ ] **T-8** i18n keys for the new strings in `messages/{de,en,es}.json` (button labels,
|
||||
validation errors mapped via `getErrorMessage`). → REQ-007, REQ-008 (UX)
|
||||
- [ ] **T-9** Component test `avatar-placeholder.svelte.spec.ts`: failing test asserting
|
||||
initials render when `avatarUrl` is null; implement the placeholder. → REQ-004
|
||||
- [ ] **T-10** `/profile` upload control: file picker, client-side type/size pre-check,
|
||||
instant preview, confirm/remove. States: idle/preview/uploading/error/done. → REQ-002, REQ-003
|
||||
- [ ] **T-11** Render avatar where names appear (comments, activity feed, `/users/[id]`),
|
||||
falling back to the placeholder. → REQ-004
|
||||
- [ ] **T-12** E2E `avatar.spec.ts`: upload → preview → confirm → avatar visible; remove →
|
||||
initials return. → REQ-002, REQ-003, REQ-004
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- [ ] **T-13** Set `spring.servlet.multipart.max-file-size` to a 2 MB-matching ceiling so an
|
||||
oversized body is rejected at the container edge (defense in depth for REQ-008).
|
||||
- [ ] **T-14** Update `.specify/rtm.md` Status column to `Done` per REQ as each test goes green.
|
||||
45
.specify/features/_example/threat-model.md
Normal file
45
.specify/features/_example/threat-model.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Threat Model — Profile picture upload
|
||||
|
||||
**Feature spec:** [./spec.md](./spec.md)
|
||||
**Date:** 2026-06-13
|
||||
**Author:** Security persona (worked example)
|
||||
|
||||
## Data Flow Diagram (text)
|
||||
|
||||
**Actors**
|
||||
- Anonymous visitor (unauthenticated)
|
||||
- Authenticated user (uploads their own avatar)
|
||||
- Admin (`Permission.ADMIN_USER` — may remove others' avatars)
|
||||
|
||||
**Trust boundaries**
|
||||
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
|
||||
- TB-2: Caddy ⇄ Backend `:8080` (DMZ ⇄ app)
|
||||
- TB-3: Backend ⇄ MinIO + PostgreSQL (app ⇄ data plane)
|
||||
|
||||
**Data flows**
|
||||
- F-1: Browser → [TB-1,TB-2] → `UserAvatarController` : multipart image
|
||||
- F-2: `UserService` → [TB-3] → MinIO : object at `avatars/{userId}`
|
||||
- F-3: `UserService` → [TB-3] → PostgreSQL : `app_users.avatar_object_key`
|
||||
- F-4: Browser → [TB-1,TB-2,TB-3] → MinIO (via proxy GET) : image bytes
|
||||
|
||||
## STRIDE
|
||||
|
||||
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| **S**poofing | F-1 | Unauthenticated caller uploads/deletes an avatar | Session auth required; `@RequirePermission` (REQ-006) | Low × Med | Mitigated |
|
||||
| **T**ampering | F-3 | Caller sets `avatarObjectKey` via request body to point at an arbitrary stored object | `avatarObjectKey` is server-set in `UserService` only, never bound from body (CWE-639) | Med × High | Mitigated |
|
||||
| **R**epudiation | F-2/F-3 | No record of who changed an avatar | Standard request logging by user UUID (no PII); admin deletions auditable via existing logs | Low × Low | Accepted |
|
||||
| **I**nformation disclosure | F-4 | A public/signed S3 URL would let anyone fetch any avatar without auth | Avatars served only through the authenticated proxy `GET /api/users/{id}/avatar`; no public URL | Med × Med | Mitigated |
|
||||
| **I**nformation disclosure | F-1 | Malicious file (polyglot) served back with a sniffed content type → stored XSS | Store with a fixed `image/png`/`image/jpeg` content type; proxy sets `Content-Type` + `X-Content-Type-Options: nosniff`; only PNG/JPEG accepted (REQ-007) | Low × High | Mitigated |
|
||||
| **D**enial of service | F-1/F-2 | Oversized or many uploads exhaust storage/memory | 2 MB cap enforced before MinIO write + `multipart.max-file-size` ceiling (REQ-008); deterministic key means one object per user | Med × Med | Mitigated |
|
||||
| **E**levation of privilege | F-1 | Non-admin removes/replaces another user's avatar via `/{id}` | Ownership check; `ADMIN_USER` required for `/{id}` (REQ-005/REQ-009, 403) | Low × Med | Mitigated |
|
||||
|
||||
## ASTRIDE
|
||||
|
||||
Not applicable — this feature invokes no AI agent, model, or tool.
|
||||
|
||||
## Residual Risk
|
||||
|
||||
- **Repudiation (Accepted):** avatar changes are not written to a dedicated audit table.
|
||||
Accepted because the asset is low-value (a self-chosen picture) and request logs already
|
||||
attribute the action to a user UUID. Revisit if avatars ever become trust signals.
|
||||
40
.specify/personas/architect.md
Normal file
40
.specify/personas/architect.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Persona — Architect (spec review)
|
||||
|
||||
> Concise spec-review checklist. Full character persona:
|
||||
> [`.claude/personas/architect.md`](../../.claude/personas/architect.md). This file gates a
|
||||
> `spec.md` and its `design.md`/ADRs for systemic fit and long-term consequence.
|
||||
|
||||
## Role summary
|
||||
|
||||
I check that a feature fits the system's domain boundaries and decision history, and that
|
||||
any irreversible choice it makes is captured in an ADR before code is written. I block specs
|
||||
that quietly contradict an Accepted ADR, blur a domain boundary, or bake in a decision with
|
||||
no recorded rationale.
|
||||
|
||||
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||
|
||||
1. Does the feature respect the package-by-domain structure — new code in the right domain, no logic smeared across layer packages?
|
||||
2. Does it honor the layering rule and the frontend boundary rule, or does it justify and record any new cross-domain edge?
|
||||
3. Does any irreversible or contentious decision (new dependency, new domain, data-model shape, response-as-view vs entity, sync vs async side effect) have an ADR in `Proposed`/`Accepted` status under `docs/adr/`?
|
||||
4. Does the spec contradict any existing Accepted ADR — and if a change is intended, does it **supersede** that ADR rather than silently diverge?
|
||||
5. Is the ADR number the next free one verified against `docs/adr/` on disk?
|
||||
6. Does the design reuse an established pattern (in-transaction views per ADR-036, domain events per ADR-006, DatePrecision sharing per ADR-039/040) instead of a novel mechanism for a solved problem?
|
||||
7. Are domain terms used per [docs/GLOSSARY.md](../../docs/GLOSSARY.md), keeping the ubiquitous language consistent?
|
||||
8. Is the blast radius bounded — does the change avoid forcing edits across unrelated domains, or is the coupling explicitly justified?
|
||||
9. Does the data model choose the right precision/constraint level deliberately (e.g. NOT NULL audit fields, CHECK constraints) rather than by default, and is the choice recorded?
|
||||
10. Does the spec keep `Person`/`AppUser` (and other established separations) distinct?
|
||||
11. Are non-functional consequences (performance of the lazy-fetch path, N+1 risk, index needs) named in `design.md`?
|
||||
12. Does `design.md` list the alternatives considered and why they were rejected, not just the chosen path?
|
||||
|
||||
## EARS patterns to watch for
|
||||
|
||||
- **Ubiquitous** requirements (`The <system> shall <invariant>`) encode architectural invariants — confirm each invariant is enforced at the right layer (DB CHECK, service guard, or type) and not merely asserted in prose.
|
||||
- **Optional-feature** requirements signal a new seam/extension point — verify it does not become an unbounded plugin surface without an ADR.
|
||||
- Watch for requirements that imply a second source of truth for data that already has an owning domain.
|
||||
|
||||
## Output format
|
||||
|
||||
A Gitea comment titled **`### Architect — Spec Review`** with the checklist table
|
||||
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
|
||||
blocking `FAIL` numbers and, for any decision lacking one, the specific ADR that must be
|
||||
written before implementation.
|
||||
39
.specify/personas/developer.md
Normal file
39
.specify/personas/developer.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Persona — Developer (spec review)
|
||||
|
||||
> Concise spec-review checklist. Full character persona:
|
||||
> [`.claude/personas/developer.md`](../../.claude/personas/developer.md). This file gates a
|
||||
> `spec.md` for implementability against the real codebase.
|
||||
|
||||
## Role summary
|
||||
|
||||
I check that a spec can actually be built in *this* codebase without fighting its
|
||||
architecture: that it reuses existing services, layers, and error machinery, and that its
|
||||
requirements decompose cleanly into red/green TDD tasks. I block specs that invent parallel
|
||||
structures or hand-wave the hard integration points.
|
||||
|
||||
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||
|
||||
1. Does the spec reference existing service interfaces (e.g. `DocumentService`, `FileService`, `UserService`) rather than inventing new ones inconsistent with the current layer structure?
|
||||
2. Does it respect the layering rule — no requirement implies a controller touching a repository or a service reaching into another domain's repository?
|
||||
3. If it adds a backend domain, does it commit to adding the package to `ArchitectureTest`'s allow-lists?
|
||||
4. Are new error conditions expressed as named `ErrorCode`s, with the four-site update (`ErrorCode.java`, `errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`) called out as tasks?
|
||||
5. Does every entity/DTO field the spec adds get `@Schema(requiredMode = REQUIRED)` where always-populated, and is `npm run generate:api` listed as a task after backend changes?
|
||||
6. Are frontend changes inside the correct `$lib/<domain>/` boundary, with any cross-domain import either pre-allowed in `eslint.config.js` or flagged for an explicit allow-entry?
|
||||
7. Does each `REQ-NNN` imply a concrete test at the right level (unit / `@WebMvcTest` slice / Playwright E2E per COLLABORATING.md's table) — i.e. is it specified concretely enough to write that test?
|
||||
8. Is lazy-loading handled — does any returned entity with a lazy collection get a view (ADR-036) instead of being serialized raw?
|
||||
9. Does the design avoid premature abstraction (KISS over DRY) — no new base class/util introduced before a third caller exists?
|
||||
10. Are data-model changes expressed as a single forward-only Flyway migration with the next free `V<n>` number verified against disk?
|
||||
11. Does the spec avoid backwards-compat shims for code paths that have no existing callers?
|
||||
12. Are the requirements decomposable into a red/green-ordered task list — each behavior small enough that a failing test can precede its implementation?
|
||||
|
||||
## EARS patterns to watch for
|
||||
|
||||
- **Event-driven** requirements must name the exact endpoint/method so the test target is unambiguous (`When POST /api/users/{id}/avatar receives a valid image, the user service shall …`).
|
||||
- **Unwanted-behavior** requirements are the ones that become `@WebMvcTest` error-path cases — flag any that lack a stated `ErrorCode` and HTTP status.
|
||||
- **Optional-feature** (`Where …`) requirements map to a `@RequirePermission` gate — confirm the permission already exists or is added.
|
||||
|
||||
## Output format
|
||||
|
||||
A Gitea comment titled **`### Developer — Spec Review`** with the checklist table
|
||||
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing the
|
||||
blocking `FAIL` numbers and the single most important integration risk in one sentence.
|
||||
39
.specify/personas/devops.md
Normal file
39
.specify/personas/devops.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Persona — DevOps (spec review)
|
||||
|
||||
> Concise spec-review checklist. Full character persona:
|
||||
> [`.claude/personas/devops.md`](../../.claude/personas/devops.md). This file gates a
|
||||
> `spec.md` for deployability, migration safety, and CI/observability impact.
|
||||
|
||||
## Role summary
|
||||
|
||||
I check that a feature can ship to the self-hosted Gitea-Actions / Docker-Compose
|
||||
environment without breaking deploys, migrations, or observability. I block specs that add
|
||||
a migration with no rollback story, a new env var nobody documented, or a CI step that the
|
||||
act_runner cannot execute.
|
||||
|
||||
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||
|
||||
1. Does the spec include a rollback strategy for any database migration it introduces (forward-only `V<n>` plus the manual DDL to reverse it, or an explicit "no rollback, forward-fix only" statement)?
|
||||
2. Is the Flyway migration number the next free `V<n>` verified against disk, not copied from a stale issue body?
|
||||
3. Are all new configuration values introduced as documented env vars (added to `.env.example`) and read via env, never hard-coded?
|
||||
4. Does any new CI step avoid `actions/(upload|download)-artifact@v4+` and other features the Gitea `act_runner` does not support?
|
||||
5. If the spec adds a CI guard, is it self-testing (the regex proves it catches the bad form and ignores the good form), matching the existing guard style?
|
||||
6. Does the feature keep the management port (`8081`) / app port (`8080`) separation intact, and not require Caddy to proxy `/actuator/*`?
|
||||
7. Are new dependencies pinned, and does the change keep `npm audit --audit-level=high` and Semgrep green?
|
||||
8. Does a new external service or sidecar come with a healthcheck and a documented Compose entry, and is bucket/bootstrap logic idempotent (re-deploy must not fail)?
|
||||
9. Are new metrics/logs/traces routed through the existing observability stack (Prometheus scrape, Promtail/Loki, Tempo, GlitchTip) rather than a new ad-hoc channel?
|
||||
10. Does logging added by the feature stay PII-free and structured (JSON), consistent with the existing log pipeline?
|
||||
11. Is the feature backwards-compatible across a rolling deploy, or does the spec state the required downtime/ordering (migrate-then-deploy)?
|
||||
12. Does the spec avoid committing secrets, and does any composite-action secret flow follow the unquoted-heredoc env convention (ADR-029)?
|
||||
|
||||
## EARS patterns to watch for
|
||||
|
||||
- **State-driven** (`While a migration is in progress, the system shall …`) and **Unwanted-behavior** (`If the OCR service is unavailable, then the system shall return OCR_SERVICE_UNAVAILABLE`) requirements encode operational resilience — flag mutating/processing features that lack them.
|
||||
- **Optional-feature** (`Where the observability stack is enabled …`) requirements gate optional infra — confirm the feature degrades cleanly when it is off.
|
||||
|
||||
## Output format
|
||||
|
||||
A Gitea comment titled **`### DevOps — Spec Review`** with the checklist table
|
||||
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
|
||||
blocking `FAIL` numbers, with the migration/rollback line called out explicitly when
|
||||
relevant.
|
||||
43
.specify/personas/requirements-engineer.md
Normal file
43
.specify/personas/requirements-engineer.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Persona — Requirements Engineer (spec review)
|
||||
|
||||
> Concise spec-review checklist. The full character persona (used for issue/PR review via
|
||||
> the `review-issue` / `review-pr` skills) lives at
|
||||
> [`.claude/personas/req_engineer.md`](../../.claude/personas/req_engineer.md). This file is
|
||||
> scoped to one job: gate a `spec.md` before implementation starts.
|
||||
|
||||
## Role summary
|
||||
|
||||
I own requirement quality: every requirement must be atomic, testable, uniquely identified,
|
||||
and written in EARS so an engineer and an AI agent read it the same way. I block specs that
|
||||
are ambiguous, unmeasurable, or untraceable — vague requirements become vague code.
|
||||
|
||||
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||
|
||||
1. Does every requirement have a unique zero-padded `REQ-NNN` ID, scoped to this feature?
|
||||
2. Is every requirement written in one of the five EARS patterns (no free-prose "shall" sentences)?
|
||||
3. Is each requirement atomic — exactly one testable behavior, no "and"-joined clauses hiding two requirements?
|
||||
4. Does every requirement name a concrete system actor (e.g. `the document service`, `the upload form`) rather than a vague "system"?
|
||||
5. Does each `REQ-NNN` have at least one matching, **measurable** acceptance criterion (numbers/limits, not adjectives like "fast" or "user-friendly")?
|
||||
6. Are all five EARS patterns considered, and is each used where appropriate (not every requirement forced into Ubiquitous)?
|
||||
7. Is there an Unwanted-behavior (`If …`) requirement for every error, limit, and rejected input the happy path implies?
|
||||
8. Does the `## Out of Scope` section explicitly fence off the nearest tempting scope creep?
|
||||
9. Are all `## Open Questions` resolved (or explicitly deferred with an owner) — none left as silent blockers?
|
||||
10. Does the spec link the constitution principle(s) it depends on in `## Context & Why`?
|
||||
11. Is every `REQ-NNN` present in `.specify/rtm.md` with a Feature, Test, and Status column filled (even if Status = Planned)?
|
||||
12. Does the spec reuse existing domain vocabulary from [docs/GLOSSARY.md](../../docs/GLOSSARY.md) (e.g. Person vs AppUser, Chronik vs Aktivität) rather than inventing terms?
|
||||
13. Are the User Journey and E2E Scenarios (per COLLABORATING.md) present and consistent with the EARS requirements?
|
||||
|
||||
## EARS patterns to watch for (common violations)
|
||||
|
||||
- **Ubiquitous** — `The <system> shall <behavior>.` Violation: an invariant written as prose with no "shall".
|
||||
- **Event-driven** — `When <trigger>, the <system> shall <behavior>.` Violation: a trigger described but the response left implicit.
|
||||
- **State-driven** — `While <state>, the <system> shall <behavior>.` Violation: a state precondition buried inside an Event-driven clause.
|
||||
- **Optional-feature** — `Where <feature is present>, the <system> shall <behavior>.` Violation: a permission-/flag-gated behavior written as Ubiquitous, so it appears mandatory.
|
||||
- **Unwanted-behavior** — `If <undesired condition>, then the <system> shall <response>.` Violation: missing entirely — the single most common gap. Every limit and rejected input needs one.
|
||||
|
||||
## Output format
|
||||
|
||||
A Gitea comment titled **`### Requirements Engineer — Spec Review`** containing the
|
||||
checklist as a table `| # | Item | Status | Note |` with `PASS` / `FAIL` / `QUESTION` per
|
||||
row, then a short verdict line: `Verdict: APPROVE` or `Verdict: CHANGES REQUESTED` with the
|
||||
blocking `FAIL` numbers listed.
|
||||
42
.specify/personas/security.md
Normal file
42
.specify/personas/security.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Persona — Security (spec review)
|
||||
|
||||
> Concise spec-review checklist. Full character persona (Nora "NullX" Steiner):
|
||||
> [`.claude/personas/security_expert.md`](../../.claude/personas/security_expert.md). This
|
||||
> file gates a `spec.md` and its `threat-model.md` before implementation.
|
||||
|
||||
## Role summary
|
||||
|
||||
I read every spec adversarially: I assume the requirement will be hit by an unauthenticated
|
||||
attacker, a logged-in user attacking another user's data, and malicious input. I block specs
|
||||
whose mutating endpoints, file handling, or audit trails leave a hole that the happy-path
|
||||
requirements never mention.
|
||||
|
||||
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||
|
||||
1. Are **all** state-mutating endpoints (`POST/PUT/PATCH/DELETE`) covered by an Unwanted-behavior EARS clause for unauthenticated **and** unauthorized access, each naming the `Permission` and the response code?
|
||||
2. Does every mutating endpoint name the `@RequirePermission(Permission.X)` it will carry — and is that permission the least privilege that works?
|
||||
3. Are audit fields (`createdBy`/`updatedBy`) specified as server-set from the session principal, with an explicit requirement forbidding them in the request body (mass-assignment / authorship-forgery, CWE-639)?
|
||||
4. Is every IDOR surface addressed — does fetching/mutating a child resource verify it belongs to the caller's accessible parent (e.g. JourneyItem → Geschichte), with a requirement and a test?
|
||||
5. Is all untrusted text (user input, OCR/import-derived) specified to render via default escaping, never `{@html}` (CWE-79)?
|
||||
6. For file uploads: are content-type allow-list, size limit, and magic-byte/extension validation specified as requirements with concrete numbers and an `ErrorCode`?
|
||||
7. Does the spec avoid leaking entity internals (email, password hash, group graph) in any response — i.e. does it use a view, not a raw `AppUser`/entity?
|
||||
8. Are concurrency conflicts (optimistic locking) specified to surface as `conflict()` (409), never a raw 500 exposing Hibernate internals (CWE-209)?
|
||||
9. Does the `threat-model.md` exist and cover the relevant STRIDE categories for each new data flow and trust boundary?
|
||||
10. If the feature invokes an AI agent/tool (OCR/NLP/LLM), does the threat model cover the ASTRIDE extensions (prompt injection, context poisoning, unsafe tool invocation, reasoning subversion)?
|
||||
11. Are secrets (tokens, DSNs, passwords) sourced only from env vars, with none introduced into the repo, config, or logs?
|
||||
12. Does logging for this feature exclude PII beyond a stable UUID (no names, emails, document/transcription content)?
|
||||
13. Does a new runtime dependency (if any) have an ADR and a clean `npm audit` / Semgrep status?
|
||||
|
||||
## EARS patterns to watch for
|
||||
|
||||
- The **Unwanted-behavior** pattern (`If <attacker condition>, then the <system> shall <safe response>`) is *the* security pattern. Every auth, authz, validation, and limit case must appear as one. A spec with zero `If` requirements on a mutating endpoint is an automatic `FAIL`.
|
||||
- **Optional-feature** (`Where the caller has Permission.X …`) requirements encode the authorization model — verify the gate is on the *write*, not just the read.
|
||||
- Watch for **Ubiquitous** requirements that quietly assume trust ("The system shall store the uploaded file") with no companion `If` clause validating it first.
|
||||
|
||||
## Output format
|
||||
|
||||
A Gitea comment titled **`### Security — Spec Review`** with the checklist table
|
||||
`| # | Item | Status | Note |`, each `FAIL` tagged with its CWE where applicable, then
|
||||
`Verdict: APPROVE` / `CHANGES REQUESTED` listing blocking `FAIL` numbers. Security `FAIL`s
|
||||
are hard blockers — a spec does not proceed until each is resolved or risk-accepted in the
|
||||
threat model.
|
||||
39
.specify/personas/ui-ux.md
Normal file
39
.specify/personas/ui-ux.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Persona — UI/UX (spec review)
|
||||
|
||||
> Concise spec-review checklist. Full character persona:
|
||||
> [`.claude/personas/ui_expert.md`](../../.claude/personas/ui_expert.md). This file gates a
|
||||
> `spec.md` for user-facing features against the project's design system and audience split.
|
||||
|
||||
## Role summary
|
||||
|
||||
I check that a user-facing feature is usable by *this* audience — older transcribers on
|
||||
laptops/tablets and younger readers on phones — and that it uses the established design
|
||||
tokens, components, and i18n rather than reinventing them. I block specs whose UI is
|
||||
described in adjectives instead of states, or that ignore accessibility and responsiveness.
|
||||
|
||||
## Review checklist (PASS / FAIL / QUESTION per item)
|
||||
|
||||
1. Does the spec describe every interaction **state** (loading, empty, error, success, disabled), not just the happy path?
|
||||
2. Are user-facing strings specified to go through Paraglide i18n with keys added to `messages/{de,en,es}.json` — no hard-coded German/English literals?
|
||||
3. Does it reuse the established component library and patterns (`BackButton`, the card pattern, `brand-navy`/`brand-mint` tokens, `font-serif`/`font-sans`) rather than introducing new one-off styles?
|
||||
4. Is the responsive behavior specified per the device split — Critical for the reader/phone path, at least Minor for the author/laptop path — with concrete breakpoints, not "responsive"?
|
||||
5. Are error states mapped to `getErrorMessage(code)` output so the user sees a localized message, never a raw code or stack?
|
||||
6. Is every interactive element keyboard-reachable and screen-reader-labeled (the project runs `@axe-core/playwright`)?
|
||||
7. Are acceptance criteria measurable (e.g. "image preview appears within 1 of selection", "tap target ≥ 44px"), not adjectival ("looks clean")?
|
||||
8. Does the spec define an E2E Playwright scenario (per COLLABORATING.md) for each primary user journey step?
|
||||
9. For destructive or irreversible actions, is a confirmation/undo affordance specified?
|
||||
10. Does any uploaded/derived content render through default escaping (no `{@html}`), and are images given alt text / dimensions to avoid layout shift?
|
||||
11. Does the feature respect existing navigation (live DOM nav, real routes — verify route names against the running app, since CLAUDE.md route lists can be stale)?
|
||||
12. Is dark-mode / token theming respected (uses semantic tokens like `bg-surface`/`text-ink-3`, not raw palette constants)?
|
||||
|
||||
## EARS patterns to watch for
|
||||
|
||||
- **State-driven** (`While the upload is in progress, the upload form shall show a progress indicator`) requirements capture UI states — a UI spec with no `While` requirements usually means the loading/disabled states were forgotten.
|
||||
- **Event-driven** (`When the user selects an image, the form shall render a preview`) requirements map directly to Playwright steps — confirm each has a measurable acceptance criterion.
|
||||
- **Unwanted-behavior** (`If the selected file exceeds the size limit, then the form shall show a localized error and not upload`) requirements cover client-side validation feedback.
|
||||
|
||||
## Output format
|
||||
|
||||
A Gitea comment titled **`### UI/UX — Spec Review`** with the checklist table
|
||||
`| # | Item | Status | Note |`, then `Verdict: APPROVE` / `CHANGES REQUESTED` listing
|
||||
blocking `FAIL` numbers and the single biggest usability/accessibility gap in one sentence.
|
||||
219
.specify/rtm.md
Normal file
219
.specify/rtm.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 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 |
|
||||
| REQ-001 | TimelineFilters is presentation-only (3 $bindable layer booleans + onChange); no goto/url.searchParams/api.GET/fetch | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#renders the three layer toggles with accessible names`, `#reflects a layer as pressed and flips it, firing onChange`; `timelineFilterBoundary.spec.ts` | Done |
|
||||
| REQ-002 | route derives a client-side $derived filtered view, passes it to TimelineView; no goto/fetch on toggle | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#hides letter cards when the Letters layer is off ... with no fetch`; `timelineFilterBoundary.spec.ts` | Done |
|
||||
| REQ-003 | Personal off → personal events (curated + derived life-events) hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides personal events — curated and derived`, `page.svelte.spec.ts#hides personal event cards` | Done |
|
||||
| REQ-004 | Historical off → historical event entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides HISTORICAL events`, `page.svelte.spec.ts#hides historical event cards` | Done |
|
||||
| REQ-005 | Letters off → letter entries hidden client-side | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `timelineFilter.spec.ts#hides LETTER entries`, `page.svelte.spec.ts#hides letter cards` | Done |
|
||||
| REQ-006 | zero visible → filter empty-state + one-click reset below the open bar (never blank, never generic empty) | #780 | timeline-layer-filter | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/timelineFilter.ts#filterTimeline` | `page.svelte.spec.ts#shows the filtered-empty message + reset below the open bar`, `timelineFilter.spec.ts#drops year bands that become empty` | Done |
|
||||
| REQ-007 | sticky "Filter (N aktiv)" trigger; N from isDefaultState/hiddenLayerCount; 44px target | #780 | timeline-layer-filter | `frontend/src/lib/timeline/timelineFilter.ts`, `frontend/src/lib/timeline/TimelineFilters.svelte` | `timelineFilter.spec.ts#isDefaultState`, `#hiddenLayerCount`; `TimelineFilters.svelte.spec.ts#shows a plain trigger ... and a count`, `#gives the trigger a 44px touch target` | Done |
|
||||
| REQ-008 | reset text button restores all layers on; visibility tracks a $derived any-layer-off flag | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts#hides the reset button by default and restores all layers when activated` | Done |
|
||||
| REQ-009 | prefers-reduced-motion → slide duration 0 (matchMedia guard reused from documents/[id]/+page.svelte:57) | #780 | timeline-layer-filter | `frontend/src/lib/timeline/TimelineFilters.svelte` | manual reduced-motion check + svelte-autofixer/code review (guard reused, only the slide `duration` zeroed) | Done |
|
||||
| REQ-010 | 8 timeline_filter_* keys in de/en/es; trigger vs trigger_active({count}) distinct | #780 | timeline-layer-filter | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl layer-filter keys are present in all locales (#780 REQ-010)` | Done |
|
||||
| REQ-001 | WRITE_ALL viewer → "Ereignis hinzufügen" link to /zeitstrahl/events/new in the wrapping Zeitstrahl header | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/messages/{de,en,es}.json` | `zeitstrahl/page.svelte.spec.ts#renders the add-event CTA in a wrapping header when the viewer can write` | Done |
|
||||
| REQ-002 | viewer without WRITE_ALL → no add-event affordance on /zeitstrahl | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#renders no add-event CTA when the viewer cannot write` | Done |
|
||||
| REQ-003 | WRITE_ALL viewer → person-page "Ereignis für diese Person" link to /zeitstrahl/events/new?personId={id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/messages/{de,en,es}.json` | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` | Done |
|
||||
| REQ-004 | viewer without WRITE_ALL → no add-event affordance on /persons/{id} | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte` | `PersonCard.svelte.spec.ts#renders no add-event link to a reader` | Done |
|
||||
| REQ-005 | WRITE_ALL → EventPill edit link /zeitstrahl/events/{eventId}/edit for a curated PERSONAL event | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#shows an edit affordance for a curated PERSONAL event when canWrite is true` | Done |
|
||||
| REQ-006 | WRITE_ALL → WorldBand edit link /zeitstrahl/events/{eventId}/edit for a curated HISTORICAL event (new inline ✎) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#shows an edit affordance for a curated HISTORICAL event when canWrite is true`, `#mirrors the EventPill pencil` | Done |
|
||||
| REQ-007 | viewer without WRITE_ALL → neither EventPill nor WorldBand renders an edit link | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#renders no edit affordance for a curated PERSONAL event when canWrite is false`, `WorldBand.svelte.spec.ts#renders no edit affordance for a curated HISTORICAL event when canWrite is false`, `TimelineView.svelte.spec.ts#renders no edit links in either path when canWrite is false` | Done |
|
||||
| REQ-008 | derived OR null eventId → no edit link regardless of permission (contract preserved) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `EventPill.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite`, `#shows no edit affordance for a derived event even with canWrite`, `WorldBand.svelte.spec.ts#shows no edit affordance when eventId is null even with canWrite` | Done |
|
||||
| REQ-009 | TimelineView threads canWrite through the year-band path and the undated bucket (identical gate) | #842 | timeline-curator-affordances | `frontend/src/lib/timeline/TimelineView.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `TimelineView.svelte.spec.ts#threads canWrite to a curated event in both a year band and the undated bucket`, `#threads canWrite to a curated HISTORICAL world band in both paths` | Done |
|
||||
| REQ-010 | person add-event opens #781 create form prefilled with the person, returns to /persons/{id} on save | #842 | timeline-curator-affordances | `frontend/src/routes/persons/[id]/PersonCard.svelte`, `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (#781) | `PersonCard.svelte.spec.ts#shows an add-event link pre-seeded with the person to a curator` (URL), `zeitstrahl/events/new/page.server.spec.ts#redirects to /persons/{id} when originPersonId is a valid UUID` (#781) | Done |
|
||||
| REQ-011 | non-curator direct nav to /zeitstrahl/events/new or /{id}/edit → 403 (existing #781 route guard, regression) | #842 | timeline-curator-affordances | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (#781, unchanged — both share `requireWriteAll`) | `zeitstrahl/events/new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `zeitstrahl/events/[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
|
||||
| REQ-001 | `/zeitstrahl` renders a single chronological timeline with no grouping-mode control (no toggle/Thema/drawer) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/TimelineView.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`; absence of `GroupingControl`/`LetterBucket`/`BucketHeaderChip` (never ported) | Done |
|
||||
| REQ-002 | curated event with ≥1 same-year linked letter → contained card (event header + glyph/title/date·provenance/edit + compact letter cards) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `EventCluster.svelte.spec.ts#renders a data-testid event-card with the event title once`, `#shows the event-edit link for a curator`, `#renders its letters as compact a.lcard.ev cards`; `YearBand.svelte.spec.ts#renders a curated event with a same-year linked letter as one event-card, title once, no separate pill` | Done |
|
||||
| REQ-003 | event card body > 5 letters → first 5 + keyboard-operable show-more/less toggle (aria-expanded, ≥44px) | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/eventClustering.ts` (`CLUSTER_PREVIEW=5`) | `EventCluster.svelte.spec.ts#shows the first 5 of 8 letters + a show-more toggle that expands to 8 then back to 5`, `#renders no show-more toggle when the cluster holds 5 or fewer letters` | Done |
|
||||
| REQ-004 | linked letters in a year other than the event's band → labeled ✉ text-header card (no pill, no edit link) per such year | #850 | inline-event-clustering | `frontend/src/lib/timeline/EventCluster.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `EventCluster.svelte.spec.ts#renders a cross-year text header (✉ title, no event-edit, no pill) when no event is given`; `YearBand.svelte.spec.ts#renders a cross-year cluster (event not in this band) as a ✉ text-header card holding it` | Done |
|
||||
| REQ-005 | event/derived/world-band with no linked letters → existing plain pill/world-band (unchanged) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `YearBand.svelte.spec.ts#renders a curated event with NO linked letters as a plain EventPill, no card` | Done |
|
||||
| REQ-006 | letter linked to no curated event → loose chronological letter (alternating; density strip past 12) | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#keeps a letter with no linkedEventId loose`; `YearBand.svelte.spec.ts#renders loose (unlinked) letters in a sparse year as alternating .letter-row, no card` | Done |
|
||||
| REQ-007 | loose-letter layout + density strip count ONLY non-event-linked letters; clustered letter never also loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte`, `frontend/src/lib/timeline/eventClustering.ts` | `eventClustering.spec.ts#places each letter in exactly one place`; `YearBand.svelte.spec.ts#counts only loose letters in the density strip; event letters stay in the card` | Done |
|
||||
| REQ-008 | letter whose only linking event is filtered out (absent from lookup) → loose, never re-introduces the event (filter-then-cluster) | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`splitYearLetters` filters via `eventLookup`) | `eventClustering.spec.ts#keeps a letter whose linkedEventId is absent from the lookup loose (filter-then-cluster, REQ-008)` | Done |
|
||||
| REQ-009 | `TimelineEntryDTO` carries nullable `linkedEventId` for LETTER entries, one batched pass, no new column/migration, not @Schema(REQUIRED); a multi-event letter links deterministically (earliest date, then id) | #850 | inline-event-clustering | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#resolveLetterEventLinks`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#letter_in_a_curated_events_documents_carries_that_events_id`, `#letter_in_no_curated_event_has_null_linkedEventId`, `#multi_event_letter_links_deterministically_to_the_earliest_event` | Done |
|
||||
| REQ-010 | event/letter/sender/receiver text via Svelte `{...}` escaping; no `{@html}` anywhere in `lib/timeline/` (grep gate, CWE-79) | #850 | inline-event-clustering | `frontend/src/lib/timeline/*.svelte` | `timeline-no-raw-html.spec.ts#no timeline component contains the raw-HTML directive`; `EventCluster.svelte.spec.ts#renders an HTML-bearing event title verbatim as text, never as markup` | Done |
|
||||
| REQ-011 | wrapping header keeps #842 add-event CTA + #780 filter trigger; meta-line drops the grouping segment | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` | `page.svelte.spec.ts#renders the meta sub-line with range and counts, no grouping segment`, `#drops the letters segment instead of showing "0 Briefe"` (no "Gruppierung") | Done |
|
||||
| REQ-012 | show-more/less labels are new Paraglide keys in de/en/es; unused #827 grouping/Thema keys removed | #850 | inline-event-clustering | `frontend/messages/{de,en,es}.json`, `frontend/src/lib/timeline/EventCluster.svelte` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales` (now asserts `timeline_bucket_show_more`/`_less`; `timeline_grouping_date` removed) | Done |
|
||||
| REQ-013 | `GET /api/timeline` failure → existing localized error state via `getErrorMessage(code)` (unchanged #779) | #850 | inline-event-clustering | `frontend/src/routes/zeitstrahl/+page.svelte` (unchanged error path) | covered by #779 `zeitstrahl` error-state tests (regression — no change) | Done |
|
||||
| REQ-014 | HISTORICAL curated event with ≥1 linked letter keeps its full-width WorldBand — never clusters into a card (preserves #779 REQ-009); its letters stay loose | #850 | inline-event-clustering | `frontend/src/lib/timeline/eventClustering.ts` (`buildEventLookup` excludes HISTORICAL) | `eventClustering.spec.ts#excludes a HISTORICAL event so its letters stay loose, keeping its WorldBand (REQ-014)`; `TimelineView.svelte.spec.ts#keeps a HISTORICAL event a WorldBand with a same-year linked letter — never clusters (REQ-014)` | Done |
|
||||
| REQ-015 | a cross-year ✉ card is placed at its earliest linked letter's chronological position in the band — never appended after later-dated loose letters | #850 | inline-event-clustering | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#interleaves a cross-year card before a later-dated loose letter in the same band (REQ-015)` | Done |
|
||||
| REQ-001 | `TimelineEvent.description` flows through `TimelineEntryDTO` to the frontend; null for letters and derived events | #844 | event-note-display | `backend/.../timeline/TimelineEntryDTO.java`, `backend/.../timeline/TimelineService.java#mapEvent`, `frontend/src/lib/generated/api.ts` | `TimelineServiceTest#mapEvent_populates_description_from_event`, `#mapEvent_leaves_description_null_when_event_has_none`, `#mapDocument_leaves_description_null_for_letter`; `TimelineControllerTest#timelineIncludesEventDescription` | Done |
|
||||
| REQ-002 | description text is HTML-escaped; no `{@html}` — Svelte `{...}` interpolation ensures XSS safety (CWE-79) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#escapesHtml — renders XSS payload as inert text, no injected element` | Done |
|
||||
| REQ-003 | newlines in the description are preserved visually via `white-space: pre-line` | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#preservesLineBreaks — note element carries whitespace-pre-line class` | Done |
|
||||
| REQ-004 | description renders below the title/subtitle line in EventPill (PERSONAL) and WorldBand (HISTORICAL) | #844 | event-note-display | `frontend/src/lib/timeline/EventPill.svelte`, `frontend/src/lib/timeline/WorldBand.svelte` | `e2e/zeitstrahl-note.spec.ts#PERSONAL curated event note appears below its title`, `#HISTORICAL curated event note appears below its title` | Done |
|
||||
| REQ-005 | description longer than 3 lines is clamped and shows a disclosure toggle with aria-expanded=false (show more) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#clampsAndShowsToggle — long note shows "mehr anzeigen" with aria-expanded=false` | Done |
|
||||
| REQ-006 | short description (≤ 3 lines) renders fully with no toggle | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#shortNoteNoToggle — a one-line note renders fully with no disclosure control` | Done |
|
||||
| REQ-007 | clicking the toggle expands the note (aria-expanded=true, "show less"); clicking again collapses it | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#toggleExpandsCollapses — click expands, re-click collapses` | Done |
|
||||
| REQ-008 | null, empty, or blank-only description renders nothing (no note element in DOM) | #844 | event-note-display | `frontend/src/lib/timeline/EventNote.svelte` | `event-note.svelte.spec.ts#blankNoteRendersNothing — null/empty string/blank-only string produces no note element` (3 cases) | Done |
|
||||
42
.specify/templates/adr.md
Normal file
42
.specify/templates/adr.md
Normal file
@@ -0,0 +1,42 @@
|
||||
<!--
|
||||
ADR template. ADRs live in the existing archive: docs/adr/NNN-kebab-title.md.
|
||||
Verify the next free NNN against `ls docs/adr/` on disk (parallel worktrees make
|
||||
issue-body numbers stale). An ADR is IMMUTABLE once Status = Accepted — to change a
|
||||
decision, write a NEW higher-numbered ADR and set this one's Status to Superseded.
|
||||
This header mirrors the existing archive style (see docs/adr/040-*.md). Delete this comment.
|
||||
-->
|
||||
|
||||
# ADR-NNN — <Short decision title>
|
||||
|
||||
**Status:** Proposed <!-- Proposed | Accepted | Deprecated | Superseded by ADR-MMM -->
|
||||
**Date:** <YYYY-MM-DD>
|
||||
**Issue:** #<n> <!-- the Gitea issue / feature this decision serves -->
|
||||
|
||||
## Context
|
||||
|
||||
<The forces at play: what problem demands a decision now, the constraints from the
|
||||
constitution and existing ADRs, and why the status quo is insufficient. State facts, not
|
||||
the chosen answer.>
|
||||
|
||||
## Decision
|
||||
|
||||
<The decision, stated in active voice as something the project now does. Number sub-decisions
|
||||
(### 1, ### 2, …) if the ADR commits several related choices, matching the existing archive.>
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Option | Pros | Cons | Reason rejected |
|
||||
|---|---|---|---|
|
||||
| <chosen — name it> | <pros> | <cons> | **Chosen** |
|
||||
| <alternative A> | <pros> | <cons> | <why not> |
|
||||
| <alternative B> | <pros> | <cons> | <why not> |
|
||||
|
||||
## Consequences
|
||||
|
||||
<What becomes easier and what becomes harder. Include the obligations this decision places
|
||||
on future work (migrations forward-only, tests that must exist, guards that must hold), and
|
||||
any new coupling introduced.>
|
||||
|
||||
## References
|
||||
|
||||
- <constitution §, related ADRs, issue links, external docs>
|
||||
97
.specify/templates/api-contract-stub.md
Normal file
97
.specify/templates/api-contract-stub.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# API Contract Stub
|
||||
|
||||
This project is **REST + OpenAPI**. The backend serves the live spec via springdoc at
|
||||
`http://localhost:8080/v3/api-docs` (dev profile only), and the frontend generates its
|
||||
TypeScript client from it with `npm run generate:api` (`openapi-typescript` →
|
||||
`frontend/src/lib/generated/api.ts`). There is no GraphQL in this stack.
|
||||
|
||||
> **The live spec is generated from the Java controllers — it is the source of truth.** A
|
||||
> hand-written stub is a *design artifact*: it pins the intended shape during spec review.
|
||||
> Issue-only: paste the stub inline into the issue's `## API / Contract Stub` section. Keep it
|
||||
> OpenAPI **3.1**, and keep `@Schema(requiredMode = REQUIRED)` on the Java side as the real
|
||||
> driver of `required`.
|
||||
|
||||
## How to use this stub
|
||||
|
||||
1. Fill in the skeleton below with the paths/methods/schemas your feature adds, and paste it
|
||||
into the issue's `## API / Contract Stub` section.
|
||||
2. Every mutating path documents the `403`/`401` responses and the `cookieAuth` security
|
||||
requirement (matching the real `@RequirePermission` gate).
|
||||
3. If you prefer a standalone, lintable file (e.g. for a large contract), commit it on the
|
||||
**feature branch** as `<feature>.openapi.yaml` — the `sdd-gate.yml` CI job lints any
|
||||
committed OpenAPI contract with Spectral (`npx @stoplight/spectral-cli lint`). It never
|
||||
needs to predate the issue.
|
||||
4. After the endpoint ships, run `npm run generate:api` and diff the generated types against
|
||||
this contract; reconcile any drift (the generated spec wins — update the contract).
|
||||
|
||||
## OpenAPI 3.1 skeleton
|
||||
|
||||
```yaml
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Familienarchiv API — <feature name>
|
||||
version: 0.0.1-SNAPSHOT
|
||||
description: Design-time contract for <feature>. Source of truth is the generated /v3/api-docs.
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
description: Local backend (dev profile)
|
||||
- url: https://archiv.raddatz.cloud
|
||||
description: Production (behind Caddy)
|
||||
components:
|
||||
securitySchemes:
|
||||
cookieAuth: # Spring Session JDBC — opaque session id in the SESSION cookie
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: SESSION
|
||||
schemas:
|
||||
ErrorResponse: # shape produced by GlobalExceptionHandler
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable ErrorCode (see ErrorCode.java / errors.ts).
|
||||
example: FORBIDDEN
|
||||
message:
|
||||
type: string
|
||||
# <YourResponseView>: # always a view, never a lazy-collection entity (ADR-036)
|
||||
# type: object
|
||||
# required: [id]
|
||||
# properties:
|
||||
# id: { type: string, format: uuid }
|
||||
security:
|
||||
- cookieAuth: [] # default: every path requires a session unless overridden to []
|
||||
paths:
|
||||
/api/<resource>:
|
||||
post:
|
||||
summary: <create …>
|
||||
operationId: <createResource>
|
||||
security:
|
||||
- cookieAuth: [] # plus @RequirePermission(Permission.X) on the controller
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/<CreateDTO>' }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/<YourResponseView>' }
|
||||
'400': { description: Validation failed, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||
'401': { description: Unauthenticated, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||
'403': { description: Missing permission, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
|
||||
```
|
||||
|
||||
## Validating the contract in CI
|
||||
|
||||
The `sdd-gate.yml` `contract-validate` job lints any committed OpenAPI file changed in the PR:
|
||||
|
||||
```bash
|
||||
npx @stoplight/spectral-cli lint <your-contract>.yaml
|
||||
```
|
||||
|
||||
The ruleset is `.spectral.yaml` at the repo root (extends `spectral:oas`; documentation-only
|
||||
warnings relaxed for design-time stubs). Spectral auto-discovers it. It catches malformed
|
||||
specs, undefined `$ref`s, and duplicate `operationId`s; tune `.spectral.yaml` to adjust.
|
||||
89
.specify/templates/feature-spec.md
Normal file
89
.specify/templates/feature-spec.md
Normal file
@@ -0,0 +1,89 @@
|
||||
<!--
|
||||
Feature Spec template — paste this into the Gitea issue body (issue-only: this IS the spec;
|
||||
there is no committed spec.md). The .gitea/ISSUE_TEMPLATE/feature.md mirror gives the same
|
||||
structure with the right labels. Replace every <placeholder>. Delete this comment before submitting.
|
||||
EARS = Easy Approach to Requirements Syntax. Every requirement uses one of the five patterns
|
||||
shown in ## Requirements and carries a unique REQ-NNN id (three-digit, scoped to THIS feature).
|
||||
Use plain code-path references (not relative markdown links) — links don't resolve inside a Gitea issue.
|
||||
-->
|
||||
|
||||
# <Feature title — match the Gitea issue: "As a <role> I want <capability> so <reason>">
|
||||
|
||||
## Context & Why
|
||||
|
||||
<Business motivation in 2–4 sentences: who needs this and why now.>
|
||||
|
||||
Constitution principles this feature depends on (see `.specify/constitution.md`):
|
||||
- §<n> <principle name> — <why it applies>
|
||||
|
||||
Related: <links to prior issues / ADRs>.
|
||||
|
||||
## User Journey
|
||||
|
||||
<Plain-prose steps the user takes to get value, from the user's perspective — per COLLABORATING.md. Anything not in this journey is out of scope.>
|
||||
|
||||
## Requirements
|
||||
|
||||
> One requirement per line, each with a `REQ-NNN` id and one EARS pattern. Include the
|
||||
> patterns the feature actually needs — do not force all five, but a mutating feature almost
|
||||
> always needs at least one Event-driven and one Unwanted-behavior requirement.
|
||||
|
||||
- **REQ-001** (Ubiquitous) — The `<system component>` shall `<always-true behavior>`.
|
||||
- **REQ-002** (Event-driven) — When `<trigger / endpoint receives X>`, the `<system component>` shall `<response>`.
|
||||
- **REQ-003** (State-driven) — While `<system is in state X>`, the `<system component>` shall `<behavior>`.
|
||||
- **REQ-004** (Optional-feature) — Where `<the caller has Permission.X / a feature flag is set>`, the `<system component>` shall `<behavior>`.
|
||||
- **REQ-005** (Unwanted-behavior) — If `<undesired condition, e.g. caller is unauthenticated / input invalid>`, then the `<system component>` shall `<safe response, e.g. return 401 / ErrorCode.X>`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
> One measurable criterion per REQ-NNN. Numbers, limits, status codes — never adjectives.
|
||||
|
||||
- **REQ-001** — <measurable, e.g. "the response always includes a non-null `id` (UUID)">.
|
||||
- **REQ-002** — <measurable, e.g. "POST returns 201 and the persisted row within the same request">.
|
||||
- **REQ-003** — <measurable>.
|
||||
- **REQ-004** — <measurable, e.g. "a caller without Permission.X receives 403 with ErrorCode.FORBIDDEN">.
|
||||
- **REQ-005** — <measurable, e.g. "an unauthenticated request receives 401 and nothing is persisted">.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- <Explicit boundary statement — the nearest tempting scope creep, named and excluded.>
|
||||
- <…>
|
||||
|
||||
## API / Contract Stub
|
||||
|
||||
<Inline OpenAPI stub. Name the new/changed paths, methods, request/response shapes, status codes, and `@RequirePermission`. Use the `.specify/templates/api-contract-stub.md` skeleton as a writing aid.>
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
<Entity/schema delta: new tables/columns, constraints, the next free Flyway `V<n>`, and the rollback note. Write "none" if not applicable.>
|
||||
|
||||
## Security Considerations
|
||||
|
||||
<STRIDE categories touched (Spoofing/Tampering/Repudiation/Information disclosure/DoS/Elevation). For AI-agent/tool features, also ASTRIDE. Include an inline STRIDE table (use `.specify/templates/threat-model.md`) if the feature has a non-trivial attack surface.>
|
||||
|
||||
## Open Questions
|
||||
|
||||
> Each item is a BLOCKER until resolved. Empty this list before implementation starts.
|
||||
|
||||
- [ ] <question> — owner: <name>
|
||||
- [ ] <question> — owner: <name>
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Task ID(s) | Test ID(s) | Status |
|
||||
|---|---|---|---|
|
||||
| REQ-001 | <T-1> | <test name> | Planned |
|
||||
| REQ-002 | <T-2> | <test name> | Planned |
|
||||
|
||||
<After approval, add one committed row per REQ-NNN to `.specify/rtm.md` with this issue's number. Fill Task/Test IDs as work progresses.>
|
||||
|
||||
## Persona Review Results
|
||||
|
||||
| Persona | Status | Key Findings | Resolved |
|
||||
|---|---|---|---|
|
||||
| Requirements Engineer | PENDING | | |
|
||||
| Developer | PENDING | | |
|
||||
| Security | PENDING | | |
|
||||
| DevOps | PENDING | | |
|
||||
| UI/UX | PENDING | | |
|
||||
| Architect | PENDING | | |
|
||||
53
.specify/templates/threat-model.md
Normal file
53
.specify/templates/threat-model.md
Normal file
@@ -0,0 +1,53 @@
|
||||
<!--
|
||||
Threat model template — STRIDE + ASTRIDE. WRITING AID: fill this in and paste the result into
|
||||
the issue's "## Security Considerations" section (issue-only — the threat model lives in the
|
||||
issue body, not a committed file). Required when a feature adds a new trust boundary, handles
|
||||
uploads, exposes a new mutating endpoint, or invokes an AI agent/tool. The Security persona
|
||||
gates it during /review-issue. Delete this comment.
|
||||
-->
|
||||
|
||||
# Threat Model — <Feature name>
|
||||
|
||||
**Feature spec:** Gitea issue #<n>
|
||||
**Date:** <YYYY-MM-DD>
|
||||
**Author:** <name>
|
||||
|
||||
## Data Flow Diagram (text)
|
||||
|
||||
**Actors**
|
||||
- <e.g. Anonymous visitor, Authenticated reader, Authenticated transcriber, Admin, OCR sidecar>
|
||||
|
||||
**Trust boundaries**
|
||||
- TB-1: Browser ⇄ Caddy (public internet ⇄ DMZ)
|
||||
- TB-2: Caddy ⇄ Backend (`:8080`) (DMZ ⇄ app)
|
||||
- TB-3: Backend ⇄ PostgreSQL / MinIO / sidecars (app ⇄ data plane)
|
||||
- <add feature-specific boundaries>
|
||||
|
||||
**Data flows** (source → [boundary] → sink : data)
|
||||
- F-1: Browser → [TB-1,TB-2] → Backend : <request payload>
|
||||
- F-2: Backend → [TB-3] → MinIO : <stored object>
|
||||
- <…>
|
||||
|
||||
## STRIDE
|
||||
|
||||
| Threat Category | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| **S**poofing | <asset> | <e.g. unauthenticated caller forges a request> | <session auth + @RequirePermission> | Low × High | <Open/Mitigated/Accepted> |
|
||||
| **T**ampering | <asset> | <e.g. mass-assignment of createdBy> | <server-set audit fields, no body binding> | Med × High | |
|
||||
| **R**epudiation | <asset> | <e.g. no record of who changed what> | <NOT NULL createdBy/updatedBy audit trail> | Low × Med | |
|
||||
| **I**nformation disclosure | <asset> | <e.g. entity leaks email/hash; raw 500 leaks Hibernate internals> | <view not entity; DomainException.conflict> | Med × High | |
|
||||
| **D**enial of service | <asset> | <e.g. oversized upload / unbounded list> | <size limit, batch cap, pagination> | Med × Med | |
|
||||
| **E**levation of privilege | <asset> | <e.g. reader reaches a write endpoint / IDOR> | <least-privilege Permission, ownership check> | Low × High | |
|
||||
|
||||
## ASTRIDE (only if the feature invokes an AI agent / tool — OCR, NLP, LLM)
|
||||
|
||||
| Threat | Asset / Flow | Threat Description | Mitigation | Likelihood × Impact | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| Prompt Injection | <input to the model> | <untrusted document text steers the model> | <treat model output as untrusted; no auto-exec> | | |
|
||||
| Context Poisoning | <retrieved/shared context> | <attacker plants data that biases later runs> | <scope/provenance of context; validation> | | |
|
||||
| Unsafe Tool Invocation | <tool the agent can call> | <model triggers a privileged action> | <allow-list tools; human-in-loop on mutations> | | |
|
||||
| Reasoning Subversion | <decision the model makes> | <crafted input flips a classification/decision> | <confidence threshold; deterministic guardrail> | | |
|
||||
|
||||
## Residual Risk
|
||||
|
||||
<Threats marked Accepted, who accepted them, and why the residual risk is tolerable.>
|
||||
15
.spectral.yaml
Normal file
15
.spectral.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Spectral ruleset for OpenAPI contract linting (SDD api-contract files).
|
||||
# Spectral v6 ships no implicit ruleset — this enables the built-in OpenAPI rules.
|
||||
# Used by .gitea/workflows/sdd-gate.yml (contract-validate) and locally:
|
||||
# npx @stoplight/spectral-cli lint <contract>.yaml
|
||||
extends: ["spectral:oas"]
|
||||
|
||||
rules:
|
||||
# Design-time SDD stubs are not full published API docs — relax the documentation-completeness
|
||||
# warnings that would otherwise fire on a focused contract. The structural/correctness rules
|
||||
# (oas3-schema, valid $refs, duplicate operationId, etc.) stay on.
|
||||
info-contact: off
|
||||
info-description: off
|
||||
operation-description: off
|
||||
operation-tag-defined: off
|
||||
oas3-unused-component: off
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -16,6 +16,10 @@ See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking wo
|
||||
|
||||
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
|
||||
|
||||
## 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
|
||||
@@ -95,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ PersonRelationship sub-domain
|
||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ Tag domain
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
|
||||
└── user/ User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -117,6 +121,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
|
||||
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -165,7 +170,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -202,6 +207,8 @@ frontend/src/routes/
|
||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── 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
|
||||
├── enrich/ Enrichment workflow — [id], done
|
||||
├── admin/ User, group, tag, OCR, system management
|
||||
@@ -273,7 +280,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ 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).
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ Evaluate all suggestions on their technical merits. No sycophancy — if somethi
|
||||
|
||||
## 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:
|
||||
|
||||
1. **Research** — Read the relevant code. Understand existing patterns before touching anything.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Contributing to Familienarchiv
|
||||
|
||||
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 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).
|
||||
|
||||
235
SPEC_DRIVEN_DEVELOPMENT.md
Normal file
235
SPEC_DRIVEN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Spec-Driven Development (SDD)
|
||||
|
||||
How we turn a feature idea into merged, traceable code in this repo. SDD layers a uniform,
|
||||
machine-readable front-end onto the workflow we already run (Gitea issues → branch/PR →
|
||||
multi-persona review → red/green TDD). It does not replace any of that — see
|
||||
[ADR-042](./docs/adr/042-sdd-adoption.md) for the why.
|
||||
|
||||
- **The rules** live in [`.specify/constitution.md`](./.specify/constitution.md) (humans) and
|
||||
[`.specify/AGENTS.md`](./.specify/AGENTS.md) (AI agents, every invocation).
|
||||
- **The templates** live in [`.specify/templates/`](./.specify/templates/).
|
||||
- **The worked example** is [`.specify/features/_example/`](./.specify/features/_example/) — read it first.
|
||||
|
||||
---
|
||||
|
||||
## 0. The whole workflow at a glance
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
idea([Feature idea]):::start --> draft
|
||||
|
||||
subgraph author["✍️ Author"]
|
||||
draft[/"/draft-spec<br/>(Requirements Engineer)"/]:::skill --> issue[("Gitea issue = the SPEC<br/>EARS REQ-NNN + acceptance criteria")]:::spec
|
||||
end
|
||||
|
||||
issue --> ri[/"/review-issue"/]:::skill
|
||||
ri --> g1{"GATE 1 · spec review<br/>6 personas APPROVE?<br/>Open Questions empty?"}:::gate
|
||||
g1 -- "FAIL / question" --> amend["Amend the issue body"]:::work --> ri
|
||||
g1 -- "APPROVE" --> rtm["Seed RTM rows<br/>REQ-ID → issue #"]:::work
|
||||
|
||||
rtm --> wt["Create git worktree<br/>(pull main first)"]:::work --> impl[/"/implement"/]:::skill
|
||||
|
||||
subgraph build["🔁 Build · TDD per REQ-NNN"]
|
||||
impl --> red["Red: failing test"]:::work --> green["Green: minimal code"]:::work --> sync["Refactor + sync<br/>generate:api · flip RTM → Done"]:::work --> commit["Commit · Refs #n"]:::work
|
||||
commit -- "next REQ" --> red
|
||||
end
|
||||
|
||||
build --> pr[["Open PR · Closes #n"]]:::work --> g2{"GATE 2 · CI green?<br/>ci.yml + sdd-gate.yml"}:::gate
|
||||
g2 -- "red" --> fixci["Fix on branch"]:::work --> g2
|
||||
g2 -- "green" --> rp[/"/review-pr"/]:::skill
|
||||
|
||||
rp --> g3{"GATE 3 · PR review<br/>all personas APPROVE?<br/>every REQ implemented + tested?<br/>no Do-Not-Touch violation?"}:::gate
|
||||
g3 -- "changes requested" --> fixpr["Fix on branch"]:::work --> rp
|
||||
g3 -- "APPROVE" --> merge([Merge → main<br/>closed issue = archived spec]):::start
|
||||
|
||||
rules["📐 constitution.md + AGENTS.md<br/>(bind every step)"]:::rules -.-> draft
|
||||
rules -.-> impl
|
||||
rules -.-> rp
|
||||
|
||||
classDef start fill:#1d3b53,color:#fff,stroke:#1d3b53;
|
||||
classDef skill fill:#e8f5f0,stroke:#3aa884,color:#13352b;
|
||||
classDef gate fill:#fff3cd,stroke:#d39e00,color:#5a4500;
|
||||
classDef spec fill:#eef2ff,stroke:#5b6ee1,color:#1e2a5a;
|
||||
classDef work fill:#f6f6f6,stroke:#bbb,color:#222;
|
||||
classDef rules fill:#fdecea,stroke:#d9534f,color:#611a15;
|
||||
```
|
||||
|
||||
> `/deliver-issue` runs **GATE 1 → discuss → build → GATE 3 (loop)** end-to-end in one go.
|
||||
|
||||
### Prerequisites (one-time setup)
|
||||
|
||||
Before the workflow runs cleanly, confirm these exist (most ship with this repo):
|
||||
|
||||
- [ ] **Gitea labels** `spec-required` and `needs-review` exist (the feature template + `/draft-spec` attach them; the `labels` create-param is ignored, so they must pre-exist).
|
||||
- [ ] **Gitea MCP** server configured (`gitea`) — the skills read/write issues and PRs through it.
|
||||
- [ ] **`.spectral.yaml`** at the repo root (extends `spectral:oas`) — the CI contract check needs it.
|
||||
- [ ] **Personas present**: identities in [`.claude/personas/`](./.claude/personas/) + checklists in [`.specify/personas/`](./.specify/personas/).
|
||||
- [ ] **`.specify/constitution.md` + `AGENTS.md`** committed on `main` (so every branch inherits them).
|
||||
- [ ] **Worktrees + hooks**: new feature work goes in a `git worktree` (plus-free name); run `npm install` in `frontend/` once per worktree so the pre-commit lint hook works.
|
||||
|
||||
### The three gates
|
||||
|
||||
| Gate | When | Mechanism | Blocks on |
|
||||
|---|---|---|---|
|
||||
| **1 · Spec review** | after `/draft-spec`, before any code | `/review-issue` (6 persona checklists) | any persona `CHANGES REQUESTED`, or an unresolved `## Open Question` |
|
||||
| **2 · CI** | on every PR | `ci.yml` (tests · lint · semgrep) + `sdd-gate.yml` (rtm-check · contract-validate · constitution-diff) | `ci.yml` failure (hard); `sdd-gate` jobs are non-blocking during adoption — see the workflow TODO |
|
||||
| **3 · PR review** | before merge | `/review-pr` (7 personas + traceability) | any persona `Changes requested`, an unimplemented/untested `REQ-NNN`, or a constitution Do-Not-Touch violation |
|
||||
|
||||
---
|
||||
|
||||
## 1. The workflow in 8 steps
|
||||
|
||||
| # | Step | Who | Artifacts created / touched |
|
||||
|---|---|---|---|
|
||||
| 1 | **Idea → Gitea issue** using the Feature template | author | Gitea issue (labels `spec-required`, `needs-review`) from `.gitea/ISSUE_TEMPLATE/feature.md` |
|
||||
| 2 | **Write the spec _in the issue body_** — Context, User Journey, EARS `REQ-NNN` requirements, measurable acceptance criteria, Out of Scope | author | the Gitea issue body **is** the spec (single source of truth — no committed `spec.md`) |
|
||||
| 3 | **Capture durable design decisions** as needed | author | a `docs/adr/` ADR for any project-wide/irreversible decision; an OpenAPI contract and a STRIDE threat model inline in the issue (use the `.specify/templates/` as the writing aid) |
|
||||
| 4 | **Persona spec review** — the six checklists gate the spec | RE, Developer, Security, DevOps, UI/UX, Architect | `/review-issue` posts each persona's checklist verdict as a Gitea comment; findings folded into the issue body |
|
||||
| 5 | **Resolve Open Questions & blocking FAILs** — spec does not proceed while any remain | author | issue body updated; `Open Questions` emptied |
|
||||
| 6 | **Seed the RTM** — one row per `REQ-NNN`, pointing at the issue | author | rows added to [`.specify/rtm.md`](./.specify/rtm.md) (`Issue: #n`, `Status: Planned`) — committed with the feature branch |
|
||||
| 7 | **Implement** in a worktree, TDD per task (failing test → green → refactor → commit); agent reads `AGENTS.md` + the **issue body** (the spec) | implementer (often an AI agent) | code + tests; `npm run generate:api` after backend changes; RTM `Status` → `Done` |
|
||||
| 8 | **PR → multi-persona PR review → merge** | reviewers | PR (`Closes #n`); the closed issue is the archived spec, the RTM rows record what shipped |
|
||||
|
||||
The personas at step 4 review the **spec (the issue)**; the same personas at step 8 (via the
|
||||
existing `review-pr` / `deliver-issue` skills) review the **code**. Step 4 catches at spec time
|
||||
what used to surface only at step 8.
|
||||
|
||||
**Skills that drive this:** `/draft-spec` (requirements engineer authors steps 1–2 → creates
|
||||
the issue) → `/review-issue` (step 4 gate) → `/implement` (steps 6–7) → `/review-pr` (step 8).
|
||||
`/deliver-issue` runs review → discuss → implement → review-loop end-to-end.
|
||||
|
||||
> **Why issue-only?** The Gitea issue body is the single source of truth for a spec — there is
|
||||
> no committed per-feature `spec.md` to drift out of sync with it. The only SDD artifact that
|
||||
> lives in git per feature is the RTM row (`REQ-ID → issue # → test`). The worked example under
|
||||
> [`.specify/features/_example/`](./.specify/features/_example/) is a **template/reference**, not
|
||||
> a live feature — it shows the full artifact set in one place; real features keep the spec in
|
||||
> the issue.
|
||||
|
||||
## 2. How a Gitea issue becomes a spec
|
||||
|
||||
**Before (free-form issue):**
|
||||
|
||||
> **Title:** Add profile pictures
|
||||
> Users should be able to upload a picture for their profile. Make sure it's not too big and
|
||||
> only admins can remove other people's. Show initials if there's no picture.
|
||||
|
||||
Ambiguous: how big? which formats? what status code on rejection? what about unauthenticated
|
||||
callers? No identifiers to trace, no measurable criteria.
|
||||
|
||||
**After (SDD-structured issue — excerpt):**
|
||||
|
||||
> **Title:** As a user I want to upload a profile picture so other family members recognise me
|
||||
>
|
||||
> **## Requirements**
|
||||
> - **REQ-002** (Event-driven) — When an authenticated user sends `POST /api/users/me/avatar`
|
||||
> with a valid image, the user service shall store it and return a profile view with a
|
||||
> non-null `avatarUrl`.
|
||||
> - **REQ-008** (Unwanted-behavior) — If the uploaded file exceeds 2 MB, then the user service
|
||||
> shall return `400 ErrorCode.AVATAR_TOO_LARGE` and store nothing.
|
||||
> - **REQ-009** (Unwanted-behavior) — If a caller without `Permission.ADMIN_USER` targets
|
||||
> another user's avatar, then the system shall return `403 ErrorCode.FORBIDDEN`.
|
||||
>
|
||||
> **## Acceptance Criteria**
|
||||
> - **REQ-008** — a 2.1 MB PNG returns `400 AVATAR_TOO_LARGE`; bucket object count unchanged.
|
||||
|
||||
Every behavior is now a uniquely-identified, testable, EARS-formed requirement with a
|
||||
measurable acceptance criterion. See the full version in
|
||||
[`.specify/features/_example/spec.md`](./.specify/features/_example/spec.md).
|
||||
|
||||
## 3. How to run a persona review
|
||||
|
||||
Each persona reads the spec, walks its checklist in `.specify/personas/<persona>.md`, and
|
||||
posts a Gitea comment with **PASS / FAIL / QUESTION** per
|
||||
item and a verdict. A `FAIL` from Security or Architect is a hard block. Concrete example:
|
||||
|
||||
> ### Security — Spec Review
|
||||
>
|
||||
> | # | Item | Status | Note |
|
||||
> |---|---|---|---|
|
||||
> | 1 | All mutating endpoints have authn + authz `If` clauses | PASS | REQ-006 (401), REQ-009 (403) |
|
||||
> | 3 | Audit fields server-set, forbidden in body | **FAIL** | `avatarObjectKey` is bound from the request body → mass-assignment (CWE-639). Make it server-set in `UserService`. |
|
||||
> | 6 | Upload type allow-list + size | PASS | REQ-007 / REQ-008 |
|
||||
> | 9 | threat-model.md present & STRIDE-complete | **QUESTION** | Is the avatar URL public or proxied? If public S3, that's information disclosure. |
|
||||
>
|
||||
> **Verdict: CHANGES REQUESTED** — blocking FAIL: #3. Resolve #9 in the threat model.
|
||||
|
||||
The author folds the fix into the spec (here: server-set key + authenticated proxy URL),
|
||||
empties the finding, and the persona re-reviews until `APPROVE`. This mirrors the existing
|
||||
`review-issue` skill — the persona checklists just make the spec pass/fail explicit.
|
||||
|
||||
## 4. How the AI agent uses the spec
|
||||
|
||||
Once the spec is `APPROVE`d and tasks are seeded, the implementer points the agent at the
|
||||
artifacts. Example prompt:
|
||||
|
||||
> Implement Gitea issue #142 (profile picture upload). Read `.specify/AGENTS.md` and obey the
|
||||
> constitution it references. The contract is the issue body — its EARS requirements
|
||||
> REQ-001…REQ-009 and acceptance criteria. Build a red/green task list from them, write the
|
||||
> failing test for each REQ first, confirm it fails, then make it pass. After backend model
|
||||
> changes run `npm run generate:api`. Do not mark a REQ done until its test is green; flip its
|
||||
> row in `.specify/rtm.md` to Done as you go.
|
||||
|
||||
The agent now has: the rules (`AGENTS.md` → constitution) and the exact requirements with ids
|
||||
from the issue — so its output is bounded and verifiable. (The `/implement` skill fetches the
|
||||
issue body for you via the Gitea API.)
|
||||
|
||||
## 5. Maintenance rules
|
||||
|
||||
- **Constitution** ([`.specify/constitution.md`](./.specify/constitution.md)) — change it only
|
||||
when a project-wide rule genuinely changes. Bump the semantic version (MAJOR = rule
|
||||
removed/weakened, MINOR = rule added/tightened, PATCH = wording), run the §6 Sync Impact
|
||||
review, and let the `constitution-diff` CI job list the files to reconcile. Record the bump
|
||||
in ADR-042's revision log (or a superseding ADR for MAJOR).
|
||||
- **AGENTS.md** — keep it under 200 lines. It cross-references the constitution; it must never
|
||||
duplicate or contradict it.
|
||||
- **ADRs** — project-wide/irreversible decisions go in [`docs/adr/`](./docs/adr/) (next free
|
||||
`NNN`, verify on disk). Immutable once `Accepted`; supersede, don't edit.
|
||||
- **Feature specs** — the spec is the Gitea issue body; there is no committed `spec.md`.
|
||||
"Archiving" is just closing the issue (`Closes #n` on merge). The closed issue + the RTM
|
||||
rows are the record of what shipped.
|
||||
- **RTM** ([`.specify/rtm.md`](./.specify/rtm.md)) — append one row per `REQ-NNN` when a spec
|
||||
is approved, each pointing at its issue (`#n`); flip `Status` as tests go green; never delete
|
||||
a shipped requirement's row.
|
||||
- **Personas** — update `.specify/personas/*.md` checklists when a recurring blind spot
|
||||
appears; keep them aligned with the richer `.claude/personas/`.
|
||||
|
||||
## 6. Quick-start cheatsheet
|
||||
|
||||
**EARS patterns** (every requirement is one of these + a `REQ-NNN` id):
|
||||
|
||||
| Pattern | Shape |
|
||||
|---|---|
|
||||
| Ubiquitous | `The <system> shall <behavior>.` |
|
||||
| Event-driven | `When <trigger>, the <system> shall <behavior>.` |
|
||||
| State-driven | `While <state>, the <system> shall <behavior>.` |
|
||||
| Optional-feature | `Where <feature/permission present>, the <system> shall <behavior>.` |
|
||||
| Unwanted-behavior | `If <undesired condition>, then the <system> shall <response>.` |
|
||||
|
||||
**File locations:**
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Non-negotiable rules | `.specify/constitution.md` |
|
||||
| Agent rules (read every time) | `.specify/AGENTS.md` |
|
||||
| Templates (writing aids) | `.specify/templates/{feature-spec,adr,threat-model,api-contract-stub}.md` |
|
||||
| Persona checklists | `.specify/personas/*.md` |
|
||||
| In-flight feature spec | the **Gitea issue body** (not a committed file) |
|
||||
| Worked example (template/reference) | `.specify/features/_example/` |
|
||||
| Traceability matrix | `.specify/rtm.md` (`REQ-ID → issue # → test`) |
|
||||
| ADR archive | `docs/adr/NNN-*.md` |
|
||||
| Issue templates | `.gitea/ISSUE_TEMPLATE/{feature,bug}.md` |
|
||||
| CI gate | `.gitea/workflows/sdd-gate.yml` |
|
||||
|
||||
**Before you mark a feature done:** every `REQ-NNN` has a green test, the RTM Status is
|
||||
`Done`, all six personas APPROVE, `npm run lint` and the targeted tests pass, and
|
||||
`npm run generate:api` has been run if the backend model changed.
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
# validate an OpenAPI contract locally (if you drafted one — same as CI)
|
||||
npx @stoplight/spectral-cli lint <your-contract>.yaml
|
||||
|
||||
# regenerate the TS client after a backend model/endpoint change
|
||||
cd frontend && npm run generate:api # backend must run with --spring.profiles.active=dev
|
||||
```
|
||||
@@ -369,6 +369,12 @@
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
||||
<!-- Grace period after the test JVM calls System.exit(0). The 30s default is too
|
||||
short: the single reused fork closes ~32 cached Spring contexts at shutdown,
|
||||
each tearing down a Testcontainers Postgres + HikariCP pool, which overruns 30s
|
||||
and makes Surefire kill the fork (BUILD FAILURE despite 0 test failures). This is
|
||||
a different knob from forkedProcessTimeoutInSeconds above. See issue #848. -->
|
||||
<forkedProcessExitTimeoutInSeconds>120</forkedProcessExitTimeoutInSeconds>
|
||||
<systemPropertyVariables>
|
||||
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
||||
</systemPropertyVariables>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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,6 +56,11 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||
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
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
@@ -1051,6 +1051,10 @@ public class DocumentService {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
public List<Document> getAllForTimeline() {
|
||||
return documentRepository.findAllForTimeline();
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,8 @@ public enum ErrorCode {
|
||||
CIRCULAR_RELATIONSHIP,
|
||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||
DUPLICATE_RELATIONSHIP,
|
||||
/** A relationship's toDate is before its fromDate. 400 */
|
||||
INVALID_RELATIONSHIP_DATES,
|
||||
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
@@ -155,6 +157,14 @@ public enum ErrorCode {
|
||||
/** The merge target is a descendant of the source tag. 400 */
|
||||
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 ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
@@ -162,6 +172,8 @@ public enum ErrorCode {
|
||||
BATCH_TOO_LARGE,
|
||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
||||
BULK_EDIT_TOO_MANY_IDS,
|
||||
/** A concurrent modification was detected (generic optimistic-lock backstop). 409 */
|
||||
CONFLICT,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ public class GlobalExceptionHandler {
|
||||
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)
|
||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception 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.relationship.RelationType;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
@@ -126,7 +126,7 @@ public class PersonTreeImporter {
|
||||
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||
try {
|
||||
relationshipService.addRelationship(person,
|
||||
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
||||
new RelationshipUpsertRequest(related, RelationType.valueOf(type), null, null, null, null, null));
|
||||
return true;
|
||||
} catch (DomainException e) {
|
||||
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||
|
||||
@@ -242,4 +242,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
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,6 +18,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.DatePrecisionValidation;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -210,6 +211,10 @@ public class PersonService {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
public List<Person> getPersonsByGeneration(Integer generation) {
|
||||
return personRepository.findByGeneration(generation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||
Person person = getById(personId);
|
||||
@@ -444,41 +449,28 @@ public class PersonService {
|
||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||
.birthDate(dto.getBirthDate())
|
||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||
.birthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()))
|
||||
.deathDate(dto.getDeathDate())
|
||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||
.deathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()))
|
||||
.generation(dto.getGeneration())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500. Coherence
|
||||
// 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,
|
||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
||||
DatePrecisionValidation.requireCoherence(birthDate, birthPrecision, "birth");
|
||||
DatePrecisionValidation.requireCoherence(deathDate, deathPrecision, "death");
|
||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
@@ -495,9 +487,9 @@ public class PersonService {
|
||||
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.setBirthDate(dto.getBirthDate());
|
||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||
person.setBirthDatePrecision(DatePrecisionValidation.normalize(dto.getBirthDatePrecision()));
|
||||
person.setDeathDate(dto.getDeathDate());
|
||||
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||
person.setDeathDatePrecision(DatePrecisionValidation.normalize(dto.getDeathDatePrecision()));
|
||||
// Form path: a human can clear generation back to null. Unlike the importer
|
||||
// which routes through preferHuman, we write the DTO value verbatim.
|
||||
person.setGeneration(dto.getGeneration());
|
||||
|
||||
@@ -5,9 +5,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -39,11 +41,25 @@ public class PersonRelationship {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private RelationType relationType;
|
||||
|
||||
@Column(name = "from_year")
|
||||
private Integer fromYear;
|
||||
// Start/end of the relationship (wedding, employment start, …). The date column
|
||||
// is nullable, the precision column is NOT NULL with UNKNOWN meaning "no date" —
|
||||
// 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;
|
||||
|
||||
@Column(name = "to_year")
|
||||
private Integer toYear;
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "from_date_precision", nullable = false, length = 16)
|
||||
@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)
|
||||
private String notes;
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.raddatz.familienarchiv.person.relationship;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.FamilyMemberPatchDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
@@ -63,11 +63,20 @@ public class RelationshipController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
||||
@Valid @RequestBody RelationshipUpsertRequest dto) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.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}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package org.raddatz.familienarchiv.person.relationship;
|
||||
|
||||
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.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||
@@ -86,66 +88,139 @@ public class RelationshipService {
|
||||
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
|
||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||
if (personId.equals(dto.relatedPersonId())) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||
}
|
||||
public RelationshipDTO addRelationship(UUID personId, RelationshipUpsertRequest dto) {
|
||||
requireNotSelf(personId, dto.relatedPersonId());
|
||||
Person person = personService.getById(personId);
|
||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||
|
||||
validateYears(dto.fromYear(), dto.toYear());
|
||||
|
||||
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());
|
||||
}
|
||||
validateRelationshipDates(dto.fromDate(), dto.fromDatePrecision(), dto.toDate(), dto.toDatePrecision());
|
||||
requireNoReverseParent(person.getId(), relatedPerson.getId(), dto.relationType());
|
||||
|
||||
PersonRelationship rel = PersonRelationship.builder()
|
||||
.person(person)
|
||||
.relatedPerson(relatedPerson)
|
||||
.relationType(dto.relationType())
|
||||
.fromYear(dto.fromYear())
|
||||
.toYear(dto.toYear())
|
||||
.fromDate(dto.fromDate())
|
||||
.fromDatePrecision(DatePrecisionValidation.normalize(dto.fromDatePrecision()))
|
||||
.toDate(dto.toDate())
|
||||
.toDatePrecision(DatePrecisionValidation.normalize(dto.toDatePrecision()))
|
||||
.notes(blankToNull(dto.notes()))
|
||||
.build();
|
||||
|
||||
PersonRelationship saved;
|
||||
try {
|
||||
// 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) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||
"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.
|
||||
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
|
||||
personService.setFamilyMember(person.getId(), true);
|
||||
personService.setFamilyMember(relatedPerson.getId(), true);
|
||||
}
|
||||
PersonRelationship saved = persistOrConflict(rel, person.getId(), relatedPerson.getId(), dto.relationType());
|
||||
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 {
|
||||
return relationshipRepository.saveAndFlush(rel);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||
"Relationship already exists for (" + subjectId + ", " + objectId + ", " + type + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// Family-graph edges imply both endpoints are family members. Idempotent (the setter is
|
||||
// a no-op when already flagged, so re-imports stay clean) and additive — an edit never
|
||||
// auto-unflags.
|
||||
private void flagFamilyMembership(RelationType type, UUID subjectId, UUID objectId) {
|
||||
if (FAMILY_RELATION_TYPES.contains(type)) {
|
||||
personService.setFamilyMember(subjectId, true);
|
||||
personService.setFamilyMember(objectId, true);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
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)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||
|
||||
UUID storageSubject = rel.getPerson().getId();
|
||||
UUID storageObject = rel.getRelatedPerson().getId();
|
||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||
throw DomainException.forbidden(
|
||||
throw DomainException.notFound(
|
||||
ErrorCode.RELATIONSHIP_NOT_FOUND,
|
||||
"Relationship " + relId + " does not belong to person " + personId);
|
||||
}
|
||||
relationshipRepository.delete(rel);
|
||||
return rel;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -164,10 +239,17 @@ public class RelationshipService {
|
||||
return date != null ? date.getYear() : null;
|
||||
}
|
||||
|
||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
||||
// Mirrors PersonService.validateLifeDates (ADR-039/044): coherence first so the
|
||||
// user gets a structured 400 instead of the DB CHECK constraint's 500, then order.
|
||||
// Coherence is shared with the person domain (DatePrecisionValidation); only the order
|
||||
// check (and its INVALID_RELATIONSHIP_DATES code) is relationship specific.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +267,10 @@ public class RelationshipService {
|
||||
yearOf(rp.getBirthDate()),
|
||||
yearOf(rp.getDeathDate()),
|
||||
r.getRelationType(),
|
||||
r.getFromYear(),
|
||||
r.getToYear(),
|
||||
r.getFromDate(),
|
||||
r.getFromDatePrecision(),
|
||||
r.getToDate(),
|
||||
r.getToDatePrecision(),
|
||||
r.getNotes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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,8 +1,10 @@
|
||||
package org.raddatz.familienarchiv.person.relationship.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -26,7 +28,9 @@ public record RelationshipDTO(
|
||||
Integer relatedPersonBirthYear,
|
||||
Integer relatedPersonDeathYear,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||
Integer fromYear,
|
||||
Integer toYear,
|
||||
LocalDate fromDate,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision fromDatePrecision,
|
||||
LocalDate toDate,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision toDatePrecision,
|
||||
String notes
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,6 +175,59 @@ 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.
|
||||
* Used by DocumentService to expand selected filter tags before applying AND/OR logic.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/** Discriminator for derived life-events assembled from Person / PersonRelationship data. */
|
||||
public enum DerivedEventType {
|
||||
BIRTH,
|
||||
DEATH,
|
||||
MARRIAGE
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
|
||||
public enum Kind {
|
||||
EVENT,
|
||||
LETTER
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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><b>Letter→event link ({@code linkedEventId}):</b> for a {@link Kind#LETTER} entry, the id of
|
||||
* the curated {@link TimelineEvent} whose {@code documents} set contains the letter's document, or
|
||||
* {@code null} when the letter is referenced by no curated event (#850). Computed on read from the
|
||||
* existing {@code timeline_event_documents} join — no new column. {@code null} for non-letter
|
||||
* entries. Deliberately NOT {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript
|
||||
* type stays optional.
|
||||
*
|
||||
* <p><b>Event description ({@code description}):</b> curator-authored context note for a curated
|
||||
* {@link Kind#EVENT} entry (#844). Populated from {@link TimelineEvent#getDescription()} — null
|
||||
* for {@link Kind#LETTER} and derived entries. Deliberately NOT
|
||||
* {@code @Schema(requiredMode = REQUIRED)} so the generated TypeScript type stays 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,
|
||||
UUID linkedEventId,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -99,9 +99,11 @@ public class TimelineEvent {
|
||||
* CWE-639 — see ADR-040).
|
||||
*/
|
||||
@Column(name = "created_by", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
@@ -112,18 +114,21 @@ public class TimelineEvent {
|
||||
* stale. Same forgery rationale as {@link #createdBy}.
|
||||
*/
|
||||
@Column(name = "updated_by", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID updatedBy;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Optimistic-lock version for the multi-curator edit flow (issue 3). Object {@code Long}
|
||||
* Optimistic-lock version for the multi-curator edit flow (#775). Object {@code Long}
|
||||
* (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on
|
||||
* insert. A concurrent-write conflict must be translated to {@code DomainException.conflict}
|
||||
* in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate
|
||||
* internals (CWE-209).
|
||||
*/
|
||||
@Version
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long version;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TimelineEventRepository extends JpaRepository<TimelineEvent, UUID> {
|
||||
// TODO(issue 5): findByPersonsContaining(Person) needed for the per-person filter
|
||||
// TODO(#777): findByPersonsContaining(Person) needed for the per-person filter
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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
|
||||
) {}
|
||||
@@ -0,0 +1,328 @@
|
||||
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, 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, 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, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
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());
|
||||
|
||||
// Fetch curated events once; the events that survive the filter below feed both the
|
||||
// event entries and the batched letter→event link pass (resolveLetterEventLinks), so the
|
||||
// membership pass costs no extra query and touches only on-screen events. REQ-009.
|
||||
List<TimelineEvent> allEvents = eventRepository.findAll();
|
||||
|
||||
// ── curated events ───────────────────────────────────────────────────
|
||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||
List<TimelineEvent> filteredEvents = new ArrayList<>();
|
||||
for (TimelineEvent ev : allEvents) {
|
||||
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;
|
||||
filteredEvents.add(ev);
|
||||
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);
|
||||
Map<UUID, UUID> eventByDocId = resolveLetterEventLinks(letters, filteredEvents);
|
||||
for (Document doc : letters) {
|
||||
entries.add(mapDocument(doc, rootByDocId, eventByDocId));
|
||||
}
|
||||
|
||||
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,
|
||||
null,
|
||||
ev.getDescription()
|
||||
);
|
||||
}
|
||||
|
||||
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId,
|
||||
Map<UUID, UUID> eventByDocId) {
|
||||
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(),
|
||||
eventByDocId.get(doc.getId()),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves each letter's linked curated event in one batched pass, keyed by document id: the
|
||||
* event whose {@code documents} set contains the letter (REQ-009). A single doc→event map is
|
||||
* built over the already-loaded events — no per-letter query ({@code TimelineEvent.documents}
|
||||
* carries {@code @BatchSize(50)}). When a document is referenced by more than one curated
|
||||
* event, the earliest-dated event wins (then lowest {@code eventId}); the pass runs over a
|
||||
* stably-ordered copy so the link is a deterministic property of the data, not a coin-flip on
|
||||
* the undefined repository iteration order ({@code putIfAbsent} keeps the first = winner). The
|
||||
* map is built only over the events that survived the timeline filter, so the lazy
|
||||
* {@code documents} collection is hydrated for on-screen events alone (finding #10); a letter
|
||||
* whose only linking event was filtered out links to nothing, matching the frontend's
|
||||
* filter-then-cluster (#850). Logs nothing — the assembly path keeps UUIDs out of logs (§2.7).
|
||||
*/
|
||||
private Map<UUID, UUID> resolveLetterEventLinks(List<Document> letters, List<TimelineEvent> events) {
|
||||
Set<UUID> letterDocIds = letters.stream().map(Document::getId).collect(Collectors.toSet());
|
||||
if (letterDocIds.isEmpty()) return Map.of();
|
||||
|
||||
// Stable order so a multi-event letter links deterministically: earliest event date
|
||||
// (undated last), then lowest id. putIfAbsent below then keeps the winner (REQ-009).
|
||||
List<TimelineEvent> ordered = events.stream()
|
||||
.sorted(Comparator
|
||||
.comparing(TimelineEvent::getEventDate,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(TimelineEvent::getId))
|
||||
.toList();
|
||||
|
||||
Map<UUID, UUID> eventByDocId = new HashMap<>();
|
||||
for (TimelineEvent ev : ordered) {
|
||||
Set<Document> linkedDocs = ev.getDocuments();
|
||||
if (linkedDocs == null) continue;
|
||||
for (Document linked : linkedDocs) {
|
||||
if (letterDocIds.contains(linked.getId())) {
|
||||
eventByDocId.putIfAbsent(linked.getId(), ev.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
return eventByDocId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -56,10 +56,9 @@ CREATE TABLE timeline_event_documents (
|
||||
);
|
||||
|
||||
-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus
|
||||
-- explicit indexes on all four FK columns.
|
||||
-- the inverse-side FK columns. timeline_event_id needs no extra index on either join table —
|
||||
-- it is the leading column of the composite PK, so the PK index already serves those lookups.
|
||||
CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date);
|
||||
CREATE INDEX idx_timeline_events_type ON timeline_events (type);
|
||||
CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id);
|
||||
CREATE INDEX idx_timeline_event_persons_event_id ON timeline_event_persons (timeline_event_id);
|
||||
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);
|
||||
CREATE INDEX idx_timeline_event_documents_event_id ON timeline_event_documents (timeline_event_id);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 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,4 +2943,17 @@ class DocumentServiceTest {
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
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,6 +14,9 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
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.mockito.Mockito.mockStatic;
|
||||
@@ -103,6 +106,49 @@ 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
|
||||
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
|
||||
// Debuggability (DevOps): the WARN must name *which* constraint fired so an
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
@@ -169,7 +170,7 @@ class CanonicalImportOrchestratorTest {
|
||||
RelationshipDTO edge = new RelationshipDTO(
|
||||
UUID.randomUUID(), parentId, childId,
|
||||
"Parent", null, null, "Child", null, null,
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||
when(relationshipService.getFamilyNetwork())
|
||||
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||
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.relationship.RelationType;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -76,7 +76,7 @@ class PersonTreeImporterTest {
|
||||
new PersonTreeImporter(personService, relationshipService)
|
||||
.load(json.toFile());
|
||||
|
||||
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
||||
ArgumentCaptor<RelationshipUpsertRequest> captor = ArgumentCaptor.forClass(RelationshipUpsertRequest.class);
|
||||
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||
|
||||
@@ -1105,4 +1105,25 @@ class PersonServiceTest {
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
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,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.person.relationship;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
@@ -25,6 +26,8 @@ import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -98,7 +101,7 @@ class RelationshipControllerTest {
|
||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||
"Alice Müller", 1900, 1980,
|
||||
"Bob Müller", 1930, null,
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||
when(relationshipService.getFamilyNetwork())
|
||||
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||
|
||||
@@ -139,7 +142,7 @@ class RelationshipControllerTest {
|
||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||
"Alice Müller", null, null,
|
||||
"Bob Müller", null, null,
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
RelationType.PARENT_OF, null, DatePrecision.UNKNOWN, null, DatePrecision.UNKNOWN, null);
|
||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||
@@ -158,4 +161,51 @@ class RelationshipControllerTest {
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
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,10 +4,11 @@ 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.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||
@@ -20,6 +21,7 @@ import org.springframework.context.annotation.Import;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -65,13 +67,17 @@ class RelationshipServiceIntegrationTest {
|
||||
|
||||
@Test
|
||||
void addRelationship_stores_and_is_readable() {
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, null);
|
||||
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.personId()).isEqualTo(alice.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());
|
||||
assertThat(rels).hasSize(1);
|
||||
@@ -80,7 +86,7 @@ class RelationshipServiceIntegrationTest {
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_409_when_duplicate() {
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||
relationshipService.addRelationship(alice.getId(), dto);
|
||||
|
||||
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||
@@ -93,9 +99,9 @@ class RelationshipServiceIntegrationTest {
|
||||
void addRelationship_throws_409_when_circular_parent() {
|
||||
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||
relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||
|
||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
@@ -103,28 +109,58 @@ class RelationshipServiceIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
||||
void deleteRelationship_throws_404_when_rel_belongs_to_different_person() {
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||
|
||||
// Charlie is unrelated to this row.
|
||||
// Charlie is unrelated to this row. Ownership mismatch is 404, not 403, so a
|
||||
// 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()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||
|
||||
// The row is still there.
|
||||
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
|
||||
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||
// 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.
|
||||
relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||
|
||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
||||
var reverse = new RelationshipUpsertRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null, null, null);
|
||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
@@ -135,7 +171,7 @@ class RelationshipServiceIntegrationTest {
|
||||
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||
new RelationshipUpsertRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null, null, null));
|
||||
|
||||
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||
|
||||
@@ -148,7 +184,7 @@ class RelationshipServiceIntegrationTest {
|
||||
// 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.
|
||||
relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
new RelationshipUpsertRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||
relationshipService.setFamilyMember(charlie.getId(), false);
|
||||
|
||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||
@@ -165,7 +201,7 @@ class RelationshipServiceIntegrationTest {
|
||||
@Test
|
||||
void delete_person_cascades_to_relationships() {
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null));
|
||||
UUID relId = created.id();
|
||||
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||
|
||||
|
||||
@@ -6,16 +6,18 @@ 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.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipUpsertRequest;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -59,9 +61,9 @@ class RelationshipServiceTest {
|
||||
charlie = person("Charlie");
|
||||
}
|
||||
|
||||
// --- Nora blocker 1 ---
|
||||
// --- Nora blocker 1 (anti-enumeration: ownership mismatch is 404, aligned with PUT) ---
|
||||
@Test
|
||||
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
||||
void deleteRelationship_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));
|
||||
@@ -69,7 +71,7 @@ class RelationshipServiceTest {
|
||||
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||
verify(relationshipRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@@ -82,7 +84,7 @@ class RelationshipServiceTest {
|
||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||
|
||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
@@ -98,7 +100,7 @@ class RelationshipServiceTest {
|
||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
@@ -107,7 +109,7 @@ class RelationshipServiceTest {
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
||||
var dto = new RelationshipUpsertRequest(alice.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
@@ -116,14 +118,42 @@ class RelationshipServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
||||
void addRelationship_throws_INVALID_RELATIONSHIP_DATES_when_toDate_before_fromDate() {
|
||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
||||
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.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
.isEqualTo(ErrorCode.INVALID_RELATIONSHIP_DATES);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -140,13 +170,16 @@ class RelationshipServiceTest {
|
||||
return r;
|
||||
});
|
||||
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF,
|
||||
LocalDate.of(1900, 1, 1), DatePrecision.YEAR, null, null, "first born");
|
||||
var result = service.addRelationship(alice.getId(), dto);
|
||||
|
||||
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||
assertThat(result.fromYear()).isEqualTo(1900);
|
||||
assertThat(result.fromDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
assertThat(result.fromDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.toDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.notes()).isEqualTo("first born");
|
||||
}
|
||||
|
||||
@@ -166,7 +199,7 @@ class RelationshipServiceTest {
|
||||
return r;
|
||||
});
|
||||
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.PARENT_OF, null, null, null, null, null);
|
||||
service.addRelationship(alice.getId(), dto);
|
||||
|
||||
verify(personService).setFamilyMember(alice.getId(), true);
|
||||
@@ -187,7 +220,7 @@ class RelationshipServiceTest {
|
||||
return r;
|
||||
});
|
||||
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
|
||||
var dto = new RelationshipUpsertRequest(bob.getId(), RelationType.FRIEND, null, null, null, null, null);
|
||||
service.addRelationship(alice.getId(), dto);
|
||||
|
||||
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
||||
@@ -216,6 +249,131 @@ class RelationshipServiceTest {
|
||||
.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
|
||||
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).
|
||||
@@ -260,11 +418,15 @@ class RelationshipServiceTest {
|
||||
}
|
||||
|
||||
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()
|
||||
.id(id)
|
||||
.person(parent)
|
||||
.relatedPerson(child)
|
||||
.relationType(RelationType.PARENT_OF)
|
||||
.person(subject)
|
||||
.relatedPerson(object)
|
||||
.relationType(type)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ class ArchitectureTest {
|
||||
.and().resideInAPackage("..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.
|
||||
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||
// where it can be audited and reasoned about independently.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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,6 +12,7 @@ import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -450,6 +451,74 @@ class TagServiceTest {
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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());
|
||||
}
|
||||
|
||||
// ─── REQ-001: description field serialised ───────────────────────────────
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void timelineIncludesEventDescription() throws Exception {
|
||||
// REQ-001 (controller slice): a curated event entry with description "Kontext" is
|
||||
// serialised into the timeline response at the correct JSON path.
|
||||
var entry = new TimelineEntryDTO(Kind.EVENT, org.raddatz.familienarchiv.document.DatePrecision.DAY,
|
||||
false, "", "",
|
||||
java.time.LocalDate.of(1914, 8, 1), null, "Kriegsbeginn",
|
||||
EventType.HISTORICAL, UUID.randomUUID(), null, List.of(), null,
|
||||
null, null, null, null, "Kontext");
|
||||
when(timelineService.assemble(any()))
|
||||
.thenReturn(new TimelineDTO(
|
||||
List.of(new TimelineYearDTO(1914, List.of(entry))),
|
||||
List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.years[0].entries[0].description", is("Kontext")));
|
||||
}
|
||||
|
||||
// ─── 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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
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, 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, 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());
|
||||
}
|
||||
|
||||
// ─── event description (#844, REQ-001) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mapEvent_populates_description_from_event() {
|
||||
// REQ-001: a curated event with a description surfaces it on the assembled entry.
|
||||
TimelineEvent ev = TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title("Kriegsbeginn").type(EventType.HISTORICAL)
|
||||
.eventDate(LocalDate.of(1914, 8, 1)).precision(DatePrecision.DAY)
|
||||
.description("Kontext")
|
||||
.build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
TimelineEntryDTO entry = result.years().get(0).entries().get(0);
|
||||
assertThat(entry.description()).isEqualTo("Kontext");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapEvent_leaves_description_null_when_event_has_none() {
|
||||
// REQ-001: an event without a description → null on the entry.
|
||||
TimelineEvent ev = TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title("Ereignis").type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1920, 1, 1)).precision(DatePrecision.YEAR)
|
||||
.build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineEntryDTO entry = timelineService.assemble(noFilters()).years().get(0).entries().get(0);
|
||||
assertThat(entry.description()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapDocument_leaves_description_null_for_letter() {
|
||||
// REQ-001: LETTER entries carry null description, regardless of any document fields.
|
||||
Document doc = docWithDate(LocalDate.of(1916, 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.description()).isNull();
|
||||
}
|
||||
|
||||
// ─── letter→event link (#850, REQ-009) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void letter_in_a_curated_events_documents_carries_that_events_id() {
|
||||
// REQ-009: linkedEventId = the curated event whose documents set contains the letter.
|
||||
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||
UUID eventId = UUID.randomUUID();
|
||||
TimelineEvent event = TimelineEvent.builder().id(eventId)
|
||||
.title("Briefe von der Front").type(EventType.PERSONAL)
|
||||
.documents(new HashSet<>(Set.of(letterDoc)))
|
||||
.build(); // no eventDate → event lands undated, leaving the year band to the letter
|
||||
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||
|
||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||
|
||||
assertThat(entry.linkedEventId()).isEqualTo(eventId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void letter_in_no_curated_event_has_null_linkedEventId() {
|
||||
// REQ-009: a letter referenced by no curated event → linkedEventId null; the frontend
|
||||
// then renders it as a loose chronological letter (REQ-006).
|
||||
Document letterDoc = docWithDate(LocalDate.of(1915, 5, 1), DatePrecision.MONTH);
|
||||
TimelineEvent event = TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title("Anderes Ereignis").type(EventType.PERSONAL)
|
||||
.documents(new HashSet<>(Set.of(Document.builder().id(UUID.randomUUID()).build())))
|
||||
.build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of(event));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||
|
||||
TimelineEntryDTO entry = onlyLetterEntry(timelineService.assemble(noFilters()));
|
||||
|
||||
assertThat(entry.linkedEventId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void multi_event_letter_links_deterministically_to_the_earliest_event() {
|
||||
// REQ-009: a document referenced by >1 curated event links to the earliest-dated event
|
||||
// (then lowest id), independent of repository iteration order — not a coin-flip on
|
||||
// findAll()'s undefined order.
|
||||
Document shared = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
|
||||
TimelineEvent earlier = TimelineEvent.builder()
|
||||
.id(UUID.fromString("00000000-0000-0000-0000-000000000001"))
|
||||
.title("Frühes Ereignis").type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1913, 3, 1)).precision(DatePrecision.MONTH)
|
||||
.documents(new HashSet<>(Set.of(shared)))
|
||||
.build();
|
||||
TimelineEvent later = TimelineEvent.builder()
|
||||
.id(UUID.fromString("00000000-0000-0000-0000-000000000002"))
|
||||
.title("Spätes Ereignis").type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1914, 3, 1)).precision(DatePrecision.MONTH)
|
||||
.documents(new HashSet<>(Set.of(shared)))
|
||||
.build();
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(shared));
|
||||
|
||||
// `later` first in iteration: a naive putIfAbsent would wrongly pick it.
|
||||
when(eventRepository.findAll()).thenReturn(List.of(later, earlier));
|
||||
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
|
||||
.isEqualTo(earlier.getId());
|
||||
|
||||
// Reversed order yields the same winner — the link is order-independent.
|
||||
when(eventRepository.findAll()).thenReturn(List.of(earlier, later));
|
||||
assertThat(theLetter(timelineService.assemble(noFilters())).linkedEventId())
|
||||
.isEqualTo(earlier.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void letter_linked_only_to_a_filtered_out_event_has_null_linkedEventId() {
|
||||
// finding #10: the link pass runs over the events that survived the filter, not all of
|
||||
// them. A letter whose only linking event is excluded by the active filter links to
|
||||
// nothing (the frontend would drop it loose anyway) — and the lazy `documents` collection
|
||||
// is never hydrated for events that are off-screen.
|
||||
Document letterDoc = docWithDate(LocalDate.of(1916, 5, 1), DatePrecision.MONTH);
|
||||
TimelineEvent worldEvent = TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title("Somme").type(EventType.HISTORICAL)
|
||||
.documents(new HashSet<>(Set.of(letterDoc)))
|
||||
.build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of(worldEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterDoc));
|
||||
|
||||
// Filter to PERSONAL only → the HISTORICAL event is filtered out of the view.
|
||||
TimelineEntryDTO entry = theLetter(timelineService.assemble(
|
||||
new TimelineFilter(null, null, EventType.PERSONAL, null, null)));
|
||||
|
||||
assertThat(entry.linkedEventId()).isNull();
|
||||
}
|
||||
|
||||
private static TimelineEntryDTO theLetter(TimelineDTO result) {
|
||||
return java.util.stream.Stream.concat(
|
||||
result.years().stream().flatMap(y -> y.entries().stream()),
|
||||
result.undated().stream())
|
||||
.filter(e -> e.kind() == Kind.LETTER)
|
||||
.findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
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, 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();
|
||||
}
|
||||
}
|
||||
210
design_handoff_familienarchiv_redesign/DESIGN_RULES.md
Normal file
210
design_handoff_familienarchiv_redesign/DESIGN_RULES.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Familienarchiv — Design Rules (binding)
|
||||
|
||||
This is the visual law for the redesign. It promotes the informal `Regeln` page into
|
||||
enforceable specs. **When this doc and a prototype disagree, the prototype wins** — these
|
||||
values are transcribed from the prototypes, but the rendered file is ground truth.
|
||||
|
||||
Built on the **De Gruyter Brill** corporate identity. Tone: restrained, archival,
|
||||
formal-respectful — institutional, not cute. German-first, formal **Sie**, never emoji.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design tokens
|
||||
|
||||
Port `prototypes/colors_and_type.css` verbatim into the app (Tailwind 4 `@theme` or CSS
|
||||
custom properties). Components reference **semantic** tokens, never raw hex.
|
||||
|
||||
### Color — light mode
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--c-canvas` | `#f0efe9` | Page background (warm sand). Flat, no gradients. |
|
||||
| `--c-surface` | `#ffffff` | Cards, inputs, menus. |
|
||||
| `--c-muted` | `#f5f4ef` | Subtle fills (mission-control tiles, hover wash). |
|
||||
| `--c-line` | `#e4e2d7` | 1px borders. |
|
||||
| `--c-line-2` | `#eeede8` | Inner dividers / row separators. |
|
||||
| `--c-ink` | `#012851` | Primary text (navy). |
|
||||
| `--c-ink-2` | `#4b5563` | Secondary / body text. |
|
||||
| `--c-ink-3` | `#6b7280` | Meta, placeholder, captions. |
|
||||
| `--c-primary` | `#012851` | Primary buttons, active segment, header. |
|
||||
| `--c-primary-fg` | `#ffffff` | Text on primary. |
|
||||
| `--c-accent` | `#a1dcd8` | **Mint — decorative only.** Top stripes, left rules, underlines, timeline spine. **Never carries text.** |
|
||||
| `--c-accent-bg` | `rgba(161,220,216,.15)` | Tinted note/skill backgrounds. |
|
||||
| `--c-header` | `#012851` | Header bar (always navy, both themes). |
|
||||
| `--c-turquoise` | `#00c7b1` | Transcription mode only. |
|
||||
| `--c-danger` | `#c0392b` | Destructive actions (Löschen). |
|
||||
| `--c-focus-ring` | `#012851` | 2px focus outline, 2px offset (mint in dark). |
|
||||
|
||||
### Color — dark mode (`:root[data-theme='dark']`)
|
||||
|
||||
Navy-tinted. Key flips: `--c-canvas:#010e1e`, `--c-surface:#011526`, `--c-muted:#011a30`,
|
||||
`--c-line:#0d3358`, `--c-ink:#f0efe9`, `--c-ink-2:#9ca3af`, `--c-ink-3:#8b97a5`.
|
||||
**`--c-accent` flips to turquoise `#00c7b1`**, and `--c-primary` flips to mint `#a1dcd8`
|
||||
with `--c-primary-fg:#012851`. Full set in `colors_and_type.css`.
|
||||
|
||||
### Person / avatar palette (deterministic — see §5)
|
||||
|
||||
`#5a8a6a #a0522d #c17a00 #607080 #7a4f9a #c0446e #3060b0 #4a7a3a #9a8040 #c05540`
|
||||
|
||||
> The live avatar constant is `$lib/shared/avatarPalette.ts` (single source of
|
||||
> truth). Three of these hues fail the ≥4.5:1 white-initials contrast floor and
|
||||
> ship as AA-darkened variants there (sage `#527e61`, amber `#a46800`,
|
||||
> sand `#897239`); the bright hues above remain the decorative tag-dot colors.
|
||||
|
||||
### Tag dot colors
|
||||
|
||||
`--c-tag-sage #5a8a6a`, `--c-tag-sienna #a0522d`, `--c-tag-amber #c17a00`,
|
||||
`--c-tag-slate #607080`, `--c-tag-violet #7a4f9a`, `--c-tag-rose #c0446e`,
|
||||
`--c-tag-cobalt #3060b0`, `--c-tag-moss #4a7a3a`, `--c-tag-sand #9a8040`,
|
||||
`--c-tag-coral #c05540`.
|
||||
|
||||
### Badge types (Personen)
|
||||
|
||||
| Type | bg / text / border |
|
||||
|---|---|
|
||||
| Institution | `#e8eff7` / `#1a4971` / `#c4d5e8` |
|
||||
| Gruppe | `#f0e8f5` / `#5a2d6f` / `#d8c5e3` |
|
||||
| Unbekannt | `#fdf4e3` / `#7a5a0a` / `#f0ddb3` |
|
||||
|
||||
### Radius / shadow
|
||||
|
||||
- `--radius-sm: 2px` — cards, inputs, buttons, segmented control. **The default.**
|
||||
- `--radius-md: 4px` — tag chips/badges only.
|
||||
- `--radius-full: 9999px` — avatars, dots, pills.
|
||||
- `--shadow-sm: 0 1px 2px 0 rgb(0 0 0/.05)` — resting cards.
|
||||
- `--shadow-md: 0 4px 6px -1px rgb(0 0 0/.1), 0 2px 4px -2px rgb(0 0 0/.1)` — dropdowns.
|
||||
|
||||
---
|
||||
|
||||
## 2. Typography
|
||||
|
||||
Two families. **Montserrat** (`--font-sans`) for all UI chrome; **Tinos** (`--font-serif`)
|
||||
for headlines, body, letter content, transcriptions, story prose.
|
||||
|
||||
| Role | Family | Size / weight | Treatment |
|
||||
|---|---|---|---|
|
||||
| Page title (`h1`) | Tinos | **46px / 700**, line-height 1.06 | sentence case |
|
||||
| Story detail title | Tinos | 38px / 700, lh 1.15 | sentence case |
|
||||
| Card title (`h3`) | Tinos | 19–24px / 700 | sentence case |
|
||||
| Body / letter / snippet | Tinos | 15–18px, lh 1.55–1.75 | snippets *italic*, quotes use `„…“` |
|
||||
| **Rubric / eyebrow label** | Montserrat | **12px / 700**, `letter-spacing:.14em`, UPPERCASE | above every page title |
|
||||
| Section caption | Montserrat | 11–12px / 700, `.12–.14em`, UPPERCASE | card headers |
|
||||
| Button / nav label | Montserrat | 11–12px / 700, `.08–.1em`, UPPERCASE | |
|
||||
| Tag chip label | Montserrat | 10px / 700, `.13–.15em`, UPPERCASE | |
|
||||
| Meta line | Montserrat | 12px / 400 | counts, dates; separated by ` · ` |
|
||||
|
||||
**Casing law:** UI chrome (labels, buttons, nav, captions, tags) is ALL CAPS + wide
|
||||
tracking, Montserrat bold. Headlines and document titles are sentence case in Tinos serif.
|
||||
|
||||
---
|
||||
|
||||
## 3. Shared component: page header (eyebrow + title)
|
||||
|
||||
Every top-level page opens with this block. **Build it once** as a `PageHeader`
|
||||
component (props: eyebrow, title, lede, optional right-side count or action).
|
||||
|
||||
```
|
||||
<div border-left:4px solid var(--c-accent); padding-left:18px>
|
||||
<eyebrow> Montserrat 12px/700, .14em, UPPERCASE, color --c-ink-3, margin-bottom 8px
|
||||
<h1> Tinos 46px/700, lh 1.06, color --c-ink
|
||||
<lede> Tinos italic 16px, color --c-ink-2, margin-top 10px, max-width 520px
|
||||
```
|
||||
|
||||
The **4px mint left rule** is the signature. A right-aligned count
|
||||
(`38 Personen`, `147 Dokumente · 38 Personen`) or a primary action button sits opposite via
|
||||
`justify-content:space-between; align-items:flex-end`.
|
||||
|
||||
Page shell for every screen: `min-height:100vh; background:var(--c-canvas)`, header on top,
|
||||
then `<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shared component: app header + nav (`ArchiveHeader`)
|
||||
|
||||
Single sticky header reused on every page. **This is the most important thing to extract
|
||||
into one component** — it is currently duplicated and must not be.
|
||||
|
||||
- `position:sticky; top:0; z-index:50; background:var(--c-header)`.
|
||||
- **4px mint stripe** (`#a1dcd8`) across the very top, above the bar.
|
||||
- Bar: `max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center`, white text.
|
||||
- Wordmark `FAMILIENARCHIV`: Montserrat 18px/700, `letter-spacing:.16em`, UPPERCASE, `margin-right:28px`.
|
||||
- Nav items: Montserrat 11px/700, `.07em`, UPPERCASE, color `rgba(255,255,255,.6)`,
|
||||
`line-height:44px`. **Active** item → color `#fff` + `border-bottom:2px solid #a1dcd8`.
|
||||
Nav order: Dokumente · Personen · Briefwechsel · Geschichten · Zeitstrahl · Aktivitäten
|
||||
(· Regeln, internal). Each links to its route; pass the active key as a prop.
|
||||
- Right cluster: **Hell / Dunkel** theme toggle (segmented; active segment = mint bg
|
||||
`#a1dcd8` + navy text) and a round user avatar chip (`MR`, white bg, navy text, 32px).
|
||||
- **Theme toggle** writes `localStorage['theme']` (`'light'|'dark'`) and sets
|
||||
`document.documentElement.dataset.theme`. A tiny inline boot script in `<head>` reads it
|
||||
before paint to avoid a flash. Map this to the app's existing theme mechanism if one
|
||||
exists; otherwise replicate.
|
||||
- Motion: `transition-colors` only. No transforms, no scale on press.
|
||||
|
||||
---
|
||||
|
||||
## 5. Shared primitive: avatar + deterministic color
|
||||
|
||||
Every person is a round avatar with initials, colored by a hash of the name so the same
|
||||
person is always the same color across every screen. **Extract this into one util +
|
||||
component** — it is currently copy-pasted into 6 files.
|
||||
|
||||
```js
|
||||
// palette = the 10 person colors in §1
|
||||
function avatarFor(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length > 1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
return { bg: palette[h % palette.length], initials };
|
||||
}
|
||||
```
|
||||
|
||||
- Always a perfect circle (`border-radius:full`), white initials in Montserrat 700, `line-height:1`.
|
||||
- Sizes in use: **48px**/16px (selectors, person cards), **40px**/14px (story byline, feed,
|
||||
Briefwechsel rows), **26px**/10px (overlapping stacks — `margin-left:-6px` +
|
||||
`2px solid var(--c-surface)` ring), **28px** (timeline person nodes).
|
||||
- The color **distinguishes, it does not decorate** — never restyle it for emphasis.
|
||||
|
||||
---
|
||||
|
||||
## 6. Shared component: segmented control (filters / views)
|
||||
|
||||
Inline filter switch used on Personen, Geschichten, Zeitstrahl, Aktivitäten, and the header
|
||||
theme toggle.
|
||||
|
||||
- `display:inline-flex; border:1px solid var(--c-line)` (radius 0 / sm; segments share borders).
|
||||
- Each segment: Montserrat 12px/700, `.08em`, UPPERCASE, `padding:9–10px 16px`, `cursor:pointer`.
|
||||
- **Active** segment: `background:var(--c-primary); color:var(--c-primary-fg)`.
|
||||
- Inactive: `background:var(--c-surface); color:var(--c-ink-2)`, `border-left:1px solid var(--c-line)` between segments.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cards, metadata, empty states
|
||||
|
||||
**Card:** `background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-sm); border-radius:2px; padding:20–24px`.
|
||||
Most content cards add a **3px mint top border** (`border-top:3px solid var(--c-accent)`) as
|
||||
the archival signature; some use a **3px mint left border** for inline/resume strips.
|
||||
|
||||
**Metadata line:** one Montserrat 12px line, items separated by ` · `, often led by a 14px
|
||||
De Gruyter icon at `opacity:.5`. Example: `📅 14. März 1923 · 14 Dokumente · 4 Personen`
|
||||
(icon is an `<img>`, not emoji).
|
||||
|
||||
**Status dots:** 7px circle + UPPERCASE label. Transkribiert/Veröffentlicht `#5a8a6a`,
|
||||
In Arbeit `#c17a00`, Neu/Entwurf `#607080`.
|
||||
|
||||
**Empty state:** dashed `1px var(--c-line)` border, centered. Serif heading
|
||||
(`Noch keine Geschichten angelegt.`) + Montserrat sub line ending in the German ellipsis
|
||||
(`Beginnen Sie mit einem Brief…`). Quiet, Sie-form, helpful — never cute.
|
||||
|
||||
---
|
||||
|
||||
## 8. Motion, hover, imagery
|
||||
|
||||
- All motion is **color**, `transition: color/background/border .15–.2s`. No transform, no
|
||||
scale, no press-down.
|
||||
- Rows: `hover:bg-muted/50`. Links: 2px mint underline at `text-underline-offset:3px`,
|
||||
`text-decoration-thickness:2px`. Header nav: `white/60 → white`.
|
||||
- Backdrops `bg-black/20`. **No backdrop-blur, no glassmorphism, no gradients, no noise.**
|
||||
- Imagery is warm aged letter scans; UI chrome stays cool navy to contrast.
|
||||
- De Gruyter icons: black strokes as `<img>`, `opacity-40` resting tint (`.65` on the dark
|
||||
header), globally inverted in dark mode.
|
||||
217
design_handoff_familienarchiv_redesign/EPIC.md
Normal file
217
design_handoff_familienarchiv_redesign/EPIC.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# EPIC: Familienarchiv Visual Redesign ("Mappe")
|
||||
|
||||
> Implement **in order**. Stories 1–4 are foundation and shared primitives — every screen
|
||||
> depends on them. Do not start a screen story until 1–4 are merged, or the header, avatar,
|
||||
> and tokens get re-implemented per screen and drift. Read `DESIGN_RULES.md` first; open the
|
||||
> matching `prototypes/*.dc.html` for pixel ground truth while building each story.
|
||||
|
||||
## Epic goal
|
||||
|
||||
Reskin the entire Familienarchiv app to the unified "Mappe" archival direction and ship
|
||||
three new sections (Geschichten, Zeitstrahl, Aktivitäten). All copy German-first via
|
||||
Paraglide; light + dark mode; De Gruyter icon convention preserved.
|
||||
|
||||
## Epic-level acceptance criteria
|
||||
|
||||
- [ ] All eight screens match their prototype in light **and** dark mode.
|
||||
- [ ] Header, page-header, avatar, segmented control, and card exist as **single shared
|
||||
components** — zero duplication of the header markup or the avatar-color function.
|
||||
- [ ] No raw hex in components; everything references the semantic tokens.
|
||||
- [ ] Every visible string is a Paraglide message key, German authored first; `en`/`es`
|
||||
stubs added.
|
||||
- [ ] Icons rendered as `<img>`, invert correctly in dark mode.
|
||||
- [ ] No gradients, blur, emoji, or transform-based motion introduced.
|
||||
|
||||
---
|
||||
|
||||
> **Reframe (2026-06-16): this is alignment, not greenfield.** An audit of the live codebase
|
||||
> found the substrate already in place — the full token system (`DESIGN_RULES §1–2`), dark
|
||||
> mode, the app header, and the three "new" sections (Geschichten, Zeitstrahl, Aktivitäten)
|
||||
> all already exist. So **Stories 1–4 below are close-out + extraction**, not from-scratch
|
||||
> builds, and Stories 5–11 are "align the existing screen to its prototype," not new pages.
|
||||
> The detailed, trackable breakdown lives in the Gitea milestone **"Mappe Visual Redesign"**
|
||||
> (issues split into *shared components* then *pages*); **Phase B** at the bottom of this file
|
||||
> lists the screens that previously had no prototype and now do.
|
||||
|
||||
## Story 1 — Foundation: tokens, fonts, theme
|
||||
|
||||
**Goal:** establish the visual substrate the whole app reads from.
|
||||
|
||||
- Port `prototypes/colors_and_type.css` into the app's token layer (Tailwind 4 `@theme` /
|
||||
`layout.css`). Keep every variable name in `DESIGN_RULES.md §1`.
|
||||
- Wire Montserrat + Tinos (or licensed Gotham/Times) and the `--font-sans`/`--font-serif` vars.
|
||||
- Implement light/dark via `:root[data-theme='dark']` + the pre-paint boot script that reads
|
||||
`localStorage['theme']`. Add the global `img[src*='degruyter-icons']{filter:invert(1)}`
|
||||
dark rule.
|
||||
|
||||
**Done when:** a throwaway page using `var(--c-*)` tokens renders correct in both themes; no
|
||||
flash of wrong theme on reload.
|
||||
|
||||
## Story 2 — Shared: app header + nav (`ArchiveHeader`)
|
||||
|
||||
**Spec:** `DESIGN_RULES.md §4`. Prototype: `ArchiveHeader.dc.html`.
|
||||
|
||||
- One sticky header component: mint stripe, wordmark, nav with active-key prop, theme
|
||||
toggle, user chip. Nav routes to all sections.
|
||||
- Theme toggle drives the Story 1 mechanism.
|
||||
|
||||
**Done when:** header renders identically on every route; active item shows the mint
|
||||
underline; toggle flips theme and persists.
|
||||
|
||||
## Story 3 — Shared: avatar + deterministic color
|
||||
|
||||
**Spec:** `DESIGN_RULES.md §5`.
|
||||
|
||||
- `avatarFor(name)` util (hash → palette index + initials) + an `<Avatar name size>`
|
||||
component supporting 26/28/40/48px and the overlapping-stack ring variant.
|
||||
|
||||
**Done when:** the same name yields the same color everywhere; stacks overlap with the
|
||||
surface-colored ring.
|
||||
|
||||
## Story 4 — Shared: page-header, segmented control, card, metadata, empty state
|
||||
|
||||
**Spec:** `DESIGN_RULES.md §3, §6, §7`.
|
||||
|
||||
- `PageHeader` (eyebrow + 4px mint left rule + serif h1 + italic lede + right slot).
|
||||
- `SegmentedControl` (active = navy). `Card` (mint top/left border variants). `MetaLine`
|
||||
(` · ` separated, optional leading icon). `EmptyState` (dashed, serif + ellipsis).
|
||||
|
||||
**Done when:** each primitive matches the prototype and is consumed by the screen stories
|
||||
below — not re-styled inline.
|
||||
|
||||
---
|
||||
|
||||
## Story 5 — Dokumente (dashboard / search results)
|
||||
|
||||
**Route:** `/`. **Prototype:** `Dokumente.dc.html`.
|
||||
|
||||
PageHeader (eyebrow "Archiv", title "Dokumente", lede, right count "147 Dokumente · 38
|
||||
Personen"). Then: a **search bar card** (input `Titel, Personen, Tags durchsuchen…` +
|
||||
"Datum ↓" sort + "Filter" buttons); a **resume strip** (mint left border, "Weiter bei:" +
|
||||
italic underlined doc link); a **`1fr 320px` grid** — left: "Zuletzt hinzugefügt" list
|
||||
(title · avatar stack · right-aligned date `width:128px` · status dot+label `width:118px`),
|
||||
right column: **upload dropzone** (dashed, Upload icon, `PDF, JPG, PNG, TIFF bis 50 MB`) +
|
||||
"Benötigt Metadaten" card; below full-width **Mission Control** — 3 tiles (Segmentierung /
|
||||
Transkription / Zur Überprüfung), each with a caption, a pill "skill" hint, a weekly count,
|
||||
and a list of linked items. Right column collapses below `lg`; main goes full-width.
|
||||
|
||||
**Done when:** matches prototype both themes; status dots use the §7 colors; grid collapses.
|
||||
|
||||
## Story 6 — Personen (directory)
|
||||
|
||||
**Route:** `/persons`. **Prototype:** `Personen.dc.html`.
|
||||
|
||||
PageHeader (eyebrow "Verzeichnis") + right count "38 Personen". Search input
|
||||
(`z.B. Oma Frieda, Onkel Karl…`) + segmented control (Alle / Personen / Institutionen /
|
||||
Gruppen). **3-column card grid**; each card: 48px avatar, serif name, relation sub, optional
|
||||
type **badge** (Institution/Gruppe/Unbekannt — §1 colors), divider, meta line
|
||||
`✉ N Briefe · N Dokumente`. Cards carry the 3px mint top border.
|
||||
|
||||
**Done when:** badge colors correct; avatar colors deterministic; grid responsive.
|
||||
|
||||
## Story 7 — Briefwechsel — DROPPED
|
||||
|
||||
The two-person letter-exchange feature was removed from the product. Its prototype, route, and
|
||||
nav entry no longer exist. Skip.
|
||||
|
||||
## Story 8 — Geschichten (story collections list) — NEW
|
||||
|
||||
**Route:** `/geschichten`. **Prototype:** `Geschichten.dc.html`.
|
||||
|
||||
PageHeader (eyebrow "Sammlungen") + primary button "Neue Geschichte". Segmented control
|
||||
(Alle / Veröffentlicht / In Arbeit / Entwurf) + Filter button. **2-column card grid**; each
|
||||
card (link, mint top border): tag chips (dot + UPPERCASE label), serif 24px title, serif dek,
|
||||
meta line `📅 range · N Dokumente · N Personen`, footer with overlapping avatar stack + status
|
||||
dot/label. Cards link to the story detail.
|
||||
|
||||
**Done when:** tag dots and status colors correct; cards link to Story 9.
|
||||
|
||||
## Story 9 — Geschichte (single story detail) — NEW
|
||||
|
||||
**Route:** `/geschichten/:id`. **Prototype:** `Geschichte.dc.html`. **Two variants**
|
||||
(`variant` prop): **"Lesereise"** (a guided reading — intro + narration blocks + letter
|
||||
cards + annotation notes) and **"Sammlung"** (a collection — intro + "Erwähnte Dokumente"
|
||||
list). Centered `max-width:880px` article card (mint top border, `padding:48px 56px`):
|
||||
type badge, 38px serif title, byline row (author avatar + name + "zusammengestellt am …" +
|
||||
Bearbeiten / Löschen actions), intro paragraph, then ordered **blocks**:
|
||||
|
||||
- **narration** — 3px mint left rule, serif italic 18px.
|
||||
- **letter** — clickable row: 40px tile w/ Mail icon, serif title, meta `date · von X an Y`,
|
||||
trailing Arrow-Right icon.
|
||||
- **note** — mint-tinted (`--c-accent-bg`) left-rule box, "Anmerkung" caption + serif italic.
|
||||
|
||||
**Done when:** both variants render from the prop; block types styled per spec; Löschen uses
|
||||
`--c-danger`.
|
||||
|
||||
## Story 10 — Zeitstrahl (timeline) — NEW
|
||||
|
||||
**Route:** `/zeitstrahl`. **Prototype:** `Zeitstrahl.dc.html`.
|
||||
|
||||
PageHeader (eyebrow "Chronik") + right count. Segmented control (Alle / Briefe / Personen /
|
||||
Ereignisse) + a small legend. **Centered vertical spine** (`max-width:760px`, 2px mint center
|
||||
line). Item types stacked on the spine: **year** pill (navy), **summary** card (count + a
|
||||
12-bar monthly-density mini chart in mint + range labels), **letter** cards **alternating
|
||||
left/right** with a spine dot (`2px solid --c-primary`) and optional tag pill, **person**
|
||||
node (28px navy circle glyph + name + derived meta), **curated** node (★, mint left rule),
|
||||
**historical** band (full-width, Globe icon, serif italic, top/bottom hairline).
|
||||
|
||||
**Done when:** spine centered; letters alternate; bars scale to value %; all five item types
|
||||
render.
|
||||
|
||||
## Story 11 — Aktivitäten (activity feed) — NEW
|
||||
|
||||
**Route:** `/aktivitaeten`. **Prototype:** `Aktivitaeten.dc.html`.
|
||||
|
||||
PageHeader (eyebrow "Verlauf") + "Aktualisieren" button. Segmented control (Alle /
|
||||
Transkription / Uploads / Personen). Feed grouped by day (Heute / Gestern / Diese Woche),
|
||||
each group a UPPERCASE caption + rows. Each **row**: 40px avatar with a small **action-icon
|
||||
badge** bottom-right (Check/Upload/Chat/Edit/…), then a sentence — bold actor name +
|
||||
Montserrat verb + *italic underlined* target link — and a time sub.
|
||||
|
||||
**Done when:** grouping + icon badges match; target links styled with mint underline.
|
||||
|
||||
## Story 12 (optional) — Regeln (internal style reference)
|
||||
|
||||
**Prototype:** `Regeln.dc.html`. Internal page documenting the seven blocks (Typografie,
|
||||
Farbe, Seitenkopf, Steuerung, Avatare, Metadaten/Leerzustände). Build only if the team wants
|
||||
a living in-app reference; otherwise `DESIGN_RULES.md` is the canonical record. Gate behind
|
||||
admin/dev.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order & dependencies
|
||||
|
||||
```
|
||||
1 Tokens ─┬─ 2 Header ──┐
|
||||
├─ 3 Avatar ──┼─→ 5 Dokumente, 6 Personen,
|
||||
└─ 4 Primitives┘ 8 Geschichten → 9 Geschichte,
|
||||
10 Zeitstrahl, 11 Aktivitäten (parallelizable)
|
||||
12 Regeln (optional, last)
|
||||
→ then Phase B (added screens), all depend on 1–4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Added screens (previously un-prototyped pages)
|
||||
|
||||
These are the pages the original handoff never covered. Each now has a hifi `.dc.html`
|
||||
prototype in `prototypes/`. All depend on the shared primitives from Stories 1–4 and are
|
||||
parallelizable among themselves. **Each maps to one Gitea page-issue** in the milestone.
|
||||
Admin + OCR pages are explicitly **out of scope** here (phase-2 milestone).
|
||||
|
||||
| # | Screen | Prototype | Route(s) | Done when |
|
||||
|---|---|---|---|---|
|
||||
| B1 | Dokumente-Liste | `Dokumente-Liste.dc.html` | `/documents` | PageHeader; search card; AND/OR segmented; grouped mint-top cards; avatar-stack rows; 7px status dot+label; pagination |
|
||||
| B2 | Dokument-Detail | `Dokument-Detail.dc.html` | `/documents/[id]` | compact top bar w/ mint accent bar; PDF pane + transcription panel w/ Lesen/Bearbeiten segmented + turquoise mode; details card |
|
||||
| B3 | Dokument-Bearbeiten | `Dokument-Bearbeiten.dc.html` | `/documents/[id]/edit`, `/new`, `/bulk-edit` | split pane; progress strip; Wer&Wann + Beschreibung cards; dropzone (new); action bar (Löschen danger / Abbrechen / Zur Überprüfung / Speichern) |
|
||||
| B4 | PersonDetail | `PersonDetail.dc.html` | `/persons/[id]` | PageHeader; 2-col mint-top cards; deterministic avatar; correspondents + relationships + letter lists |
|
||||
| B5 | PersonForm | `PersonForm.dc.html` | `/persons/new`, `/[id]/edit` | PageHeader; Stammdaten card w/ type segmented; caps labels; Namensverlauf; edit-only merge danger zone; save bar |
|
||||
| B6 | PersonReview | `PersonReview.dc.html` | `/persons/review` | PageHeader + count; row card w/ muted avatar; idle/rename/merge states; danger merge+delete; confirm dialog; empty state |
|
||||
| B7 | Geschichte-Editor | `Geschichte-Editor.dc.html` | `/geschichten/new`, `/[id]/edit` | type-pick segmented; prose editor toolbar; journey editor w/ **color-only** drag; sidebar status+persons; save bar |
|
||||
| B8 | Ereignis-Editor | `Ereignis-Editor.dc.html` | `/zeitstrahl/events/new`, `/[id]/edit` | PageHeader; Wann&Was card w/ type segmented + date precision + danger error; persons/docs sidebar; save bar |
|
||||
| B9 | Stammbaum | `Stammbaum.dc.html` | `/stammbaum` | PageHeader + count; node cards w/ **§5 avatar** in resting/selected/dimmed; line connectors; side panel; zoom controls; empty state |
|
||||
| B10 | Themen | `Themen.dc.html` | `/themen` | PageHeader + count; segmented filter; mint-top cards w/ **§1 tag-dot** (not stripe); child rows; empty state |
|
||||
| B11 | Anreicherung | `Anreicherung.dc.html` | `/enrich`, `/[id]`, `/done` | list (status rows) / step (progress bar + split pane + action bar) / done (success card) |
|
||||
| B12 | Profil | `Profil.dc.html` | `/profile`, `/users/[id]` | PageHeader; 2-col data/password cards; token banners; notifications; public profile card w/ avatar |
|
||||
| B13 | Anmeldung | `Anmeldung.dc.html` | `/login`, `/register`, `/forgot-password`, `/reset-password` | self-contained branded shell (no app header); Tinos sentence-case titles; 17px inputs; token banners |
|
||||
| B14 | Hilfe-Transkription | `Hilfe-Transkription.dc.html` | `/hilfe/transkription` | PageHeader; article column; rule cards w/ De Gruyter icons (no emoji); fixes `border-brand-sand`/`bg-white` bugs |
|
||||
125
design_handoff_familienarchiv_redesign/README.md
Normal file
125
design_handoff_familienarchiv_redesign/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Handoff: Familienarchiv — Visual Redesign ("Mappe" direction)
|
||||
|
||||
## Overview
|
||||
|
||||
This package hands off a **complete visual redesign of the Familienarchiv web app** to a
|
||||
developer using Claude Code. Familienarchiv is a private digital family archive
|
||||
(German-first SvelteKit app) for digitising, transcribing, tagging and searching
|
||||
historical correspondence.
|
||||
|
||||
The redesign unifies every page under one system — internally called **"Mappe"** (German
|
||||
for *folder/portfolio*): a warm, archival, institutional look built on the De Gruyter Brill
|
||||
corporate identity. It also **adds three new sections** that did not exist in the old app
|
||||
(**Geschichten**, **Zeitstrahl**, **Aktivitäten**) alongside redesigns of the existing ones.
|
||||
|
||||
The work is broken into an **epic with foundation-first stories**. Read the three docs in
|
||||
this order:
|
||||
|
||||
1. **`README.md`** (this file) — what's here, fidelity, how to use it.
|
||||
2. **`DESIGN_RULES.md`** — the binding visual law (tokens, type, the shared patterns).
|
||||
*Read this before writing any code.*
|
||||
3. **`EPIC.md`** — the epic + ordered stories + acceptance criteria. *Implement in order.*
|
||||
|
||||
## About the design files
|
||||
|
||||
The files in `prototypes/` are **design references created in HTML** — high-fidelity
|
||||
prototypes that show the intended look and behaviour exactly. **They are not the production
|
||||
codebase and must not be shipped as-is.** They are authored as "Design Components"
|
||||
(`.dc.html`) for a prototyping runtime (`support.js`); that runtime is **not** part of the
|
||||
target app.
|
||||
|
||||
Your task is to **recreate these designs inside the existing Familienarchiv codebase**
|
||||
(SvelteKit 2 + Svelte 5 + Tailwind 4 + Paraglide i18n) using its established patterns —
|
||||
real Svelte components, real routes, real i18n message keys. Where the prototype uses a
|
||||
made-up runtime construct (`<dc-import>`, `<sc-for>`, `renderVals()`), map it to the
|
||||
codebase's idiom (a Svelte component import, an `{#each}` block, component props/state).
|
||||
|
||||
### Why HTML prototypes are the source of truth
|
||||
|
||||
Every colour, size, weight, spacing and radius is written **inline** in the prototype
|
||||
markup, and the files **render**. When the written spec and a prototype disagree, **the
|
||||
prototype wins** — open it in a browser and measure. Treat `DESIGN_RULES.md` and `EPIC.md`
|
||||
as the *intent and ordering*, and the prototypes as the *pixel ground truth*.
|
||||
|
||||
To view a prototype: open any `prototypes/*.dc.html` in a browser (they are self-contained;
|
||||
`colors_and_type.css`, `support.js` and `assets/icons/` sit alongside them). Use the
|
||||
**Hell / Dunkel** toggle in the header to verify dark mode.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**High-fidelity (hifi).** Final colours, typography, spacing, radii, shadows, copy and
|
||||
interaction intent are all decided. Recreate pixel-perfectly using the codebase's existing
|
||||
libraries. Do not re-interpret the visual language — apply it.
|
||||
|
||||
## What's in this bundle
|
||||
|
||||
```
|
||||
design_handoff_familienarchiv_redesign/
|
||||
├── README.md ← you are here
|
||||
├── DESIGN_RULES.md ← binding tokens + shared patterns (the "rules")
|
||||
├── EPIC.md ← epic, stories, acceptance criteria (implement in order)
|
||||
├── _AUTHORING_KIT.md ← the contract every prototype was authored against (copy-verbatim snippets)
|
||||
└── prototypes/ ← runnable hifi references (source of truth)
|
||||
│ ── original section screens ──
|
||||
├── ArchiveHeader.dc.html shared header + nav + theme toggle
|
||||
├── Dokumente.dc.html dashboard
|
||||
├── Personen.dc.html person directory
|
||||
├── Geschichten.dc.html curated story collections (list)
|
||||
├── Geschichte.dc.html single story detail (2 variants)
|
||||
├── Zeitstrahl.dc.html chronological timeline
|
||||
├── Aktivitaeten.dc.html activity feed
|
||||
├── Regeln.dc.html the design-rules spec page (internal reference)
|
||||
│ ── added screens (the previously un-prototyped pages) ──
|
||||
├── Dokumente-Liste.dc.html document search / grouped results
|
||||
├── Dokument-Detail.dc.html document viewer + transcription workbench
|
||||
├── Dokument-Bearbeiten.dc.html edit / new / bulk-edit (variant prop)
|
||||
├── PersonDetail.dc.html person detail (read)
|
||||
├── PersonForm.dc.html person new / edit (variant prop)
|
||||
├── PersonReview.dc.html provisional-person triage workflow
|
||||
├── Geschichte-Editor.dc.html story authoring: new / story / journey (variant prop)
|
||||
├── Ereignis-Editor.dc.html timeline event new / edit (variant prop)
|
||||
├── Stammbaum.dc.html family tree
|
||||
├── Themen.dc.html topics / tag directory
|
||||
├── Anreicherung.dc.html enrich workflow: list / step / done (variant prop)
|
||||
├── Profil.dc.html account settings + public profile (variant prop)
|
||||
├── Anmeldung.dc.html auth: login / register / forgot / reset (variant prop, no app header)
|
||||
├── Hilfe-Transkription.dc.html transcription help / guidelines
|
||||
│ ── shared assets ──
|
||||
├── colors_and_type.css design tokens (port into the app)
|
||||
├── support.js prototype runtime — DO NOT ship
|
||||
└── assets/icons/ De Gruyter "Simple" icons used by the screens
|
||||
```
|
||||
|
||||
> **Status note (2026-06-16).** Two facts updated this bundle since the original handoff:
|
||||
> 1. **This is now an alignment effort, not a greenfield reskin.** The token system
|
||||
> (`DESIGN_RULES §1–2`), dark mode, the app header, and the three "new" sections
|
||||
> (Geschichten, Zeitstrahl, Aktivitäten) **already exist in the codebase**. Foundation
|
||||
> Stories 1–4 are *close-out + extraction* of the missing shared primitives (`PageHeader`,
|
||||
> `Avatar`/`avatarFor`, `SegmentedControl`, `Card`, `MetaLine`, `EmptyState`, `StatusDot`),
|
||||
> not from-scratch builds. See the Gitea milestone **"Mappe Visual Redesign"** for the
|
||||
> issue-level breakdown (shared components, then pages).
|
||||
> 2. **Briefwechsel was dropped** — that feature was removed from the product, so its
|
||||
> prototype and nav entry are gone. Admin + OCR pages are deferred to a phase-2 milestone.
|
||||
|
||||
## Assets
|
||||
|
||||
- **Icons** — De Gruyter "Simple" Medium-24px SVGs in `prototypes/assets/icons/`. In the
|
||||
real app these live at `static/degruyter-icons/Simple/…` and are rendered as `<img>`
|
||||
tags. Reuse the existing app copies; this bundle ships only the subset the redesign
|
||||
touches so you can see which are needed.
|
||||
- **Fonts** — Montserrat + Tinos via Google Fonts (substitutes for Gotham + Times). The
|
||||
`@import` is at the top of `colors_and_type.css`. If the team has the licensed Gotham/Times
|
||||
faces, swap them in and keep the variable names.
|
||||
- **Logo** — none. The brand is the wordmark `FAMILIENARCHIV` (Montserrat Bold, uppercase,
|
||||
`letter-spacing:.16em`) on the navy header.
|
||||
|
||||
## Target codebase notes
|
||||
|
||||
- **i18n**: all copy in the prototypes is German. The app is German-first with `en`/`es`
|
||||
translations (Paraglide). Every visible string must become a message key, German written
|
||||
first. Existing keys live in `frontend/messages/{de,en,es}.json`.
|
||||
- **Icons as `<img>`**: keep the app convention — never inline SVG, never icon fonts. Dark
|
||||
mode inverts them globally via `img[src*='degruyter-icons'] { filter: invert(1); }`.
|
||||
- **Tailwind 4**: the prototypes use inline styles for clarity. Port them to the codebase's
|
||||
Tailwind utilities / `@theme` tokens — but the *values* must match the tokens in
|
||||
`DESIGN_RULES.md` exactly.
|
||||
275
design_handoff_familienarchiv_redesign/_AUTHORING_KIT.md
Normal file
275
design_handoff_familienarchiv_redesign/_AUTHORING_KIT.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Prototype Authoring Kit — "Mappe" redesign
|
||||
|
||||
You are authoring a **`.dc.html` design prototype** for the Familienarchiv redesign. These
|
||||
prototypes are the **pixel ground truth** for one screen. They are NOT production code — they
|
||||
run in a tiny prototyping runtime (`support.js`) and are opened directly in a browser.
|
||||
|
||||
**Read alongside this kit:** `DESIGN_RULES.md` (the binding visual law) and the existing
|
||||
prototype you are told to use as a template. When this kit and `DESIGN_RULES.md` disagree,
|
||||
`DESIGN_RULES.md` wins; when a rule and an existing rendered prototype disagree, the
|
||||
prototype wins.
|
||||
|
||||
Everything below is **copy-verbatim**. Do not invent new tokens, colors, fonts, radii, or
|
||||
spacings. Use only `var(--c-*)` / `var(--font-*)` / `var(--shadow-*)` and the literal values
|
||||
shown here.
|
||||
|
||||
---
|
||||
|
||||
## 1. File skeleton (copy exactly; fill the `<main>` and the `renderVals()`)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button,textarea,select{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
<dc-import name="ArchiveHeader" active="ACTIVE_KEY" hint-size="100%,68px"></dc-import>
|
||||
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||
<!-- PAGE CONTENT HERE -->
|
||||
</main>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name,
|
||||
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||
}
|
||||
renderVals(){
|
||||
return { /* data the template renders */ };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- `ACTIVE_KEY` ∈ `dokumente · personen · geschichten · zeitstrahl · aktivitaeten · stammbaum · themen · regeln`.
|
||||
Pick the section your page belongs to (e.g. a document edit page → `dokumente`; a person
|
||||
edit page → `personen`; a timeline event editor → `zeitstrahl`).
|
||||
- **Auth pages (`Anmeldung`) have NO `ArchiveHeader`** — they get their own branded shell
|
||||
(see §11).
|
||||
|
||||
## 2. Runtime constructs (this is all the runtime understands)
|
||||
|
||||
- `{{ expr }}` — interpolate a value from `renderVals()` (path access: `a.b`, `a[0]`). Works
|
||||
in text and in any attribute, including `style="{{ obj }}"` where `obj` is a JS style
|
||||
object.
|
||||
- `<sc-for list="{{ items }}" as="x" hint-placeholder-count="6"> … {{ x.foo }} … </sc-for>`
|
||||
- `<sc-if value="{{ flag }}" hint-placeholder-val="{{ false }}"> … </sc-if>`
|
||||
- `onClick="{{ handler }}"` where `handler` is a function returned from `renderVals()`.
|
||||
- The logic class is `class Component extends DCLogic`. `renderVals()` returns the flat data
|
||||
object. Use `this.props.X` to read a prop. `this.state` + `this.setState({...})` for
|
||||
interactivity (rarely needed — prototypes are mostly static).
|
||||
- To inject a raw element from logic (e.g. an icon inside a loop), use
|
||||
`React.createElement('img', { className:'dgicon', src:'assets/icons/Mail-MD.svg', style:{width:18,height:18,opacity:.5} })`.
|
||||
- **camelCase event/style props** in the template: `onClick`, not `onclick`.
|
||||
|
||||
## 3. PageHeader (DESIGN_RULES §3) — every top-level page opens with this
|
||||
|
||||
```html
|
||||
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">EYEBROW</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Title</h1>
|
||||
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Lede sentence, Sie-form.</p>
|
||||
</div>
|
||||
<!-- right slot: a count span OR a primary button (see §6) -->
|
||||
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">147 Dokumente</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Edit/detail/form pages still open with a PageHeader (eyebrow like `PERSON BEARBEITEN`,
|
||||
`EREIGNIS`, `NEUE PERSON`). Immersive split-pane workbenches (document detail viewer,
|
||||
document/enrich edit) may use a compact top bar instead — follow your page brief.
|
||||
|
||||
## 4. Card (DESIGN_RULES §7)
|
||||
|
||||
Base card, **with the 3px mint top-border archival signature** (most content cards):
|
||||
|
||||
```html
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">…</div>
|
||||
```
|
||||
|
||||
Variants: inline/resume strip uses `border-left:3px solid var(--c-accent)` instead of the top
|
||||
border. Section caption inside a card:
|
||||
|
||||
```html
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Section title</h2>
|
||||
```
|
||||
|
||||
## 5. Segmented control (DESIGN_RULES §6) — filters / view switches / binary type picks
|
||||
|
||||
```html
|
||||
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:10px 16px; cursor:pointer">Alle</span>
|
||||
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:10px 16px; border-left:1px solid var(--c-line); cursor:pointer">Zweitens</span>
|
||||
<!-- repeat inactive segments; each adds border-left:1px solid var(--c-line) -->
|
||||
</div>
|
||||
```
|
||||
|
||||
Active = `background:var(--c-primary); color:var(--c-primary-fg)`. Inactive =
|
||||
`background:var(--c-surface); color:var(--c-ink-2)`.
|
||||
|
||||
## 6. Buttons (DESIGN_RULES §2 casing law — all UPPERCASE Montserrat 700)
|
||||
|
||||
```html
|
||||
<!-- PRIMARY (navy) -->
|
||||
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||
|
||||
<!-- SECONDARY (bordered) -->
|
||||
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Abbrechen</button>
|
||||
|
||||
<!-- DANGER (destructive — Löschen) -->
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||
```
|
||||
|
||||
## 7. Form field + label (DESIGN_RULES §1, §2)
|
||||
|
||||
```html
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel</span>
|
||||
<input placeholder="z.B. Brief an Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
```
|
||||
|
||||
Inputs are Tinos 16px (auth inputs 17px). Textareas same. Focus is shown by the runtime's
|
||||
default outline; if you add a visible focus style use a 2px `var(--c-focus-ring)` outline with
|
||||
2px offset — never remove focus without a replacement.
|
||||
|
||||
## 8. MetaLine (DESIGN_RULES §7) — ` · `-separated, optional leading icon
|
||||
|
||||
```html
|
||||
<div style="display:flex; align-items:center; gap:8px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||
<span>14. März 1923</span><span>·</span><span>14 Dokumente</span><span>·</span><span>4 Personen</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 9. Status dot (DESIGN_RULES §7) — 7px circle + UPPERCASE label
|
||||
|
||||
```html
|
||||
<span style="display:inline-flex; align-items:center; gap:7px">
|
||||
<span style="width:7px; height:7px; border-radius:999px; background:#5a8a6a"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">Transkribiert</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
Status colors: Transkribiert / Veröffentlicht / Bestätigt `#5a8a6a` · In Arbeit `#c17a00` ·
|
||||
Neu / Entwurf / Unbestätigt `#607080`. **Never color alone** — always the label too.
|
||||
|
||||
## 10. Empty state (DESIGN_RULES §7) — dashed, serif heading, German ellipsis
|
||||
|
||||
```html
|
||||
<div style="border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center">
|
||||
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Noch keine Geschichten angelegt.</div>
|
||||
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Beginnen Sie mit einem Brief…</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 11. Auth shell (only for `Anmeldung.dc.html` — no ArchiveHeader)
|
||||
|
||||
```html
|
||||
<div style="min-height:100vh; display:flex; flex-direction:column; background:var(--c-canvas)">
|
||||
<header style="background:var(--c-header)">
|
||||
<div style="height:4px; background:#a1dcd8"></div>
|
||||
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center; color:#fff">
|
||||
<span style="font-family:var(--font-sans); font-size:18px; font-weight:700; letter-spacing:.16em; text-transform:uppercase">Familienarchiv</span>
|
||||
</div>
|
||||
</header>
|
||||
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:40px 16px">
|
||||
<div style="width:100%; max-width:400px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 8px">Anmelden</h1>
|
||||
<!-- labelled 17px inputs (§7), full-width primary button (§6), secondary link row -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Title is **Tinos sentence-case** (NOT an uppercase chrome label). Inputs 17px. Register
|
||||
variant uses `max-width:640px` and sectioned fields.
|
||||
|
||||
## 12. Icons (render as `<img class="dgicon">`, never inline SVG, never emoji)
|
||||
|
||||
```html
|
||||
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:16px; height:16px; opacity:.4">
|
||||
```
|
||||
|
||||
Available in `assets/icons/`: `Account-MD` · `Arrow-Right-MD` · `Bookmarks-MD` ·
|
||||
`Calendar-Add-MD` · `Chat-MD` · `Check-MD` · `Copy-Item-MD` · `Edit-Content-MD` ·
|
||||
`Filter-MD` · `Folder-MD` · `Globe-MD` · `Library-MD` · `Location-MD` · `Mag-Glass-MD` ·
|
||||
`Mail-MD` · `Refresh-MD` · `Upload-MD` · `View-More-MD`. Use only these; pick the closest
|
||||
match. Icons rest at `opacity:.4` (`.5` on meta lines). They invert automatically in dark
|
||||
mode via the `.dgicon` rule in the skeleton.
|
||||
|
||||
## 13. Hard rules checklist (verify before you finish)
|
||||
|
||||
- [ ] Every color is a `var(--c-*)` token — **zero** raw hex except the `av()` palette and the
|
||||
`#a1dcd8` mint stripe inside the header/auth shell. No `red-*`/`gray-*`/Tailwind.
|
||||
- [ ] Casing law: UI chrome (labels, buttons, nav, captions, tags, status, eyebrow) is
|
||||
**UPPERCASE Montserrat 700 + wide tracking**; headlines & body & names are **Tinos
|
||||
sentence case**. Quotes use `„…"`; snippets are *italic*.
|
||||
- [ ] Cards carry the 3px mint **top** border (or 3px mint **left** for inline strips).
|
||||
- [ ] Touch targets ≥ 44px (`min-height:44px`) on buttons / interactive rows / icon buttons.
|
||||
- [ ] Icon-only buttons would carry an `aria-label` in production — add `title="…"` in the
|
||||
prototype so intent is clear.
|
||||
- [ ] **No** transforms, scale, translate, blur, glassmorphism, gradients, or emoji. Motion is
|
||||
color only.
|
||||
- [ ] German, formal **Sie**. Use realistic family-archive content (Kurrent/Sütterlin letters
|
||||
~1894–1945; people like Herbert Cram, Clara Cram, Marie Cram, Eugenie de Gruyter).
|
||||
- [ ] Renders correctly in light AND dark — because you used tokens, it will. Do not hardcode
|
||||
anything that breaks dark mode.
|
||||
- [ ] Avatars use `this.av(name)` and the size objects (`a26/a28/a40/a48`). Same name → same
|
||||
color (10-color palette).
|
||||
|
||||
## 14. Realistic data
|
||||
|
||||
Pull field names / sections from the **current Svelte page** you are given so the prototype
|
||||
reflects what the app actually shows. Invent plausible German archival content for the values.
|
||||
Aim for enough rows/items to show the layout breathing (e.g. 6–9 grid cards, 4–8 list rows).
|
||||
|
||||
## 15. Person chip (multiselect / token input)
|
||||
|
||||
A selected person inside a form (person multiselect, sender/receiver token input, etc.) is a
|
||||
**square 2px rectangle chip** — `--radius-sm`, matching inputs/cards across the app. The
|
||||
**avatar inside stays round**, but the chip container is **NOT** a round pill. (`radius-full`
|
||||
in §1 is for avatars, dots, and status/count pills — not person chips.) Use this exact
|
||||
shape everywhere a removable person appears:
|
||||
|
||||
```html
|
||||
<span style="display:inline-flex; align-items:center; gap:8px; background:var(--c-muted); border:1px solid var(--c-line); border-radius:2px; padding:4px 6px 4px 4px">
|
||||
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ p.name }}</span>
|
||||
<a href="#" title="Person entfernen" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; width:28px; height:28px; font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-3); text-decoration:none">×</a>
|
||||
</span>
|
||||
```
|
||||
|
||||
The person **name** is content → Tinos serif. The chip itself is `border-radius:2px`. Wrap
|
||||
chip lists in `display:flex; flex-wrap:wrap; gap:8px`. (A read-only correspondent link uses
|
||||
the same square 2px container with no `×`.)
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
<dc-import name="ArchiveHeader" active="aktivitaeten" hint-size="100%,68px"></dc-import>
|
||||
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||
|
||||
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Verlauf</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Aktivitäten</h1>
|
||||
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Wer zuletzt transkribiert, ergänzt und kuratiert hat — die Hände hinter dem Archiv.</p>
|
||||
</div>
|
||||
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; background:var(--c-surface); border:1px solid var(--c-line); padding:10px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer">
|
||||
<img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:15px; height:15px; opacity:.55"> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- filter -->
|
||||
<div style="margin-bottom:30px">
|
||||
<div style="display:inline-flex; border:1px solid var(--c-line)">
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Alle</span>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Transkription</span>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Uploads</span>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Personen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- feed -->
|
||||
<sc-for list="{{ acts }}" as="g" hint-placeholder-count="3">
|
||||
<div style="margin-bottom:30px">
|
||||
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">{{ g.day }}</div>
|
||||
<sc-for list="{{ g.items }}" as="a" hint-placeholder-count="3">
|
||||
<div style="display:flex; gap:14px; align-items:flex-start; padding:16px 18px; background:var(--c-surface); border:1px solid var(--c-line); margin-bottom:10px">
|
||||
<div style="position:relative; flex-shrink:0">
|
||||
<span style="{{ a.a40 }}">{{ a.initials }}</span>
|
||||
<span style="position:absolute; right:-4px; bottom:-4px; width:20px; height:20px; border-radius:999px; background:var(--c-surface); border:1px solid var(--c-line); display:flex; align-items:center; justify-content:center">
|
||||
{{ a.iconEl }}
|
||||
</span>
|
||||
</div>
|
||||
<div style="flex:1; min-width:0; padding-top:2px">
|
||||
<p style="margin:0; font-family:var(--font-sans); font-size:14px; line-height:1.5; color:var(--c-ink-2)">
|
||||
<span style="font-weight:600; color:var(--c-ink)">{{ a.name }}</span> {{ a.verb }} <a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:16px; font-style:italic; color:var(--c-ink); text-decoration:underline; text-decoration-color:var(--c-accent); text-underline-offset:3px; text-decoration-thickness:2px">{{ a.target }}</a>
|
||||
</p>
|
||||
<div style="margin-top:4px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ a.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-for>
|
||||
</main>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name, a40:{ ...base, width:40, height:40, fontSize:14 } };
|
||||
}
|
||||
|
||||
activities(){
|
||||
const mk = (actor, verb, target, time, icon) => ({ ...this.av(actor), verb, target, time, iconName:icon });
|
||||
return [
|
||||
{ day:'Heute', items:[
|
||||
mk('Frieda Rose','hat die Transkription abgeschlossen von','Brief an Frieda aus dem Harz','09:42','Check-MD'),
|
||||
mk('Karl Müller','hat 3 Seiten segmentiert in','Konvolut Rose, Heft III','08:15','Copy-Item-MD'),
|
||||
mk('Anna Bauer','hat einen Kommentar hinterlassen zu','Postkarte aus Wien','07:51','Chat-MD'),
|
||||
]},
|
||||
{ day:'Gestern', items:[
|
||||
mk('Otto Schmidt','hat zwei Dokumente hochgeladen in','Mappe B','17:30','Upload-MD'),
|
||||
mk('Margarete Hoffmann','hat Metadaten ergänzt zu','Umschlag, 1924','14:05','Edit-Content-MD'),
|
||||
mk('Wilhelm Rose','hat die Geschichte erstellt','Wilhelms Lehrjahre','11:20','Bookmarks-MD'),
|
||||
]},
|
||||
{ day:'Diese Woche', items:[
|
||||
mk('Elise Vogt','hat zur Überprüfung freigegeben','Brief aus der Lehrzeit','Di.','Check-MD'),
|
||||
mk('Heinrich Rose','ist dem Archiv beigetreten','','Mo.','Account-MD'),
|
||||
]},
|
||||
];
|
||||
}
|
||||
|
||||
renderVals(){
|
||||
return {
|
||||
acts: this.activities().map(g => ({ ...g, items: g.items.map(a => ({ ...a, iconEl: React.createElement('img', { className:'dgicon', src:'assets/icons/'+a.iconName+'.svg', style:{ width:12, height:12, opacity:.65 } }) })) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,256 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button,textarea,select{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
|
||||
<!-- ═══ AUTH SHELL (§11) — no ArchiveHeader on auth pages ═══ -->
|
||||
<div style="min-height:100vh; display:flex; flex-direction:column; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
|
||||
<!-- branded auth header: navy bar + 4px mint stripe + wordmark -->
|
||||
<header style="background:var(--c-header)">
|
||||
<div style="height:4px; background:#a1dcd8"></div>
|
||||
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center; color:#fff">
|
||||
<span style="font-family:var(--font-sans); font-size:18px; font-weight:700; letter-spacing:.16em; text-transform:uppercase">Familienarchiv</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:48px 16px">
|
||||
<div style="width:100%; max-width:{{ cardMaxWidth }}">
|
||||
|
||||
<!-- ─────────────────────────────────────────────── LOGIN ── -->
|
||||
<sc-if value="{{ isLogin }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||
|
||||
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 6px">Anmelden</h1>
|
||||
<p style="font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-2); margin:0 0 24px">Willkommen zurück im Familienarchiv.</p>
|
||||
|
||||
<!-- token-driven error banner (var(--c-danger)) -->
|
||||
<div role="alert" style="display:flex; align-items:flex-start; gap:10px; margin-bottom:18px; border:1px solid var(--c-danger); border-left:3px solid var(--c-danger); background:var(--c-accent-bg); border-radius:2px; padding:11px 14px">
|
||||
<span style="width:7px; height:7px; border-radius:999px; background:var(--c-danger); margin-top:6px; flex-shrink:0"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-danger)">E-Mail oder Passwort ist nicht korrekt.</span>
|
||||
</div>
|
||||
|
||||
<!-- rate-limit / warning note (tinted with accent-bg) -->
|
||||
<div role="status" style="display:flex; align-items:flex-start; gap:10px; margin-bottom:22px; border:1px solid var(--c-line); border-left:3px solid var(--c-warning); background:var(--c-accent-bg); border-radius:2px; padding:11px 14px">
|
||||
<img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:15px; height:15px; opacity:.5; margin-top:1px; flex-shrink:0">
|
||||
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-2)">Zu viele Versuche. Bitte warten Sie eine Minute, bevor Sie es erneut versuchen.</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:18px">
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||
<input type="email" autocomplete="email" placeholder="vorname@familie.de" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:24px">
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort</span>
|
||||
<input type="password" autocomplete="current-password" placeholder="••••••••" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Anmelden</button>
|
||||
|
||||
<!-- secondary link row -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:20px; padding-top:18px; border-top:1px solid var(--c-line-2)">
|
||||
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Passwort vergessen?</a>
|
||||
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Konto erstellen</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ───────────────────────────────────────── REGISTRIEREN ── -->
|
||||
<sc-if value="{{ isRegister }}" hint-placeholder-val="{{ false }}">
|
||||
<div>
|
||||
<!-- hero: Montserrat eyebrow + Tinos ~38px title -->
|
||||
<div style="text-align:center; margin-bottom:28px">
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:10px">Einladung angenommen</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:38px; line-height:1.15; color:var(--c-ink); margin:0">Konto erstellen</h1>
|
||||
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:12px auto 0; max-width:440px">Legen Sie Ihren Zugang an, um Briefe zu lesen, zu transkribieren und mitzuschreiben.</p>
|
||||
</div>
|
||||
|
||||
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 36px">
|
||||
|
||||
<!-- Section: Über dich -->
|
||||
<section style="margin-bottom:30px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Über Sie</h2>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px">
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Vorname</span>
|
||||
<input type="text" autocomplete="given-name" placeholder="z.B. Marie" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Nachname</span>
|
||||
<input type="text" autocomplete="family-name" placeholder="z.B. Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: Konto -->
|
||||
<section style="margin-bottom:30px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Konto</h2>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||
<input type="email" autocomplete="email" placeholder="marie@familie.de" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<!-- Passwort with show/hide affordance -->
|
||||
<label style="display:block; margin-bottom:8px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort</span>
|
||||
<div style="position:relative">
|
||||
<input type="password" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 64px 12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<button type="button" title="Passwort anzeigen" class="fa-link" style="position:absolute; right:6px; top:50%; transform:translateY(-50%); display:inline-flex; align-items:center; min-height:36px; padding:0 12px; background:transparent; border:none; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); cursor:pointer">Zeigen</button>
|
||||
</div>
|
||||
</label>
|
||||
<p style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); margin:0 0 18px">Mindestens 8 Zeichen.</p>
|
||||
|
||||
<!-- Passwort bestätigen with show/hide affordance -->
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort bestätigen</span>
|
||||
<div style="position:relative">
|
||||
<input type="password" autocomplete="new-password" placeholder="Passwort wiederholen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 64px 12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<button type="button" title="Passwort anzeigen" class="fa-link" style="position:absolute; right:6px; top:50%; transform:translateY(-50%); display:inline-flex; align-items:center; min-height:36px; padding:0 12px; background:transparent; border:none; font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); cursor:pointer">Zeigen</button>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Section: Benachrichtigungen — checkbox card -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 14px">Benachrichtigungen</h2>
|
||||
<label style="display:flex; align-items:flex-start; gap:14px; cursor:pointer; border:1px solid var(--c-primary); background:var(--c-accent-bg); border-radius:2px; padding:16px">
|
||||
<span style="margin-top:1px; display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; flex-shrink:0; border:1px solid var(--c-primary); background:var(--c-primary); border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:13px; height:13px; filter:brightness(0) invert(1)">
|
||||
</span>
|
||||
<span style="min-width:0">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:14px; font-weight:700; color:var(--c-ink)">Bei Erwähnungen benachrichtigen</span>
|
||||
<span style="display:block; font-family:var(--font-serif); font-size:15px; line-height:1.5; color:var(--c-ink-2); margin-top:3px">Erhalten Sie eine E-Mail, wenn jemand Sie in einem Kommentar oder einer Transkription erwähnt.</span>
|
||||
</span>
|
||||
<input type="checkbox" checked style="position:absolute; opacity:0; width:0; height:0">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Registrieren</button>
|
||||
|
||||
<p style="text-align:center; font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3); margin:18px 0 0">
|
||||
Schon ein Konto?
|
||||
<a href="Anmeldung.dc.html" class="fa-link" style="font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">Anmelden</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ───────────────────────────────── PASSWORT VERGESSEN ── -->
|
||||
<sc-if value="{{ isForgot }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||
|
||||
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 8px">Passwort zurücksetzen</h1>
|
||||
<p style="font-family:var(--font-serif); font-size:15px; line-height:1.6; color:var(--c-ink-2); margin:0 0 24px">Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link, mit dem Sie ein neues Passwort vergeben können.</p>
|
||||
|
||||
<!-- success banner example (token-driven, accent-bg tint) -->
|
||||
<div role="status" style="display:flex; align-items:flex-start; gap:10px; margin-bottom:22px; border:1px solid var(--c-line); border-left:3px solid var(--c-accent); background:var(--c-accent-bg); border-radius:2px; padding:12px 14px">
|
||||
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:15px; height:15px; opacity:.5; margin-top:1px; flex-shrink:0">
|
||||
<span style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-2)">Falls ein Konto zu dieser Adresse besteht, ist der Link unterwegs. Prüfen Sie Ihr Postfach.</span>
|
||||
</div>
|
||||
|
||||
<label style="display:block; margin-bottom:24px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">E-Mail</span>
|
||||
<input type="email" autocomplete="email" placeholder="vorname@familie.de" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Link senden</button>
|
||||
|
||||
<div style="text-align:center; margin-top:20px; padding-top:18px; border-top:1px solid var(--c-line-2)">
|
||||
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Zurück zur Anmeldung</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ────────────────────────────── PASSWORT ZURÜCKSETZEN ── -->
|
||||
<sc-if value="{{ isReset }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="width:100%; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:36px 32px">
|
||||
|
||||
<h1 style="font-family:var(--font-serif); font-size:30px; font-weight:700; color:var(--c-ink); margin:0 0 8px">Neues Passwort</h1>
|
||||
<p style="font-family:var(--font-serif); font-size:15px; line-height:1.6; color:var(--c-ink-2); margin:0 0 24px">Vergeben Sie ein neues Passwort für Ihr Konto. Danach werden Sie zur Anmeldung weitergeleitet.</p>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Neues Passwort</span>
|
||||
<input type="password" autocomplete="new-password" placeholder="Mindestens 8 Zeichen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<label style="display:block; margin-bottom:24px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Passwort bestätigen</span>
|
||||
<input type="password" autocomplete="new-password" placeholder="Passwort wiederholen" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:12px 14px; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<button class="fa-link" style="width:100%; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:12px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Passwort speichern</button>
|
||||
|
||||
<div style="text-align:center; margin-top:20px; padding-top:18px; border-top:1px solid var(--c-line-2)">
|
||||
<a href="Anmeldung.dc.html" class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Zurück zur Anmeldung</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- quiet footer wordmark -->
|
||||
<div style="padding:24px 16px; text-align:center">
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--c-ink-3)">Familienarchiv</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1100},"variant":{"editor":"enum","options":["login","registrieren","passwort-vergessen","passwort-zuruecksetzen"],"default":"login","tsType":"string"}}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name,
|
||||
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||
}
|
||||
renderVals(){
|
||||
const variant = this.props.variant || 'login';
|
||||
const isLogin = variant === 'login';
|
||||
const isRegister = variant === 'registrieren';
|
||||
const isForgot = variant === 'passwort-vergessen';
|
||||
const isReset = variant === 'passwort-zuruecksetzen';
|
||||
|
||||
return {
|
||||
isLogin, isRegister, isForgot, isReset,
|
||||
cardMaxWidth: isRegister ? '640px' : '400px',
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,282 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button,textarea,select{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||
|
||||
<!-- ════════════ VARIANT: LISTE (start screen) ════════════ -->
|
||||
<sc-if value="{{ isListe }}" hint-placeholder-val="{{ true }}">
|
||||
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||
|
||||
<!-- PageHeader (§3) -->
|
||||
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Aufgabe</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Anreicherung</h1>
|
||||
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Ergänzen Sie fehlende Angaben Stück für Stück — wir führen Sie durch jeden Brief.</p>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; align-items:flex-end; gap:10px">
|
||||
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Arrow-Right-MD.svg" style="width:15px; height:15px; opacity:.7; filter:invert(1)">Starten</button>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">{{ count }} Dokumente offen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: documents needing metadata (§4 — 3px mint top border) -->
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 8px">Benötigt Metadaten</h2>
|
||||
|
||||
<sc-for list="{{ docs }}" as="d" hint-placeholder-count="6">
|
||||
<div style="display:flex; align-items:center; gap:16px; border-bottom:1px solid var(--c-line-2); padding:13px 0; min-height:44px">
|
||||
<label style="display:inline-flex; align-items:center; justify-content:center; min-width:44px; min-height:44px; cursor:pointer; flex-shrink:0">
|
||||
<input type="checkbox" style="width:18px; height:18px; accent-color:var(--c-primary); cursor:pointer">
|
||||
</label>
|
||||
<a href="Anreicherung.dc.html" class="fa-link" style="flex:1; min-width:0; font-family:var(--font-serif); font-size:17px; color:var(--c-ink); text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ d.title }}</a>
|
||||
<span style="display:inline-flex; align-items:center; gap:7px; width:128px; flex-shrink:0">
|
||||
<span style="width:7px; height:7px; border-radius:999px; flex-shrink:0; background:{{ d.dotColor }}"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ d.status }}</span>
|
||||
</span>
|
||||
<div style="display:flex; align-items:center; gap:8px; width:160px; flex-shrink:0; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-2)">
|
||||
<img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:14px; height:14px; opacity:.5">
|
||||
<span>{{ d.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
|
||||
<p style="margin:16px 0 0; font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">{{ count }} Dokumente warten auf Pflichtangaben.</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (§10) — shown when nothing left to enrich -->
|
||||
<sc-if value="{{ isEmpty }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin-top:18px; border:1px dashed var(--c-line); border-radius:2px; padding:48px 24px; text-align:center">
|
||||
<div style="font-family:var(--font-serif); font-size:20px; color:var(--c-ink); margin-bottom:8px">Alles angereichert — nichts zu tun.</div>
|
||||
<div style="font-family:var(--font-sans); font-size:13px; color:var(--c-ink-3)">Jeder Brief trägt Titel, Datum und Absender…</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</main>
|
||||
</sc-if>
|
||||
|
||||
<!-- ════════════ VARIANT: SCHRITT (guided editor) ════════════ -->
|
||||
<sc-if value="{{ isSchritt }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex; flex-direction:column">
|
||||
|
||||
<!-- compact workflow top bar -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; border-bottom:1px solid var(--c-line); background:var(--c-surface); padding:12px 24px">
|
||||
<a href="Anreicherung.dc.html" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; border:1px solid var(--c-line); background:var(--c-surface); padding:9px 14px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; border-radius:2px">← Zurück</a>
|
||||
<p style="flex:1; min-width:0; text-align:center; font-family:var(--font-serif); font-size:16px; font-weight:700; color:var(--c-ink); margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ doc.title }}</p>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); flex-shrink:0">Schritt {{ doc.step }} von {{ doc.total }}</span>
|
||||
</div>
|
||||
|
||||
<!-- EnrichProgress block -->
|
||||
<div style="display:flex; align-items:center; gap:14px; border-bottom:1px solid var(--c-line); background:var(--c-surface); padding:10px 24px">
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); white-space:nowrap">Pflichtfelder {{ doc.filled }} / {{ doc.required }}</span>
|
||||
<div style="flex:1; height:4px; border-radius:999px; background:var(--c-line); overflow:hidden">
|
||||
<div style="height:100%; border-radius:999px; background:var(--c-primary); width:{{ doc.progressPct }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- split pane -->
|
||||
<div style="display:grid; grid-template-columns:6fr 4fr; align-items:stretch; min-height:calc(100vh - 68px - 110px)">
|
||||
|
||||
<!-- LEFT: PDF preview -->
|
||||
<div style="border-right:1px solid var(--c-line); background:var(--c-pdf-bg); display:flex; flex-direction:column">
|
||||
<div style="display:flex; align-items:center; border-bottom:1px solid var(--c-line); background:var(--c-surface); padding:8px 16px">
|
||||
<label class="fa-link" style="margin-left:auto; display:inline-flex; align-items:center; gap:8px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); cursor:pointer"><img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:14px; height:14px; opacity:.5">Datei ersetzen</label>
|
||||
</div>
|
||||
<div style="flex:1; display:flex; align-items:flex-start; justify-content:center; padding:28px 16px; overflow:auto">
|
||||
<div style="width:100%; max-width:520px; aspect-ratio:1 / 1.414; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-md); border-radius:2px; padding:40px 38px; display:flex; flex-direction:column; gap:14px">
|
||||
<div style="font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-3); text-align:right">Wien, im August 1924</div>
|
||||
<div style="font-family:var(--font-serif); font-size:17px; color:var(--c-ink-2)">Meine liebe Clara,</div>
|
||||
<div style="font-family:var(--font-serif); font-size:15px; line-height:1.8; color:var(--c-ink-3)">die Reise verlief gut, doch der Zug nach Mariahilf hatte Verspätung. Onkel Walter lässt herzlich grüßen und fragt nach den Kindern.</div>
|
||||
<div style="font-family:var(--font-serif); font-size:15px; line-height:1.8; color:var(--c-ink-3)">Die Tage hier sind warm; ich denke oft an unseren Garten und an Euch alle daheim.</div>
|
||||
<div style="margin-top:auto; font-family:var(--font-serif); font-style:italic; font-size:15px; color:var(--c-ink-3); text-align:right">In Liebe, Herbert</div>
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3)">Seite 1 von 1</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: metadata form -->
|
||||
<div style="display:flex; flex-direction:column; background:var(--c-canvas)">
|
||||
<div style="flex:1; overflow-y:auto; padding:20px; display:flex; flex-direction:column; gap:18px">
|
||||
|
||||
<!-- Wer & Wann card -->
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Wer & Wann</h2>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Datum *</span>
|
||||
<input value="02.08.1924" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-top:7px; font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">
|
||||
<span>Genauigkeit:</span><span style="color:var(--c-ink-2)">Tag</span><span>·</span><span>Monat</span><span>·</span><span>Jahr</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Absender *</span>
|
||||
<div style="position:relative">
|
||||
<input value="Herbert Cram" placeholder="Person suchen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Empfänger</span>
|
||||
<div style="position:relative">
|
||||
<input value="Clara Cram" placeholder="Personen hinzufügen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Ort</span>
|
||||
<div style="position:relative">
|
||||
<input value="" placeholder="z.B. Wien" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Location-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Beschreibung card -->
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 16px">Beschreibung</h2>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel *</span>
|
||||
<input value="Postkarte aus Wien" placeholder="z.B. Brief an Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:10px; margin:0 0 18px">
|
||||
<div style="flex:1; height:1px; background:var(--c-line)"></div>
|
||||
<span style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3)">Optional</span>
|
||||
<div style="flex:1; height:1px; background:var(--c-line)"></div>
|
||||
</div>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Schlagworte</span>
|
||||
<input value="Feldpost, Reise, Familie" placeholder="Schlagworte hinzufügen…" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<label style="display:block; margin-bottom:18px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Inhalt</span>
|
||||
<textarea rows="4" placeholder="Kurze Zusammenfassung des Inhalts…" style="width:100%; resize:vertical; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; line-height:1.6; color:var(--c-ink); outline:none; border-radius:2px"></textarea>
|
||||
</label>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:18px">
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Karton</span>
|
||||
<input value="Mappe B" placeholder="z.B. Mappe B" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Mappe</span>
|
||||
<input value="" placeholder="z.B. Heft III" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action bar -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--c-line); background:var(--c-surface); padding:14px 20px">
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">Überspringen</a>
|
||||
<div style="display:flex; align-items:center; gap:12px">
|
||||
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern & geprüft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- ════════════ VARIANT: FERTIG (done) ════════════ -->
|
||||
<sc-if value="{{ isFertig }}" hint-placeholder-val="{{ false }}">
|
||||
<main style="max-width:1180px; margin:0 auto; padding:80px 32px; display:flex; justify-content:center">
|
||||
<div style="width:100%; max-width:520px; background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:48px 40px; text-align:center">
|
||||
<div style="width:56px; height:56px; margin:0 auto 22px; border-radius:999px; background:var(--c-accent-bg); display:flex; align-items:center; justify-content:center">
|
||||
<img class="dgicon" src="assets/icons/Check-MD.svg" style="width:28px; height:28px; opacity:.7">
|
||||
</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:32px; line-height:1.15; color:var(--c-ink); margin:0">Geschafft!</h1>
|
||||
<p style="font-family:var(--font-serif); font-size:17px; line-height:1.6; color:var(--c-ink-2); margin:14px 0 0">Alle Briefe dieser Sitzung sind angereichert. Vielen Dank — Ihre Sorgfalt hält das Archiv beieinander.</p>
|
||||
<div style="display:flex; flex-direction:column; align-items:stretch; gap:12px; margin-top:30px">
|
||||
<a href="Dokumente.dc.html" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:13px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; text-decoration:none; border-radius:2px">Zur Übersicht</a>
|
||||
<a href="Anreicherung.dc.html" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:13px 20px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; text-decoration:none; border-radius:2px">Zur Liste</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":1100},"variant":{"editor":"enum","options":["liste","schritt","fertig"],"default":"schritt","tsType":"string"}}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name,
|
||||
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||
}
|
||||
|
||||
listDocs(){
|
||||
const sc = { 'Neu':'#607080', 'In Arbeit':'#c17a00', 'Entwurf':'#607080' };
|
||||
const raw = [
|
||||
{ title:'Postkarte aus Wien', status:'In Arbeit', date:'2. August 1924' },
|
||||
{ title:'Brief ohne Titel (Mappe B)', status:'Neu', date:'1924' },
|
||||
{ title:'Umschlag — Absender unbekannt', status:'Neu', date:'November 1925' },
|
||||
{ title:'Geschäftsbrief betr. Grundstück Mariahilf', status:'In Arbeit', date:'19. November 1925' },
|
||||
{ title:'Glückwunsch zum 60. Geburtstag', status:'Entwurf', date:'22. Mai 1928' },
|
||||
{ title:'Feldpostkarte, Datum unleserlich', status:'Neu', date:'ohne Datum' },
|
||||
];
|
||||
return raw.map(d => ({ ...d, dotColor: sc[d.status] || '#607080' }));
|
||||
}
|
||||
|
||||
renderVals(){
|
||||
const variant = this.props.variant || 'schritt';
|
||||
const docs = this.listDocs();
|
||||
|
||||
return {
|
||||
isListe: variant === 'liste',
|
||||
isSchritt: variant === 'schritt',
|
||||
isFertig: variant === 'fertig',
|
||||
|
||||
// liste
|
||||
docs,
|
||||
count: docs.length,
|
||||
isEmpty: false,
|
||||
|
||||
// schritt
|
||||
doc: {
|
||||
title: 'Postkarte aus Wien',
|
||||
step: 3,
|
||||
total: 12,
|
||||
filled: 2,
|
||||
required: 3,
|
||||
progressPct: '66.7%',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
</helmet>
|
||||
<header style="background:var(--c-header); position:sticky; top:0; z-index:50">
|
||||
<div style="height:4px; background:#a1dcd8"></div>
|
||||
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:64px; display:flex; align-items:center; color:#fff">
|
||||
<a href="Dokumente.dc.html" style="font-family:var(--font-sans); font-size:18px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; margin-right:28px; white-space:nowrap; color:#fff; text-decoration:none">Familienarchiv</a>
|
||||
<nav style="display:flex; gap:2px">
|
||||
<a class="fa-link" href="Dokumente.dc.html" style="{{ nDok }}">Dokumente</a>
|
||||
<a class="fa-link" href="Personen.dc.html" style="{{ nPers }}">Personen</a>
|
||||
<a class="fa-link" href="Geschichten.dc.html" style="{{ nGesch }}">Geschichten</a>
|
||||
<a class="fa-link" href="Zeitstrahl.dc.html" style="{{ nZeit }}">Zeitstrahl</a>
|
||||
<a class="fa-link" href="Aktivitaeten.dc.html" style="{{ nAkt }}">Aktivitäten</a>
|
||||
<a class="fa-link" href="Stammbaum.dc.html" style="{{ nStamm }}">Stammbaum</a>
|
||||
<a class="fa-link" href="Themen.dc.html" style="{{ nThemen }}">Themen</a>
|
||||
<a class="fa-link" href="Regeln.dc.html" style="{{ nReg }}">Regeln</a>
|
||||
</nav>
|
||||
<div style="margin-left:auto; display:flex; gap:16px; align-items:center">
|
||||
<div style="display:flex; border:1px solid rgba(255,255,255,.25)">
|
||||
<button class="fa-link" onClick="{{ setLight }}" style="{{ thLight }}">Hell</button>
|
||||
<button class="fa-link" onClick="{{ setDark }}" style="{{ thDark }}">Dunkel</button>
|
||||
</div>
|
||||
<div style="width:32px; height:32px; border-radius:999px; background:#fff; color:#012851; display:flex; align-items:center; justify-content:center; font-family:var(--font-sans); font-size:11px; font-weight:700">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":68},"active":{"editor":"enum","options":["dokumente","personen","geschichten","zeitstrahl","aktivitaeten","stammbaum","themen","regeln"],"default":"dokumente","tsType":"string"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { theme: null };
|
||||
stored(){ try { return localStorage.getItem('fa-theme'); } catch(e){ return null; } }
|
||||
get theme(){ return this.state.theme ?? this.stored() ?? 'light'; }
|
||||
apply(){ try { document.documentElement.dataset.theme = this.theme === 'dark' ? 'dark' : ''; } catch(e){} }
|
||||
componentDidMount(){ this.apply(); }
|
||||
componentDidUpdate(){ this.apply(); }
|
||||
set(t){ try { localStorage.setItem('fa-theme', t); } catch(e){} this.setState({ theme: t }); }
|
||||
|
||||
renderVals(){
|
||||
const active = this.props.active || 'dokumente';
|
||||
const theme = this.theme;
|
||||
const navBase = { color:'rgba(255,255,255,.6)', padding:'0 9px', fontFamily:'var(--font-sans)', fontSize:11, fontWeight:700, letterSpacing:'.07em', textTransform:'uppercase', textDecoration:'none', lineHeight:'44px', borderBottom:'2px solid transparent', marginTop:'10px', whiteSpace:'nowrap' };
|
||||
const navOn = { ...navBase, color:'#fff', borderBottom:'2px solid #a1dcd8' };
|
||||
const nav = (k) => active===k ? navOn : navBase;
|
||||
const thBase = { fontFamily:'var(--font-sans)', fontSize:11, fontWeight:700, letterSpacing:'.1em', textTransform:'uppercase', padding:'5px 11px', cursor:'pointer', border:'none', background:'transparent', color:'rgba(255,255,255,.6)' };
|
||||
const thOn = { ...thBase, background:'#a1dcd8', color:'#012851' };
|
||||
return {
|
||||
nDok:nav('dokumente'), nPers:nav('personen'), nGesch:nav('geschichten'), nZeit:nav('zeitstrahl'), nAkt:nav('aktivitaeten'), nStamm:nav('stammbaum'), nThemen:nav('themen'), nReg:nav('regeln'),
|
||||
thLight: theme==='dark' ? thBase : thOn, thDark: theme==='dark' ? thOn : thBase,
|
||||
setLight: () => this.set('light'), setDark: () => this.set('dark'),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button,textarea,select{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||
|
||||
<!-- ===== Compact top bar (immersive workbench — replaces full PageHeader) ===== -->
|
||||
<div style="border-bottom:1px solid var(--c-line); background:var(--c-surface)">
|
||||
<div style="max-width:1180px; margin:0 auto; padding:0 32px; height:54px; display:flex; align-items:center; gap:18px">
|
||||
<!-- BackButton-style chip -->
|
||||
<a href="{{ backHref }}" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none">
|
||||
<span style="font-size:15px; line-height:1">←</span>{{ backLabel }}
|
||||
</a>
|
||||
<span style="width:1px; height:22px; background:var(--c-line)"></span>
|
||||
<!-- Truncated Tinos title -->
|
||||
<span style="flex:1; min-width:0; font-family:var(--font-serif); font-size:18px; font-weight:700; color:var(--c-ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ workbenchTitle }}</span>
|
||||
</div>
|
||||
<!-- Required-fields progress strip -->
|
||||
<div style="max-width:1180px; margin:0 auto; padding:0 32px 12px; display:flex; align-items:center; gap:14px">
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); white-space:nowrap">Erforderliche Felder {{ reqFilled }} / {{ reqTotal }}</span>
|
||||
<div style="flex:1; height:3px; border-radius:999px; background:var(--c-line); overflow:hidden">
|
||||
<div style="height:100%; border-radius:999px; background:var(--c-primary); width:{{ reqPct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Split pane ===== -->
|
||||
<div style="max-width:1180px; margin:0 auto; padding:0 32px">
|
||||
<div style="display:grid; grid-template-columns:46% 1fr; gap:0; border:1px solid var(--c-line); border-top:none">
|
||||
|
||||
<!-- LEFT: PDF preview OR empty dropzone (variant=neu) -->
|
||||
<div style="background:var(--c-pdf-bg); border-right:1px solid var(--c-line); display:flex; flex-direction:column; min-height:680px">
|
||||
|
||||
<!-- variant: bearbeiten | sammel — PDF preview with "Datei ersetzen" -->
|
||||
<sc-if value="{{ showPreview }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="display:flex; flex-direction:column; height:100%">
|
||||
<!-- Toolbar -->
|
||||
<div style="display:flex; align-items:center; border-bottom:1px solid var(--c-line); background:var(--c-pdf-ctrl); padding:8px 14px">
|
||||
<span style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-pdf-text); opacity:.7">{{ pdfFilename }}</span>
|
||||
<label class="fa-link" style="margin-left:auto; display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); padding:9px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Refresh-MD.svg" style="width:14px; height:14px; opacity:.5">Datei ersetzen
|
||||
</label>
|
||||
</div>
|
||||
<!-- Page surface -->
|
||||
<div style="flex:1; display:flex; align-items:flex-start; justify-content:center; padding:28px; overflow:hidden">
|
||||
<div style="width:340px; max-width:100%; aspect-ratio:1 / 1.41; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-md); padding:34px 30px; display:flex; flex-direction:column; gap:11px">
|
||||
<div style="font-family:var(--font-serif); font-size:13px; font-style:italic; color:var(--c-pdf-text); opacity:.85">Im Felde, den 12. März 1916</div>
|
||||
<div style="font-family:var(--font-serif); font-size:14px; color:var(--c-pdf-text); line-height:1.7; opacity:.9">Meine innig geliebte Clara, nun sind es bald zwei Jahre, dass ich Dich nicht mehr in den Armen halten durfte …</div>
|
||||
<div style="height:1px; background:var(--c-line); margin:4px 0"></div>
|
||||
<div style="font-family:var(--font-serif); font-size:13px; color:var(--c-pdf-text); line-height:1.7; opacity:.55">… (Kurrentschrift, Seite 1 von 4) …</div>
|
||||
<div style="font-family:var(--font-serif); font-size:13px; color:var(--c-pdf-text); line-height:1.7; opacity:.55">Dein Dich ewig liebender Herbert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- variant: neu — empty dashed dropzone -->
|
||||
<sc-if value="{{ showDropzone }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:32px">
|
||||
<label class="fa-link" style="display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; width:100%; max-width:420px; min-height:300px; border:2px dashed var(--c-accent); border-radius:2px; background:var(--c-accent-bg); padding:40px 28px; text-align:center; cursor:pointer">
|
||||
<img class="dgicon" src="assets/icons/Upload-MD.svg" style="width:34px; height:34px; opacity:.5">
|
||||
<span style="font-family:var(--font-sans); font-size:15px; font-weight:700; color:var(--c-ink-2)">Dateien hier ablegen oder klicken</span>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">PDF, JPG, PNG, TIFF bis 50 MB</span>
|
||||
</label>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: scrollable form column -->
|
||||
<div style="background:var(--c-surface); display:flex; flex-direction:column; min-height:680px">
|
||||
<div style="flex:1; padding:24px; display:flex; flex-direction:column; gap:18px">
|
||||
|
||||
<!-- variant: sammel — shared-metadata note -->
|
||||
<sc-if value="{{ isBulk }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="background:var(--c-accent-bg); border:1px solid var(--c-line); border-left:3px solid var(--c-accent); padding:14px 16px; border-radius:2px">
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); margin-bottom:4px">Geteilte Metadaten</div>
|
||||
<p style="margin:0; font-family:var(--font-serif); font-size:15px; font-style:italic; color:var(--c-ink-2)">Felder werden ergänzt, nicht ersetzt.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<!-- CARD: Wer & Wann -->
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 18px">Wer & Wann</h2>
|
||||
|
||||
<!-- Absender (typeahead, required) -->
|
||||
<label style="display:block; margin-bottom:16px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Absender *</span>
|
||||
<div style="position:relative">
|
||||
<input value="{{ f.sender }}" placeholder="Person suchen … z.B. Herbert Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Empfänger -->
|
||||
<div style="margin-bottom:16px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Empfänger {{ additiveSuffix }}</span>
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px; border:1px solid var(--c-line); background:var(--c-surface); padding:8px 10px; border-radius:2px; min-height:44px">
|
||||
<sc-for list="{{ f.receivers }}" as="r" hint-placeholder-count="2">
|
||||
<span style="display:inline-flex; align-items:center; gap:7px; background:var(--c-muted); border:1px solid var(--c-line); padding:4px 10px 4px 5px; border-radius:2px">
|
||||
<span style="{{ r.a26 }}">{{ r.initials }}</span>
|
||||
<span style="font-family:var(--font-serif); font-size:14px; color:var(--c-ink)">{{ r.name }}</span>
|
||||
</span>
|
||||
</sc-for>
|
||||
<input placeholder="{{ receiverPlaceholder }}" style="flex:1; min-width:120px; border:none; background:transparent; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Datum + Präzision -->
|
||||
<div style="display:grid; grid-template-columns:1fr 150px; gap:12px; margin-bottom:16px">
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Datum *</span>
|
||||
<input value="{{ f.date }}" placeholder="TT.MM.JJJJ" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Genauigkeit</span>
|
||||
<select style="width:100%; appearance:none; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px; cursor:pointer; min-height:44px">
|
||||
<option selected>Tag</option><option>Monat</option><option>Jahr</option><option>Unbekannt</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Ort -->
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Ort</span>
|
||||
<div style="position:relative">
|
||||
<input value="{{ f.location }}" placeholder="z.B. Im Felde, Galizien" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Location-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- CARD: Beschreibung -->
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:24px">
|
||||
<h2 style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:var(--c-ink-3); margin:0 0 18px">Beschreibung</h2>
|
||||
|
||||
<!-- Titel -->
|
||||
<label style="display:block; margin-bottom:16px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Titel *</span>
|
||||
<input value="{{ f.title }}" placeholder="z.B. Feldpostbrief an Clara" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
|
||||
<!-- Notiz / Inhalt -->
|
||||
<label style="display:block; margin-bottom:16px">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Notiz</span>
|
||||
<textarea rows="4" placeholder="Kurze Beschreibung oder erste Notizen zum Inhalt …" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px; resize:vertical">{{ f.note }}</textarea>
|
||||
</label>
|
||||
|
||||
<!-- Tags / Schlagworte -->
|
||||
<div>
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Schlagworte {{ additiveSuffix }}</span>
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px; border:1px solid var(--c-line); background:var(--c-surface); padding:8px 10px; border-radius:2px; min-height:44px">
|
||||
<sc-for list="{{ f.tags }}" as="t" hint-placeholder-count="3">
|
||||
<span style="display:inline-flex; align-items:center; gap:6px; background:var(--c-muted); border:1px solid var(--c-line); padding:4px 10px; border-radius:4px">
|
||||
<span style="width:8px; height:8px; border-radius:999px; background:{{ t.dot }}"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink-2)">{{ t.label }}</span>
|
||||
</span>
|
||||
</sc-for>
|
||||
<input placeholder="Schlagwort hinzufügen …" style="flex:1; min-width:120px; border:none; background:transparent; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sticky action bar -->
|
||||
<div style="position:sticky; bottom:0; display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--c-line); background:var(--c-surface); padding:14px 24px">
|
||||
<sc-if value="{{ isEdit }}" hint-placeholder-val="{{ true }}">
|
||||
<a href="#" class="fa-link" title="Dokument löschen" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-danger); text-decoration:none">Löschen</a>
|
||||
</sc-if>
|
||||
<div style="display:flex; align-items:center; gap:12px; margin-left:auto">
|
||||
<a href="{{ backHref }}" class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 18px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px; text-decoration:none">Abbrechen</a>
|
||||
<button class="fa-link" style="background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 18px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Zur Überprüfung</button>
|
||||
<button class="fa-link" style="background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 22px; min-height:44px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:48px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1180,"height":840},"variant":{"editor":"enum","options":["bearbeiten","neu","sammel"],"default":"bearbeiten","tsType":"string"}}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name,
|
||||
a26:{ ...base, width:26, height:26, fontSize:10 },
|
||||
a28:{ ...base, width:28, height:28, fontSize:11 },
|
||||
a40:{ ...base, width:40, height:40, fontSize:14 },
|
||||
a48:{ ...base, width:48, height:48, fontSize:16 } };
|
||||
}
|
||||
rcv(name){ const a = this.av(name); return { name, initials:a.initials, a26:a.a26 }; }
|
||||
|
||||
renderVals(){
|
||||
const variant = this.props.variant || 'bearbeiten';
|
||||
const isEdit = variant === 'bearbeiten';
|
||||
const isNew = variant === 'neu';
|
||||
const isBulk = variant === 'sammel';
|
||||
|
||||
const tagPal = {
|
||||
sage:'#5a8a6a', sienna:'#a0522d', amber:'#c17a00', slate:'#607080',
|
||||
violet:'#7a4f9a', cobalt:'#3060b0',
|
||||
};
|
||||
|
||||
const editing = {
|
||||
sender:'Herbert Cram',
|
||||
receivers:[ this.rcv('Clara Cram') ],
|
||||
date:'12.03.1916',
|
||||
location:'Im Felde, Galizien',
|
||||
title:'Feldpostbrief an Clara — „nun sind es bald zwei Jahre“',
|
||||
note:'Vierseitiger Feldpostbrief in Kurrentschrift. Herbert schreibt aus dem Stellungskrieg an seine Frau Clara. Teil des Konvoluts 1914–1918.',
|
||||
tags:[
|
||||
{ label:'Feldpost', dot:tagPal.sienna },
|
||||
{ label:'1. Weltkrieg', dot:tagPal.slate },
|
||||
{ label:'Galizien', dot:tagPal.sage },
|
||||
],
|
||||
};
|
||||
const blank = {
|
||||
sender:'', receivers:[], date:'', location:'', title:'', note:'', tags:[],
|
||||
};
|
||||
const bulk = {
|
||||
sender:'', receivers:[], date:'', location:'', title:'', note:'',
|
||||
tags:[ { label:'Feldpost', dot:tagPal.sienna } ],
|
||||
};
|
||||
const f = isEdit ? editing : (isBulk ? bulk : blank);
|
||||
|
||||
// Required progress: bearbeiten=alle 3, neu=0, sammel=2 (Absender+Datum geteilt, Titel pro Datei).
|
||||
const reqFilled = isEdit ? 3 : (isBulk ? 2 : 0);
|
||||
const reqTotal = 3;
|
||||
|
||||
return {
|
||||
isEdit, isNew, isBulk,
|
||||
showPreview: isEdit || isBulk,
|
||||
showDropzone: isNew,
|
||||
backHref: isEdit ? 'Dokumente.dc.html' : 'Dokumente-Liste.dc.html',
|
||||
backLabel: 'Zurück',
|
||||
workbenchTitle: isBulk
|
||||
? '3 Dokumente · Gemeinsame Metadaten'
|
||||
: (isNew ? 'Neues Dokument' : 'Feldpostbrief an Clara — „nun sind es bald zwei Jahre“'),
|
||||
pdfFilename: isBulk ? 'feldpost_1916_03_12.pdf' : 'cram_feldpost_1916.pdf',
|
||||
reqFilled, reqTotal,
|
||||
reqPct: Math.round((reqFilled / reqTotal) * 100),
|
||||
additiveSuffix: isBulk ? '· wird ergänzt' : '',
|
||||
receiverPlaceholder: f.receivers.length ? 'Weitere hinzufügen …' : 'Person suchen …',
|
||||
f,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button,textarea,select{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||
|
||||
<!-- ── Compact top bar (immersive workbench — no big PageHeader) ── -->
|
||||
<div style="position:relative; z-index:10; border-bottom:1px solid var(--c-line); background:var(--c-surface); box-shadow:var(--shadow-sm)">
|
||||
<div style="display:flex; align-items:center; gap:0; padding-right:16px; min-height:76px">
|
||||
|
||||
<!-- Back chip -->
|
||||
<a href="Dokumente.dc.html" class="fa-link" title="Zurück zur Übersicht" style="display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:44px; padding:0 14px 0 16px; margin-right:6px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">← Zurück</a>
|
||||
|
||||
<div style="width:1px; height:24px; background:var(--c-line); flex-shrink:0; margin-right:14px"></div>
|
||||
|
||||
<!-- Title block with 3px MINT left accent bar -->
|
||||
<div style="border-left:3px solid var(--c-accent); padding-left:14px; min-width:0; flex:1">
|
||||
<div style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:3px">{{ signatur }} · Feldpost</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:21px; line-height:1.2; color:var(--c-ink); margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Overlapping sender/receiver avatar stack (a26) -->
|
||||
<div style="display:flex; align-items:center; padding-left:12px; margin-right:14px; flex-shrink:0">
|
||||
<sc-for list="{{ stack }}" as="p" hint-placeholder-count="2">
|
||||
<span title="{{ p.name }}" style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
|
||||
<!-- Status dot + UPPERCASE label (§9) -->
|
||||
<span style="display:inline-flex; align-items:center; gap:7px; flex-shrink:0; margin-right:14px">
|
||||
<span style="width:7px; height:7px; border-radius:999px; background:{{ statusColor }}"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ status }}</span>
|
||||
</span>
|
||||
|
||||
<div style="width:1px; height:24px; background:var(--c-line); flex-shrink:0; margin-right:14px"></div>
|
||||
|
||||
<!-- Details toggle (secondary, active state) -->
|
||||
<button class="fa-link" aria-expanded="true" style="display:inline-flex; align-items:center; gap:7px; min-height:44px; background:var(--c-primary); color:var(--c-primary-fg); border:1px solid var(--c-primary); padding:0 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; cursor:pointer; border-radius:2px; flex-shrink:0">
|
||||
Details
|
||||
<span style="font-size:9px; line-height:1">▾</span>
|
||||
</button>
|
||||
|
||||
<div style="width:1px; height:24px; background:var(--c-line); flex-shrink:0; margin:0 14px"></div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-shrink:0">
|
||||
<button class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:0 18px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Edit-Content-MD.svg" style="width:15px; height:15px; opacity:.9; filter:invert(1) brightness(2)">Transkribieren</button>
|
||||
<button class="fa-link" style="display:inline-flex; align-items:center; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:0 16px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px">Bearbeiten</button>
|
||||
<button class="fa-link" title="Weitere Aktionen" style="display:inline-flex; align-items:center; justify-content:center; width:44px; height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); cursor:pointer; border-radius:2px; flex-shrink:0"><img class="dgicon" src="assets/icons/View-More-MD.svg" style="width:18px; height:18px; opacity:.55"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Slide-over "Details" metadata card (3px mint TOP border) ── -->
|
||||
<div style="border-top:3px solid var(--c-accent); background:var(--c-surface); padding:26px 32px">
|
||||
<div style="max-width:1180px; margin:0 auto; display:grid; grid-template-columns:1fr 1.4fr 1fr 1fr; gap:30px; align-items:start">
|
||||
|
||||
<!-- Details column -->
|
||||
<div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Details</div>
|
||||
<div style="display:flex; flex-direction:column; gap:14px">
|
||||
<div>
|
||||
<div style="display:flex; align-items:center; gap:7px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:3px"><img class="dgicon" src="assets/icons/Calendar-Add-MD.svg" style="width:13px; height:13px; opacity:.5">Datum</div>
|
||||
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">31. Oktober 1915</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="display:flex; align-items:center; gap:7px; font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:3px"><img class="dgicon" src="assets/icons/Location-MD.svg" style="width:13px; height:13px; opacity:.5">Ort</div>
|
||||
<div style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">Feldpost — Westfront</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:5px">Status</div>
|
||||
<span style="display:inline-flex; align-items:center; gap:7px">
|
||||
<span style="width:7px; height:7px; border-radius:999px; background:{{ statusColor }}"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ status }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personen column (avatar chips a40) -->
|
||||
<div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Personen</div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:6px">Absender</div>
|
||||
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:10px; padding:6px 8px; margin:0 -8px 10px; text-decoration:none; border-radius:2px">
|
||||
<span style="{{ sender.a40 }}">{{ sender.initials }}</span>
|
||||
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">{{ sender.name }}</span>
|
||||
</a>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:6px">Empfänger</div>
|
||||
<a href="#" class="fa-link" style="display:flex; align-items:center; gap:10px; padding:6px 8px; margin:0 -8px; text-decoration:none; border-radius:2px">
|
||||
<span style="{{ receiver.a40 }}">{{ receiver.initials }}</span>
|
||||
<span style="font-family:var(--font-serif); font-size:16px; color:var(--c-ink)">{{ receiver.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Schlagwörter column (tag chips) -->
|
||||
<div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Schlagwörter</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:8px">
|
||||
<sc-for list="{{ tags }}" as="t" hint-placeholder-count="4">
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:6px; background:var(--c-muted); border:1px solid var(--c-line); padding:5px 10px; border-radius:4px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink); text-decoration:none">
|
||||
<span style="width:7px; height:7px; border-radius:999px; background:{{ t.dot }}"></span>{{ t.name }}
|
||||
</a>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geschichten + comment/chat affordance -->
|
||||
<div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:14px">Geschichten</div>
|
||||
<a href="Geschichte.dc.html" class="fa-link" style="display:block; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); text-decoration:none; margin-bottom:3px">Feldpost: Herbert an der Westfront 1914–1918</a>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; color:var(--c-ink-3); margin-bottom:18px">Marcel Raddatz · 11. Juni 2026</div>
|
||||
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); padding:9px 14px; border-radius:2px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none">
|
||||
<img class="dgicon" src="assets/icons/Chat-MD.svg" style="width:15px; height:15px; opacity:.55">3 Anmerkungen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main split pane ── -->
|
||||
<div style="display:grid; grid-template-columns:1fr 480px; min-height:640px; align-items:stretch">
|
||||
|
||||
<!-- LEFT: document/PDF viewer placeholder on --c-pdf-bg -->
|
||||
<div style="display:flex; flex-direction:column; background:var(--c-pdf-bg); min-height:640px">
|
||||
<div style="flex:1; display:flex; align-items:center; justify-content:center; padding:36px">
|
||||
<!-- aged-scan page placeholder -->
|
||||
<div style="width:100%; max-width:540px; aspect-ratio:3 / 4; background:var(--c-surface); border:1px solid var(--c-line); box-shadow:var(--shadow-md); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px">
|
||||
<img class="dgicon" src="assets/icons/Mail-MD.svg" style="width:46px; height:46px; opacity:.3">
|
||||
<div style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-3)">Feldpostbrief — Seite 1</div>
|
||||
<div style="font-family:var(--font-sans); font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3)">{{ signatur }} · Kurrentschrift</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- page-control footer (prev/next, page x of y) -->
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:16px; padding:12px; background:var(--c-pdf-ctrl); border-top:1px solid var(--c-line)">
|
||||
<button class="fa-link" title="Vorherige Seite" style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; cursor:pointer; color:var(--c-pdf-text); font-family:var(--font-sans); font-size:16px; font-weight:700">‹</button>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:600; letter-spacing:.04em; color:var(--c-pdf-text)">Seite 1 von 2</span>
|
||||
<button class="fa-link" title="Nächste Seite" style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background:var(--c-surface); border:1px solid var(--c-line); border-radius:2px; cursor:pointer; color:var(--c-pdf-text); font-family:var(--font-sans); font-size:16px; font-weight:700">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: transcription panel -->
|
||||
<div style="display:flex; flex-direction:column; background:var(--c-surface); border-left:1px solid var(--c-line); min-height:640px">
|
||||
|
||||
<!-- panel header: read/edit SEGMENTED CONTROL + turquoise accent chip -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; padding:14px 18px; border-bottom:1px solid var(--c-line)">
|
||||
<div style="display:flex; align-items:center; gap:12px">
|
||||
<!-- segmented control (§6) -->
|
||||
<div style="display:inline-flex; border:1px solid var(--c-line); border-radius:2px; overflow:hidden">
|
||||
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Lesen</span>
|
||||
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Bearbeiten</span>
|
||||
</div>
|
||||
<!-- turquoise Transkriptionsmodus accent chip -->
|
||||
<span style="display:inline-flex; align-items:center; gap:6px; border:1px solid var(--c-turquoise); padding:5px 10px; border-radius:999px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-turquoise)">
|
||||
<span style="width:7px; height:7px; border-radius:999px; background:var(--c-turquoise)"></span>Transkriptionsmodus
|
||||
</span>
|
||||
</div>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">4 Abschnitte · zuletzt 12. Juni 2026</span>
|
||||
</div>
|
||||
|
||||
<!-- transcription blocks in Tinos -->
|
||||
<div style="flex:1; overflow-y:auto; padding:26px 28px">
|
||||
<sc-for list="{{ blocks }}" as="b" hint-placeholder-count="4">
|
||||
<div style="{{ b.wrap }}">
|
||||
<p style="font-family:var(--font-serif); font-size:16px; line-height:1.85; color:var(--c-ink); margin:0">{{ b.text }}</p>
|
||||
<sc-if value="{{ b.annotated }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:inline-flex; align-items:center; gap:7px; margin-top:10px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3)">
|
||||
<img class="dgicon" src="assets/icons/Chat-MD.svg" style="width:13px; height:13px; opacity:.5">Markiert · 1 Anmerkung
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name,
|
||||
a26:{ ...base, width:26, height:26, fontSize:10, marginLeft:-6, border:'2px solid var(--c-surface)' },
|
||||
a40:{ ...base, width:40, height:40, fontSize:14 } };
|
||||
}
|
||||
|
||||
blocks(){
|
||||
const raw = [
|
||||
{ annotated:false, text:'Meine liebe, gute Clara! Heute hatte ich endlich wieder einen ruhigen Tag, um Dir ausführlich zu schreiben. Das Wetter ist umgeschlagen, kalter Regen seit gestern, und die Wege sind grundlos. Trotzdem geht es mir gut, sei darum unbesorgt.' },
|
||||
{ annotated:true, text:'In 10 Minuten gehe ich in Stellung zum ehrenvollen, heissen und schweren Kampfe. Was kommen mag, ich gehe ruhig hinein – nur der Gedanke an Dich und die Kinder läßt mich nicht los, Tag und Nacht.' },
|
||||
{ annotated:false, text:'Hast Du meine letzten beiden Karten erhalten? Schreibe mir bald, wie es Mutter geht und ob die Kohlen für den Winter schon eingebracht sind. Grüße mir die Nachbarn und besonders Frau Hoffmann recht herzlich.' },
|
||||
{ annotated:false, text:'Nun muß ich schließen, der Posten wartet. Bleib gesund und tapfer, meine Gute. In treuer Liebe und mit tausend Küssen, Dein Herbert.' },
|
||||
];
|
||||
return raw.map(b => ({
|
||||
...b,
|
||||
wrap: b.annotated
|
||||
? { borderLeft:'3px solid var(--c-accent)', paddingLeft:18, paddingTop:14, paddingBottom:18, marginBottom:8, background:'var(--c-accent-bg)' }
|
||||
: { paddingTop:14, paddingBottom:18, marginBottom:8, borderBottom:'1px solid var(--c-line)' },
|
||||
}));
|
||||
}
|
||||
|
||||
renderVals(){
|
||||
const sender = this.av('Herbert Cram');
|
||||
const receiver = this.av('Clara Cram');
|
||||
const tagColors = { 'Feldpost':'#a0522d', 'Erster Weltkrieg':'#607080', 'Westfront':'#3060b0', 'Liebesbrief':'#c0446e' };
|
||||
const tags = Object.keys(tagColors).map(name => ({ name, dot:tagColors[name] }));
|
||||
return {
|
||||
signatur:'H-0366',
|
||||
title:'Feldpostbrief an Clara — »in 10 Minuten gehe ich in Stellung«',
|
||||
status:'Transkribiert',
|
||||
statusColor:'#5a8a6a',
|
||||
sender, receiver,
|
||||
stack:[sender, receiver],
|
||||
tags,
|
||||
blocks: this.blocks(),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:#f0efe9}
|
||||
*{box-sizing:border-box}
|
||||
input,button,textarea,select{font-family:inherit}
|
||||
::selection{background:#a1dcd8;color:#012851}
|
||||
[data-theme='dark'] .dgicon{filter:invert(1) brightness(1.6)}
|
||||
.fa-link{transition:color .15s, background .15s, border-color .15s}
|
||||
</style>
|
||||
<script>(function(){try{if(localStorage.getItem('fa-theme')==='dark'){document.documentElement.dataset.theme='dark';}}catch(e){}})();</script>
|
||||
</helmet>
|
||||
<div style="min-height:100vh; background:var(--c-canvas); color:var(--c-ink); font-family:var(--font-serif)">
|
||||
<dc-import name="ArchiveHeader" active="dokumente" hint-size="100%,68px"></dc-import>
|
||||
<main style="max-width:1180px; margin:0 auto; padding:40px 32px 80px">
|
||||
|
||||
<!-- PAGE HEADER -->
|
||||
<div style="margin-bottom:30px; display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap">
|
||||
<div style="border-left:4px solid var(--c-accent); padding-left:18px">
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Archiv</div>
|
||||
<h1 style="font-family:var(--font-serif); font-weight:700; font-size:46px; line-height:1.06; color:var(--c-ink); margin:0">Dokumente</h1>
|
||||
<p style="font-family:var(--font-serif); font-style:italic; font-size:16px; color:var(--c-ink-2); margin:10px 0 0; max-width:520px">Durchsuchen Sie den Bestand nach Titel, Person oder Schlagwort — gebündelt nach Jahr.</p>
|
||||
</div>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3)">147 Dokumente · 38 Personen</span>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH CARD -->
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; padding:16px; margin-bottom:16px">
|
||||
<!-- row 1: search + sort + filter -->
|
||||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap">
|
||||
<div style="position:relative; flex:1; min-width:240px">
|
||||
<input placeholder="Titel, Personen, Tags durchsuchen…" style="width:100%; box-sizing:border-box; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 40px 11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
<img class="dgicon" src="assets/icons/Mag-Glass-MD.svg" style="position:absolute; right:12px; top:50%; transform:translateY(-50%); width:16px; height:16px; opacity:.4">
|
||||
</div>
|
||||
<button class="fa-link" style="display:flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); border:1px solid var(--c-line); padding:11px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">Datum ↓</button>
|
||||
<button class="fa-link" style="display:flex; align-items:center; gap:8px; min-height:44px; background:var(--c-muted); border:1px solid var(--c-line); padding:11px 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px"><img class="dgicon" src="assets/icons/Filter-MD.svg" style="width:15px; height:15px; opacity:.55">Filter</button>
|
||||
</div>
|
||||
|
||||
<!-- row 2: advanced filters (shown open) -->
|
||||
<div style="margin-top:18px; padding-top:18px; border-top:1px solid var(--c-line-2); display:flex; flex-direction:column; gap:18px">
|
||||
|
||||
<!-- tags + AND/OR segmented control -->
|
||||
<div>
|
||||
<div style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:8px">Schlagwörter</div>
|
||||
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap">
|
||||
<sc-for list="{{ tagChips }}" as="t" hint-placeholder-count="3">
|
||||
<span style="display:inline-flex; align-items:center; gap:6px; border:1px solid var(--c-line); background:var(--c-muted); padding:5px 10px; border-radius:4px; font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.13em; text-transform:uppercase; color:var(--c-ink-2)">
|
||||
<span style="{{ t.dot }}"></span>{{ t.name }}
|
||||
</span>
|
||||
</sc-for>
|
||||
<div style="display:inline-flex; border:1px solid var(--c-line); margin-left:4px">
|
||||
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-primary-fg); background:var(--c-primary); padding:9px 16px; cursor:pointer">Und</span>
|
||||
<span class="fa-link" style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); background:var(--c-surface); padding:9px 16px; border-left:1px solid var(--c-line); cursor:pointer">Oder</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- typeahead + dates + undated toggle -->
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr 1fr; gap:14px; align-items:end">
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Absender</span>
|
||||
<input placeholder="z.B. Herbert Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Empfänger</span>
|
||||
<input placeholder="z.B. Clara Cram" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Von</span>
|
||||
<input placeholder="01.01.1914" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
<label style="display:block">
|
||||
<span style="display:block; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-3); margin-bottom:6px">Bis</span>
|
||||
<input placeholder="31.12.1918" style="width:100%; border:1px solid var(--c-line); background:var(--c-surface); padding:11px 14px; font-family:var(--font-serif); font-size:16px; color:var(--c-ink); outline:none; border-radius:2px">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- undated-only toggle -->
|
||||
<div>
|
||||
<button class="fa-link" title="Nur undatierte Dokumente anzeigen" style="display:inline-flex; align-items:center; gap:10px; min-height:44px; border:1px solid var(--c-line); background:var(--c-muted); padding:0 14px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2); cursor:pointer; border-radius:2px">
|
||||
<span style="display:inline-flex; width:16px; height:16px; border:1px solid var(--c-ink-3); border-radius:2px"></span>
|
||||
Ohne Datum
|
||||
<span style="display:inline-flex; align-items:center; justify-content:center; min-width:24px; padding:2px 6px; border-radius:999px; background:var(--c-line); color:var(--c-ink-2); font-size:11px; font-weight:700; letter-spacing:0">9</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RESULT COUNT + ACTIONS -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:18px; flex-wrap:wrap">
|
||||
<p style="margin:0; font-family:var(--font-sans); font-size:14px; color:var(--c-ink-2)">147 Treffer</p>
|
||||
<div style="display:flex; align-items:center; gap:12px">
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-surface); color:var(--c-ink-2); border:1px solid var(--c-line); padding:11px 20px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px; text-decoration:none"><img class="dgicon" src="assets/icons/Edit-Content-MD.svg" style="width:15px; height:15px; opacity:.55">Alle bearbeiten</a>
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; gap:8px; min-height:44px; background:var(--c-primary); color:var(--c-primary-fg); border:none; padding:11px 20px; font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; border-radius:2px; text-decoration:none">Neu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GROUPED RESULT CARDS -->
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="3">
|
||||
<div style="background:var(--c-surface); border:1px solid var(--c-line); border-top:3px solid var(--c-accent); box-shadow:var(--shadow-sm); border-radius:2px; margin-bottom:18px">
|
||||
<div style="border-bottom:1px solid var(--c-line-2); background:var(--c-muted); padding:11px 24px">
|
||||
<span style="font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--c-ink-3)">{{ g.label }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<sc-for list="{{ g.docs }}" as="d" hint-placeholder-count="5">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; border-bottom:1px solid var(--c-line-2); padding:15px 24px">
|
||||
<a href="#" class="fa-link" style="font-family:var(--font-serif); font-size:18px; color:var(--c-ink); text-decoration:none; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1">{{ d.title }}</a>
|
||||
<div style="display:flex; align-items:center; gap:18px; flex-shrink:0">
|
||||
<div style="display:flex; padding-left:6px">
|
||||
<sc-for list="{{ d.people }}" as="p" hint-placeholder-count="3">
|
||||
<span style="{{ p.a26 }}">{{ p.initials }}</span>
|
||||
</sc-for>
|
||||
</div>
|
||||
<span style="font-family:var(--font-sans); font-size:12px; color:var(--c-ink-3); width:130px; text-align:right">{{ d.date }}</span>
|
||||
<span style="display:inline-flex; align-items:center; gap:7px; width:120px">
|
||||
<span style="{{ d.statusDot }}"></span>
|
||||
<span style="font-family:var(--font-sans); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--c-ink-2)">{{ d.status }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
|
||||
<!-- PAGINATION -->
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:8px; margin-top:28px">
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-3); text-decoration:none; border-radius:2px">Zurück</a>
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-primary); background:var(--c-primary); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-primary-fg); text-decoration:none; border-radius:2px">1</a>
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-ink-2); text-decoration:none; border-radius:2px">2</a>
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-ink-2); text-decoration:none; border-radius:2px">3</a>
|
||||
<span style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; font-family:var(--font-sans); font-size:12px; font-weight:700; color:var(--c-ink-3)">…</span>
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; color:var(--c-ink-2); text-decoration:none; border-radius:2px">8</a>
|
||||
<a href="#" class="fa-link" style="display:inline-flex; align-items:center; justify-content:center; min-height:40px; min-width:40px; padding:0 14px; border:1px solid var(--c-line); background:var(--c-surface); font-family:var(--font-sans); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--c-ink-2); text-decoration:none; border-radius:2px">Weiter</a>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{}">
|
||||
class Component extends DCLogic {
|
||||
av(name){
|
||||
const pal = ['#5a8a6a','#a0522d','#c17a00','#607080','#7a4f9a','#c0446e','#3060b0','#4a7a3a','#9a8040','#c05540'];
|
||||
let h = 0; for (let i=0;i<name.length;i++){ h = (h*31 + name.charCodeAt(i)) >>> 0; }
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const initials = (parts[0][0] + (parts.length>1 ? parts[parts.length-1][0] : '')).toUpperCase();
|
||||
const bg = pal[h % pal.length];
|
||||
const base = { borderRadius:'999px', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', fontFamily:'var(--font-sans)', fontWeight:700, flexShrink:0, lineHeight:1, background:bg };
|
||||
return { bg, initials, name, a26:{ ...base, width:26, height:26, fontSize:10, marginLeft:-6, border:'2px solid var(--c-surface)' } };
|
||||
}
|
||||
avs(names){ return names.map(n => this.av(n)); }
|
||||
|
||||
// Real DocumentStatus labels (documentStatusLabel.ts) mapped to the 3 status colors.
|
||||
statusColor(status){
|
||||
const sc = {
|
||||
'Transkribiert':'#5a8a6a', 'Geprüft':'#5a8a6a', 'Archiviert':'#5a8a6a',
|
||||
'Hochgeladen':'#c17a00',
|
||||
'Platzhalter':'#607080',
|
||||
};
|
||||
return sc[status] || '#607080';
|
||||
}
|
||||
|
||||
groups(){
|
||||
const tag = (t) => ({ ...t, statusDot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:this.statusColor(t.status), flexShrink:0 } });
|
||||
const raw = [
|
||||
{ label:'1916', docs:[
|
||||
{ title:'Feldpostbrief von der Westfront', date:'3. Februar 1916', status:'Transkribiert', people:['Herbert Cram','Clara Cram'] },
|
||||
{ title:'Postkarte aus dem Lazarett Sedan', date:'17. April 1916', status:'Geprüft', people:['Herbert Cram','Marie Cram'] },
|
||||
{ title:'Brief an die Mutter, Sütterlin', date:'9. Juni 1916', status:'Hochgeladen', people:['Herbert Cram','Eugenie de Gruyter'] },
|
||||
{ title:'Glückwunsch zur Verlobung', date:'28. August 1916', status:'Transkribiert', people:['Clara Cram','Marie Cram'] },
|
||||
{ title:'Feldpost — Absender unleserlich', date:'1916 (Monat unbekannt)', status:'Platzhalter', people:['Clara Cram'] },
|
||||
{ title:'Weihnachtsgruß aus dem Schützengraben', date:'22. Dezember 1916', status:'Hochgeladen', people:['Herbert Cram','Clara Cram','Marie Cram'] },
|
||||
]},
|
||||
{ label:'1915', docs:[
|
||||
{ title:'Erste Karte nach der Einberufung', date:'14. März 1915', status:'Geprüft', people:['Herbert Cram','Eugenie de Gruyter'] },
|
||||
{ title:'Brief über das Leben in der Etappe', date:'30. Mai 1915', status:'Transkribiert', people:['Herbert Cram','Clara Cram'] },
|
||||
{ title:'Postkarte mit Feldpoststempel', date:'11. September 1915', status:'Hochgeladen', people:['Marie Cram','Clara Cram'] },
|
||||
{ title:'Brief an Eugenie, Kurrentschrift', date:'4. November 1915', status:'Platzhalter', people:['Eugenie de Gruyter','Herbert Cram'] },
|
||||
{ title:'Adventsgruß an die Familie', date:'5. Dezember 1915', status:'Transkribiert', people:['Herbert Cram','Marie Cram','Clara Cram'] },
|
||||
]},
|
||||
{ label:'1914', docs:[
|
||||
{ title:'Brief vor der Mobilmachung', date:'19. Juli 1914', status:'Geprüft', people:['Herbert Cram','Clara Cram'] },
|
||||
{ title:'Karte vom Bahnhof Köln', date:'8. August 1914', status:'Transkribiert', people:['Herbert Cram','Eugenie de Gruyter'] },
|
||||
{ title:'Erster Feldpostbrief aus Belgien', date:'27. September 1914', status:'Hochgeladen', people:['Herbert Cram','Marie Cram'] },
|
||||
{ title:'Umschlag ohne Inhalt (Mappe A)', date:'1914 (undatiert)', status:'Platzhalter', people:['Clara Cram'] },
|
||||
]},
|
||||
];
|
||||
return raw.map(g => ({ label:g.label, docs:g.docs.map(d => ({ ...d, people:this.avs(d.people), statusDot:{ display:'inline-block', width:7, height:7, borderRadius:'999px', background:this.statusColor(d.status), flexShrink:0 } })) }));
|
||||
}
|
||||
|
||||
tagChips(){
|
||||
const tc = { sage:'#5a8a6a', sienna:'#a0522d', amber:'#c17a00', cobalt:'#3060b0' };
|
||||
const raw = [
|
||||
{ name:'Feldpost', color:tc.sienna },
|
||||
{ name:'Erster Weltkrieg', color:tc.cobalt },
|
||||
{ name:'Westfront', color:tc.amber },
|
||||
];
|
||||
return raw.map(t => ({ name:t.name, dot:{ display:'inline-block', width:8, height:8, borderRadius:'999px', background:t.color, flexShrink:0 } }));
|
||||
}
|
||||
|
||||
renderVals(){
|
||||
return {
|
||||
groups: this.groups(),
|
||||
tagChips: this.tagChips(),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user