Draft stories never appear on the Geschichten overview — blog writers can't see their own drafts #807
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?
User-facing impact
A user with
BLOG_WRITEcreates a story. It is saved as aDRAFT. When they open the stories overview at/geschichten, only PUBLISHED stories are listed — their own drafts are nowhere to be found. There is no way to get back to an unfinished draft from the overview; the only path is to remember/bookmark the direct/geschichten/{id}URL.Root cause
The overview's server load hardcodes
status: 'PUBLISHED'in the API query and never requests drafts. The backend is perfectly willing to return a blog writer's own drafts — the overview simply never asks for them, and the page has no UI to surface them.frontend/src/routes/geschichten/+page.server.ts:14-23The backend already supports this correctly. In
GeschichteService.list()(backend/.../geschichte/GeschichteService.java:117-135):So a blog writer calling
GET /api/geschichten?status=DRAFTwould get back their own drafts (theauthorIdfilter scopes drafts to the current user — no leaking of other authors' drafts). The capability exists end-to-end; the overview page just doesn't use it.Why the existing "drafts module" doesn't cover this
The home page (
frontend/src/routes/+page.server.ts:47-50) does fetch drafts and render a drafts module — but only on the reader home, gated byisReader = !canWrite && !canAnnotate. That means:WRITE_ALLorANNOTATE_ALLlands on the non-reader home and sees no drafts module at all.limit: 10) teaser on the home page, never on the dedicated/geschichtenoverview where they'd naturally look for their stories.So drafts are effectively invisible from the page whose entire job is listing stories.
Security concern — CWE-639 (Broken Access Control)
When a blog writer calls
GET /api/geschichtenwithout astatusparameter, the current code at line 118 ofGeschichteService.javaevaluatescurrentUserHasBlogWrite() ? null : GeschichteStatus.PUBLISHED. Thenullpasses through tohasStatus(null)which adds no predicate, andauthorIdstaysnullbecauseeffective == GeschichteStatus.DRAFTis false fornull. The query returns all stories from all authors, including other authors'DRAFTstories. Severity: Medium (CVSS ~5.3, AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N). Requires authentication; impact is confidentiality — draft content and existence disclosed to authenticated users.The existing test
list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible(line 235 inGeschichteServiceTest.java) actively asserts the vulnerable behavior. It passesnullstatus and assertsout.hasSize(2)without verifying what status was passed to the repository — it would pass against both the vulnerable code and the fixed code. Renaming and rewriting this test before the fix lands is mandatory; a looseany()on the status is not sufficient for a security regression test. Also,list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE(line 224) should be strengthened to useeq(GeschichteStatus.PUBLISHED)as the first arg tofindSummaries, notany().GeschichteQueryServiceis not vulnerable — it exposesexistsByIdandfindByIdonly; nolist()equivalent, no status filtering. No fix needed there.getById()is not vulnerable — lines 73-77 correctly throw NOT_FOUND (not FORBIDDEN) for drafts when the user lacksBLOG_WRITE. ThefindSummariesJPQL uses a typedGeschichteStatusparameter (not string concatenation), so no SQL injection risk.Secondary observations
geschichten/+page.svelterenders every card identically and only shows a date viapublishedAt(g)(line 48-51), which isnullfor drafts. If drafts are added to the list they'll need a "Entwurf" badge so writers can distinguish them from published stories. No such i18n key / badge exists yet.(alle Entwürfe)caption on the section heading (see i18n keys below).GeschichteService.list()— treat "blog writer + non-DRAFTstatus" as PUBLISHED-only:effective = (currentUserHasBlogWrite() && status == DRAFT) ? DRAFT : PUBLISHED, so drafts require an explicitstatus=DRAFTrequest (which already author-scopes). The frontend sending explicitstatus: 'DRAFT'remains as defense-in-depth layer 2. Do not gate the fix behindcanBlogWriteon the frontend alone — the backend fix is the control.Decisions (resolved)
Draft row link target — link to
/geschichten/{id}(detail page)Clicking a draft row navigates to the story detail/read view, the same as published stories. The writer then clicks an Edit CTA from there to resume editing. No conditional
hrefneeded inGeschichteListRow.Section heading copy — "Entwürfe"
Use
geschichten_drafts_heading= "Entwürfe" / "Drafts" / "Borradores". The unfiltered scope is communicated via the(alle Entwürfe)caption (see i18n keys below). "Meine Entwürfe" was considered but rejected — "Entwürfe" + caption is sufficient at current scale.Draft badge color tokens — reuse
bg-journey-tint border border-journey-border text-journeyDo not use raw Tailwind amber utilities (
bg-amber-50 border-amber-200 text-amber-700).layout.cssalready has--color-journey-tint,--color-journey-border, and related amber tokens (including dark-mode overrides). Reusebg-journey-tint border border-journey-border text-journey— the same tokens used by the JOURNEY badge inGeschichteListRow.svelte(lines 41-46). This is consistent, dark-mode safe, and does not bypass the project token system. WCAG 1.4.1 is satisfied by the literal word "Entwurf" in the badge text regardless of color. Contrast oftext-journeyonbg-journey-tintis to be verified at implementation time — if it fails 4.5:1 for 12px/700 text, use a darker token. The JOURNEY badge has been in production since the JOURNEY feature shipped; the draft badge inherits the same contrast level as the established bar.settled<T>()helper location — extract to shared moduleThe
settled<T>()helper inroutes/+page.server.ts(lines 17-21) must be extracted to$lib/shared/server/settled.ts(next tolocale.tsandpermissions.ts) as a preparatory commit before implementing the drafts fetch. Do not duplicate it inline inroutes/geschichten/+page.server.ts— two diverging copies is unacceptable. After extraction, bothroutes/+page.server.tsandroutes/geschichten/+page.server.tsimport from the shared location. Verify that any import change does not break the existing test imports.Heading hierarchy — add a "Veröffentlicht" heading gated on
drafts.length > 0When the "Entwürfe" section is visible, both sections must have an
<h2>to keep the heading outline balanced for screen reader navigation. Addgeschichten_published_heading= "Veröffentlicht" / "Published" / "Publicadas" (3 i18n keys). Gate both headings ondrafts.length > 0— the "Veröffentlicht" heading is only added when the "Entwürfe" heading is present; without drafts, the page uses no<h2>for the single list (unchanged baseline behavior).Proposed fix
Surface the current blog writer's own drafts on
/geschichten. Implementation order:1. Backend guard (TDD first)
First: rename and rewrite the existing null-status test (before touching
list()). Renamelist_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visibletolist_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories. The rewrite must useverify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any())— this makes the existing false-safety-net test into a real regression fixture. Also strengthenlist_forces_PUBLISHED_status_for_reader_without_BLOG_WRITEto useeq(GeschichteStatus.PUBLISHED)notany()as the first argument.Then write two new failing JUnit tests:
These tests must be red against the current code. Then tighten
list():Add a Javadoc comment to
list()explicitly stating: null status for a blog writer resolves to PUBLISHED; DRAFT resolves to the writer's own drafts only. Minimum content: "null status for a blog writer resolves to PUBLISHED, never to all-stories. DRAFT status scopes the query to the current user's own drafts only." This makes the security rule auditable by reading the method signature alone.2. Extract
settled<T>()to shared module (preparatory commit)Move the
settled<T>()helper fromroutes/+page.server.ts:17-21to$lib/shared/server/settled.tsand export it. Update the import inroutes/+page.server.ts. Keep it a pure function — no side effects, no logging. Verify all existing tests forroutes/+page.server.tsstill pass after the import change.3. Frontend
+page.server.tsconst { canBlogWrite } = await parent()(the current load function takes({ url, fetch })withoutparent— change the signature to({ url, fetch, parent })).canBlogWrite, push a secondGET /api/geschichten?status=DRAFTrequest usingPromise.allSettled+ the sharedsettled<T>()helper — notPromise.all— so a drafts-fetch failure degrades todrafts: []without 500-ing the whole overview. Mirror the exact pattern fromroutes/+page.server.ts:47-63.{ geschichten, drafts, personFilters, documentIdFilter }.Test coverage for
+page.server.ts: ThecallLoadhelper must provideparent. Update the helper first (standalone step, green commit) so existing tests remain green:All 13 existing tests in
page.server.test.tsusecallLoad(url)with no opts — addingcanBlogWrite: falseas the default keeps them green unchanged.New server test cases needed:
canBlogWrite=true→mockGetcalled twice (once PUBLISHED, once DRAFT); assertexpect(mockGet).toHaveBeenCalledTimes(2)and verify the second call hasstatus: 'DRAFT'.canBlogWrite=false→ no second request.drafts: [], PUBLISHED list unaffected. Mock pattern:Promise.allSettledcatches network-level failures, not just non-ok responses.4. Frontend
+page.svelteRender a distinct "Entwürfe" section above the published list, only when
data.drafts.length > 0. Gate strictly ondrafts.length > 0, notcanBlogWrite— no empty-state noise for writers with zero drafts.Place the Entwürfe section and published list in the same card (
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">), separated by aborder-b-2 border-linedivider before the published list heading.When
data.drafts.length > 0, render an<h2>for the drafts section and a matching<h2>for the published list (gated on the same condition), to keep the heading outline balanced:Use a keyed
{#each data.drafts as g (g.id)}(matching the existing published list pattern).The existing
$derivedforemptyMessageconsiders onlypersonFiltersanddocumentFilter— do not change it. It correctly describes the state of the filtered published list, not the drafts section.Browser test coverage needed: Add
drafts: []to themakeDatafactory as the very first change, before touching the svelte page, to keep all existing browser tests green:Then add:
drafts.length > 0.drafts: [].canBlogWrite.draft-badgenot present whenstatus: 'PUBLISHED') — prevents a regression that always shows the badge.5.
GeschichteListRow.svelte— draft badge'status'to thePicktype (current:'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt').GeschichteSummary.statusis already@Schema(requiredMode = REQUIRED)on the backend, so non-optional in generated types — no?guard needed.data-testid="draft-badge"(desktop),data-testid="draft-badge-mobile"(mobile)bg-journey-tint border border-journey-border text-journey(dark-mode safe; matches the JOURNEY badge tokens already in use)rounded-sm px-1.5 py-px font-sans text-xs font-bold tracking-wide uppercaseshrink-0on the mobile badge span (matching the JOURNEY mobile badge at line 72) to prevent collapsing the title on narrow screensflex-wrapto the mobilediv.mb-1flex row to prevent badge overflow at 320px when both JOURNEY and DRAFT badges are present simultaneously{m.geschichten_draft_badge()}(non-caps form "Entwurf", not "ENTWURF" — do not reuse existinggeschichte_editor_status_draftkey which renders "ENTWURF")publishedAtisnullfor drafts; the existing{#if publishedAt}gate handles this correctly — no change neededshrink-0on both)div.mb-1flex row). Use two separate{#if}blocks, not one combined condition, for readability.6. i18n keys (
messages/{de,en,es}.json)geschichten_drafts_headinggeschichten_draft_badgegeschichten_drafts_unfiltered_captiongeschichten_published_headingNote:
geschichte_editor_status_draft("ENTWURF") already exists — do not reuse it; the list badge uses the non-caps form "Entwurf".7. Regenerate API types
Run
npm run generate:apiinfrontend/. Expected: no diff (no new backend fields or endpoints were introduced;GeschichteSummary.statuswas alreadyREQUIRED). If a diff appears, fix the missing annotation before merging.Acceptance criteria
/geschichten, visually marked with an "Entwurf" badge, in a section labeled "Entwürfe" above the published list.author.idof every item in the drafts section equals the logged-in user's id.(alle Entwürfe)caption signals this to the user.canBlogWrite.BLOG_WRITEsees no drafts and no draft section (unchanged behaviour).drafts: [], section absent).GeschichteService.list()with a blog writer + null/omitted status returns only PUBLISHED stories (backend guard, covered by two new JUnit tests with@DisplayNamereferencing the security nature).<h2>heading is also present for the published list (heading outline is balanced for screen reader navigation).Files involved
backend/.../geschichte/GeschichteService.java— null-status guard + Javadocbackend/.../geschichte/GeschichteServiceTest.java— rename/rewrite existing false-safety-net null-status test; strengthenlist_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE; 2 new security-regression tests with@DisplayName$lib/shared/server/settled.ts(new) — extractedsettled<T>()helper (preparatory commit)frontend/src/routes/+page.server.ts— update import for extractedsettled<T>()helperfrontend/src/routes/geschichten/+page.server.ts—await parent(), second DRAFT request viaPromise.allSettled+ sharedsettled<T>(), resilient fetchfrontend/src/routes/geschichten/page.server.test.ts—parentmock incallLoad(standalone commit first), new test casesfrontend/src/routes/geschichten/+page.svelte— "Entwürfe" section + gated "Veröffentlicht" heading above published listfrontend/src/routes/geschichten/page.svelte.spec.ts— adddrafts: []tomakeDatafactory first; then new browser tests for drafts section + badge + badge-absent-on-published-row + Veröffentlicht headingfrontend/src/lib/geschichte/GeschichteListRow.svelte— addstatusto Pick, add draft badge (desktop + mobile slots,shrink-0on mobile,flex-wrapon mobile row)frontend/messages/{de,en,es}.json— 4 new i18n keys👨💻 Felix Brandt — Fullstack Developer
Observations
Backend — the two vulnerable tests are exactly as described.
list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible(line 235) usesany()for all five args tofindSummariesand only assertsout.hasSize(2)— passing against both vulnerable and fixed code.list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE(line 224) is equally loose:any()as the first argument makes it vacuous. Both rewrites must be the very first commits beforelist()is touched, so there is proof of red→green.Backend —
GeschichteService.list()is clean and under 20 lines. The proposed ternary replacement stays within that budget. The Javadoc fits in 2 lines of comment above the method and satisfies the security-contract requirement without padding the line count.Frontend —
callLoadsignature mismatch confirmed. CurrentcallLoadat line 27 ofpage.server.test.tstakes only(url: URL)— noparent. Addingparent: vi.fn().mockResolvedValue({ canBlogWrite: false, ... })as the default in the helper keeps all 13 existing tests green with no call-site changes.settled<T>()extraction is one file. The function lives atroutes/+page.server.ts:17-21. Target is$lib/shared/server/settled.ts— that directory already exists withlocale.tsandpermissions.ts. The function is pure (no side effects, no imports), so the extraction is a clean copy + export + import update.GeschichteListRow.svelte— thePicktype is missing'status'. Current:'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'. Adding'status'is safe —GeschichteSummary.statusis@Schema(requiredMode = REQUIRED)so it is non-optional in generated types. No?guard needed. The file is atfrontend/src/lib/geschichte/GeschichteListRow.svelte.page.svelte.spec.tsmakeDatafactory — the factory at line 27 includescanBlogWrite: falsebut nodraftsfield. Addingdrafts: []must happen before any change to+page.svelte, or all 30+ existing browser tests will TypeScript-error the moment the page expectsdata.drafts.The
+page.sveltecard structure — the page currently wraps the filter row and story list in one<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">. The drafts section and divider must be placed inside this same card div, above the existing{#if data.geschichten.length === 0}block. Check that the{#each data.drafts as g (g.id)}key expression matches the published list pattern{#each data.geschichten as g (g.id)}.Recommendations
list()— green; (3) extractsettled<T>()— green; (4) updatecallLoadhelper — green; (5) adddrafts: []tomakeData— green; (6) backend + frontend feature; (7) i18n keys + API regen. Never bundle two logical changes.GeschichteListRowbadge placement: use two separate{#if geschichte.status === 'DRAFT'}blocks — one in the desktop meta column after the JOURNEY badge, one in the mobilediv.mb-1flex row after the journey badge span. Do not combine with a single|| isJourneycondition; readability matters more than the one line saved.$derivedforisDraft: extractconst isDraft = $derived(geschichte.status === 'DRAFT')alongsideconst isJourney = $derived(...)— keeps the template logic-free and named.Promise.allSettlednetwork-rejection test is the most important new server test. It provesPromise.allSettled(correct) vsPromise.all(500s on draft failure). Write it with a path/status-keyed mock, not a blanket reject-all mock, so the PUBLISHED fetch is still verified to succeed independently.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
CWE-639 (Broken Access Control) confirmed in live code.
GeschichteService.java:118:When a blog writer calls with
status = null,effective = null. Line 121:effective == GeschichteStatus.DRAFT→ false →authorId = null.findSummaries(null, null, ...)returns all stories from all authors including other writers' DRAFTs. Confirmed information-disclosure vulnerability.The fix is correct and tight.
effective = (currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT) ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHEDcloses the null-passthrough without introducing new branches. The two new@DisplayName("security: ...")tests provide a permanent regression fixture in CI output — they will appear by name in any future security audit of the test suite.findSummariesJPQL uses a typedGeschichteStatusparameter — confirmed by the repository interface signature. No SQL injection risk. The status parameter is an enum, not a user-provided string.getById()is not vulnerable. Lines 73-77 throw NOT_FOUND (not FORBIDDEN) for drafts without BLOG_WRITE — correct security-through-misdirection pattern for this threat model. No timing oracle concern at this scale.GeschichteQueryServiceis not vulnerable —existsByIdandfindByIdonly, nolist()path. The mandatory audit is confirmed complete; no additional guard needed.The false-safety-net test is the highest-priority item.
list_passes_null_status_through_for_BLOG_WRITE_so_drafts_are_visibleassertshasSize(2)against athenReturn(List.of(s1, s2))stub — it passes regardless of what status was passed to the repository. It gives a false green on the vulnerable code. Renaming it and replacingany()witheq(GeschichteStatus.PUBLISHED)as the first argument turns it into a real regression fixture. This rewrite must be red beforelist()is touched.Frontend
canBlogWriteguard (checkingparent()before issuing the DRAFT request) is defense-in-depth layer 2 — correct. It prevents DRAFT requests from appearing in access logs for non-blog-writers, which is a clean audit trail. But it is not the security control; the backend fix is.The Javadoc on
list()is a security-documentation practice, not optional polish. A future developer must be able to audit the null-status contract by reading the method signature without tracing callers or reading the test suite.Recommendations
eq(GeschichteStatus.PUBLISHED)andeq(GeschichteStatus.DRAFT)as the first argument toverify(...).findSummaries(...). The@DisplayNameprefix "security:" makes these permanently identifiable in CI output and in any future manual audit.list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories. Run it against the currentlist()— it must fail. Then fixlist()— it must pass. This sequence proves the test was meaningful.list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITEby replacingany()witheq(GeschichteStatus.PUBLISHED)as the first argument toverify(...).findSummaries(...). Currently this test would pass even if the service passednullto the repository for a reader.list()in the same commit — the rename commit must be red (test fails against current code), and the fix commit must turn it green. This is the standard security-fix TDD workflow.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
Two false-safety-net tests confirmed in
GeschichteServiceTest.java. Bothlist_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible(line 235) andlist_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE(line 224) useany()as the first argument toverify(...).findSummaries(...). A test that verifies nothing about the status argument while calling itself a safety net is worse than no test — it provides false confidence and will pass against both the broken and fixed code.callLoadinpage.server.test.tsdoes not acceptparent. Current signature at line 27:callLoad(url: URL). All 13 existing tests usecallLoad(makeUrl(...)). Once+page.server.tsaddsawait parent(), every existing test will fail with a "parent is not a function" or similar error. ThecallLoadupdate must be a standalone, green commit before any production code changes. The defaultcanBlogWrite: falseensures all 13 existing call sites pass without modification.The DRAFT-fetch network-rejection test is the most critical new server test. It distinguishes
Promise.allSettled(correct — degrades todrafts: []) fromPromise.all(wrong — 500s the entire overview). The mock must reject at the network level (Promise.reject(new TypeError('Failed to fetch'))), not just return{ response: { ok: false } }— a non-ok response is handled differently than a thrown rejection inPromise.allSettled.makeDatafactory inpage.svelte.spec.tsmust getdrafts: []before+page.svelteis touched. Currently the factory at line 27 returns{ geschichten: [], personFilters: [], documentFilter: null, canBlogWrite: false }. Once+page.svelteexpectsdata.drafts, all 30+ existing browser tests will TypeScript-error. This is a one-line addition:drafts: []— it must be its own standalone green commit.GeschichteListRow.svelte.spec.tsalready exists atfrontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts. The badge-absent-on-published-row test belongs there, not in the page spec, since the badge logic lives in the row component. This test usesdata-testid="draft-badge"(not text matching) so it is decoupled from i18n string changes.Test naming should follow the
@DisplayNameconvention for Java. The two new security tests must prefix their@DisplayNamewith"security:"to be identifiable in CI output without opening the file.No integration test gap. The security fix is fully coverable at the unit test layer (Mockito +
@ExtendWith(MockitoExtension.class)). ThefindSummariescontract is what matters, and the mock verifies the exact arguments. No Testcontainers needed for this fix.Recommendations
list()fix → green,settled<T>()extraction → green,callLoadupdate → green,makeDatafactory update → green) must stay green before the next commit begins. Never let the test suite go red between preparatory commits (except the intentional red from the test rename).expect(document.querySelector('[data-testid="draft-badge"]')).toBeNull()on a row withstatus: 'PUBLISHED'. This is the highest-value regression test — it is the only test that would catch a badge rendered unconditionally due to a missing{#if}.drafts.length > 0and absent whendrafts: []. This exercises the gated heading decision.🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Observations
GeschichteListRow.sveltebadge placement is correct. The desktop meta column isflex flex-col items-start gap-1— adding a draft badge after the JOURNEY badge stacks them vertically withgap-1spacing. The mobilediv.mb-1isflex items-center gap-1.5— adding a draft badge here means a JOURNEY+DRAFT story shows two badges next to the title. Withshrink-0on both badges andflex-wrapon the row, this is safe at 320px.flex-wrapon the mobilediv.mb-1row is confirmed necessary. Currently the row isflex items-center gap-1.5 sm:hiddenat line 52. Withoutflex-wrap, a JOURNEY+DRAFT story's two badges plus<h2>title can overflow at 320px. The<h2>hasfont-serif text-lg— at 320px with two badges that is a tight fit. Addingflex-wrapallows the title to wrap to the next line rather than truncating.Badge contrast:
text-journeyonbg-journey-tint— measure at implementation time. The JOURNEY badge has been in production since the JOURNEY feature shipped. The decision to verify at implementation time (not block on it now) is correct. Iftext-journeyfails 4.5:1 for 12px/700 text onbg-journey-tint, usetext-journey-darkor a darker variant. WCAG 1.4.1 (use of color) is satisfied by the literal word "Entwurf" regardless.Caption text
text-ink-3onbg-surface— may need to betext-ink-2. Check at implementation time. The caption(alle Entwürfe)usesnormal-case font-normal text-ink-3. Iftext-ink-3is below 4.5:1 onbg-surface, usetext-ink-2. The section heading usestext-ink-3withuppercase font-boldwhich is 3:1 minimum (large/bold text) — that threshold is easier to meet.Gated "Veröffentlicht" heading is the right decision. The heading outline becomes balanced only when the "Entwürfe" section is visible. When there are no drafts, adding a "Veröffentlicht"
<h2>above the single list would be redundant noise. The gate ondata.drafts.length > 0handles both cases correctly.The
<h2>heading for the drafts section usestext-xs font-bold uppercase tracking-widest text-ink-3— this is the project's standard section title pattern (confirmed in the card pattern in the style guide). The(alle Entwürfe)caption span usesnormal-case font-normal text-ink-3— this correctly overrides theuppercasefrom the parent<h2>.Touch target check: the draft badge sits inside the
<a>row which hasmin-h-[44px]— the badge inherits the 44px touch target from the parent anchor. No separate touch target needed for the badge (it is non-interactive).Dark mode:
bg-journey-tint,border-journey-border, andtext-journeyhave dark-mode overrides inlayout.css(confirmed by existing JOURNEY badge). The draft badge inherits dark-mode safety without any additional work.Recommendations
flex-wrapto the mobilediv.mb-1flex row — changeflex items-center gap-1.5 sm:hiddentoflex flex-wrap items-center gap-1.5 sm:hidden. This prevents overflow at 320px for JOURNEY+DRAFT dual-badge rows without affecting single-badge rows.--color-journey(text) and--color-journey-tint(background) fromlayout.css. 4.5:1 is the AA threshold for 12px/700 text. If it fails, add atext-journey-darktoken override rather than using a raw hex.text-ink-3is below 4.5:1 onbg-surface, usetext-ink-2. Verify both light and dark mode contrast independently, since dark mode remaps the token.<h2>inside the card but doesn't specify padding. Usepx-3 py-2.5(matching the filter row) orpx-3 pt-3 pb-1so the heading has breathing room above the draft rows. Check visually against theborder-b-2 border-linedivider that follows.🏛️ Markus Keller — Application Architect
Observations
Module boundaries are clean throughout.
GeschichteServiceownsGeschichteRepositoryand callsPersonService,DocumentService,UserServicethrough their public interfaces.GeschichteQueryServiceexists specifically soJourneyItemServicecan check Geschichte existence without holding a direct repository reference. The proposed fix does not introduce any cross-domain repository access — the three-line ternary change stays entirely withinGeschichteService.The
settled<T>()extraction is architecturally correct. The function is currently a private closure inroutes/+page.server.ts:17-21. Two server load functions needing the same helper is the canonical signal for extraction.$lib/shared/server/already containslocale.tsandpermissions.ts—settled.tsfits by convention. This is not over-engineering; it closes the door on a diverging second copy that would form the moment the pattern is needed in a third load function (there are multiple+page.server.tsfiles in this codebase that could benefit from it).The
Promise.allSettled+settled<T>()pattern is the project's established resilient-fetch idiom. It is already used inroutes/+page.server.ts:47-63for the reader home drafts fetch. Making it canonical via a shared module is the right call. The alternative —Promise.all— would turn a drafts-fetch failure into a 500 on the entire/geschichtenoverview, which is disproportionate to the business impact.No new backend endpoints, no new entities, no Flyway migration. This is a bug fix on an existing endpoint plus a frontend feature using the existing
GET /api/geschichten?status=DRAFTendpoint. The backend change is three lines: rename a test, rewrite a test, change one ternary. The schema is unchanged.GeschichteService.list()is currently 19 lines (117-135). The ternary replacement removes one line and the Javadoc adds two lines — net +1 line. Still under 20 lines. No extraction needed.Documentation check: No new Flyway migration, no new entity, no new route, no new controller, no new service, no new domain concept, no new infrastructure component. The documentation update table in the developer persona does not trigger any update. No ADR is warranted — this is a bug fix with a clear correct behavior (the null-passthrough was never intended), not a decision with meaningful alternatives.
parent()in+page.server.ts— callingparent()to obtain permissions is the established SvelteKit pattern in this codebase (routes/+page.server.ts:24does exactly this). The geschichten load function currently skipsparent()entirely because it does not need permissions — adding it for the blog-writer draft check is aligned with the pattern.Recommendations
list()under 20 lines after the fix. The current method (lines 117-135) is tight. The ternary replacement and Javadoc addition will not materially change the line count. If you feel the urge to extract a privateeffectiveStatus()method, resist it — the ternary is self-documenting and the method would be too small to name well.$lib/shared/server/settled.tsis the correct and final home. It is server-only (used in+page.server.tsfiles), shared across routes, and has no UI dependency. Do not place it in$lib/shared/utils/(client-side utilities) or$lib/shared/root (too generic).messages/{de,en,es}.jsonand regenerating Paraglide types is well-established. Confirm no diff innpm run generate:apioutput before committing —GeschichteSummary.statuswas already@Schema(requiredMode = REQUIRED)so no type regeneration should be needed.📋 Elicit — Requirements Engineer
Observations
The spec is implementation-ready. All resolved decisions are folded into the body with rationale. File paths are exact and verified against the codebase. Acceptance criteria are testable. This passes the Definition of Ready checklist.
Requirements coverage is complete for the defined scope. The issue covers the security fix (backend), the resilient fetch (frontend server), the UI (svelte page), the component (list row), the i18n (4 keys), the test strategy (9 browser tests, 3 server tests, 2+2 JUnit tests), and the non-goal boundary ("editor/admin seeing all authors' drafts — out of scope").
Privacy acceptance criterion is precisely stated and testable. "The
author.idof every item in the drafts section equals the logged-in user's id." This is verifiable at the backend unit test layer viaeq(writer.getId())in theverifycall. No frontend test is needed for this invariant — the frontend trusts the backend (correct, given the backend fix is the control).One minor i18n note. The spec table includes
geschichten_published_heading(4 keys total), and correctly warns against reusinggeschichte_editor_status_draft(value: "ENTWURF") for the list badge. Confirmed:de.jsonhas"geschichte_editor_status_draft": "ENTWURF"at line 1059. The new badge keygeschichten_draft_badgewith value "Entwurf" (non-caps) is distinct and necessary. Also confirmed:"dashboard_reader_draft_meta"exists (line 537) but its value "Entwurf · zuletzt bearbeitet {relative}" is display metadata for the home page drafts module — not reusable as a bare badge label.One implementation-order clarification worth surfacing. Step 4 (frontend
+page.svelte) says "adddrafts: []to themakeDatafactory as the very first change, before touching the svelte page." Step 5 (GeschichteListRow.svelte) says add'status'to the Pick type. These two component changes are independent — either can go first. The constraint is only thatmakeDataupdate precedes+page.sveltechanges.The non-goal is well-bounded and explicit. "An editor/admin seeing all authors' drafts for review — out of scope." This prevents scope creep during implementation. No action needed.
No loading-state gap. The overview page is SSR — the DRAFT fetch happens server-side during
load(), so there is no client-visible loading state. Graceful degradation (network rejection →drafts: [], section absent) is the only failure mode that reaches the user, and it is covered.Recommendations
makeDatafactory update (addingdrafts: []) and thecallLoadhelper update (addingparent) are both "preparatory green commits" — they must each be standalone commits that keep the test suite fully green. Bundling either with production code changes creates a debugging burden if something goes wrong.Implementation complete — branch
feat/issue-807-drafts-overviewAll 9 commits on top of
feat/issue-750-lesereisen-data-model:576dc6e0a8cff2fdb6beaa6d08c36410settled<T>())0693871ee0a8605ddb6cfd70f3133f97063b7aa7What was fixed
CWE-639 (broken access control):
GeschichteService.list()previously forwardednullstatus directly to the repository for blog writers, returning all stories including other authors' DRAFTs. Nownullalways resolves toPUBLISHED; only an explicitDRAFTrequest (blog writers only) scopes the query to the caller's own drafts.What was added
/geschichten, showing their own drafts unfiltered (separate from the filtered published list)drafts: []on network failure — the overview never 500ssettled<T>()extracted to$lib/shared/server/settled.tsfor reuse across loadersgeschichten_drafts_heading,geschichten_draft_badge,geschichten_drafts_unfiltered_caption,geschichten_published_heading