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,17 @@
package com.recipeapp.admin;
import com.recipeapp.admin.entity.AdminAuditLog;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface AdminAuditLogRepository extends JpaRepository<AdminAuditLog, UUID> {
List<AdminAuditLog> findByTargetUserIdOrderByPerformedAtDesc(UUID targetUserId, Pageable pageable);
List<AdminAuditLog> findAllByOrderByPerformedAtDesc(Pageable pageable);
long countByTargetUserId(UUID targetUserId);
}

View File

@@ -0,0 +1,73 @@
package com.recipeapp.admin;
import com.recipeapp.admin.dto.*;
import com.recipeapp.common.ApiResponse;
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/admin")
public class AdminController {
private final AdminService adminService;
public AdminController(AdminService adminService) {
this.adminService = adminService;
}
@GetMapping("/users")
public ResponseEntity<ApiResponse<List<AdminUserResponse>>> listUsers(
@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(required = false) String search,
@RequestParam(required = false) Boolean isActive,
Principal principal) {
var result = adminService.listUsers(search, isActive, limit, offset);
var pagination = new ApiResponse.Pagination(result.total(), limit, offset,
offset + limit < result.total());
var meta = new ApiResponse.Meta(pagination);
return ResponseEntity.ok(ApiResponse.success(result.users(), meta));
}
@PostMapping("/users")
public ResponseEntity<ApiResponse<AdminUserResponse>> createUser(
@Valid @RequestBody CreateUserRequest request,
Principal principal) {
var response = adminService.createUser(request, principal.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}
@PatchMapping("/users/{id}")
public ResponseEntity<ApiResponse<AdminUserResponse>> updateUser(
@PathVariable UUID id,
@RequestBody UpdateUserRequest request,
Principal principal) {
var response = adminService.updateUser(id, request, principal.getName());
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/users/{id}/reset-password")
public ResponseEntity<ApiResponse<ResetPasswordResponse>> resetPassword(
@PathVariable UUID id,
@Valid @RequestBody ResetPasswordRequest request,
Principal principal) {
var response = adminService.resetPassword(id, request, principal.getName());
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping("/audit-log")
public ResponseEntity<ApiResponse<List<AuditLogResponse>>> listAuditLog(
@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(required = false) UUID targetUserId,
Principal principal) {
var logs = adminService.listAuditLog(targetUserId, limit, offset);
return ResponseEntity.ok(ApiResponse.success(logs));
}
}

View File

@@ -0,0 +1,21 @@
package com.recipeapp.admin;
import com.recipeapp.admin.dto.*;
import java.util.List;
import java.util.UUID;
public interface AdminService {
ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset);
AdminUserResponse createUser(CreateUserRequest request, String adminEmail);
AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail);
ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail);
List<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset);
record ListUsersResult(List<AdminUserResponse> users, long total) {}
}

View File

@@ -0,0 +1,166 @@
package com.recipeapp.admin;
import com.recipeapp.admin.dto.*;
import com.recipeapp.admin.entity.AdminAuditLog;
import com.recipeapp.auth.UserAccountRepository;
import com.recipeapp.auth.entity.UserAccount;
import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@Transactional
public class AdminServiceImpl implements AdminService {
private final UserAccountRepository userAccountRepository;
private final AdminAuditLogRepository auditLogRepository;
private final AdminUserQueryRepository adminUserQueryRepository;
private final PasswordEncoder passwordEncoder;
public AdminServiceImpl(UserAccountRepository userAccountRepository,
AdminAuditLogRepository auditLogRepository,
AdminUserQueryRepository adminUserQueryRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.auditLogRepository = auditLogRepository;
this.adminUserQueryRepository = adminUserQueryRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
@Transactional(readOnly = true)
public ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset) {
Pageable pageable = PageRequest.of(offset / limit, limit);
var users = adminUserQueryRepository.findUsersFiltered(search, isActive, pageable);
long total = adminUserQueryRepository.countUsersFiltered(search, isActive);
var responses = users.stream().map(this::toAdminUserResponse).toList();
return new ListUsersResult(responses, total);
}
@Override
public AdminUserResponse createUser(CreateUserRequest request, String adminEmail) {
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
throw new ConflictException("A user with this email already exists");
}
var admin = resolveAdmin(adminEmail);
String hashedPassword = passwordEncoder.encode(request.tempPassword());
var user = new UserAccount(request.email(), request.displayName(), hashedPassword);
if (request.systemRole() != null) {
user.setSystemRole(request.systemRole());
}
user = userAccountRepository.save(user);
auditLogRepository.save(new AdminAuditLog(
admin.getId(), user.getId(), "create_account",
Map.of("email", request.email(), "displayName", request.displayName()), null));
return toAdminUserResponse(user);
}
@Override
public AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail) {
var admin = resolveAdmin(adminEmail);
var user = userAccountRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
List<String> actions = new ArrayList<>();
Map<String, Object> detail = new HashMap<>();
if (request.displayName() != null) {
detail.put("displayName", request.displayName());
user.setDisplayName(request.displayName());
actions.add("update_account");
}
if (request.email() != null) {
if (!request.email().equalsIgnoreCase(user.getEmail())
&& userAccountRepository.existsByEmailIgnoreCase(request.email())) {
throw new ConflictException("A user with this email already exists");
}
detail.put("email", request.email());
user.setEmail(request.email());
actions.add("update_account");
}
if (request.systemRole() != null && !request.systemRole().equals(user.getSystemRole())) {
detail.put("systemRole", request.systemRole());
detail.put("previousSystemRole", user.getSystemRole());
user.setSystemRole(request.systemRole());
actions.add("change_system_role");
}
if (request.isActive() != null && request.isActive() != user.isActive()) {
detail.put("isActive", request.isActive());
user.setActive(request.isActive());
actions.add(request.isActive() ? "reactivate_account" : "deactivate_account");
}
user = userAccountRepository.save(user);
String action = actions.isEmpty() ? "update_account" : actions.getLast();
auditLogRepository.save(new AdminAuditLog(
admin.getId(), user.getId(), action, detail, null));
return toAdminUserResponse(user);
}
@Override
public ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail) {
var admin = resolveAdmin(adminEmail);
var user = userAccountRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
user.setPasswordHash(passwordEncoder.encode(request.tempPassword()));
userAccountRepository.save(user);
Map<String, Object> detail = new HashMap<>();
if (request.reason() != null) {
detail.put("reason", request.reason());
}
auditLogRepository.save(new AdminAuditLog(
admin.getId(), user.getId(), "reset_password", detail, null));
return new ResetPasswordResponse("Password reset successfully", true);
}
@Override
@Transactional(readOnly = true)
public List<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset) {
Pageable pageable = PageRequest.of(offset / limit, limit);
List<AdminAuditLog> logs;
if (targetUserId != null) {
logs = auditLogRepository.findByTargetUserIdOrderByPerformedAtDesc(targetUserId, pageable);
} else {
logs = auditLogRepository.findAllByOrderByPerformedAtDesc(pageable);
}
return logs.stream().map(log -> {
String adminEmail = userAccountRepository.findById(log.getAdminId())
.map(UserAccount::getEmail).orElse(null);
String targetEmail = userAccountRepository.findById(log.getTargetUserId())
.map(UserAccount::getEmail).orElse(null);
return new AuditLogResponse(
log.getId(), log.getAdminId(), adminEmail,
log.getTargetUserId(), targetEmail,
log.getAction(), log.getDetail(), log.getPerformedAt());
}).toList();
}
private UserAccount resolveAdmin(String adminEmail) {
return userAccountRepository.findByEmailIgnoreCase(adminEmail)
.orElseThrow(() -> new ResourceNotFoundException("Admin user not found"));
}
private AdminUserResponse toAdminUserResponse(UserAccount user) {
return new AdminUserResponse(
user.getId(), user.getEmail(), user.getDisplayName(),
user.getSystemRole(), user.isActive(), user.getCreatedAt());
}
}

