Compare commits

..

29 Commits

Author SHA1 Message Date
Marcel
b05990fffb docs(adr): renumber SDD adoption ADR 041 -> 042 (collision with renovate ADR)
All checks were successful
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m48s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
CI / Unit & Component Tests (push) Successful in 4m58s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m51s
CI / fail2ban Regex (push) Successful in 48s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m9s
CI / Unit & Component Tests (pull_request) Successful in 3m36s
Two ADR-041 files landed on main in parallel (sdd-adoption and
renovate-runner-setup). Renames the SDD one to 042 and repoints its references
(SPEC_DRIVEN_DEVELOPMENT, constitution, .specify/adrs/README, sdd-gate.yml).
The renovate ADR keeps 041; its references are left untouched. Riding this PR
per request.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:38:12 +02:00
Marcel
fa8a734f96 docs(sdd): mark #778 RTM rows Done with real test names
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m33s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m37s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 15s
SDD Gate / Contract Validate (pull_request) Successful in 24s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
Flip REQ-001..006 for the timeline date-label feature from Planned to Done and
fill the implementing files plus the concrete dateLabel.spec.ts test names that
prove each requirement.

Closes #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:18:19 +02:00
Marcel
6d81471294 docs(timeline): flag DatePrecision as a hand-maintained backend mirror
Note above the DatePrecision type that it mirrors the Java DatePrecision enum,
must be updated manually in lockstep with that enum, and must not be migrated
to the OpenAPI-generated type — it drives the shared client-side formatter
shared by documents and the timeline date-label facade.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:17:24 +02:00
Marcel
956a23d0a8 feat(timeline): add precision-aware date-label facade
timelineDateLabel delegates to the shared formatDocumentDate so a timeline
chip renders identically to the same date on a document, in the active
locale (REQ-001/REQ-002). UNKNOWN precision and null/undefined/'' eventDate
short-circuit to null with no formatter call (REQ-003/REQ-004); raw is always
null since timeline events carry no verbatim spreadsheet cell. The facade
owns no precision logic of its own (REQ-005).

