diff --git a/docs/superpowers/plans/2026-04-21-photo-recipe-magic.md b/docs/superpowers/plans/2026-04-21-photo-recipe-magic.md new file mode 100644 index 0000000..adf57ec --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-photo-recipe-magic.md @@ -0,0 +1,2237 @@ +# Foto-Rezept-Magie Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Nutzer kann ein Foto eines gedruckten oder handgeschriebenen Rezepts aufnehmen — Gemini 2.5 Flash extrahiert die Felder, der Nutzer landet im vorausgefüllten `RecipeEditor` und speichert. + +**Architecture:** Foto geht per Multipart an einen neuen Server-Endpoint, der es mit `sharp` runterskaliert, einmal an Gemini schickt (structured output + Zod-validiert, 1× Retry) und das extrahierte `Partial` zurückgibt — **ohne** DB-Write. Der Client hält das Ergebnis in State und rendert den bestehenden `RecipeEditor` mit `recipe.id === null`. Auf Save POSTet der Client die kompletten Felder an einen neuen `POST /api/recipes`-Endpoint, der `insertRecipe` wrappt. Weder das Original-Foto noch die AI-Payload werden persistiert oder geloggt. + +**Tech Stack:** SvelteKit 2, Svelte 5, better-sqlite3, `sharp` (Image-Preprocess), `@google/generative-ai` (Gemini-SDK), `zod` (Validierung), Vitest (Unit + API), Playwright (E2E). + +**Spec reference:** `docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md` + +--- + +## Task 1: Dependencies & Env-Config + +**Files:** +- Modify: `package.json` (add `@google/generative-ai`, `sharp`) +- Modify: `Dockerfile:3-10` (ergänze build deps für `sharp`) +- Modify: `.env.example`, `docker-compose.yml`, `docker-compose.prod.yml` +- Create: (nothing new) + +Dies ist **Infrastruktur-Setup**. Keine Tests — die Deps-Installation verifiziert sich von selbst, der Dockerfile-Build durch CI im Follow-up. + +- [ ] **Step 1: Deps installieren** + +```bash +npm install @google/generative-ai sharp +``` + +Expected: beide in `dependencies` in `package.json`, `package-lock.json` aktualisiert. + +- [ ] **Step 2: Dockerfile erweitern für sharp-arm64** + +Ersetze Zeile 7 (`RUN apk add --no-cache python3 make g++ libc6-compat`) durch: + +```dockerfile +# Alpine braucht Build-Tools für better-sqlite3 (native) und sharp (libvips); +# vips-dev liefert libheif-Support für HEIC-Input von iOS. +RUN apk add --no-cache python3 make g++ libc6-compat vips-dev +``` + +Runtime-Stage (Zeile 21) bleibt `libc6-compat` — sharp linkt libvips statisch in seinen npm-Prebuilds. + +- [ ] **Step 3: Env-Vars ergänzen** + +In `.env.example` ergänzen: + +``` +# Gemini Vision (Foto-Rezept-Magie). Ohne Key ist die Funktion deaktiviert. +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.5-flash +GEMINI_TIMEOUT_MS=20000 +``` + +In `docker-compose.yml` und `docker-compose.prod.yml` im `environment`-Block des App-Services die drei Variablen ergänzen: + +```yaml + environment: + # …bestehende Einträge… + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash} + GEMINI_TIMEOUT_MS: ${GEMINI_TIMEOUT_MS:-20000} +``` + +- [ ] **Step 4: Build verifizieren** + +```bash +npm run check +``` + +Expected: PASS (keine neuen Type-Fehler durch die neuen Packages — sie werden noch nicht importiert). + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json Dockerfile .env.example docker-compose.yml docker-compose.prod.yml +git commit -m "chore(deps): Gemini-SDK und sharp fuer Foto-Rezept-Magie" +``` + +--- + +## Task 2: Description-Phrasen-Pool + +**Files:** +- Create: `src/lib/server/ai/description-phrases.ts` +- Create: `src/lib/server/ai/description-phrases.test.ts` + +Isolierter, reiner Helper. TDD: erst der Test, dann das Modul. + +- [ ] **Step 1: Failing test schreiben** + +`src/lib/server/ai/description-phrases.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { DESCRIPTION_PHRASES, pickRandomPhrase } from './description-phrases'; + +describe('description-phrases', () => { + it('contains exactly 50 entries', () => { + expect(DESCRIPTION_PHRASES).toHaveLength(50); + }); + + it('has no empty or whitespace-only entries', () => { + for (const phrase of DESCRIPTION_PHRASES) { + expect(phrase.trim().length).toBeGreaterThan(0); + } + }); + + it('has no duplicates', () => { + const set = new Set(DESCRIPTION_PHRASES); + expect(set.size).toBe(DESCRIPTION_PHRASES.length); + }); + + it('pickRandomPhrase returns a member of the pool', () => { + const pool = new Set(DESCRIPTION_PHRASES); + for (let i = 0; i < 100; i++) { + expect(pool.has(pickRandomPhrase())).toBe(true); + } + }); +}); +``` + +- [ ] **Step 2: Test laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/lib/server/ai/description-phrases.test.ts +``` + +Expected: FAIL — Modul existiert noch nicht. + +- [ ] **Step 3: Modul implementieren** + +`src/lib/server/ai/description-phrases.ts`: + +```ts +export const DESCRIPTION_PHRASES: readonly string[] = [ + 'Mit dem Zauberstab aus dem Kochbuch geholt.', + 'Foto-Magie frisch aus dem Ofen.', + 'Aus dem Bild herbeigezaubert.', + 'Ein Klick, ein Foto, fertig.', + 'Knipsen statt Abtippen.', + 'Von der Buchseite direkt in die Pfanne.', + 'Die Kamera hat mitgelesen.', + 'Abrakadabra — Rezept da.', + 'Per Linse in die Küche teleportiert.', + 'Von Oma abfotografiert, von der KI entziffert.', + 'Frisch aus dem Bilderrahmen.', + 'Klick, zisch, Rezept.', + 'Das Foto wurde überredet, sich zu verraten.', + 'Schnappschuss zur Schüssel.', + 'Einmal lesen lassen, schon da.', + 'Keine Hand hat dieses Rezept abgetippt.', + 'Vom Bild in die Bratpfanne.', + 'Papier ist geduldig, das Foto war es auch.', + 'Eine Seite, ein Foto, ein Rezept.', + 'Die KI hat drübergeschielt.', + 'Handschriftlich entziffert — oder zumindest versucht.', + 'Aus der Linse in die Liste.', + 'Vom Küchentisch zur Kachel.', + 'Knips und weg — zumindest der Zettel.', + 'Das Bild hat geredet.', + 'Keine Tippfehler, nur Sehfehler.', + 'Per Foto eingebürgert.', + 'Rezept-Übersetzung aus dem Bild.', + 'Die Seite hat sich verraten.', + 'Blitzlicht und dann Gulasch.', + 'Ein Augenzwinkern der Kamera genügte.', + 'Geknipst, gelesen, gespeichert.', + 'Fotografische Gedächtnishilfe.', + 'Aus der Schublade ans Licht.', + 'Das Rezept stand schon da — wir haben nur hingeguckt.', + 'Zaubertrick mit Kamera.', + 'Vom Papier befreit.', + 'Ein Foto sagt mehr als tausend Zutatenlisten.', + 'Eingescannt, rausgelesen, reingeschrieben.', + 'Die Kamera als Küchenhilfe.', + 'Handy hoch, Rezept runter.', + 'Aus dem Kochbuch gebeamt.', + 'Ein scharfes Foto, ein klares Rezept.', + 'Vom Regal zur App in einem Schritt.', + 'Aus dem Bild geschöpft wie Suppe aus dem Topf.', + 'Optisch erfasst, digital serviert.', + 'Das Kleingedruckte hat die KI gelesen.', + 'Vom Kladdenzettel in die Datenbank.', + 'Kurz gezückt, schon gekocht.', + 'Kein Schreibkrampf, nur ein Klick.' +]; + +export function pickRandomPhrase(): string { + return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)]; +} +``` + +- [ ] **Step 4: Test laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/lib/server/ai/description-phrases.test.ts +``` + +Expected: PASS — alle 4 Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/ai/description-phrases.ts src/lib/server/ai/description-phrases.test.ts +git commit -m "feat(ai): 50er-Pool Magie-Phrasen fuer Foto-description" +``` + +--- + +## Task 3: Image-Preprocess (sharp-Wrapper) + +**Files:** +- Create: `src/lib/server/ai/image-preprocess.ts` +- Create: `src/lib/server/ai/image-preprocess.test.ts` + +Reine Funktion: Buffer rein, Buffer raus. Keine Side-Effects, keine I/O außerhalb von `sharp`. + +- [ ] **Step 1: Failing tests schreiben** + +`src/lib/server/ai/image-preprocess.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import sharp from 'sharp'; +import { preprocessImage } from './image-preprocess'; + +async function makeTestImage(width: number, height: number, format: 'jpeg' | 'png' | 'webp' = 'jpeg'): Promise { + return sharp({ + create: { + width, + height, + channels: 3, + background: { r: 128, g: 128, b: 128 } + } + }) + .toFormat(format) + .toBuffer(); +} + +describe('preprocessImage', () => { + it('resizes a landscape image so long edge <= 1600px', async () => { + const input = await makeTestImage(4000, 2000); + const { buffer, mimeType } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeGreaterThan(1000); + expect(mimeType).toBe('image/jpeg'); + }); + + it('resizes a portrait image so long edge <= 1600px', async () => { + const input = await makeTestImage(2000, 4000); + const { buffer } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600); + }); + + it('does not upscale smaller images', async () => { + const input = await makeTestImage(800, 600); + const { buffer } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(meta.width).toBe(800); + expect(meta.height).toBe(600); + }); + + it('converts PNG input to JPEG output', async () => { + const input = await makeTestImage(1000, 1000, 'png'); + const { buffer, mimeType } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(meta.format).toBe('jpeg'); + expect(mimeType).toBe('image/jpeg'); + }); + + it('strips EXIF metadata', async () => { + const input = await sharp({ + create: { width: 100, height: 100, channels: 3, background: '#888' } + }) + .withMetadata({ exif: { IFD0: { Copyright: 'test' } } }) + .jpeg() + .toBuffer(); + const { buffer } = await preprocessImage(input); + const meta = await sharp(buffer).metadata(); + expect(meta.exif).toBeUndefined(); + }); + + it('rejects non-image buffers', async () => { + const notAnImage = Buffer.from('hello world'); + await expect(preprocessImage(notAnImage)).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/lib/server/ai/image-preprocess.test.ts +``` + +Expected: FAIL — Modul existiert noch nicht. + +- [ ] **Step 3: Modul implementieren** + +`src/lib/server/ai/image-preprocess.ts`: + +```ts +import sharp from 'sharp'; + +const MAX_EDGE = 1600; +const JPEG_QUALITY = 85; + +export type PreprocessedImage = { + buffer: Buffer; + mimeType: 'image/jpeg'; +}; + +// Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen. +// sharp liest HEIC/HEIF transparent wenn libheif/libvips mit Support gebaut wurde +// (ist in Alpine's vips-dev enthalten). +export async function preprocessImage(input: Buffer): Promise { + const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation + const meta = await pipeline.metadata(); + if (!meta.width || !meta.height) { + throw new Error('Unable to read image dimensions'); + } + + const longEdge = Math.max(meta.width, meta.height); + const resizer = longEdge > MAX_EDGE + ? pipeline.resize({ width: meta.width >= meta.height ? MAX_EDGE : undefined, + height: meta.height > meta.width ? MAX_EDGE : undefined, + withoutEnlargement: true }) + : pipeline; + + const buffer = await resizer + .jpeg({ quality: JPEG_QUALITY, mozjpeg: true }) + .withMetadata({}) // drops EXIF/IPTC/XMP + .toBuffer(); + + return { buffer, mimeType: 'image/jpeg' }; +} +``` + +- [ ] **Step 4: Tests laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/lib/server/ai/image-preprocess.test.ts +``` + +Expected: PASS — alle 6 Tests grün. Falls der EXIF-Strip-Test fehlschlägt: sharp's `withMetadata({})` muss leere Options haben, nicht weglassen. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/ai/image-preprocess.ts src/lib/server/ai/image-preprocess.test.ts +git commit -m "feat(ai): image-preprocess mit sharp (Resize + JPEG-Strip)" +``` + +--- + +## Task 4: Extraction-Prompt + JSON-Schema + Zod-Validator + +**Files:** +- Create: `src/lib/server/ai/recipe-extraction-prompt.ts` +- Create: `src/lib/server/ai/recipe-extraction-prompt.test.ts` + +Hier liegt der extrahierte Prompt-Text plus das Gemini-`responseSchema` plus das Zod-Spiegelbild davon. Tests gegen die Zod-Validierung — den Gemini-Call selbst testen wir erst in Task 5. + +- [ ] **Step 1: Failing tests schreiben** + +`src/lib/server/ai/recipe-extraction-prompt.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { + RECIPE_EXTRACTION_SYSTEM_PROMPT, + GEMINI_RESPONSE_SCHEMA, + extractionResponseSchema, + type ExtractionResponse +} from './recipe-extraction-prompt'; + +describe('recipe-extraction-prompt', () => { + it('system prompt is in German and mentions title+ingredients+steps', () => { + const p = RECIPE_EXTRACTION_SYSTEM_PROMPT.toLowerCase(); + expect(p).toContain('rezept'); + expect(p).toContain('zutaten'); + expect(p).toContain('zubereitung'); + }); + + it('Gemini response schema has required top-level keys', () => { + expect(GEMINI_RESPONSE_SCHEMA.type).toBe('object'); + expect(Object.keys(GEMINI_RESPONSE_SCHEMA.properties)).toEqual( + expect.arrayContaining(['title', 'ingredients', 'steps']) + ); + }); + + it('Zod validator accepts a well-formed response', () => { + const good: ExtractionResponse = { + title: 'Testrezept', + servings_default: 4, + servings_unit: 'Portionen', + prep_time_min: 15, + cook_time_min: 30, + total_time_min: null, + ingredients: [ + { quantity: 100, unit: 'g', name: 'Mehl', note: null } + ], + steps: [ + { text: 'Mehl in eine Schüssel geben.' } + ] + }; + expect(() => extractionResponseSchema.parse(good)).not.toThrow(); + }); + + it('Zod validator rejects missing title', () => { + const bad = { + servings_default: 4, + ingredients: [], + steps: [] + }; + expect(() => extractionResponseSchema.parse(bad)).toThrow(); + }); + + it('Zod validator coerces quantity=null and unit=null', () => { + const ok: ExtractionResponse = { + title: 'Prise-Rezept', + servings_default: null, + servings_unit: null, + prep_time_min: null, + cook_time_min: null, + total_time_min: null, + ingredients: [{ quantity: null, unit: null, name: 'Salz', note: 'nach Geschmack' }], + steps: [{ text: 'Einfach so.' }] + }; + expect(() => extractionResponseSchema.parse(ok)).not.toThrow(); + }); + + it('Zod validator rejects unexpected extra top-level keys (strict)', () => { + const bad = { + title: 'x', + servings_default: null, + servings_unit: null, + prep_time_min: null, + cook_time_min: null, + total_time_min: null, + ingredients: [], + steps: [], + malicious_extra_field: 'pwned' + }; + expect(() => extractionResponseSchema.parse(bad)).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts +``` + +Expected: FAIL — Modul existiert noch nicht. + +- [ ] **Step 3: Modul implementieren** + +`src/lib/server/ai/recipe-extraction-prompt.ts`: + +```ts +import { z } from 'zod'; +import { SchemaType } from '@google/generative-ai'; + +// Hinweis: Das Gemini-SDK nutzt eigene Schema-Konstanten (SchemaType.STRING etc.). +// Wir halten das Schema hier rein-JS und konvertieren erst im Client. +export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent. +Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück. + +Regeln: +- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array). +- Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat + (g, ml, l, kg, EL, TL, Stück, Prise, Msp, …). +- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt. +- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60. +- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst. +- Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null. +- Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`; + +// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent +// übergeben; Gemini respektiert die Struktur und liefert garantiert valides JSON. +export const GEMINI_RESPONSE_SCHEMA = { + type: SchemaType.OBJECT, + properties: { + title: { type: SchemaType.STRING, nullable: false }, + servings_default: { type: SchemaType.INTEGER, nullable: true }, + servings_unit: { type: SchemaType.STRING, nullable: true }, + prep_time_min: { type: SchemaType.INTEGER, nullable: true }, + cook_time_min: { type: SchemaType.INTEGER, nullable: true }, + total_time_min: { type: SchemaType.INTEGER, nullable: true }, + ingredients: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.OBJECT, + properties: { + quantity: { type: SchemaType.NUMBER, nullable: true }, + unit: { type: SchemaType.STRING, nullable: true }, + name: { type: SchemaType.STRING, nullable: false }, + note: { type: SchemaType.STRING, nullable: true } + }, + required: ['name'] + } + }, + steps: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.OBJECT, + properties: { + text: { type: SchemaType.STRING, nullable: false } + }, + required: ['text'] + } + } + }, + required: ['title', 'ingredients', 'steps'] +} as const; + +// Zod-Spiegel des Schemas für server-seitige Validierung der Gemini-Antwort. +// .strict() verhindert, dass zusätzliche Keys (die Gemini theoretisch erfinden könnte) +// unbemerkt durchrutschen. +const ingredientSchema = z.object({ + quantity: z.number().nullable(), + unit: z.string().max(30).nullable(), + name: z.string().min(1).max(200), + note: z.string().max(300).nullable() +}).strict(); + +const stepSchema = z.object({ + text: z.string().min(1).max(4000) +}).strict(); + +export const extractionResponseSchema = z.object({ + title: z.string().min(1).max(200), + servings_default: z.number().int().nonnegative().nullable(), + servings_unit: z.string().max(30).nullable(), + prep_time_min: z.number().int().nonnegative().nullable(), + cook_time_min: z.number().int().nonnegative().nullable(), + total_time_min: z.number().int().nonnegative().nullable(), + ingredients: z.array(ingredientSchema), + steps: z.array(stepSchema) +}).strict(); + +export type ExtractionResponse = z.infer; +``` + +- [ ] **Step 4: Tests laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts +``` + +Expected: PASS — alle 6 Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/ai/recipe-extraction-prompt.ts src/lib/server/ai/recipe-extraction-prompt.test.ts +git commit -m "feat(ai): Extraction-Prompt + Gemini-Schema + Zod-Validator" +``` + +--- + +## Task 5: Gemini-Client (Call, Timeout, Retry) + +**Files:** +- Create: `src/lib/server/ai/gemini-client.ts` +- Create: `src/lib/server/ai/gemini-client.test.ts` + +Dünne Wrapper-Schicht, deren einziger Job ist: Image-Buffer + MIME → `ExtractionResponse` (oder wohldefinierter Fehler). Tests mocken das Gemini-SDK. + +- [ ] **Step 1: Failing tests schreiben** + +`src/lib/server/ai/gemini-client.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock das Gemini-SDK bevor der Client importiert wird. +const mockGenerateContent = vi.fn(); +vi.mock('@google/generative-ai', async () => { + const actual = await vi.importActual('@google/generative-ai'); + return { + ...actual, + GoogleGenerativeAI: vi.fn().mockImplementation(() => ({ + getGenerativeModel: () => ({ generateContent: mockGenerateContent }) + })) + }; +}); + +import { extractRecipeFromImage, GeminiError } from './gemini-client'; + +beforeEach(() => { + mockGenerateContent.mockReset(); + process.env.GEMINI_API_KEY = 'test-key'; + process.env.GEMINI_MODEL = 'gemini-2.5-flash'; + process.env.GEMINI_TIMEOUT_MS = '5000'; +}); + +const validResponse = { + title: 'Apfelkuchen', + servings_default: 8, + servings_unit: 'Stück', + prep_time_min: 20, + cook_time_min: 45, + total_time_min: null, + ingredients: [{ quantity: 500, unit: 'g', name: 'Mehl', note: null }], + steps: [{ text: 'Ofen auf 180 °C vorheizen.' }] +}; + +describe('extractRecipeFromImage', () => { + it('happy path: returns parsed recipe data', async () => { + mockGenerateContent.mockResolvedValueOnce({ + response: { text: () => JSON.stringify(validResponse) } + }); + const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); + expect(result.title).toBe('Apfelkuchen'); + expect(result.ingredients).toHaveLength(1); + }); + + it('retries once on schema-invalid JSON, then succeeds', async () => { + mockGenerateContent + .mockResolvedValueOnce({ response: { text: () => '{"title": "no arrays"}' } }) + .mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } }); + const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); + expect(result.title).toBe('Apfelkuchen'); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); + }); + + it('throws AI_FAILED after schema-invalid + retry also invalid', async () => { + mockGenerateContent + .mockResolvedValueOnce({ response: { text: () => '{}' } }) + .mockResolvedValueOnce({ response: { text: () => '{"title": "still bad"}' } }); + await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) + .rejects.toMatchObject({ code: 'AI_FAILED' }); + }); + + it('throws AI_RATE_LIMITED without retry on 429', async () => { + const err = new Error('429 Too Many Requests') as Error & { status?: number }; + err.status = 429; + mockGenerateContent.mockRejectedValueOnce(err); + await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) + .rejects.toMatchObject({ code: 'AI_RATE_LIMITED' }); + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + }); + + it('retries once on 5xx, then succeeds', async () => { + const err = new Error('500 Server Error') as Error & { status?: number }; + err.status = 500; + mockGenerateContent + .mockRejectedValueOnce(err) + .mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } }); + const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); + expect(result.title).toBe('Apfelkuchen'); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); + }); + + it('throws AI_FAILED after 5xx + retry also fails', async () => { + const err = new Error('500') as Error & { status?: number }; + err.status = 500; + mockGenerateContent.mockRejectedValue(err); + await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) + .rejects.toMatchObject({ code: 'AI_FAILED' }); + expect(mockGenerateContent).toHaveBeenCalledTimes(2); + }); + + it('throws AI_TIMEOUT when generateContent does not resolve within GEMINI_TIMEOUT_MS', async () => { + process.env.GEMINI_TIMEOUT_MS = '50'; + mockGenerateContent.mockImplementation(() => new Promise(() => {})); // never resolves + await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) + .rejects.toMatchObject({ code: 'AI_TIMEOUT' }); + }); + + it('throws AI_NOT_CONFIGURED when GEMINI_API_KEY is empty', async () => { + process.env.GEMINI_API_KEY = ''; + await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) + .rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' }); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/lib/server/ai/gemini-client.test.ts +``` + +Expected: FAIL — Modul existiert noch nicht. + +- [ ] **Step 3: Modul implementieren** + +`src/lib/server/ai/gemini-client.ts`: + +```ts +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { env } from '$env/dynamic/private'; +import { + RECIPE_EXTRACTION_SYSTEM_PROMPT, + GEMINI_RESPONSE_SCHEMA, + extractionResponseSchema, + type ExtractionResponse +} from './recipe-extraction-prompt'; + +export type GeminiErrorCode = + | 'AI_NOT_CONFIGURED' + | 'AI_RATE_LIMITED' + | 'AI_TIMEOUT' + | 'AI_FAILED'; + +export class GeminiError extends Error { + constructor(public readonly code: GeminiErrorCode, message: string) { + super(message); + this.name = 'GeminiError'; + } +} + +function getStatus(err: unknown): number | undefined { + if (err && typeof err === 'object' && 'status' in err) { + const s = (err as { status?: unknown }).status; + if (typeof s === 'number') return s; + } + return undefined; +} + +async function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new GeminiError('AI_TIMEOUT', `Gemini timeout after ${ms} ms`)), ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); } + ); + }); +} + +// Call Gemini once. Throws GeminiError on known failures, rethrows network/status errors. +async function callGemini( + imageBuffer: Buffer, + mimeType: string, + appendUserNote?: string +): Promise { + const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set'); + } + const modelId = env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; + const timeoutMs = Number(env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? 20000); + + const client = new GoogleGenerativeAI(apiKey); + const model = client.getGenerativeModel({ + model: modelId, + systemInstruction: RECIPE_EXTRACTION_SYSTEM_PROMPT, + generationConfig: { + temperature: 0.1, + responseMimeType: 'application/json', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + responseSchema: GEMINI_RESPONSE_SCHEMA as any + } + }); + + const parts: Array<{ inlineData: { data: string; mimeType: string } } | { text: string }> = [ + { inlineData: { data: imageBuffer.toString('base64'), mimeType } } + ]; + if (appendUserNote) parts.push({ text: appendUserNote }); + + const result = await withTimeout( + model.generateContent({ contents: [{ role: 'user', parts }] }), + timeoutMs + ); + const text = result.response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new GeminiError('AI_FAILED', 'Gemini returned non-JSON output'); + } + const validated = extractionResponseSchema.safeParse(parsed); + if (!validated.success) { + throw new GeminiError('AI_FAILED', `Schema validation failed: ${validated.error.message}`); + } + return validated.data; +} + +// Public entry: one retry on recoverable failures (5xx OR schema-invalid), +// no retry on 429 or config errors. +export async function extractRecipeFromImage( + imageBuffer: Buffer, + mimeType: string +): Promise { + try { + return await callGemini(imageBuffer, mimeType); + } catch (e) { + if (e instanceof GeminiError && e.code === 'AI_NOT_CONFIGURED') throw e; + if (e instanceof GeminiError && e.code === 'AI_TIMEOUT') throw e; + + const status = getStatus(e); + if (status === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit'); + + const recoverable = + (e instanceof GeminiError && e.code === 'AI_FAILED') || + (status !== undefined && status >= 500); + if (!recoverable) { + throw e instanceof GeminiError ? e : new GeminiError('AI_FAILED', String(e)); + } + + // 1x retry + await new Promise((r) => setTimeout(r, 500)); + try { + return await callGemini( + imageBuffer, + mimeType, + 'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.' + ); + } catch (retryErr) { + if (retryErr instanceof GeminiError) throw retryErr; + const retryStatus = getStatus(retryErr); + if (retryStatus === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry'); + throw new GeminiError('AI_FAILED', String(retryErr)); + } + } +} +``` + +- [ ] **Step 4: Tests laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/lib/server/ai/gemini-client.test.ts +``` + +Expected: PASS — alle 8 Tests grün. Falls Tests aufgrund des Env-Mocks flakig sind: sichergehen dass `process.env` im `beforeEach` rein sauber gesetzt wird. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/ai/gemini-client.ts src/lib/server/ai/gemini-client.test.ts +git commit -m "feat(ai): Gemini-Client mit Timeout, 1x-Retry und Fehler-Codes" +``` + +--- + +## Task 6: Rate-Limiter (In-Memory, pro IP) + +**Files:** +- Create: `src/lib/server/ai/rate-limit.ts` +- Create: `src/lib/server/ai/rate-limit.test.ts` + +Einfacher `Map` — 10 Requests/Minute pro IP. + +- [ ] **Step 1: Failing tests schreiben** + +`src/lib/server/ai/rate-limit.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createRateLimiter } from './rate-limit'; + +describe('rate-limit', () => { + beforeEach(() => vi.useFakeTimers()); + + it('allows first 10 requests, rejects 11th', () => { + const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); + for (let i = 0; i < 10; i++) expect(limiter.check('1.2.3.4')).toBe(true); + expect(limiter.check('1.2.3.4')).toBe(false); + }); + + it('tracks per-IP independently', () => { + const limiter = createRateLimiter({ windowMs: 60_000, max: 2 }); + expect(limiter.check('a')).toBe(true); + expect(limiter.check('a')).toBe(true); + expect(limiter.check('a')).toBe(false); + expect(limiter.check('b')).toBe(true); + }); + + it('resets after window elapses', () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 1 }); + expect(limiter.check('x')).toBe(true); + expect(limiter.check('x')).toBe(false); + vi.advanceTimersByTime(1001); + expect(limiter.check('x')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/lib/server/ai/rate-limit.test.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Modul implementieren** + +`src/lib/server/ai/rate-limit.ts`: + +```ts +export type RateLimiter = { check: (key: string) => boolean }; + +export function createRateLimiter(opts: { windowMs: number; max: number }): RateLimiter { + const store = new Map(); + return { + check(key: string): boolean { + const now = Date.now(); + const entry = store.get(key); + if (!entry || entry.resetAt <= now) { + store.set(key, { count: 1, resetAt: now + opts.windowMs }); + return true; + } + if (entry.count >= opts.max) return false; + entry.count += 1; + return true; + } + }; +} +``` + +- [ ] **Step 4: Tests laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/lib/server/ai/rate-limit.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/ai/rate-limit.ts src/lib/server/ai/rate-limit.test.ts +git commit -m "feat(ai): simpler In-Memory-Ratelimiter pro IP" +``` + +--- + +## Task 7: POST /api/recipes/extract-from-photo + +**Files:** +- Create: `src/routes/api/recipes/extract-from-photo/+server.ts` +- Create: `src/routes/api/recipes/extract-from-photo/+server.test.ts` + +Verknüpft alle vorigen Module: Validierung → preprocess → Gemini → Phrase → Response. + +- [ ] **Step 1: Failing tests schreiben** + +`src/routes/api/recipes/extract-from-photo/+server.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import sharp from 'sharp'; + +// Mock den Gemini-Client bevor der Endpoint importiert wird. +const mockExtract = vi.fn(); +vi.mock('$lib/server/ai/gemini-client', () => ({ + extractRecipeFromImage: mockExtract, + GeminiError: class GeminiError extends Error { + constructor(public readonly code: string, message: string) { + super(message); + } + } +})); + +import { POST } from './+server'; +import { GeminiError } from '$lib/server/ai/gemini-client'; + +async function makeJpeg(): Promise { + return sharp({ create: { width: 100, height: 100, channels: 3, background: '#888' } }) + .jpeg() + .toBuffer(); +} + +function mkRequest(body: FormData, ip = '1.2.3.4'): Parameters[0] { + return { + request: new Request('http://test/api/recipes/extract-from-photo', { + method: 'POST', + body + }), + getClientAddress: () => ip + } as unknown as Parameters[0]; +} + +beforeEach(() => { + mockExtract.mockReset(); + process.env.GEMINI_API_KEY = 'test-key'; +}); + +const validAiResponse = { + title: 'Testrezept', + servings_default: 4, + servings_unit: 'Portionen', + prep_time_min: 10, + cook_time_min: 20, + total_time_min: null, + ingredients: [{ quantity: 1, unit: null, name: 'Apfel', note: null }], + steps: [{ text: 'Apfel schälen.' }] +}; + +describe('POST /api/recipes/extract-from-photo', () => { + it('happy path: 200 with recipe shape', async () => { + mockExtract.mockResolvedValueOnce(validAiResponse); + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }), 'x.jpg'); + const res = await POST(mkRequest(fd)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.recipe.title).toBe('Testrezept'); + expect(body.recipe.description).toMatch(/\w/); // random phrase, non-empty + expect(body.recipe.image_path).toBeNull(); + expect(body.recipe.ingredients[0].raw_text).toContain('Apfel'); + }); + + it('413 when file exceeds 8 MB', async () => { + const big = Buffer.alloc(9 * 1024 * 1024); + const fd = new FormData(); + fd.append('photo', new Blob([big], { type: 'image/jpeg' })); + const res = await POST(mkRequest(fd)); + expect(res.status).toBe(413); + expect((await res.json()).code).toBe('PAYLOAD_TOO_LARGE'); + }); + + it('415 when content-type not in whitelist', async () => { + const fd = new FormData(); + fd.append('photo', new Blob([Buffer.from('hi')], { type: 'text/plain' })); + const res = await POST(mkRequest(fd)); + expect(res.status).toBe(415); + expect((await res.json()).code).toBe('UNSUPPORTED_MEDIA_TYPE'); + }); + + it('400 when no photo field', async () => { + const fd = new FormData(); + const res = await POST(mkRequest(fd)); + expect(res.status).toBe(400); + }); + + it('422 NO_RECIPE_IN_IMAGE when 0 ingredients AND 0 steps', async () => { + mockExtract.mockResolvedValueOnce({ + ...validAiResponse, + ingredients: [], + steps: [] + }); + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + const res = await POST(mkRequest(fd)); + expect(res.status).toBe(422); + expect((await res.json()).code).toBe('NO_RECIPE_IN_IMAGE'); + }); + + it('503 AI_NOT_CONFIGURED when GEMINI_API_KEY empty', async () => { + process.env.GEMINI_API_KEY = ''; + mockExtract.mockRejectedValueOnce(new GeminiError('AI_NOT_CONFIGURED', 'no key')); + const fd = new FormData(); + fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + const res = await POST(mkRequest(fd)); + expect(res.status).toBe(503); + expect((await res.json()).code).toBe('AI_NOT_CONFIGURED'); + }); + + it('429 when rate limit exceeded for the same IP', async () => { + mockExtract.mockResolvedValue(validAiResponse); + const fd = () => { + const f = new FormData(); + f.append('photo', new Blob([Buffer.from('tiny')], { type: 'image/jpeg' })); + return f; + }; + // sharp wird für tiny-buffers werfen — aber rate-limit greift VOR preprocess, + // also füllen wir erst 10 OK-Calls auf, dann der 11. sollte 429 sein. + // Hack: wir erhöhen die rate-limit-Hürde nicht — wir prüfen nur, dass 429 zurückkommt. + for (let i = 0; i < 10; i++) { + const jpg = new FormData(); + jpg.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); + await POST(mkRequest(jpg, '9.9.9.9')); + } + const limitHit = await POST(mkRequest(fd(), '9.9.9.9')); + expect(limitHit.status).toBe(429); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts +``` + +Expected: FAIL — Endpoint existiert noch nicht. + +- [ ] **Step 3: Endpoint implementieren** + +`src/routes/api/recipes/extract-from-photo/+server.ts`: + +```ts +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { extractRecipeFromImage, GeminiError } from '$lib/server/ai/gemini-client'; +import { preprocessImage } from '$lib/server/ai/image-preprocess'; +import { pickRandomPhrase } from '$lib/server/ai/description-phrases'; +import { createRateLimiter } from '$lib/server/ai/rate-limit'; +import type { Ingredient, Step } from '$lib/types'; + +const MAX_BYTES = 8 * 1024 * 1024; +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', + 'image/heif' +]); + +// Globaler Singleton-Limiter. 10 Requests/Minute pro IP — verhindert versehentliche +// Kosten-Runaways (z.B. wenn ein Tester das Icon sturmklickt). +const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); + +function errJson(status: number, code: string, message: string) { + return json({ code, message }, { status }); +} + +function buildRawText(q: number | null, u: string | null, name: string): string { + const parts: string[] = []; + if (q !== null) parts.push(String(q).replace('.', ',')); + if (u) parts.push(u); + parts.push(name); + return parts.join(' '); +} + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + const ip = getClientAddress(); + if (!limiter.check(ip)) { + return errJson(429, 'RATE_LIMITED', 'Zu viele Anfragen — bitte einen Moment warten.'); + } + + let form: FormData; + try { + form = await request.formData(); + } catch { + return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); + } + const photo = form.get('photo'); + if (!(photo instanceof Blob)) { + return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.'); + } + if (photo.size > MAX_BYTES) { + return errJson(413, 'PAYLOAD_TOO_LARGE', `Foto zu groß (max ${MAX_BYTES / 1024 / 1024} MB).`); + } + if (!ALLOWED_MIME.has(photo.type)) { + return errJson(415, 'UNSUPPORTED_MEDIA_TYPE', `MIME "${photo.type}" nicht unterstützt.`); + } + + const rawBuffer = Buffer.from(await photo.arrayBuffer()); + let preprocessed: { buffer: Buffer; mimeType: 'image/jpeg' }; + try { + preprocessed = await preprocessImage(rawBuffer); + } catch (e) { + return errJson(415, 'UNSUPPORTED_MEDIA_TYPE', `Bild konnte nicht gelesen werden: ${(e as Error).message}`); + } + + const startedAt = Date.now(); + let extracted; + try { + extracted = await extractRecipeFromImage(preprocessed.buffer, preprocessed.mimeType); + } catch (e) { + if (e instanceof GeminiError) { + const status = + e.code === 'AI_RATE_LIMITED' ? 429 : + e.code === 'AI_TIMEOUT' ? 503 : + e.code === 'AI_NOT_CONFIGURED' ? 503 : + 503; + // Nur Code + Meta loggen, niemals den Prompt/Response-Inhalt. + console.warn(`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`); + return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.'); + } + console.warn(`[extract-from-photo] UNEXPECTED ${(e as Error).message}`); + return errJson(503, 'AI_FAILED', 'Die Bild-Analyse ist fehlgeschlagen.'); + } + + // Minimum-Gültigkeit: Titel + (mind. 1 Zutat ODER mind. 1 Schritt) + if (!extracted.title.trim() || + (extracted.ingredients.length === 0 && extracted.steps.length === 0)) { + return errJson(422, 'NO_RECIPE_IN_IMAGE', 'Ich konnte kein Rezept im Bild erkennen.'); + } + + const ingredients: Ingredient[] = extracted.ingredients.map((i, idx) => ({ + position: idx + 1, + quantity: i.quantity, + unit: i.unit, + name: i.name, + note: i.note, + raw_text: buildRawText(i.quantity, i.unit, i.name), + section_heading: null + })); + + const steps: Step[] = extracted.steps.map((s, idx) => ({ + position: idx + 1, + text: s.text + })); + + return json({ + recipe: { + id: null, + title: extracted.title, + description: pickRandomPhrase(), + source_url: null, + source_domain: null, + image_path: null, + servings_default: extracted.servings_default, + servings_unit: extracted.servings_unit, + prep_time_min: extracted.prep_time_min, + cook_time_min: extracted.cook_time_min, + total_time_min: extracted.total_time_min, + cuisine: null, + category: null, + ingredients, + steps, + tags: [] + } + }); +}; +``` + +- [ ] **Step 4: Tests laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts +``` + +Expected: PASS — alle 7 Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/api/recipes/extract-from-photo/ +git commit -m "feat(api): POST /api/recipes/extract-from-photo" +``` + +--- + +## Task 8: POST /api/recipes (neues Scratch-Insert) + +**Files:** +- Create: `src/routes/api/recipes/+server.ts` +- Create: `src/routes/api/recipes/+server.test.ts` + +Neuer Endpoint, der ein komplettes `Recipe`-Objekt als Body nimmt und via `insertRecipe` schreibt. Wird vom Save-Handler auf `/new/from-photo` aufgerufen. Der bestehende `/api/recipes/blank`-Endpoint bleibt für den klassischen „Leer anlegen"-Button unverändert. + +- [ ] **Step 1: Failing tests schreiben** + +`src/routes/api/recipes/+server.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '$lib/server/db/migrate'; +import { POST } from './+server'; + +// Hinweis: Wir brauchen eine reale DB. SvelteKit's getDb muss auf eine In-Memory +// DB umgeleitet werden — das Muster dafür ist in bestehenden API-Tests +// (z.B. src/routes/api/recipes/import/+server.test.ts) zu finden und wird +// bei Bedarf adaptiert. + +function mkReq(body: unknown): Parameters[0] { + return { + request: new Request('http://test/api/recipes', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }) + } as unknown as Parameters[0]; +} + +const validBody = { + title: 'Aus Foto erstellt', + description: 'Abrakadabra — Rezept da.', + servings_default: 4, + servings_unit: 'Portionen', + prep_time_min: 10, + cook_time_min: 20, + total_time_min: null, + ingredients: [ + { position: 1, quantity: 1, unit: null, name: 'Apfel', note: null, raw_text: '1 Apfel', section_heading: null } + ], + steps: [{ position: 1, text: 'Apfel schneiden.' }] +}; + +describe('POST /api/recipes', () => { + it('happy path returns 201 + id', async () => { + const res = await POST(mkReq(validBody)); + expect(res.status).toBe(201); + const body = await res.json(); + expect(typeof body.id).toBe('number'); + expect(body.id).toBeGreaterThan(0); + }); + + it('400 on empty title', async () => { + const res = await POST(mkReq({ ...validBody, title: '' })); + expect(res.status).toBe(400); + }); + + it('400 on missing ingredients array', async () => { + const { ingredients: _, ...bad } = validBody; + void _; + const res = await POST(mkReq(bad)); + expect(res.status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** + +```bash +npx vitest run src/routes/api/recipes/+server.test.ts +``` + +Expected: FAIL — Endpoint existiert noch nicht. + +- [ ] **Step 3: Endpoint implementieren** + +`src/routes/api/recipes/+server.ts`: + +```ts +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { z } from 'zod'; +import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; +import { insertRecipe } from '$lib/server/recipes/repository'; + +const IngredientSchema = z.object({ + position: z.number().int().nonnegative(), + quantity: z.number().nullable(), + unit: z.string().max(30).nullable(), + name: z.string().min(1).max(200), + note: z.string().max(300).nullable(), + raw_text: z.string().max(500), + section_heading: z.string().max(200).nullable() +}); + +const StepSchema = z.object({ + position: z.number().int().positive(), + text: z.string().min(1).max(4000) +}); + +const CreateRecipeSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).nullable(), + servings_default: z.number().int().nonnegative().nullable(), + servings_unit: z.string().max(30).nullable(), + prep_time_min: z.number().int().nonnegative().nullable(), + cook_time_min: z.number().int().nonnegative().nullable(), + total_time_min: z.number().int().nonnegative().nullable(), + ingredients: z.array(IngredientSchema), + steps: z.array(StepSchema) +}); + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + const p = validateBody(body, CreateRecipeSchema); + const id = insertRecipe(getDb(), { + id: null, + title: p.title, + description: p.description, + source_url: null, + source_domain: null, + image_path: null, + servings_default: p.servings_default, + servings_unit: p.servings_unit, + prep_time_min: p.prep_time_min, + cook_time_min: p.cook_time_min, + total_time_min: p.total_time_min, + cuisine: null, + category: null, + ingredients: p.ingredients, + steps: p.steps, + tags: [] + }); + return json({ id }, { status: 201 }); +}; +``` + +- [ ] **Step 4: Tests laufen lassen (erwartet PASS)** + +```bash +npx vitest run src/routes/api/recipes/+server.test.ts +``` + +Expected: PASS — alle 3 Tests grün. Falls das DB-Setup im Test nicht klappt: Muster aus bestehenden Tests wie `src/routes/api/recipes/import/+server.test.ts` übernehmen. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/api/recipes/+server.ts src/routes/api/recipes/+server.test.ts +git commit -m "feat(api): POST /api/recipes fuer Scratch-Insert aus Foto-Import" +``` + +--- + +## Task 9: RecipeEditor — Nullable-ID-Support + +**Files:** +- Modify: `src/lib/components/RecipeEditor.svelte:136-144` + +Minimal-Änderung: Bild-Block nur rendern wenn `recipe.id !== null`. Kein State-Management-Umbau nötig. + +- [ ] **Step 1: Bild-Block conditional wrappen** + +In `src/lib/components/RecipeEditor.svelte` den Bild-Block von: + +```svelte +
+