View File

@@ -0,0 +1,19 @@
package com.recipeapp.admin;
import com.recipeapp.auth.entity.UserAccount;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
public interface AdminUserQueryRepository extends JpaRepository<UserAccount, UUID> {
@Query("SELECT u FROM UserAccount u WHERE (:search IS NULL OR LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%')) OR LOWER(u.displayName) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:isActive IS NULL OR u.isActive = :isActive)")
List<UserAccount> findUsersFiltered(@Param("search") String search, @Param("isActive") Boolean isActive, Pageable pageable);
@Query("SELECT COUNT(u) FROM UserAccount u WHERE (:search IS NULL OR LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%')) OR LOWER(u.displayName) LIKE LOWER(CONCAT('%', :search, '%'))) AND (:isActive IS NULL OR u.isActive = :isActive)")
long countUsersFiltered(@Param("search") String search, @Param("isActive") Boolean isActive);
}

View File

@@ -0,0 +1,13 @@
package com.recipeapp.admin.dto;
import java.time.Instant;
import java.util.UUID;
public record AdminUserResponse(
UUID id,
String email,
String displayName,
String systemRole,
boolean isActive,
Instant createdAt
) {}

View File

@@ -0,0 +1,16 @@
package com.recipeapp.admin.dto;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record AuditLogResponse(
UUID id,
UUID adminId,
String adminEmail,
UUID targetUserId,
String targetEmail,
String action,
Map<String, Object> detail,
Instant performedAt
) {}

View File

@@ -0,0 +1,12 @@
package com.recipeapp.admin.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(max = 100) String displayName,
@NotBlank @Size(min = 8) String tempPassword,
String systemRole
) {}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.admin.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ResetPasswordRequest(
@NotBlank @Size(min = 8) String tempPassword,
String reason
) {}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.admin.dto;
public record ResetPasswordResponse(
String message,
boolean mustChangePassword
) {}

View File

@@ -0,0 +1,8 @@
package com.recipeapp.admin.dto;
public record UpdateUserRequest(
String displayName,
String email,
String systemRole,
Boolean isActive
) {}

View File

@@ -0,0 +1,60 @@
package com.recipeapp.admin.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "admin_audit_log")
public class AdminAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "admin_id", nullable = false)
private UUID adminId;
@Column(name = "target_user_id", nullable = false)
private UUID targetUserId;
@Column(nullable = false, length = 30)
private String action;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> detail;
@Column(name = "ip_address", columnDefinition = "inet")
private String ipAddress;
@Column(name = "performed_at", nullable = false, updatable = false)
private Instant performedAt;
protected AdminAuditLog() {}
public AdminAuditLog(UUID adminId, UUID targetUserId, String action, Map<String, Object> detail, String ipAddress) {
this.adminId = adminId;
this.targetUserId = targetUserId;
this.action = action;
this.detail = detail;
this.ipAddress = ipAddress;
}
@PrePersist
void onCreate() {
performedAt = Instant.now();
}
public UUID getId() { return id; }
public UUID getAdminId() { return adminId; }
public UUID getTargetUserId() { return targetUserId; }
public String getAction() { return action; }
public Map<String, Object> getDetail() { return detail; }
public String getIpAddress() { return ipAddress; }
public Instant getPerformedAt() { return performedAt; }
}

View File

