Remove service interfaces — use concrete classes directly

Each domain had a single-implementation interface (e.g. AdminService
interface + AdminServiceImpl). Merged implementation into the service
class and deleted the redundant interfaces per KISS principle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:04:41 +02:00
parent 03b96e8584
commit 9713412d42
21 changed files with 1171 additions and 1595 deletions

View File

@@ -1,21 +1,169 @@
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.List;
import java.util.UUID;
import java.util.*;
public interface AdminService {
@Service
@Transactional
public class AdminService {
ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset);
public record ListUsersResult(List<AdminUserResponse> users, long total) {}
AdminUserResponse createUser(CreateUserRequest request, String adminEmail);
AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail);
private final UserAccountRepository userAccountRepository;
private final AdminAuditLogRepository auditLogRepository;
private final AdminUserQueryRepository adminUserQueryRepository;
private final PasswordEncoder passwordEncoder;
ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail);
public AdminService(UserAccountRepository userAccountRepository,
AdminAuditLogRepository auditLogRepository,
AdminUserQueryRepository adminUserQueryRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.auditLogRepository = auditLogRepository;
this.adminUserQueryRepository = adminUserQueryRepository;
this.passwordEncoder = passwordEncoder;
}
List<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset);
record ListUsersResult(List<AdminUserResponse> users, long total) {}
@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);
}
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);
}
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);
}
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);
}
@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

@@ -1,166 +0,0 @@
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

@@ -1,10 +1,95 @@
package com.recipeapp.auth;
import com.recipeapp.auth.dto.*;
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.HouseholdMemberRepository;
import com.recipeapp.household.entity.HouseholdMember;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public interface AuthService {
UserResponse signup(SignupRequest request);
UserResponse login(LoginRequest request);
UserResponse getCurrentUser(String email);
UserResponse updateProfile(String email, UpdateProfileRequest request);
@Service
public class AuthService {
private final UserAccountRepository userAccountRepository;
private final HouseholdMemberRepository householdMemberRepository;
private final PasswordEncoder passwordEncoder;
public AuthService(UserAccountRepository userAccountRepository,
HouseholdMemberRepository householdMemberRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.householdMemberRepository = householdMemberRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public UserResponse signup(SignupRequest request) {
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
throw new ConflictException("Email already registered");
}
var user = new UserAccount(
request.email(),
request.displayName(),
passwordEncoder.encode(request.password())
);
user = userAccountRepository.save(user);
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
@Transactional(readOnly = true)
public UserResponse login(LoginRequest request) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(request.email())
.orElseThrow(() -> new ResourceNotFoundException("Invalid email or password"));
if (!user.isActive()) {
throw new ValidationException("Account is deactivated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new ValidationException("Invalid email or password");
}
return toUserResponse(user);
}
@Transactional(readOnly = true)
public UserResponse getCurrentUser(String email) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
return toUserResponse(user);
}
@Transactional
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
if (request.displayName() != null) {
user.setDisplayName(request.displayName());
}
if (request.newPassword() != null) {
if (request.currentPassword() == null) {
throw new ValidationException("Current password is required to set a new password");
}
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
throw new ValidationException("Current password is incorrect");
}
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
}
user = userAccountRepository.save(user);
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
private UserResponse toUserResponse(UserAccount user) {
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
.map(member -> UserResponse.withHousehold(
user.getId(), user.getEmail(), user.getDisplayName(),
member.getHousehold().getId(), member.getHousehold().getName(),
member.getRole(), user.getSystemRole()))
.orElse(UserResponse.withHousehold(
user.getId(), user.getEmail(), user.getDisplayName(),
null, null, null, user.getSystemRole()));
}
}

View File

