feat(sprint-3): Phase 6 — prevention officer capability
- PreventionOfficerService: limit enforcement, under-21 monitoring, monthly distribution tracking
- PUT /api/v1/staff/{id}/prevention-officer: assign/revoke with club limit check (409 on exceed)
- GET /api/v1/members/under-21: list under-21 members with quota data (prevention officer access)
- GET /api/v1/members/{id}/prevention-data: member prevention details (quota, distributions)
- PreventionOfficerLimitExceededException mapped to 409 in GlobalExceptionHandler
- StaffResponse extended with preventionOfficer field
- PreventionOfficerServiceTest: 10 unit tests covering assignment, revocation, limits, age calc
- MemberRepository.findByTenantIdAndUnder21True added
This commit is contained in:
@@ -3,9 +3,12 @@ package de.cannamanage.api.controller;
|
||||
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||
import de.cannamanage.api.dto.member.MemberResponse;
|
||||
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
||||
import de.cannamanage.api.dto.prevention.PreventionDataResponse;
|
||||
import de.cannamanage.api.dto.prevention.Under21MemberResponse;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.PreventionOfficerService;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -17,6 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.List;
|
||||
@@ -29,6 +33,7 @@ import java.util.UUID;
|
||||
public class MemberController {
|
||||
|
||||
private final MemberRepository memberRepository;
|
||||
private final PreventionOfficerService preventionOfficerService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
||||
@@ -89,6 +94,57 @@ public class MemberController {
|
||||
return ResponseEntity.ok(toResponse(saved));
|
||||
}
|
||||
|
||||
@GetMapping("/under-21")
|
||||
@Operation(summary = "List under-21 members", description = "Returns all under-21 members with current month distribution data. Prevention officer or ADMIN access required.")
|
||||
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||
public ResponseEntity<List<Under21MemberResponse>> getUnder21Members() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
List<Member> under21Members = preventionOfficerService.getUnder21Members(tenantId);
|
||||
|
||||
List<Under21MemberResponse> response = under21Members.stream()
|
||||
.map(m -> {
|
||||
int age = preventionOfficerService.calculateAge(m.getDateOfBirth());
|
||||
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, m.getId());
|
||||
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, m.getId());
|
||||
BigDecimal limit = preventionOfficerService.getMonthlyLimit(m);
|
||||
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||
String quotaStatus = remaining.compareTo(BigDecimal.ZERO) > 0 ? "OK" : "EXHAUSTED";
|
||||
return new Under21MemberResponse(
|
||||
m.getId(), m.getFirstName(), m.getLastName(),
|
||||
age, m.getDateOfBirth(), distCount,
|
||||
gramsUsed, limit, quotaStatus
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/prevention-data")
|
||||
@Operation(summary = "Get prevention data for a member", description = "Returns prevention-relevant data for a specific member. Prevention officer or ADMIN access required.")
|
||||
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||
public ResponseEntity<PreventionDataResponse> getPreventionData(@PathVariable UUID id) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Member member = memberRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||
|
||||
int age = preventionOfficerService.calculateAge(member.getDateOfBirth());
|
||||
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, member.getId());
|
||||
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, member.getId());
|
||||
BigDecimal limit = preventionOfficerService.getMonthlyLimit(member);
|
||||
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||
|
||||
return ResponseEntity.ok(new PreventionDataResponse(
|
||||
member.getId(),
|
||||
member.getFirstName() + " " + member.getLastName(),
|
||||
member.isUnder21(),
|
||||
age,
|
||||
distCount,
|
||||
gramsUsed,
|
||||
limit,
|
||||
remaining
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isUnder21(LocalDate dateOfBirth) {
|
||||
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.prevention.PreventionOfficerRequest;
|
||||
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||
@@ -7,6 +8,7 @@ import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.PreventionOfficerService;
|
||||
import de.cannamanage.service.StaffService;
|
||||
import de.cannamanage.service.StaffTemplates;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
@@ -31,6 +33,7 @@ import java.util.UUID;
|
||||
public class StaffController {
|
||||
|
||||
private final StaffService staffService;
|
||||
private final PreventionOfficerService preventionOfficerService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
@@ -103,6 +106,19 @@ public class StaffController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/prevention-officer")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Assign or revoke prevention officer status",
|
||||
description = "Sets prevention officer flag on a staff member. Enforces club.maxPreventionOfficers limit on assign.")
|
||||
public ResponseEntity<StaffResponse> setPreventionOfficer(@PathVariable UUID id,
|
||||
@Valid @RequestBody PreventionOfficerRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = preventionOfficerService.setPreventionOfficer(tenantId, id, request.preventionOfficer());
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||
}
|
||||
|
||||
@GetMapping("/templates")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List available permission templates")
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.prevention;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PreventionDataResponse(
|
||||
UUID memberId,
|
||||
String name,
|
||||
boolean isUnder21,
|
||||
int age,
|
||||
long currentMonthDistributions,
|
||||
BigDecimal gramsUsedThisMonth,
|
||||
BigDecimal monthlyLimit,
|
||||
BigDecimal quotaRemaining
|
||||
) {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package de.cannamanage.api.dto.prevention;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record PreventionOfficerRequest(
|
||||
@NotNull Boolean preventionOfficer
|
||||
) {}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package de.cannamanage.api.dto.prevention;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Under21MemberResponse(
|
||||
UUID id,
|
||||
String firstName,
|
||||
String lastName,
|
||||
int age,
|
||||
LocalDate dateOfBirth,
|
||||
long totalDistributionsThisMonth,
|
||||
BigDecimal gramsUsedThisMonth,
|
||||
BigDecimal monthlyLimit,
|
||||
String quotaStatus
|
||||
) {}
|
||||
@@ -19,6 +19,7 @@ public record StaffResponse(
|
||||
Set<StaffPermission> permissions,
|
||||
String templateName,
|
||||
boolean active,
|
||||
boolean preventionOfficer,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static StaffResponse from(StaffAccount staff, User user) {
|
||||
@@ -30,6 +31,7 @@ public record StaffResponse(
|
||||
staff.getGrantedPermissions(),
|
||||
null, // templateName not stored; permissions are expanded
|
||||
staff.isActive(),
|
||||
staff.isPreventionOfficer(),
|
||||
staff.getCreatedAt()
|
||||
);
|
||||
}
|
||||
@@ -43,6 +45,7 @@ public record StaffResponse(
|
||||
staff.getGrantedPermissions(),
|
||||
null,
|
||||
staff.isActive(),
|
||||
staff.isPreventionOfficer(),
|
||||
staff.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
+13
@@ -3,6 +3,7 @@ package de.cannamanage.api.exception;
|
||||
import de.cannamanage.api.service.AuthService;
|
||||
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -108,6 +109,18 @@ public class GlobalExceptionHandler {
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(PreventionOfficerLimitExceededException.class)
|
||||
public ProblemDetail handlePreventionOfficerLimitExceeded(PreventionOfficerLimitExceededException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.CONFLICT, ex.getMessage());
|
||||
problem.setTitle("Prevention Officer Limit Exceeded");
|
||||
problem.setType(URI.create("urn:cannamanage:error:PREVENTION_OFFICER_LIMIT_EXCEEDED"));
|
||||
problem.setProperty("code", "PREVENTION_OFFICER_LIMIT_EXCEEDED");
|
||||
problem.setProperty("maxAllowed", ex.getMaxAllowed());
|
||||
problem.setProperty("timestamp", Instant.now().toString());
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
|
||||
@@ -10,6 +10,7 @@ import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
import de.cannamanage.service.PreventionOfficerService;
|
||||
import de.cannamanage.service.StaffService;
|
||||
import de.cannamanage.service.TokenRevocationService;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
@@ -40,6 +41,7 @@ class StaffControllerTest {
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean private StaffService staffService;
|
||||
@MockBean private PreventionOfficerService preventionOfficerService;
|
||||
@MockBean private UserRepository userRepository;
|
||||
@MockBean private JwtService jwtService;
|
||||
@MockBean private JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
Reference in New Issue
Block a user