@@ -0,0 +1,56 @@
package com.recipeapp.pantry;
import com.recipeapp.pantry.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/pantry-items")
public class PantryController {
private final PantryService pantryService;
private final HouseholdResolver householdResolver;
public PantryController(PantryService pantryService, HouseholdResolver householdResolver) {
this.pantryService = pantryService;
this.householdResolver = householdResolver;
}
@GetMapping
public List<PantryItemResponse> listItems(Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return pantryService.listItems(householdId);
}
@PostMapping
public ResponseEntity<PantryItemResponse> createItem(
Principal principal,
@Valid @RequestBody CreatePantryItemRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
PantryItemResponse response = pantryService.createItem(householdId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PatchMapping("/{id}")
public PantryItemResponse updateItem(
Principal principal,
@PathVariable UUID id,
@Valid @RequestBody UpdatePantryItemRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
return pantryService.updateItem(householdId, id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteItem(Principal principal, @PathVariable UUID id) {
UUID householdId = householdResolver.resolve(principal.getName());
pantryService.deleteItem(householdId, id);
}
}

View File

@@ -0,0 +1,17 @@
package com.recipeapp.pantry;
import com.recipeapp.pantry.entity.PantryItem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PantryItemRepository extends JpaRepository<PantryItem, UUID> {
@Query("SELECT p FROM PantryItem p WHERE p.household.id = :householdId ORDER BY p.bestBefore ASC NULLS LAST")
List<PantryItem> findByHouseholdIdOrderByBestBeforeAscNullsLast(UUID householdId);
Optional<PantryItem> findByIdAndHouseholdId(UUID id, UUID householdId);
}

View File

@@ -0,0 +1,17 @@
package com.recipeapp.pantry;
import com.recipeapp.pantry.dto.*;
import java.util.List;
import java.util.UUID;
public interface PantryService {
List<PantryItemResponse> listItems(UUID householdId);
PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request);
PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request);
void deleteItem(UUID householdId, UUID itemId);
}

View File

@@ -0,0 +1,98 @@
package com.recipeapp.pantry;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household;
import com.recipeapp.pantry.dto.*;
import com.recipeapp.pantry.entity.PantryItem;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef;
import com.recipeapp.recipe.entity.Ingredient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
public class PantryServiceImpl implements PantryService {
private final PantryItemRepository pantryItemRepository;
private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository;
public PantryServiceImpl(PantryItemRepository pantryItemRepository,
HouseholdRepository householdRepository,
IngredientRepository ingredientRepository) {
this.pantryItemRepository = pantryItemRepository;
this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository;
}
@Override
@Transactional(readOnly = true)
public List<PantryItemResponse> listItems(UUID householdId) {
return pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(householdId)
.stream()
.map(this::toResponse)
.toList();
}
@Override
public PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request) {
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
Ingredient ingredient = null;
if (request.ingredientId() != null) {
ingredient = ingredientRepository.findById(request.ingredientId())
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
}
if (request.ingredientId() == null && (request.customName() == null || request.customName().isBlank())) {
throw new ValidationException("Either ingredientId or customName must be provided");
}
PantryItem item = new PantryItem(household, ingredient, request.customName(),
request.quantity(), request.unit(), request.bestBefore(), request.openedOn());
item = pantryItemRepository.save(item);
return toResponse(item);
}
@Override
public PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request) {
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
if (request.quantity() != null) item.setQuantity(request.quantity());
if (request.unit() != null) item.setUnit(request.unit());
if (request.bestBefore() != null) item.setBestBefore(request.bestBefore());
if (request.openedOn() != null) item.setOpenedOn(request.openedOn());
item = pantryItemRepository.save(item);
return toResponse(item);
}
@Override
public void deleteItem(UUID householdId, UUID itemId) {
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
pantryItemRepository.delete(item);
}
private PantryItemResponse toResponse(PantryItem item) {
UUID ingredientId = item.getIngredient() != null ? item.getIngredient().getId() : null;
String name = item.getIngredient() != null ? item.getIngredient().getName() : item.getCustomName();
CategoryRef category = null;
if (item.getIngredient() != null && item.getIngredient().getCategory() != null) {
category = new CategoryRef(
item.getIngredient().getCategory().getId(),
item.getIngredient().getCategory().getName());
}
return new PantryItemResponse(
item.getId(), ingredientId, name, category,
item.getQuantity(), item.getUnit(), item.getBestBefore(), item.getOpenedOn());
}
}

View File

@@ -0,0 +1,14 @@
package com.recipeapp.pantry.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record CreatePantryItemRequest(
UUID ingredientId,
String customName,
BigDecimal quantity,
String unit,
LocalDate bestBefore,
LocalDate openedOn
) {}

View File

@@ -0,0 +1,18 @@
package com.recipeapp.pantry.dto;
import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record PantryItemResponse(
UUID id,
UUID ingredientId,
String name,
CategoryRef category,
BigDecimal quantity,
String unit,
LocalDate bestBefore,
LocalDate openedOn
) {}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.pantry.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
public record UpdatePantryItemRequest(
BigDecimal quantity,
String unit,
LocalDate bestBefore,
LocalDate openedOn
) {}

View File

@@ -0,0 +1,68 @@
package com.recipeapp.pantry.entity;
import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.entity.Ingredient;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "pantry_item")
public class PantryItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ingredient_id")
private Ingredient ingredient;
@Column(name = "custom_name", length = 200)
private String customName;
@Column(precision = 8, scale = 2)
private BigDecimal quantity;
@Column(length = 20)
private String unit;
@Column(name = "best_before")
private LocalDate bestBefore;
@Column(name = "opened_on")
private LocalDate openedOn;
protected PantryItem() {}
public PantryItem(Household household, Ingredient ingredient, String customName,
BigDecimal quantity, String unit, LocalDate bestBefore, LocalDate openedOn) {
this.household = household;
this.ingredient = ingredient;
this.customName = customName;
this.quantity = quantity;
this.unit = unit;
this.bestBefore = bestBefore;
this.openedOn = openedOn;
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public Ingredient getIngredient() { return ingredient; }
public void setIngredient(Ingredient ingredient) { this.ingredient = ingredient; }
public String getCustomName() { return customName; }
public void setCustomName(String customName) { this.customName = customName; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public LocalDate getBestBefore() { return bestBefore; }
public void setBestBefore(LocalDate bestBefore) { this.bestBefore = bestBefore; }
public LocalDate getOpenedOn() { return openedOn; }
public void setOpenedOn(LocalDate openedOn) { this.openedOn = openedOn; }
}

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; }
}

View File

@@ -0,0 +1,31 @@
package com.recipeapp.recipe;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.household.HouseholdMemberRepository;
import com.recipeapp.household.entity.HouseholdMember;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class HouseholdResolver {
private final HouseholdMemberRepository householdMemberRepository;
public HouseholdResolver(HouseholdMemberRepository householdMemberRepository) {
this.householdMemberRepository = householdMemberRepository;
}
public UUID resolve(String userEmail) {
return findMembership(userEmail).getHousehold().getId();
}
public UUID resolveUserId(String userEmail) {
return findMembership(userEmail).getUser().getId();
}
private HouseholdMember findMembership(String userEmail) {
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
}
}

View File

