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:
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
21
backend/src/main/java/com/recipeapp/admin/AdminService.java
Normal file
21
backend/src/main/java/com/recipeapp/admin/AdminService.java
Normal 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) {}
|
||||
}
|
||||
166
backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java
Normal file
166
backend/src/main/java/com/recipeapp/admin/AdminServiceImpl.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.recipeapp.admin.dto;
|
||||
|
||||
public record ResetPasswordResponse(
|
||||
String message,
|
||||
boolean mustChangePassword
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.recipeapp.admin.dto;
|
||||
|
||||
public record UpdateUserRequest(
|
||||
String displayName,
|
||||
String email,
|
||||
String systemRole,
|
||||
Boolean isActive
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user