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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateWeekPlanRequest(@NotNull LocalDate weekStart) {}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.UUID;
|
||||
|
||||
public record UpdateSlotRequest(@NotNull UUID recipeId) {}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user