From 2289547503c42de4268366b9381e57d84121ef67 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:13:15 +0200 Subject: [PATCH 01/11] docs(review): fix table names, IMAGE_DIR, image endpoints - ARCHITECTURE.md: ingredient/step (waren faelschlich recipe_*) - OPERATIONS.md: IMAGE_DIR (statt IMAGES_PATH) - session-handoff: /api/recipes/[id]/image POST/DELETE ergaenzt Findings aus REVIEW-2026-04-18.md / docs-vs-code.md --- docs/ARCHITECTURE.md | 2 +- docs/OPERATIONS.md | 2 +- .../plans/2026-04-18-review-fixes.md | 153 ++++++++++++++++++ .../superpowers/session-handoff-2026-04-17.md | 2 +- 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-18-review-fixes.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0752fe4..111044b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -52,7 +52,7 @@ src/ 1. User klickt auf Web-Hit → `/preview?url=...` 2. `/api/recipes/preview` → `importer.ts` lädt HTML, `parseHTML` von linkedom, `json-ld-recipe.ts` extrahiert `Recipe`-Objekt mit **externer** Bild-URL 3. Preview-Seite rendert das `Recipe` via `RecipeView.svelte` (erkennt externe URL und lädt direkt vom Original-CDN) -4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `recipe_ingredient` + `recipe_step` + `recipe_tag` +4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `ingredient` + `step` + `recipe_tag` 5. Redirect zu `/recipes/[id]` ### Web-Suche diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0adffba..e856c5c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -133,7 +133,7 @@ Die App hat ein eingebautes Backup unter `/admin` (ZIP-Export mit DB + Bildern). | `SEARXNG_URL` | `http://localhost:8888` | SearXNG-Endpoint, im Compose auf `http://searxng:8080` | | `KOCHWAS_THUMB_TTL_DAYS` | `30` | TTL für Thumbnail-Cache in der SQLite | | `DATABASE_PATH` | `data/kochwas.db` | Pfad zur SQLite, relativ oder absolut | -| `IMAGES_PATH` | `data/images` | Pfad für lokale Bild-Dateien | +| `IMAGE_DIR` | `data/images` | Pfad für lokale Bild-Dateien | | `PORT` | `3000` | Node-HTTP-Port (adapter-node) | Siehe `.env.example` im Repo. diff --git a/docs/superpowers/plans/2026-04-18-review-fixes.md b/docs/superpowers/plans/2026-04-18-review-fixes.md new file mode 100644 index 0000000..cfc9660 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-review-fixes.md @@ -0,0 +1,153 @@ +# Review-Fixes 2026-04-18 — Implementation Plan + +> **Quelle:** `docs/superpowers/review/REVIEW-2026-04-18.md` + Sub-Reports. +> **Branch:** `review-fixes-2026-04-18` +> **Goal:** Alle HIGH/MEDIUM Findings aus dem Code-Review adressieren, bewusst verschobene Items dokumentieren. +> **Architecture:** Inkrementelle Refactors, jeder atomar committed + gepusht, Tests nach jedem Wave grün. +> **Tech-Stack:** SvelteKit, TypeScript-strict, Zod, Vitest, better-sqlite3, Service-Worker. + +--- + +## Was wird angegangen (must-do) + +| # | Wave | Zeit | Begründung | +|---|------|------|------------| +| 1 | Doku-Fixes (ARCHITECTURE/OPERATIONS/handoff) | 5 min | Hoher Wert, trivialer Aufwand | +| 2 | constants.ts + Image-Endpoint EN + interne Types | 30 min | Alle "Quick-Wins" aus REVIEW | +| 3 | api-helpers.ts (parsePositiveIntParam + validateBody) | 1-2 h | Refactor A — 9+11 Call-Sites | +| 4 | requireProfile() + asyncFetch Wrapper | 1 h | Profile-Guard 4× + fetch-Pattern 5× | +| 5 | Cleanup (yauzl-Doku, baseRecipe-Fixture, Console-Logs) | 30 min | Restliche LOW-Findings | +| 6 | Ingredient-Parser Edge-Cases (Refactor D) | 2-3 h | Locale-Komma, Unicode-Brüche, Bounds | +| 7 | Verifikation (test/check/build, Docker-Smoke) | 30 min | Baseline gegen Regressionen | +| 8 | Re-Review + OPEN-ISSUES-NEXT.md | 1 h | Beweis + Ausblick | + +## Was bewusst NICHT angegangen wird (Begründung in OPEN-ISSUES-NEXT.md) + +- **Refactor B** (Search-State-Store, halber Tag): Touch von 808-Zeilen-Page + 678-Zeilen-Layout, bricht riskant Frontend ohne UAT. Eigene Phase planen. +- **Refactor C** (RecipeEditor zerlegen): Review sagt explizit "keine Eile, solange niemand sonst drin arbeitet". +- **SearXNG Rate-Limit Recovery**: Größeres Feature, eigene Phase. +- **SW-Zombie-Cleanup Unit-Tests**: Bereits 6 pwa-store-Tests vorhanden, Erweiterung wäre Bonus. +- **JSON-LD Parser Edge-Cases** (Locales): Weniger Käse als Ingredient-Parser-Issues, eigene Iteration. + +--- + +## Wave 1 — Doku-Fixes + +**Files:** `docs/ARCHITECTURE.md:55`, `docs/OPERATIONS.md:135`, `docs/superpowers/session-handoff-2026-04-17.md:46` + +- [ ] ARCHITECTURE.md: `recipe_ingredient` + `recipe_step` → `ingredient` + `step` +- [ ] OPERATIONS.md: `IMAGES_PATH` → `IMAGE_DIR` +- [ ] session-handoff: `/api/recipes/[id]/image` (POST/DELETE) ergänzen +- [ ] Commit `docs(review): Doku-Mismatches korrigiert` + +## Wave 2 — Konstanten + Cleanup + +**Files:** `src/lib/constants.ts` (neu), `src/routes/+page.svelte`, `src/lib/client/pwa.svelte.ts`, `src/routes/api/recipes/[id]/image/+server.ts`, `src/lib/sw/cache-strategy.ts`, `src/lib/sw/diff-manifest.ts` + +- [ ] `src/lib/constants.ts` mit `SW_VERSION_QUERY_TIMEOUT_MS = 1500`, `SW_UPDATE_POLL_INTERVAL_MS = 30 * 60_000` +- [ ] Image-Endpoint: deutsche Fehlermeldungen → englisch (Konsistenz) +- [ ] `RequestShape` / `ManifestDiff`: `export` weg wenn rein intern +- [ ] Test + check, Commit + +## Wave 3 — api-helpers.ts (TDD) + +**Files:** `src/lib/server/api-helpers.ts` (neu), `tests/unit/api-helpers.test.ts` (neu), `src/lib/types.ts` (ErrorResponse) + +### 3a Helper bauen +- [ ] Test: `parsePositiveIntParam("42", "id")` → 42 +- [ ] Test: `parsePositiveIntParam("0", "id")` wirft 400 +- [ ] Test: `parsePositiveIntParam("abc", "id")` wirft 400 +- [ ] Test: `parsePositiveIntParam(null, "id")` wirft 400 +- [ ] Test: `validateBody(invalid, schema)` wirft 400 mit issues +- [ ] Test: `validateBody(valid, schema)` returns parsed +- [ ] Implement helpers +- [ ] Tests grün, Commit + +### 3b Migration parseId → parsePositiveIntParam (9 Sites) +Files (jeder Endpoint): +- `src/routes/api/recipes/[id]/+server.ts` +- `src/routes/api/recipes/[id]/favorite/+server.ts` +- `src/routes/api/recipes/[id]/rating/+server.ts` +- `src/routes/api/recipes/[id]/cooked/+server.ts` +- `src/routes/api/recipes/[id]/comments/+server.ts` +- `src/routes/api/recipes/[id]/image/+server.ts` +- `src/routes/api/profiles/[id]/+server.ts` +- `src/routes/api/domains/[id]/+server.ts` +- `src/routes/api/wishlist/[recipe_id]/+server.ts` + +- [ ] Pro Endpoint: lokales parseId entfernen, Helper importieren +- [ ] Tests grün +- [ ] Commit + +### 3c Migration safeParse → validateBody +Files: alle `+server.ts` mit `safeParse`. ErrorResponse-Shape standardisieren. + +- [ ] Pro Endpoint umstellen +- [ ] Tests grün +- [ ] Commit + +## Wave 4 — Client-Helpers + +### 4a requireProfile() +- [ ] Helper in `src/lib/client/profile.svelte.ts` ergänzen +- [ ] 4 Sites in `src/routes/recipes/[id]/+page.svelte` ersetzen +- [ ] Test + Commit + +### 4b asyncFetch Wrapper +- [ ] `src/lib/client/api-fetch-wrapper.ts` mit `asyncFetch(url, init, actionTitle)` +- [ ] 5 Sites umstellen: `recipes/[id]/+page.svelte` (2×), `admin/domains/+page.svelte` (2×), `admin/profiles/+page.svelte` +- [ ] Test + Commit + +## Wave 5 — Cleanup + +- [ ] yauzl: Inline-Kommentar in package.json: "Reserved for Phase 5b ZIP-Backup-Import" +- [ ] baseRecipe Fixture nach `tests/fixtures/recipe.ts` (wenn dupliziert) +- [ ] Console-Logs: per `if (import.meta.env.DEV)` wrappen oder absichtlich-Kommentar +- [ ] Commit + +## Wave 6 — Ingredient-Parser Edge-Cases + +**Files:** `src/lib/server/parsers/ingredient.ts`, `tests/unit/ingredient.test.ts` + +### Tests zuerst (red) +- [ ] Locale-Komma: `"1,5 kg Mehl"` → qty 1.5 +- [ ] Unicode-½: `"½ TL Salz"` → qty 0.5 +- [ ] Unicode-⅓: `"⅓ Tasse Wasser"` → qty 1/3 +- [ ] Unicode-¼: `"¼ kg Zucker"` → qty 0.25 +- [ ] Negativ: `"-1 EL Öl"` → wirft / qty=null +- [ ] Null: `"0 g Mehl"` → wirft / qty=null +- [ ] Führende Null: `"0.5 kg"` → 0.5 +- [ ] Wissenschaftliche Notation: `"1e3 g"` → wirft / qty=null + +### Parser fixen +- [ ] Unicode-Brüche-Map +- [ ] Locale-Komma-Handling (sicher: "1,5" wenn nur 1 Komma + Ziffern drumrum) +- [ ] Bounds: 0 < qty <= 10000 (Zod refinement oder Pre-Check) +- [ ] Tests grün, Commit + +## Wave 7 — Verifikation + +- [ ] `npm test` — 158+ Tests grün +- [ ] `npm run check` — 0 Errors +- [ ] `npm run build` — erfolgreich +- [ ] Optional: Docker-Smoke `docker compose -f docker-compose.prod.yml up --build` +- [ ] Push aller Commits + +## Wave 8 — Re-Review + OPEN-ISSUES-NEXT.md + +- [ ] Parallele Explore-Agenten: dead-code, redundancy, structure, docs-vs-code +- [ ] Befunde in `docs/superpowers/review/OPEN-ISSUES-NEXT.md` +- [ ] Bewusst verschobene Items mit Begründung +- [ ] Neue Findings (falls vorhanden) +- [ ] Commit + Push + +--- + +## Erfolgs-Kriterien + +1. Tests grün (158+) +2. svelte-check: 0 Errors, 0 Warnings (oder ≤ Baseline) +3. Build erfolgreich +4. Alle 8 Quick-Wins + Refactor A + Refactor D umgesetzt +5. OPEN-ISSUES-NEXT.md vorhanden mit klarer Trennung "verschoben (warum)" vs "neu entdeckt" +6. Branch ready zum Mergen / PR diff --git a/docs/superpowers/session-handoff-2026-04-17.md b/docs/superpowers/session-handoff-2026-04-17.md index c23c51e..6bb9475 100644 --- a/docs/superpowers/session-handoff-2026-04-17.md +++ b/docs/superpowers/session-handoff-2026-04-17.md @@ -43,7 +43,7 @@ docker compose -f docker-compose.prod.yml up -d ### Server-Seite - **DB:** SQLite mit FTS5, Migrationen (`./migrations/*.sql`) werden von Vite gebündelt und beim ersten DB-Zugriff angewendet. Auto-mkdir für `data/` und `data/images/`. - **Module:** `parsers/` (iso8601, ingredient, json-ld-recipe), `recipes/` (scaler + repository + actions + importer + search-local), `domains/` (repository + whitelist), `profiles/`, `images/image-downloader`, `search/searxng`, `backup/export`, `http`. -- **Routes:** `/api/health`, `/api/profiles`, `/api/profiles/[id]`, `/api/domains`, `/api/domains/[id]`, `/api/recipes/search`, `/api/recipes/search/web`, `/api/recipes/preview`, `/api/recipes/import`, `/api/recipes/[id]`, `/api/recipes/[id]/rating`, `/api/recipes/[id]/favorite`, `/api/recipes/[id]/cooked`, `/api/recipes/[id]/comments`, `/api/admin/backup`, `/images/[filename]`. +- **Routes:** `/api/health`, `/api/profiles`, `/api/profiles/[id]`, `/api/domains`, `/api/domains/[id]`, `/api/recipes/search`, `/api/recipes/search/web`, `/api/recipes/preview`, `/api/recipes/import`, `/api/recipes/[id]`, `/api/recipes/[id]/rating`, `/api/recipes/[id]/favorite`, `/api/recipes/[id]/cooked`, `/api/recipes/[id]/comments`, `/api/recipes/[id]/image` (POST/DELETE), `/api/admin/backup`, `/images/[filename]`. ### Client-Seite (Svelte 5 Runes) - **Layout** mit Profil-Chip und Zahnrad zu Admin. From 830c740747210d57a3cd7fc9ecf0fbfdf9639c2e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:14:38 +0200 Subject: [PATCH 02/11] refactor(constants): zentrale SW-Timing-Konstanten + minor cleanups - src/lib/constants.ts: SW_VERSION_QUERY_TIMEOUT_MS, SW_UPDATE_POLL_INTERVAL_MS - pwa.svelte.ts: nutzt die Konstanten statt 1500/30*60_000 - cache-strategy.ts / diff-manifest.ts: RequestShape/ManifestDiff entkapselt (intern statt export, da nirgends extern importiert) - recipes/[id]/image: deutsche Fehlermeldungen auf Englisch (Konsistenz mit allen anderen Endpoints) Findings aus REVIEW-2026-04-18.md (Quick-Wins 6+7) und dead-code.md --- src/lib/client/pwa.svelte.ts | 6 ++++-- src/lib/constants.ts | 11 +++++++++++ src/lib/sw/cache-strategy.ts | 2 +- src/lib/sw/diff-manifest.ts | 2 +- src/routes/api/recipes/[id]/image/+server.ts | 8 ++++---- 5 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 src/lib/constants.ts diff --git a/src/lib/client/pwa.svelte.ts b/src/lib/client/pwa.svelte.ts index a5eb10e..d61bed9 100644 --- a/src/lib/client/pwa.svelte.ts +++ b/src/lib/client/pwa.svelte.ts @@ -1,3 +1,5 @@ +import { SW_UPDATE_POLL_INTERVAL_MS, SW_VERSION_QUERY_TIMEOUT_MS } from '$lib/constants'; + // Service-Worker-Update-Pattern: Workbox-Style Handshake (kein // skipWaiting im install-Handler, User bestätigt via Toast) mit // zusätzlichem Zombie-Schutz. @@ -39,7 +41,7 @@ class PwaStore { // mitbekommt, wenn er die Seite lange offen lässt ohne zu navigieren. this.pollTimer = setInterval(() => { void this.registration?.update().catch(() => {}); - }, 30 * 60_000); + }, SW_UPDATE_POLL_INTERVAL_MS); } private onUpdateFound(): void { @@ -97,7 +99,7 @@ class PwaStore { function queryVersion(sw: ServiceWorker): Promise { return new Promise((resolve) => { const channel = new MessageChannel(); - const timer = setTimeout(() => resolve(null), 1500); + const timer = setTimeout(() => resolve(null), SW_VERSION_QUERY_TIMEOUT_MS); channel.port1.onmessage = (e) => { clearTimeout(timer); const v = (e.data as { version?: unknown } | null)?.version; diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..ba30530 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,11 @@ +// Shared timing constants. Keep magic numbers here so callers stay readable +// and the rationale lives next to the value. + +// How long to wait for a Service Worker to answer GET_VERSION before +// treating the response as missing. Short on purpose — SWs that take this +// long are likely the Chromium zombie case (see pwa.svelte.ts). +export const SW_VERSION_QUERY_TIMEOUT_MS = 1500; + +// Active update check while the page sits open in a tab. 30 minutes is a +// trade-off between being timely and not hammering the server. +export const SW_UPDATE_POLL_INTERVAL_MS = 30 * 60_000; diff --git a/src/lib/sw/cache-strategy.ts b/src/lib/sw/cache-strategy.ts index a1de20a..6454ae9 100644 --- a/src/lib/sw/cache-strategy.ts +++ b/src/lib/sw/cache-strategy.ts @@ -1,6 +1,6 @@ export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only'; -export type RequestShape = { url: string; method: string }; +type RequestShape = { url: string; method: string }; // Pure function — sole decision-maker for "which strategy for this request?". // Called by the service worker for every fetch event. diff --git a/src/lib/sw/diff-manifest.ts b/src/lib/sw/diff-manifest.ts index 28a53b2..8f77d20 100644 --- a/src/lib/sw/diff-manifest.ts +++ b/src/lib/sw/diff-manifest.ts @@ -1,7 +1,7 @@ // Vergleicht die aktuelle Rezept-ID-Liste (vom Server) mit dem, was // der Cache schon hat. Der SW nutzt das Delta, um nur Neue zu laden // und Gelöschte abzuräumen. -export type ManifestDiff = { toAdd: number[]; toRemove: number[] }; +type ManifestDiff = { toAdd: number[]; toRemove: number[] }; export function diffManifest(currentIds: number[], cachedIds: number[]): ManifestDiff { const current = new Set(currentIds); diff --git a/src/routes/api/recipes/[id]/image/+server.ts b/src/routes/api/recipes/[id]/image/+server.ts index f056b13..4927dd9 100644 --- a/src/routes/api/recipes/[id]/image/+server.ts +++ b/src/routes/api/recipes/[id]/image/+server.ts @@ -32,13 +32,13 @@ export const POST: RequestHandler = async ({ params, request }) => { const form = await request.formData().catch(() => null); const file = form?.get('file'); - if (!(file instanceof File)) error(400, { message: 'Feld "file" fehlt' }); - if (file.size === 0) error(400, { message: 'Leere Datei' }); - if (file.size > MAX_BYTES) error(413, { message: 'Bild zu groß (max. 10 MB)' }); + if (!(file instanceof File)) error(400, { message: 'Field "file" missing' }); + if (file.size === 0) error(400, { message: 'Empty file' }); + if (file.size > MAX_BYTES) error(413, { message: 'Image too large (max 10 MB)' }); const mime = file.type.toLowerCase(); const ext = EXT_BY_MIME[mime]; - if (!ext) error(415, { message: `Bildformat ${file.type || 'unbekannt'} nicht unterstützt` }); + if (!ext) error(415, { message: `Image format ${file.type || 'unknown'} not supported` }); const buf = Buffer.from(await file.arrayBuffer()); const hash = createHash('sha256').update(buf).digest('hex'); From 739cc2d0584cb197569ff317a55b6d38d226e81f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:16:00 +0200 Subject: [PATCH 03/11] feat(server): api-helpers fuer parsePositiveIntParam + validateBody - src/lib/server/api-helpers.ts mit parsePositiveIntParam(), validateBody() und ErrorResponse type - 13 unit tests fuer die beiden helper (HttpError-Shape verifiziert) - Konsolidiert spaeter 9x parseId und 11x safeParse-Bloecke aus den +server.ts handlern Findings aus REVIEW-2026-04-18.md (Refactor A) und redundancy.md --- src/lib/server/api-helpers.ts | 39 ++++++++++++++ tests/unit/api-helpers.test.ts | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/lib/server/api-helpers.ts create mode 100644 tests/unit/api-helpers.test.ts diff --git a/src/lib/server/api-helpers.ts b/src/lib/server/api-helpers.ts new file mode 100644 index 0000000..5e9a27d --- /dev/null +++ b/src/lib/server/api-helpers.ts @@ -0,0 +1,39 @@ +import { error } from '@sveltejs/kit'; +import type { ZodSchema } from 'zod'; + +// Shared error body shape for SvelteKit `error()` calls. `issues` is set +// when validateBody fails so the client can show a precise validation +// hint; everywhere else only `message` is used. +export type ErrorResponse = { + message: string; + issues?: unknown; +}; + +/** + * Parse a route param (or query param) as a positive integer (>=1). + * Throws SvelteKit `error(400)` with `Missing ` when null/undefined, + * or `Invalid ` when the value is not an integer >= 1. + */ +export function parsePositiveIntParam( + raw: string | undefined | null, + field: string +): number { + if (raw == null) error(400, { message: `Missing ${field}` }); + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` }); + return n; +} + +/** + * Validate an unknown body against a Zod schema. Throws SvelteKit + * `error(400, { message: 'Invalid body', issues })` on mismatch and returns + * the typed parse result on success. Accepts `null` (the typical result of + * `await request.json().catch(() => null)`). + */ +export function validateBody(body: unknown, schema: ZodSchema): T { + const parsed = schema.safeParse(body); + if (!parsed.success) { + error(400, { message: 'Invalid body', issues: parsed.error.issues }); + } + return parsed.data; +} diff --git a/tests/unit/api-helpers.test.ts b/tests/unit/api-helpers.test.ts new file mode 100644 index 0000000..e7dc20c --- /dev/null +++ b/tests/unit/api-helpers.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { parsePositiveIntParam, validateBody } from '../../src/lib/server/api-helpers'; + +// SvelteKit's `error()` throws an HttpError shape with { status, body }. +// We verify both — wrapping these everywhere costs nothing and keeps the +// API contract stable. + +function expectHttpError(fn: () => unknown, status: number, message?: string) { + try { + fn(); + } catch (err) { + const e = err as { status?: number; body?: { message?: string } }; + expect(e.status, `status should be ${status}`).toBe(status); + if (message !== undefined) { + expect(e.body?.message).toBe(message); + } + return; + } + throw new Error('expected fn to throw, but it returned normally'); +} + +describe('parsePositiveIntParam', () => { + it('parses a valid positive integer', () => { + expect(parsePositiveIntParam('42', 'id')).toBe(42); + expect(parsePositiveIntParam('1', 'id')).toBe(1); + expect(parsePositiveIntParam('999999', 'id')).toBe(999999); + }); + + it('throws 400 for zero', () => { + expectHttpError(() => parsePositiveIntParam('0', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for negative numbers', () => { + expectHttpError(() => parsePositiveIntParam('-1', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for non-integer', () => { + expectHttpError(() => parsePositiveIntParam('1.5', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for non-numeric strings', () => { + expectHttpError(() => parsePositiveIntParam('abc', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for empty string', () => { + expectHttpError(() => parsePositiveIntParam('', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for null', () => { + expectHttpError(() => parsePositiveIntParam(null, 'id'), 400, 'Missing id'); + }); + + it('throws 400 for undefined', () => { + expectHttpError(() => parsePositiveIntParam(undefined, 'id'), 400, 'Missing id'); + }); + + it('uses the provided field name in error messages', () => { + expectHttpError(() => parsePositiveIntParam('foo', 'recipe_id'), 400, 'Invalid recipe_id'); + expectHttpError(() => parsePositiveIntParam(null, 'recipe_id'), 400, 'Missing recipe_id'); + }); +}); + +describe('validateBody', () => { + const Schema = z.object({ + name: z.string().min(1), + age: z.number().int().nonnegative() + }); + + it('returns parsed data when valid', () => { + const result = validateBody({ name: 'foo', age: 42 }, Schema); + expect(result).toEqual({ name: 'foo', age: 42 }); + }); + + it('throws 400 with message and issues on schema mismatch', () => { + try { + validateBody({ name: '', age: -1 }, Schema); + throw new Error('expected throw'); + } catch (err) { + const e = err as { status?: number; body?: { message?: string; issues?: unknown[] } }; + expect(e.status).toBe(400); + expect(e.body?.message).toBe('Invalid body'); + expect(Array.isArray(e.body?.issues)).toBe(true); + expect(e.body?.issues?.length).toBeGreaterThan(0); + } + }); + + it('throws 400 for null body (request.json failure case)', () => { + expectHttpError(() => validateBody(null, Schema), 400, 'Invalid body'); + }); + + it('throws 400 for primitive non-object body', () => { + expectHttpError(() => validateBody('a string', Schema), 400, 'Invalid body'); + }); +}); From ff293e9db81b40874d468b82371cb51e7e65a552 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:19:12 +0200 Subject: [PATCH 04/11] refactor(api): alle handler auf api-helpers umstellen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 +server.ts handler nutzen jetzt parsePositiveIntParam und validateBody statt jeweils lokaler parseId/safeParse-Bloecke. Konsequenzen: - 9 lokale parseId/parsePositiveInt Definitionen geloescht - 11 safeParse + manueller error()-Throws ersetzt - domains/[id], domains, profiles: catch-Block reicht jetzt HttpError durch (isHttpError) — vorher wurde ein 404 vom updateDomain als 409 re-emittiert - recipes/[id]/image: kein function-clutter mehr neben den FormData-Pfaden - Konsistente Error-Bodies: validateBody schickt {message, issues}, parsePositiveIntParam {message: 'Missing X' / 'Invalid X'} Findings aus REVIEW-2026-04-18.md (Refactor A) und redundancy.md --- src/routes/api/domains/+server.ts | 14 +++++------ src/routes/api/domains/[id]/+server.ts | 21 ++++++---------- src/routes/api/profiles/+server.ts | 12 ++++----- src/routes/api/profiles/[id]/+server.ts | 19 +++++--------- src/routes/api/recipes/[id]/+server.ts | 17 ++++--------- .../api/recipes/[id]/comments/+server.ts | 25 ++++++------------- src/routes/api/recipes/[id]/cooked/+server.ts | 17 ++++--------- .../api/recipes/[id]/favorite/+server.ts | 25 ++++++------------- src/routes/api/recipes/[id]/image/+server.ts | 11 +++----- src/routes/api/recipes/[id]/rating/+server.ts | 25 ++++++------------- src/routes/api/recipes/import/+server.ts | 9 +++---- src/routes/api/wishlist/+server.ts | 9 +++---- .../api/wishlist/[recipe_id]/+server.ts | 13 +++------- 13 files changed, 75 insertions(+), 142 deletions(-) diff --git a/src/routes/api/domains/+server.ts b/src/routes/api/domains/+server.ts index 7bd3b7d..7a0bf9b 100644 --- a/src/routes/api/domains/+server.ts +++ b/src/routes/api/domains/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json, error, isHttpError } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; import { addDomain, listDomains, setDomainFavicon } from '$lib/server/domains/repository'; import { ensureFavicons, fetchAndStoreFavicon } from '$lib/server/domains/favicons'; @@ -21,16 +22,14 @@ export const GET: RequestHandler = async () => { }; export const POST: RequestHandler = async ({ request }) => { - const body = await request.json().catch(() => null); - const parsed = CreateSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); + const data = validateBody(await request.json().catch(() => null), CreateSchema); try { const db = getDb(); const d = addDomain( db, - parsed.data.domain, - parsed.data.display_name ?? null, - parsed.data.added_by_profile_id ?? null + data.domain, + data.display_name ?? null, + data.added_by_profile_id ?? null ); // Favicon direkt nach dem Insert mitziehen, damit die Antwort schon das // Icon enthält — der POST ist eh ein interaktiver Admin-Vorgang. @@ -41,6 +40,7 @@ export const POST: RequestHandler = async ({ request }) => { } return json(d, { status: 201 }); } catch (e) { + if (isHttpError(e)) throw e; error(409, { message: (e as Error).message }); } }; diff --git a/src/routes/api/domains/[id]/+server.ts b/src/routes/api/domains/[id]/+server.ts index 01da6a3..0c22f9f 100644 --- a/src/routes/api/domains/[id]/+server.ts +++ b/src/routes/api/domains/[id]/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json, error, isHttpError } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { removeDomain, updateDomain, @@ -16,20 +17,12 @@ const UpdateSchema = z.object({ display_name: z.string().max(100).nullable().optional() }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const PATCH: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = UpdateSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), UpdateSchema); try { const db = getDb(); - const updated = updateDomain(db, id, parsed.data); + const updated = updateDomain(db, id, data); if (!updated) error(404, { message: 'Not found' }); // Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden. if (updated.favicon_path === null) { @@ -41,12 +34,14 @@ export const PATCH: RequestHandler = async ({ params, request }) => { } return json(updated); } catch (e) { + // HTTP-Errors aus error() durchreichen, sonst landet ein 404 als 409. + if (isHttpError(e)) throw e; error(409, { message: (e as Error).message }); } }; export const DELETE: RequestHandler = async ({ params }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); removeDomain(getDb(), id); return json({ ok: true }); }; diff --git a/src/routes/api/profiles/+server.ts b/src/routes/api/profiles/+server.ts index 8ec51da..38c0734 100644 --- a/src/routes/api/profiles/+server.ts +++ b/src/routes/api/profiles/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json, error, isHttpError } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; import { createProfile, listProfiles } from '$lib/server/profiles/repository'; const CreateSchema = z.object({ @@ -14,15 +15,12 @@ export const GET: RequestHandler = async () => { }; export const POST: RequestHandler = async ({ request }) => { - const body = await request.json().catch(() => null); - const parsed = CreateSchema.safeParse(body); - if (!parsed.success) { - error(400, { message: 'Invalid body', issues: parsed.error.issues }); - } + const data = validateBody(await request.json().catch(() => null), CreateSchema); try { - const p = createProfile(getDb(), parsed.data.name, parsed.data.avatar_emoji ?? null); + const p = createProfile(getDb(), data.name, data.avatar_emoji ?? null); return json(p, { status: 201 }); } catch (e) { + if (isHttpError(e)) throw e; error(409, { message: (e as Error).message }); } }; diff --git a/src/routes/api/profiles/[id]/+server.ts b/src/routes/api/profiles/[id]/+server.ts index 289ef26..d1d3d73 100644 --- a/src/routes/api/profiles/[id]/+server.ts +++ b/src/routes/api/profiles/[id]/+server.ts @@ -1,28 +1,21 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { deleteProfile, renameProfile } from '$lib/server/profiles/repository'; const RenameSchema = z.object({ name: z.string().min(1).max(50) }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const PATCH: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = RenameSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - renameProfile(getDb(), id, parsed.data.name); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), RenameSchema); + renameProfile(getDb(), id, data.name); return json({ ok: true }); }; export const DELETE: RequestHandler = async ({ params }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); deleteProfile(getDb(), id); return json({ ok: true }); }; diff --git a/src/routes/api/recipes/[id]/+server.ts b/src/routes/api/recipes/[id]/+server.ts index efc43be..5448d9d 100644 --- a/src/routes/api/recipes/[id]/+server.ts +++ b/src/routes/api/recipes/[id]/+server.ts @@ -2,6 +2,7 @@ import type { RequestHandler } from './$types'; import { json, error } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { deleteRecipe, getRecipeById, @@ -48,14 +49,8 @@ const PatchSchema = z }) .refine((v) => Object.keys(v).length > 0, { message: 'Empty patch' }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const GET: RequestHandler = async ({ params }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); const db = getDb(); const recipe = getRecipeById(db, id); if (!recipe) error(404, { message: 'Recipe not found' }); @@ -68,12 +63,10 @@ export const GET: RequestHandler = async ({ params }) => { }; export const PATCH: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); const body = await request.json().catch(() => null); - const parsed = PatchSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); + const p = validateBody(body, PatchSchema); const db = getDb(); - const p = parsed.data; // Spezielle Kurz-Updates (bleiben als Sonderfall, weil sie FTS triggern // bzw. andere Tabellen mitpflegen). if (p.title !== undefined && Object.keys(p).length === 1) { @@ -121,7 +114,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => { }; export const DELETE: RequestHandler = async ({ params }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); deleteRecipe(getDb(), id); return json({ ok: true }); }; diff --git a/src/routes/api/recipes/[id]/comments/+server.ts b/src/routes/api/recipes/[id]/comments/+server.ts index 4814240..5a5bfd0 100644 --- a/src/routes/api/recipes/[id]/comments/+server.ts +++ b/src/routes/api/recipes/[id]/comments/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { addComment, deleteComment, listComments } from '$lib/server/recipes/actions'; const Schema = z.object({ @@ -11,30 +12,20 @@ const Schema = z.object({ const DeleteSchema = z.object({ comment_id: z.number().int().positive() }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const GET: RequestHandler = async ({ params }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); return json(listComments(getDb(), id)); }; export const POST: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - const cid = addComment(getDb(), id, parsed.data.profile_id, parsed.data.text); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), Schema); + const cid = addComment(getDb(), id, data.profile_id, data.text); return json({ id: cid }, { status: 201 }); }; export const DELETE: RequestHandler = async ({ request }) => { - const body = await request.json().catch(() => null); - const parsed = DeleteSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - deleteComment(getDb(), parsed.data.comment_id); + const data = validateBody(await request.json().catch(() => null), DeleteSchema); + deleteComment(getDb(), data.comment_id); return json({ ok: true }); }; diff --git a/src/routes/api/recipes/[id]/cooked/+server.ts b/src/routes/api/recipes/[id]/cooked/+server.ts index e1965c9..bf273db 100644 --- a/src/routes/api/recipes/[id]/cooked/+server.ts +++ b/src/routes/api/recipes/[id]/cooked/+server.ts @@ -1,25 +1,18 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { logCooked } from '$lib/server/recipes/actions'; import { removeFromWishlistForAll } from '$lib/server/wishlist/repository'; const Schema = z.object({ profile_id: z.number().int().positive() }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const POST: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), Schema); const db = getDb(); - const entry = logCooked(db, id, parsed.data.profile_id); + const entry = logCooked(db, id, data.profile_id); // Wenn das Rezept heute gekocht wurde, ist der Wunsch erfüllt — für alle // Profile raus aus der Wunschliste. Client nutzt den removed_from_wishlist- // Flag, um den lokalen State (Badge, Button) ohne Reload zu aktualisieren. diff --git a/src/routes/api/recipes/[id]/favorite/+server.ts b/src/routes/api/recipes/[id]/favorite/+server.ts index 5c82f09..f2bd02c 100644 --- a/src/routes/api/recipes/[id]/favorite/+server.ts +++ b/src/routes/api/recipes/[id]/favorite/+server.ts @@ -1,31 +1,22 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { addFavorite, removeFavorite } from '$lib/server/recipes/actions'; const Schema = z.object({ profile_id: z.number().int().positive() }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const PUT: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - addFavorite(getDb(), id, parsed.data.profile_id); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), Schema); + addFavorite(getDb(), id, data.profile_id); return json({ ok: true }); }; export const DELETE: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - removeFavorite(getDb(), id, parsed.data.profile_id); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), Schema); + removeFavorite(getDb(), id, data.profile_id); return json({ ok: true }); }; diff --git a/src/routes/api/recipes/[id]/image/+server.ts b/src/routes/api/recipes/[id]/image/+server.ts index 4927dd9..e19f3b4 100644 --- a/src/routes/api/recipes/[id]/image/+server.ts +++ b/src/routes/api/recipes/[id]/image/+server.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam } from '$lib/server/api-helpers'; import { getRecipeById, updateImagePath } from '$lib/server/recipes/repository'; const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images'; @@ -19,14 +20,8 @@ const EXT_BY_MIME: Record = { 'image/avif': '.avif' }; -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const POST: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); const db = getDb(); if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' }); @@ -53,7 +48,7 @@ export const POST: RequestHandler = async ({ params, request }) => { }; export const DELETE: RequestHandler = ({ params }) => { - const id = parseId(params.id!); + const id = parsePositiveIntParam(params.id, 'id'); const db = getDb(); if (!getRecipeById(db, id)) error(404, { message: 'Recipe not found' }); updateImagePath(db, id, null); diff --git a/src/routes/api/recipes/[id]/rating/+server.ts b/src/routes/api/recipes/[id]/rating/+server.ts index 0ea926a..1a2cfd3 100644 --- a/src/routes/api/recipes/[id]/rating/+server.ts +++ b/src/routes/api/recipes/[id]/rating/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam, validateBody } from '$lib/server/api-helpers'; import { clearRating, setRating } from '$lib/server/recipes/actions'; const Schema = z.object({ @@ -11,26 +12,16 @@ const Schema = z.object({ const DeleteSchema = z.object({ profile_id: z.number().int().positive() }); -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' }); - return id; -} - export const PUT: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - setRating(getDb(), id, parsed.data.profile_id, parsed.data.stars); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), Schema); + setRating(getDb(), id, data.profile_id, data.stars); return json({ ok: true }); }; export const DELETE: RequestHandler = async ({ params, request }) => { - const id = parseId(params.id!); - const body = await request.json().catch(() => null); - const parsed = DeleteSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - clearRating(getDb(), id, parsed.data.profile_id); + const id = parsePositiveIntParam(params.id, 'id'); + const data = validateBody(await request.json().catch(() => null), DeleteSchema); + clearRating(getDb(), id, data.profile_id); return json({ ok: true }); }; diff --git a/src/routes/api/recipes/import/+server.ts b/src/routes/api/recipes/import/+server.ts index 429e6fd..6b89a2d 100644 --- a/src/routes/api/recipes/import/+server.ts +++ b/src/routes/api/recipes/import/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; import { importRecipe } from '$lib/server/recipes/importer'; import { mapImporterError } from '$lib/server/errors'; @@ -10,11 +11,9 @@ const ImportSchema = z.object({ url: z.string().url() }); const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images'; export const POST: RequestHandler = async ({ request }) => { - const body = await request.json().catch(() => null); - const parsed = ImportSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); + const data = validateBody(await request.json().catch(() => null), ImportSchema); try { - const result = await importRecipe(getDb(), IMAGE_DIR, parsed.data.url); + const result = await importRecipe(getDb(), IMAGE_DIR, data.url); return json({ id: result.id, duplicate: result.duplicate }); } catch (e) { mapImporterError(e); diff --git a/src/routes/api/wishlist/+server.ts b/src/routes/api/wishlist/+server.ts index 871cc90..fac3751 100644 --- a/src/routes/api/wishlist/+server.ts +++ b/src/routes/api/wishlist/+server.ts @@ -1,7 +1,8 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; import { addToWishlist, listWishlist, @@ -32,9 +33,7 @@ export const GET: RequestHandler = async ({ url }) => { }; export const POST: RequestHandler = async ({ request }) => { - const body = await request.json().catch(() => null); - const parsed = AddSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' }); - addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id); + const data = validateBody(await request.json().catch(() => null), AddSchema); + addToWishlist(getDb(), data.recipe_id, data.profile_id); return json({ ok: true }, { status: 201 }); }; diff --git a/src/routes/api/wishlist/[recipe_id]/+server.ts b/src/routes/api/wishlist/[recipe_id]/+server.ts index f4c2fba..339b155 100644 --- a/src/routes/api/wishlist/[recipe_id]/+server.ts +++ b/src/routes/api/wishlist/[recipe_id]/+server.ts @@ -1,26 +1,21 @@ import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; +import { parsePositiveIntParam } from '$lib/server/api-helpers'; import { removeFromWishlist, removeFromWishlistForAll } from '$lib/server/wishlist/repository'; -function parsePositiveInt(raw: string | null, field: string): number { - const n = raw === null ? NaN : Number(raw); - if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` }); - return n; -} - // DELETE /api/wishlist/:id?profile_id=X → entfernt nur den eigenen Wunsch // DELETE /api/wishlist/:id?all=true → entfernt für ALLE Profile export const DELETE: RequestHandler = async ({ params, url }) => { - const id = parsePositiveInt(params.recipe_id!, 'recipe_id'); + const id = parsePositiveIntParam(params.recipe_id, 'recipe_id'); const db = getDb(); if (url.searchParams.get('all') === 'true') { removeFromWishlistForAll(db, id); } else { - const profileId = parsePositiveInt(url.searchParams.get('profile_id'), 'profile_id'); + const profileId = parsePositiveIntParam(url.searchParams.get('profile_id'), 'profile_id'); removeFromWishlist(db, id, profileId); } return json({ ok: true }); From 30a447a3eaabd8073582d3fbaea38d7a5de74f4b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:22:19 +0200 Subject: [PATCH 05/11] refactor(client): requireProfile() + asyncFetch wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireProfile(): - src/lib/client/profile.svelte.ts: neuer Helper, returnt das aktive Profile oder null nach standardisiertem alertAction - 5x in recipes/[id]/+page.svelte: setRating, toggleFavorite, logCooked, addComment, toggleWishlist verlieren je 7 Zeilen Guard-Klausel - profile-Variable im Closure macht den ! am profileStore.active obsolet asyncFetch(): - src/lib/client/api-fetch-wrapper.ts: returnt Response auf 2xx, null nach alertAction auf Fehler - 4 Call-Sites umgestellt: saveRecipe + saveTitle (recipes/[id]), saveEdit (admin/domains), rename (admin/profiles) - admin/domains add() bewusst nicht migriert — inline-Error-UX statt Modal Findings aus REVIEW-2026-04-18.md (Quick-Win 5) und redundancy.md --- src/lib/client/api-fetch-wrapper.ts | 25 +++++ src/lib/client/profile.svelte.ts | 15 +++ src/routes/admin/domains/+page.svelte | 32 +++---- src/routes/admin/profiles/+page.svelte | 26 +++-- src/routes/recipes/[id]/+page.svelte | 126 +++++++++---------------- 5 files changed, 114 insertions(+), 110 deletions(-) create mode 100644 src/lib/client/api-fetch-wrapper.ts diff --git a/src/lib/client/api-fetch-wrapper.ts b/src/lib/client/api-fetch-wrapper.ts new file mode 100644 index 0000000..3604f7b --- /dev/null +++ b/src/lib/client/api-fetch-wrapper.ts @@ -0,0 +1,25 @@ +import { alertAction } from '$lib/client/confirm.svelte'; + +/** + * Fetch wrapper for actions where a non-OK response should pop a modal + * via alertAction(). Returns the Response on 2xx, or null after showing + * the alert. Caller should `if (!res) return;` after the call. + * + * Use this for *interactive* actions (rename, delete, save). For form + * submissions where the error should appear inline next to the field + * (e.g. admin/domains add()), keep manual handling. + */ +export async function asyncFetch( + url: string, + init: RequestInit | undefined, + errorTitle: string +): Promise { + const res = await fetch(url, init); + if (res.ok) return res; + const body = (await res.json().catch(() => null)) as { message?: string } | null; + await alertAction({ + title: errorTitle, + message: body?.message ?? `HTTP ${res.status}` + }); + return null; +} diff --git a/src/lib/client/profile.svelte.ts b/src/lib/client/profile.svelte.ts index d0b8e32..d8ba289 100644 --- a/src/lib/client/profile.svelte.ts +++ b/src/lib/client/profile.svelte.ts @@ -1,4 +1,5 @@ import type { Profile } from '$lib/types'; +import { alertAction } from '$lib/client/confirm.svelte'; const STORAGE_KEY = 'kochwas.activeProfileId'; @@ -60,3 +61,17 @@ class ProfileStore { } export const profileStore = new ProfileStore(); + +/** + * Returns the active profile, or null after showing the standard + * "kein Profil gewählt" dialog. Use as the first line of any per-profile + * action so we don't repeat the guard at every call-site. + */ +export async function requireProfile(): Promise { + if (profileStore.active) return profileStore.active; + await alertAction({ + title: 'Kein Profil gewählt', + message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.' + }); + return null; +} diff --git a/src/routes/admin/domains/+page.svelte b/src/routes/admin/domains/+page.svelte index 77ad96e..da2a010 100644 --- a/src/routes/admin/domains/+page.svelte +++ b/src/routes/admin/domains/+page.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { Pencil, Check, X, Globe } from 'lucide-svelte'; import type { AllowedDomain } from '$lib/types'; - import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; + import { confirmAction } from '$lib/client/confirm.svelte'; + import { asyncFetch } from '$lib/client/api-fetch-wrapper'; import { requireOnline } from '$lib/client/require-online'; let domains = $state([]); @@ -64,22 +65,19 @@ if (!requireOnline('Das Speichern')) return; saving = true; try { - const res = await fetch(`/api/domains/${d.id}`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - domain: editDomain.trim(), - display_name: editLabel.trim() || null - }) - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - await alertAction({ - title: 'Speichern fehlgeschlagen', - message: body.message ?? `HTTP ${res.status}` - }); - return; - } + const res = await asyncFetch( + `/api/domains/${d.id}`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + domain: editDomain.trim(), + display_name: editLabel.trim() || null + }) + }, + 'Speichern fehlgeschlagen' + ); + if (!res) return; cancelEdit(); await load(); } finally { diff --git a/src/routes/admin/profiles/+page.svelte b/src/routes/admin/profiles/+page.svelte index 52010aa..b398589 100644 --- a/src/routes/admin/profiles/+page.svelte +++ b/src/routes/admin/profiles/+page.svelte @@ -1,6 +1,7 @@