@@ -0,0 +1,39 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.*;
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/ingredient-categories")
public class IngredientCategoryController {
private final RecipeService recipeService;
private final HouseholdResolver householdResolver;
public IngredientCategoryController(RecipeService recipeService, HouseholdResolver householdResolver) {
this.recipeService = recipeService;
this.householdResolver = householdResolver;
}
@GetMapping
public List<IngredientCategoryResponse> listCategories(Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return recipeService.listCategories(householdId);
}
@PostMapping
public ResponseEntity<IngredientCategoryResponse> createCategory(
Principal principal,
@Valid @RequestBody IngredientCategoryCreateRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
IngredientCategoryResponse response = recipeService.createCategory(householdId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}

View File

@@ -8,4 +8,6 @@ import java.util.UUID;
public interface IngredientCategoryRepository extends JpaRepository<IngredientCategory, UUID> {
List<IngredientCategory> findByHouseholdIdOrderBySortOrder(UUID householdId);
boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name);
long countByHouseholdId(UUID householdId);
}

View File

@@ -0,0 +1,41 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.*;
import jakarta.validation.Valid;
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/ingredients")
public class IngredientController {
private final RecipeService recipeService;
private final HouseholdResolver householdResolver;
public IngredientController(RecipeService recipeService, HouseholdResolver householdResolver) {
this.recipeService = recipeService;
this.householdResolver = householdResolver;
}
@GetMapping
public List<IngredientResponse> searchIngredients(
Principal principal,
@RequestParam(required = false) String search,
@RequestParam(required = false) Boolean isStaple) {
UUID householdId = householdResolver.resolve(principal.getName());
return recipeService.searchIngredients(householdId, search, isStaple);
}
@PatchMapping("/{id}")
public IngredientResponse patchIngredient(
Principal principal,
@PathVariable UUID id,
@Valid @RequestBody IngredientPatchRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
return recipeService.patchIngredient(householdId, id, request);
}
}

View File

@@ -8,4 +8,7 @@ import java.util.UUID;
public interface IngredientRepository extends JpaRepository<Ingredient, UUID> {
List<Ingredient> findByHouseholdId(UUID householdId);
List<Ingredient> findByHouseholdIdAndNameContainingIgnoreCase(UUID householdId, String name);
List<Ingredient> findByHouseholdIdAndIsStaple(UUID householdId, boolean isStaple);
List<Ingredient> findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(UUID householdId, String name, boolean isStaple);
}

View File

@@ -0,0 +1,79 @@
package com.recipeapp.recipe;
import com.recipeapp.common.ApiResponse;
import com.recipeapp.recipe.dto.*;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/v1/recipes")
public class RecipeController {
private final RecipeService recipeService;
private final HouseholdResolver householdResolver;
public RecipeController(RecipeService recipeService, HouseholdResolver householdResolver) {
this.recipeService = recipeService;
this.householdResolver = householdResolver;
}
@GetMapping
public ResponseEntity<ApiResponse<List<RecipeSummaryResponse>>> listRecipes(
Principal principal,
@RequestParam(required = false) String search,
@RequestParam(required = false) String effort,
@RequestParam(required = false) Boolean isChildFriendly,
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
@RequestParam(required = false) String sort,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) {
UUID householdId = householdResolver.resolve(principal.getName());
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset);
long total = recipeService.countRecipes(
householdId, search, effort, isChildFriendly, cookTimeMaxMin);
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
var meta = new ApiResponse.Meta(pagination);
return ResponseEntity.ok(ApiResponse.success(recipes, meta));
}
@GetMapping("/{id}")
public RecipeDetailResponse getRecipe(Principal principal, @PathVariable UUID id) {
UUID householdId = householdResolver.resolve(principal.getName());
return recipeService.getRecipe(householdId, id);
}
@PostMapping
public ResponseEntity<RecipeDetailResponse> createRecipe(
Principal principal,
@Valid @RequestBody RecipeCreateRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
RecipeDetailResponse detail = recipeService.createRecipe(householdId, request);
return ResponseEntity.created(URI.create("/v1/recipes/" + detail.id())).body(detail);
}
@PutMapping("/{id}")
public RecipeDetailResponse updateRecipe(
Principal principal,
@PathVariable UUID id,
@Valid @RequestBody RecipeCreateRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
return recipeService.updateRecipe(householdId, id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteRecipe(Principal principal, @PathVariable UUID id) {
UUID householdId = householdResolver.resolve(principal.getName());
recipeService.deleteRecipe(householdId, id);
}
}

View File

@@ -0,0 +1,57 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.RecipeSummaryResponse;
import com.recipeapp.recipe.entity.Recipe;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
Optional<Recipe> findByIdAndHouseholdIdAndDeletedAtIsNull(UUID id, UUID householdId);
List<Recipe> findByHouseholdIdAndDeletedAtIsNull(UUID householdId);
@Query("""
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
FROM Recipe r
WHERE r.household.id = :householdId
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
AND (:effort IS NULL OR r.effort = :effort)
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
ORDER BY r.createdAt DESC
""")
List<RecipeSummaryResponse> findFiltered(
@Param("householdId") UUID householdId,
@Param("search") String search,
@Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
@Param("sort") String sort,
@Param("limit") int limit,
@Param("offset") int offset);
@Query("""
SELECT COUNT(r)
FROM Recipe r
WHERE r.household.id = :householdId
AND r.deletedAt IS NULL
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
AND (:effort IS NULL OR r.effort = :effort)
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
""")
long countFiltered(
@Param("householdId") UUID householdId,
@Param("search") String search,
@Param("effort") String effort,
@Param("isChildFriendly") Boolean isChildFriendly,
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
}

View File

@@ -0,0 +1,35 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.*;
import java.util.List;
import java.util.UUID;
public interface RecipeService {
List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin,
String sort, int limit, int offset);
long countRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin);
RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId);
RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request);
RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request);
void deleteRecipe(UUID householdId, UUID recipeId);
List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple);
IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request);
List<TagResponse> listTags(UUID householdId);
TagResponse createTag(UUID householdId, TagCreateRequest request);
List<IngredientCategoryResponse> listCategories(UUID householdId);
IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request);
}

View File