Register the new `timeline` frontend domain in the eslint boundaries config
(allowed to import only `shared`) and add src/lib/timeline/** to the vitest
coverage include (REQ-006). The spec partially mocks the paraglide runtime
via importOriginal so getLocale is stubbed while the formatter still resolves
real season/range message exports.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:16:33 +02:00
Marcel
f46f153f33 test(timeline): add failing dateLabel facade spec
Red phase for the timeline date-label helper. Asserts delegation to the
shared formatDocumentDate (localized DAY de/en, SEASON de, same-year RANGE)
and the null cases for UNKNOWN/empty eventDate. The runtime mock path keeps
the `.js` suffix so it matches the import under test.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:13:32 +02:00
Marcel
b32cc5be7e docs(sdd): seed RTM rows for #778 timeline date-label
REQ-001..006 -> #778, Status: Planned. Spec (EARS) lives in the issue body;
these rows are the committed traceability anchor. Flipped to Done as tests land.

Refs #778
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:10:55 +02:00
Marcel
e93e5ec4d1 ci(sdd): cache and pin Spectral in contract-validate
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m2s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 4m44s
CI / fail2ban Regex (push) Successful in 47s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
CI / Semgrep Security Scan (push) Successful in 22s
Pins @stoplight/spectral-cli@6.16.0 and caches ~/.npm keyed on that version, so
Spectral is fetched once and reused across runs instead of re-downloaded each
time. A version bump busts the cache key deterministically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
87b199a772 docs(sdd): add at-a-glance workflow graph, prerequisites, and gates
Consolidates the full SDD workflow into one view at the top of the guide: a
Mermaid flowchart (skills, the three gates, the TDD loop), a one-time
prerequisites checklist, and a gates table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
dc25b77a1c fix(sdd): add Spectral ruleset so contract-validate passes
Spectral v6 ships no implicit ruleset — the CI job exited 'no ruleset found'.
Adds .spectral.yaml (extends spectral:oas, documentation-only warnings relaxed
for design-time stubs), adds operation tags to the _example contract so it lints
clean (0 results), and aligns the api-contract-stub note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
d50e239a2f feat(sdd): add /draft-spec skill — requirements-engineer authors a new spec
Front of the SDD funnel: the requirements engineer interviews the user, elicits
EARS REQ-NNN requirements + measurable acceptance criteria (probing hard for the
Unwanted-behavior clauses specs usually miss), then creates the Gitea feature
issue (issue body = spec), labels it, and emits RTM rows. Authors only — hands
off to /review-issue rather than self-approving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
c160ab3223 refactor(sdd): make the feature spec issue-only (no committed spec.md)
The Gitea issue body is the single source of truth for a spec; the only
per-feature artifact in git is the RTM row (REQ-ID -> issue # -> test). Drops
per-feature spec.md/tasks.md/checklist files from the workflow (the _example
stays as a template/reference). Updates the guide, ADR-041, AGENTS.md, CLAUDE.md,
templates, the RTM (adds an Issue column), the implement/review-pr skills, and
replaces the file-spec CI jobs with an rtm-check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
fa6677a7c5 feat(sdd): adopt review-issue, review-pr, implement skills to the SDD workflow
review-issue becomes the SDD spec-review gate (adds the requirements engineer,
pairs each .claude persona identity with its .specify checklist, EARS/REQ-NNN
aware). review-pr verifies the diff against the constitution and the spec's
REQ-NNN traceability. implement reads the spec artifacts, plans from tasks.md,
ties tasks to REQ-NNN, and keeps the RTM and generated API types in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
a401e595d7 docs(sdd): add SDD onboarding guide and cross-reference from governance docs
Adds SPEC_DRIVEN_DEVELOPMENT.md (8-step workflow, before/after issue, persona
review example, agent-prompt example, maintenance rules, cheatsheet) and points
CLAUDE.md, COLLABORATING.md, and CONTRIBUTING.md at the new .specify/ workflow
without altering the existing cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
a904590843 feat(sdd): add Gitea issue templates and SDD CI gate
Adds .gitea/ISSUE_TEMPLATE/{feature,bug}.md (the feature template mirrors the
EARS feature-spec) and .gitea/workflows/sdd-gate.yml — spec-lint, contract
validation (Spectral), traceability check (all non-blocking during adoption),
and a constitution-impact PR comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
fdc3e4ffa9 feat(sdd): add .specify scaffold — constitution, AGENTS, personas, templates, example, RTM
Introduces the SDD root: a v1.0.0 constitution and machine-readable AGENTS.md
grounded in the project's real conventions; six EARS-aware persona spec-review
checklists that cross-reference .claude/personas/; feature-spec/ADR/threat-model/
api-contract templates; a fully worked _example feature; a living RTM; and an
adrs/ pointer that reuses the existing docs/adr/ archive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
e186a3f646 docs(adr): adopt Spec-Driven Development (ADR-041)
Records the decision to layer SDD (EARS requirements, OpenSpec-style delta
artifacts, a versioned constitution, and AGENTS.md) on top of the existing
Gitea-issue + multi-persona-review workflow. Numbered 041 to extend the
existing docs/adr/ archive rather than starting a parallel one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:55:26 +02:00
Marcel
210dde6562 fix(timeline): reject reversed RANGE events; thread precision
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m56s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Successful in 5m49s
CI / fail2ban Regex (push) Successful in 49s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s
The DB CHECK chk_timeline_event_range enforces only the presence
biconditional (eventDateEnd non-null IFF RANGE), not date ordering, so a
RANGE event with eventDateEnd before eventDate persisted silently and
rendered as a negative span. validateRangeInvariant now also rejects
end-before-start (INVALID_DATE_RANGE); equal dates remain a valid one-day
closed range.

Also compute effectivePrecision once per create/update and thread it into
validateRangeInvariant and applyUpdate instead of recomputing.

Addresses review of #822 (#775).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
3de4ff55ea chore(timeline): regenerate API types for event CRUD endpoints
Regenerated frontend/src/lib/generated/api.ts from the OpenAPI spec — adds the
/api/timeline/events paths and TimelineEventRequest/TimelineEventView schemas.
CI has no OpenAPI drift guard, so the regen is committed here. (Operation-id
churn create->create_1 etc. is cosmetic; the typed client keys off paths, not
operation ids; the timeline PersonView merges with geschichte's identical one.)

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
96e04dbda9 feat(timeline): map new ErrorCodes to localized messages
errors.ts ErrorCode union + getErrorMessage() cases for the four new codes,
with de/en/es i18n keys. Conflict messages are calm/recoverable
('...wurde zwischenzeitlich geändert. Bitte neu laden.'). Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
bb0639b324 docs(timeline): sync ErrorCode catalog with new timeline codes
R9 doc-sync: add TIMELINE_EVENT_NOT_FOUND, TIMELINE_EVENT_CONFLICT,
TIMELINE_TITLE_TOO_LONG, and the generic CONFLICT to the valid-error-codes
list in CLAUDE.md and the error-code reference in docs/ARCHITECTURE.md. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
d7f8abd6c4 test(timeline): add service integration tests (Testcontainers)
Two service-level integration tests against real Postgres (V77 CHECKs are
Postgres-specific): (1) view-assembly round-trip proving the
@Transactional(readOnly=true) LazyInit guard populates persons/documents after
an em.clear()ed fresh getEvent, with a serialized-JSON assertion that no
notes/provisional/password leak; (2) real optimistic-lock 409 — editor B's
stale version yields TIMELINE_EVENT_CONFLICT end-to-end (the unit test only
proves the catch/guard branches).

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
209f223b9f fix(timeline): engage optimistic lock via explicit version compare
The spec's prescribed mechanism (load managed entity -> setVersion(clientVersion)
-> saveAndFlush -> catch ObjectOptimisticLockingFailureException) does NOT engage
the lock: Hibernate ignores a manually-set @Version on a managed entity and uses
its own loaded-version snapshot for the UPDATE ... WHERE version=? clause, so a
stale client write silently succeeds. The integration test the issue mandated to
'prove the lock engages end-to-end' caught exactly this.

Replace it with requireVersionMatch: an explicit compare of the client's
last-seen token against the freshly-loaded version (the true semantics of the Q1
client-supplied-token decision). The native @Version increment still fires on
every save, and the saveAndFlush+catch is retained as the backstop for two
transactions flushing concurrently. Null token => last-write-wins, unchanged.

Deviation from #775's reviewed setVersion mechanism (per maintainer direction the
issue body is left as-is); version unit tests updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
34146d7309 feat(exception): add optimistic-lock backstop returning generic 409
Centralized @ExceptionHandler(ObjectOptimisticLockingFailureException) net so
any write path losing a @Version race becomes a generic 409 (CONFLICT code) —
never a 500 + Sentry + Hibernate internals (CWE-209). No Sentry, class-name-
only parameterized logging, body free of id/version/class. Entity-agnostic by
design (no switch on getPersistentClassName); the service catch keeps the
precise TIMELINE_EVENT_CONFLICT. Per #775 Q2/R4/R8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
390ab30260 feat(timeline): add TimelineEventController CRUD endpoints
POST→201, PUT→200, DELETE→204, GET→200; @RequirePermission(WRITE_ALL) on the
three writes, GET via global auth baseline (no annotation, documented). @Valid
request body; all bodies are TimelineEventView. Injects UserService + private
requireUserId wrapper. Controller slice tests cover 401/403/exact-status per
verb, GET 404, service PERSON_NOT_FOUND→404, Bean-Validation 400s carrying
code=VALIDATION_ERROR, and ArgumentCaptor proof that actorId is the resolved
session principal (not a forged body field) on both write paths.

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
c51fc5e79f feat(timeline): add TimelineEventService with CRUD + view assembly
create/update/delete write methods (@Transactional) + getEvent read
(@Transactional(readOnly=true) for the LazyInit guard). Persons resolved via
PersonService.getAllById with a distinct-size check; documents via per-id
DocumentService.getDocumentById loop; both dedupe-first, fail-closed. RANGE
invariant (both directions), title-length guard, YEAR date normalization, and
default precision. Audit fields server-set (createdBy+updatedBy on create;
only updatedBy on update). Optimistic-lock conflict translated to
TIMELINE_EVENT_CONFLICT via saveAndFlush+catch. Views assembled after flush.

Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
b7a5cd7b53 feat(timeline): add TimelineEventView response views
TimelineEventView + nested PersonView + timeline-local DocumentRef. Explicit
field allow-list, never the raw entity (lazy-collection 500 + curator-field
leak). DocumentRef stays timeline-local by design (#775 R7). Per ADR-040 §2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
0eea19c0d4 feat(timeline): add TimelineEventRequest input DTO
Flat input DTO with Bean Validation (@NotBlank/@NotNull/@Size). createdBy/
updatedBy deliberately absent (server-populated; CWE-639). version is an
optional concurrency token, exempt from the server-only audit rule. Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
Marcel
262568f577 feat(timeline): add ErrorCodes for event CRUD
Add TIMELINE_EVENT_NOT_FOUND (404), TIMELINE_EVENT_CONFLICT (409),
TIMELINE_TITLE_TOO_LONG (400), and a generic CONFLICT (409) used by the
optimistic-lock backstop. Per #775 / ADR-040.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:29:47 +02:00
83ca2eb34d DevOps: Renovate runner + nightly npm audit early-warning (#818) (#821)
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
## Summary

Closes #818. Sets up the prevention layer so newly-published advisories are caught on a branch we own, not on a contributor's PR.

**What changed:**
- `renovate.json` — migrated 2 deprecated keys (`matchPackagePatterns` → `matchPackageNames`, `matchPaths` → `matchFileNames`); added `osvVulnerabilityAlerts`, `dependencyDashboard`, `vulnerabilityAlerts` (labels: security + P1-high), weekly routine `schedule`, and `lockFileMaintenance` (no automerge)
- `.gitea/workflows/renovate.yml` — **new** daily cron runner (`0 3 * * *`), pinned to `renovatebot/github-action@8217b3fc` (v46.1.15) with `renovate-version: "46.1.15"`, `RENOVATE_TOKEN` secret, Gitea platform/endpoint env vars
- `.gitea/workflows/nightly.yml` — added `npm-audit` job (parallel to `deploy-staging`, independent signal): shell self-test, `set +e` audit capture, jq-built deduped issue open/update, `NIGHTLY_AUDIT_TOKEN` via step env only, heartbeat on clean path
- `docs/adr/041-renovate-runner-setup.md` — **new** negative-space ADR (no auto GITEA_TOKEN, two-token rationale, OSV-vs-platform, digest-pin threat model, schedule-batches-routine-only, l2-containers omission)
- `docs/infrastructure/ci-gitea.md` — two-token model, PAT rotation cadence, OSV-vs-platform, nightly/PR-gate divergence table, runbook for nightly-opened issues
- `docs/infrastructure/self-hosted-catalogue.md` — fixed Renovate snippet (daily cron, digest pin, `RENOVATE_TOKEN`, fixed version, no root `automerge: true`)

**No `l2-containers.puml` entry** — Renovate is a scheduled CI job, not a long-lived container. Stated here as a decision, not an oversight (ADR-041).

## Manual steps required before the runner is live (not automated)

1. Create a dedicated bot account (e.g. `renovate-bot`) on the Gitea instance
2. Mint `RENOVATE_TOKEN` PAT (scopes: `contents` + `pull_request` + `issues`) → add as Gitea secret
3. Mint `NIGHTLY_AUDIT_TOKEN` PAT (scope: `issues` only) → add as Gitea secret
4. Configure `main` branch protection to forbid the bot pushing directly

## Acceptance criteria status

- [x] `renovate.json` deprecated keys migrated; vuln surfacing config enabled
- [x] `.gitea/workflows/renovate.yml` exists (digest-pinned, daily cron, fixed version)
- [x] `self-hosted-catalogue.md` snippet corrected (4 items)
- [x] `nightly.yml` npm-audit job: survives non-zero exit, deduped tracking issue, jq payload, NIGHTLY_AUDIT_TOKEN via env only, heartbeat on clean
- [x] ADR-041 records all negative-space decisions
- [x] `ci-gitea.md` documents two-token model + runbook
- [ ] Phase 0 manual gates: bot account creation, Renovate onboarding PR evidence, Dependency Dashboard screenshot — **requires manual provisioning**
- [ ] Dedupe AC verified via `workflow_dispatch` — **requires NIGHTLY_AUDIT_TOKEN secret to be provisioned first**
- [ ] `$GITHUB_STEP_SUMMARY` availability on this runner — **verify in first live run**

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #821
2026-06-13 12:13:35 +02:00
55 changed files with 3865 additions and 111 deletions

View 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>`.

View File

@@ -3,10 +3,17 @@ name: implement
description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed. description: Felix Brandt reads a Gitea issue or Pull Request, clarifies ambiguities with the user, presents an implementation plan for approval, then works autonomously using red/green TDD until every task is done and committed.
--- ---
# Implement — Felix Brandt's Issue/PR-Driven TDD Workflow # Implement — Felix Brandt's Spec-Driven TDD Workflow
You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else. You are Felix Brandt. Read your full persona from `.claude/personas/developer.md` before doing anything else.
Then load the SDD ground truth you must obey throughout:
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, executable constraints, workflow rules, do-not-touch list
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules AGENTS.md references
The feature's `spec.md` (its `REQ-NNN` requirements) is the contract. Implement exactly what
the requirements say — no more, no less.
## Argument ## Argument
The user provides a Gitea issue **or** pull request URL, e.g.: The user provides a Gitea issue **or** pull request URL, e.g.:
@@ -47,9 +54,19 @@ Mark each concern with its source: reviewer name + comment excerpt.
Also read: Also read:
- `CLAUDE.md` for project conventions - `CLAUDE.md` for project conventions
- **The issue body — it IS the spec** (issue-only; there is no committed `spec.md`). Extract its
`REQ-NNN` requirements, acceptance criteria, API stub, data-model delta, and any inline
STRIDE/threat notes. These are your contract.
- [`.specify/rtm.md`](../../../.specify/rtm.md) — note each `REQ-NNN`'s current Status (rows are
keyed by this issue number)
- Any relevant existing source files mentioned in the issue/comments - Any relevant existing source files mentioned in the issue/comments
- The current branch state (`git status`, `git log --oneline -10`) - The current branch state (`git status`, `git log --oneline -10`)
> **If the issue is NOT a well-formed SDD spec** (free-prose, no `REQ-NNN`, missing sections),
> stop before Phase 2 and tell the user: it should go through `/review-issue` (the SDD
> spec-review gate) first. Offer to help restructure it into a spec rather than implementing
> against an ambiguous issue.
Do not start Phase 2 until you have read everything. Do not start Phase 2 until you have read everything.
--- ---
@@ -58,10 +75,12 @@ Do not start Phase 2 until you have read everything.
### Issue mode ### Issue mode
After reading, identify every point that is genuinely ambiguous or underspecified — things you cannot safely decide unilaterally: First, check the spec's `## Open Questions`**any unresolved item there is a blocker** and
- Scope questions (is X in or out of this issue?) must be answered before implementation (SDD step 5). Then identify any further point that is
- Design decisions with multiple valid approaches where the choice affects architecture genuinely ambiguous or underspecified — things you cannot safely decide unilaterally:
- Missing acceptance criteria (how do we know when this is done?) - Scope questions (is X in or out? — check `## Out of Scope` first)
- A `REQ-NNN` that is not testable as written, or has no measurable acceptance criterion
- Design decisions with multiple valid approaches where the choice affects architecture (if it's an irreversible choice, it may need an ADR — flag it)
- Conflicting statements between the issue body and the comments - Conflicting statements between the issue body and the comments
- Dependencies on external things (backend changes needed? migration required?) - Dependencies on external things (backend changes needed? migration required?)
@@ -81,12 +100,15 @@ Wait for the user to answer before continuing.
## Phase 3 — Implementation Plan ## Phase 3 — Implementation Plan
Once clarifications are resolved, present a numbered implementation plan as a task list. Each item must be: Once clarifications are resolved, present a numbered implementation plan as a task list,
**derived from the issue's `REQ-NNN` requirements** (one or more tasks per requirement, in
red/green order). Each item must be:
- A single atomic unit of work (one behavior, one file change, one migration) - A single atomic unit of work (one behavior, one file change, one migration)
- Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist" - Written as a sentence that implies the test name: "Tag detail page returns 404 when tag does not exist"
- Ordered so each item builds on the previous ones - Ordered so each item builds on the previous ones (red/green order — a failing test precedes its implementation)
- Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]` - Prefixed with the layer: `[backend]`, `[frontend]`, `[migration]`, `[test]`, `[refactor]`
- **In issue/SDD mode, tagged with the `REQ-NNN` it satisfies** so every requirement is covered and nothing extra is built. Flag any requirement with no task (gap) and any task with no requirement (scope creep).
**In PR mode**, each task must reference the reviewer concern it addresses, e.g.: **In PR mode**, each task must reference the reviewer concern it addresses, e.g.:
``` ```
@@ -97,10 +119,10 @@ Format:
``` ```
## Implementation Plan ## Implementation Plan
1. [backend] PersonController returns 404 when person id does not exist 1. [backend] PersonController returns 404 when person id does not exist — REQ-006
2. [migration] Add index on documents.sender_id for performance 2. [migration] V<n> add index on documents.sender_id (verify next free number on disk) — REQ-002
3. [frontend] PersonCard renders full name from firstName + lastName props 3. [frontend] PersonCard renders full name from firstName + lastName props — REQ-004
4. [frontend] PersonCard shows placeholder when both names are null 4. [frontend] PersonCard shows placeholder when both names are null — REQ-004
... ...
``` ```
@@ -145,12 +167,22 @@ Check the current branch.
2. Apply any needed clean-up — no new behavior 2. Apply any needed clean-up — no new behavior
3. Run the full suite again to confirm still green 3. Run the full suite again to confirm still green
**Sync (SDD):**
1. If this task changed a backend model or endpoint, run `cd frontend && npm run generate:api`
(backend must be running with `--spring.profiles.active=dev`) and stage the regenerated types.
2. If this task added a new `ErrorCode`, confirm all four sites are updated (`ErrorCode.java`,
`frontend/src/lib/shared/errors.ts`, `getErrorMessage()`, `messages/{de,en,es}.json`).
3. Flip the task's `REQ-NNN` Status in [`.specify/rtm.md`](../../../.specify/rtm.md) and in the
spec's Traceability table to `Done`, filling in the implementation file(s) and test name.
**Commit:** **Commit:**
Commit atomically after each task using the project's commit conventions: Commit atomically after each task using the project's commit conventions, referencing the
issue (`Refs #n` / `Closes #n`) on the last line:
``` ```
feat(scope): short imperative description feat(scope): short imperative description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Refs #<n>
Co-Authored-By: <model> <noreply@anthropic.com>
``` ```
Move to the next task immediately. Move to the next task immediately.
@@ -164,8 +196,10 @@ Move to the next task immediately.
### Rules during autonomous implementation ### Rules during autonomous implementation
- Obey the constitution and AGENTS.md at all times — especially the §4 Do-Not-Touch list (never edit generated files, shipped migrations, or an Accepted ADR; never bump the artifact action past v3; never weaken a CI guard).
- Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code - Never skip the red step — if you cannot write a failing test for a task, stop and explain why to the user before writing any implementation code
- Never add behavior beyond what the current task requires - Never add behavior beyond what the current task requires — and never add behavior with no backing `REQ-NNN`. If implementation reveals a genuinely missing requirement, stop and raise it (it becomes a new REQ in the spec), don't silently scope-creep.
- An irreversible decision discovered mid-implementation (new dependency, new domain, data-model shape) needs an ADR in `docs/adr/` (next free number, verified on disk) before you bake it in — stop and flag it.
- Never bundle two tasks into one commit - Never bundle two tasks into one commit
- If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests - If a test that was passing starts failing during a later task, fix it before continuing — do not leave broken tests
- If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently - If you hit a genuine blocker (missing API, infrastructure not available, etc.) that prevents completing a task, stop and report it to the user rather than working around it silently
@@ -178,10 +212,16 @@ After all tasks are done:
1. Run the full test suite one final time and confirm all green 1. Run the full test suite one final time and confirm all green
2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors 2. Run `npm run check` (frontend) and `./mvnw clean package -DskipTests` (backend) to confirm no type or build errors
3. **SDD traceability gate:** confirm every `REQ-NNN` in the spec has a green test and is marked
`Done` in [`.specify/rtm.md`](../../../.specify/rtm.md). Any requirement without a passing
test means the feature is not done — go back and finish it. Confirm `generate:api` was run
if any backend model/endpoint changed.
### Issue mode ### Issue mode
3. Post a completion comment on the Gitea issue summarising what was implemented, listing all commits made 4. Post a completion comment on the Gitea issue summarising what was implemented, mapping each
4. Report back to the user: every task ✅, any skipped/deferred tasks (with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.) `REQ-NNN` to its commit and test, and listing all commits made
5. Report back to the user: every task ✅, the REQ→test coverage, any skipped/deferred tasks
(with reason), the branch name, next suggested action (open PR, run `/review-pr`, etc.)
### PR mode ### PR mode
3. Push the updated branch 3. Push the updated branch

View File

@@ -1,13 +1,15 @@
--- ---
name: review-issue name: review-issue
description: Multi-persona feature issue review. Each persona from .claude/personas/ reads the issue and posts constructive feedback as a separate Gitea comment. description: Multi-persona SDD spec review of a Gitea feature issue. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, walks it PASS/FAIL/QUESTION against the EARS requirements, and posts findings as a separate Gitea comment before implementation starts.
--- ---
# Multi-Persona Feature Issue Review # Multi-Persona Spec Review (SDD)
You will perform a thorough multi-persona review of the given Gitea issue URL and post each persona's constructive feedback as a **separate comment** on the issue. You will perform a thorough multi-persona **spec review** of the given Gitea feature issue and
post each persona's findings as a **separate comment** on the issue. This is the SDD
Personas give **advisory input only** — no blocking, no verdicts. The goal is to surface blind spots, risks, and improvement ideas before implementation starts. spec-review gate (step 4 of [SPEC_DRIVEN_DEVELOPMENT.md](../../../SPEC_DRIVEN_DEVELOPMENT.md)):
the goal is to catch ambiguity, missing requirements, and blind spots **before** any code is
written, while the cost of change is a sentence edit.
## Argument ## Argument
@@ -19,57 +21,83 @@ Parse it to extract:
- `repo` — e.g. `familienarchiv` - `repo` — e.g. `familienarchiv`
- `issue_number` — e.g. `161` - `issue_number` — e.g. `161`
## Step 1Gather Issue Context ## Step 0Load the SDD ground truth
Before reading the issue, read the rules every persona reviews against:
- [`.specify/constitution.md`](../../../.specify/constitution.md) — the non-negotiable rules
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — stack, constraints, workflow
- [`.specify/templates/feature-spec.md`](../../../.specify/templates/feature-spec.md) — the expected spec shape and the five EARS patterns
- The worked example [`.specify/features/_example/spec.md`](../../../.specify/features/_example/spec.md) — what "good" looks like
## Step 1 — Gather issue context
Use the Gitea MCP tools to collect: Use the Gitea MCP tools to collect:
1. The full issue (title, body, labels, milestone, assignees) via `issue_read` 1. The full issue (title, body, labels, milestone, assignees) via `issue_read`
2. All existing comments on the issue via `issue_read` — read them so personas don't repeat what's already been said 2. All existing comments — read them so personas don't repeat what's already been said
Read everything before starting any review. Read everything before starting any review.
## Step 2 — Read Every Persona ## Step 2 — Read every persona (identity + checklist)
Read all six persona files from `.claude/personas/`: Each persona is its **character identity** (`.claude/personas/`) **plus** its **SDD spec-review
- `developer.md` → Felix Brandt checklist** (`.specify/personas/`). Adopt the voice from the former; gate the spec with the latter.
- `architect.md` → architect persona
- `tester.md` → tester persona
- `security_expert.md` → security persona
- `ui_expert.md` → UI/UX persona
- `devops.md` → DevOps persona
## Step 3 — Write Each Review | Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) |
|---|---|---|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` |
| Developer (Felix Brandt) | `developer.md` | `developer.md` |
| Security (Nora "NullX" Steiner) | `security_expert.md` | `security.md` |
| DevOps | `devops.md` | `devops.md` |
| UI/UX | `ui_expert.md` | `ui-ux.md` |
| Architect | `architect.md` | `architect.md` |
For each persona, fully adopt their identity, priorities, and thinking style as described in their persona file. Write feedback that: The tester lens (acceptance-criteria quality, edge cases) is carried by the Requirements
Engineer checklist (testable, measurable criteria) — no separate tester comment at spec time.
- Is **constructive and forward-looking** — no blockers, no verdicts, no approval stamps ## Step 3 — Run each checklist against the spec
- Asks clarifying questions the persona would genuinely want answered before or during implementation
- Points out risks, edge cases, or gaps the persona sees from their domain
- Offers concrete suggestions or alternative approaches where relevant
- References the issue text specifically — don't write generic advice
- Stays focused on what the persona would actually care about (e.g. Felix asks about test strategy and naming; the architect asks about layer boundaries and coupling; the security expert asks about auth, input validation, and data exposure; the tester asks about acceptance criteria and edge cases; the UI expert asks about interaction patterns and accessibility; DevOps asks about deployment, config, and observability)
Format each comment in Markdown with a persona header, e.g.: For each persona, walk **every item** in its `.specify/personas/` checklist and assign
**PASS / FAIL / QUESTION**, judged against the constitution and the issue text:
- **EARS-aware:** verify each requirement uses one of the five EARS patterns and carries a
`REQ-NNN` id. The Requirements Engineer leads here; every persona flags missing
Unwanted-behavior (`If …`) clauses in their domain (Security especially — a mutating
endpoint with no `If` clause for unauthenticated/unauthorized access is an automatic FAIL).
- **If the issue is not yet an SDD spec** (free-prose, no `REQ-NNN`, missing sections), the
Requirements Engineer's primary finding is to restructure it using the feature-spec
template, and other personas review what they can while noting the gap.
- Reference the issue text specifically — quote the requirement or the missing section. No
generic advice.
## Step 4 — Write and post each comment
Each persona posts a **separate** comment via the Gitea MCP `issue_write` tool, in the format
its checklist's "Output format" section defines — a header, the checklist table, and a verdict:
``` ```
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### 🔐 Security — Spec Review
### Questions & Observations | # | Item | Status | Note |
... |---|------|--------|------|
| 1 | All mutating endpoints have authn + authz `If` clauses | FAIL | REQ-004 POST has no 401 clause (CWE-...) |
| 2 | ... | PASS | |
### Suggestions **Verdict: CHANGES REQUESTED** — blocking FAIL: #1. Resolve before implementation.
...
``` ```
Keep each comment focused and scannable. Use bullet points. Avoid walls of text. Post all six comments. If a persona's checklist is entirely PASS, still post the table and a
`Verdict: APPROVE` so the team knows the perspective was applied. Keep comments scannable.
## Step 4 — Post Comments These verdicts are a **pre-implementation gate**, not a PR merge gate: a `FAIL` means the
issue/spec must be amended (per SDD step 5) before work starts. Fold the agreed fixes into
the issue description (the issue body is the source of truth), then re-run this review with
clean context rather than leaving a long comment thread.
Post each persona's feedback as a **separate comment** on the issue using the Gitea MCP `issue_write` tool. ## Step 5 — Report back
Post all six comments. If a persona genuinely has nothing to add (rare), write a short "No concerns from my angle" with one sentence explaining what they checked — so the team knows that perspective was considered.
## Step 5 — Report Back
After all comments are posted, tell the user: After all comments are posted, tell the user:
- Which personas posted feedback - Each persona's verdict (APPROVE / CHANGES REQUESTED)
- A brief summary of the most important cross-cutting themes (questions or risks that multiple personas flagged) - The consolidated list of blocking FAILs (these must be resolved before implementation)
- Cross-cutting themes multiple personas flagged
- Whether the issue is a well-formed SDD spec yet, or needs restructuring first
- A reminder to mirror the agreed `REQ-NNN` rows into [`.specify/rtm.md`](../../../.specify/rtm.md)

View File

@@ -1,74 +1,95 @@
--- ---
name: review-pr name: review-pr
description: Multi-persona PR review. Each persona from .claude/personas/ reviews the PR and posts their findings as a separate Gitea comment. description: Multi-persona SDD code review of a Gitea PR. Each persona pairs its .claude/personas/ identity with its .specify/personas/ checklist, verifies the diff against the constitution and the feature spec's REQ-NNN (every requirement implemented and tested), and posts findings as a separate Gitea comment.
--- ---
# Multi-Persona PR Review # Multi-Persona PR Review (SDD)
You will perform a thorough multi-persona code review of the given PR URL and post each persona's findings as a **separate comment** on the PR. You will perform a thorough multi-persona code review of the given PR and post each persona's
findings as a **separate comment**. Under SDD, the review verifies the diff against two
contracts: the project [constitution](../../../.specify/constitution.md) and the feature's
spec (the linked **Gitea issue body** — every `REQ-NNN` must be implemented **and** covered by a test).
## Argument ## Argument
The user provides a Gitea PR URL, e.g.: The user provides a Gitea PR URL, e.g.:
`http://heim-nas:3005/marcel/familienarchiv/pulls/160` `http://heim-nas:3005/marcel/familienarchiv/pulls/160`
Parse it to extract: Parse it to extract `owner`, `repo`, and `pull_number`.
- `owner` — e.g. `marcel`
- `repo` — e.g. `familienarchiv`
- `pull_number` — e.g. `160`
## Step 1Gather PR Context ## Step 0Load the SDD ground truth
Read before reviewing:
- [`.specify/constitution.md`](../../../.specify/constitution.md) — rules the code must obey (esp. §4 Do-Not-Touch)
- [`.specify/AGENTS.md`](../../../.specify/AGENTS.md) — constraints
- The feature's spec — the **Gitea issue** the PR closes (`Closes #n`). Read its body for the
`REQ-NNN` requirements, acceptance criteria, inline API stub, and any STRIDE/threat notes.
- [`.specify/rtm.md`](../../../.specify/rtm.md) — the requirement→test→status matrix
## Step 1 — Gather PR context
Use the Gitea MCP tools to collect: Use the Gitea MCP tools to collect:
1. PR metadata (title, description, base branch, head branch) via `pull_request_read` 1. PR metadata (title, description, base/head branch) via `pull_request_read`
2. The list of changed files via `get_dir_contents` or the PR files endpoint 2. The list of changed files
3. The full diff / file contents of every changed file — read each file at the head commit using `get_file_contents` 3. The full content of every changed file at the head commit via `get_file_contents`
Read ALL changed files completely before starting any review. Do not skip files. Read ALL changed files completely before starting. Do not skip files.
## Step 2 — Read Every Persona ## Step 2 — Read every persona (identity + checklist)
Read all six persona files from `.claude/personas/`: Adopt each persona's voice from `.claude/personas/`; apply its review lens. For the SDD
- `developer.md` → Felix Brandt personas, also re-read the matching `.specify/personas/` checklist — at PR time the same
- `architect.md` → architect persona checklist items are verified against the **code** rather than the spec.
- `tester.md` → tester persona
- `security_expert.md` → security persona
- `ui_expert.md` → UI/UX persona
- `devops.md` → DevOps persona
## Step 3 — Write Each Review | Persona | Identity (`.claude/personas/`) | Checklist (`.specify/personas/`) | PR-time focus |
|---|---|---|---|
| Requirements Engineer | `req_engineer.md` | `requirements-engineer.md` | Traceability: every `REQ-NNN` implemented; RTM updated |
| Developer (Felix Brandt) | `developer.md` | `developer.md` | Clean code, layering, generate:api run, ErrorCode four-site |
| Tester | `tester.md` | — (uses identity) | Test quality: each REQ has a real failing-first test; edge cases; levels right |
| Security (Nora "NullX") | `security_expert.md` | `security.md` | authn/authz, IDOR, mass-assignment, `{@html}`, secrets/PII |
| DevOps | `devops.md` | `devops.md` | migration rollback, env vars, CI guards intact, artifact pin |
| UI/UX | `ui_expert.md` | `ui-ux.md` | states, i18n, a11y, design tokens |
| Architect | `architect.md` | `architect.md` | boundaries, ADR present for irreversible choices, no superseded-ADR violation |
For each persona, fully adopt their identity, priorities, and review lens as described in their persona file. Write a review that: ## Step 3 — Write each review
For each persona, write a review that:
- Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested** - Opens with a one-line verdict: **✅ Approved**, **⚠️ Approved with concerns**, or **🚫 Changes requested**
- Lists concrete findings with file paths and line references where relevant - Lists concrete findings with file paths and line references; cite the constitution rule
- Distinguishes blockers (must fix) from suggestions (nice to have) (e.g. "violates §2.4 — `updatedBy` bound from request body") or the `REQ-NNN` at issue
- Uses the persona's voice and priorities (e.g. Felix cares about TDD and clean code; the security expert checks for injection, auth, and data exposure; the architect checks layer boundaries and coupling) - Distinguishes **blockers** (must fix) from **suggestions** (nice to have)
- Stays focused — only comment on what the persona would actually care about - **Requirements Engineer specifically** produces a traceability table — for each `REQ-NNN`:
is it implemented? is there a test? is `rtm.md` updated to `Done`? Any unimplemented or
Format each comment in Markdown with a persona header, e.g.: untested REQ is a blocker. Any code behavior with no backing requirement is flagged
(scope creep — should it be a new REQ, or removed?).
- A constitution **Do-Not-Touch** violation (edited generated file, edited shipped migration,
edited an Accepted ADR, bumped the artifact action past v3, weakened a CI guard) is always
a blocker.
``` ```
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### 🔐 Security — PR Review
**Verdict: ⚠️ Approved with concerns** **Verdict: ⚠️ Approved with concerns**
### Blockers ### Blockers
... - `UserAvatarController.java:42` — REQ-009's 403 path has no test (constitution §2.8)
### Suggestions ### Suggestions
... - ...
``` ```
## Step 4 — Post Comments ## Step 4 — Post comments
Post each persona's review as a **separate comment** on the PR using the Gitea MCP `issue_write` tool (issues and PRs share the comment API in Gitea). Post each persona's review as a **separate comment** via the Gitea MCP `issue_write` tool
(issues and PRs share the comment API). Post all personas; if one has nothing to flag, post a
brief "LGTM" naming what they checked.
Post all six comments. Do not skip any persona even if their domain has nothing to flag — in that case write a brief "LGTM" with a short explanation of what they checked. ## Step 5 — Report back
## Step 5 — Report Back Summarize to the user:
- Each persona's verdict and the overall verdict (worst-case wins: any "Changes requested" → overall "Changes requested")
After all comments are posted, summarize to the user: - The full list of blockers, grouped by persona
- Which personas posted comments - **Traceability status:** which `REQ-NNN` are implemented+tested vs. missing, and whether
- The overall verdict across all personas (worst-case wins: if any said "Changes requested", the overall is "Changes requested") `rtm.md` is in sync
- A bullet list of the top blockers found (if any) - Any constitution Do-Not-Touch violations (called out explicitly)

View 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.>

View 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 (24 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 | | |

View 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
View 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.12.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
View 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
View 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.

View File

@@ -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)

View 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' }

View 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.**

View 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.21.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`) |

View 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 |

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

45
.specify/rtm.md Normal file
View File

@@ -0,0 +1,45 @@
# 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. -->

42
.specify/templates/adr.md Normal file
View 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>

View 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.

View 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 24 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 | | |

View 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
View 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

View File

@@ -16,6 +16,10 @@ See [COLLABORATING.md](./COLLABORATING.md) for the full rules: issue tracking wo
See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack. See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS trade-offs (KISS wins), and SOLID principles applied to this stack.
## Spec-Driven Development
This project uses Spec-Driven Development. **Before implementing a feature, read [`.specify/AGENTS.md`](./.specify/AGENTS.md)** (the short, machine-readable agent rules) and obey the [`.specify/constitution.md`](./.specify/constitution.md) it references. A feature's contract is its **Gitea issue body** (EARS `REQ-NNN` requirements) — there is no committed `spec.md`; the RTM ([`.specify/rtm.md`](./.specify/rtm.md)) traces each `REQ-ID → issue # → test`. Full workflow: [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md); template/reference: [`.specify/features/_example/`](./.specify/features/_example/). The LLM reminders below restate constitution rules — the constitution and AGENTS.md are authoritative if they ever diverge.
--- ---
## Stack ## Stack
@@ -165,7 +169,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints). **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
### Security / Permissions ### Security / Permissions
@@ -273,7 +277,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints). **LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop).
--- ---

View File

@@ -8,6 +8,14 @@ Evaluate all suggestions on their technical merits. No sycophancy — if somethi
## Core Workflow: Research → Plan → Implement → Validate ## Core Workflow: Research → Plan → Implement → Validate
> **Spec-Driven Development.** Feature work is front-ended by an SDD spec: EARS-formatted
> `REQ-NNN` requirements, persona spec-review checklists, and the project constitution. The
> sequence below is unchanged — SDD formalises its *inputs* (the issue body becomes a
> structured spec; the User Journey + E2E Scenarios below feed it). See
> [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md) and
> [`.specify/`](./.specify/) ([constitution](./.specify/constitution.md),
> [AGENTS.md](./.specify/AGENTS.md)).
Every non-trivial feature or bug fix follows this sequence: Every non-trivial feature or bug fix follows this sequence:
1. **Research** — Read the relevant code. Understand existing patterns before touching anything. 1. **Research** — Read the relevant code. Understand existing patterns before touching anything.

View File

@@ -1,6 +1,7 @@
# Contributing to Familienarchiv # Contributing to Familienarchiv
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md). For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
For the Spec-Driven Development workflow (EARS specs, persona review, the constitution, and `.specify/`) see [SPEC_DRIVEN_DEVELOPMENT.md](./SPEC_DRIVEN_DEVELOPMENT.md).
For coding style see [CODESTYLE.md](./CODESTYLE.md). For coding style see [CODESTYLE.md](./CODESTYLE.md).
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)). For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md). For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).

235
SPEC_DRIVEN_DEVELOPMENT.md Normal file
View 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 12 → creates
the issue) → `/review-issue` (step 4 gate) → `/implement` (steps 67) → `/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
```

View File

@@ -155,6 +155,14 @@ public enum ErrorCode {
/** The merge target is a descendant of the source tag. 400 */ /** The merge target is a descendant of the source tag. 400 */
TAG_MERGE_INVALID_TARGET, TAG_MERGE_INVALID_TARGET,
// --- Timeline (Zeitstrahl) ---
/** A timeline event with the given ID does not exist. 404 */
TIMELINE_EVENT_NOT_FOUND,
/** Optimistic-locking conflict — the timeline event was modified by another curator. 409 */
TIMELINE_EVENT_CONFLICT,
/** A timeline event title exceeds the maximum length (255 characters — the DB column bound). 400 */
TIMELINE_TITLE_TOO_LONG,
// --- Generic --- // --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */ /** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR, VALIDATION_ERROR,
@@ -162,6 +170,8 @@ public enum ErrorCode {
BATCH_TOO_LARGE, BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */ /** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS, BULK_EDIT_TOO_MANY_IDS,
/** A concurrent modification was detected (generic optimistic-lock backstop). 409 */
CONFLICT,
/** An unexpected server-side error occurred. 500 */ /** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR, INTERNAL_ERROR,
} }

View File

@@ -104,6 +104,30 @@ public class GlobalExceptionHandler {
return "unknown"; return "unknown";
} }
/**
* Generic backstop for optimistic-locking conflicts that escape a service-level catch. A
* conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it
* must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries
* only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no
* version, no persistent-class name.
*
* <p>Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map
* back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on
* stable schema constraint NAMES, persistent-class names are not a contract. The precise,
* code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only
* the net that keeps any current or future write path from regressing to a 500.
*/
@ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class)
public ResponseEntity<ErrorResponse> handleOptimisticLock(
org.springframework.orm.ObjectOptimisticLockingFailureException ex) {
// Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` /
// ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409.
log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName());
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.CONFLICT,
"The resource was modified concurrently. Please reload and try again."));
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) { public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
Sentry.captureException(ex); Sentry.captureException(ex);

View File

@@ -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);
}
}