@@ -1,99 +0,0 @@
package com.recipeapp.auth;
import com.recipeapp.auth.dto.*;
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.HouseholdMemberRepository;
import com.recipeapp.household.entity.HouseholdMember;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthServiceImpl implements AuthService {
private final UserAccountRepository userAccountRepository;
private final HouseholdMemberRepository householdMemberRepository;
private final PasswordEncoder passwordEncoder;
public AuthServiceImpl(UserAccountRepository userAccountRepository,
HouseholdMemberRepository householdMemberRepository,
PasswordEncoder passwordEncoder) {
this.userAccountRepository = userAccountRepository;
this.householdMemberRepository = householdMemberRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
@Transactional
public UserResponse signup(SignupRequest request) {
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
throw new ConflictException("Email already registered");
}
var user = new UserAccount(
request.email(),
request.displayName(),
passwordEncoder.encode(request.password())
);
user = userAccountRepository.save(user);
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
@Override
@Transactional(readOnly = true)
public UserResponse login(LoginRequest request) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(request.email())
.orElseThrow(() -> new ResourceNotFoundException("Invalid email or password"));
if (!user.isActive()) {
throw new ValidationException("Account is deactivated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new ValidationException("Invalid email or password");
}
return toUserResponse(user);
}
@Override
@Transactional(readOnly = true)
public UserResponse getCurrentUser(String email) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
return toUserResponse(user);
}
@Override
@Transactional
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
UserAccount user = userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
if (request.displayName() != null) {
user.setDisplayName(request.displayName());
}
if (request.newPassword() != null) {
if (request.currentPassword() == null) {
throw new ValidationException("Current password is required to set a new password");
}
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
throw new ValidationException("Current password is incorrect");
}
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
}
user = userAccountRepository.save(user);
return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName());
}
private UserResponse toUserResponse(UserAccount user) {
return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail())
.map(member -> UserResponse.withHousehold(
user.getId(), user.getEmail(), user.getDisplayName(),
member.getHousehold().getId(), member.getHousehold().getName(),
member.getRole(), user.getSystemRole()))
.orElse(UserResponse.withHousehold(
user.getId(), user.getEmail(), user.getDisplayName(),
null, null, null, user.getSystemRole()));
}
}

View File