@@ -0,0 +1,263 @@
package com.recipeapp.recipe;
import com.recipeapp.common.ConflictException;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.household.entity.Household;
import com.recipeapp.recipe.dto.*;
import com.recipeapp.recipe.entity.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class RecipeServiceImpl implements RecipeService {
private final RecipeRepository recipeRepository;
private final IngredientRepository ingredientRepository;
private final TagRepository tagRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final HouseholdRepository householdRepository;
public RecipeServiceImpl(RecipeRepository recipeRepository,
IngredientRepository ingredientRepository,
TagRepository tagRepository,
IngredientCategoryRepository ingredientCategoryRepository,
HouseholdRepository householdRepository) {
this.recipeRepository = recipeRepository;
this.ingredientRepository = ingredientRepository;
this.tagRepository = tagRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.householdRepository = householdRepository;
}
@Override
@Transactional(readOnly = true)
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin,
String sort, int limit, int offset) {
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
cookTimeMaxMin, sort, limit, offset);
}
@Override
@Transactional(readOnly = true)
public long countRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin) {
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
}
@Override
@Transactional(readOnly = true)
public RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId) {
Recipe recipe = findRecipe(householdId, recipeId);
return toDetailResponse(recipe);
}
@Override
@Transactional
public RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request) {
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.setHeroImageUrl(request.heroImageUrl());
addIngredients(recipe, household, request.ingredients());
addSteps(recipe, request.steps());
addTags(recipe, request.tagIds());
recipe = recipeRepository.save(recipe);
return toDetailResponse(recipe);
}
@Override
@Transactional
public RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request) {
Recipe recipe = findRecipe(householdId, recipeId);
Household household = recipe.getHousehold();
recipe.setName(request.name());
recipe.setServes(request.serves());
recipe.setCookTimeMin(request.cookTimeMin());
recipe.setEffort(request.effort());
recipe.setChildFriendly(request.isChildFriendly());
recipe.setHeroImageUrl(request.heroImageUrl());
recipe.getIngredients().clear();
recipe.getSteps().clear();
addIngredients(recipe, household, request.ingredients());
addSteps(recipe, request.steps());
addTags(recipe, request.tagIds());
recipe = recipeRepository.save(recipe);
return toDetailResponse(recipe);
}
@Override
@Transactional
public void deleteRecipe(UUID householdId, UUID recipeId) {
Recipe recipe = findRecipe(householdId, recipeId);
recipe.setDeletedAt(Instant.now());
}
// ── Ingredients ──
@Override
@Transactional(readOnly = true)
public List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple) {
List<Ingredient> ingredients;
if (search != null && isStaple != null) {
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(
householdId, search, isStaple);
} else if (search != null) {
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(householdId, search);
} else if (isStaple != null) {
ingredients = ingredientRepository.findByHouseholdIdAndIsStaple(householdId, isStaple);
} else {
ingredients = ingredientRepository.findByHouseholdId(householdId);
}
return ingredients.stream().map(this::toIngredientResponse).toList();
}
@Override
@Transactional
public IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request) {
Ingredient ingredient = ingredientRepository.findById(ingredientId)
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
if (request.name() != null) {
ingredient.setName(request.name());
}
if (request.isStaple() != null) {
ingredient.setStaple(request.isStaple());
}
if (request.categoryId() != null) {
IngredientCategory category = ingredientCategoryRepository.findById(request.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category not found"));
ingredient.setCategory(category);
}
return toIngredientResponse(ingredient);
}
// ── Tags ──
@Override
@Transactional(readOnly = true)
public List<TagResponse> listTags(UUID householdId) {
return tagRepository.findByHouseholdId(householdId).stream()
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
.toList();
}
@Override
@Transactional
public TagResponse createTag(UUID householdId, TagCreateRequest request) {
if (tagRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
throw new ConflictException("Tag already exists");
}
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
Tag tag = tagRepository.save(new Tag(household, request.name(), request.tagType()));
return new TagResponse(tag.getId(), tag.getName(), tag.getTagType());
}
// ── Ingredient Categories ──
@Override
@Transactional(readOnly = true)
public List<IngredientCategoryResponse> listCategories(UUID householdId) {
return ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(householdId).stream()
.map(c -> new IngredientCategoryResponse(c.getId(), c.getName()))
.toList();
}
@Override
@Transactional
public IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request) {
if (ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
throw new ConflictException("Category already exists");
}
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
short nextSort = (short) (ingredientCategoryRepository.countByHouseholdId(householdId) + 1);
IngredientCategory category = ingredientCategoryRepository.save(
new IngredientCategory(household, request.name(), nextSort));
return new IngredientCategoryResponse(category.getId(), category.getName());
}
// ── Private helpers ──
private Recipe findRecipe(UUID householdId, UUID recipeId) {
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
}
private void addIngredients(Recipe recipe, Household household, List<RecipeCreateRequest.IngredientEntry> entries) {
if (entries == null) return;
for (var entry : entries) {
Ingredient ingredient;
if (entry.ingredientId() != null) {
ingredient = ingredientRepository.findById(entry.ingredientId())
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
} else {
ingredient = ingredientRepository.save(new Ingredient(household, entry.newIngredientName(), false));
}
recipe.getIngredients().add(new RecipeIngredient(
recipe, ingredient, entry.quantity(), entry.unit(), entry.sortOrder()));
}
}
private void addSteps(Recipe recipe, List<RecipeCreateRequest.StepEntry> entries) {
if (entries == null) return;
for (var entry : entries) {
recipe.getSteps().add(new RecipeStep(recipe, entry.stepNumber(), entry.instruction()));
}
}
private void addTags(Recipe recipe, List<UUID> tagIds) {
if (tagIds == null || tagIds.isEmpty()) return;
List<Tag> tags = tagRepository.findAllById(tagIds);
recipe.setTags(new HashSet<>(tags));
}
private RecipeDetailResponse toDetailResponse(Recipe recipe) {
var ingredients = recipe.getIngredients().stream()
.map(ri -> {
Ingredient ing = ri.getIngredient();
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
: null;
return new RecipeDetailResponse.IngredientItem(
ing.getId(), ing.getName(), catRef,
ri.getQuantity(), ri.getUnit(), ri.getSortOrder());
})
.toList();
var steps = recipe.getSteps().stream()
.map(s -> new RecipeDetailResponse.StepItem(s.getStepNumber(), s.getInstruction()))
.toList();
var tags = recipe.getTags().stream()
.map(t -> new RecipeDetailResponse.TagItem(t.getId(), t.getName(), t.getTagType()))
.toList();
return new RecipeDetailResponse(
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
ingredients, steps, tags);
}
private IngredientResponse toIngredientResponse(Ingredient ing) {
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
: null;
return new IngredientResponse(ing.getId(), ing.getName(), catRef, ing.isStaple());
}
}

View File

