From 36deb72cf0234de41a18ad7ea2c4ef427c87b0a5 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 11 Jun 2026 16:56:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-3):=20Phase=202=20=E2=80=94=20club?= =?UTF-8?q?=20settings=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/ClubController.java | 94 +++++++++ .../api/dto/club/ClubResponse.java | 24 +++ .../api/dto/club/ClubStatsResponse.java | 14 ++ .../api/dto/club/UpdateClubRequest.java | 34 ++++ .../migration/V4__club_settings_columns.sql | 12 ++ .../api/controller/ClubControllerTest.java | 113 +++++++++++ .../de/cannamanage/domain/entity/Club.java | 62 ++++++ cannamanage-service/pom.xml | 5 + .../de/cannamanage/service/ClubService.java | 120 ++++++++++++ .../service/repository/BatchRepository.java | 3 + .../service/repository/ClubRepository.java | 14 ++ .../repository/DistributionRepository.java | 9 + .../service/repository/MemberRepository.java | 5 + .../repository/StaffAccountRepository.java | 6 + .../cannamanage/service/ClubServiceTest.java | 185 ++++++++++++++++++ 15 files changed, 700 insertions(+) create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/controller/ClubController.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubResponse.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubStatsResponse.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/club/UpdateClubRequest.java create mode 100644 cannamanage-api/src/main/resources/db/migration/V4__club_settings_columns.sql create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/controller/ClubControllerTest.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/ClubService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/repository/ClubRepository.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/ClubServiceTest.java diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ClubController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ClubController.java new file mode 100644 index 0000000..27b72ea --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ClubController.java @@ -0,0 +1,94 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.club.ClubResponse; +import de.cannamanage.api.dto.club.ClubStatsResponse; +import de.cannamanage.api.dto.club.UpdateClubRequest; +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.service.ClubService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/clubs") +@RequiredArgsConstructor +@Tag(name = "Club Settings", description = "Club configuration and statistics") +public class ClubController { + + private final ClubService clubService; + + @GetMapping("/me") + @Operation(summary = "Get current club", description = "Returns the club for the current tenant") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity getMyClub() { + UUID tenantId = TenantContext.getCurrentTenant(); + Club club = clubService.getClubByTenantId(tenantId); + return ResponseEntity.ok(toResponse(club)); + } + + @PutMapping("/me") + @Operation(summary = "Update club settings", description = "Updates the club configuration for the current tenant") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateMyClub(@Valid @RequestBody UpdateClubRequest request) { + UUID tenantId = TenantContext.getCurrentTenant(); + Club updated = clubService.updateClub( + tenantId, + request.name(), + request.registrationNumber(), + request.contactEmail(), + request.contactPhone(), + request.addressStreet(), + request.addressCity(), + request.addressPostalCode(), + request.addressState(), + request.foundedDate(), + request.maxPreventionOfficers(), + request.allowedEmailPattern() + ); + return ResponseEntity.ok(toResponse(updated)); + } + + @GetMapping("/me/stats") + @Operation(summary = "Get club statistics", description = "Returns aggregated club statistics") + @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)") + public ResponseEntity getMyClubStats() { + UUID tenantId = TenantContext.getCurrentTenant(); + ClubService.ClubStats stats = clubService.getClubStats(tenantId); + return ResponseEntity.ok(new ClubStatsResponse( + stats.totalMembers(), + stats.activeMembers(), + stats.totalStaff(), + stats.activeStaff(), + stats.totalDistributionsThisMonth(), + stats.totalGramsDistributedThisMonth(), + stats.activeBatches(), + stats.preventionOfficerCount() + )); + } + + private ClubResponse toResponse(Club club) { + return new ClubResponse( + club.getId(), + club.getName(), + club.getRegistrationNumber(), + club.getContactEmail(), + club.getContactPhone(), + club.getAddressStreet(), + club.getAddressCity(), + club.getAddressPostalCode(), + club.getAddressState(), + club.getFoundedDate(), + club.getMaxPreventionOfficers(), + club.getAllowedEmailPattern(), + club.getStatus(), + club.getCreatedAt() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubResponse.java new file mode 100644 index 0000000..d5d3646 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubResponse.java @@ -0,0 +1,24 @@ +package de.cannamanage.api.dto.club; + +import de.cannamanage.domain.enums.ClubStatus; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record ClubResponse( + UUID id, + String name, + String registrationNumber, + String contactEmail, + String contactPhone, + String addressStreet, + String addressCity, + String addressPostalCode, + String addressState, + LocalDate foundedDate, + Integer maxPreventionOfficers, + String allowedEmailPattern, + ClubStatus status, + Instant createdAt +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubStatsResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubStatsResponse.java new file mode 100644 index 0000000..904944f --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/ClubStatsResponse.java @@ -0,0 +1,14 @@ +package de.cannamanage.api.dto.club; + +import java.math.BigDecimal; + +public record ClubStatsResponse( + long totalMembers, + long activeMembers, + long totalStaff, + long activeStaff, + long totalDistributionsThisMonth, + BigDecimal totalGramsDistributedThisMonth, + long activeBatches, + long preventionOfficerCount +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/UpdateClubRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/UpdateClubRequest.java new file mode 100644 index 0000000..4add853 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/club/UpdateClubRequest.java @@ -0,0 +1,34 @@ +package de.cannamanage.api.dto.club; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDate; + +public record UpdateClubRequest( + @NotBlank(message = "Club name is required") + String name, + + String registrationNumber, + + @Email(message = "Must be a valid email address") + String contactEmail, + + String contactPhone, + + String addressStreet, + + String addressCity, + + String addressPostalCode, + + String addressState, + + LocalDate foundedDate, + + @Min(value = 1, message = "Must have at least 1 prevention officer slot") + Integer maxPreventionOfficers, + + String allowedEmailPattern +) {} diff --git a/cannamanage-api/src/main/resources/db/migration/V4__club_settings_columns.sql b/cannamanage-api/src/main/resources/db/migration/V4__club_settings_columns.sql new file mode 100644 index 0000000..82473ac --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V4__club_settings_columns.sql @@ -0,0 +1,12 @@ +-- Sprint 3 Phase 2: Club settings extended columns +-- Additional address fields, contact info, and allowed email pattern for clubs + +ALTER TABLE clubs ADD COLUMN registration_number VARCHAR(100); +ALTER TABLE clubs ADD COLUMN contact_email VARCHAR(255); +ALTER TABLE clubs ADD COLUMN contact_phone VARCHAR(50); +ALTER TABLE clubs ADD COLUMN address_street VARCHAR(255); +ALTER TABLE clubs ADD COLUMN address_city VARCHAR(100); +ALTER TABLE clubs ADD COLUMN address_postal_code VARCHAR(20); +ALTER TABLE clubs ADD COLUMN address_state VARCHAR(100); +ALTER TABLE clubs ADD COLUMN founded_date DATE; +ALTER TABLE clubs ADD COLUMN allowed_email_pattern VARCHAR(255); diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/ClubControllerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/ClubControllerTest.java new file mode 100644 index 0000000..144603a --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/controller/ClubControllerTest.java @@ -0,0 +1,113 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.club.UpdateClubRequest; +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.ClubStatus; +import de.cannamanage.service.ClubService; +import org.junit.jupiter.api.AfterEach; +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.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ClubControllerTest { + + @Mock + private ClubService clubService; + + @InjectMocks + private ClubController clubController; + + private UUID tenantId; + private Club club; + + @BeforeEach + void setUp() { + tenantId = UUID.randomUUID(); + TenantContext.setCurrentTenant(tenantId); + + club = new Club(); + club.setId(UUID.randomUUID()); + club.setTenantId(tenantId); + club.setName("Green Garden Club"); + club.setRegistrationNumber("REG-2024-001"); + club.setContactEmail("info@greengardenclub.de"); + club.setContactPhone("+49 30 12345678"); + club.setAddressStreet("Hanfweg 42"); + club.setAddressCity("Berlin"); + club.setAddressPostalCode("10115"); + club.setAddressState("Berlin"); + club.setFoundedDate(LocalDate.of(2024, 7, 1)); + club.setMaxPreventionOfficers(2); + club.setAllowedEmailPattern(".*@greengardenclub\\.de"); + club.setStatus(ClubStatus.ACTIVE); + club.setCreatedAt(Instant.now()); + club.setLicenseNumber("LIC-001"); + } + + @AfterEach + void tearDown() { + TenantContext.clear(); + } + + @Test + void getMyClub_returnsClubResponse() { + when(clubService.getClubByTenantId(tenantId)).thenReturn(club); + + ResponseEntity response = clubController.getMyClub(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + verify(clubService).getClubByTenantId(tenantId); + } + + @Test + void updateMyClub_updatesAndReturns() { + UpdateClubRequest request = new UpdateClubRequest( + "Updated Club", "REG-NEW", "new@club.de", "+49111", + "Newstreet 1", "Hamburg", "20095", "Hamburg", + LocalDate.of(2024, 1, 1), 3, ".*@club\\.de" + ); + when(clubService.updateClub( + eq(tenantId), eq("Updated Club"), eq("REG-NEW"), + eq("new@club.de"), eq("+49111"), + eq("Newstreet 1"), eq("Hamburg"), eq("20095"), eq("Hamburg"), + eq(LocalDate.of(2024, 1, 1)), eq(3), eq(".*@club\\.de") + )).thenReturn(club); + + ResponseEntity response = clubController.updateMyClub(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + } + + @Test + void getMyClubStats_returnsStats() { + ClubService.ClubStats stats = new ClubService.ClubStats( + 50, 42, 5, 4, 120, new BigDecimal("1500.50"), 8, 2 + ); + when(clubService.getClubStats(tenantId)).thenReturn(stats); + + ResponseEntity response = clubController.getMyClubStats(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + verify(clubService).getClubStats(tenantId); + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java index 27d74b4..af19101 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java @@ -3,6 +3,8 @@ package de.cannamanage.domain.entity; import de.cannamanage.domain.enums.ClubStatus; import jakarta.persistence.*; +import java.time.LocalDate; + @Entity @Table(name = "clubs") public class Club extends AbstractTenantEntity { @@ -10,6 +12,30 @@ public class Club extends AbstractTenantEntity { @Column(name = "name", nullable = false, length = 255) private String name; + @Column(name = "registration_number", length = 100) + private String registrationNumber; + + @Column(name = "contact_email", length = 255) + private String contactEmail; + + @Column(name = "contact_phone", length = 50) + private String contactPhone; + + @Column(name = "address_street", length = 255) + private String addressStreet; + + @Column(name = "address_city", length = 100) + private String addressCity; + + @Column(name = "address_postal_code", length = 20) + private String addressPostalCode; + + @Column(name = "address_state", length = 100) + private String addressState; + + @Column(name = "founded_date") + private LocalDate foundedDate; + @Column(name = "address") private String address; @@ -19,6 +45,12 @@ public class Club extends AbstractTenantEntity { @Column(name = "max_members", nullable = false) private Integer maxMembers = 500; + @Column(name = "max_prevention_officers", nullable = false) + private Integer maxPreventionOfficers = 2; + + @Column(name = "allowed_email_pattern", length = 255) + private String allowedEmailPattern; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 50) private ClubStatus status = ClubStatus.ACTIVE; @@ -26,6 +58,30 @@ public class Club extends AbstractTenantEntity { public String getName() { return name; } public void setName(String name) { this.name = name; } + public String getRegistrationNumber() { return registrationNumber; } + public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; } + + public String getContactEmail() { return contactEmail; } + public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; } + + public String getContactPhone() { return contactPhone; } + public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; } + + public String getAddressStreet() { return addressStreet; } + public void setAddressStreet(String addressStreet) { this.addressStreet = addressStreet; } + + public String getAddressCity() { return addressCity; } + public void setAddressCity(String addressCity) { this.addressCity = addressCity; } + + public String getAddressPostalCode() { return addressPostalCode; } + public void setAddressPostalCode(String addressPostalCode) { this.addressPostalCode = addressPostalCode; } + + public String getAddressState() { return addressState; } + public void setAddressState(String addressState) { this.addressState = addressState; } + + public LocalDate getFoundedDate() { return foundedDate; } + public void setFoundedDate(LocalDate foundedDate) { this.foundedDate = foundedDate; } + public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } @@ -35,6 +91,12 @@ public class Club extends AbstractTenantEntity { public Integer getMaxMembers() { return maxMembers; } public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; } + public Integer getMaxPreventionOfficers() { return maxPreventionOfficers; } + public void setMaxPreventionOfficers(Integer maxPreventionOfficers) { this.maxPreventionOfficers = maxPreventionOfficers; } + + public String getAllowedEmailPattern() { return allowedEmailPattern; } + public void setAllowedEmailPattern(String allowedEmailPattern) { this.allowedEmailPattern = allowedEmailPattern; } + public ClubStatus getStatus() { return status; } public void setStatus(ClubStatus status) { this.status = status; } } diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml index 229177d..4602c5a 100644 --- a/cannamanage-service/pom.xml +++ b/cannamanage-service/pom.xml @@ -57,6 +57,11 @@ org.springframework spring-context + + + org.springframework + spring-web + diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/ClubService.java b/cannamanage-service/src/main/java/de/cannamanage/service/ClubService.java new file mode 100644 index 0000000..8092152 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/ClubService.java @@ -0,0 +1,120 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.enums.BatchStatus; +import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.repository.BatchRepository; +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.ZoneOffset; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubService { + + private final ClubRepository clubRepository; + private final MemberRepository memberRepository; + private final StaffAccountRepository staffAccountRepository; + private final DistributionRepository distributionRepository; + private final BatchRepository batchRepository; + + @Transactional(readOnly = true) + public Club getClubByTenantId(UUID tenantId) { + return clubRepository.findByTenantId(tenantId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found for tenant")); + } + + @Transactional + public Club updateClub(UUID tenantId, String name, String registrationNumber, + String contactEmail, String contactPhone, + String addressStreet, String addressCity, + String addressPostalCode, String addressState, + LocalDate foundedDate, + Integer maxPreventionOfficers, String allowedEmailPattern) { + Club club = getClubByTenantId(tenantId); + + // Validate regex pattern if provided + if (allowedEmailPattern != null && !allowedEmailPattern.isBlank()) { + validateRegexPattern(allowedEmailPattern); + } + + club.setName(name); + club.setRegistrationNumber(registrationNumber); + club.setContactEmail(contactEmail); + club.setContactPhone(contactPhone); + club.setAddressStreet(addressStreet); + club.setAddressCity(addressCity); + club.setAddressPostalCode(addressPostalCode); + club.setAddressState(addressState); + club.setFoundedDate(foundedDate); + if (maxPreventionOfficers != null) { + club.setMaxPreventionOfficers(maxPreventionOfficers); + } + club.setAllowedEmailPattern(allowedEmailPattern); + + return clubRepository.save(club); + } + + @Transactional(readOnly = true) + public ClubStats getClubStats(UUID tenantId) { + long totalMembers = memberRepository.countByTenantId(tenantId); + long activeMembers = memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE); + long totalStaff = staffAccountRepository.countByTenantId(tenantId); + long activeStaff = staffAccountRepository.countByTenantIdAndActiveTrue(tenantId); + + // Distributions this month + Instant startOfMonth = LocalDate.now().withDayOfMonth(1) + .atStartOfDay(ZoneOffset.UTC).toInstant(); + long totalDistributionsThisMonth = distributionRepository + .countByTenantIdAndDistributedAtAfter(tenantId, startOfMonth); + BigDecimal totalGramsThisMonth = distributionRepository + .sumGramsByTenantIdAndDistributedAtAfter(tenantId, startOfMonth); + + long activeBatches = batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE); + long preventionOfficerCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId); + + return new ClubStats( + totalMembers, activeMembers, + totalStaff, activeStaff, + totalDistributionsThisMonth, + totalGramsThisMonth != null ? totalGramsThisMonth : BigDecimal.ZERO, + activeBatches, preventionOfficerCount + ); + } + + private void validateRegexPattern(String pattern) { + try { + Pattern.compile(pattern); + } catch (PatternSyntaxException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid regex pattern for allowedEmailPattern: " + e.getDescription()); + } + } + + public record ClubStats( + long totalMembers, + long activeMembers, + long totalStaff, + long activeStaff, + long totalDistributionsThisMonth, + BigDecimal totalGramsDistributedThisMonth, + long activeBatches, + long preventionOfficerCount + ) {} +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java index 4ccaf64..5e778d7 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/BatchRepository.java @@ -1,6 +1,7 @@ package de.cannamanage.service.repository; import de.cannamanage.domain.entity.Batch; +import de.cannamanage.domain.enums.BatchStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,4 +9,6 @@ import java.util.UUID; @Repository public interface BatchRepository extends JpaRepository { + + long countByTenantIdAndStatus(UUID tenantId, BatchStatus status); } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/ClubRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/ClubRepository.java new file mode 100644 index 0000000..23323e6 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/ClubRepository.java @@ -0,0 +1,14 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.Club; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ClubRepository extends JpaRepository { + + Optional findByTenantId(UUID tenantId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java index 128fe62..e393538 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java @@ -20,4 +20,13 @@ public interface DistributionRepository extends JpaRepository= :after") + BigDecimal sumGramsByTenantIdAndDistributedAtAfter( + @Param("tenantId") UUID tenantId, + @Param("after") Instant after + ); } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java index 6a04ff5..2d42fd5 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java @@ -1,6 +1,7 @@ package de.cannamanage.service.repository; import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.enums.MemberStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,4 +9,8 @@ import java.util.UUID; @Repository public interface MemberRepository extends JpaRepository { + + long countByTenantId(UUID tenantId); + + long countByTenantIdAndStatus(UUID tenantId, MemberStatus status); } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java index 3f1f417..d895053 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java @@ -20,4 +20,10 @@ public interface StaffAccountRepository extends JpaRepository clubService.getClubByTenantId(tenantId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Club not found for tenant"); + } + + @Test + void updateClub_success() { + when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club)); + when(clubRepository.save(any(Club.class))).thenReturn(club); + + Club result = clubService.updateClub( + tenantId, "Updated Club", "REG-123", + "info@club.de", "+49123456", + "Mainstreet 1", "Berlin", "10115", "Berlin", + LocalDate.of(2024, 1, 15), 3, ".*@club\\.de" + ); + + assertThat(result.getName()).isEqualTo("Updated Club"); + assertThat(result.getRegistrationNumber()).isEqualTo("REG-123"); + assertThat(result.getContactEmail()).isEqualTo("info@club.de"); + assertThat(result.getMaxPreventionOfficers()).isEqualTo(3); + assertThat(result.getAllowedEmailPattern()).isEqualTo(".*@club\\.de"); + verify(clubRepository).save(club); + } + + @Test + void updateClub_invalidRegex_throwsBadRequest() { + when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club)); + + assertThatThrownBy(() -> clubService.updateClub( + tenantId, "Club", null, null, null, + null, null, null, null, null, null, "[invalid" + )) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Invalid regex pattern"); + } + + @Test + void updateClub_nullPattern_accepted() { + when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club)); + when(clubRepository.save(any(Club.class))).thenReturn(club); + + Club result = clubService.updateClub( + tenantId, "Club", null, null, null, + null, null, null, null, null, null, null + ); + + assertThat(result).isNotNull(); + verify(clubRepository).save(club); + } + + @Test + void updateClub_blankPattern_accepted() { + when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club)); + when(clubRepository.save(any(Club.class))).thenReturn(club); + + Club result = clubService.updateClub( + tenantId, "Club", null, null, null, + null, null, null, null, null, null, " " + ); + + assertThat(result).isNotNull(); + verify(clubRepository).save(club); + } + + @Test + void getClubStats_returnsAggregatedStats() { + when(memberRepository.countByTenantId(tenantId)).thenReturn(50L); + when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(42L); + when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(5L); + when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(4L); + when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class))) + .thenReturn(120L); + when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class))) + .thenReturn(new BigDecimal("1500.50")); + when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(8L); + when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(2L); + + ClubService.ClubStats stats = clubService.getClubStats(tenantId); + + assertThat(stats.totalMembers()).isEqualTo(50L); + assertThat(stats.activeMembers()).isEqualTo(42L); + assertThat(stats.totalStaff()).isEqualTo(5L); + assertThat(stats.activeStaff()).isEqualTo(4L); + assertThat(stats.totalDistributionsThisMonth()).isEqualTo(120L); + assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo("1500.50"); + assertThat(stats.activeBatches()).isEqualTo(8L); + assertThat(stats.preventionOfficerCount()).isEqualTo(2L); + } + + @Test + void getClubStats_nullGrams_returnsZero() { + when(memberRepository.countByTenantId(tenantId)).thenReturn(0L); + when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(0L); + when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(0L); + when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(0L); + when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class))) + .thenReturn(0L); + when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class))) + .thenReturn(null); + when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(0L); + when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(0L); + + ClubService.ClubStats stats = clubService.getClubStats(tenantId); + + assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo(BigDecimal.ZERO); + } +}