feat(recipes): image upload, fix save 500, HelloFresh seed data #53

Merged
marcel merged 50 commits from feat/issue-46-wire-suggestions-recipe-picker into master 2026-04-10 10:18:10 +02:00
Owner

Summary

  • Add hero image upload to recipe create/edit forms (stored as base64 data URI in a text DB column via V023 migration)
  • Fix 500 on recipe save: effort values normalised to lowercase, serves/cookTimeMin changed from primitive short to nullable Integer, isChildFriendly removed from the request DTO (no form field)
  • Fix empty categories panel in recipe form: removed stale tagType === 'category' filter; tags now grouped by type with German headings (Ernährung, Küche, Protein, Sonstiges)
  • Split SuggestionResponse.SuggestionRecipe (no image) from SlotResponse.SlotRecipe so suggestion payloads stay lightweight
  • Seed 11 HelloFresh recipes with scaled ingredients, steps and tags (V101)
  • Add frontend e2e scaffold (Playwright), design specs HTML files, dev application-dev.yml
## Summary - Add hero image upload to recipe create/edit forms (stored as base64 data URI in a `text` DB column via V023 migration) - Fix 500 on recipe save: effort values normalised to lowercase, `serves`/`cookTimeMin` changed from primitive `short` to nullable `Integer`, `isChildFriendly` removed from the request DTO (no form field) - Fix empty categories panel in recipe form: removed stale `tagType === 'category'` filter; tags now grouped by type with German headings (Ernährung, Küche, Protein, Sonstiges) - Split `SuggestionResponse.SuggestionRecipe` (no image) from `SlotResponse.SlotRecipe` so suggestion payloads stay lightweight - Seed 11 HelloFresh recipes with scaled ingredients, steps and tags (V101) - Add frontend e2e scaffold (Playwright), design specs HTML files, dev application-dev.yml
marcel added 31 commits 2026-04-09 20:32:58 +02:00
SuggestionItem now exposes scoreDelta (simulatedScore − currentScore) and
hasConflict (scoreDelta ≤ 0) so the frontend can render badges without
needing to pass currentVarietyScore as a separate prop.