@@ -1,13 +1,207 @@
package com.recipeapp.household;
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.dto.*;
import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember;
import com.recipeapp.planning.VarietyScoreConfigRepository;
import com.recipeapp.planning.entity.VarietyScoreConfig;
import com.recipeapp.recipe.IngredientCategoryRepository;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.TagRepository;
import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Tag;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
public interface HouseholdService {
HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request);
HouseholdResponse getMyHousehold(String userEmail);
List<MemberResponse> getMembers(String userEmail);
InviteResponse createInvite(String userEmail);
AcceptInviteResponse acceptInvite(String userEmail, String code);
@Service
public class HouseholdService {
private final UserAccountRepository userAccountRepository;
private final HouseholdRepository householdRepository;
private final HouseholdMemberRepository householdMemberRepository;
private final HouseholdInviteRepository householdInviteRepository;
private final IngredientRepository ingredientRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
public HouseholdService(UserAccountRepository userAccountRepository,
HouseholdRepository householdRepository,
HouseholdMemberRepository householdMemberRepository,
HouseholdInviteRepository householdInviteRepository,
IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository;
this.householdInviteRepository = householdInviteRepository;
this.ingredientRepository = ingredientRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
}
@Transactional
public HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
}
Household household = householdRepository.save(new Household(request.name(), user));
HouseholdMember member = householdMemberRepository.save(
new HouseholdMember(household, user, "planner"));
seedDefaultData(household);
return toHouseholdResponse(household, List.of(member));
}
@Transactional(readOnly = true)
public HouseholdResponse getMyHousehold(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
List<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
return toHouseholdResponse(household, members);
}
@Transactional(readOnly = true)
public List<MemberResponse> getMembers(String userEmail) {
HouseholdMember member = findMembership(userEmail);
return householdMemberRepository.findByHouseholdId(member.getHousehold().getId())
.stream()
.map(this::toMemberResponse)
.toList();
}
@Transactional
public InviteResponse createInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save(
new HouseholdInvite(household, code, expiresAt));
return new InviteResponse(
invite.getInviteCode(),
"https://yourapp.com/join/" + invite.getInviteCode(),
invite.getExpiresAt());
}
@Transactional
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
}
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
if ("used".equals(invite.getStatus())) {
throw new ConflictException("Invite code already used");
}
if (invite.getExpiresAt().isBefore(Instant.now())) {
throw new ValidationException("Invite code has expired");
}
invite.setStatus("used");
householdInviteRepository.save(invite);
Household household = invite.getHousehold();
householdMemberRepository.save(new HouseholdMember(household, user, "member"));
return new AcceptInviteResponse(household.getId(), household.getName(), "member");
}
private void seedDefaultData(Household household) {
var categories = ingredientCategoryRepository.saveAll(List.of(
new IngredientCategory(household, "Produce", (short) 1),
new IngredientCategory(household, "Fish & Meat", (short) 2),
new IngredientCategory(household, "Dairy & Eggs", (short) 3),
new IngredientCategory(household, "Dry Goods & Pasta", (short) 4),
new IngredientCategory(household, "Canned & Jarred", (short) 5),
new IngredientCategory(household, "Sauces & Condiments", (short) 6),
new IngredientCategory(household, "Frozen", (short) 7),
new IngredientCategory(household, "Bakery & Bread", (short) 8)));
tagRepository.saveAll(List.of(
new Tag(household, "Chicken", "protein"),
new Tag(household, "Fish", "protein"),
new Tag(household, "Beef", "protein"),
new Tag(household, "Pork", "protein"),
new Tag(household, "Vegetarian", "dietary"),
new Tag(household, "Vegan", "dietary"),
new Tag(household, "Pasta", "cuisine"),
new Tag(household, "Quick meal", "other"),
new Tag(household, "Child-friendly", "other")));
ingredientRepository.saveAll(List.of(
new Ingredient(household, "Salt", true),
new Ingredient(household, "Pepper", true),
new Ingredient(household, "Olive oil", true),
new Ingredient(household, "Butter", true),
new Ingredient(household, "Garlic", true),
new Ingredient(household, "Onion", true),
new Ingredient(household, "Sugar", true),
new Ingredient(household, "Flour", true),
new Ingredient(household, "Rice", true),
new Ingredient(household, "Pasta", true)));
varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household));
}
private String generateInviteCode() {
var sb = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private UserAccount findUser(String email) {
return userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
private HouseholdMember findMembership(String email) {
return householdMemberRepository.findByUserEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
}
private HouseholdResponse toHouseholdResponse(Household household, List<HouseholdMember> members) {
return new HouseholdResponse(
household.getId(),
household.getName(),
members.stream().map(this::toMemberResponse).toList());
}
private MemberResponse toMemberResponse(HouseholdMember member) {
return new MemberResponse(
member.getUser().getId(),
member.getUser().getDisplayName(),
member.getRole(),
member.getJoinedAt());
}
}

View File

@@ -1,212 +0,0 @@
package com.recipeapp.household;
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.dto.*;
import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember;
import com.recipeapp.planning.VarietyScoreConfigRepository;
import com.recipeapp.planning.entity.VarietyScoreConfig;
import com.recipeapp.recipe.IngredientCategoryRepository;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.TagRepository;
import com.recipeapp.recipe.entity.Ingredient;
import com.recipeapp.recipe.entity.IngredientCategory;
import com.recipeapp.recipe.entity.Tag;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
@Service
public class HouseholdServiceImpl implements HouseholdService {
private final UserAccountRepository userAccountRepository;
private final HouseholdRepository householdRepository;
private final HouseholdMemberRepository householdMemberRepository;
private final HouseholdInviteRepository householdInviteRepository;
private final IngredientRepository ingredientRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
public HouseholdServiceImpl(UserAccountRepository userAccountRepository,
HouseholdRepository householdRepository,
HouseholdMemberRepository householdMemberRepository,
HouseholdInviteRepository householdInviteRepository,
IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository;
this.householdInviteRepository = householdInviteRepository;
this.ingredientRepository = ingredientRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
}
@Override
@Transactional
public HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
}
Household household = householdRepository.save(new Household(request.name(), user));
HouseholdMember member = householdMemberRepository.save(
new HouseholdMember(household, user, "planner"));
seedDefaultData(household);
return toHouseholdResponse(household, List.of(member));
}
@Override
@Transactional(readOnly = true)
public HouseholdResponse getMyHousehold(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
List<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
return toHouseholdResponse(household, members);
}
@Override
@Transactional(readOnly = true)
public List<MemberResponse> getMembers(String userEmail) {
HouseholdMember member = findMembership(userEmail);
return householdMemberRepository.findByHouseholdId(member.getHousehold().getId())
.stream()
.map(this::toMemberResponse)
.toList();
}
@Override
@Transactional
public InviteResponse createInvite(String userEmail) {
HouseholdMember member = findMembership(userEmail);
Household household = member.getHousehold();
String code = generateInviteCode();
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
HouseholdInvite invite = householdInviteRepository.save(
new HouseholdInvite(household, code, expiresAt));
return new InviteResponse(
invite.getInviteCode(),
"https://yourapp.com/join/" + invite.getInviteCode(),
invite.getExpiresAt());
}
@Override
@Transactional
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
UserAccount user = findUser(userEmail);
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
throw new ConflictException("User is already in a household");
}
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
if ("used".equals(invite.getStatus())) {
throw new ConflictException("Invite code already used");
}
if (invite.getExpiresAt().isBefore(Instant.now())) {
throw new ValidationException("Invite code has expired");
}
invite.setStatus("used");
householdInviteRepository.save(invite);
Household household = invite.getHousehold();
householdMemberRepository.save(new HouseholdMember(household, user, "member"));
return new AcceptInviteResponse(household.getId(), household.getName(), "member");
}
private void seedDefaultData(Household household) {
var categories = ingredientCategoryRepository.saveAll(List.of(
new IngredientCategory(household, "Produce", (short) 1),
new IngredientCategory(household, "Fish & Meat", (short) 2),
new IngredientCategory(household, "Dairy & Eggs", (short) 3),
new IngredientCategory(household, "Dry Goods & Pasta", (short) 4),
new IngredientCategory(household, "Canned & Jarred", (short) 5),
new IngredientCategory(household, "Sauces & Condiments", (short) 6),
new IngredientCategory(household, "Frozen", (short) 7),
new IngredientCategory(household, "Bakery & Bread", (short) 8)));
tagRepository.saveAll(List.of(
new Tag(household, "Chicken", "protein"),
new Tag(household, "Fish", "protein"),
new Tag(household, "Beef", "protein"),
new Tag(household, "Pork", "protein"),
new Tag(household, "Vegetarian", "dietary"),
new Tag(household, "Vegan", "dietary"),
new Tag(household, "Pasta", "cuisine"),
new Tag(household, "Quick meal", "other"),
new Tag(household, "Child-friendly", "other")));
ingredientRepository.saveAll(List.of(
new Ingredient(household, "Salt", true),
new Ingredient(household, "Pepper", true),
new Ingredient(household, "Olive oil", true),
new Ingredient(household, "Butter", true),
new Ingredient(household, "Garlic", true),
new Ingredient(household, "Onion", true),
new Ingredient(household, "Sugar", true),
new Ingredient(household, "Flour", true),
new Ingredient(household, "Rice", true),
new Ingredient(household, "Pasta", true)));
varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household));
}
private String generateInviteCode() {
var sb = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private UserAccount findUser(String email) {
return userAccountRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
private HouseholdMember findMembership(String email) {
return householdMemberRepository.findByUserEmailIgnoreCase(email)
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
}
private HouseholdResponse toHouseholdResponse(Household household, List<HouseholdMember> members) {
return new HouseholdResponse(
household.getId(),
household.getName(),
members.stream().map(this::toMemberResponse).toList());
}
private MemberResponse toMemberResponse(HouseholdMember member) {
return new MemberResponse(
member.getUser().getId(),
member.getUser().getDisplayName(),
member.getRole(),
member.getJoinedAt());
}
}

View File

@@ -1,17 +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;
public interface PantryService {
@Service
@Transactional
public class PantryService {
List<PantryItemResponse> listItems(UUID householdId);
private final PantryItemRepository pantryItemRepository;
private final HouseholdRepository householdRepository;
private final IngredientRepository ingredientRepository;
PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request);
public PantryService(PantryItemRepository pantryItemRepository,
HouseholdRepository householdRepository,
IngredientRepository ingredientRepository) {
this.pantryItemRepository = pantryItemRepository;
this.householdRepository = householdRepository;
this.ingredientRepository = ingredientRepository;
}
PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request);
void deleteItem(UUID householdId, UUID itemId);
@Transactional(readOnly = true)
public List<PantryItemResponse> listItems(UUID householdId) {
return pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(householdId)
.stream()
.map(this::toResponse)
.toList();
}
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);
}
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);
}
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

