Build Artikel-Modal with reservation and cancel actions #7
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?
Task 7 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story (US-GAL-003 + US-RES-001 + US-RES-002):
Acceptance criteria
reservierungen.artikel_idhandles race condition — no application-level locking needed; failed INSERT is caught silently (first-come-first-served)aufhebenaction checksWHERE code_id = locals.familyCode.id— users cannot cancel others' reservations (NFR-SEC-003)?id=URL param (server rendersselectedArtikelfrom query param)/galerie?id={id}to re-render modal with updated statusFiles to create/modify
src/lib/components/ArtikelModal.svelte(new)src/routes/artikel/[id]/+page.server.ts(new —reservierenandaufhebenactions)src/routes/galerie/+page.server.ts(modify — addselectedArtikelfrom?id=param)src/routes/galerie/+page.svelte(modify — replace placeholder withArtikelModalcomponent)Depends on: #6 | Size: M | Spec: reservierung-design §4.3, views spec View 03, NFR-SEC-003, NFR-DATA-002
👤 Markus Keller — Application Architect
Observations
artikel/[id]/+page.server.tsfor the two Form Actions (reservieren,aufheben) andgalerie/+page.server.tsfor loadingselectedArtikelvia?id=query param. This aligns exactly with the system design spec section 6.reservierungen.artikel_idis the complete concurrency strategy. No application-level lock, no SELECT-before-INSERT. The issue correctly specifies catching the failed INSERT silently — that pattern is right.?id=URL param and server-side rendering ofselectedArtikelis architecturally sound and consistent with SSR-first design. After reservation actions, redirecting back to/galerie?id={id}re-opens the modal with fresh server state — no client-side cache to invalidate.ArtikelModal.svelteis a new component — not a route. Modal state lives ingalerie/+page.svelteas a$statevariable (the selected article ID). The component receives the article as a prop, not as a page load.aufhebenaction must verifyWHERE code_id = locals.familyCode.idserver-side. The issue specifies this (NFR-SEC-003). Do not add a client-side guard as a substitute — the server check is the real guard.Recommendations
artikel/[id]/+page.server.tspurely as a Form Action file — noload()function needed there. The load for the gallery is ingalerie/+page.server.ts.galerie/+page.server.tsload function should queryselectedArtikelwith a join toartikel_fotosandreservierungenin a single prepared statement whenurl.searchParams.get('id')is present. Avoid a second round-trip.lib/db.ts, defined at module load:stmtInsertReservierung,stmtDeleteReservierung,stmtReservierungByArtikel(for ownership check),stmtArtikelById(for the modal query in galerie load).UNIQUE constraint failedfrom the INSERT error; rethrow everything else. Do not swallow unexpected database errors.redirect(303, '/galerie?id=' + artikelId). This ensures the browser re-GETs the page, the load function re-queries the DB, and the modal opens with current state.Open Decisions
/galerie(removing?id=)? Options: (a) pushState client-side — clean URL but requires JS; (b) do nothing — URL stays as/galerie?id=Xuntil navigation, which means refresh re-opens the modal. Both are acceptable for this app; (b) is simpler. (Raised by: Markus Keller)👤 Felix Brandt — Fullstack Developer
Observations
ArtikelModal.svelteis listed as a new file — this is the correct split. The gallery page is the orchestrator; the modal is a single nameable visual region. Photo navigation state (currentFotoIndex) is local$stateinside the modal component.let selectedArtikel = $state<ArtikelWithStatus | null>(null)ingalerie/+page.svelte, passed as a prop toArtikelModal.$derived:const status = $derived.by(() => { if (!selectedArtikel.reservierung) return 'frei'; if (selectedArtikel.reservierung.code_id === data.familyCode.id) return 'meins'; return 'anderer'; }). Extract this — do not inline the condition in template markup.reservieren,aufheben) must useuse:enhancefor client-side UX while remaining functional without JS. Thesubmittingstate from the enhance callback prevents double-submits.{#each fotos as foto, i (foto.id)}key expression is mandatory — keyed iteration for the photo strip./galerie?id={id}triggers a new load, which is correct. Do not useinvalidate()manually.Recommendations
let currentIndex = $state(0)insideArtikelModal. Prev/next buttons callcurrentIndex = (currentIndex - 1 + fotos.length) % fotos.length. Reset to 0 whenselectedArtikelchanges via$effect(() => { currentIndex = 0; })— this is one of the few valid$effectuses (syncing external state on prop change).{currentIndex + 1} / {fotos.length}should be hidden whenfotos.length === 1(per the spec's "hide counter for single photo" note).form?.errorto German user strings — never expose raw error keys:'already_reserved'→'Dieser Artikel wurde soeben von jemand anderem reserviert.','not_your_reservation'→'Diese Reservierung gehört dir nicht.'<div role="button" tabindex="0" on:click={closeModal} on:keydown={e => e.key === 'Escape' && closeModal()}— keyboard accessible, not just mouse.aufhebenaction withif (!locals.familyCode) redirect(303, '/')before any DB call. Same forreservieren.Open Decisions
touch-action: pan-x). Options: (a) CSS-only touch-action with JS pointer event listeners (lightweight, no dependency); (b) a small touch library. For a family app with no bundle size pressure, (a) is the right call — but the implementation complexity vs. just prev/next buttons is a real tradeoff worth confirming before coding starts. (Raised by: Felix Brandt)👤 Nora "NullX" Steiner — Security Engineer
Observations
aufhebenaction deletes a reservation. Without an ownership check, any authenticated family member with the article ID can cancel any other member's reservation by crafting a POST to/artikel/[id]?/aufhebenwithartikel_idin the form body.WHERE code_id = locals.familyCode.id— this is the mandatory check. It must be implemented as a server-side query, not a client-side condition on a hidden form field.reservierenaction's UNIQUE constraint is the correct atomicity mechanism. No pre-check SELECT is needed or safe. The catch block must specifically detectUNIQUE constraint failed(CWE-362 — Race Condition) and returnfail(409).locals.familyCode(set byhooks.server.ts) — correct pattern. No route should read the rawfamily_codecookie.reserviert_von(the display name of the reserver). This is safe — it is a display name set by an admin, not user-controlled input. No XSS risk in Svelte's template system (auto-escaped).Recommendations
aufhebenownership check as follows — exactly this pattern, no shortcut:DELETE WHERE artikel_id = ? AND code_id = ?without first confirming the row exists — the affectedRows count is unreliable as the sole feedback mechanism.if (!locals.familyCode) redirect(303, '/')as the very first line. No DB call before auth check.form?.errorvalues exposed to the template must never contain the actual code value, reservation ID, or internal DB details.'already_reserved'and'not_your_reservation'are safe opaque keys.aufhebenaction explaining the threat:// NFR-SEC-003: prevents IDOR — any family member could cancel any reservation without this check.Open Decisions
👤 Sara Holt — QA Engineer
Observations
reservierenandaufhebenForm Actions inartikel/[id]/+page.server.ts— pure server logic, testable with:memory:SQLite; (2) theArtikelModal.sveltecomponent — three rendering states testable withvitest-browser-svelte; (3) the E2E journey: tap card → modal opens → reserve → see "Meine Reservierung".aufhebenIDOR check (NFR-SEC-003) must have a security regression test: code B attempting to cancel code A's reservation must returnfail(403).selectedArtikelfrom?id=) means the modal must render correctly on SSR, which Playwright E2E will verify by navigating directly to/galerie?id=X.form?.errordisplay in the modal (race condition message) needs a component test, not just an E2E test — it needs to be fast and deterministic.Recommendations
Integration tests for Form Actions (Vitest +
:memory:SQLite):reservieren returns 409 when article is already reserved— insert a reservation first, then attempt a second INSERT, expectUNIQUE constraint failed.reservieren succeeds and inserts a row— verify the row exists with the correctcode_id.aufheben returns 403 when code_id does not match— reservation owned by code A, attempt cancel with code B.aufheben deletes the row when code_id matches— verify row is gone after successful cancel.reservieren returns 400 for non-integer artikel_id— send'abc'asartikel_id.Component tests (
vitest-browser-svelte):shows "Reservieren" button when status is freishows "✓ Meine Reservierung" badge and "Aufheben" button when status is meinsshows "Reserviert von [Name]" and disabled "Nicht verfügbar" button when status is andererhides photo counter when only one photoshows race condition error message when form.error is "already_reserved"E2E journeys (Playwright — add to existing critical journeys list):
Galerie → Karte antippen → Modal öffnet → Reservieren → Badge "Meine Reservierung" sichtbarGalerie → Modal öffnet über ?id= URL-Parameter (kein JS-Klick)Galerie → Eigene Reservierung aufheben → Status kehrt zu "Frei" zurückEach integration test must use a fresh
:memory:database inbeforeEach. Never share a database instance across test cases.Open Decisions
ArtikelModalcould be tested at the component level (click next → counter increments) or only at E2E level. Component tests are faster and more reliable here. Recommend adding:clicking next button advances photo counter from "1 / 3" to "2 / 3". (Raised by: Sara Holt)👤 Leonie Voss — UI/UX Design Lead
Observations
2026-05-05-erbstuecke-wannsee-views.html.fixed inset-0 bg-black/45 flex items-end z-50for the overlay,bg-surface rounded-t-xl w-full max-h-[88dvh] overflow-y-autofor the sheet. The88dvhmax-height prevents the sheet from covering the full screen on short phones.w-9 h-1 bg-line rounded mx-auto mt-2.5 mb-3.5) is a visual affordance only — the swipe-to-close behavior is the real interaction.aspect-[4/3]— notaspect-square. The counterabsolute bottom-2 right-2.5 bg-black/45 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-fullmust be hidden when there is only one photo (per spec note).inline-block text-[9px] font-extrabold uppercase tracking-wider bg-[#DFF0E6] text-[#2E6645] px-2.5 py-0.5 rounded-full border border-[#A8D5B8] mb-1.5— this is a different green than the nav (--color-primary). Do not mix them up.font-size: 12px; padding: 7px 14px; border-radius: 8px; text-align: center— larger and more prominent than the card-level badge (which uses the pill form). These are intentionally different sizes.notizfield must only render whenartikel.notizis present —{#if artikel.notiz}. Rendering an empty italic<p>wastes space.disabledattribute set — not just visual styling.cursor-not-allowedwithoutdisabledis a styling hack, not true inaccessibility prevention.Recommendations
<dialog>orrole="dialog" aria-modal="true"on the sheet element. The overlay div needsaria-label="Artikel-Details"or equivalent. Without this, screen readers do not know the modal exists.aria-live="polite"around the reservation status section. When a Form Action completes and the status changes (e.g., from Frei to Meine), the screen reader announces the change without disrupting focus.font-serif text-[18px] font-bold text-ink leading-snug mb-1.5(Lora). Do not use Inter for the title — it must be Lora per the type system.aria-label="Vorheriges Foto"/aria-label="Nächstes Foto") withbg-black/45 text-whiteoverlaid at the left/right edges of the photo — min 44×44px touch target. Never render them outside the photo area as text links.on:click={closeModal}but the sheet itself needson:click|stopPropagationto prevent clicks inside the modal from closing it.text-[9px]is the only element below 10px. This is acceptable per spec for this specific element — but do not introduce any other sub-10px text in the modal.#2E6645(category badge text) on#DFF0E6(badge background) — check with WebAIM. If it fails 4.5:1, darken the text to#1E4D2E.Open Decisions
👤 Elicit — Requirements Engineer
Observations
?id=URL param pattern. However: the acceptance criteria say "Modal works without JavaScript via?id=URL param (server rendersselectedArtikel)" — this implies that on a no-JS load, the modal appears inline in the page (not as a floating sheet), since the overlay and sheet positioning require JS to trigger the$statechange. The server-rendered version should display the article details in a plain section or card, not attempt to render the bottom-sheet. This distinction is not explicit in the issue.POST → redirect(303) → GET /galerie?id={id}is the correct PRG (Post-Redirect-Get) pattern. It prevents form resubmission on browser back/refresh. This is good — it is implied but worth naming explicitly.Recommendations
?id=is present and JS is not available, the article details are rendered inline on the gallery page (not as a bottom sheet overlay)."fotos.length > 1. The spec's "hide counter at 1 photo" note implies (b) for the counter — apply the same rule to the buttons.form?.errorfor the race condition (already_reserved) should display inside the modal, not as a top-level page alert. The acceptance criteria do not specify where the error appears — recommend making this explicit: "Error appears in the modal's reservation status section, replacing the action button."Open Decisions
/galerie?id=5without JS, the server rendersselectedArtikel. But the gallery page's bottom-sheet modal is a JS-driven component. Should the server-renderedselectedArtikelappear as: (a) a full-page article detail at the top of the gallery; (b) a static HTML version of the modal sheet; (c) nothing special — the?id=param only serves as a re-open signal after POST redirect, and no-JS users just see the gallery. Option (c) is the simplest and defensible since the app requires cookies (which implies a browser capable of JS in practice). (Raised by: Elicit)🗳️ Decision Queue — Action Required
5 decisions need your input before implementation starts.
Modal URL & Navigation
Modal close URL — After closing the modal via backdrop click, should the URL update from
/galerie?id=Xback to/galerie? Option (a):history.pushStateclient-side — clean URL, requires JS. Option (b): do nothing — URL retains?id=X, refresh re-opens the modal. Option (b) is simpler and acceptable; option (a) is cleaner UX. (Raised by: Markus Keller)No-JS modal rendering mode — When
/galerie?id=5is loaded without JS,selectedArtikelis server-rendered. Should it appear as: (a) article details at the top of the gallery; (b) a static HTML sheet; (c) nothing —?id=is only a post-redirect signal, no-JS users just see the gallery. Option (c) is simplest and defensible (cookie-based auth implies a JS-capable browser in practice). (Raised by: Elicit)Photo Navigation
Swipe gesture for photos — The spec requires
touch-action: pan-xswipe for photo navigation. Options: (a) implement pointer event drag (lightweight, no library, moderate complexity); (b) skip swipe — only use ‹ / › prev/next buttons. The acceptance criteria explicitly lists ‹ / › buttons; swipe is an enhancement. Confirm whether swipe is a hard requirement or a nice-to-have before coding starts. (Raised by: Felix Brandt)Swipe-to-close the bottom sheet — The spec describes "Swipe-Down schließt Modal" but the acceptance criteria only lists "Backdrop click closes modal." Options: (a) swipe handle is decorative — close only via backdrop click (simpler); (b) implement drag-to-dismiss with a 100px downward threshold. Confirm which is required. (Raised by: Leonie Voss)
Prev/Next Buttons Visibility
fotos.length > 1(same rule as the counter). Option (b) is consistent with the spec's "hide counter at 1 photo" note and avoids rendering non-functional buttons. (Raised by: Elicit)