Implement Recipe, Planning, Shopping, Pantry, and Admin domains

Outside-in TDD for all 5 remaining domains (128 tests total):
- Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests)
- Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests)
- Shopping: generate from plan, publish, check/add/remove items (15 tests)
- Pantry: CRUD with expiry sorting (11 tests)
- Admin: user management, password reset, audit logging (13 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:56:51 +02:00
parent 4f457303d8
commit 9ec703abcd
88 changed files with 5267 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
package com.recipeapp.planning;
import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/v1/cooking-logs")
public class CookingLogController {
private final PlanningService planningService;
private final HouseholdResolver householdResolver;
public CookingLogController(PlanningService planningService, HouseholdResolver householdResolver) {
this.planningService = planningService;
this.householdResolver = householdResolver;
}
@PostMapping
public ResponseEntity<CookingLogResponse> createCookingLog(
Principal principal,
@Valid @RequestBody CreateCookingLogRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
UUID userId = householdResolver.resolveUserId(principal.getName());
CookingLogResponse response = planningService.createCookingLog(householdId, userId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
public List<CookingLogResponse> listCookingLogs(
Principal principal,
@RequestParam(defaultValue = "30") int limit,
@RequestParam(defaultValue = "0") int offset) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.listCookingLogs(householdId, limit, offset);
}
}

View File

@@ -0,0 +1,14 @@
package com.recipeapp.planning;
import com.recipeapp.planning.entity.CookingLog;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public interface CookingLogRepository extends JpaRepository<CookingLog, UUID> {
List<CookingLog> findByHouseholdIdOrderByCookedOnDesc(UUID householdId, Pageable pageable);
List<CookingLog> findByHouseholdIdAndCookedOnAfter(UUID householdId, LocalDate after);
}

View File

@@ -0,0 +1,29 @@
package com.recipeapp.planning;
import com.recipeapp.planning.dto.*;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public interface PlanningService {
WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart);
WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart);
SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request);
SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request);
void deleteSlot(UUID householdId, UUID planId, UUID slotId);
WeekPlanResponse confirmPlan(UUID householdId, UUID planId);
SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate);
VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId);
CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request);
List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset);
}

View File