Bild

+ onimagechange?.(p)} + /> +
+``` + +ändern zu: + +```svelte + {#if recipe.id !== null} +
+

Bild

+ onimagechange?.(p)} + /> +
+ {:else} +
+

Bild kannst du nach dem Speichern hinzufügen.

+
+ {/if} +``` + +Im ` +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run check +``` + +Expected: PASS. Falls der `RecipeEditorSavePatch`-Typ nicht auflöst: direkt die Feld-Signatur aus `RecipeEditor.svelte:13-22` kopieren. + +- [ ] **Step 4: Quick Smoke-Test manuell** + +```bash +npm run dev +``` + +Navigiere zu `http://localhost:5173/new/from-photo`. Erwartet: 503-Fehlerseite, weil `GEMINI_API_KEY` leer ist (server-load-Gate greift). + +Mit Key: `GEMINI_API_KEY=x npm run dev`, dann sollte die Picker-Seite erscheinen. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/new/from-photo/ +git commit -m "feat(ui): /new/from-photo mit File-Picker, Lade- und Fehler-States" +``` + +- [ ] **Step 6: Service-Worker-Precache erweitern** + +Öffne `src/service-worker.ts` und finde das Pre-Cache-Manifest (suche nach `precache` oder `buildSWManifest`). In der Shell-Liste `'/new/from-photo'` ergänzen, analog zu `/` und `/preview`. + +```bash +npm run check && npx vitest run +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/service-worker.ts +git commit -m "feat(sw): /new/from-photo in Shell-Precache" +``` + +--- + +## Task 12: Camera-Icon im Header + +**Files:** +- Modify: `src/routes/+layout.svelte` +- Modify: `src/routes/+layout.server.ts` (prüfen, ggf. erweitern für `geminiConfigured`-Flag) + +Der Button wird nur gerendert, wenn der Server signalisiert, dass Gemini konfiguriert ist. Offline → disabled. + +- [ ] **Step 1: Flag aus Layout-Load exponieren** + +Öffne `src/routes/+layout.server.ts` (falls nicht vorhanden: neu anlegen): + +```ts +import type { LayoutServerLoad } from './$types'; +import { env } from '$env/dynamic/private'; + +export const load: LayoutServerLoad = async ({ parent }) => { + const parentData = await parent().catch(() => ({})); + return { + ...parentData, + geminiConfigured: Boolean(env.GEMINI_API_KEY) + }; +}; +``` + +Falls die Datei bereits existiert und andere Keys liefert: nur `geminiConfigured: Boolean(env.GEMINI_API_KEY)` in das bestehende Return-Objekt ergänzen. + +- [ ] **Step 2: Icon in den Header** + +In `src/routes/+layout.svelte` die Header-Zeile erweitern: der Import ergänzt `Camera` zu bestehenden lucide-svelte-Imports, und die Button-Reihe im Header bekommt einen neuen Eintrag. Konkrete Integration: + +```svelte + + + + +{#if data.geminiConfigured} + { if (!networkStore.online) e.preventDefault(); }} + > + + +{/if} +``` + +Im `