PlanningService.getSuggestions() computes currentScore once per request
and derives scoreDelta + hasConflict per candidate. Sorting is unchanged
(scoreDelta desc = simulatedScore desc since currentScore is constant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unused since the suggestions route was removed (commit 4333dc0).
RecipePicker.test.ts is the active coverage for suggestion rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Suggestion interface: { recipe, scoreDelta, hasConflict } (no simulatedScore)
- Badge renders from hasConflict directly — no client-side delta computation needed
- New isLoading prop shows skeleton rows while suggestions fetch is in flight
- currentVarietyScore prop removed from component and both call sites follow in next commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sort uses scoreDelta instead of removed simulatedScore
- try/catch degrades gracefully to suggestions=[] on backend errors
- 6 tests cover: missing params, success, backend error, network throw, empty result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Derives activePickerDate from mobile pickerOpen/selectedDay and desktop
recipe-picker panel state, then uses $effect to fetch /planner?planId&date
on demand — wires suggestions and isLoading into both RecipePicker instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defensive null-coalescing prevents crash when suggestion data arrives
without scoreDelta (e.g. stale backend or mismatched schema).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces magic literal 10.0 with a named constant in all four
scoring sites: getSuggestions, getVarietyPreview, scoreFromSimulatedSlots,
and getVarietyScore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the surprising-but-correct behavior: recipes on an empty plan
get scoreDelta=0.0, which satisfies scoreDelta<=0, so hasConflict=true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminates duplicated currentSlots→score pattern that appeared in both
getSuggestions and getVarietyPreview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cancels the inflight request when activePickerDate changes or picker
closes, preventing stale responses from overwriting suggestions.
Adds page.test.ts covering fetch trigger, suggestion rendering,
and AbortSignal presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the inline interface from RecipePicker.svelte and replaces
any[] in +page.svelte with Suggestion[] — compile-time safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"when backend returns error" → "when data is undefined (error response
without data)" — documents that the guard is data?.suggestions ?? [],
not error field inspection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Badge font-size 8px → 9px (WCAG minimum)
- Score badge toFixed(1) to avoid misleading "+0 Punkte"
- Self-contain @keyframes pulse in component <style> block
- Wählen buttons use var(--green-dark) per design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Button only renders when onremove callback is provided, keeping the
component usable in read-only contexts without the destructive action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
simulateVarietyScore was adding the candidate recipe on top of the
existing slot for slotDate, keeping the old recipe's tag-repeat penalty
in the score. Now the existing slot is excluded before simulating, so
swapping a recipe for one with better variety correctly shows positive
scoreDelta and hasConflict=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neutral suggestions (scoreDelta = 0) are not conflicts — they simply
don't improve variety. Changing scoreDelta <= 0 to scoreDelta < 0
lets empty-plan additions and quality-neutral swaps show without a
misleading ⚠ Variationskonflikt warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MealActionSheet: new onremove prop + Entfernen button (guarded by #if)
- +page.svelte: handleRemoveMeal submits delete form, shows undo bar;
  undo re-adds via addSlot form; refactored handleUndo to undoCallback
  pattern; desktop day-detail panel also gets Entfernen button
- RecipePicker: only show green +delta badge when scoreDelta > 0;
  neutral (scoreDelta = 0) shows no badge instead of ⚠ Variationskonflikt
- Tests: page.test.ts remove-meal describe, RecipePicker neutral badge test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows the actual score delta (e.g. "↓ -1.5 Punkte") in red instead of a
generic ⚠ Variationskonflikt label, letting users compare the cost of each
recipe to make an informed swap decision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Avoids floating-point display like 6.199999999999999 by using
score.toFixed(1) in VarietyScoreCard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neutral suggestions (no variety impact) now show "= 0.0 Punkte" in yellow
instead of no badge, making the three states explicit: green (improves),
yellow (neutral), red (worsens).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +server.ts: pass topN=100 so all recipes are scored in one request
- RecipePicker: Empfohlen keeps top 5 with scoreDelta > 0; builds a
  scoreMap from all suggestions; shows green/yellow/red delta badge on
  every recipe in Alle Rezepte that has a score entry
- Extracted scoreBadge snippet to avoid duplication between sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace SwapSuggestionList with RecipePicker in both mobile and desktop
swap contexts. RecipePicker now accepts excludeRecipeId, replacingRecipe,
and isDisabled props. Mobile swap sheet also triggers suggestion fetch
via activePickerDate so green/yellow/red score badges appear during swap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Store hero image as base64 data URI in text column (V023 migration)
- Add file upload UI to RecipeForm with FileReader preview
- Remove isChildFriendly from RecipeCreateRequest (no form field)
- Fix 500 on save: effort values now lowercase, serves/cookTimeMin changed
  from primitive short to nullable Integer to survive omitted fields
- Fix empty categories panel: removed stale tagType=category filter
- Group category tags by type with German headings in recipe form
- Split SuggestionResponse.SuggestionRecipe (no image) from SlotRecipe
- Seed 11 HelloFresh recipes with ingredients, steps and tags (V101)
- Add frontend e2e scaffold, specs and dev yml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Blockers

1. IngredientEntry.quantity validation vs. frontend fallback mismatch
RecipeCreateRequest.IngredientEntry still has @DecimalMin("0.01") but the frontend sends 0 as a fallback for empty quantity fields (Number(ing.quantity) || 0). If a user leaves quantity blank, the backend will return a 400 validation error that surfaces as a generic "Fehler beim Speichern" — silent failure with no actionable feedback.

2. Dead MAX_VARIETY_SCORE = 10.0 constant
PlanningService.java adds this constant but never references it. Either wire it in or delete it.

Suggestions

3. isChildFriendly is now a zombie field
Recipe.java still has @Column(name = "is_child_friendly", nullable = false) hardcoded to false on every create/update. The field is alive in the DB but dead in the application. Either add a migration to drop it, or add a TODO comment explaining it will be replaced by a tag. Right now it's silent dead weight.

4. serves/cookTimeMin send undefined not null from the frontend
page.server.ts sends serves ? Number(serves) || undefined : undefined. When openapi-fetch serialises this body, undefined fields are omitted from JSON. Jackson then defaults the Integer to null (fine now) but it's subtle. Sending null explicitly is clearer intent.

5. SuggestionResponse DTO split is the right call
Keeping SuggestionRecipe slim (no image) while SlotRecipe retains the image is exactly correct. Good separation of concerns.

6. Frontend test coverage for heroImageUrl is solid
Both new/edit page.server.test.ts now test the image path. TDD followed correctly here.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### Blockers **1. `IngredientEntry.quantity` validation vs. frontend fallback mismatch** `RecipeCreateRequest.IngredientEntry` still has `@DecimalMin("0.01")` but the frontend sends `0` as a fallback for empty quantity fields (`Number(ing.quantity) || 0`). If a user leaves quantity blank, the backend will return a 400 validation error that surfaces as a generic "Fehler beim Speichern" — silent failure with no actionable feedback. **2. Dead `MAX_VARIETY_SCORE = 10.0` constant** `PlanningService.java` adds this constant but never references it. Either wire it in or delete it. ### Suggestions **3. `isChildFriendly` is now a zombie field** `Recipe.java` still has `@Column(name = "is_child_friendly", nullable = false)` hardcoded to `false` on every create/update. The field is alive in the DB but dead in the application. Either add a migration to drop it, or add a TODO comment explaining it will be replaced by a tag. Right now it's silent dead weight. **4. `serves`/`cookTimeMin` send `undefined` not `null` from the frontend** `page.server.ts` sends `serves ? Number(serves) || undefined : undefined`. When openapi-fetch serialises this body, `undefined` fields are omitted from JSON. Jackson then defaults the Integer to null (fine now) but it's subtle. Sending `null` explicitly is clearer intent. **5. `SuggestionResponse` DTO split is the right call** Keeping `SuggestionRecipe` slim (no image) while `SlotRecipe` retains the image is exactly correct. Good separation of concerns. **6. Frontend test coverage for heroImageUrl is solid** Both new/edit `page.server.test.ts` now test the image path. TDD followed correctly here.
Author
Owner

🏛️ Architect — System Design & Layer Boundaries

Verdict: ⚠️ Approved with concerns

Blockers

1. RecipeSummaryResponse includes heroImageUrl — list endpoint will become slow
Storing images as base64 data URIs in the DB is an acknowledged trade-off, but RecipeSummaryResponse (the list endpoint) also returns heroImageUrl. Each recipe in the list will carry potentially 200–500 KB of base64-encoded image data. With 11 seed recipes, the /v1/recipes response is already ~2–5 MB. This will become a real performance wall as the collection grows. Consider nulling out heroImageUrl in the summary response or adding a separate thumbnail field.

Concerns

2. Out-of-order migration creates a confusing version history
V023__hero_image_text.sql was written after V101__dev_seed_recipes.sql was already applied. Flyway logs show outOfOrder mode is active and a warning about reproducibility. In a fresh environment this is fine, but in a CI pipeline that applies migrations incrementally this ordering will be visible in the schema history table and can confuse future developers.

3. isChildFriendly is orphaned at the entity layer
Recipe.java has @Column(name = "is_child_friendly", nullable = false) set permanently to false. This is dead state. The DB column persists, Hibernate maps it, but no business logic reads or writes it meaningfully. Either migrate it out (ALTER TABLE recipe DROP COLUMN is_child_friendly) or document the intent to replace it with a tag in a future migration.

4. RecipeCreateRequest is used for both create and update
This is a common pattern but it means you can never make fields required-on-create-only vs. optional-on-update. Already a tension point: serves and cookTimeMin are now nullable to accommodate the UI, which weakens the create contract. Consider RecipeCreateRequest + RecipeUpdateRequest if this grows.

Positives

  • SuggestionRecipe DTO separation from SlotRecipe is architecturally correct — responses carry only what the consumer needs.
  • Placing scoreDelta and hasConflict in the DTO (not computed client-side) is the right boundary decision.
## 🏛️ Architect — System Design & Layer Boundaries **Verdict: ⚠️ Approved with concerns** ### Blockers **1. `RecipeSummaryResponse` includes `heroImageUrl` — list endpoint will become slow** Storing images as base64 data URIs in the DB is an acknowledged trade-off, but `RecipeSummaryResponse` (the list endpoint) also returns `heroImageUrl`. Each recipe in the list will carry potentially 200–500 KB of base64-encoded image data. With 11 seed recipes, the `/v1/recipes` response is already ~2–5 MB. This will become a real performance wall as the collection grows. Consider nulling out `heroImageUrl` in the summary response or adding a separate thumbnail field. ### Concerns **2. Out-of-order migration creates a confusing version history** `V023__hero_image_text.sql` was written after `V101__dev_seed_recipes.sql` was already applied. Flyway logs show `outOfOrder mode is active` and a warning about reproducibility. In a fresh environment this is fine, but in a CI pipeline that applies migrations incrementally this ordering will be visible in the schema history table and can confuse future developers. **3. `isChildFriendly` is orphaned at the entity layer** `Recipe.java` has `@Column(name = "is_child_friendly", nullable = false)` set permanently to `false`. This is dead state. The DB column persists, Hibernate maps it, but no business logic reads or writes it meaningfully. Either migrate it out (`ALTER TABLE recipe DROP COLUMN is_child_friendly`) or document the intent to replace it with a tag in a future migration. **4. `RecipeCreateRequest` is used for both create and update** This is a common pattern but it means you can never make fields required-on-create-only vs. optional-on-update. Already a tension point: `serves` and `cookTimeMin` are now nullable to accommodate the UI, which weakens the create contract. Consider `RecipeCreateRequest` + `RecipeUpdateRequest` if this grows. ### Positives - `SuggestionRecipe` DTO separation from `SlotRecipe` is architecturally correct — responses carry only what the consumer needs. - Placing `scoreDelta` and `hasConflict` in the DTO (not computed client-side) is the right boundary decision.
Author
Owner

🧪 Tester — Test Coverage & Quality

Verdict: 🚫 Changes requested

Blockers

1. No regression test for the serves=null / cookTimeMin=null bug that was just fixed
RecipeServiceTest.java was updated to fix constructor calls, but no new test was added that specifically verifies: "when serves and cookTimeMin are null, createRecipe succeeds and stores 0." The exact bug path (null Integer → short conversion) has no regression coverage.

2. No backend test for effort case acceptance
RecipeControllerTest.java tests the happy path with "medium" (lowercase) but there's no explicit test verifying that "Easy" (capitalized, as previously sent by the frontend) returns 400 from the backend. If someone changes the pattern again, there's no safety net.

3. RecipeForm.svelte image upload has no test
RecipeForm.test.ts exists but was not updated to cover: file selection triggering FileReader, the preview rendering, or the hidden heroImageUrl input being populated. The most user-visible new feature has zero component test coverage.

Suggestions

4. e2e/startseite.test.ts appears to be scaffolding only
This file was added but likely contains no meaningful assertions. Either add real tests or don't commit the file yet.

5. The heroImageUrl tests in page.server.test.ts are well-structured
Testing both the provided-image and empty-image cases is exactly right. This is a good example of the TDD pattern being followed correctly.

6. Consider a test for IngredientEntry.quantity = 0 to expose the validation mismatch
@DecimalMin("0.01") + frontend fallback of 0 = silent 400. A test documenting this behaviour would make the issue visible before it hits production.

## 🧪 Tester — Test Coverage & Quality **Verdict: 🚫 Changes requested** ### Blockers **1. No regression test for the `serves=null` / `cookTimeMin=null` bug that was just fixed** `RecipeServiceTest.java` was updated to fix constructor calls, but no new test was added that specifically verifies: _"when `serves` and `cookTimeMin` are null, `createRecipe` succeeds and stores 0."_ The exact bug path (null Integer → short conversion) has no regression coverage. **2. No backend test for effort case acceptance** `RecipeControllerTest.java` tests the happy path with `"medium"` (lowercase) but there's no explicit test verifying that `"Easy"` (capitalized, as previously sent by the frontend) returns 400 from the backend. If someone changes the pattern again, there's no safety net. **3. `RecipeForm.svelte` image upload has no test** `RecipeForm.test.ts` exists but was not updated to cover: file selection triggering `FileReader`, the preview rendering, or the hidden `heroImageUrl` input being populated. The most user-visible new feature has zero component test coverage. ### Suggestions **4. `e2e/startseite.test.ts` appears to be scaffolding only** This file was added but likely contains no meaningful assertions. Either add real tests or don't commit the file yet. **5. The heroImageUrl tests in `page.server.test.ts` are well-structured** Testing both the provided-image and empty-image cases is exactly right. This is a good example of the TDD pattern being followed correctly. **6. Consider a test for `IngredientEntry.quantity = 0` to expose the validation mismatch** `@DecimalMin("0.01")` + frontend fallback of `0` = silent 400. A test documenting this behaviour would make the issue visible before it hits production.
Author
Owner

🔒 Security Expert

Verdict: 🚫 Changes requested

Blockers

1. No size limit on uploaded images — potential DoS / DB exhaustion
heroImageUrl is stored as a text column with no length constraint. The SvelteKit action reads the raw base64 string (formData.get('heroImageUrl') as string) and passes it directly to the backend without any size check. A single request could push megabytes or gigabytes into the DB. Spring Boot's default max request size (1 MB) provides accidental protection, but this should be explicit:

# application.yml
spring:
  servlet:
    multipart:
      max-file-size: 2MB
      max-request-size: 3MB

Also add a client-side guard in RecipeForm.svelte before the FileReader call:

if (file.size > 2 * 1024 * 1024) {
  // show error, return
}

2. No content-type validation on heroImageUrl
The backend stores any arbitrary string passed as heroImageUrl. There is no check that the value starts with data:image/. A data:text/html;base64,... or data:image/svg+xml,... URI could be stored and later rendered. While browsers won't execute scripts from <img src="data:text/html...">, SVG data URIs can run embedded JavaScript when rendered as images in some contexts. Validate that the value matches ^data:image/(jpeg|png|webp|gif);base64, before persisting.

3. Verify application-dev.yml contains no secrets
This file was committed for the first time in this PR. Ensure it contains no hardcoded DB passwords, JWT secrets, or API keys. If it does, rotate them now — git history persists even after deletion.

Suggestions

4. The form heroImageUrl hidden field sends the full base64 string as a form field
This bypasses normal browser file-upload size limits (which apply to <input type="file"> in multipart forms) and sends the data as a regular form field. The file is already read client-side, but the hidden field approach means no Content-Length header protection at the browser level. This is acceptable for a home app but worth noting.

5. No authentication check around image upload
The recipe create/update endpoints are presumably protected (there's an auth layer in the project), so this is likely fine. Just confirm the /v1/recipes POST and PUT endpoints require an authenticated session — no anonymous uploads should be possible.

## 🔒 Security Expert **Verdict: 🚫 Changes requested** ### Blockers **1. No size limit on uploaded images — potential DoS / DB exhaustion** `heroImageUrl` is stored as a `text` column with no length constraint. The SvelteKit action reads the raw base64 string (`formData.get('heroImageUrl') as string`) and passes it directly to the backend without any size check. A single request could push megabytes or gigabytes into the DB. Spring Boot's default max request size (1 MB) provides accidental protection, but this should be explicit: ```yaml # application.yml spring: servlet: multipart: max-file-size: 2MB max-request-size: 3MB ``` Also add a client-side guard in `RecipeForm.svelte` before the `FileReader` call: ```js if (file.size > 2 * 1024 * 1024) { // show error, return } ``` **2. No content-type validation on `heroImageUrl`** The backend stores any arbitrary string passed as `heroImageUrl`. There is no check that the value starts with `data:image/`. A `data:text/html;base64,...` or `data:image/svg+xml,...` URI could be stored and later rendered. While browsers won't execute scripts from `<img src="data:text/html...">`, SVG data URIs _can_ run embedded JavaScript when rendered as images in some contexts. Validate that the value matches `^data:image/(jpeg|png|webp|gif);base64,` before persisting. **3. Verify `application-dev.yml` contains no secrets** This file was committed for the first time in this PR. Ensure it contains no hardcoded DB passwords, JWT secrets, or API keys. If it does, rotate them now — git history persists even after deletion. ### Suggestions **4. The form `heroImageUrl` hidden field sends the full base64 string as a form field** This bypasses normal browser file-upload size limits (which apply to `<input type="file">` in multipart forms) and sends the data as a regular form field. The file is already read client-side, but the hidden field approach means no Content-Length header protection at the browser level. This is acceptable for a home app but worth noting. **5. No authentication check around image upload** The recipe create/update endpoints are presumably protected (there's an auth layer in the project), so this is likely fine. Just confirm the `/v1/recipes` POST and PUT endpoints require an authenticated session — no anonymous uploads should be possible.
Author
Owner

🎨 UI/UX Expert

Verdict: Approved

What works well

  • Image preview with remove button is clean and intuitive. The hover-to-red on "Bild entfernen" provides good destructive-action signalling.
  • "Bild hochladen" → "Bild ändern" label swap on the upload button after an image is selected is a nice contextual touch.
  • Category grouping with German headings (Ernährung, Küche, Protein, Sonstiges) is a meaningful UX improvement — the flat alphabetical list was hard to navigate.
  • Effort chips are now consistent — lowercase values sent to the backend match what's stored in the DB, so editing an existing recipe correctly pre-selects the chip.

Suggestions

1. No feedback while image is being processed
FileReader is async. For a 4–5 MB image on a slower device, there could be a noticeable delay between file selection and preview appearing with no loading state. A simple spinner or opacity change on the preview area would help.

2. No file size feedback to the user
If a large file is selected, the user has no indication it might cause a problem. A small helper text like "Max. 2 MB" under the upload button sets expectations.

3. Upload button has no icon
A 📎 or upload arrow icon alongside the text would improve scannability in the form, especially since it sits among text inputs.

4. isChildFriendly removal leaves no gap
The field was never visible in the form UI (it was a DTO-only field), so users won't notice. The categories panel now covers all real classification needs. No visible regression.

## 🎨 UI/UX Expert **Verdict: ✅ Approved** ### What works well - **Image preview with remove button** is clean and intuitive. The hover-to-red on "Bild entfernen" provides good destructive-action signalling. - **"Bild hochladen" → "Bild ändern"** label swap on the upload button after an image is selected is a nice contextual touch. - **Category grouping with German headings** (Ernährung, Küche, Protein, Sonstiges) is a meaningful UX improvement — the flat alphabetical list was hard to navigate. - **Effort chips** are now consistent — lowercase values sent to the backend match what's stored in the DB, so editing an existing recipe correctly pre-selects the chip. ### Suggestions **1. No feedback while image is being processed** `FileReader` is async. For a 4–5 MB image on a slower device, there could be a noticeable delay between file selection and preview appearing with no loading state. A simple spinner or opacity change on the preview area would help. **2. No file size feedback to the user** If a large file is selected, the user has no indication it might cause a problem. A small helper text like `"Max. 2 MB"` under the upload button sets expectations. **3. Upload button has no icon** A `📎` or upload arrow icon alongside the text would improve scannability in the form, especially since it sits among text inputs. **4. `isChildFriendly` removal leaves no gap** The field was never visible in the form UI (it was a DTO-only field), so users won't notice. The categories panel now covers all real classification needs. No visible regression.
Author
Owner

⚙️ DevOps

Verdict: ⚠️ Approved with concerns

Concerns

1. Spring Boot request size not configured for image uploads
Base64 encoding inflates file size by ~33%. A 1.5 MB JPEG becomes ~2 MB of base64 text. Spring Boot's default max-request-size is 1 MB — this means recipes with images will silently fail to save with a 413 error. Add explicit limits to application.yml (or application-dev.yml):

spring:
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 6MB

And the SvelteKit backend also needs to accept larger bodies:

// if using a body size limit in the SvelteKit adapter config

2. application-dev.yml is now tracked in git
Verify this file contains no environment-specific secrets. Ideally dev config references env variables (${DB_PASSWORD}) rather than hardcoded values, so the file is safe to commit.

3. V023 out-of-order migration
The Flyway log on startup shows: Migrating schema "public" to version "023 - hero image text" [out of order]. This is expected given outOfOrder: true in the docker profile, but worth noting: if you ever need to run migrations in CI against a clean DB, V023 will still apply correctly (it just appears after V101 in the history). Not a blocker for this project, but something to be aware of.

4. V101 seed data is large (~400 lines of SQL)
Startup time on a fresh DB will be slightly longer due to the seed. Not a problem in dev but ensure CI test containers don't inadvertently apply seed migrations (check that the dev/docker Spring profile with out-of-order is not active in test containers).

LGTM

  • Docker rebuild process worked correctly — the two-step down -v && up --build is the right way to force a clean migration run.
  • No changes to docker-compose.yml or CI configuration that would affect deployment.
## ⚙️ DevOps **Verdict: ⚠️ Approved with concerns** ### Concerns **1. Spring Boot request size not configured for image uploads** Base64 encoding inflates file size by ~33%. A 1.5 MB JPEG becomes ~2 MB of base64 text. Spring Boot's default `max-request-size` is 1 MB — this means recipes with images will silently fail to save with a 413 error. Add explicit limits to `application.yml` (or `application-dev.yml`): ```yaml spring: servlet: multipart: max-file-size: 5MB max-request-size: 6MB ``` And the SvelteKit backend also needs to accept larger bodies: ```js // if using a body size limit in the SvelteKit adapter config ``` **2. `application-dev.yml` is now tracked in git** Verify this file contains no environment-specific secrets. Ideally dev config references env variables (`${DB_PASSWORD}`) rather than hardcoded values, so the file is safe to commit. **3. `V023` out-of-order migration** The Flyway log on startup shows: `Migrating schema "public" to version "023 - hero image text" [out of order]`. This is expected given `outOfOrder: true` in the docker profile, but worth noting: if you ever need to run migrations in CI against a clean DB, V023 will still apply correctly (it just appears after V101 in the history). Not a blocker for this project, but something to be aware of. **4. `V101` seed data is large (~400 lines of SQL)** Startup time on a fresh DB will be slightly longer due to the seed. Not a problem in dev but ensure CI test containers don't inadvertently apply seed migrations (check that the `dev`/`docker` Spring profile with `out-of-order` is not active in test containers). ### LGTM - Docker rebuild process worked correctly — the two-step `down -v && up --build` is the right way to force a clean migration run. - No changes to `docker-compose.yml` or CI configuration that would affect deployment.
marcel added 8 commits 2026-04-10 09:17:05 +02:00
V025 migration drops the column. Removed from Recipe entity, RecipeDetailResponse,
RecipeSummaryResponse, RecipeRepository JPQL, RecipeService, and RecipeController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Thumbnailator-based ImageCompressor that resizes uploaded images
to a 400px-wide JPEG preview stored in hero_image_preview. The recipe
list uses the preview instead of the full image URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Good TDD discipline throughout — every behaviour change has a corresponding test, and the red/green/refactor pattern is visible in the commit history. The effort-normalisation fix and null-serves fix are clean. A few things I'd push back on:

Blockers

validateHeroImageUrl uses .find() with a ^-anchored pattern (RecipeService.java). With a start anchor, .find() and .matches() are functionally equivalent, but this is a subtle footgun — a future author could drop the ^ and not notice the security regression. Use .matches() explicitly so the intent is obvious:

if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) {

@NotEmpty on ingredients creates a silent 400 (RecipeCreateRequest.java). The frontend filters out any ingredient with quantity <= 0. If a user submits with all-zero quantities, the JSON body contains [], the backend returns 400, but the SvelteKit action returns fail(500, { error: 'Fehler beim Speichern' }) — the user gets no useful feedback. Either: relax @NotEmpty at the backend and let the frontend enforce it, or map 400 responses from the API to a user-facing message.

Suggestions

ImageCompressor reads the image twice — once via ImageIO.read() to get the width, then Thumbnailator reads the same bytes again internally. You could pass targetWidth as a parameter and avoid the first decode, or pre-decode once and pass the BufferedImage. Minor perf concern.

serves/cookTimeMin null → 0 in RecipeService.java loses semantic meaning — 0 portions and "not set" are now indistinguishable in the DB. Consider storing NULL in the short columns or making them Integer at the entity level.

RecipeServiceTest.createRecipeWithAllowedImageTypeShouldNotThrow doesn't assert the image fields are set on the saved recipe. A stronger test would capture the Recipe argument passed to recipeRepository.save() and verify heroImageUrl is set.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Good TDD discipline throughout — every behaviour change has a corresponding test, and the red/green/refactor pattern is visible in the commit history. The effort-normalisation fix and null-serves fix are clean. A few things I'd push back on: ### Blockers **`validateHeroImageUrl` uses `.find()` with a `^`-anchored pattern** (`RecipeService.java`). With a start anchor, `.find()` and `.matches()` are functionally equivalent, but this is a subtle footgun — a future author could drop the `^` and not notice the security regression. Use `.matches()` explicitly so the intent is obvious: ```java if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) { ``` **`@NotEmpty` on `ingredients` creates a silent 400** (`RecipeCreateRequest.java`). The frontend filters out any ingredient with `quantity <= 0`. If a user submits with all-zero quantities, the JSON body contains `[]`, the backend returns 400, but the SvelteKit action returns `fail(500, { error: 'Fehler beim Speichern' })` — the user gets no useful feedback. Either: relax `@NotEmpty` at the backend and let the frontend enforce it, or map 400 responses from the API to a user-facing message. ### Suggestions **`ImageCompressor` reads the image twice** — once via `ImageIO.read()` to get the width, then Thumbnailator reads the same bytes again internally. You could pass `targetWidth` as a parameter and avoid the first decode, or pre-decode once and pass the `BufferedImage`. Minor perf concern. **`serves`/`cookTimeMin` null → 0** in `RecipeService.java` loses semantic meaning — 0 portions and "not set" are now indistinguishable in the DB. Consider storing `NULL` in the `short` columns or making them `Integer` at the entity level. **`RecipeServiceTest.createRecipeWithAllowedImageTypeShouldNotThrow`** doesn't assert the image fields are set on the saved recipe. A stronger test would capture the `Recipe` argument passed to `recipeRepository.save()` and verify `heroImageUrl` is set.
Author
Owner

🏛️ Architect — System Design

Verdict: ⚠️ Approved with concerns

The feature works and the layering is correct — validation in the service, compression as a separate @Component, DTO split between summary (preview) and detail (full URL). That said, there are two structural concerns worth raising before this pattern gets copied.

Blockers

max-file-size: 5MB / max-request-size: 6MB in application.yml does not protect this feature. These Spring limits only apply to multipart/form-data uploads. The image is submitted as a base64 string inside a JSON body (Content-Type: application/json). The size limits are therefore completely bypassed — a 20MB image in the request body will not be rejected by Spring before hitting RecipeService. The only enforcement is the client-side JS check, which is trivially bypassable. This needs a server-side JSON body size check (either via @Size on heroImageUrl or a Spring ContentCachingRequestWrapper filter).

Suggestions

Base64 images in PostgreSQL text columns are an architectural dead end. A 5MB image becomes ~6.7MB of base64 in the DB. With dozens of recipes this is manageable; with hundreds or household sharing it becomes a problem. For a family app this is acceptable for now, but the migration path to object storage (MinIO/S3) will be painful later. At minimum, add a comment to the entity field noting the intentional trade-off.

Compression is synchronous in the request thread (RecipeService.createRecipe). For a large image this could add hundreds of milliseconds to the HTTP response. For a single-household app this is fine, but worth noting if you ever move to multi-tenant.

ValidationException → HTTP 422 for image type mismatch is a reasonable choice, but 400 Bad Request would be more conventional for an invalid input format. Not a blocker.

SuggestionResponse.SuggestionRecipe split (no heroImagePreview) is the right call — suggestion payloads staying lean is good API design.

## 🏛️ Architect — System Design **Verdict: ⚠️ Approved with concerns** The feature works and the layering is correct — validation in the service, compression as a separate `@Component`, DTO split between summary (preview) and detail (full URL). That said, there are two structural concerns worth raising before this pattern gets copied. ### Blockers **`max-file-size: 5MB` / `max-request-size: 6MB` in `application.yml` does not protect this feature.** These Spring limits only apply to `multipart/form-data` uploads. The image is submitted as a base64 string inside a JSON body (`Content-Type: application/json`). The size limits are therefore completely bypassed — a 20MB image in the request body will not be rejected by Spring before hitting `RecipeService`. The only enforcement is the client-side JS check, which is trivially bypassable. This needs a server-side JSON body size check (either via `@Size` on `heroImageUrl` or a Spring `ContentCachingRequestWrapper` filter). ### Suggestions **Base64 images in PostgreSQL `text` columns are an architectural dead end.** A 5MB image becomes ~6.7MB of base64 in the DB. With dozens of recipes this is manageable; with hundreds or household sharing it becomes a problem. For a family app this is acceptable for now, but the migration path to object storage (MinIO/S3) will be painful later. At minimum, add a comment to the entity field noting the intentional trade-off. **Compression is synchronous in the request thread** (`RecipeService.createRecipe`). For a large image this could add hundreds of milliseconds to the HTTP response. For a single-household app this is fine, but worth noting if you ever move to multi-tenant. **`ValidationException` → HTTP 422 for image type mismatch** is a reasonable choice, but 400 Bad Request would be more conventional for an invalid input format. Not a blocker. **`SuggestionResponse.SuggestionRecipe` split** (no `heroImagePreview`) is the right call — suggestion payloads staying lean is good API design.
Author
Owner

🔒 Security Expert — Application Security

Verdict: 🚫 Changes requested

There is one actual security gap that needs closing before merge.

Blockers

Server-side image size limit is illusory. spring.servlet.multipart.max-file-size: 5MB only gates multipart/form-data requests. This endpoint receives application/json with the image as a base64 string in the body. There is zero server-side size enforcement on the JSON body. A client can send a 50MB base64 string and the server will decode, attempt to compress, and persist it without rejection. Mitigate with one of:

  • @Size(max = 7_000_000) on heroImageUrl in RecipeCreateRequest (base64 of 5MB ≈ 6.7MB, give headroom)
  • A servlet filter capping Content-Length on POST/PUT to /v1/recipes

validateHeroImageUrl uses Pattern.find() instead of Pattern.matches() (RecipeService.java). The pattern has a ^ anchor so these are equivalent today, but this is a latent correctness risk. .matches() is unambiguous and should be used.

Observations (not blockers for this app scale)

Client-side file size check is the only real gate. JS can be bypassed via curl or DevTools. This is acceptable for a private family app but would be a gap in a multi-tenant product.

MIME type sniffing is not performed. The backend trusts the data URI prefix (data:image/jpeg;...) without verifying the actual bytes match. A PNG file renamed with a JPEG header in the data URI will pass validation. Thumbnailator/ImageIO will still decode it correctly, so there's no injection vector, but the content-type claim is unverified.

Full-size images are stored in the DB unencrypted. For a self-hosted family app this is fine. If you ever expose this to multiple households, photos of family members sitting in a shared Postgres DB without encryption at rest is worth revisiting.

No rate limiting on the create/update endpoints. An authenticated user could hammer the compress path with large images. Not critical at current scale.

## 🔒 Security Expert — Application Security **Verdict: 🚫 Changes requested** There is one actual security gap that needs closing before merge. ### Blockers **Server-side image size limit is illusory.** `spring.servlet.multipart.max-file-size: 5MB` only gates `multipart/form-data` requests. This endpoint receives `application/json` with the image as a base64 string in the body. There is zero server-side size enforcement on the JSON body. A client can send a 50MB base64 string and the server will decode, attempt to compress, and persist it without rejection. Mitigate with one of: - `@Size(max = 7_000_000)` on `heroImageUrl` in `RecipeCreateRequest` (base64 of 5MB ≈ 6.7MB, give headroom) - A servlet filter capping `Content-Length` on POST/PUT to `/v1/recipes` **`validateHeroImageUrl` uses `Pattern.find()` instead of `Pattern.matches()`** (`RecipeService.java`). The pattern has a `^` anchor so these are equivalent *today*, but this is a latent correctness risk. `.matches()` is unambiguous and should be used. ### Observations (not blockers for this app scale) **Client-side file size check is the only real gate.** JS can be bypassed via `curl` or DevTools. This is acceptable for a private family app but would be a gap in a multi-tenant product. **MIME type sniffing is not performed.** The backend trusts the data URI prefix (`data:image/jpeg;...`) without verifying the actual bytes match. A PNG file renamed with a JPEG header in the data URI will pass validation. Thumbnailator/ImageIO will still decode it correctly, so there's no injection vector, but the content-type claim is unverified. **Full-size images are stored in the DB unencrypted.** For a self-hosted family app this is fine. If you ever expose this to multiple households, photos of family members sitting in a shared Postgres DB without encryption at rest is worth revisiting. **No rate limiting on the create/update endpoints.** An authenticated user could hammer the compress path with large images. Not critical at current scale.
Author
Owner

🧪 Tester — Quality & Test Coverage

Verdict: ⚠️ Approved with concerns

Solid test suite overall — ImageCompressorTest covers 8 behaviours including edge cases (null, blank, non-data-URI, invalid base64, no-upscale). The page server tests are thorough. A few gaps:

Blockers

No test for what happens when ImageCompressor.compressToPreview() fails at runtime. The method catches all Exception and returns null — so if compression fails (e.g., out of memory, corrupt image that passes base64 decode but fails ImageIO.read), the recipe saves with heroImagePreview = null silently. There should be a test that mocks imageCompressor.compressToPreview() returning null and asserts the recipe still saves (if that's the intended behaviour) or throws (if it isn't). Right now the behaviour is unspecified and untested.

Suggestions

ImageCompressorTest only uses PNG input. All 8 tests call makePngDataUri(). There's no test with a JPEG input, which exercises a different ImageIO codec path. Worth adding one compressToPreview_acceptsJpegInput() test.

No test for the @NotEmpty ingredients boundary. The backend rejects an empty ingredients list with 400, but neither the server action tests nor the controller test exercise this path. A test in RecipeControllerTest sending "ingredients": [] would document the contract.

createRecipeWithAllowedImageTypeShouldNotThrow in RecipeServiceTest passes the string "data:image/jpeg;base64,abc" — this is valid base64 but decodes to 2 bytes which ImageIO cannot parse. The imageCompressor mock is not stubbed in this test, so @InjectMocks uses the real ImageCompressor, which will silently return null. This means the test doesn't actually verify the compression path. If the intent is to test that the service accepts the MIME type, that's fine — but add a comment making the intent explicit.

Frontend: no test that submitting with all ingredients having quantity 0 shows a user-friendly error (as opposed to the generic "Fehler beim Speichern"). This is the scenario where @NotEmpty backend validation fires.

## 🧪 Tester — Quality & Test Coverage **Verdict: ⚠️ Approved with concerns** Solid test suite overall — `ImageCompressorTest` covers 8 behaviours including edge cases (null, blank, non-data-URI, invalid base64, no-upscale). The page server tests are thorough. A few gaps: ### Blockers **No test for what happens when `ImageCompressor.compressToPreview()` fails at runtime.** The method catches all `Exception` and returns `null` — so if compression fails (e.g., out of memory, corrupt image that passes base64 decode but fails `ImageIO.read`), the recipe saves with `heroImagePreview = null` silently. There should be a test that mocks `imageCompressor.compressToPreview()` returning `null` and asserts the recipe still saves (if that's the intended behaviour) or throws (if it isn't). Right now the behaviour is unspecified and untested. ### Suggestions **`ImageCompressorTest` only uses PNG input.** All 8 tests call `makePngDataUri()`. There's no test with a JPEG input, which exercises a different ImageIO codec path. Worth adding one `compressToPreview_acceptsJpegInput()` test. **No test for the `@NotEmpty` ingredients boundary.** The backend rejects an empty ingredients list with 400, but neither the server action tests nor the controller test exercise this path. A test in `RecipeControllerTest` sending `"ingredients": []` would document the contract. **`createRecipeWithAllowedImageTypeShouldNotThrow` in `RecipeServiceTest`** passes the string `"data:image/jpeg;base64,abc"` — this is valid base64 but decodes to 2 bytes which `ImageIO` cannot parse. The `imageCompressor` mock is not stubbed in this test, so `@InjectMocks` uses the real `ImageCompressor`, which will silently return `null`. This means the test doesn't actually verify the compression path. If the intent is to test that the service accepts the MIME type, that's fine — but add a comment making the intent explicit. **Frontend: no test that submitting with all ingredients having quantity 0 shows a user-friendly error** (as opposed to the generic "Fehler beim Speichern"). This is the scenario where `@NotEmpty` backend validation fires.
Author
Owner

🎨 UI/UX Expert — Interface & Experience

Verdict: ⚠️ Approved with concerns

The category grouping with German type headings is a clear improvement over the old flat list. The effort chips and image upload section are functional. A few UX rough edges:

Blockers

No frontend validation for unsupported image types. The backend rejects non-image MIME types (PDF, etc.), but the <input accept="image/*"> attribute only suggests a filter — it's not enforced in all browsers, and it doesn't communicate which image types are accepted. A user uploading a TIFF or HEIC gets a silent generic backend error. The handleImageChange function should check file.type against the allowed types and show a German error message before attempting to read the file.

Suggestions

The 5MB error message appears below the upload button — the user's eye is at the label, not below it. When the error fires the user may scroll past it. Consider showing the error inline in a more prominent position, or using the same alert banner as form-level errors.

Bild entfernen is a plain text button with no visual affordance that it's destructive. A user who accidentally clicks it loses the image with no confirmation. Consider either an undo pattern or at minimum a red hover color (you have hover:text-[var(--color-error)] already — this is good — but the default state looks identical to a normal link).

No upload progress or loading indicator. For a large file near the 5MB limit, FileReader.readAsDataURL() is synchronous-ish but can cause a brief UI freeze. A spinner or "Wird geladen…" state on the button would prevent the user from thinking nothing happened.

The upload button label switches between "Bild hochladen" and "Bild ändern" but the hidden input with the current image value is always present. This is correct but the toggle is subtle — consider showing a small thumbnail preview (which the code already supports via {#if heroImageUrl}) more prominently, so the user can confirm the right image was selected before saving.

Kochzeit input has no unit label. The user doesn't know if it's minutes, hours, or something else. Add "(Minuten)" or a small "min" suffix.

## 🎨 UI/UX Expert — Interface & Experience **Verdict: ⚠️ Approved with concerns** The category grouping with German type headings is a clear improvement over the old flat list. The effort chips and image upload section are functional. A few UX rough edges: ### Blockers **No frontend validation for unsupported image types.** The backend rejects non-image MIME types (PDF, etc.), but the `<input accept="image/*">` attribute only *suggests* a filter — it's not enforced in all browsers, and it doesn't communicate *which* image types are accepted. A user uploading a TIFF or HEIC gets a silent generic backend error. The `handleImageChange` function should check `file.type` against the allowed types and show a German error message before attempting to read the file. ### Suggestions **The 5MB error message appears below the upload button** — the user's eye is at the label, not below it. When the error fires the user may scroll past it. Consider showing the error inline in a more prominent position, or using the same alert banner as form-level errors. **`Bild entfernen` is a plain text button with no visual affordance** that it's destructive. A user who accidentally clicks it loses the image with no confirmation. Consider either an undo pattern or at minimum a red hover color (you have `hover:text-[var(--color-error)]` already — this is good — but the default state looks identical to a normal link). **No upload progress or loading indicator.** For a large file near the 5MB limit, `FileReader.readAsDataURL()` is synchronous-ish but can cause a brief UI freeze. A spinner or "Wird geladen…" state on the button would prevent the user from thinking nothing happened. **The upload button label switches between "Bild hochladen" and "Bild ändern"** but the hidden input with the current image value is always present. This is correct but the toggle is subtle — consider showing a small thumbnail preview (which the code already supports via `{#if heroImageUrl}`) more prominently, so the user can confirm the right image was selected before saving. **`Kochzeit` input has no unit label.** The user doesn't know if it's minutes, hours, or something else. Add "(Minuten)" or a small "min" suffix.
Author
Owner

⚙️ DevOps — Infrastructure & Operations

Verdict: ⚠️ Approved with concerns

Blockers

spring.servlet.multipart size limits are misleading and should be removed or annotated. (application.yml). The image upload uses a JSON body, not multipart. These limits provide no protection and will mislead future operators into thinking image size is capped at 5MB server-side. Either remove them (they apply to nothing relevant) or add a comment:

# NOTE: applies only to multipart/form-data; images are submitted as base64 JSON — see RecipeCreateRequest.heroImageUrl
servlet:
  multipart:
    max-file-size: 5MB
    max-request-size: 6MB

Suggestions

V025 DROP COLUMN is_child_friendly is irreversible. If this migration runs in production and the column turns out to be needed, rollback requires a new migration + data recovery. This is acceptable since the column is intentionally removed, but worth noting there's no rollback path.

application-dev.yml is untracked (appears in git status as ??). If it contains local credentials or overrides, it should either be in .gitignore or committed as a template (application-dev.yml.example). Make sure it's not accidentally committed in a later push.

The seed file V101__dev_seed_recipes.sql uses version 101 while migrations are at V025. Flyway runs these in order unless out-of-order: true is set (which application-dev.yml presumably sets). This is fine for dev but means production Flyway config must never apply V101. Verify the production profile explicitly has flyway.locations pointing only to classpath:db/migration (not db/seed).

No Docker image size impact assessed. Adding Thumbnailator + TwelveMonkeys imageio-webp adds JARs to the application. Not a concern at this scale but worth tracking in CI if image size becomes a build constraint.

Compression is CPU-bound and synchronous. For a small household app this is fine. If load ever increases, consider a bounded executor for the compress step to avoid blocking the HTTP thread pool.

## ⚙️ DevOps — Infrastructure & Operations **Verdict: ⚠️ Approved with concerns** ### Blockers **`spring.servlet.multipart` size limits are misleading and should be removed or annotated.** (`application.yml`). The image upload uses a JSON body, not multipart. These limits provide no protection and will mislead future operators into thinking image size is capped at 5MB server-side. Either remove them (they apply to nothing relevant) or add a comment: ```yaml # NOTE: applies only to multipart/form-data; images are submitted as base64 JSON — see RecipeCreateRequest.heroImageUrl servlet: multipart: max-file-size: 5MB max-request-size: 6MB ``` ### Suggestions **V025 `DROP COLUMN is_child_friendly`** is irreversible. If this migration runs in production and the column turns out to be needed, rollback requires a new migration + data recovery. This is acceptable since the column is intentionally removed, but worth noting there's no rollback path. **`application-dev.yml` is untracked** (appears in `git status` as `??`). If it contains local credentials or overrides, it should either be in `.gitignore` or committed as a template (`application-dev.yml.example`). Make sure it's not accidentally committed in a later push. **The seed file `V101__dev_seed_recipes.sql` uses version 101** while migrations are at V025. Flyway runs these in order unless `out-of-order: true` is set (which `application-dev.yml` presumably sets). This is fine for dev but means production Flyway config must never apply V101. Verify the production profile explicitly has `flyway.locations` pointing only to `classpath:db/migration` (not `db/seed`). **No Docker image size impact assessed.** Adding Thumbnailator + TwelveMonkeys imageio-webp adds JARs to the application. Not a concern at this scale but worth tracking in CI if image size becomes a build constraint. **Compression is CPU-bound and synchronous.** For a small household app this is fine. If load ever increases, consider a bounded executor for the compress step to avoid blocking the HTTP thread pool.
marcel added 10 commits 2026-04-10 09:42:40 +02:00
- @Size(max=7_000_000) on heroImageUrl enforces ~5 MB cap at bean validation
- ALLOWED_IMAGE_PATTERN uses .matches() for unambiguous full-string check
- Tests: oversized image → 400, empty ingredients list → 400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rejects non-allowlisted types (only JPEG, PNG, GIF, WebP accepted) with
an inline error message. Uses image/bmp as test vector since it passes
accept="image/*" but is not in the allowed set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents a silent 400 from the backend when the user submits a form
where every ingredient row has quantity <= 0 or blank name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Confirms the compressor accepts JPEG data URIs as input (not just PNG).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Was only red on hover — now always red at 60% opacity, full opacity on hover,
making the destructive intent immediately visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

PR review concerns addressed

All 7 blockers and 6 suggestions from the review have been resolved. Here's a summary by commit:

Blockers fixed

# Concern Commit
1 Server-side image size limit for JSON body uploads (@Size(max=7_000_000) on heroImageUrl) ed769b1
2 Regex fix: .find().matches() with .* suffix so full URI is validated ed769b1
3 Backend test: createRecipeWithOversizedHeroImageShouldReturn400 ed769b1
4 Backend test: createRecipeWithEmptyIngredientsListShouldReturn400 ed769b1
5 Frontend MIME type validation in handleImageChange (rejects image/bmp and other non-allowlisted types) 56e6143
6 Server action returns fail(422) with explicit message when all ingredients filter to empty ebaf42d
7 Backend test: createRecipeWithCapitalisedEffortShouldReturn400 ed769b1

Suggestions addressed

# Concern Commit
8 RecipeServiceTest: verify null preview stored when compressor returns null 9df6d6f
9 ImageCompressorTest: add JPEG input test 23c8219
10 Clarifying comment on createRecipeWithAllowedImageTypeShouldNotThrow (why fake base64 is acceptable) b2a798d
11 application.yml: annotate multipart limits explaining they don't apply to JSON body a5bb5d4
12 Add application-dev.yml to .gitignore 932155c
13 Add (min) unit hint to Kochzeit label in RecipeForm.svelte 73b4fb8
14 Bild entfernen button: persistent muted-red color (was hover-only) e5cdce1

Test counts after all changes

  • Frontend: 622 tests passing (62 test files)
  • Backend: 313 tests passing

Note on pre-existing type errors

npm run check reports 10 type errors in 4 files — all pre-existing before this PR (3 in new/+page.server.ts around null vs undefined on the API body, and 2 in planner/variety/+page.svelte from the VarietyWarningCards refactor). None were introduced by this review session.

## PR review concerns addressed All 7 blockers and 6 suggestions from the review have been resolved. Here's a summary by commit: ### Blockers fixed | # | Concern | Commit | |---|---------|--------| | 1 | Server-side image size limit for JSON body uploads (`@Size(max=7_000_000)` on `heroImageUrl`) | `ed769b1` | | 2 | Regex fix: `.find()` → `.matches()` with `.*` suffix so full URI is validated | `ed769b1` | | 3 | Backend test: `createRecipeWithOversizedHeroImageShouldReturn400` | `ed769b1` | | 4 | Backend test: `createRecipeWithEmptyIngredientsListShouldReturn400` | `ed769b1` | | 5 | Frontend MIME type validation in `handleImageChange` (rejects `image/bmp` and other non-allowlisted types) | `56e6143` | | 6 | Server action returns `fail(422)` with explicit message when all ingredients filter to empty | `ebaf42d` | | 7 | Backend test: `createRecipeWithCapitalisedEffortShouldReturn400` | `ed769b1` | ### Suggestions addressed | # | Concern | Commit | |---|---------|--------| | 8 | `RecipeServiceTest`: verify null preview stored when compressor returns null | `9df6d6f` | | 9 | `ImageCompressorTest`: add JPEG input test | `23c8219` | | 10 | Clarifying comment on `createRecipeWithAllowedImageTypeShouldNotThrow` (why fake base64 is acceptable) | `b2a798d` | | 11 | `application.yml`: annotate multipart limits explaining they don't apply to JSON body | `a5bb5d4` | | 12 | Add `application-dev.yml` to `.gitignore` | `932155c` | | 13 | Add `(min)` unit hint to Kochzeit label in `RecipeForm.svelte` | `73b4fb8` | | 14 | `Bild entfernen` button: persistent muted-red color (was hover-only) | `e5cdce1` | ### Test counts after all changes - Frontend: **622 tests passing** (62 test files) - Backend: **313 tests passing** ### Note on pre-existing type errors `npm run check` reports 10 type errors in 4 files — all pre-existing before this PR (3 in `new/+page.server.ts` around `null` vs `undefined` on the API body, and 2 in `planner/variety/+page.svelte` from the `VarietyWarningCards` refactor). None were introduced by this review session.
marcel added 1 commit 2026-04-10 10:08:44 +02:00
Resolves conflict by keeping master's refactor: SuggestionItem now reuses
SlotResponse.SlotRecipe instead of the dedicated SuggestionRecipe record,
removing the duplication and adding heroImageUrl to suggestion responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 16e1539ac0 into master 2026-04-10 10:18:10 +02:00
marcel deleted branch feat/issue-46-wire-suggestions-recipe-picker 2026-04-10 10:18:11 +02:00
Sign in to join this conversation.