@@ -0,0 +1,333 @@
package com.recipeapp.planning;
import com.recipeapp.auth.UserAccountRepository;
import com.recipeapp.auth.entity.UserAccount;
import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household;
import com.recipeapp.planning.dto.*;
import com.recipeapp.planning.entity.*;
import com.recipeapp.recipe.RecipeRepository;
import com.recipeapp.recipe.entity.Recipe;
import com.recipeapp.recipe.entity.RecipeIngredient;
import com.recipeapp.recipe.entity.Tag;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class PlanningServiceImpl implements PlanningService {
private final WeekPlanRepository weekPlanRepository;
private final WeekPlanSlotRepository weekPlanSlotRepository;
private final CookingLogRepository cookingLogRepository;
private final RecipeRepository recipeRepository;
private final HouseholdRepository householdRepository;
private final UserAccountRepository userAccountRepository;
public PlanningServiceImpl(WeekPlanRepository weekPlanRepository,
WeekPlanSlotRepository weekPlanSlotRepository,
CookingLogRepository cookingLogRepository,
RecipeRepository recipeRepository,
HouseholdRepository householdRepository,
UserAccountRepository userAccountRepository) {
this.weekPlanRepository = weekPlanRepository;
this.weekPlanSlotRepository = weekPlanSlotRepository;
this.cookingLogRepository = cookingLogRepository;
this.recipeRepository = recipeRepository;
this.householdRepository = householdRepository;
this.userAccountRepository = userAccountRepository;
}
@Override
@Transactional(readOnly = true)
public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) {
WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart)
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
return toWeekPlanResponse(plan);
}
@Override
@Transactional
public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) {
if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) {
throw new ValidationException("weekStart must be a Monday");
}
if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) {
throw new ConflictException("Week plan already exists for this week");
}
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart));
return toWeekPlanResponse(plan);
}
@Override
@Transactional
public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) {
WeekPlan plan = findPlan(planId, householdId);
Recipe recipe = findRecipe(request.recipeId(), householdId);
WeekPlanSlot slot = weekPlanSlotRepository.save(
new WeekPlanSlot(plan, recipe, request.slotDate()));
return toSlotResponse(slot);
}
@Override
@Transactional
public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) {
findPlan(planId, householdId);
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
Recipe recipe = findRecipe(request.recipeId(), householdId);
slot.setRecipe(recipe);
return toSlotResponse(slot);
}
@Override
@Transactional
public void deleteSlot(UUID householdId, UUID planId, UUID slotId) {
findPlan(planId, householdId);
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
weekPlanSlotRepository.delete(slot);
}
@Override
@Transactional
public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) {
WeekPlan plan = findPlan(planId, householdId);
if ("confirmed".equals(plan.getStatus())) {
throw new ValidationException("Plan is already confirmed");
}
if (plan.getSlots().isEmpty()) {
throw new ValidationException("Plan has no slots");
}
plan.setStatus("confirmed");
plan.setConfirmedAt(Instant.now());
return toWeekPlanResponse(plan);
}
@Override
@Transactional(readOnly = true)
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate) {
WeekPlan plan = findPlan(planId, householdId);
// Collect recipes already in this plan
Set<UUID> usedRecipeIds = plan.getSlots().stream()
.map(s -> s.getRecipe().getId())
.collect(Collectors.toSet());
// Collect proteins used in adjacent days
Set<String> adjacentProteins = new HashSet<>();
for (WeekPlanSlot slot : plan.getSlots()) {
if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) {
for (Tag tag : slot.getRecipe().getTags()) {
if ("protein".equals(tag.getTagType())) {
adjacentProteins.add(tag.getName().toLowerCase());
}
}
}
}
// Collect ingredients used in adjacent days
Set<UUID> adjacentIngredientIds = new HashSet<>();
for (WeekPlanSlot slot : plan.getSlots()) {
if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
adjacentIngredientIds.add(ri.getIngredient().getId());
}
}
}
// Recent cooking logs (last 14 days)
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
householdId, slotDate.minusDays(14));
Set<UUID> recentlyCookedIds = recentLogs.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
// Count effort levels in plan
Map<String, Long> effortCounts = plan.getSlots().stream()
.collect(Collectors.groupingBy(s -> s.getRecipe().getEffort(), Collectors.counting()));
// Get all household recipes, score and pick top 5
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
.filter(r -> !usedRecipeIds.contains(r.getId()))
.map(recipe -> {
List<String> fitReasons = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (!recentlyCookedIds.contains(recipe.getId())) {
fitReasons.add("not_cooked_recently");
}
boolean hasProteinRepeat = recipe.getTags().stream()
.filter(t -> "protein".equals(t.getTagType()))
.anyMatch(t -> adjacentProteins.contains(t.getName().toLowerCase()));
if (!hasProteinRepeat) {
fitReasons.add("no_protein_repeat");
}
String effort = recipe.getEffort();
long currentCount = effortCounts.getOrDefault(effort, 0L);
if (currentCount < 3) {
fitReasons.add("effort_balance");
}
boolean sharesIngredient = recipe.getIngredients().stream()
.anyMatch(ri -> adjacentIngredientIds.contains(ri.getIngredient().getId()));
if (sharesIngredient) {
warnings.add("shares_ingredient_with_adjacent_day");
}
return new SuggestionResponse.SuggestionItem(
toSlotRecipe(recipe), fitReasons, warnings);
})
.sorted((a, b) -> b.fitReasons().size() - a.fitReasons().size())
.limit(5)
.toList();
return new SuggestionResponse(suggestions);
}
@Override
@Transactional(readOnly = true)
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
WeekPlan plan = findPlan(planId, householdId);
List<WeekPlanSlot> slots = plan.getSlots();
if (slots.isEmpty()) {
return new VarietyScoreResponse(0, List.of(), List.of(), Map.of());
}
// Effort balance
Map<String, Integer> effortBalance = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
effortBalance.merge(slot.getRecipe().getEffort(), 1, Integer::sum);
}
// Ingredient overlaps (same ingredient on consecutive days)
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.getSlotDate());
}
}
List<VarietyScoreResponse.IngredientOverlap> overlaps = ingredientDays.entrySet().stream()
.filter(e -> hasConsecutiveDays(e.getValue()))
.map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue()))
.toList();
// Protein repeats (same protein on consecutive days)
Map<String, List<LocalDate>> proteinDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (Tag tag : slot.getRecipe().getTags()) {
if ("protein".equals(tag.getTagType())) {
proteinDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.getSlotDate());
}
}
}
List<String> proteinRepeats = proteinDays.entrySet().stream()
.filter(e -> hasConsecutiveDays(e.getValue()))
.map(Map.Entry::getKey)
.toList();
// Score: start at 10, deduct for issues
double score = 10.0;
score -= overlaps.size() * 0.5;
score -= proteinRepeats.size() * 1.0;
// Deduct for effort imbalance
int maxEffort = effortBalance.values().stream().mapToInt(Integer::intValue).max().orElse(0);
int minEffort = effortBalance.values().stream().mapToInt(Integer::intValue).min().orElse(0);
if (maxEffort - minEffort > 2) {
score -= 1.0;
}
score = Math.max(0, Math.min(10, score));
return new VarietyScoreResponse(score, overlaps, proteinRepeats, effortBalance);
}
@Override
@Transactional
public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) {
Recipe recipe = recipeRepository.findById(request.recipeId())
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
UserAccount user = userAccountRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now();
CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user));
return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(),
log.getCookedOn(), user.getId());
}
@Override
@Transactional(readOnly = true)
public List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset) {
return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(
householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1)))
.stream()
.map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(),
cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId()))
.toList();
}
// ── Helpers ──
private WeekPlan findPlan(UUID planId, UUID householdId) {
WeekPlan plan = weekPlanRepository.findById(planId)
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
if (!plan.getHousehold().getId().equals(householdId)) {
throw new ResourceNotFoundException("Week plan not found");
}
return plan;
}
private Recipe findRecipe(UUID recipeId, UUID householdId) {
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
}
private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) {
List<SlotResponse> slots = plan.getSlots().stream()
.map(this::toSlotResponse)
.toList();
return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(),
plan.getConfirmedAt(), slots);
}
private SlotResponse toSlotResponse(WeekPlanSlot slot) {
return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe()));
}
private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) {
return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(),
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
}
private boolean hasConsecutiveDays(List<LocalDate> days) {
if (days.size() < 2) return false;
List<LocalDate> sorted = days.stream().sorted().toList();
for (int i = 1; i < sorted.size(); i++) {
if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,91 @@
package com.recipeapp.planning;
import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.UUID;
@RestController
@RequestMapping("/v1/week-plans")
public class WeekPlanController {
private final PlanningService planningService;
private final HouseholdResolver householdResolver;
public WeekPlanController(PlanningService planningService, HouseholdResolver householdResolver) {
this.planningService = planningService;
this.householdResolver = householdResolver;
}
@GetMapping
public WeekPlanResponse getWeekPlan(Principal principal, @RequestParam LocalDate weekStart) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getWeekPlan(householdId, weekStart);
}
@PostMapping
public ResponseEntity<WeekPlanResponse> createWeekPlan(
Principal principal,
@Valid @RequestBody CreateWeekPlanRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
WeekPlanResponse response = planningService.createWeekPlan(householdId, request.weekStart());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/{id}/slots")
public ResponseEntity<SlotResponse> addSlot(
Principal principal,
@PathVariable UUID id,
@Valid @RequestBody CreateSlotRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
SlotResponse response = planningService.addSlot(householdId, id, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PatchMapping("/{planId}/slots/{slotId}")
public SlotResponse updateSlot(
Principal principal,
@PathVariable UUID planId,
@PathVariable UUID slotId,
@Valid @RequestBody UpdateSlotRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.updateSlot(householdId, planId, slotId, request);
}
@DeleteMapping("/{planId}/slots/{slotId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteSlot(
Principal principal,
@PathVariable UUID planId,
@PathVariable UUID slotId) {
UUID householdId = householdResolver.resolve(principal.getName());
planningService.deleteSlot(householdId, planId, slotId);
}
@PostMapping("/{id}/confirm")
public WeekPlanResponse confirmPlan(Principal principal, @PathVariable UUID id) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.confirmPlan(householdId, id);
}
@GetMapping("/{id}/suggestions")
public SuggestionResponse getSuggestions(
Principal principal,
@PathVariable UUID id,
@RequestParam LocalDate slotDate) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getSuggestions(householdId, id, slotDate);
}
@GetMapping("/{id}/variety-score")
public VarietyScoreResponse getVarietyScore(Principal principal, @PathVariable UUID id) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getVarietyScore(householdId, id);
}
}

View File

@@ -0,0 +1,13 @@
package com.recipeapp.planning;
import com.recipeapp.planning.entity.WeekPlan;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
public interface WeekPlanRepository extends JpaRepository<WeekPlan, UUID> {
Optional<WeekPlan> findByHouseholdIdAndWeekStart(UUID householdId, LocalDate weekStart);
boolean existsByHouseholdIdAndWeekStart(UUID householdId, LocalDate weekStart);
}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.planning;
import com.recipeapp.planning.entity.WeekPlanSlot;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface WeekPlanSlotRepository extends JpaRepository<WeekPlanSlot, UUID> {
}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.planning.dto;
import java.time.LocalDate;
import java.util.UUID;
public record CookingLogResponse(UUID id, UUID recipeId, String recipeName, LocalDate cookedOn, UUID cookedBy) {}

View File

@@ -0,0 +1,7 @@
package com.recipeapp.planning.dto;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.UUID;
public record CreateCookingLogRequest(@NotNull UUID recipeId, LocalDate cookedOn) {}

View File

@@ -0,0 +1,7 @@
package com.recipeapp.planning.dto;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.UUID;
public record CreateSlotRequest(@NotNull LocalDate slotDate, @NotNull UUID recipeId) {}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.planning.dto;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record CreateWeekPlanRequest(@NotNull LocalDate weekStart) {}

View File

@@ -0,0 +1,12 @@
package com.recipeapp.planning.dto;
import java.time.LocalDate;
import java.util.UUID;
public record SlotResponse(
UUID id,
LocalDate slotDate,
SlotRecipe recipe
) {
public record SlotRecipe(UUID id, String name, String effort, short cookTimeMin, String heroImageUrl) {}
}

View File

@@ -0,0 +1,12 @@
package com.recipeapp.planning.dto;
import java.util.List;
public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionItem(
SlotResponse.SlotRecipe recipe,
List<String> fitReasons,
List<String> warnings
) {}
}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.planning.dto;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
public record UpdateSlotRequest(@NotNull UUID recipeId) {}