@@ -1,98 +0,0 @@
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

@@ -1,30 +1,409 @@
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.List;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;
public interface PlanningService {
@Service
public class PlanningService {
WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart);
private final WeekPlanRepository weekPlanRepository;
private final WeekPlanSlotRepository weekPlanSlotRepository;
private final CookingLogRepository cookingLogRepository;
private final RecipeRepository recipeRepository;
private final HouseholdRepository householdRepository;
private final UserAccountRepository userAccountRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart);
public PlanningService(WeekPlanRepository weekPlanRepository,
WeekPlanSlotRepository weekPlanSlotRepository,
CookingLogRepository cookingLogRepository,
RecipeRepository recipeRepository,
HouseholdRepository householdRepository,
UserAccountRepository userAccountRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
this.weekPlanRepository = weekPlanRepository;
this.weekPlanSlotRepository = weekPlanSlotRepository;
this.cookingLogRepository = cookingLogRepository;
this.recipeRepository = recipeRepository;
this.householdRepository = householdRepository;
this.userAccountRepository = userAccountRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
}
SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request);
@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);
}
SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request);
@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);
}
void deleteSlot(UUID householdId, UUID planId, UUID slotId);
@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);
}
WeekPlanResponse confirmPlan(UUID householdId, UUID planId);
@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);
}
SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
List<String> tagFilters, Integer topN);
@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);
}
VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId);
@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);
}
CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request);
@Transactional(readOnly = true)
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
List<String> tagFilters, Integer topN) {
WeekPlan plan = findPlan(planId, householdId);
int limit = (topN != null) ? topN : 5;
if (limit <= 0) {
return new SuggestionResponse(List.of());
}
List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset);
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
Set<UUID> usedRecipeIds = plan.getSlots().stream()
.map(s -> s.getRecipe().getId())
.collect(Collectors.toSet());
Set<UUID> recentlyCookedIds = cookingLogRepository
.findByHouseholdIdAndCookedOnAfter(householdId,
plan.getWeekStart().minusDays(config.getHistoryDays()))
.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
Set<String> lowerTagFilters = tagFilters.stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
.filter(r -> !usedRecipeIds.contains(r.getId()))
.filter(r -> matchesAllTags(r, lowerTagFilters))
.map(candidate -> {
double score = simulateVarietyScore(
plan, candidate, slotDate, config, recentlyCookedIds);
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
})
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
.limit(limit)
.toList();
return new SuggestionResponse(suggestions);
}
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
if (lowerTagFilters.isEmpty()) return true;
Set<String> recipeTags = recipe.getTags().stream()
.map(t -> t.getName().toLowerCase())
.collect(Collectors.toSet());
return recipeTags.containsAll(lowerTagFilters);
}
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
}
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long tagRepeatCount = tagDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long ingredientOverlapCount = ingredientDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream()
.map(s -> s.recipe.getId())
.distinct()
.filter(recentlyCookedIds::contains)
.count();
// 4. Duplicate recipes within the simulated plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1)
.mapToLong(c -> c - 1)
.sum();
double score = 10.0;
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@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(), List.of(), List.of());
}
// Load config (or use defaults)
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
int historyDays = config.getHistoryDays();
// 1. Tag-type repeats on consecutive days
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (Tag tag : slot.getRecipe().getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(),
k -> new TagAccumulator(tag.getTagType()))
.addDay(slot.getSlotDate());
}
}
}
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
.filter(e -> hasConsecutiveDays(e.getValue().days))
.map(e -> new VarietyScoreResponse.TagRepeat(
e.getKey(), e.getValue().tagType, e.getValue().days))
.toList();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
if (!ri.getIngredient().isStaple()) {
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();
// 3. Recent repeats from cooking log
LocalDate referenceDate = plan.getWeekStart();
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
householdId, referenceDate.minusDays(historyDays));
Set<UUID> recentlyCookedIds = recentLogs.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
List<String> recentRepeats = slots.stream()
.map(s -> s.getRecipe())
.filter(r -> recentlyCookedIds.contains(r.getId()))
.map(Recipe::getName)
.distinct()
.toList();
// 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
List<String> duplicatesInPlan = new ArrayList<>();
long duplicatePenaltyCount = 0;
for (var entry : recipeCounts.entrySet()) {
if (entry.getValue() > 1) {
String recipeName = slots.stream()
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
.findFirst()
.map(s -> s.getRecipe().getName())
.orElse("Unknown");
duplicatesInPlan.add(recipeName);
duplicatePenaltyCount += entry.getValue() - 1;
}
}
// Calculate score
double score = 10.0;
score -= tagRepeats.size() * wTagRepeat;
score -= overlaps.size() * wIngredientOverlap;
score -= recentRepeats.size() * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
score = Math.max(0, Math.min(10, score));
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
}
private static class TagAccumulator {
final String tagType;
final List<LocalDate> days = new ArrayList<>();
TagAccumulator(String tagType) { this.tagType = tagType; }
void addDay(LocalDate day) { days.add(day); }
}
@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());
}
@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

