diff --git a/backend/src/main/java/com/recipeapp/admin/AdminService.java b/backend/src/main/java/com/recipeapp/admin/AdminService.java index 4127e1f..8ee1372 100644 --- a/backend/src/main/java/com/recipeapp/admin/AdminService.java +++ b/backend/src/main/java/com/recipeapp/admin/AdminService.java @@ -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 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 listAuditLog(UUID targetUserId, int limit, int offset); - record ListUsersResult(List 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 actions = new ArrayList<>(); + Map 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 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 listAuditLog(UUID targetUserId, int limit, int offset) { + Pageable pageable = PageRequest.of(offset / limit, limit); + List 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()); + } } diff --git a/backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java b/backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java deleted file mode 100644 index f453bc2..0000000 --- a/backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java +++ /dev/null @@ -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 actions = new ArrayList<>(); - Map 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 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 listAuditLog(UUID targetUserId, int limit, int offset) { - Pageable pageable = PageRequest.of(offset / limit, limit); - List 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()); - } -} diff --git a/backend/src/main/java/com/recipeapp/auth/AuthService.java b/backend/src/main/java/com/recipeapp/auth/AuthService.java index 0905fc2..1f007c6 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthService.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthService.java @@ -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())); + } } diff --git a/backend/src/main/java/com/recipeapp/auth/AuthServiceImpl.java b/backend/src/main/java/com/recipeapp/auth/AuthServiceImpl.java deleted file mode 100644 index 3e132ba..0000000 --- a/backend/src/main/java/com/recipeapp/auth/AuthServiceImpl.java +++ /dev/null @@ -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())); - } -} diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 7e89213..242f1d5 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -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 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 members = householdMemberRepository.findByHouseholdId(household.getId()); + return toHouseholdResponse(household, members); + } + + @Transactional(readOnly = true) + public List 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 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()); + } } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java b/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java deleted file mode 100644 index 723a863..0000000 --- a/backend/src/main/java/com/recipeapp/household/HouseholdServiceImpl.java +++ /dev/null @@ -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 members = householdMemberRepository.findByHouseholdId(household.getId()); - return toHouseholdResponse(household, members); - } - - @Override - @Transactional(readOnly = true) - public List 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 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()); - } -} diff --git a/backend/src/main/java/com/recipeapp/pantry/PantryService.java b/backend/src/main/java/com/recipeapp/pantry/PantryService.java index 33c5e25..7ab0ec3 100644 --- a/backend/src/main/java/com/recipeapp/pantry/PantryService.java +++ b/backend/src/main/java/com/recipeapp/pantry/PantryService.java @@ -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 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 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()); + } } diff --git a/backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java b/backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java deleted file mode 100644 index e5518d2..0000000 --- a/backend/src/main/java/com/recipeapp/pantry/PantryServiceImpl.java +++ /dev/null @@ -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 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()); - } -} diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 719188d..048c058 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -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 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 tagFilters, Integer topN) { + WeekPlan plan = findPlan(planId, householdId); + int limit = (topN != null) ? topN : 5; + if (limit <= 0) { + return new SuggestionResponse(List.of()); + } - List listCookingLogs(UUID householdId, int limit, int offset); + VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId) + .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); + + Set usedRecipeIds = plan.getSlots().stream() + .map(s -> s.getRecipe().getId()) + .collect(Collectors.toSet()); + + Set recentlyCookedIds = cookingLogRepository + .findByHouseholdIdAndCookedOnAfter(householdId, + plan.getWeekStart().minusDays(config.getHistoryDays())) + .stream() + .map(cl -> cl.getRecipe().getId()) + .collect(Collectors.toSet()); + + List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); + + Set lowerTagFilters = tagFilters.stream() + .map(String::toLowerCase) + .collect(Collectors.toSet()); + + List 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 lowerTagFilters) { + if (lowerTagFilters.isEmpty()) return true; + Set 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 recentlyCookedIds) { + // Build a simulated slot list: existing slots + candidate on slotDate + List simulatedSlots = new ArrayList<>(); + for (WeekPlanSlot slot : plan.getSlots()) { + simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); + } + simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); + + List 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> 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> 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 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 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 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 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 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> 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 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 recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter( + householdId, referenceDate.minusDays(historyDays)); + Set recentlyCookedIds = recentLogs.stream() + .map(cl -> cl.getRecipe().getId()) + .collect(Collectors.toSet()); + + List 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 recipeCounts = slots.stream() + .collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting())); + + List 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 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 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 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 days) { + if (days.size() < 2) return false; + List 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; + } } diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java b/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java deleted file mode 100644 index 6fe724f..0000000 --- a/backend/src/main/java/com/recipeapp/planning/PlanningServiceImpl.java +++ /dev/null @@ -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 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 usedRecipeIds = plan.getSlots().stream() - .map(s -> s.getRecipe().getId()) - .collect(Collectors.toSet()); - - Set recentlyCookedIds = cookingLogRepository - .findByHouseholdIdAndCookedOnAfter(householdId, - plan.getWeekStart().minusDays(config.getHistoryDays())) - .stream() - .map(cl -> cl.getRecipe().getId()) - .collect(Collectors.toSet()); - - List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); - - Set lowerTagFilters = tagFilters.stream() - .map(String::toLowerCase) - .collect(Collectors.toSet()); - - List 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 lowerTagFilters) { - if (lowerTagFilters.isEmpty()) return true; - Set 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 recentlyCookedIds) { - // Build a simulated slot list: existing slots + candidate on slotDate - List simulatedSlots = new ArrayList<>(); - for (WeekPlanSlot slot : plan.getSlots()) { - simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate())); - } - simulatedSlots.add(new SimulatedSlot(candidate, slotDate)); - - List 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> 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> 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 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 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 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 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 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> 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 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 recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter( - householdId, referenceDate.minusDays(historyDays)); - Set recentlyCookedIds = recentLogs.stream() - .map(cl -> cl.getRecipe().getId()) - .collect(Collectors.toSet()); - - List 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 recipeCounts = slots.stream() - .collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting())); - - List 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 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 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 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 days) { - if (days.size() < 2) return false; - List 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; - } -} diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java index 0123eb9..497ed2f 100644 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeService.java +++ b/backend/src/main/java/com/recipeapp/recipe/RecipeService.java @@ -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 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 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 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 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 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 searchIngredients(UUID householdId, String search, Boolean isStaple) { + List 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 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 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 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 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 tagIds) { + if (tagIds == null || tagIds.isEmpty()) return; + List 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()); + } } diff --git a/backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java b/backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java deleted file mode 100644 index 00513ce..0000000 --- a/backend/src/main/java/com/recipeapp/recipe/RecipeServiceImpl.java +++ /dev/null @@ -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 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 searchIngredients(UUID householdId, String search, Boolean isStaple) { - List 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 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 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 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 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 tagIds) { - if (tagIds == null || tagIds.isEmpty()) return; - List 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()); - } -} diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java deleted file mode 100644 index ea8d541..0000000 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingServiceImpl.java +++ /dev/null @@ -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 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 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 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; - } - } -} diff --git a/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java b/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java index f94a3d0..501999e 100644 --- a/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/recipeapp/admin/AdminServiceTest.java @@ -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"); diff --git a/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java b/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java index c8581c1..48f0ec9 100644 --- a/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/recipeapp/auth/AuthServiceTest.java @@ -35,7 +35,7 @@ class AuthServiceTest { private PasswordEncoder passwordEncoder; @InjectMocks - private AuthServiceImpl authService; + private AuthService authService; @Test void signupShouldCreateUserAndReturnResponse() { diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 0f65c7f..baf3c32 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -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"); diff --git a/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java b/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java index 7563a14..1c72865 100644 --- a/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java +++ b/backend/src/test/java/com/recipeapp/pantry/PantryServiceTest.java @@ -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(); diff --git a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java index 2a5a86d..670c50b 100644 --- a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java +++ b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java @@ -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 diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index ec5c2fd..3e7495f 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -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); diff --git a/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java b/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java index 65e8029..dfcc8d9 100644 --- a/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java +++ b/backend/src/test/java/com/recipeapp/planning/VarietyScoreTest.java @@ -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); diff --git a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java index dbf793a..8b62c2f 100644 --- a/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java +++ b/backend/src/test/java/com/recipeapp/recipe/RecipeServiceTest.java @@ -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();