View File

@@ -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
) {}

View File

@@ -0,0 +1,247 @@
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.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;
@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;
}
// --- 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);
}
}

View File

@@ -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
) {}
}

View File

@@ -14,6 +14,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
@@ -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 @Test
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() { void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
// Debuggability (DevOps): the WARN must name *which* constraint fired so an // Debuggability (DevOps): the WARN must name *which* constraint fired so an

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own | | `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic | | `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. | | `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. Timeline domain codes: `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG`, plus a generic `CONFLICT` (409 optimistic-lock backstop). |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` | | `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter``PersonRegisterImporter``PersonTreeImporter``DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` | | `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter``PersonRegisterImporter``PersonTreeImporter``DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers | | `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |

View File

@@ -0,0 +1,94 @@
# ADR-042 — Adopt Spec-Driven Development (SDD)
**Status:** Accepted
**Date:** 2026-06-13
**Issue:** SDD integration (docs/sdd-integration branch)
> This is the "ADR-000" the SDD scaffold refers to, numbered 042 to fit the existing archive
> sequence (041 was taken by the Renovate runner-setup ADR merged in parallel). See
> [`.specify/adrs/README.md`](../../.specify/adrs/README.md).
## Context
The project already runs a lightweight, multi-persona review loop: feature work is tracked
as dense Gitea issues, and the `review-issue` / `review-pr` / `deliver-issue` skills dispatch
the character personas in `.claude/personas/` (requirements engineer, developer, security,
devops, ui, architect, tester) to comment on issues and PRs. `COLLABORATING.md` already
mandates a Research → Plan → Implement → Validate cycle, red/green TDD, and a User-Journey +
E2E-Scenario section on every feature issue. Decisions are captured in this ADR archive.
What is missing is a **machine-readable, uniform front-end to that workflow**:
- Requirements are written in free prose, so two readers (and an AI agent) can interpret the
same issue differently. There is no enforced requirement grammar and no stable requirement
identifier to trace from spec → code → test.
- There is no single short file an AI coding agent reads on every invocation; the rules are
spread across `CLAUDE.md`, `COLLABORATING.md`, `CODESTYLE.md`, and `CONTRIBUTING.md`.
- Persona review is valuable but ad-hoc — there is no per-role checklist that gates a spec
*before* implementation, so blind spots surface during PR review instead of at spec time.
- There is no living traceability record and no CI signal that a spec is well-formed.
The project is solo + LLM-driven; spec density and machine-readability are exactly the
leverage points that make agent output reliable.
## Decision
Adopt Spec-Driven Development, layered **on top of** the existing workflow, not replacing it:
1. **EARS requirements.** Every feature requirement is written in one of the five EARS
patterns and carries a per-feature `REQ-NNN` id. (constitution §3; templates/feature-spec.md)
2. **The spec lives in the Gitea issue body — issue-only, no committed `spec.md`.** A feature's
spec (EARS requirements, acceptance criteria, scope) is authored and reviewed in the Gitea
issue, the single source of truth (consistent with the established "issue body is the source
of truth / don't commit standalone spec files" practice). The only per-feature artifact in
git is the RTM row (`REQ-ID → issue # → test`). Durable design decisions still get a
`docs/adr/` ADR; an OpenAPI contract or STRIDE threat model is drafted inline in the issue
using the `.specify/templates/` as writing aids. `.specify/features/_example/` is a committed
template/reference, not a live feature.
3. **A constitution + AGENTS.md.** `.specify/constitution.md` records the few non-negotiable
rules (semantically versioned); `.specify/AGENTS.md` is the short machine-readable file AI
agents read every invocation, cross-referencing the constitution rather than duplicating it.
4. **Persona checklists.** `.specify/personas/*.md` turn the existing rich personas into
concise, EARS-aware, pass/fail spec-review checklists that gate a spec before code.
5. **Living RTM + CI gate.** `.specify/rtm.md` traces every `REQ-NNN` to its issue and test;
`.gitea/workflows/sdd-gate.yml` validates any committed OpenAPI contract, lints any committed
spec (e.g. the `_example`), reports constitution-change impact, and surfaces RTM drift
(non-blocking initially).
6. **Reuse, don't duplicate.** ADRs stay in `docs/adr/`; persona checklists reference
`.claude/personas/`; the Gitea issue templates mirror the feature-spec template.
## Alternatives Considered
| Option | Pros | Cons | Reason rejected |
|---|---|---|---|
| Adopt SDD (EARS requirements in the issue + AGENTS.md + constitution), integrated with existing docs | Adds requirement rigor & machine-readability; reuses ADR archive, personas, and the issue-as-spec practice; incremental | Per-feature spec authoring overhead | **Chosen** |
| No change (keep free-prose issues + persona review) | Zero new process | Ambiguous requirements; no traceability; no single agent-facing file; blind spots found late | Leaves the exact gaps that hurt agent output |
| Full GitHub Spec Kit | Mature, opinionated tooling | Heavy, opinionated structure that fights the existing Gitea/skill/persona workflow; redundant ADR/persona machinery | Too much to retrofit; conflicts with what already works |
| BMAD-METHOD | Rich agent-role framework | Large conceptual surface for a solo project; overlaps the existing persona system | Over-engineered for this team size |
## Consequences
- **Cost:** ~3060 min of spec authoring + structured persona review per feature, before
implementation begins.
- **Gains:** unambiguous, testable requirements; a stable spec→code→test trace; one
authoritative agent-facing rules file; review blind spots caught at spec time; better and
more consistent AI-agent output (the project's primary delivery mechanism).
- **Obligations created:**
- The constitution is semantically versioned; any change triggers its Sync Impact review.
- A new feature follows the workflow in [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md).
- `rtm.md` is kept in sync as requirements land (CI warns on drift).
- The SDD CI jobs start non-blocking and flip to blocking once adoption settles (TODO noted
in the workflow).
- **Non-disruptive:** the existing Gitea-issue, branch/PR, TDD, and persona-review practices
are unchanged in spirit — SDD formalises their inputs, it does not replace them.
## References
- [.specify/constitution.md](../../.specify/constitution.md), [.specify/AGENTS.md](../../.specify/AGENTS.md)
- [SPEC_DRIVEN_DEVELOPMENT.md](../../SPEC_DRIVEN_DEVELOPMENT.md)
- [COLLABORATING.md](../../COLLABORATING.md), [CONTRIBUTING.md](../../CONTRIBUTING.md)
- EARS: Mavin et al., "Easy Approach to Requirements Syntax" (2009)
## Revision log
- 2026-06-13 — constitution v1.0.0 ratified with this ADR.

View File

@@ -158,6 +158,7 @@ export default defineConfig(
{ type: 'ocr', pattern: 'src/lib/ocr/**' }, { type: 'ocr', pattern: 'src/lib/ocr/**' },
{ type: 'activity', pattern: 'src/lib/activity/**' }, { type: 'activity', pattern: 'src/lib/activity/**' },
{ type: 'conversation', pattern: 'src/lib/conversation/**' }, { type: 'conversation', pattern: 'src/lib/conversation/**' },
{ type: 'timeline', pattern: 'src/lib/timeline/**' },
{ type: 'shared', pattern: 'src/lib/shared/**' }, { type: 'shared', pattern: 'src/lib/shared/**' },
{ type: 'routes', pattern: 'src/routes/**' } { type: 'routes', pattern: 'src/routes/**' }
] ]
@@ -198,6 +199,7 @@ export default defineConfig(
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } },
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } }, { from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
{ {
from: { type: 'routes' }, from: { type: 'routes' },

View File

@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).", "error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).", "error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).", "error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"error_timeline_event_not_found": "Zeitleistenereignis nicht gefunden.",
"error_timeline_event_conflict": "Dieses Ereignis wurde zwischenzeitlich geändert. Bitte neu laden.",
"error_timeline_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_conflict": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden.",
"person_unknown": "[Unbekannt]", "person_unknown": "[Unbekannt]",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.", "error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden." "error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."

