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

- 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>
This commit is contained in:
2026-04-09 20:23:28 +02:00
parent 116e400a91
commit 520dae5adf
34 changed files with 9862 additions and 84 deletions

View File

@@ -153,7 +153,7 @@ public class PlanningService {
plan, candidate, slotDate, config, recentlyCookedIds);
double scoreDelta = simulatedScore - currentScore;
boolean hasConflict = scoreDelta < 0;
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
return new SuggestionResponse.SuggestionItem(toSuggestionRecipe(candidate), scoreDelta, hasConflict);
})
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
.limit(limit)
@@ -422,6 +422,11 @@ public class PlanningService {
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
}
private SuggestionResponse.SuggestionRecipe toSuggestionRecipe(Recipe recipe) {
return new SuggestionResponse.SuggestionRecipe(recipe.getId(), recipe.getName(),
recipe.getEffort(), recipe.getCookTimeMin());
}
private boolean hasConsecutiveDays(List<LocalDate> days) {
if (days.size() < 2) return false;
List<LocalDate> sorted = days.stream().sorted().toList();

View File

@@ -1,11 +1,14 @@
package com.recipeapp.planning.dto;
import java.util.List;
import java.util.UUID;
public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionRecipe(UUID id, String name, String effort, short cookTimeMin) {}
public record SuggestionItem(
SlotResponse.SlotRecipe recipe,
SuggestionRecipe recipe,
double scoreDelta,
boolean hasConflict
) {}

View File

@@ -60,8 +60,10 @@ public class RecipeService {
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
Recipe recipe = new Recipe(household, request.name(), request.serves(),
request.cookTimeMin(), request.effort(), request.isChildFriendly());
Recipe recipe = new Recipe(household, request.name(),
request.serves() != null ? request.serves().shortValue() : 0,
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
request.effort(), false);
recipe.setHeroImageUrl(request.heroImageUrl());
addIngredients(recipe, household, request.ingredients());
@@ -78,10 +80,9 @@ public class RecipeService {
Household household = recipe.getHousehold();
recipe.setName(request.name());
recipe.setServes(request.serves());
recipe.setCookTimeMin(request.cookTimeMin());
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
recipe.setEffort(request.effort());
recipe.setChildFriendly(request.isChildFriendly());
recipe.setHeroImageUrl(request.heroImageUrl());
recipe.getIngredients().clear();

View File

@@ -6,13 +6,13 @@ import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
public record RecipeCreateRequest(
@NotBlank @Size(max = 200) String name,
@Min(1) @Max(20) short serves,
@Min(0) short cookTimeMin,
Integer serves,
Integer cookTimeMin,
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
boolean isChildFriendly,
@Size(max = 500) String heroImageUrl,
String heroImageUrl,
@NotEmpty @Valid List<IngredientEntry> ingredients,
@Valid List<StepEntry> steps,
@NotEmpty List<UUID> tagIds

View File

@@ -36,7 +36,7 @@ public class Recipe {
@Column(name = "is_child_friendly", nullable = false)
private boolean isChildFriendly;
@Column(name = "hero_image_url", length = 500)
@Column(name = "hero_image_url", columnDefinition = "text")
private String heroImageUrl;
@Column(name = "deleted_at")