@@ -0,0 +1,39 @@
package com.recipeapp.recipe;
import com.recipeapp.recipe.dto.*;
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/tags")
public class TagController {
private final RecipeService recipeService;
private final HouseholdResolver householdResolver;
public TagController(RecipeService recipeService, HouseholdResolver householdResolver) {
this.recipeService = recipeService;
this.householdResolver = householdResolver;
}
@GetMapping
public List<TagResponse> listTags(Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return recipeService.listTags(householdId);
}
@PostMapping
public ResponseEntity<TagResponse> createTag(
Principal principal,
@Valid @RequestBody TagCreateRequest request) {
UUID householdId = householdResolver.resolve(principal.getName());
TagResponse response = recipeService.createTag(householdId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface TagRepository extends JpaRepository<Tag, UUID> {
List<Tag> findByHouseholdId(UUID householdId);
boolean existsByHouseholdIdAndNameIgnoreCase(UUID householdId, String name);
}

View File

@@ -0,0 +1,8 @@
package com.recipeapp.recipe.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record IngredientCategoryCreateRequest(
@NotBlank @Size(max = 50) String name
) {}

View File

@@ -0,0 +1,5 @@
package com.recipeapp.recipe.dto;
import java.util.UUID;
public record IngredientCategoryResponse(UUID id, String name) {}

View File

@@ -0,0 +1,9 @@
package com.recipeapp.recipe.dto;
import java.util.UUID;
public record IngredientPatchRequest(
String name,
Boolean isStaple,
UUID categoryId
) {}

View File

@@ -0,0 +1,10 @@
package com.recipeapp.recipe.dto;
import java.util.UUID;
public record IngredientResponse(
UUID id,
String name,
RecipeDetailResponse.CategoryRef category,
boolean isStaple
) {}

View File

@@ -0,0 +1,32 @@
package com.recipeapp.recipe.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
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,
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
boolean isChildFriendly,
@Size(max = 500) String heroImageUrl,
@NotEmpty @Valid List<IngredientEntry> ingredients,
@Valid List<StepEntry> steps,
@NotEmpty List<UUID> tagIds
) {
public record IngredientEntry(
UUID ingredientId,
@Size(max = 200) String newIngredientName,
@NotNull @DecimalMin("0.01") BigDecimal quantity,
@NotBlank @Size(max = 20) String unit,
short sortOrder
) {}
public record StepEntry(
@Min(1) short stepNumber,
@NotBlank String instruction
) {}
}

View File

@@ -0,0 +1,33 @@
package com.recipeapp.recipe.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
public record RecipeDetailResponse(
UUID id,
String name,
short serves,
short cookTimeMin,
String effort,
boolean isChildFriendly,
String heroImageUrl,
List<IngredientItem> ingredients,
List<StepItem> steps,
List<TagItem> tags
) {
public record IngredientItem(
UUID ingredientId,
String name,
CategoryRef category,
BigDecimal quantity,
String unit,
short sortOrder
) {}
public record CategoryRef(UUID id, String name) {}
public record StepItem(short stepNumber, String instruction) {}
public record TagItem(UUID id, String name, String tagType) {}
}

View File

@@ -0,0 +1,13 @@
package com.recipeapp.recipe.dto;
import java.util.UUID;
public record RecipeSummaryResponse(
UUID id,
String name,
short serves,
short cookTimeMin,
String effort,
boolean isChildFriendly,
String heroImageUrl
) {}

View File

@@ -0,0 +1,10 @@
package com.recipeapp.recipe.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record TagCreateRequest(
@NotBlank @Size(max = 50) String name,
@NotBlank @Pattern(regexp = "protein|dietary|cuisine|other") String tagType
) {}

View File

@@ -0,0 +1,5 @@
package com.recipeapp.recipe.dto;
import java.util.UUID;
public record TagResponse(UUID id, String name, String tagType) {}

View File

@@ -0,0 +1,112 @@
package com.recipeapp.recipe.entity;
import com.recipeapp.household.entity.Household;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "recipe")
public class Recipe {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false)
private short serves;
@Column(name = "cook_time_min", nullable = false)
private short cookTimeMin;
@Column(nullable = false, length = 10)
private String effort;
@Column(name = "is_child_friendly", nullable = false)
private boolean isChildFriendly;
@Column(name = "hero_image_url", length = 500)
private String heroImageUrl;
@Column(name = "deleted_at")
private Instant deletedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder")
private List<RecipeIngredient> ingredients = new ArrayList<>();
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("stepNumber")
private List<RecipeStep> steps = new ArrayList<>();
@ManyToMany
@JoinTable(name = "recipe_tag",
joinColumns = @JoinColumn(name = "recipe_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();
protected Recipe() {}
public Recipe(Household household, String name, short serves, short cookTimeMin,
String effort, boolean isChildFriendly) {
this.household = household;
this.name = name;
this.serves = serves;
this.cookTimeMin = cookTimeMin;
this.effort = effort;
this.isChildFriendly = isChildFriendly;
}
@PrePersist
void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public short getServes() { return serves; }
public void setServes(short serves) { this.serves = serves; }
public short getCookTimeMin() { return cookTimeMin; }
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
public String getEffort() { return effort; }
public void setEffort(String effort) { this.effort = effort; }
public boolean isChildFriendly() { return isChildFriendly; }
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
public String getHeroImageUrl() { return heroImageUrl; }
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
public Instant getDeletedAt() { return deletedAt; }
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public List<RecipeIngredient> getIngredients() { return ingredients; }
public List<RecipeStep> getSteps() { return steps; }
public Set<Tag> getTags() { return tags; }
public void setTags(Set<Tag> tags) { this.tags = tags; }
public boolean isDeleted() { return deletedAt != null; }
}

View File

@@ -0,0 +1,52 @@
package com.recipeapp.recipe.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@Table(name = "recipe_ingredient")
public class RecipeIngredient {
@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 = "ingredient_id", nullable = false)
private Ingredient ingredient;
@Column(nullable = false, precision = 8, scale = 2)
private BigDecimal quantity;
@Column(nullable = false, length = 20)
private String unit;
@Column(name = "sort_order", nullable = false)
private short sortOrder;
protected RecipeIngredient() {}
public RecipeIngredient(Recipe recipe, Ingredient ingredient, BigDecimal quantity,
String unit, short sortOrder) {
this.recipe = recipe;
this.ingredient = ingredient;
this.quantity = quantity;
this.unit = unit;
this.sortOrder = sortOrder;
}
public UUID getId() { return id; }
public Recipe getRecipe() { return recipe; }
public Ingredient getIngredient() { return ingredient; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public short getSortOrder() { return sortOrder; }
public void setSortOrder(short sortOrder) { this.sortOrder = sortOrder; }
}

View File

@@ -0,0 +1,38 @@
package com.recipeapp.recipe.entity;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "recipe_step")
public class RecipeStep {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id", nullable = false)
private Recipe recipe;
@Column(name = "step_number", nullable = false)
private short stepNumber;
@Column(nullable = false, columnDefinition = "text")
private String instruction;
protected RecipeStep() {}
public RecipeStep(Recipe recipe, short stepNumber, String instruction) {
this.recipe = recipe;
this.stepNumber = stepNumber;
this.instruction = instruction;
}
public UUID getId() { return id; }
public Recipe getRecipe() { return recipe; }
public short getStepNumber() { return stepNumber; }
public void setStepNumber(short stepNumber) { this.stepNumber = stepNumber; }
public String getInstruction() { return instruction; }
public void setInstruction(String instruction) { this.instruction = instruction; }
}

View File

@@ -0,0 +1,68 @@
package com.recipeapp.shopping;
import com.recipeapp.recipe.HouseholdResolver;
import com.recipeapp.shopping.dto.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.UUID;
@RestController
public class ShoppingListController {
private final ShoppingService shoppingService;
private final HouseholdResolver householdResolver;
public ShoppingListController(ShoppingService shoppingService, HouseholdResolver householdResolver) {
this.shoppingService = shoppingService;
this.householdResolver = householdResolver;
}
@PostMapping("/v1/week-plans/{id}/shopping-list")
@ResponseStatus(HttpStatus.CREATED)
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.generateFromPlan(householdId, id);
}
@GetMapping("/v1/shopping-lists/{id}")
public ShoppingListResponse getShoppingList(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.getShoppingList(householdId, id);
}
@PostMapping("/v1/shopping-lists/{id}/publish")
public PublishResponse publish(@PathVariable UUID id, Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.publish(householdId, id);
}
@PatchMapping("/v1/shopping-lists/{listId}/items/{itemId}")
public ShoppingListItemResponse checkItem(@PathVariable UUID listId,
@PathVariable UUID itemId,
@RequestBody CheckItemRequest request,
Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
UUID userId = householdResolver.resolveUserId(principal.getName());
return shoppingService.checkItem(householdId, listId, itemId, request, userId);
}
@PostMapping("/v1/shopping-lists/{id}/items")
@ResponseStatus(HttpStatus.CREATED)
public ShoppingListItemResponse addItem(@PathVariable UUID id,
@RequestBody AddItemRequest request,
Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
return shoppingService.addItem(householdId, id, request);
}
@DeleteMapping("/v1/shopping-lists/{listId}/items/{itemId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteItem(@PathVariable UUID listId,
@PathVariable UUID itemId,
Principal principal) {
UUID householdId = householdResolver.resolve(principal.getName());
shoppingService.deleteItem(householdId, listId, itemId);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
package com.recipeapp.shopping;
import com.recipeapp.shopping.dto.*;
import java.util.UUID;
public interface ShoppingService {
ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId);
ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId);
PublishResponse publish(UUID householdId, UUID shoppingListId);
ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId,
CheckItemRequest request, UUID userId);
ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request);
void deleteItem(UUID householdId, UUID listId, UUID itemId);
}