@@ -1,419 +0,0 @@
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;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
public PlanningServiceImpl(WeekPlanRepository weekPlanRepository,
WeekPlanSlotRepository weekPlanSlotRepository,
CookingLogRepository cookingLogRepository,
RecipeRepository recipeRepository,
HouseholdRepository householdRepository,
UserAccountRepository userAccountRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
this.weekPlanRepository = weekPlanRepository;
this.weekPlanSlotRepository = weekPlanSlotRepository;
this.cookingLogRepository = cookingLogRepository;
this.recipeRepository = recipeRepository;
this.householdRepository = householdRepository;
this.userAccountRepository = userAccountRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
}
@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,
List<String> tagFilters, Integer topN) {
WeekPlan plan = findPlan(planId, householdId);
int limit = (topN != null) ? topN : 5;
if (limit <= 0) {
return new SuggestionResponse(List.of());
}
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
Set<UUID> usedRecipeIds = plan.getSlots().stream()
.map(s -> s.getRecipe().getId())
.collect(Collectors.toSet());
Set<UUID> recentlyCookedIds = cookingLogRepository
.findByHouseholdIdAndCookedOnAfter(householdId,
plan.getWeekStart().minusDays(config.getHistoryDays()))
.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
Set<String> lowerTagFilters = tagFilters.stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
.filter(r -> !usedRecipeIds.contains(r.getId()))
.filter(r -> matchesAllTags(r, lowerTagFilters))
.map(candidate -> {
double score = simulateVarietyScore(
plan, candidate, slotDate, config, recentlyCookedIds);
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
})
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
.limit(limit)
.toList();
return new SuggestionResponse(suggestions);
}
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
if (lowerTagFilters.isEmpty()) return true;
Set<String> recipeTags = recipe.getTags().stream()
.map(t -> t.getName().toLowerCase())
.collect(Collectors.toSet());
return recipeTags.containsAll(lowerTagFilters);
}
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
}
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long tagRepeatCount = tagDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long ingredientOverlapCount = ingredientDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream()
.map(s -> s.recipe.getId())
.distinct()
.filter(recentlyCookedIds::contains)
.count();
// 4. Duplicate recipes within the simulated plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1)
.mapToLong(c -> c - 1)
.sum();
double score = 10.0;
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@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(), List.of(), List.of());
}
// Load config (or use defaults)
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
int historyDays = config.getHistoryDays();
// 1. Tag-type repeats on consecutive days
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (Tag tag : slot.getRecipe().getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(),
k -> new TagAccumulator(tag.getTagType()))
.addDay(slot.getSlotDate());
}
}
}
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
.filter(e -> hasConsecutiveDays(e.getValue().days))
.map(e -> new VarietyScoreResponse.TagRepeat(
e.getKey(), e.getValue().tagType, e.getValue().days))
.toList();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
if (!ri.getIngredient().isStaple()) {
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();
// 3. Recent repeats from cooking log
LocalDate referenceDate = plan.getWeekStart();
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
householdId, referenceDate.minusDays(historyDays));
Set<UUID> recentlyCookedIds = recentLogs.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
List<String> recentRepeats = slots.stream()
.map(s -> s.getRecipe())
.filter(r -> recentlyCookedIds.contains(r.getId()))
.map(Recipe::getName)
.distinct()
.toList();
// 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
List<String> duplicatesInPlan = new ArrayList<>();
long duplicatePenaltyCount = 0;
for (var entry : recipeCounts.entrySet()) {
if (entry.getValue() > 1) {
String recipeName = slots.stream()
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
.findFirst()
.map(s -> s.getRecipe().getName())
.orElse("Unknown");
duplicatesInPlan.add(recipeName);
duplicatePenaltyCount += entry.getValue() - 1;
}
}
// Calculate score
double score = 10.0;
score -= tagRepeats.size() * wTagRepeat;
score -= overlaps.size() * wIngredientOverlap;
score -= recentRepeats.size() * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
score = Math.max(0, Math.min(10, score));
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
}
private static class TagAccumulator {
final String tagType;
final List<LocalDate> days = new ArrayList<>();
TagAccumulator(String tagType) { this.tagType = tagType; }
void addDay(LocalDate day) { days.add(day); }
}
@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

