Implement Recipe, Planning, Shopping, Pantry, and Admin domains

Outside-in TDD for all 5 remaining domains (128 tests total):
- Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests)
- Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests)
- Shopping: generate from plan, publish, check/add/remove items (15 tests)
- Pantry: CRUD with expiry sorting (11 tests)
- Admin: user management, password reset, audit logging (13 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:56:51 +02:00
parent 4f457303d8
commit 9ec703abcd
88 changed files with 5267 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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