View File

@@ -0,0 +1,270 @@
package com.recipeapp.shopping;
import com.recipeapp.auth.UserAccountRepository;
import com.recipeapp.auth.entity.UserAccount;
import com.recipeapp.common.ResourceNotFoundException;
import com.recipeapp.common.ValidationException;
import com.recipeapp.household.HouseholdRepository;
import com.recipeapp.planning.WeekPlanRepository;
import com.recipeapp.planning.entity.WeekPlan;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.RecipeIngredient;
import com.recipeapp.shopping.dto.*;
import com.recipeapp.shopping.entity.ShoppingList;
import com.recipeapp.shopping.entity.ShoppingListItem;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
@Transactional
public class ShoppingServiceImpl implements ShoppingService {
private final ShoppingListRepository shoppingListRepository;
private final ShoppingListItemRepository shoppingListItemRepository;
private final WeekPlanRepository weekPlanRepository;
private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository;
private final UserAccountRepository userAccountRepository;
public ShoppingServiceImpl(ShoppingListRepository shoppingListRepository,
ShoppingListItemRepository shoppingListItemRepository,
WeekPlanRepository weekPlanRepository,
HouseholdRepository householdRepository,
IngredientRepository ingredientRepository,
UserAccountRepository userAccountRepository) {
this.shoppingListRepository = shoppingListRepository;
this.shoppingListItemRepository = shoppingListItemRepository;
this.weekPlanRepository = weekPlanRepository;
this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository;
this.userAccountRepository = userAccountRepository;
}
@Override
public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) {
WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId)
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
if (!weekPlan.getHousehold().getId().equals(householdId)) {
throw new ResourceNotFoundException("Week plan not found");
}
var household = weekPlan.getHousehold();
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
shoppingList = shoppingListRepository.save(shoppingList);
// Aggregate ingredients across all slots/recipes
// Key: ingredientId + unit -> merged data
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
for (var slot : weekPlan.getSlots()) {
var recipe = slot.getRecipe();
for (RecipeIngredient ri : recipe.getIngredients()) {
Ingredient ingredient = ri.getIngredient();
// Filter out staples
if (ingredient.isStaple()) {
continue;
}
String key = ingredient.getId().toString() + "|" + ri.getUnit();
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
.addQuantity(ri.getQuantity())
.addRecipeId(recipe.getId());
}
}
// Create shopping list items
for (MergedIngredient mi : merged.values()) {
ShoppingListItem item = new ShoppingListItem(
shoppingList,
mi.ingredient,
null,
mi.totalQuantity,
mi.unit,
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
);
shoppingList.getItems().add(item);
}
shoppingListRepository.save(shoppingList);
return toResponse(shoppingList);
}
@Override
@Transactional(readOnly = true)
public ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId) {
ShoppingList list = findList(householdId, shoppingListId);
return toResponse(list);
}
@Override
public PublishResponse publish(UUID householdId, UUID shoppingListId) {
ShoppingList list = findList(householdId, shoppingListId);
if (!"draft".equals(list.getStatus())) {
throw new ValidationException("Shopping list is already published");
}
list.setStatus("published");
list.setPublishedAt(Instant.now());
shoppingListRepository.save(list);
return new PublishResponse(list.getId(), list.getStatus(), list.getPublishedAt());
}
@Override
public ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId,
CheckItemRequest request, UUID userId) {
ShoppingList list = findList(householdId, listId);
ShoppingListItem item = findItem(list, itemId);
item.setChecked(request.isChecked());
if (request.isChecked()) {
UserAccount user = userAccountRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
item.setCheckedBy(user);
} else {
item.setCheckedBy(null);
}
shoppingListItemRepository.save(item);
return toItemResponse(item);
}
@Override
public ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request) {
ShoppingList list = findList(householdId, shoppingListId);
Ingredient ingredient = null;
if (request.ingredientId() != null) {
ingredient = ingredientRepository.findById(request.ingredientId())
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
}
ShoppingListItem item = new ShoppingListItem(
list,
ingredient,
request.customName(),
request.quantity(),
request.unit(),
new UUID[0]
);
item = shoppingListItemRepository.save(item);
list.getItems().add(item);
return toItemResponse(item);
}
@Override
public void deleteItem(UUID householdId, UUID listId, UUID itemId) {
ShoppingList list = findList(householdId, listId);
if ("published".equals(list.getStatus())) {
throw new ValidationException("Cannot delete items from a published shopping list");
}
ShoppingListItem item = findItem(list, itemId);
list.getItems().remove(item);
shoppingListItemRepository.delete(item);
}
// ── Helpers ──
private ShoppingList findList(UUID householdId, UUID shoppingListId) {
ShoppingList list = shoppingListRepository.findById(shoppingListId)
.orElseThrow(() -> new ResourceNotFoundException("Shopping list not found"));
if (!list.getHousehold().getId().equals(householdId)) {
throw new ResourceNotFoundException("Shopping list not found");
}
return list;
}
private ShoppingListItem findItem(ShoppingList list, UUID itemId) {
return list.getItems().stream()
.filter(i -> i.getId().equals(itemId))
.findFirst()
.orElseThrow(() -> new ResourceNotFoundException("Shopping list item not found"));
}
private ShoppingListResponse toResponse(ShoppingList list) {
List<ShoppingListItemResponse> items = list.getItems().stream()
.map(this::toItemResponse)
.toList();
return new ShoppingListResponse(
list.getId(),
list.getWeekPlan().getId(),
list.getStatus(),
list.getPublishedAt(),
items
);
}
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) {
String name;
ShoppingListItemResponse.CategoryRef categoryRef = null;
UUID ingredientId = null;
if (item.getIngredient() != null) {
ingredientId = item.getIngredient().getId();
name = item.getIngredient().getName();
if (item.getIngredient().getCategory() != null) {
categoryRef = new ShoppingListItemResponse.CategoryRef(
item.getIngredient().getCategory().getId(),
item.getIngredient().getCategory().getName()
);
}
} else {
name = item.getCustomName();
}
return new ShoppingListItemResponse(
item.getId(),
ingredientId,
name,
categoryRef,
item.getQuantity(),
item.getUnit(),
item.isChecked(),
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of()
);
}
private static class MergedIngredient {
final Ingredient ingredient;
final String unit;
BigDecimal totalQuantity = BigDecimal.ZERO;
final List<UUID> recipeIds = new ArrayList<>();
MergedIngredient(Ingredient ingredient, String unit) {
this.ingredient = ingredient;
this.unit = unit;
}
MergedIngredient addQuantity(BigDecimal qty) {
if (qty != null) {
this.totalQuantity = this.totalQuantity.add(qty);
}
return this;
}
MergedIngredient addRecipeId(UUID recipeId) {
this.recipeIds.add(recipeId);
return this;
}
}
}