View File

@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).", "error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).", "error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).", "error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
"error_timeline_event_not_found": "Timeline event not found.",
"error_timeline_event_conflict": "This event was changed in the meantime. Please reload.",
"error_timeline_title_too_long": "The title is too long (maximum 255 characters).",
"error_conflict": "The record was changed in the meantime. Please reload.",
"person_unknown": "[Unknown]", "person_unknown": "[Unknown]",
"error_journey_document_already_added": "This letter is already included in the reading journey.", "error_journey_document_already_added": "This letter is already included in the reading journey.",
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation." "error_geschichte_type_immutable": "The type of a story cannot be changed after creation."

View File

@@ -1237,6 +1237,10 @@
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).", "error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).", "error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).", "error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
"error_timeline_event_not_found": "Evento de la línea de tiempo no encontrado.",
"error_timeline_event_conflict": "Este evento se modificó mientras tanto. Vuelve a cargar.",
"error_timeline_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
"error_conflict": "El registro se modificó mientras tanto. Vuelve a cargar.",
"person_unknown": "[Desconocido]", "person_unknown": "[Desconocido]",
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.", "error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación." "error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."

View File

@@ -52,6 +52,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/timeline/events/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getEvent"];
put: operations["update"];
post?: never;
delete: operations["delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags/{id}": { "/api/tags/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -232,6 +248,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/timeline/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["create"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tags/{id}/merge": { "/api/tags/{id}/merge": {
parameters: { parameters: {
query?: never; query?: never;
@@ -433,7 +465,7 @@ export interface paths {
}; };
get: operations["list"]; get: operations["list"];
put?: never; put?: never;
post: operations["create"]; post: operations["create_1"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -834,10 +866,10 @@ export interface paths {
get: operations["getById"]; get: operations["getById"];
put?: never; put?: never;
post?: never; post?: never;
delete: operations["delete"]; delete: operations["delete_1"];
options?: never; options?: never;
head?: never; head?: never;
patch: operations["update"]; patch: operations["update_1"];
trace?: never; trace?: never;
}; };
"/api/geschichten/{id}/items/{itemId}": { "/api/geschichten/{id}/items/{itemId}": {
@@ -1691,6 +1723,61 @@ export interface components {
notifyOnReply?: boolean; notifyOnReply?: boolean;
notifyOnMention?: boolean; notifyOnMention?: boolean;
}; };
TimelineEventRequest: {
title: string;
/** @enum {string} */
type: "PERSONAL" | "HISTORICAL";
/** Format: date */
eventDate: string;
/** @enum {string} */
precision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
eventDateEnd?: string;
description?: string;
/** Format: int64 */
version?: number;
personIds?: string[];
documentIds?: string[];
};
DocumentRef: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
TimelineEventView: {
/** Format: uuid */
id: string;
title: string;
/** @enum {string} */
type: "PERSONAL" | "HISTORICAL";
/** Format: date */
eventDate: string;
/** @enum {string} */
precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
eventDateEnd?: string;
description?: string;
/** Format: int64 */
version: number;
/** Format: uuid */
createdBy: string;
/** Format: date-time */
createdAt: string;
/** Format: uuid */
updatedBy: string;
/** Format: date-time */
updatedAt: string;
persons: components["schemas"]["PersonView"][];
documents: components["schemas"]["DocumentRef"][];
};
TagUpdateDTO: { TagUpdateDTO: {
name?: string; name?: string;
/** Format: uuid */ /** Format: uuid */
@@ -2060,12 +2147,6 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
}; };
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
JourneyItemCreateDTO: { JourneyItemCreateDTO: {
/** Format: uuid */ /** Format: uuid */
documentId?: string; documentId?: string;
@@ -2498,10 +2579,10 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
number?: number; number?: number;
sort?: components["schemas"]["SortObject"]; sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean; first?: boolean;
last?: boolean; last?: boolean;
/** Format: int32 */
numberOfElements?: number;
empty?: boolean; empty?: boolean;
}; };
PageableObject: { PageableObject: {
@@ -2885,6 +2966,74 @@ export interface operations {
}; };
}; };
}; };
getEvent: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineEventView"];
};
};
};
};
update: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TimelineEventRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineEventView"];
};
};
};
};
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateTag: { updateTag: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3325,6 +3474,30 @@ export interface operations {
}; };
}; };
}; };
create: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TimelineEventRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["TimelineEventView"];
};
};
};
};
mergeTag: { mergeTag: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3754,7 +3927,7 @@ export interface operations {
}; };
}; };
}; };
create: { create_1: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -4480,7 +4653,7 @@ export interface operations {
}; };
}; };
}; };
delete: { delete_1: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -4500,7 +4673,7 @@ export interface operations {
}; };
}; };
}; };
update: { update_1: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;

View File

@@ -56,6 +56,9 @@ export type ErrorCode =
| 'GESCHICHTE_TYPE_IMMUTABLE' | 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG' | 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG' | 'GESCHICHTE_INTRO_TOO_LONG'
| 'TIMELINE_EVENT_NOT_FOUND'
| 'TIMELINE_EVENT_CONFLICT'
| 'TIMELINE_TITLE_TOO_LONG'
| 'INVALID_CREDENTIALS' | 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED' | 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS' | 'MISSING_CREDENTIALS'
@@ -66,6 +69,7 @@ export type ErrorCode =
| 'VALIDATION_ERROR' | 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE' | 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS' | 'BULK_EDIT_TOO_MANY_IDS'
| 'CONFLICT'
| 'INTERNAL_ERROR'; | 'INTERNAL_ERROR';
export interface BackendError { export interface BackendError {
@@ -194,6 +198,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_geschichte_title_too_long(); return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG': case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long(); return m.error_geschichte_intro_too_long();
case 'TIMELINE_EVENT_NOT_FOUND':
return m.error_timeline_event_not_found();
case 'TIMELINE_EVENT_CONFLICT':
return m.error_timeline_event_conflict();
case 'TIMELINE_TITLE_TOO_LONG':
return m.error_timeline_title_too_long();
case 'INVALID_CREDENTIALS': case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials(); return m.error_invalid_credentials();
case 'SESSION_EXPIRED': case 'SESSION_EXPIRED':
@@ -214,6 +224,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_batch_too_large(); return m.error_batch_too_large();
case 'BULK_EDIT_TOO_MANY_IDS': case 'BULK_EDIT_TOO_MANY_IDS':
return m.error_bulk_edit_too_many_ids(); return m.error_bulk_edit_too_many_ids();
case 'CONFLICT':
return m.error_conflict();
default: default:
return m.error_internal_error(); return m.error_internal_error();
} }

View File

@@ -4,6 +4,13 @@ import { m } from '$lib/paraglide/messages.js';
/** /**
* Precision of a document's date — mirrors the backend {@code DatePrecision} enum * Precision of a document's date — mirrors the backend {@code DatePrecision} enum
* and the import normalizer's seven values verbatim. * and the import normalizer's seven values verbatim.
*
* DRIFT RISK: this is a hand-maintained mirror of the Java {@code DatePrecision}
* enum, NOT an OpenAPI-generated type. It must be updated manually whenever the
* Java enum changes, and must NOT be migrated to the generated API type — the
* generated enum is request/response-shaped, while this drives the shared
* client-side formatter (used by both documents and the timeline façade). Keep
* the two in lockstep by hand.
*/ */
export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APPROX' | 'UNKNOWN'; export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APPROX' | 'UNKNOWN';

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatDocumentDate } from '$lib/shared/utils/documentDate';
import { timelineDateLabel } from './dateLabel';
// The mocked path MUST include the `.js` suffix to match the import in
// dateLabel.ts — a specifier mismatch silently skips the mock and the helper
// would resolve the real locale. We partially mock via importOriginal so only
// getLocale is stubbed: the shared formatter still pulls real Paraglide message
// exports (season words, range prefixes) off the same runtime module.
vi.mock('$lib/paraglide/runtime.js', async (importOriginal) => ({
...(await importOriginal<typeof import('$lib/paraglide/runtime.js')>()),
getLocale: vi.fn(() => 'de')
}));
describe('timelineDateLabel', () => {
beforeEach(() => {
// Default every test to German; locale-specific tests override below.
vi.mocked(getLocale).mockReturnValue('de');
});
it('renders a DAY date localized in German (REQ-001/REQ-002)', () => {
const label = timelineDateLabel('1916-07-28', 'DAY');
expect(label).toContain('28');
expect(label).toContain('Juli');
});
it('renders a DAY date localized in English (REQ-001/REQ-002)', () => {
vi.mocked(getLocale).mockReturnValue('en');
const label = timelineDateLabel('1916-07-28', 'DAY');
expect(label).toContain('July');
});
it('renders a SEASON date with the German season word (REQ-001/REQ-002)', () => {
const label = timelineDateLabel('1916-04-01', 'SEASON');
expect(label).toContain('Frühling');
});
it('returns null for UNKNOWN precision even with a date (REQ-003)', () => {
expect(timelineDateLabel('1916-07-28', 'UNKNOWN')).toBeNull();
});
it('returns null for UNKNOWN precision without a date (REQ-003)', () => {
expect(timelineDateLabel(null, 'UNKNOWN')).toBeNull();
});
it('returns null for APPROX with a null eventDate, without calling the formatter (REQ-004)', () => {
expect(timelineDateLabel(null, 'APPROX')).toBeNull();
});
it('returns null for DAY with an empty-string eventDate (REQ-004)', () => {
expect(timelineDateLabel('', 'DAY')).toBeNull();
});
it('delegates a same-year RANGE to formatDocumentDate (REQ-001/REQ-002)', () => {
const expected = formatDocumentDate('1914-01-01', 'RANGE', '1914-11-11', null, 'de');
expect(timelineDateLabel('1914-01-01', 'RANGE', '1914-11-11')).toBe(expected);
});
it('treats undefined eventDateEnd identically to null for RANGE (REQ-004)', () => {
expect(timelineDateLabel('1914-01-01', 'RANGE', undefined)).toBe(
timelineDateLabel('1914-01-01', 'RANGE', null)
);
});
});

