Build Admin Artikel form — camera-first photo upload and edit #10
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 10 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story (US-ADM-001 + US-ADM-002):
Acceptance criteria
Add article (
/admin/inventar/neu):<input type="file" capture="environment">)KATEGORIEN)INSERT INTO artikel, thenprocessAndSavePhoto()for each valid file,INSERT INTO artikel_fotoswith sequential positionsEdit article (
/admin/inventar/[id]/bearbeiten):?/fotoLoeschenaction: deletes fromartikel_fotosand from disk?/speichernaction updates artikel fields and appends new photos[id]Files to create
src/routes/admin/inventar/neu/+page.sveltesrc/routes/admin/inventar/neu/+page.server.tssrc/routes/admin/inventar/[id]/bearbeiten/+page.sveltesrc/routes/admin/inventar/[id]/bearbeiten/+page.server.tsDepends on: #9 | Size: M | Spec: reservierung-design §5.2, views spec View 05 + 06
👤 Markus Keller — Application Architect
Observations
/neu,/[id]/bearbeiten) as separate routes with separate+page.server.tsfiles. This is the right call — the load shape differs (neu has no prefill, bearbeiten must load existing artikel + fotos).ArtikelForm.sveltewith optional prefill props keeps logic DRY. Confirm this component receives exactly{ artikel?: Artikel, fotos?: Foto[] }— no entiredataobject.?/fotoLoeschenaction deleting from disk + DB inbearbeiten/+page.server.tsis correct placement. Do not hoist this to a separate API route.position = 0as the gallery thumbnail. When appending to an existing article, the correct position value isMAX(position) + 1— notCOUNT(fotos), because deleting a middle photo leaves a gap. UseSELECT MAX(position) FROM artikel_fotos WHERE artikel_id = ?before insert.INSERT INTO artikelmust happen beforeprocessAndSavePhoto()because the upload path isuploads/{artikel_id}/{uuid}.webp— you need the article ID from the INSERT to build the directory. The issue's stated order is correct.?/speichernaction for the edit form updates drei fields (kategorie, titel, notiz) via a singleUPDATE artikel SET ... WHERE id = ?. Do not split into multiple statements — one statement, one round-trip.error(404, 'Nicht gefunden')— do not redirect. A redirect to/admin/inventarfor unknown IDs hides the bug during development.artikel_fotostable hasUNIQUE(artikel_id, position). Position reuse after deletion will silently fail with a constraint error. The append strategy must use MAX not COUNT.Recommendations
{ artikel: Artikel, fotos: Foto[] }. Keep the server types insrc/lib/types.ts.stmtMaxFotoPositionprepared statement inlib/db.ts:SELECT COALESCE(MAX(position), -1) FROM artikel_fotos WHERE artikel_id = ?. UseCOALESCE(..., -1)so the first photo gets position 0.?/fotoLoeschenaction must: (1) verify the foto belongs to an article the admin owns — i.e., queryartikel_fotosfirst, confirmartikel_idmatches the route param[id], then delete. This prevents an admin from calling?/fotoLoeschenon another article's photo via crafted form data.db.transaction(() => { for (const file of files) { stmtInsertFoto.run(...); } })(). If one insert fails the whole batch rolls back cleanly.Open Decisions (omit if none)
position-array. The issue's edit acceptance criteria do not mention reorder. Does bearbeiten support drag reorder in this ticket, or is that deferred? Cost of deferral: the first photo in the strip stays the thumbnail, which admins cannot change after initial upload without deleting and re-uploading.👤 Felix Brandt — Fullstack Developer
Observations
processAndSavePhoto()for each valid file in the?/speichernaction. "Valid file" needs a concrete definition at the action boundary: size check (file.size > 0) and MIME check (image/jpeg | image/png | image/webp) before calling sharp. Silently skipping zero-byte files from a multi-select is the right UX.createObjectURL()-Preview im Strip, Upload erst beim Speichern via Multipart Form Action". This means the Svelte component holds new photos asFileobjects in$stateand only submits them in the multipart form on save. The hidden<input type="file" accept="image/*" capture="environment" multiple>should havebind:files— but Svelte 5 uses$bindablefor this. Confirm the file input approach: eitherbind:fileson the hidden input or a JavaScript-controlledFileListvia$state<File[]>.?/fotoLoeschenaction is a Form Action in the bearbeiten page. It must return after deletion rather than redirect — SvelteKit will invalidate and reload the page data. Usereturn {}(notredirect) so the photo strip updates reactively viause:enhance./admin/inventar— nothistory.back(). On mobile,history.back()may exit the admin section if the user arrived from a deep link.?/fotoLoeschen": each ✕ needs its own<form method="POST" action="?/fotoLoeschen" use:enhance>with a hiddenfoto_idinput. Do not use a single shared form with JavaScript to swap the hidden value — it breaks without JS.{#each fotos as foto (foto.id)}— keyed iteration is mandatory. Reordering without a key corrupts local drag state.bind:valueon kategorie, titel, notiz and then send them only via the multipart form — SvelteKit'srequest.formData()on the server receives the multipart fields. The native<select>and<input>elements just need correctnameattributes; no JS binding needed for the submit path.Recommendations
?/speichern(insert path) first: given a valid multipart POST with one image file, the action inserts oneartikelrow and oneartikel_fotosrow, and returns no error. Use a:memory:database and a mock Buffer for the photo (skip the actual sharp call in unit tests via a thin wrapper).?/fotoLoeschen: given a foto_id that belongs to this article, the row is deleted and the file unlink is called. Given a foto_id from a different article, the action returnsfail(403).<input>but without thecaptureattribute on desktop. Consider a single<input>withoutcaptureat all —capture="environment"on desktop browsers opens the camera app on some systems, which is unexpected. The spec sayscapture="environment"on the input but the camera button triggers it via.click(). On desktop, the file picker dialog opens as fallback naturally. Keep one input; use JS to distinguish if needed.stmtInsertArtikelsucceeds but a subsequentstmtInsertFoto.run()throws, unlink the already-written WebP file. Wrap in try/catch withawait unlink(dest).catch(() => {}).Open Decisions (omit if none)
capture="environment"on the single file input — If the hidden input always hascapture="environment", desktop browsers may open camera apps instead of file pickers. Should the "Datei auswählen" link trigger a separate<input>withoutcapture, or should it remove thecaptureattribute dynamically via JS before triggering.click()? Both work; the question is implementation clarity vs. HTML purity. (Raised by: Felix Brandt)👤 Nora "NullX" Steiner — Security Engineer
Observations
The attack surface for this feature is photo upload + file deletion. Both vectors are present and need explicit guards.
1. File type validation — client-supplied MIME is untrustworthy
file.typein a multipart upload comes from the browser's Content-Type header for that part, which an attacker can forge. The issue says "each valid file" without defining validity. The implementation must inspect file bytes:2. Size check before reading into memory
file.size > 20 * 1024 * 1024must be checked beforefile.arrayBuffer()— not after. Reading a 500 MB file into a Buffer before rejecting it is a memory exhaustion vector.3. IDOR on
?/fotoLoeschenThe action receives a
foto_id. Without an ownership check, an admin could craft a POST to delete a photo belonging to any article — not just the one at[id]. The guard:This is CWE-639 (Authorization Bypass Through User-Controlled Key). It's real even in a single-admin context because the admin session cookie is not user-specific — any of the three admins could call it.
4. Path traversal in upload write path
The
processAndSavePhoto()function builds:path.join(UPLOAD_DIR, String(artikelId), filename).artikelIdcomes from the database (safe — it's an integer from the INSERT RETURNING).filenameis${randomUUID()}.webp(safe — server-generated). The path is clean as long as these invariants hold. Confirm the implementation never uses any user-supplied string in the path.5. Admin auth guard before any action
Both
+page.server.tsfiles are under/admin/inventar/. The+layout.server.tsauth guard covers the load functions. However, Form Actions in SvelteKit bypass the layout load — the+layout.server.tsguard does NOT run for Form Actions by default ifhooks.server.tssetslocals.adminand actions check it. Confirm each action begins with:Recommendations
file-typeas a dependency. Without byte-level validation, a crafted file with a.jpgMIME header but executable content passes through to sharp. Sharp will reject most non-images, but the defense layer should not rely on sharp's error handling for security.if (file.size > 20_000_000) return fail(400, ...)) must be the first check, before anyarrayBuffer()call.if (!locals.admin) redirect(303, '/admin/login')as the first line of every Form Action, even under the auth-guarded layout. Defense in depth.fotoLoeschenis a required security control, not optional. Add a failing test for it before implementing the action.👤 Sara Holt — QA Engineer
Observations
This feature has a notably complex test surface: two routes, two form actions in bearbeiten, one action in neu, file I/O, database writes, and a UI state machine (no-photo state → strip state). The issue has no test plan.
Required test layers:
Unit (
lib/photos.ts):processAndSavePhoto()with a valid JPEG buffer produces a WebP file at the expected pathprocessAndSavePhoto()with an oversized buffer returns an error (or throws) before writingstmtInsertFotothrows)Integration (
:memory:SQLite, Vitest):?/speichern(neu): insertsartikelrow + oneartikel_fotosrow per valid file; returns no error on success?/speichern(neu): with no files attached, insertsartikelrow only (zero photos is valid)?/speichern(bearbeiten): updatestitel,kategorie,notizon existing artikel; appends new fotos?/fotoLoeschen: deletes the correctartikel_fotosrow; leaves other rows for the same artikel intact?/fotoLoeschen: given afoto_idfrom a different article, returnsfail(403)(ownership check)bearbeitenload: returns 404 for an unknown article IDComponent (vitest-browser-svelte):
?/fotoLoeschenE2E (Playwright — add to existing admin journey):
/admin/inventarlist with thumbnailRecommendations
UNIQUE(artikel_id, position)constraint violation here.page.waitForTimeout()after the?/fotoLoeschenform submission. Useexpect(page.getByAltText(...)).not.toBeVisible()or assert on the updated strip count./admin/inventar/neupage specifically — the camera button and file input have non-obvious ARIA requirements.Open Decisions (omit if none)
$derivedfor enable/disable state and a separate test. (Raised by: Sara Holt)👤 Leonie Voss — UX Design Lead
Observations
The spec (View 05b) defines two explicit states for the article form — "Zustand A — Kein Foto" and "Zustand B — Mit Fotos" — with distinct interaction patterns. Several of the issue's acceptance criteria leave UX details underspecified.
Camera button spec:
font-serif text-sm font-bold bg-primary text-white w-full min-h-[60px] rounded-xl flex items-center justify-center gap-2.5min-h-[60px]— not44px. The camera affordance is the primary action on mobile admin. 60px is intentional.underline text-primary— not a button. It must not have button styling.State A → State B transition:
opacity-40 pointer-events-nonein the spec mockup). This is important — it guides the admin to take a photo before filling metadata.bg-accent text-white text-[7px] font-extrabold px-1.5 py-0.5 rounded-sm). The spec says text "Thumbnail" — not "1." — check the implementation ref table: it saysText: „Thumbnail" — nur auf Index 0.Thumbnail strip:
w-[68px] h-[68px] rounded-lg object-cover flex-shrink-0 relative. The ✕ button isabsolute top-1 right-1 bg-black/55 text-white w-[17px] h-[17px] rounded-full text-[9px] font-extrabold(from the spec mockup.rmCSS). It's only 17×17px — technically below 44px touch target. This is an intentional spec exception for the compact strip; document it.📷icon + "Mehr" label. It must also trigger the hidden<input>via.click().Sticky save bar:
bg-surface border-t border-line p-2.5 flex gap-2 shadow-[0_-2px_10px_rgba(0,0,0,.06)] flex-shrink-0bg-primarybutton. Both full-width on mobile (flex-1each within theflexcontainer).opacity-40 cursor-not-allowed) in State A.Typography and tokens:
font-sans font-semibold text-ink— notfont-serif. Headings in admin UI use Inter, not Lora. Reserve Lora for article titles and the app logo.text-status-taken(red) per the required-field convention.Recommendations
$derivedfromfotos.length > 0(wherefotosis the$state<File[]>of new files + existing fotos count). Applyclass:opacity-40 class:pointer-events-noneto the form section based on this derived value.aria-label="Foto entfernen"— the icon alone (✕ character) is not accessible. Screen readers will announce "times" or "multiply" without the label.aria-label="Foto aufnehmen"in addition to its visible text, as the 📷 emoji has inconsistent screen reader pronunciation across platforms.opacity-40— they may not. Consider usingdisabledattribute on native inputs instead of opacity, which preserves semantic state for assistive technology.👤 Tobias Wendt — DevOps & Platform Engineer
Observations
This feature introduces the first write path to the
uploadsnamed volume. Two infrastructure concerns are activated for the first time by this issue.1. uploads volume must be declared and mounted
The
docker-compose.ymlmust have:If
UPLOAD_DIRis not set, theprocessAndSavePhoto()call will write to a path that doesn't exist (or an unexpected location inside the container filesystem). The app must fail to start ifUPLOAD_DIRis missing, not fail silently at upload time.2. UPLOAD_DIR startup validation
Add to
src/lib/db.ts(or app startup hook):Without this, a misconfigured deploy silently writes photos to the container's ephemeral filesystem and loses them on the next
docker compose up --build.3.
.env.exampleupdateAdd
UPLOAD_DIR=/app/uploadsto.env.examplewith a comment explaining it maps to the named volume mount path.4. Backup coverage
The backup script already targets the
uploadsvolume (erbstuecke_uploads). No change needed — the first photo upload confirms the backup path is correct. Verify after first test deploy with:5. Multi-stage Dockerfile — no dev dependencies in production image
sharpis a production dependency — it compiles native binaries. The multi-stage Dockerfile's runner stage installs only production deps (npm ci --omit=dev). Confirmsharpis independencies, notdevDependencies, inpackage.json. A misplaced dependency will cause "Cannot find module 'sharp'" at runtime.Recommendations
UPLOAD_DIRbefore the feature ships. One missing env var silently breaks every photo upload.tar -tzf /opt/backups/erbstuecke/erbstuecke-latest.tar.gz | grep uploadsshould show entries.sharpis independencies(notdevDependencies) inpackage.json— this is the single most likely runtime breakage from the multi-stage Dockerfile pattern.UPLOAD_DIRpath inside the container must end without a trailing slash.path.join('/app/uploads', String(id), 'file.webp')is correct;path.join('/app/uploads/', ...)also works but be consistent in.env.example.👤 Elicit — Requirements Engineer
Observations
Reviewing issue #10 against the approved specs (2026-05-04-erbstuecke-reservierung-design.md §5.2, views spec View 05b, system design §5) and the user stories US-ADM-001 + US-ADM-002.
Completeness check — what is well-specified:
capture="environment"is explicit?/fotoLoeschenaction semantics (delete from DB + disk) are clear[id]is statedGaps and ambiguities I am flagging as requirements risks:
GAP-01 — Maximum photos per article (OQ-004 from design spec, still open)
The issue does not state a maximum number of photos per article. OQ-004 in the design spec recommends 10 but this was never closed. Without a limit, the thumbnail strip has no stop condition. The server action has no upper bound to enforce. This is a testable requirement that is missing.
GAP-02 — Zero-photo article: valid or invalid?
The issue says "for each valid file" — implying zero files is a degenerate case. The spec says Kategorie is required but Fotos are not mentioned as required. The inventory list view (View 05) shows a thumbnail per row — what renders when there is no photo? A placeholder? An empty grey box? This affects the gallery thumbnail on the family side too (GalerieKarte shows a 72px photo). Unspecified.
GAP-03 — Error UX on save failure
The issue states what the actions do on success. It does not state what the user sees if the server action returns
fail(). Specifically: if the photo upload fails (e.g., sharp error, disk full), what message does the admin see? The sticky save bar must show a German error message. No error strings are defined in this issue.GAP-04 — "Abbrechen" navigation target
The issue says "Abbrechen" button is in the save bar. It does not state where it navigates. For
/neu, this is clearly/admin/inventar. For/[id]/bearbeiten, it could be/admin/inventaror/admin/inventar/[id](if a detail page exists — it does not in the current spec). Confirm: both Abbrechen buttons navigate to/admin/inventar.GAP-05 — Confirmation before discard
Neither the spec nor the issue addresses what happens if an admin taps "Abbrechen" after having taken photos but before saving. The photos in
$stateare lost. For a mobile camera flow, this is a significant frustration. The spec does not require an unsaved-changes guard — confirming this is intentional (acceptable for an MVP).Recommendations
/admin/inventarin both /neu and /bearbeiten."Open Decisions (omit if none)
🗳️ Decision Queue — Action Required
5 decisions need your input before implementation starts.
Photo Upload Behaviour
Maximum photos per article (OQ-004) — The design spec left this open with a recommendation of 10. Without a closed decision, the thumbnail strip has no stop condition and the server action has no upper bound. Options: (A) close at 10 (recommended in spec), (B) different number, (C) unlimited (no UI stop, potentially large article directories). (Raised by: Elicit)
Zero-photo articles: valid or invalid? — The spec mockup (Zustand A) shows "Speichern" greyed before any photo is taken, implying at least one photo is required. The issue acceptance criteria do not state this explicitly. If photos are required, "Speichern" must be disabled in State A (needs a
$derived+ test). If optional, the gallery/inventory need a placeholder image for photo-less articles. This is an implementation assumption that should be a stated requirement. (Raised by: Elicit, Sara Holt)capture="environment"on the file input — If the single hidden<input>always carriescapture="environment", clicking "Datei auswählen" on desktop may open a camera app instead of a file picker on some browsers. Options: (A) use two separate inputs (one withcapture, one without), (B) dynamically remove thecaptureattribute via JS before triggering the "Datei auswählen" click, (C) accept that desktop behaviour may vary (most modern desktop browsers fall back to file picker anyway). (Raised by: Felix Brandt)Edit Form Scope
position-array. The issue's bearbeiten acceptance criteria do not mention reorder. If deferred: admins cannot change which photo is the gallery thumbnail without deleting and re-uploading. If included: adds drag-and-drop JS complexity to this ticket. Decision needed to scope the implementation correctly. (Raised by: Markus Keller)State A UX Clarity