View File

@@ -0,0 +1,14 @@
package com.recipeapp.planning.dto;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public record VarietyScoreResponse(
double score,
List<IngredientOverlap> ingredientOverlaps,
List<String> proteinRepeats,
Map<String, Integer> effortBalance
) {
public record IngredientOverlap(String ingredientName, List<LocalDate> days) {}
}

View File

@@ -0,0 +1,14 @@
package com.recipeapp.planning.dto;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public record WeekPlanResponse(
UUID id,
LocalDate weekStart,
String status,
Instant confirmedAt,
List<SlotResponse> slots
) {}

View File

@@ -0,0 +1,47 @@
package com.recipeapp.planning.entity;
import com.recipeapp.auth.entity.UserAccount;
import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.entity.Recipe;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "cooking_log")
public class CookingLog {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id", nullable = false)
private Recipe recipe;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(name = "cooked_on", nullable = false)
private LocalDate cookedOn;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cooked_by", nullable = false)
private UserAccount cookedBy;
protected CookingLog() {}
public CookingLog(Recipe recipe, Household household, LocalDate cookedOn, UserAccount cookedBy) {
this.recipe = recipe;
this.household = household;
this.cookedOn = cookedOn;
this.cookedBy = cookedBy;
}
public UUID getId() { return id; }
public Recipe getRecipe() { return recipe; }
public Household getHousehold() { return household; }
public LocalDate getCookedOn() { return cookedOn; }
public UserAccount getCookedBy() { return cookedBy; }
}