View File

@@ -0,0 +1,30 @@
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
import { getLocale } from '$lib/paraglide/runtime.js';
/**
* Renders a timeline event's date by delegating to the shared
* {@link formatDocumentDate} — so a timeline chip reads identically to the same
* date on a document, in whichever locale is active. This module is a thin
* façade: it owns NO precision or rendering logic (REQ-005), only the two
* timeline-specific decisions below.
*
* @param eventDate the event's anchor day (`YYYY-MM-DD`). `null`, `undefined`
* and `''` are equivalent — all mean "undated" and yield `null` WITHOUT
* calling the formatter (REQ-004).
* @param precision the event's precision metadata; `'UNKNOWN'` yields `null`
* (no chip) even when a date is present (REQ-003).
* @param eventDateEnd the RANGE end day; `undefined` and `null` are equivalent
* and both mean an open-ended range.
* @returns the localized label, or `null` for an UNKNOWN/undated event.
*/
export function timelineDateLabel(
eventDate: string | null | undefined,
precision: DatePrecision,
eventDateEnd?: string | null
): string | null {
if (precision === 'UNKNOWN' || !eventDate) return null;
// raw is always null for timeline events — there is no verbatim spreadsheet
// cell to interpolate; season words derive from the structured anchor month
// (never from untrusted text). See documentDate.ts for the raw contract.
return formatDocumentDate(eventDate, precision, eventDateEnd ?? null, null, getLocale());
}

View File

@@ -72,6 +72,7 @@ export default defineConfig({
'src/lib/shared/server/**', 'src/lib/shared/server/**',
'src/lib/shared/discussion/**', 'src/lib/shared/discussion/**',
'src/lib/document/**', 'src/lib/document/**',
'src/lib/timeline/**',
'src/hooks.server.ts' 'src/hooks.server.ts'
], ],
exclude: [ exclude: [