@@ -1,35 +1,251 @@
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 java.util.List;
import java.util.UUID;
import com.recipeapp.recipe.entity.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public interface RecipeService {
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin,
String sort, int limit, int offset);
@Service
public class RecipeService {
long countRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin);
private final RecipeRepository recipeRepository;
private final IngredientRepository ingredientRepository;
private final TagRepository tagRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final HouseholdRepository householdRepository;
RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId);
public RecipeService(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;
}
RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request);
@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);
}
RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request);
@Transactional(readOnly = true)
public long countRecipes(UUID householdId, String search, String effort,
Boolean isChildFriendly, Integer cookTimeMaxMin) {
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
}
void deleteRecipe(UUID householdId, UUID recipeId);
@Transactional(readOnly = true)
public RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId) {
Recipe recipe = findRecipe(householdId, recipeId);
return toDetailResponse(recipe);
}
List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple);
@Transactional
public RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request) {
Household household = householdRepository.findById(householdId)
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request);
Recipe recipe = new Recipe(household, request.name(), request.serves(),
request.cookTimeMin(), request.effort(), request.isChildFriendly());
recipe.setHeroImageUrl(request.heroImageUrl());
List<TagResponse> listTags(UUID householdId);
addIngredients(recipe, household, request.ingredients());
addSteps(recipe, request.steps());
addTags(recipe, request.tagIds());
TagResponse createTag(UUID householdId, TagCreateRequest request);
recipe = recipeRepository.save(recipe);
return toDetailResponse(recipe);
}
List<IngredientCategoryResponse> listCategories(UUID householdId);
@Transactional
public RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request) {
Recipe recipe = findRecipe(householdId, recipeId);
Household household = recipe.getHousehold();
IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request);
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);
}
@Transactional
public void deleteRecipe(UUID householdId, UUID recipeId) {
Recipe recipe = findRecipe(householdId, recipeId);
recipe.setDeletedAt(Instant.now());
}
// ── Ingredients ──
@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();
}
@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 ──
@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();
}
@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 ──
@Transactional(readOnly = true)
public List<IngredientCategoryResponse> listCategories(UUID householdId) {
return ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(householdId).stream()
.map(c -> new IngredientCategoryResponse(c.getId(), c.getName()))
.toList();
}
@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

