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:
Patrick Plate
2026-06-12 10:20:20 +02:00
parent 87568e5bfc
commit 4f00872486
12 changed files with 522 additions and 0 deletions
@@ -0,0 +1,136 @@
package de.cannamanage.service;
import de.cannamanage.domain.constants.ComplianceConstants;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.UUID;
/**
* Service for prevention officer assignment and under-21 member monitoring.
* Enforces the configurable limit per club and provides prevention-relevant data.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PreventionOfficerService {
private final StaffAccountRepository staffAccountRepository;
private final ClubRepository clubRepository;
private final MemberRepository memberRepository;
private final DistributionRepository distributionRepository;
/**
* Assign or revoke prevention officer status on a staff account.
* On assign: enforces club.maxPreventionOfficers limit.
* On revoke: always succeeds.
*/
@Transactional
public StaffAccount setPreventionOfficer(UUID tenantId, UUID staffId, boolean assign) {
StaffAccount staff = staffAccountRepository.findById(staffId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Staff account not found"));
if (!staff.getTenantId().equals(tenantId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Staff account not found");
}
if (assign) {
// Check limit before assigning
long currentCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId);
Club club = clubRepository.findById(tenantId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found"));
if (currentCount >= club.getMaxPreventionOfficers()) {
throw new PreventionOfficerLimitExceededException(club.getMaxPreventionOfficers());
}
staff.setPreventionOfficer(true);
log.info("Prevention officer assigned: staffId={}, tenantId={}", staffId, tenantId);
} else {
staff.setPreventionOfficer(false);
log.info("Prevention officer revoked: staffId={}, tenantId={}", staffId, tenantId);
}
return staffAccountRepository.save(staff);
}
/**
* Returns all under-21 members for the tenant with their current month distribution data.
*/
@Transactional(readOnly = true)
public List<Member> getUnder21Members(UUID tenantId) {
return memberRepository.findByTenantIdAndUnder21True(tenantId);
}
/**
* Returns the number of distributions for a member in the current month.
*/
@Transactional(readOnly = true)
public long countCurrentMonthDistributions(UUID tenantId, UUID memberId) {
Instant monthStart = getMonthStart();
Instant now = Instant.now();
List<Distribution> distributions = distributionRepository
.findByTenantIdAndDistributedAtBetween(tenantId, monthStart, now);
return distributions.stream()
.filter(d -> d.getMemberId().equals(memberId))
.count();
}
/**
* Returns the total grams distributed to a member in the current month.
*/
@Transactional(readOnly = true)
public BigDecimal sumCurrentMonthGrams(UUID tenantId, UUID memberId) {
Instant monthStart = getMonthStart();
Instant now = Instant.now();
List<Distribution> distributions = distributionRepository
.findByTenantIdAndDistributedAtBetween(tenantId, monthStart, now);
return distributions.stream()
.filter(d -> d.getMemberId().equals(memberId))
.map(Distribution::getQuantityGrams)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
/**
* Calculates the monthly limit for a member based on their age.
*/
public BigDecimal getMonthlyLimit(Member member) {
if (member.isUnder21()) {
return ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS;
}
return ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
}
/**
* Calculates the age of a member.
*/
public int calculateAge(LocalDate dateOfBirth) {
return Period.between(dateOfBirth, LocalDate.now()).getYears();
}
private Instant getMonthStart() {
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Europe/Berlin"));
ZonedDateTime monthStart = now.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
return monthStart.toInstant();
}
}
@@ -0,0 +1,19 @@
package de.cannamanage.service.exception;
/**
* Thrown when attempting to assign more prevention officers than the club limit allows.
* Maps to HTTP 409 Conflict.
*/
public class PreventionOfficerLimitExceededException extends RuntimeException {
private final int maxAllowed;
public PreventionOfficerLimitExceededException(int maxAllowed) {
super("Prevention officer limit exceeded. Maximum allowed: " + maxAllowed);
this.maxAllowed = maxAllowed;
}
public int getMaxAllowed() {
return maxAllowed;
}
}
@@ -24,4 +24,9 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
* Find all members for a tenant (all statuses).
*/
List<Member> findByTenantId(UUID tenantId);
/**
* Find all under-21 members for a tenant.
*/
List<Member> findByTenantIdAndUnder21True(UUID tenantId);
}
@@ -0,0 +1,233 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PreventionOfficerServiceTest {
@Mock private StaffAccountRepository staffAccountRepository;
@Mock private ClubRepository clubRepository;
@Mock private MemberRepository memberRepository;
@Mock private DistributionRepository distributionRepository;
@InjectMocks private PreventionOfficerService service;
private UUID tenantId;
private UUID staffId;
private StaffAccount staffAccount;
private Club club;
@BeforeEach
void setUp() {
tenantId = UUID.randomUUID();
staffId = UUID.randomUUID();
staffAccount = new StaffAccount();
staffAccount.setId(staffId);
staffAccount.setTenantId(tenantId);
staffAccount.setUserId(UUID.randomUUID());
staffAccount.setDisplayName("Test Staff");
staffAccount.setActive(true);
staffAccount.setPreventionOfficer(false);
club = new Club();
club.setId(tenantId);
club.setTenantId(tenantId);
club.setMaxPreventionOfficers(2);
}
// --- Assignment Tests ---
@Test
void assignPreventionOfficer_underLimit_succeeds() {
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId)).thenReturn(1L);
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
when(staffAccountRepository.save(any())).thenAnswer(i -> i.getArgument(0));
StaffAccount result = service.setPreventionOfficer(tenantId, staffId, true);
assertThat(result.isPreventionOfficer()).isTrue();
verify(staffAccountRepository).save(staffAccount);
}
@Test
void assignPreventionOfficer_atLimit_throws409() {
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrueAndActiveTrue(tenantId)).thenReturn(2L);
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true))
.isInstanceOf(PreventionOfficerLimitExceededException.class)
.hasMessageContaining("Maximum allowed: 2");
verify(staffAccountRepository, never()).save(any());
}
@Test
void revokePreventionOfficer_alwaysSucceeds() {
staffAccount.setPreventionOfficer(true);
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
when(staffAccountRepository.save(any())).thenAnswer(i -> i.getArgument(0));
StaffAccount result = service.setPreventionOfficer(tenantId, staffId, false);
assertThat(result.isPreventionOfficer()).isFalse();
verify(staffAccountRepository).save(staffAccount);
}
@Test
void assignPreventionOfficer_wrongTenant_throws404() {
UUID otherTenant = UUID.randomUUID();
staffAccount.setTenantId(otherTenant);
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staffAccount));
assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("not found");
}
@Test
void assignPreventionOfficer_notFound_throws404() {
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.setPreventionOfficer(tenantId, staffId, true))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("not found");
}
// --- Under-21 Members ---
@Test
void getUnder21Members_returnsList() {
Member member = new Member();
member.setId(UUID.randomUUID());
member.setTenantId(tenantId);
member.setUnder21(true);
member.setFirstName("Young");
member.setLastName("Member");
member.setDateOfBirth(LocalDate.now().minusYears(20));
when(memberRepository.findByTenantIdAndUnder21True(tenantId)).thenReturn(List.of(member));
List<Member> result = service.getUnder21Members(tenantId);
assertThat(result).hasSize(1);
assertThat(result.get(0).isUnder21()).isTrue();
}
// --- Distribution Counting ---
@Test
void countCurrentMonthDistributions_countsCorrectly() {
UUID memberId = UUID.randomUUID();
Distribution d1 = new Distribution();
d1.setMemberId(memberId);
d1.setDistributedAt(Instant.now());
d1.setQuantityGrams(BigDecimal.valueOf(5));
Distribution d2 = new Distribution();
d2.setMemberId(memberId);
d2.setDistributedAt(Instant.now());
d2.setQuantityGrams(BigDecimal.valueOf(10));
Distribution otherMemberDist = new Distribution();
otherMemberDist.setMemberId(UUID.randomUUID());
otherMemberDist.setDistributedAt(Instant.now());
otherMemberDist.setQuantityGrams(BigDecimal.valueOf(7));
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(tenantId), any(), any()))
.thenReturn(List.of(d1, d2, otherMemberDist));
long count = service.countCurrentMonthDistributions(tenantId, memberId);
assertThat(count).isEqualTo(2);
}
@Test
void sumCurrentMonthGrams_sumsCorrectly() {
UUID memberId = UUID.randomUUID();
Distribution d1 = new Distribution();
d1.setMemberId(memberId);
d1.setQuantityGrams(BigDecimal.valueOf(5));
d1.setDistributedAt(Instant.now());
Distribution d2 = new Distribution();
d2.setMemberId(memberId);
d2.setQuantityGrams(BigDecimal.valueOf(10));
d2.setDistributedAt(Instant.now());
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(tenantId), any(), any()))
.thenReturn(List.of(d1, d2));
BigDecimal grams = service.sumCurrentMonthGrams(tenantId, memberId);
assertThat(grams).isEqualByComparingTo(BigDecimal.valueOf(15));
}
// --- Monthly Limit ---
@Test
void getMonthlyLimit_under21_returns30() {
Member member = new Member();
member.setUnder21(true);
BigDecimal limit = service.getMonthlyLimit(member);
assertThat(limit).isEqualByComparingTo(BigDecimal.valueOf(30));
}
@Test
void getMonthlyLimit_adult_returns50() {
Member member = new Member();
member.setUnder21(false);
BigDecimal limit = service.getMonthlyLimit(member);
assertThat(limit).isEqualByComparingTo(BigDecimal.valueOf(50));
}
// --- Age Calculation ---
@Test
void calculateAge_returnsCorrectAge() {
LocalDate dob = LocalDate.now().minusYears(20).minusDays(10);
int age = service.calculateAge(dob);
assertThat(age).isEqualTo(20);
}
@Test
void calculateAge_birthday_today() {
LocalDate dob = LocalDate.now().minusYears(21);
int age = service.calculateAge(dob);
assertThat(age).isEqualTo(21);
}
}