feat(sprint-3): Phase 2 — club settings controller
This commit is contained in:
@@ -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<ClubResponse> 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<ClubResponse> 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<ClubStatsResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package de.cannamanage.domain.entity;
|
|||||||
import de.cannamanage.domain.enums.ClubStatus;
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "clubs")
|
@Table(name = "clubs")
|
||||||
public class Club extends AbstractTenantEntity {
|
public class Club extends AbstractTenantEntity {
|
||||||
@@ -10,6 +12,30 @@ public class Club extends AbstractTenantEntity {
|
|||||||
@Column(name = "name", nullable = false, length = 255)
|
@Column(name = "name", nullable = false, length = 255)
|
||||||
private String name;
|
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")
|
@Column(name = "address")
|
||||||
private String address;
|
private String address;
|
||||||
|
|
||||||
@@ -19,6 +45,12 @@ public class Club extends AbstractTenantEntity {
|
|||||||
@Column(name = "max_members", nullable = false)
|
@Column(name = "max_members", nullable = false)
|
||||||
private Integer maxMembers = 500;
|
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)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false, length = 50)
|
@Column(name = "status", nullable = false, length = 50)
|
||||||
private ClubStatus status = ClubStatus.ACTIVE;
|
private ClubStatus status = ClubStatus.ACTIVE;
|
||||||
@@ -26,6 +58,30 @@ public class Club extends AbstractTenantEntity {
|
|||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = 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 String getAddress() { return address; }
|
||||||
public void setAddress(String address) { this.address = address; }
|
public void setAddress(String address) { this.address = address; }
|
||||||
|
|
||||||
@@ -35,6 +91,12 @@ public class Club extends AbstractTenantEntity {
|
|||||||
public Integer getMaxMembers() { return maxMembers; }
|
public Integer getMaxMembers() { return maxMembers; }
|
||||||
public void setMaxMembers(Integer maxMembers) { this.maxMembers = 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 ClubStatus getStatus() { return status; }
|
||||||
public void setStatus(ClubStatus status) { this.status = status; }
|
public void setStatus(ClubStatus status) { this.status = status; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,11 @@
|
|||||||
<groupId>org.springframework</groupId>
|
<groupId>org.springframework</groupId>
|
||||||
<artifactId>spring-context</artifactId>
|
<artifactId>spring-context</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Spring Web for ResponseStatusException -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-web</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+3
@@ -1,6 +1,7 @@
|
|||||||
package de.cannamanage.service.repository;
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
import de.cannamanage.domain.entity.Batch;
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -8,4 +9,6 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface BatchRepository extends JpaRepository<Batch, UUID> {
|
public interface BatchRepository extends JpaRepository<Batch, UUID> {
|
||||||
|
|
||||||
|
long countByTenantIdAndStatus(UUID tenantId, BatchStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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<Club, UUID> {
|
||||||
|
|
||||||
|
Optional<Club> findByTenantId(UUID tenantId);
|
||||||
|
}
|
||||||
+9
@@ -20,4 +20,13 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
|||||||
@Param("dayStart") Instant dayStart,
|
@Param("dayStart") Instant dayStart,
|
||||||
@Param("dayEnd") Instant dayEnd
|
@Param("dayEnd") Instant dayEnd
|
||||||
);
|
);
|
||||||
|
|
||||||
|
long countByTenantIdAndDistributedAtAfter(UUID tenantId, Instant after);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
|
||||||
|
"WHERE d.tenantId = :tenantId AND d.distributedAt >= :after")
|
||||||
|
BigDecimal sumGramsByTenantIdAndDistributedAtAfter(
|
||||||
|
@Param("tenantId") UUID tenantId,
|
||||||
|
@Param("after") Instant after
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -1,6 +1,7 @@
|
|||||||
package de.cannamanage.service.repository;
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
import de.cannamanage.domain.entity.Member;
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -8,4 +9,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface MemberRepository extends JpaRepository<Member, UUID> {
|
public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||||
|
|
||||||
|
long countByTenantId(UUID tenantId);
|
||||||
|
|
||||||
|
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -20,4 +20,10 @@ public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID
|
|||||||
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
|
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
|
||||||
|
|
||||||
boolean existsByUserId(UUID userId);
|
boolean existsByUserId(UUID userId);
|
||||||
|
|
||||||
|
long countByTenantId(UUID tenantId);
|
||||||
|
|
||||||
|
long countByTenantIdAndActiveTrue(UUID tenantId);
|
||||||
|
|
||||||
|
long countByTenantIdAndPreventionOfficerTrue(UUID tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
|
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 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.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 ClubServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ClubRepository clubRepository;
|
||||||
|
@Mock
|
||||||
|
private MemberRepository memberRepository;
|
||||||
|
@Mock
|
||||||
|
private StaffAccountRepository staffAccountRepository;
|
||||||
|
@Mock
|
||||||
|
private DistributionRepository distributionRepository;
|
||||||
|
@Mock
|
||||||
|
private BatchRepository batchRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ClubService clubService;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private Club club;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = UUID.randomUUID();
|
||||||
|
club = new Club();
|
||||||
|
club.setId(UUID.randomUUID());
|
||||||
|
club.setTenantId(tenantId);
|
||||||
|
club.setName("Test Club");
|
||||||
|
club.setLicenseNumber("LIC-001");
|
||||||
|
club.setMaxPreventionOfficers(2);
|
||||||
|
club.setStatus(ClubStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getClubByTenantId_found() {
|
||||||
|
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||||
|
|
||||||
|
Club result = clubService.getClubByTenantId(tenantId);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(club);
|
||||||
|
verify(clubRepository).findByTenantId(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getClubByTenantId_notFound_throws404() {
|
||||||
|
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user