@@ -1,263 +0,0 @@
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

@@ -1,270 +0,0 @@
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

@@ -30,7 +30,7 @@ class AdminServiceTest {
@Mock private AdminUserQueryRepository adminUserQueryRepository;
@Mock private PasswordEncoder passwordEncoder;
private AdminServiceImpl adminService;
private AdminService adminService;
private final String adminEmail = "admin@example.com";
private UserAccount adminUser;
@@ -38,7 +38,7 @@ class AdminServiceTest {
@BeforeEach
void setUp() {
adminService = new AdminServiceImpl(userAccountRepository, auditLogRepository, adminUserQueryRepository, passwordEncoder);
adminService = new AdminService(userAccountRepository, auditLogRepository, adminUserQueryRepository, passwordEncoder);
adminUser = new UserAccount("admin@example.com", "Admin", "hashed");
setId(adminUser, UserAccount.class, UUID.randomUUID());
targetUser = new UserAccount("jane@example.com", "Jane", "hashed");

View File

@@ -35,7 +35,7 @@ class AuthServiceTest {
private PasswordEncoder passwordEncoder;
@InjectMocks
private AuthServiceImpl authService;
private AuthService authService;
@Test
void signupShouldCreateUserAndReturnResponse() {

View File

@@ -40,7 +40,7 @@ class HouseholdServiceTest {
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
@InjectMocks
private HouseholdServiceImpl householdService;
private HouseholdService householdService;
private UserAccount testUser() {
return new UserAccount("sarah@example.com", "Sarah", "hashed");

View File

@@ -32,7 +32,7 @@ class PantryServiceTest {
@Mock private HouseholdRepository householdRepository;
@Mock private IngredientRepository ingredientRepository;
@InjectMocks private PantryServiceImpl pantryService;
@InjectMocks private PantryService pantryService;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();

View File

@@ -37,7 +37,7 @@ class PlanningServiceTest {
@Mock private UserAccountRepository userAccountRepository;
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
@InjectMocks private PlanningServiceImpl planningService;
@InjectMocks private PlanningService planningService;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
private static final LocalDate WEEK_START = LocalDate.of(2026, 4, 6); // Monday

View File

@@ -38,7 +38,7 @@ class SuggestionsTest {
@Mock private UserAccountRepository userAccountRepository;
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
private PlanningServiceImpl planningService;
private PlanningService planningService;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
private static final LocalDate MONDAY = LocalDate.of(2026, 4, 6);
@@ -47,7 +47,7 @@ class SuggestionsTest {
@BeforeEach
void setUp() {
planningService = new PlanningServiceImpl(
planningService = new PlanningService(
weekPlanRepository, weekPlanSlotRepository, cookingLogRepository,
recipeRepository, householdRepository, userAccountRepository,
varietyScoreConfigRepository);

View File

@@ -38,7 +38,7 @@ class VarietyScoreTest {
@Mock private UserAccountRepository userAccountRepository;
@Mock private VarietyScoreConfigRepository varietyScoreConfigRepository;
private PlanningServiceImpl planningService;
private PlanningService planningService;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();
private static final LocalDate MONDAY = LocalDate.of(2026, 4, 6);
@@ -47,7 +47,7 @@ class VarietyScoreTest {
@BeforeEach
void setUp() {
planningService = new PlanningServiceImpl(
planningService = new PlanningService(
weekPlanRepository, weekPlanSlotRepository, cookingLogRepository,
recipeRepository, householdRepository, userAccountRepository,
varietyScoreConfigRepository);

View File

@@ -28,7 +28,7 @@ class RecipeServiceTest {
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
@Mock private HouseholdRepository householdRepository;
@InjectMocks private RecipeServiceImpl recipeService;
@InjectMocks private RecipeService recipeService;
private static final UUID HOUSEHOLD_ID = UUID.randomUUID();