View File

@@ -0,0 +1,11 @@
package com.recipeapp.shopping.dto;
import java.math.BigDecimal;
import java.util.UUID;
public record AddItemRequest(
UUID ingredientId,
String customName,
BigDecimal quantity,
String unit
) {}

View File

@@ -0,0 +1,3 @@
package com.recipeapp.shopping.dto;
public record CheckItemRequest(boolean isChecked) {}

View File

@@ -0,0 +1,6 @@
package com.recipeapp.shopping.dto;
import java.time.Instant;
import java.util.UUID;
public record PublishResponse(UUID id, String status, Instant publishedAt) {}

View File

@@ -0,0 +1,19 @@
package com.recipeapp.shopping.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
public record ShoppingListItemResponse(
UUID id,
UUID ingredientId,
String name,
CategoryRef category,
BigDecimal quantity,
String unit,
boolean isChecked,
UUID checkedBy,
List<UUID> sourceRecipes
) {
public record CategoryRef(UUID id, String name) {}
}

View File

@@ -0,0 +1,13 @@
package com.recipeapp.shopping.dto;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record ShoppingListResponse(
UUID id,
UUID weekPlanId,
String status,
Instant publishedAt,
List<ShoppingListItemResponse> items
) {}

View File

@@ -0,0 +1,51 @@
package com.recipeapp.shopping.entity;
import com.recipeapp.household.entity.Household;
import com.recipeapp.planning.entity.WeekPlan;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "shopping_list")
public class ShoppingList {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false)
private Household household;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "week_plan_id", nullable = false)
private WeekPlan weekPlan;
@Column(nullable = false, length = 10)
private String status = "draft";
@Column(name = "published_at")
private Instant publishedAt;
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ShoppingListItem> items = new ArrayList<>();
protected ShoppingList() {}
public ShoppingList(Household household, WeekPlan weekPlan) {
this.household = household;
this.weekPlan = weekPlan;
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public WeekPlan getWeekPlan() { return weekPlan; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getPublishedAt() { return publishedAt; }
public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; }
public List<ShoppingListItem> getItems() { return items; }
}

View File

@@ -0,0 +1,71 @@
package com.recipeapp.shopping.entity;
import com.recipeapp.auth.entity.UserAccount;
import com.recipeapp.recipe.entity.Ingredient;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@Table(name = "shopping_list_item")
public class ShoppingListItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shopping_list_id", nullable = false)
private ShoppingList shoppingList;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ingredient_id")
private Ingredient ingredient;
@Column(name = "custom_name", length = 200)
private String customName;
@Column(precision = 8, scale = 2)
private BigDecimal quantity;
@Column(length = 20)
private String unit;
@Column(name = "is_checked", nullable = false)
private boolean isChecked = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "checked_by")
private UserAccount checkedBy;
@Column(name = "source_recipes", columnDefinition = "uuid[]")
private UUID[] sourceRecipes;
protected ShoppingListItem() {}
public ShoppingListItem(ShoppingList shoppingList, Ingredient ingredient, String customName,
BigDecimal quantity, String unit, UUID[] sourceRecipes) {
this.shoppingList = shoppingList;
this.ingredient = ingredient;
this.customName = customName;
this.quantity = quantity;
this.unit = unit;
this.sourceRecipes = sourceRecipes;
}
public UUID getId() { return id; }
public ShoppingList getShoppingList() { return shoppingList; }
public Ingredient getIngredient() { return ingredient; }
public String getCustomName() { return customName; }
public void setCustomName(String customName) { this.customName = customName; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public boolean isChecked() { return isChecked; }
public void setChecked(boolean checked) { isChecked = checked; }
public UserAccount getCheckedBy() { return checkedBy; }
public void setCheckedBy(UserAccount checkedBy) { this.checkedBy = checkedBy; }
public UUID[] getSourceRecipes() { return sourceRecipes; }
public void setSourceRecipes(UUID[] sourceRecipes) { this.sourceRecipes = sourceRecipes; }
}