View File

@@ -0,0 +1,50 @@
package com.recipeapp.planning.entity;
import com.recipeapp.household.entity.Household;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "week_plan")
public class WeekPlan {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(name = "week_start", nullable = false)
private LocalDate weekStart;
@Column(nullable = false, length = 10)
private String status = "draft";
@Column(name = "confirmed_at")
private Instant confirmedAt;
@OneToMany(mappedBy = "weekPlan", cascade = CascadeType.ALL, orphanRemoval = true)
private List<WeekPlanSlot> slots = new ArrayList<>();
protected WeekPlan() {}
public WeekPlan(Household household, LocalDate weekStart) {
this.household = household;
this.weekStart = weekStart;
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public LocalDate getWeekStart() { return weekStart; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getConfirmedAt() { return confirmedAt; }
public void setConfirmedAt(Instant confirmedAt) { this.confirmedAt = confirmedAt; }
public List<WeekPlanSlot> getSlots() { return slots; }
}

View File

@@ -0,0 +1,40 @@
package com.recipeapp.planning.entity;
import com.recipeapp.recipe.entity.Recipe;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "week_plan_slot")
public class WeekPlanSlot {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "week_plan_id", nullable = false)
private WeekPlan weekPlan;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id", nullable = false)
private Recipe recipe;
@Column(name = "slot_date", nullable = false)
private LocalDate slotDate;
protected WeekPlanSlot() {}
public WeekPlanSlot(WeekPlan weekPlan, Recipe recipe, LocalDate slotDate) {
this.weekPlan = weekPlan;
this.recipe = recipe;
this.slotDate = slotDate;
}
public UUID getId() { return id; }
public WeekPlan getWeekPlan() { return weekPlan; }
public Recipe getRecipe() { return recipe; }
public void setRecipe(Recipe recipe) { this.recipe = recipe; }
public LocalDate getSlotDate() { return slotDate; }
}