Build Admin Inventar — article list and delete #9
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 9 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story (US-ADM-003):
As an admin, I see all articles in a table with thumbnails, category, and reservation status, and can delete unreserved articles.
Acceptance criteria
text-status-taken, "Frei" intext-status-free/admin/inventar/{id}/bearbeitenwindow.confirm()confirmation dialogdeleteArtikelPhotos(id)removes files from disk, DB row deleted (CASCADE removesartikel_fotos)/admin/inventar/neuFiles to create
src/routes/admin/inventar/+page.sveltesrc/routes/admin/inventar/+page.server.tsDepends on: #8, #4 | Size: S | Spec: reservierung-design §5.2 (Listenansicht)
👤 Markus Keller — Application Architect
Observations
src/routes/admin/inventar/+page.svelteand+page.server.ts) sit exactly where the system design specifies. No deviation.deleteArtikelPhotos(id)— this function belongs inlib/photos.tsand must remove the entireuploads/{artikel_id}/directory. The issue correctly states files must be removed from disk before (or in the same transactional cleanup block as) the DB row deletion. The DB CASCADE handlesartikel_fotosrows atomically; the filesystem cleanup is the non-atomic side that needs explicit handling.reservierungenfor theartikel_idbefore deleting, and return the specified error if a row exists. Never trust the UI-only visibility rule alone.lib/db.ts, not inside the route handler. The load function for this page needs: all articles with their reservation status (LEFT JOINreservierungen+codes), plus a delete action that checks the constraint.window.confirm()is synchronous and blocks the main thread — acceptable for this project scope. The Form Action must still enforce the server-side check regardless of whether the client confirms.lib/db.tsalready exports thestmtArtikelAllprepared statement with the LEFT JOIN. If #8 and #4 are done, this statement should already exist and can be reused here directly.Recommendations
lib/db.tsfor the admin article list. It should be identical to or extend the family gallery query, adding nothing extra — the same LEFT JOIN shape works for both contexts.reservierungen WHERE artikel_id = ?before callingdeleteArtikelPhotos. If a row exists, returnfail(409, { error: 'artikel_reserviert' })with the German message from the acceptance criteria. Do not rely on a SQLite error for this — a FOREIGN KEY constraint won't prevent deletion here since the reservation references the article, not the other way around.deleteArtikelPhotos(id)removes files from disk, (2)db.prepare('DELETE FROM artikel WHERE id = ?').run(id)— CASCADE removesartikel_fotosrows. If the file deletion fails, still proceed with DB deletion (best-effort filesystem cleanup, same pattern as photo upload rollback)..sveltefile should be an{#if artikel.length === 0}block, not hidden via CSS.lib/db.tsand import it.Open Decisions (omit if none)
deleteArtikelPhotos(id)partially fails (some files deleted, then an I/O error), the DB row is then deleted successfully, leaving the article directory in an inconsistent state. Accept this as tolerable for project scope, or add a best-effort directory cleanup withrm -rf? The system design implies best-effort is acceptable, but this should be confirmed.👤 Felix Brandt — Fullstack Developer
Observations
+page.svelteis an orchestrator component: it composes the table rows and holds no business logic.+page.server.tsowns the load query and the delete action. This is clean SvelteKit form.$derivedfor computed delete-visibility per row. In the template, the "Löschen"-button visibility (artikel.reservierung === null) is a derived condition per row — but since this is inside{#each}with no reactive row state, a simple inline expression is fine. No need for a module-level$derivedhere.{#each artikel as a (a.id)}— key bya.idis required. No exception.window.confirm()in Svelte 5: Use an event handler withonclickrune syntax on the delete button, noton:click. The confirm must gate the form submission, not a fetch call.<img>src for the 30×30px thumbnail comes fromartikel_fotosposition 0. The load query needs to LEFT JOINartikel_fotosonposition = 0as well. This adds one more join to the query — confirm the plan for the SQL shape.{artikel.titel ?? '—'}in the template. Simple nullish coalescing, no need for a helper.onMount+ fetch anywhere. All data via load function. No API endpoints needed.Recommendations
it('returns 409 when deleting a reserved article')— integration test with:memory:DBit('deletes article and photos when unreserved')— verify DB row gone +deleteArtikelPhotoscalledit('returns 400 for invalid artikel_id')— boundary guardlib/db.ts:form?.errorkey in the template to the German message from the acceptance criteria:"Artikel ist reserviert. Reservierung zuerst aufheben."— do not render the raw key.<form>must usemethod="POST" action="?/deleteArtikel" use:enhance. Theuse:enhancecallback should show a loading state (disable the button) to prevent double-submit.<a href="/admin/inventar/{a.id}/bearbeiten">— not a button, not a form. An anchor is semantically correct for navigation.Open Decisions (omit if none)
artikel_fotos WHERE position = 0is clean but adds complexity to the main list query. Alternative: load article list, then load all position-0 photos in a second query and merge in the load function. The single-query approach is preferable for this scale, but confirm with Markus whether the prepared statement inlib/db.tsfor the family gallery is to be reused (with the thumbnail join already present) or if a separate admin statement is warranted.👤 Nora "NullX" Steiner — Application Security Engineer
Observations
admin_sessioncookie can POST?/deleteArtikelwith anyartikel_idand delete any article — including reserved ones. This is not IDOR in the classical sense (different user accessing another user's data), but it is a bypass of a business rule that could cause data integrity harm.if (!locals.admin) redirect(303, '/admin/login'). The+layout.server.tsunder/admin/provides the layout-level guard, but Form Actions in+page.server.tsfiles must still checklocals.adminexplicitly — the layout guard does not automatically protect actions.window.confirm()is client-side only. An attacker (or a script) can POST the delete action without triggering the confirm dialog. The server-side reservation check is the real guard. The confirm dialog is a UX convenience, not a security control.artikel_id: The Form Action must validate that the submittedartikel_idis a positive integer before any DB query. A non-integer or negative value passed tostmtDeleteArtikel.run()could cause unexpected behavior, though with prepared statements the risk is low.deleteArtikelPhotos(id)function inlib/photos.tsbuilds the path aspath.join(UPLOAD_DIR, String(id)). Sinceidcomes from the database (not from user input at deletion time), there is no path traversal risk here — the article ID is a trusted integer from the server-side load. Confirm this pattern is maintained.redirect(303, '/admin/inventar')or use SvelteKit'sinvalidateAll()viause:enhance. A bare return without redirect leaves the form action URL in the browser history, which is not a security issue here but is poor UX and could lead to double-submit attempts.Recommendations
fail(409).deleteArtikelPhotosinlib/photos.tsusespath.resolve()to verify the constructed directory path starts withpath.resolve(UPLOAD_DIR)before callingrm -rf. Even thoughidis an integer, a defense-in-depth bounds check costs nothing and prevents surprises if the call signature ever changes.artikel_idvalue in any error message returned to the client.Open Decisions
👤 Sara Holt — QA Engineer & Test Strategist
Observations
:memory:SQLite database, not mocks.deleteArtikelPhotos(id)is a side-effectful function — it performs filesystem operations. Unit-testing it requires either a real temp directory or a mock offs.rm. A real temp directory is preferred (no mocks of Node builtins). Vitest'stmputilities oros.tmpdir()+mkdtempwork here.vitest-browser-sveltecomponent test.artikel = []is passed.window.confirm()call must be tested in Playwright, not Vitest, because it requires a real browser dialog. Playwright'spage.on('dialog', dialog => dialog.accept())handles this.Recommendations
src/routes/admin/inventar/+page.server.test.ts:src/routes/admin/inventar/InventarTabelle.test.ts(or inline in the page test):hrefattribute is sufficient here.Open Decisions (omit if none)
deleteArtikelPhotoswith partial failures: If files are partially deleted before an I/O error, the test would need to simulate filesystem failures. This is complex and of low value for this project scope. Recommend skipping partial-failure simulation and testing only the happy path and the "directory does not exist" (no photos) path. Confirm with Markus. (Raised by: Sara)👤 Leonie Voss — UI/UX Design Lead & Accessibility Advocate
Observations
2026-05-05-erbstuecke-wannsee-views.htmlis the authoritative spec for this view. The admin table layout is fully specified: sidebar (176px,bg-admin), content area (bg-canvas p-3.5), article table with columns: thumbnail (30×30px,border-radius: 4px), Artikel (title), Kategorie, Status, and action buttons (Bearbeiten + Löschen).text-status-free(#4A7C5C) with✓prefix. "Reserviert" must usetext-status-taken(#9B6060) with●prefix and the reserver's display name. The acceptance criteria correctly referencetext-status-takenandtext-status-free— confirm these are Tailwind tokens already defined intailwind.config.js..thumb-smwithborder-radius: 4px. In Tailwind:w-[30px] h-[30px] rounded object-cover. When no photo exists, render a<div class="w-[30px] h-[30px] rounded bg-line">placeholder — do not render a broken<img>with nosrc.act-editstyle: muted bg, border) and Löschen (act-delstyle:#FBF0F0bg,status-takenborder + text). Löschen must be visually disabled (.opacity-35 cursor-not-allowed) for reserved articles — not absent from the DOM. The spec mockup shows it dimmed, not removed.bg-accent), white text, positioned top-right of the content area aligned with the page title. Not a link — a styled button that navigates via JS or a standard<a>styled as button.+layout.sveltewhich should already exist from #4/#8. The Inventar link must haveborder-l-[3px] border-accentactive state on this route.font-serif text-sm font-bold). Table headers aretext-[8.5px] font-bold uppercase text-ink-muted.opacity-35 pointer-events-nonerather than{#if !reserviert}.Recommendations
opacity-35 pointer-events-noneon the Löschen button for reserved articles rather than conditionally rendering it. This matches the spec mockup and gives admins visual feedback that the action exists but is blocked, rather than hiding it entirely. The server still enforces the guard.<img src="/uploads/{thumbnail}" alt="" class="w-[30px] h-[30px] rounded object-cover flex-shrink-0">withalt=""(decorative). Whenthumbnailis null, render<div class="w-[30px] h-[30px] rounded bg-line flex-shrink-0">instead.padding: 3px 7pxfor these small table buttons. On desktop this is fine; confirm the admin interface is desktop-primary (the spec shows this as a sidebar layout, implying desktop use). Touch targets are less critical here than in the family gallery.text-ink-muted italicstyling, not just a dash, to visually distinguish it from a real title. The spec shows "—" but adding muted italic improves clarity.<table>with proper<thead>/<tbody>/<th scope="col">elements for the article table. The spec uses.atable— in the SvelteKit implementation, use semantic HTML with Tailwind utility classes, not CSS class names from the spec's embedded stylesheet.<tr><td colspan="5">row inside the<tbody>, centered, with muted italic text — not an element outside the table.Open Decisions (omit if none)
{#if !reserviert}) but the spec mockup shows it dimmed with reduced opacity (implying always rendered, visually disabled). These conflict. The dimmed approach is better UX (communicates that deletion exists but is blocked) and the server guards the actual operation either way. Recommend the dimmed approach — but this needs explicit confirmation since the acceptance criteria contradict it. (Raised by: Leonie)👤 Tobias Wendt — DevOps & Platform Engineer
Observations
deleteArtikelPhotos(id)writes to theuploadsnamed volume. The deletion path isUPLOAD_DIR/{id}/— this is the same volume already mounted indocker-compose.yml. Confirm thatUPLOAD_DIRis set to/app/uploadsin the container environment and thatlib/photos.tsreads this value fromprocess.env.UPLOAD_DIR, not a hardcoded string.DATABASE_PATH,UPLOAD_DIR, admin session cookie). No.env.examplechanges needed./healthendpoint is not changed by this feature.uploadsvolume is permanent. OncedeleteArtikelPhotos(id)runs and the article is deleted, recovery requires restoring from the nightly backup. Confirm the backup script (if already in place) covers theerbstuecke_uploadsnamed volume. If it does not yet exist, this feature introduces a new data-loss scenario that the backup must cover.Recommendations
lib/photos.tsreadsUPLOAD_DIRfromprocess.env.UPLOAD_DIR(with a startup-time fail-fast if absent), not a hardcoded path. This is already implied by the system design but must be confirmed before this issue is implemented.deleteArtikelPhotosfunction should usefs.rm(dir, { recursive: true, force: true })— theforce: trueoption prevents an error if the directory does not exist (articles with no photos uploaded). This is the correct behavior for a best-effort cleanup.docker compose exec app ls /app/uploadsand verify directories are created and removed correctly. Add this to the post-deploy smoke test if the deploy happens before other issues add articles.Dockerfile,docker-compose.yml, orCaddyfileare needed for this issue. If any are proposed during implementation, flag as scope creep.Open Decisions (omit if none)
uploadsvolume, but if an admin deletes an article between backup runs, the photos are permanently gone. For a family estate app where photos are irreplaceable, consider whether a soft-delete pattern (moving files to an_trash/{id}/directory rather thanrm -rf) is warranted, or whether the nightly backup window is acceptable. This is an architecture-level decision. (Raised by: Tobias)👤 Elicit — Requirements Engineer
Observations
reservierung-design §5.2) is given. This issue passes the Definition of Ready checklist on almost every point.deleteArtikelPhotos(id)is referenced without a definition. The AC mentions this function call by name as a specification requirement. It should be defined inlib/photos.tsas per the system design. Confirm this is already implemented via a prior issue or that it is in scope for this issue. If it is new work, this issue is larger than "S"./admin/inventar/neu— this route is a dependency. If #8 (which presumably creates this route) is not merged first, this link will be a 404 during development. The "Depends on: #8" note covers this, but it must be enforced in the merge order.Recommendations
opacity-35 pointer-events-none) for reserved articles.""Noch keine Artikel vorhanden."with a link or note pointing to the add-article route.deleteArtikelPhotos(id)is already implemented by a prior issue. If not, add it explicitly to the AC with a note that it must be implemented as part of this issue (inlib/photos.ts).deleteArtikelPhotos(id)removes files from disk, DB row deleted (CASCADE removesartikel_fotos)" should specify the order: file cleanup first, then DB delete — or acknowledge that this order is best-effort and the DB delete is the authoritative operation.Open Decisions
opacity-35 pointer-events-none— better UX, shows admins the action is blocked; (B) conditionally render with{#if !reserviert}— simpler, matches AC text literally. (Raised by: Elicit)deleteArtikelPhotosin scope for this issue? If it was not implemented in #8 or another prior issue, this issue must explicitly include implementinglib/photos.ts::deleteArtikelPhotos. Without this, the delete action cannot meet its AC. (Raised by: Elicit)🗳️ Decision Queue — Action Required
6 decisions need your input before implementation starts.
Delete Button UX: Hidden vs. Dimmed
{#if !reserviert}conditional rendering), but the UI spec mockup (View 05) shows the button dimmed with reduced opacity — always present, visually disabled. These two approaches conflict. Option A: always render withopacity-35 pointer-events-none(matches spec mockup, communicates to admins that deletion is blocked). Option B: conditionally render with{#if !reserviert}(matches AC text, simpler). The server enforces the guard either way. (Raised by: Leonie, Elicit)Scope: Is
deleteArtikelPhotosAlready Implemented?lib/photos.ts::deleteArtikelPhotosin scope for this issue or already done? The AC references this function by name. If it was not implemented in a prior issue (#8 or #4), it must be built here — which makes this issue larger than size "S". If it already exists, no change needed. (Raised by: Elicit)Filesystem Failure Handling
Orphaned uploads on partial delete failure. If
deleteArtikelPhotos(id)partially fails (some WebP files deleted, then I/O error), the DB row is then still deleted, leaving a partially-emptyuploads/{id}/directory. Accept this as tolerable (best-effort cleanup, consistent with the upload rollback pattern), or implement a post-delete directory sweep? Recommend: accept as tolerable for this project scope. (Raised by: Markus)Soft-delete for photos before permanent delete? Since photos of heirloom items may be irreplaceable, consider moving the
uploads/{id}/directory touploads/_trash/{id}/instead ofrm -rf— allowing recovery before the next backup cycle. The current design assumesrm -rf(permanent). This is an architecture decision. (Raised by: Tobias)Testing Scope
deleteArtikelPhotos. Testing partial I/O failure scenarios (some files deleted, then error) requires filesystem mocking or complex temp-directory orchestration. Recommended: skip partial-failure simulation, test only happy path and "no-photos directory absent" path. Confirm this is acceptable. (Raised by: Sara)Security / Operations