Remove service interfaces — use concrete classes directly
Each domain had a single-implementation interface (e.g. AdminService interface + AdminServiceImpl). Merged implementation into the service class and deleted the redundant interfaces per KISS principle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,169 @@
|
||||
package com.recipeapp.admin;
|
||||
|
||||
import com.recipeapp.admin.dto.*;
|
||||
import com.recipeapp.admin.entity.AdminAuditLog;
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
|
||||
public interface AdminService {
|
||||
@Service
|
||||
@Transactional
|
||||
public class AdminService {
|
||||
|
||||
ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset);
|
||||
public record ListUsersResult(List<AdminUserResponse> users, long total) {}
|
||||
|
||||
AdminUserResponse createUser(CreateUserRequest request, String adminEmail);
|
||||
|
||||
AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail);
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final AdminAuditLogRepository auditLogRepository;
|
||||
private final AdminUserQueryRepository adminUserQueryRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail);
|
||||
public AdminService(UserAccountRepository userAccountRepository,
|
||||
AdminAuditLogRepository auditLogRepository,
|
||||
AdminUserQueryRepository adminUserQueryRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.auditLogRepository = auditLogRepository;
|
||||
this.adminUserQueryRepository = adminUserQueryRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
List<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset);
|
||||
|
||||
record ListUsersResult(List<AdminUserResponse> users, long total) {}
|
||||
@Transactional(readOnly = true)
|
||||
public ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset) {
|
||||
Pageable pageable = PageRequest.of(offset / limit, limit);
|
||||
var users = adminUserQueryRepository.findUsersFiltered(search, isActive, pageable);
|
||||
long total = adminUserQueryRepository.countUsersFiltered(search, isActive);
|
||||
var responses = users.stream().map(this::toAdminUserResponse).toList();
|
||||
return new ListUsersResult(responses, total);
|
||||
}
|
||||
|
||||
|
||||
public AdminUserResponse createUser(CreateUserRequest request, String adminEmail) {
|
||||
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
|
||||
throw new ConflictException("A user with this email already exists");
|
||||
}
|
||||
|
||||
var admin = resolveAdmin(adminEmail);
|
||||
String hashedPassword = passwordEncoder.encode(request.tempPassword());
|
||||
var user = new UserAccount(request.email(), request.displayName(), hashedPassword);
|
||||
if (request.systemRole() != null) {
|
||||
user.setSystemRole(request.systemRole());
|
||||
}
|
||||
user = userAccountRepository.save(user);
|
||||
|
||||
auditLogRepository.save(new AdminAuditLog(
|
||||
admin.getId(), user.getId(), "create_account",
|
||||
Map.of("email", request.email(), "displayName", request.displayName()), null));
|
||||
|
||||
return toAdminUserResponse(user);
|
||||
}
|
||||
|
||||
|
||||
public AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail) {
|
||||
var admin = resolveAdmin(adminEmail);
|
||||
var user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
List<String> actions = new ArrayList<>();
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
|
||||
if (request.displayName() != null) {
|
||||
detail.put("displayName", request.displayName());
|
||||
user.setDisplayName(request.displayName());
|
||||
actions.add("update_account");
|
||||
}
|
||||
if (request.email() != null) {
|
||||
if (!request.email().equalsIgnoreCase(user.getEmail())
|
||||
&& userAccountRepository.existsByEmailIgnoreCase(request.email())) {
|
||||
throw new ConflictException("A user with this email already exists");
|
||||
}
|
||||
detail.put("email", request.email());
|
||||
user.setEmail(request.email());
|
||||
actions.add("update_account");
|
||||
}
|
||||
if (request.systemRole() != null && !request.systemRole().equals(user.getSystemRole())) {
|
||||
detail.put("systemRole", request.systemRole());
|
||||
detail.put("previousSystemRole", user.getSystemRole());
|
||||
user.setSystemRole(request.systemRole());
|
||||
actions.add("change_system_role");
|
||||
}
|
||||
if (request.isActive() != null && request.isActive() != user.isActive()) {
|
||||
detail.put("isActive", request.isActive());
|
||||
user.setActive(request.isActive());
|
||||
actions.add(request.isActive() ? "reactivate_account" : "deactivate_account");
|
||||
}
|
||||
|
||||
user = userAccountRepository.save(user);
|
||||
|
||||
String action = actions.isEmpty() ? "update_account" : actions.getLast();
|
||||
auditLogRepository.save(new AdminAuditLog(
|
||||
admin.getId(), user.getId(), action, detail, null));
|
||||
|
||||
return toAdminUserResponse(user);
|
||||
}
|
||||
|
||||
|
||||
public ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail) {
|
||||
var admin = resolveAdmin(adminEmail);
|
||||
var user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.tempPassword()));
|
||||
userAccountRepository.save(user);
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
if (request.reason() != null) {
|
||||
detail.put("reason", request.reason());
|
||||
}
|
||||
auditLogRepository.save(new AdminAuditLog(
|
||||
admin.getId(), user.getId(), "reset_password", detail, null));
|
||||
|
||||
return new ResetPasswordResponse("Password reset successfully", true);
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset) {
|
||||
Pageable pageable = PageRequest.of(offset / limit, limit);
|
||||
List<AdminAuditLog> logs;
|
||||
if (targetUserId != null) {
|
||||
logs = auditLogRepository.findByTargetUserIdOrderByPerformedAtDesc(targetUserId, pageable);
|
||||
} else {
|
||||
logs = auditLogRepository.findAllByOrderByPerformedAtDesc(pageable);
|
||||
}
|
||||
|
||||
return logs.stream().map(log -> {
|
||||
String adminEmail = userAccountRepository.findById(log.getAdminId())
|
||||
.map(UserAccount::getEmail).orElse(null);
|
||||
String targetEmail = userAccountRepository.findById(log.getTargetUserId())
|
||||
.map(UserAccount::getEmail).orElse(null);
|
||||
return new AuditLogResponse(
|
||||
log.getId(), log.getAdminId(), adminEmail,
|
||||
log.getTargetUserId(), targetEmail,
|
||||
log.getAction(), log.getDetail(), log.getPerformedAt());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private UserAccount resolveAdmin(String adminEmail) {
|
||||
return userAccountRepository.findByEmailIgnoreCase(adminEmail)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Admin user not found"));
|
||||
}
|
||||
|
||||
private AdminUserResponse toAdminUserResponse(UserAccount user) {
|
||||
return new AdminUserResponse(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(),
|
||||
user.getSystemRole(), user.isActive(), user.getCreatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package com.recipeapp.admin;
|
||||
|
||||
import com.recipeapp.admin.dto.*;
|
||||
import com.recipeapp.admin.entity.AdminAuditLog;
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class AdminServiceImpl implements AdminService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final AdminAuditLogRepository auditLogRepository;
|
||||
private final AdminUserQueryRepository adminUserQueryRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AdminServiceImpl(UserAccountRepository userAccountRepository,
|
||||
AdminAuditLogRepository auditLogRepository,
|
||||
AdminUserQueryRepository adminUserQueryRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.auditLogRepository = auditLogRepository;
|
||||
this.adminUserQueryRepository = adminUserQueryRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ListUsersResult listUsers(String search, Boolean isActive, int limit, int offset) {
|
||||
Pageable pageable = PageRequest.of(offset / limit, limit);
|
||||
var users = adminUserQueryRepository.findUsersFiltered(search, isActive, pageable);
|
||||
long total = adminUserQueryRepository.countUsersFiltered(search, isActive);
|
||||
var responses = users.stream().map(this::toAdminUserResponse).toList();
|
||||
return new ListUsersResult(responses, total);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUserResponse createUser(CreateUserRequest request, String adminEmail) {
|
||||
if (userAccountRepository.existsByEmailIgnoreCase(request.email())) {
|
||||
throw new ConflictException("A user with this email already exists");
|
||||
}
|
||||
|
||||
var admin = resolveAdmin(adminEmail);
|
||||
String hashedPassword = passwordEncoder.encode(request.tempPassword());
|
||||
var user = new UserAccount(request.email(), request.displayName(), hashedPassword);
|
||||
if (request.systemRole() != null) {
|
||||
user.setSystemRole(request.systemRole());
|
||||
}
|
||||
user = userAccountRepository.save(user);
|
||||
|
||||
auditLogRepository.save(new AdminAuditLog(
|
||||
admin.getId(), user.getId(), "create_account",
|
||||
Map.of("email", request.email(), "displayName", request.displayName()), null));
|
||||
|
||||
return toAdminUserResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminUserResponse updateUser(UUID userId, UpdateUserRequest request, String adminEmail) {
|
||||
var admin = resolveAdmin(adminEmail);
|
||||
var user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
List<String> actions = new ArrayList<>();
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
|
||||
if (request.displayName() != null) {
|
||||
detail.put("displayName", request.displayName());
|
||||
user.setDisplayName(request.displayName());
|
||||
actions.add("update_account");
|
||||
}
|
||||
if (request.email() != null) {
|
||||
if (!request.email().equalsIgnoreCase(user.getEmail())
|
||||
&& userAccountRepository.existsByEmailIgnoreCase(request.email())) {
|
||||
throw new ConflictException("A user with this email already exists");
|
||||
}
|
||||
detail.put("email", request.email());
|
||||
user.setEmail(request.email());
|
||||
actions.add("update_account");
|
||||
}
|
||||
if (request.systemRole() != null && !request.systemRole().equals(user.getSystemRole())) {
|
||||
detail.put("systemRole", request.systemRole());
|
||||
detail.put("previousSystemRole", user.getSystemRole());
|
||||
user.setSystemRole(request.systemRole());
|
||||
actions.add("change_system_role");
|
||||
}
|
||||
if (request.isActive() != null && request.isActive() != user.isActive()) {
|
||||
detail.put("isActive", request.isActive());
|
||||
user.setActive(request.isActive());
|
||||
actions.add(request.isActive() ? "reactivate_account" : "deactivate_account");
|
||||
}
|
||||
|
||||
user = userAccountRepository.save(user);
|
||||
|
||||
String action = actions.isEmpty() ? "update_account" : actions.getLast();
|
||||
auditLogRepository.save(new AdminAuditLog(
|
||||
admin.getId(), user.getId(), action, detail, null));
|
||||
|
||||
return toAdminUserResponse(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResetPasswordResponse resetPassword(UUID userId, ResetPasswordRequest request, String adminEmail) {
|
||||
var admin = resolveAdmin(adminEmail);
|
||||
var user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.tempPassword()));
|
||||
userAccountRepository.save(user);
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
if (request.reason() != null) {
|
||||
detail.put("reason", request.reason());
|
||||
}
|
||||
auditLogRepository.save(new AdminAuditLog(
|
||||
admin.getId(), user.getId(), "reset_password", detail, null));
|
||||
|
||||
return new ResetPasswordResponse("Password reset successfully", true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<AuditLogResponse> listAuditLog(UUID targetUserId, int limit, int offset) {
|
||||
Pageable pageable = PageRequest.of(offset / limit, limit);
|
||||
List<AdminAuditLog> logs;
|
||||
if (targetUserId != null) {
|
||||
logs = auditLogRepository.findByTargetUserIdOrderByPerformedAtDesc(targetUserId, pageable);
|
||||
} else {
|
||||
logs = auditLogRepository.findAllByOrderByPerformedAtDesc(pageable);
|
||||
}
|
||||
|
||||
return logs.stream().map(log -> {
|
||||
String adminEmail = userAccountRepository.findById(log.getAdminId())
|
||||
.map(UserAccount::getEmail).orElse(null);
|
||||
String targetEmail = userAccountRepository.findById(log.getTargetUserId())
|
||||
.map(UserAccount::getEmail).orElse(null);
|
||||
return new AuditLogResponse(
|
||||
log.getId(), log.getAdminId(), adminEmail,
|
||||
log.getTargetUserId(), targetEmail,
|
||||
log.getAction(), log.getDetail(), log.getPerformedAt());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private UserAccount resolveAdmin(String adminEmail) {
|
||||
return userAccountRepository.findByEmailIgnoreCase(adminEmail)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Admin user not found"));
|
||||
}
|
||||
|
||||
private AdminUserResponse toAdminUserResponse(UserAccount user) {
|
||||
return new AdminUserResponse(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(),
|
||||
user.getSystemRole(), user.isActive(), user.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,207 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.household.entity.HouseholdInvite;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import com.recipeapp.planning.VarietyScoreConfigRepository;
|
||||
import com.recipeapp.planning.entity.VarietyScoreConfig;
|
||||
import com.recipeapp.recipe.IngredientCategoryRepository;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.TagRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface HouseholdService {
|
||||
HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request);
|
||||
HouseholdResponse getMyHousehold(String userEmail);
|
||||
List<MemberResponse> getMembers(String userEmail);
|
||||
InviteResponse createInvite(String userEmail);
|
||||
AcceptInviteResponse acceptInvite(String userEmail, String code);
|
||||
@Service
|
||||
public class HouseholdService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final HouseholdMemberRepository householdMemberRepository;
|
||||
private final HouseholdInviteRepository householdInviteRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
public HouseholdService(UserAccountRepository userAccountRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
HouseholdMemberRepository householdMemberRepository,
|
||||
HouseholdInviteRepository householdInviteRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
TagRepository tagRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
this.householdInviteRepository = householdInviteRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request) {
|
||||
UserAccount user = findUser(userEmail);
|
||||
|
||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
||||
throw new ConflictException("User is already in a household");
|
||||
}
|
||||
|
||||
Household household = householdRepository.save(new Household(request.name(), user));
|
||||
HouseholdMember member = householdMemberRepository.save(
|
||||
new HouseholdMember(household, user, "planner"));
|
||||
|
||||
seedDefaultData(household);
|
||||
|
||||
return toHouseholdResponse(household, List.of(member));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public HouseholdResponse getMyHousehold(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
Household household = member.getHousehold();
|
||||
List<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
|
||||
return toHouseholdResponse(household, members);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MemberResponse> getMembers(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
return householdMemberRepository.findByHouseholdId(member.getHousehold().getId())
|
||||
.stream()
|
||||
.map(this::toMemberResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InviteResponse createInvite(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
Household household = member.getHousehold();
|
||||
|
||||
String code = generateInviteCode();
|
||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.save(
|
||||
new HouseholdInvite(household, code, expiresAt));
|
||||
|
||||
return new InviteResponse(
|
||||
invite.getInviteCode(),
|
||||
"https://yourapp.com/join/" + invite.getInviteCode(),
|
||||
invite.getExpiresAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
|
||||
UserAccount user = findUser(userEmail);
|
||||
|
||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
||||
throw new ConflictException("User is already in a household");
|
||||
}
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
|
||||
|
||||
if ("used".equals(invite.getStatus())) {
|
||||
throw new ConflictException("Invite code already used");
|
||||
}
|
||||
if (invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ValidationException("Invite code has expired");
|
||||
}
|
||||
|
||||
invite.setStatus("used");
|
||||
householdInviteRepository.save(invite);
|
||||
|
||||
Household household = invite.getHousehold();
|
||||
householdMemberRepository.save(new HouseholdMember(household, user, "member"));
|
||||
|
||||
return new AcceptInviteResponse(household.getId(), household.getName(), "member");
|
||||
}
|
||||
|
||||
private void seedDefaultData(Household household) {
|
||||
var categories = ingredientCategoryRepository.saveAll(List.of(
|
||||
new IngredientCategory(household, "Produce", (short) 1),
|
||||
new IngredientCategory(household, "Fish & Meat", (short) 2),
|
||||
new IngredientCategory(household, "Dairy & Eggs", (short) 3),
|
||||
new IngredientCategory(household, "Dry Goods & Pasta", (short) 4),
|
||||
new IngredientCategory(household, "Canned & Jarred", (short) 5),
|
||||
new IngredientCategory(household, "Sauces & Condiments", (short) 6),
|
||||
new IngredientCategory(household, "Frozen", (short) 7),
|
||||
new IngredientCategory(household, "Bakery & Bread", (short) 8)));
|
||||
|
||||
tagRepository.saveAll(List.of(
|
||||
new Tag(household, "Chicken", "protein"),
|
||||
new Tag(household, "Fish", "protein"),
|
||||
new Tag(household, "Beef", "protein"),
|
||||
new Tag(household, "Pork", "protein"),
|
||||
new Tag(household, "Vegetarian", "dietary"),
|
||||
new Tag(household, "Vegan", "dietary"),
|
||||
new Tag(household, "Pasta", "cuisine"),
|
||||
new Tag(household, "Quick meal", "other"),
|
||||
new Tag(household, "Child-friendly", "other")));
|
||||
|
||||
ingredientRepository.saveAll(List.of(
|
||||
new Ingredient(household, "Salt", true),
|
||||
new Ingredient(household, "Pepper", true),
|
||||
new Ingredient(household, "Olive oil", true),
|
||||
new Ingredient(household, "Butter", true),
|
||||
new Ingredient(household, "Garlic", true),
|
||||
new Ingredient(household, "Onion", true),
|
||||
new Ingredient(household, "Sugar", true),
|
||||
new Ingredient(household, "Flour", true),
|
||||
new Ingredient(household, "Rice", true),
|
||||
new Ingredient(household, "Pasta", true)));
|
||||
|
||||
varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household));
|
||||
}
|
||||
|
||||
private String generateInviteCode() {
|
||||
var sb = new StringBuilder(8);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private UserAccount findUser(String email) {
|
||||
return userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
private HouseholdMember findMembership(String email) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||
}
|
||||
|
||||
private HouseholdResponse toHouseholdResponse(Household household, List<HouseholdMember> members) {
|
||||
return new HouseholdResponse(
|
||||
household.getId(),
|
||||
household.getName(),
|
||||
members.stream().map(this::toMemberResponse).toList());
|
||||
}
|
||||
|
||||
private MemberResponse toMemberResponse(HouseholdMember member) {
|
||||
return new MemberResponse(
|
||||
member.getUser().getId(),
|
||||
member.getUser().getDisplayName(),
|
||||
member.getRole(),
|
||||
member.getJoinedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package com.recipeapp.household;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.dto.*;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.household.entity.HouseholdInvite;
|
||||
import com.recipeapp.household.entity.HouseholdMember;
|
||||
import com.recipeapp.planning.VarietyScoreConfigRepository;
|
||||
import com.recipeapp.planning.entity.VarietyScoreConfig;
|
||||
import com.recipeapp.recipe.IngredientCategoryRepository;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.TagRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class HouseholdServiceImpl implements HouseholdService {
|
||||
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final HouseholdMemberRepository householdMemberRepository;
|
||||
private final HouseholdInviteRepository householdInviteRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
public HouseholdServiceImpl(UserAccountRepository userAccountRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
HouseholdMemberRepository householdMemberRepository,
|
||||
HouseholdInviteRepository householdInviteRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
TagRepository tagRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.householdMemberRepository = householdMemberRepository;
|
||||
this.householdInviteRepository = householdInviteRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public HouseholdResponse createHousehold(String userEmail, CreateHouseholdRequest request) {
|
||||
UserAccount user = findUser(userEmail);
|
||||
|
||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
||||
throw new ConflictException("User is already in a household");
|
||||
}
|
||||
|
||||
Household household = householdRepository.save(new Household(request.name(), user));
|
||||
HouseholdMember member = householdMemberRepository.save(
|
||||
new HouseholdMember(household, user, "planner"));
|
||||
|
||||
seedDefaultData(household);
|
||||
|
||||
return toHouseholdResponse(household, List.of(member));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public HouseholdResponse getMyHousehold(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
Household household = member.getHousehold();
|
||||
List<HouseholdMember> members = householdMemberRepository.findByHouseholdId(household.getId());
|
||||
return toHouseholdResponse(household, members);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<MemberResponse> getMembers(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
return householdMemberRepository.findByHouseholdId(member.getHousehold().getId())
|
||||
.stream()
|
||||
.map(this::toMemberResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public InviteResponse createInvite(String userEmail) {
|
||||
HouseholdMember member = findMembership(userEmail);
|
||||
Household household = member.getHousehold();
|
||||
|
||||
String code = generateInviteCode();
|
||||
Instant expiresAt = Instant.now().plusSeconds(48 * 3600);
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.save(
|
||||
new HouseholdInvite(household, code, expiresAt));
|
||||
|
||||
return new InviteResponse(
|
||||
invite.getInviteCode(),
|
||||
"https://yourapp.com/join/" + invite.getInviteCode(),
|
||||
invite.getExpiresAt());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public AcceptInviteResponse acceptInvite(String userEmail, String code) {
|
||||
UserAccount user = findUser(userEmail);
|
||||
|
||||
if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) {
|
||||
throw new ConflictException("User is already in a household");
|
||||
}
|
||||
|
||||
HouseholdInvite invite = householdInviteRepository.findByInviteCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Invite not found"));
|
||||
|
||||
if ("used".equals(invite.getStatus())) {
|
||||
throw new ConflictException("Invite code already used");
|
||||
}
|
||||
if (invite.getExpiresAt().isBefore(Instant.now())) {
|
||||
throw new ValidationException("Invite code has expired");
|
||||
}
|
||||
|
||||
invite.setStatus("used");
|
||||
householdInviteRepository.save(invite);
|
||||
|
||||
Household household = invite.getHousehold();
|
||||
householdMemberRepository.save(new HouseholdMember(household, user, "member"));
|
||||
|
||||
return new AcceptInviteResponse(household.getId(), household.getName(), "member");
|
||||
}
|
||||
|
||||
private void seedDefaultData(Household household) {
|
||||
var categories = ingredientCategoryRepository.saveAll(List.of(
|
||||
new IngredientCategory(household, "Produce", (short) 1),
|
||||
new IngredientCategory(household, "Fish & Meat", (short) 2),
|
||||
new IngredientCategory(household, "Dairy & Eggs", (short) 3),
|
||||
new IngredientCategory(household, "Dry Goods & Pasta", (short) 4),
|
||||
new IngredientCategory(household, "Canned & Jarred", (short) 5),
|
||||
new IngredientCategory(household, "Sauces & Condiments", (short) 6),
|
||||
new IngredientCategory(household, "Frozen", (short) 7),
|
||||
new IngredientCategory(household, "Bakery & Bread", (short) 8)));
|
||||
|
||||
tagRepository.saveAll(List.of(
|
||||
new Tag(household, "Chicken", "protein"),
|
||||
new Tag(household, "Fish", "protein"),
|
||||
new Tag(household, "Beef", "protein"),
|
||||
new Tag(household, "Pork", "protein"),
|
||||
new Tag(household, "Vegetarian", "dietary"),
|
||||
new Tag(household, "Vegan", "dietary"),
|
||||
new Tag(household, "Pasta", "cuisine"),
|
||||
new Tag(household, "Quick meal", "other"),
|
||||
new Tag(household, "Child-friendly", "other")));
|
||||
|
||||
ingredientRepository.saveAll(List.of(
|
||||
new Ingredient(household, "Salt", true),
|
||||
new Ingredient(household, "Pepper", true),
|
||||
new Ingredient(household, "Olive oil", true),
|
||||
new Ingredient(household, "Butter", true),
|
||||
new Ingredient(household, "Garlic", true),
|
||||
new Ingredient(household, "Onion", true),
|
||||
new Ingredient(household, "Sugar", true),
|
||||
new Ingredient(household, "Flour", true),
|
||||
new Ingredient(household, "Rice", true),
|
||||
new Ingredient(household, "Pasta", true)));
|
||||
|
||||
varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household));
|
||||
}
|
||||
|
||||
private String generateInviteCode() {
|
||||
var sb = new StringBuilder(8);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private UserAccount findUser(String email) {
|
||||
return userAccountRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
}
|
||||
|
||||
private HouseholdMember findMembership(String email) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||
}
|
||||
|
||||
private HouseholdResponse toHouseholdResponse(Household household, List<HouseholdMember> members) {
|
||||
return new HouseholdResponse(
|
||||
household.getId(),
|
||||
household.getName(),
|
||||
members.stream().map(this::toMemberResponse).toList());
|
||||
}
|
||||
|
||||
private MemberResponse toMemberResponse(HouseholdMember member) {
|
||||
return new MemberResponse(
|
||||
member.getUser().getId(),
|
||||
member.getUser().getDisplayName(),
|
||||
member.getRole(),
|
||||
member.getJoinedAt());
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,98 @@
|
||||
package com.recipeapp.pantry;
|
||||
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.pantry.dto.*;
|
||||
import com.recipeapp.pantry.entity.PantryItem;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PantryService {
|
||||
@Service
|
||||
@Transactional
|
||||
public class PantryService {
|
||||
|
||||
List<PantryItemResponse> listItems(UUID householdId);
|
||||
private final PantryItemRepository pantryItemRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
|
||||
PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request);
|
||||
public PantryService(PantryItemRepository pantryItemRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository) {
|
||||
this.pantryItemRepository = pantryItemRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
}
|
||||
|
||||
PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request);
|
||||
|
||||
void deleteItem(UUID householdId, UUID itemId);
|
||||
@Transactional(readOnly = true)
|
||||
public List<PantryItemResponse> listItems(UUID householdId) {
|
||||
return pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(householdId)
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
public PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request) {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Ingredient ingredient = null;
|
||||
if (request.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(request.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
}
|
||||
|
||||
if (request.ingredientId() == null && (request.customName() == null || request.customName().isBlank())) {
|
||||
throw new ValidationException("Either ingredientId or customName must be provided");
|
||||
}
|
||||
|
||||
PantryItem item = new PantryItem(household, ingredient, request.customName(),
|
||||
request.quantity(), request.unit(), request.bestBefore(), request.openedOn());
|
||||
item = pantryItemRepository.save(item);
|
||||
return toResponse(item);
|
||||
}
|
||||
|
||||
|
||||
public PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request) {
|
||||
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
|
||||
|
||||
if (request.quantity() != null) item.setQuantity(request.quantity());
|
||||
if (request.unit() != null) item.setUnit(request.unit());
|
||||
if (request.bestBefore() != null) item.setBestBefore(request.bestBefore());
|
||||
if (request.openedOn() != null) item.setOpenedOn(request.openedOn());
|
||||
|
||||
item = pantryItemRepository.save(item);
|
||||
return toResponse(item);
|
||||
}
|
||||
|
||||
|
||||
public void deleteItem(UUID householdId, UUID itemId) {
|
||||
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
|
||||
pantryItemRepository.delete(item);
|
||||
}
|
||||
|
||||
private PantryItemResponse toResponse(PantryItem item) {
|
||||
UUID ingredientId = item.getIngredient() != null ? item.getIngredient().getId() : null;
|
||||
String name = item.getIngredient() != null ? item.getIngredient().getName() : item.getCustomName();
|
||||
CategoryRef category = null;
|
||||
if (item.getIngredient() != null && item.getIngredient().getCategory() != null) {
|
||||
category = new CategoryRef(
|
||||
item.getIngredient().getCategory().getId(),
|
||||
item.getIngredient().getCategory().getName());
|
||||
}
|
||||
return new PantryItemResponse(
|
||||
item.getId(), ingredientId, name, category,
|
||||
item.getQuantity(), item.getUnit(), item.getBestBefore(), item.getOpenedOn());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.recipeapp.pantry;
|
||||
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.pantry.dto.*;
|
||||
import com.recipeapp.pantry.entity.PantryItem;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class PantryServiceImpl implements PantryService {
|
||||
|
||||
private final PantryItemRepository pantryItemRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
|
||||
public PantryServiceImpl(PantryItemRepository pantryItemRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository) {
|
||||
this.pantryItemRepository = pantryItemRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<PantryItemResponse> listItems(UUID householdId) {
|
||||
return pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(householdId)
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request) {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Ingredient ingredient = null;
|
||||
if (request.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(request.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
}
|
||||
|
||||
if (request.ingredientId() == null && (request.customName() == null || request.customName().isBlank())) {
|
||||
throw new ValidationException("Either ingredientId or customName must be provided");
|
||||
}
|
||||
|
||||
PantryItem item = new PantryItem(household, ingredient, request.customName(),
|
||||
request.quantity(), request.unit(), request.bestBefore(), request.openedOn());
|
||||
item = pantryItemRepository.save(item);
|
||||
return toResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request) {
|
||||
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
|
||||
|
||||
if (request.quantity() != null) item.setQuantity(request.quantity());
|
||||
if (request.unit() != null) item.setUnit(request.unit());
|
||||
if (request.bestBefore() != null) item.setBestBefore(request.bestBefore());
|
||||
if (request.openedOn() != null) item.setOpenedOn(request.openedOn());
|
||||
|
||||
item = pantryItemRepository.save(item);
|
||||
return toResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteItem(UUID householdId, UUID itemId) {
|
||||
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
|
||||
pantryItemRepository.delete(item);
|
||||
}
|
||||
|
||||
private PantryItemResponse toResponse(PantryItem item) {
|
||||
UUID ingredientId = item.getIngredient() != null ? item.getIngredient().getId() : null;
|
||||
String name = item.getIngredient() != null ? item.getIngredient().getName() : item.getCustomName();
|
||||
CategoryRef category = null;
|
||||
if (item.getIngredient() != null && item.getIngredient().getCategory() != null) {
|
||||
category = new CategoryRef(
|
||||
item.getIngredient().getCategory().getId(),
|
||||
item.getIngredient().getCategory().getName());
|
||||
}
|
||||
return new PantryItemResponse(
|
||||
item.getId(), ingredientId, name, category,
|
||||
item.getQuantity(), item.getUnit(), item.getBestBefore(), item.getOpenedOn());
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,409 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.planning.entity.*;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface PlanningService {
|
||||
@Service
|
||||
public class PlanningService {
|
||||
|
||||
WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart);
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
private final CookingLogRepository cookingLogRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
|
||||
WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart);
|
||||
public PlanningService(WeekPlanRepository weekPlanRepository,
|
||||
WeekPlanSlotRepository weekPlanSlotRepository,
|
||||
CookingLogRepository cookingLogRepository,
|
||||
RecipeRepository recipeRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.weekPlanSlotRepository = weekPlanSlotRepository;
|
||||
this.cookingLogRepository = cookingLogRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
}
|
||||
|
||||
SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request);
|
||||
@Transactional(readOnly = true)
|
||||
public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request);
|
||||
@Transactional
|
||||
public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) {
|
||||
throw new ValidationException("weekStart must be a Monday");
|
||||
}
|
||||
if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) {
|
||||
throw new ConflictException("Week plan already exists for this week");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
void deleteSlot(UUID householdId, UUID planId, UUID slotId);
|
||||
@Transactional
|
||||
public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.save(
|
||||
new WeekPlanSlot(plan, recipe, request.slotDate()));
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
WeekPlanResponse confirmPlan(UUID householdId, UUID planId);
|
||||
@Transactional
|
||||
public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
slot.setRecipe(recipe);
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
|
||||
List<String> tagFilters, Integer topN);
|
||||
@Transactional
|
||||
public void deleteSlot(UUID householdId, UUID planId, UUID slotId) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
weekPlanSlotRepository.delete(slot);
|
||||
}
|
||||
|
||||
VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId);
|
||||
@Transactional
|
||||
public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
if ("confirmed".equals(plan.getStatus())) {
|
||||
throw new ValidationException("Plan is already confirmed");
|
||||
}
|
||||
if (plan.getSlots().isEmpty()) {
|
||||
throw new ValidationException("Plan has no slots");
|
||||
}
|
||||
plan.setStatus("confirmed");
|
||||
plan.setConfirmedAt(Instant.now());
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request);
|
||||
@Transactional(readOnly = true)
|
||||
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
|
||||
List<String> tagFilters, Integer topN) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
int limit = (topN != null) ? topN : 5;
|
||||
if (limit <= 0) {
|
||||
return new SuggestionResponse(List.of());
|
||||
}
|
||||
|
||||
List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset);
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
Set<UUID> usedRecipeIds = plan.getSlots().stream()
|
||||
.map(s -> s.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.filter(r -> matchesAllTags(r, lowerTagFilters))
|
||||
.map(candidate -> {
|
||||
double score = simulateVarietyScore(
|
||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
|
||||
})
|
||||
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
|
||||
return new SuggestionResponse(suggestions);
|
||||
}
|
||||
|
||||
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
|
||||
if (lowerTagFilters.isEmpty()) return true;
|
||||
Set<String> recipeTags = recipe.getTags().stream()
|
||||
.map(t -> t.getName().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
return recipeTags.containsAll(lowerTagFilters);
|
||||
}
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (Tag tag : slot.recipe.getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long tagRepeatCount = tagDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long ingredientOverlapCount = ingredientDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
long recentRepeatCount = simulatedSlots.stream()
|
||||
.map(s -> s.recipe.getId())
|
||||
.distinct()
|
||||
.filter(recentlyCookedIds::contains)
|
||||
.count();
|
||||
|
||||
// 4. Duplicate recipes within the simulated plan
|
||||
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
|
||||
long duplicatePenaltyCount = recipeCounts.values().stream()
|
||||
.filter(c -> c > 1)
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
double score = 10.0;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
return Math.max(0, Math.min(10, score));
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
List<WeekPlanSlot> slots = plan.getSlots();
|
||||
|
||||
if (slots.isEmpty()) {
|
||||
return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
// Load config (or use defaults)
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (Tag tag : slot.getRecipe().getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(),
|
||||
k -> new TagAccumulator(tag.getTagType()))
|
||||
.addDay(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue().days))
|
||||
.map(e -> new VarietyScoreResponse.TagRepeat(
|
||||
e.getKey(), e.getValue().tagType, e.getValue().days))
|
||||
.toList();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.IngredientOverlap> overlaps = ingredientDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue()))
|
||||
.map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
LocalDate referenceDate = plan.getWeekStart();
|
||||
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, referenceDate.minusDays(historyDays));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<String> recentRepeats = slots.stream()
|
||||
.map(s -> s.getRecipe())
|
||||
.filter(r -> recentlyCookedIds.contains(r.getId()))
|
||||
.map(Recipe::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
|
||||
|
||||
List<String> duplicatesInPlan = new ArrayList<>();
|
||||
long duplicatePenaltyCount = 0;
|
||||
for (var entry : recipeCounts.entrySet()) {
|
||||
if (entry.getValue() > 1) {
|
||||
String recipeName = slots.stream()
|
||||
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
|
||||
.findFirst()
|
||||
.map(s -> s.getRecipe().getName())
|
||||
.orElse("Unknown");
|
||||
duplicatesInPlan.add(recipeName);
|
||||
duplicatePenaltyCount += entry.getValue() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = 10.0;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(10, score));
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
private static class TagAccumulator {
|
||||
final String tagType;
|
||||
final List<LocalDate> days = new ArrayList<>();
|
||||
|
||||
TagAccumulator(String tagType) { this.tagType = tagType; }
|
||||
|
||||
void addDay(LocalDate day) { days.add(day); }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) {
|
||||
Recipe recipe = recipeRepository.findById(request.recipeId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now();
|
||||
CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user));
|
||||
|
||||
return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(),
|
||||
log.getCookedOn(), user.getId());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset) {
|
||||
return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(
|
||||
householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1)))
|
||||
.stream()
|
||||
.map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(),
|
||||
cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private WeekPlan findPlan(UUID planId, UUID householdId) {
|
||||
WeekPlan plan = weekPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
if (!plan.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private Recipe findRecipe(UUID recipeId, UUID householdId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) {
|
||||
List<SlotResponse> slots = plan.getSlots().stream()
|
||||
.map(this::toSlotResponse)
|
||||
.toList();
|
||||
return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(),
|
||||
plan.getConfirmedAt(), slots);
|
||||
}
|
||||
|
||||
private SlotResponse toSlotResponse(WeekPlanSlot slot) {
|
||||
return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe()));
|
||||
}
|
||||
|
||||
private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) {
|
||||
return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(),
|
||||
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
||||
}
|
||||
|
||||
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
||||
if (days.size() < 2) return false;
|
||||
List<LocalDate> sorted = days.stream().sorted().toList();
|
||||
for (int i = 1; i < sorted.size(); i++) {
|
||||
if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.planning.entity.*;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PlanningServiceImpl implements PlanningService {
|
||||
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
private final CookingLogRepository cookingLogRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
|
||||
public PlanningServiceImpl(WeekPlanRepository weekPlanRepository,
|
||||
WeekPlanSlotRepository weekPlanSlotRepository,
|
||||
CookingLogRepository cookingLogRepository,
|
||||
RecipeRepository recipeRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.weekPlanSlotRepository = weekPlanSlotRepository;
|
||||
this.cookingLogRepository = cookingLogRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) {
|
||||
throw new ValidationException("weekStart must be a Monday");
|
||||
}
|
||||
if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) {
|
||||
throw new ConflictException("Week plan already exists for this week");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.save(
|
||||
new WeekPlanSlot(plan, recipe, request.slotDate()));
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
slot.setRecipe(recipe);
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteSlot(UUID householdId, UUID planId, UUID slotId) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
weekPlanSlotRepository.delete(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
if ("confirmed".equals(plan.getStatus())) {
|
||||
throw new ValidationException("Plan is already confirmed");
|
||||
}
|
||||
if (plan.getSlots().isEmpty()) {
|
||||
throw new ValidationException("Plan has no slots");
|
||||
}
|
||||
plan.setStatus("confirmed");
|
||||
plan.setConfirmedAt(Instant.now());
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
|
||||
List<String> tagFilters, Integer topN) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
int limit = (topN != null) ? topN : 5;
|
||||
if (limit <= 0) {
|
||||
return new SuggestionResponse(List.of());
|
||||
}
|
||||
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
Set<UUID> usedRecipeIds = plan.getSlots().stream()
|
||||
.map(s -> s.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.filter(r -> matchesAllTags(r, lowerTagFilters))
|
||||
.map(candidate -> {
|
||||
double score = simulateVarietyScore(
|
||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
|
||||
})
|
||||
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
|
||||
return new SuggestionResponse(suggestions);
|
||||
}
|
||||
|
||||
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
|
||||
if (lowerTagFilters.isEmpty()) return true;
|
||||
Set<String> recipeTags = recipe.getTags().stream()
|
||||
.map(t -> t.getName().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
return recipeTags.containsAll(lowerTagFilters);
|
||||
}
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (Tag tag : slot.recipe.getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long tagRepeatCount = tagDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long ingredientOverlapCount = ingredientDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
long recentRepeatCount = simulatedSlots.stream()
|
||||
.map(s -> s.recipe.getId())
|
||||
.distinct()
|
||||
.filter(recentlyCookedIds::contains)
|
||||
.count();
|
||||
|
||||
// 4. Duplicate recipes within the simulated plan
|
||||
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
|
||||
long duplicatePenaltyCount = recipeCounts.values().stream()
|
||||
.filter(c -> c > 1)
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
double score = 10.0;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
return Math.max(0, Math.min(10, score));
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
List<WeekPlanSlot> slots = plan.getSlots();
|
||||
|
||||
if (slots.isEmpty()) {
|
||||
return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
// Load config (or use defaults)
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (Tag tag : slot.getRecipe().getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(),
|
||||
k -> new TagAccumulator(tag.getTagType()))
|
||||
.addDay(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue().days))
|
||||
.map(e -> new VarietyScoreResponse.TagRepeat(
|
||||
e.getKey(), e.getValue().tagType, e.getValue().days))
|
||||
.toList();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.IngredientOverlap> overlaps = ingredientDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue()))
|
||||
.map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
LocalDate referenceDate = plan.getWeekStart();
|
||||
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, referenceDate.minusDays(historyDays));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<String> recentRepeats = slots.stream()
|
||||
.map(s -> s.getRecipe())
|
||||
.filter(r -> recentlyCookedIds.contains(r.getId()))
|
||||
.map(Recipe::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
|
||||
|
||||
List<String> duplicatesInPlan = new ArrayList<>();
|
||||
long duplicatePenaltyCount = 0;
|
||||
for (var entry : recipeCounts.entrySet()) {
|
||||
if (entry.getValue() > 1) {
|
||||
String recipeName = slots.stream()
|
||||
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
|
||||
.findFirst()
|
||||
.map(s -> s.getRecipe().getName())
|
||||
.orElse("Unknown");
|
||||
duplicatesInPlan.add(recipeName);
|
||||
duplicatePenaltyCount += entry.getValue() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = 10.0;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(10, score));
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
private static class TagAccumulator {
|
||||
final String tagType;
|
||||
final List<LocalDate> days = new ArrayList<>();
|
||||
|
||||
TagAccumulator(String tagType) { this.tagType = tagType; }
|
||||
|
||||
void addDay(LocalDate day) { days.add(day); }
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) {
|
||||
Recipe recipe = recipeRepository.findById(request.recipeId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now();
|
||||
CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user));
|
||||
|
||||
return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(),
|
||||
log.getCookedOn(), user.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset) {
|
||||
return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(
|
||||
householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1)))
|
||||
.stream()
|
||||
.map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(),
|
||||
cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private WeekPlan findPlan(UUID planId, UUID householdId) {
|
||||
WeekPlan plan = weekPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
if (!plan.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private Recipe findRecipe(UUID recipeId, UUID householdId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) {
|
||||
List<SlotResponse> slots = plan.getSlots().stream()
|
||||
.map(this::toSlotResponse)
|
||||
.toList();
|
||||
return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(),
|
||||
plan.getConfirmedAt(), slots);
|
||||
}
|
||||
|
||||
private SlotResponse toSlotResponse(WeekPlanSlot slot) {
|
||||
return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe()));
|
||||
}
|
||||
|
||||
private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) {
|
||||
return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(),
|
||||
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
||||
}
|
||||
|
||||
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
||||
if (days.size() < 2) return false;
|
||||
List<LocalDate> sorted = days.stream().sorted().toList();
|
||||
for (int i = 1; i < sorted.size(); i++) {
|
||||
if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,251 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.recipeapp.recipe.entity.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public interface RecipeService {
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset);
|
||||
@Service
|
||||
public class RecipeService {
|
||||
|
||||
long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin);
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
|
||||
RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId);
|
||||
public RecipeService(RecipeRepository recipeRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
TagRepository tagRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
HouseholdRepository householdRepository) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
}
|
||||
|
||||
RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request);
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
||||
cookTimeMaxMin, sort, limit, offset);
|
||||
}
|
||||
|
||||
RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request);
|
||||
@Transactional(readOnly = true)
|
||||
public long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
}
|
||||
|
||||
void deleteRecipe(UUID householdId, UUID recipeId);
|
||||
@Transactional(readOnly = true)
|
||||
public RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple);
|
||||
@Transactional
|
||||
public RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request) {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request);
|
||||
Recipe recipe = new Recipe(household, request.name(), request.serves(),
|
||||
request.cookTimeMin(), request.effort(), request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
List<TagResponse> listTags(UUID householdId);
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
addTags(recipe, request.tagIds());
|
||||
|
||||
TagResponse createTag(UUID householdId, TagCreateRequest request);
|
||||
recipe = recipeRepository.save(recipe);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
List<IngredientCategoryResponse> listCategories(UUID householdId);
|
||||
@Transactional
|
||||
public RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
Household household = recipe.getHousehold();
|
||||
|
||||
IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request);
|
||||
recipe.setName(request.name());
|
||||
recipe.setServes(request.serves());
|
||||
recipe.setCookTimeMin(request.cookTimeMin());
|
||||
recipe.setEffort(request.effort());
|
||||
recipe.setChildFriendly(request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
recipe.getIngredients().clear();
|
||||
recipe.getSteps().clear();
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
addTags(recipe, request.tagIds());
|
||||
|
||||
recipe = recipeRepository.save(recipe);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteRecipe(UUID householdId, UUID recipeId) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
recipe.setDeletedAt(Instant.now());
|
||||
}
|
||||
|
||||
// ── Ingredients ──
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple) {
|
||||
List<Ingredient> ingredients;
|
||||
if (search != null && isStaple != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(
|
||||
householdId, search, isStaple);
|
||||
} else if (search != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(householdId, search);
|
||||
} else if (isStaple != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndIsStaple(householdId, isStaple);
|
||||
} else {
|
||||
ingredients = ingredientRepository.findByHouseholdId(householdId);
|
||||
}
|
||||
return ingredients.stream().map(this::toIngredientResponse).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request) {
|
||||
Ingredient ingredient = ingredientRepository.findById(ingredientId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
|
||||
if (request.name() != null) {
|
||||
ingredient.setName(request.name());
|
||||
}
|
||||
if (request.isStaple() != null) {
|
||||
ingredient.setStaple(request.isStaple());
|
||||
}
|
||||
if (request.categoryId() != null) {
|
||||
IngredientCategory category = ingredientCategoryRepository.findById(request.categoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found"));
|
||||
ingredient.setCategory(category);
|
||||
}
|
||||
return toIngredientResponse(ingredient);
|
||||
}
|
||||
|
||||
// ── Tags ──
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<TagResponse> listTags(UUID householdId) {
|
||||
return tagRepository.findByHouseholdId(householdId).stream()
|
||||
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TagResponse createTag(UUID householdId, TagCreateRequest request) {
|
||||
if (tagRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
|
||||
throw new ConflictException("Tag already exists");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
Tag tag = tagRepository.save(new Tag(household, request.name(), request.tagType()));
|
||||
return new TagResponse(tag.getId(), tag.getName(), tag.getTagType());
|
||||
}
|
||||
|
||||
// ── Ingredient Categories ──
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<IngredientCategoryResponse> listCategories(UUID householdId) {
|
||||
return ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(householdId).stream()
|
||||
.map(c -> new IngredientCategoryResponse(c.getId(), c.getName()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request) {
|
||||
if (ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
|
||||
throw new ConflictException("Category already exists");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
short nextSort = (short) (ingredientCategoryRepository.countByHouseholdId(householdId) + 1);
|
||||
IngredientCategory category = ingredientCategoryRepository.save(
|
||||
new IngredientCategory(household, request.name(), nextSort));
|
||||
return new IngredientCategoryResponse(category.getId(), category.getName());
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private void addIngredients(Recipe recipe, Household household, List<RecipeCreateRequest.IngredientEntry> entries) {
|
||||
if (entries == null) return;
|
||||
for (var entry : entries) {
|
||||
Ingredient ingredient;
|
||||
if (entry.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(entry.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
} else {
|
||||
ingredient = ingredientRepository.save(new Ingredient(household, entry.newIngredientName(), false));
|
||||
}
|
||||
recipe.getIngredients().add(new RecipeIngredient(
|
||||
recipe, ingredient, entry.quantity(), entry.unit(), entry.sortOrder()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addSteps(Recipe recipe, List<RecipeCreateRequest.StepEntry> entries) {
|
||||
if (entries == null) return;
|
||||
for (var entry : entries) {
|
||||
recipe.getSteps().add(new RecipeStep(recipe, entry.stepNumber(), entry.instruction()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addTags(Recipe recipe, List<UUID> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) return;
|
||||
List<Tag> tags = tagRepository.findAllById(tagIds);
|
||||
recipe.setTags(new HashSet<>(tags));
|
||||
}
|
||||
|
||||
private RecipeDetailResponse toDetailResponse(Recipe recipe) {
|
||||
var ingredients = recipe.getIngredients().stream()
|
||||
.map(ri -> {
|
||||
Ingredient ing = ri.getIngredient();
|
||||
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
|
||||
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
|
||||
: null;
|
||||
return new RecipeDetailResponse.IngredientItem(
|
||||
ing.getId(), ing.getName(), catRef,
|
||||
ri.getQuantity(), ri.getUnit(), ri.getSortOrder());
|
||||
})
|
||||
.toList();
|
||||
|
||||
var steps = recipe.getSteps().stream()
|
||||
.map(s -> new RecipeDetailResponse.StepItem(s.getStepNumber(), s.getInstruction()))
|
||||
.toList();
|
||||
|
||||
var tags = recipe.getTags().stream()
|
||||
.map(t -> new RecipeDetailResponse.TagItem(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList();
|
||||
|
||||
return new RecipeDetailResponse(
|
||||
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
||||
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
|
||||
ingredients, steps, tags);
|
||||
}
|
||||
|
||||
private IngredientResponse toIngredientResponse(Ingredient ing) {
|
||||
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
|
||||
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
|
||||
: null;
|
||||
return new IngredientResponse(ing.getId(), ing.getName(), catRef, ing.isStaple());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
import com.recipeapp.recipe.entity.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class RecipeServiceImpl implements RecipeService {
|
||||
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
|
||||
public RecipeServiceImpl(RecipeRepository recipeRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
TagRepository tagRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
HouseholdRepository householdRepository) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
||||
cookTimeMaxMin, sort, limit, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public RecipeDetailResponse getRecipe(UUID householdId, UUID recipeId) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RecipeDetailResponse createRecipe(UUID householdId, RecipeCreateRequest request) {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Recipe recipe = new Recipe(household, request.name(), request.serves(),
|
||||
request.cookTimeMin(), request.effort(), request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
addTags(recipe, request.tagIds());
|
||||
|
||||
recipe = recipeRepository.save(recipe);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RecipeDetailResponse updateRecipe(UUID householdId, UUID recipeId, RecipeCreateRequest request) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
Household household = recipe.getHousehold();
|
||||
|
||||
recipe.setName(request.name());
|
||||
recipe.setServes(request.serves());
|
||||
recipe.setCookTimeMin(request.cookTimeMin());
|
||||
recipe.setEffort(request.effort());
|
||||
recipe.setChildFriendly(request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
recipe.getIngredients().clear();
|
||||
recipe.getSteps().clear();
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
addTags(recipe, request.tagIds());
|
||||
|
||||
recipe = recipeRepository.save(recipe);
|
||||
return toDetailResponse(recipe);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteRecipe(UUID householdId, UUID recipeId) {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
recipe.setDeletedAt(Instant.now());
|
||||
}
|
||||
|
||||
// ── Ingredients ──
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<IngredientResponse> searchIngredients(UUID householdId, String search, Boolean isStaple) {
|
||||
List<Ingredient> ingredients;
|
||||
if (search != null && isStaple != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCaseAndIsStaple(
|
||||
householdId, search, isStaple);
|
||||
} else if (search != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndNameContainingIgnoreCase(householdId, search);
|
||||
} else if (isStaple != null) {
|
||||
ingredients = ingredientRepository.findByHouseholdIdAndIsStaple(householdId, isStaple);
|
||||
} else {
|
||||
ingredients = ingredientRepository.findByHouseholdId(householdId);
|
||||
}
|
||||
return ingredients.stream().map(this::toIngredientResponse).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public IngredientResponse patchIngredient(UUID householdId, UUID ingredientId, IngredientPatchRequest request) {
|
||||
Ingredient ingredient = ingredientRepository.findById(ingredientId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
|
||||
if (request.name() != null) {
|
||||
ingredient.setName(request.name());
|
||||
}
|
||||
if (request.isStaple() != null) {
|
||||
ingredient.setStaple(request.isStaple());
|
||||
}
|
||||
if (request.categoryId() != null) {
|
||||
IngredientCategory category = ingredientCategoryRepository.findById(request.categoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found"));
|
||||
ingredient.setCategory(category);
|
||||
}
|
||||
return toIngredientResponse(ingredient);
|
||||
}
|
||||
|
||||
// ── Tags ──
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<TagResponse> listTags(UUID householdId) {
|
||||
return tagRepository.findByHouseholdId(householdId).stream()
|
||||
.map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public TagResponse createTag(UUID householdId, TagCreateRequest request) {
|
||||
if (tagRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
|
||||
throw new ConflictException("Tag already exists");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
Tag tag = tagRepository.save(new Tag(household, request.name(), request.tagType()));
|
||||
return new TagResponse(tag.getId(), tag.getName(), tag.getTagType());
|
||||
}
|
||||
|
||||
// ── Ingredient Categories ──
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<IngredientCategoryResponse> listCategories(UUID householdId) {
|
||||
return ingredientCategoryRepository.findByHouseholdIdOrderBySortOrder(householdId).stream()
|
||||
.map(c -> new IngredientCategoryResponse(c.getId(), c.getName()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public IngredientCategoryResponse createCategory(UUID householdId, IngredientCategoryCreateRequest request) {
|
||||
if (ingredientCategoryRepository.existsByHouseholdIdAndNameIgnoreCase(householdId, request.name())) {
|
||||
throw new ConflictException("Category already exists");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
short nextSort = (short) (ingredientCategoryRepository.countByHouseholdId(householdId) + 1);
|
||||
IngredientCategory category = ingredientCategoryRepository.save(
|
||||
new IngredientCategory(household, request.name(), nextSort));
|
||||
return new IngredientCategoryResponse(category.getId(), category.getName());
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private void addIngredients(Recipe recipe, Household household, List<RecipeCreateRequest.IngredientEntry> entries) {
|
||||
if (entries == null) return;
|
||||
for (var entry : entries) {
|
||||
Ingredient ingredient;
|
||||
if (entry.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(entry.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
} else {
|
||||
ingredient = ingredientRepository.save(new Ingredient(household, entry.newIngredientName(), false));
|
||||
}
|
||||
recipe.getIngredients().add(new RecipeIngredient(
|
||||
recipe, ingredient, entry.quantity(), entry.unit(), entry.sortOrder()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addSteps(Recipe recipe, List<RecipeCreateRequest.StepEntry> entries) {
|
||||
if (entries == null) return;
|
||||
for (var entry : entries) {
|
||||
recipe.getSteps().add(new RecipeStep(recipe, entry.stepNumber(), entry.instruction()));
|
||||
}
|
||||
}
|
||||
|
||||
private void addTags(Recipe recipe, List<UUID> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) return;
|
||||
List<Tag> tags = tagRepository.findAllById(tagIds);
|
||||
recipe.setTags(new HashSet<>(tags));
|
||||
}
|
||||
|
||||
private RecipeDetailResponse toDetailResponse(Recipe recipe) {
|
||||
var ingredients = recipe.getIngredients().stream()
|
||||
.map(ri -> {
|
||||
Ingredient ing = ri.getIngredient();
|
||||
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
|
||||
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
|
||||
: null;
|
||||
return new RecipeDetailResponse.IngredientItem(
|
||||
ing.getId(), ing.getName(), catRef,
|
||||
ri.getQuantity(), ri.getUnit(), ri.getSortOrder());
|
||||
})
|
||||
.toList();
|
||||
|
||||
var steps = recipe.getSteps().stream()
|
||||
.map(s -> new RecipeDetailResponse.StepItem(s.getStepNumber(), s.getInstruction()))
|
||||
.toList();
|
||||
|
||||
var tags = recipe.getTags().stream()
|
||||
.map(t -> new RecipeDetailResponse.TagItem(t.getId(), t.getName(), t.getTagType()))
|
||||
.toList();
|
||||
|
||||
return new RecipeDetailResponse(
|
||||
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
||||
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
|
||||
ingredients, steps, tags);
|
||||
}
|
||||
|
||||
private IngredientResponse toIngredientResponse(Ingredient ing) {
|
||||
RecipeDetailResponse.CategoryRef catRef = ing.getCategory() != null
|
||||
? new RecipeDetailResponse.CategoryRef(ing.getCategory().getId(), ing.getCategory().getName())
|
||||
: null;
|
||||
return new IngredientResponse(ing.getId(), ing.getName(), catRef, ing.isStaple());
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
import com.recipeapp.shopping.entity.ShoppingListItem;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class ShoppingServiceImpl implements ShoppingService {
|
||||
|
||||
private final ShoppingListRepository shoppingListRepository;
|
||||
private final ShoppingListItemRepository shoppingListItemRepository;
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public ShoppingServiceImpl(ShoppingListRepository shoppingListRepository,
|
||||
ShoppingListItemRepository shoppingListItemRepository,
|
||||
WeekPlanRepository weekPlanRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.shoppingListRepository = shoppingListRepository;
|
||||
this.shoppingListItemRepository = shoppingListItemRepository;
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) {
|
||||
WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
|
||||
if (!weekPlan.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
|
||||
var household = weekPlan.getHousehold();
|
||||
|
||||
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
|
||||
shoppingList = shoppingListRepository.save(shoppingList);
|
||||
|
||||
// Aggregate ingredients across all slots/recipes
|
||||
// Key: ingredientId + unit -> merged data
|
||||
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
|
||||
|
||||
for (var slot : weekPlan.getSlots()) {
|
||||
var recipe = slot.getRecipe();
|
||||
for (RecipeIngredient ri : recipe.getIngredients()) {
|
||||
Ingredient ingredient = ri.getIngredient();
|
||||
|
||||
// Filter out staples
|
||||
if (ingredient.isStaple()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = ingredient.getId().toString() + "|" + ri.getUnit();
|
||||
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
|
||||
.addQuantity(ri.getQuantity())
|
||||
.addRecipeId(recipe.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// Create shopping list items
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList,
|
||||
mi.ingredient,
|
||||
null,
|
||||
mi.totalQuantity,
|
||||
mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
|
||||
);
|
||||
shoppingList.getItems().add(item);
|
||||
}
|
||||
|
||||
shoppingListRepository.save(shoppingList);
|
||||
|
||||
return toResponse(shoppingList);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public ShoppingListResponse getShoppingList(UUID householdId, UUID shoppingListId) {
|
||||
ShoppingList list = findList(householdId, shoppingListId);
|
||||
return toResponse(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublishResponse publish(UUID householdId, UUID shoppingListId) {
|
||||
ShoppingList list = findList(householdId, shoppingListId);
|
||||
|
||||
if (!"draft".equals(list.getStatus())) {
|
||||
throw new ValidationException("Shopping list is already published");
|
||||
}
|
||||
|
||||
list.setStatus("published");
|
||||
list.setPublishedAt(Instant.now());
|
||||
shoppingListRepository.save(list);
|
||||
|
||||
return new PublishResponse(list.getId(), list.getStatus(), list.getPublishedAt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShoppingListItemResponse checkItem(UUID householdId, UUID listId, UUID itemId,
|
||||
CheckItemRequest request, UUID userId) {
|
||||
ShoppingList list = findList(householdId, listId);
|
||||
ShoppingListItem item = findItem(list, itemId);
|
||||
|
||||
item.setChecked(request.isChecked());
|
||||
|
||||
if (request.isChecked()) {
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
item.setCheckedBy(user);
|
||||
} else {
|
||||
item.setCheckedBy(null);
|
||||
}
|
||||
|
||||
shoppingListItemRepository.save(item);
|
||||
return toItemResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShoppingListItemResponse addItem(UUID householdId, UUID shoppingListId, AddItemRequest request) {
|
||||
ShoppingList list = findList(householdId, shoppingListId);
|
||||
|
||||
Ingredient ingredient = null;
|
||||
if (request.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(request.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
}
|
||||
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
list,
|
||||
ingredient,
|
||||
request.customName(),
|
||||
request.quantity(),
|
||||
request.unit(),
|
||||
new UUID[0]
|
||||
);
|
||||
|
||||
item = shoppingListItemRepository.save(item);
|
||||
list.getItems().add(item);
|
||||
|
||||
return toItemResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteItem(UUID householdId, UUID listId, UUID itemId) {
|
||||
ShoppingList list = findList(householdId, listId);
|
||||
|
||||
if ("published".equals(list.getStatus())) {
|
||||
throw new ValidationException("Cannot delete items from a published shopping list");
|
||||
}
|
||||
|
||||
ShoppingListItem item = findItem(list, itemId);
|
||||
list.getItems().remove(item);
|
||||
shoppingListItemRepository.delete(item);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private ShoppingList findList(UUID householdId, UUID shoppingListId) {
|
||||
ShoppingList list = shoppingListRepository.findById(shoppingListId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Shopping list not found"));
|
||||
|
||||
if (!list.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Shopping list not found");
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private ShoppingListItem findItem(ShoppingList list, UUID itemId) {
|
||||
return list.getItems().stream()
|
||||
.filter(i -> i.getId().equals(itemId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Shopping list item not found"));
|
||||
}
|
||||
|
||||
private ShoppingListResponse toResponse(ShoppingList list) {
|
||||
List<ShoppingListItemResponse> items = list.getItems().stream()
|
||||
.map(this::toItemResponse)
|
||||
.toList();
|
||||
|
||||
return new ShoppingListResponse(
|
||||
list.getId(),
|
||||
list.getWeekPlan().getId(),
|
||||
list.getStatus(),
|
||||
list.getPublishedAt(),
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) {
|
||||
String name;
|
||||
ShoppingListItemResponse.CategoryRef categoryRef = null;
|
||||
UUID ingredientId = null;
|
||||
|
||||
if (item.getIngredient() != null) {
|
||||
ingredientId = item.getIngredient().getId();
|
||||
name = item.getIngredient().getName();
|
||||
if (item.getIngredient().getCategory() != null) {
|
||||
categoryRef = new ShoppingListItemResponse.CategoryRef(
|
||||
item.getIngredient().getCategory().getId(),
|
||||
item.getIngredient().getCategory().getName()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
name = item.getCustomName();
|
||||
}
|
||||
|
||||
return new ShoppingListItemResponse(
|
||||
item.getId(),
|
||||
ingredientId,
|
||||
name,
|
||||
categoryRef,
|
||||
item.getQuantity(),
|
||||
item.getUnit(),
|
||||
item.isChecked(),
|
||||
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
|
||||
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private static class MergedIngredient {
|
||||
final Ingredient ingredient;
|
||||
final String unit;
|
||||
BigDecimal totalQuantity = BigDecimal.ZERO;
|
||||
final List<UUID> recipeIds = new ArrayList<>();
|
||||
|
||||
MergedIngredient(Ingredient ingredient, String unit) {
|
||||
this.ingredient = ingredient;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
MergedIngredient addQuantity(BigDecimal qty) {
|
||||
if (qty != null) {
|
||||
this.totalQuantity = this.totalQuantity.add(qty);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
MergedIngredient addRecipeId(UUID recipeId) {
|
||||
this.recipeIds.add(recipeId);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -35,7 +35,7 @@ class AuthServiceTest {
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@InjectMocks
|
||||
private AuthServiceImpl authService;
|
||||
private AuthService authService;
|
||||
|
||||
@Test
|
||||
void signupShouldCreateUserAndReturnResponse() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user