Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 864bbbdde1 | |||
| 4f00872486 | |||
| 87568e5bfc | |||
| 64927a3244 | |||
| a267a90542 |
@@ -3,9 +3,12 @@ package de.cannamanage.api.controller;
|
|||||||
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||||
import de.cannamanage.api.dto.member.MemberResponse;
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
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.Member;
|
||||||
import de.cannamanage.domain.entity.TenantContext;
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
import de.cannamanage.domain.enums.MemberStatus;
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.repository.MemberRepository;
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
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.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -29,6 +33,7 @@ import java.util.UUID;
|
|||||||
public class MemberController {
|
public class MemberController {
|
||||||
|
|
||||||
private final MemberRepository memberRepository;
|
private final MemberRepository memberRepository;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
@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));
|
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) {
|
private boolean isUnder21(LocalDate dateOfBirth) {
|
||||||
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.security.PortalPrincipal;
|
||||||
|
import de.cannamanage.service.PortalService;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member self-service portal — read-only JSON endpoints.
|
||||||
|
* All data is scoped to the authenticated member via session principal.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/portal")
|
||||||
|
public class PortalController {
|
||||||
|
|
||||||
|
private final PortalService portalService;
|
||||||
|
|
||||||
|
public PortalController(PortalService portalService) {
|
||||||
|
this.portalService = portalService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard: quota summary + recent distributions (last 5).
|
||||||
|
*/
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ResponseEntity<PortalDashboard> dashboard(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile.
|
||||||
|
*/
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<PortalProfile> profile(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalProfile profile = portalService.getProfile(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current month quota status (daily + monthly, used/remaining).
|
||||||
|
*/
|
||||||
|
@GetMapping("/quota")
|
||||||
|
public ResponseEntity<PortalQuota> quota(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Own distribution history, paginated.
|
||||||
|
*/
|
||||||
|
@GetMapping("/distributions")
|
||||||
|
public ResponseEntity<PortalDistributionHistory> distributions(
|
||||||
|
@AuthenticationPrincipal PortalPrincipal principal,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
|
||||||
|
PortalDistributionHistory history = portalService.getDistributionHistory(
|
||||||
|
principal.getTenantId(), principal.getMemberId(), pageable);
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.report.MemberListResponse;
|
||||||
|
import de.cannamanage.api.dto.report.MonthlyReportResponse;
|
||||||
|
import de.cannamanage.api.dto.report.RecallReportResponse;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.CsvReportGenerator;
|
||||||
|
import de.cannamanage.service.PdfReportGenerator;
|
||||||
|
import de.cannamanage.service.ReportService;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for compliance and operational reports.
|
||||||
|
* Supports JSON, PDF, and CSV output formats.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/reports")
|
||||||
|
public class ReportController {
|
||||||
|
|
||||||
|
private final ReportService reportService;
|
||||||
|
private final PdfReportGenerator pdfGenerator;
|
||||||
|
private final CsvReportGenerator csvGenerator;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
|
||||||
|
public ReportController(ReportService reportService,
|
||||||
|
PdfReportGenerator pdfGenerator,
|
||||||
|
CsvReportGenerator csvGenerator,
|
||||||
|
ClubRepository clubRepository) {
|
||||||
|
this.reportService = reportService;
|
||||||
|
this.pdfGenerator = pdfGenerator;
|
||||||
|
this.csvGenerator = csvGenerator;
|
||||||
|
this.clubRepository = clubRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly distribution report.
|
||||||
|
* GET /api/v1/reports/monthly?month=2026-03&format=json|pdf|csv
|
||||||
|
*/
|
||||||
|
@GetMapping("/monthly")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> monthlyReport(
|
||||||
|
@RequestParam String month,
|
||||||
|
@RequestParam(defaultValue = "json") String format) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
YearMonth ym = YearMonth.parse(month);
|
||||||
|
MonthlyReport report = reportService.generateMonthlyReport(tenantId, ym);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderMonthlyReport(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"monatsbericht-" + month + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
case "csv" -> {
|
||||||
|
byte[] csv = csvGenerator.renderMonthlyReport(report);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"monatsbericht-" + month + ".csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toMonthlyResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member list report.
|
||||||
|
* GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
|
||||||
|
*/
|
||||||
|
@GetMapping("/members")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> memberListReport(
|
||||||
|
@RequestParam(defaultValue = "json") String format,
|
||||||
|
@RequestParam(required = false) MemberStatus status) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
MemberListReport report = reportService.generateMemberListReport(tenantId, status);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderMemberList(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"mitgliederliste.pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
case "csv" -> {
|
||||||
|
byte[] csv = csvGenerator.renderMemberList(report);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"mitgliederliste.csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toMemberListResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recall/batch trace report.
|
||||||
|
* GET /api/v1/reports/recall/{batchId}?format=json|pdf
|
||||||
|
*/
|
||||||
|
@GetMapping("/recall/{batchId}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> recallReport(
|
||||||
|
@PathVariable UUID batchId,
|
||||||
|
@RequestParam(defaultValue = "json") String format) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
RecallReport report = reportService.generateRecallReport(tenantId, batchId);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderRecallReport(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"rueckruf-" + batchId + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toRecallResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping helpers ---
|
||||||
|
|
||||||
|
private Club getClub(UUID tenantId) {
|
||||||
|
return clubRepository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Club not found for tenant " + tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MonthlyReportResponse toMonthlyResponse(MonthlyReport r) {
|
||||||
|
return new MonthlyReportResponse(
|
||||||
|
r.getMonth().toString(),
|
||||||
|
r.getTotalDistributions(),
|
||||||
|
r.getTotalGrams(),
|
||||||
|
r.getUniqueMembers(),
|
||||||
|
r.getAveragePerMember(),
|
||||||
|
r.getTopStrains().stream()
|
||||||
|
.map(s -> new MonthlyReportResponse.StrainSummaryDto(
|
||||||
|
s.getName(), s.getTotalGrams(), s.getDistributionCount()))
|
||||||
|
.toList(),
|
||||||
|
r.getDailyBreakdown().stream()
|
||||||
|
.map(d -> new MonthlyReportResponse.DailyEntryDto(
|
||||||
|
d.getDate(), d.getGrams(), d.getDistributions()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MemberListResponse toMemberListResponse(MemberListReport r) {
|
||||||
|
return new MemberListResponse(
|
||||||
|
r.getGeneratedAt(),
|
||||||
|
r.getMembers().stream()
|
||||||
|
.map(m -> new MemberListResponse.MemberEntryDto(
|
||||||
|
m.getId(), m.getFirstName(), m.getLastName(),
|
||||||
|
m.getMembershipNumber(),
|
||||||
|
m.getStatus() != null ? m.getStatus().name() : null,
|
||||||
|
m.getJoinDate(), m.getTotalDistributions(),
|
||||||
|
m.getLastDistributionDate()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecallReportResponse toRecallResponse(RecallReport r) {
|
||||||
|
return new RecallReportResponse(
|
||||||
|
r.getBatchId(),
|
||||||
|
r.getStrainName(),
|
||||||
|
r.getBatchNumber(),
|
||||||
|
r.getReceivedDate(),
|
||||||
|
r.getTotalGramsDistributed(),
|
||||||
|
r.getAffectedMembers().stream()
|
||||||
|
.map(am -> new RecallReportResponse.AffectedMemberDto(
|
||||||
|
am.getMemberId(), am.getFirstName(), am.getLastName(),
|
||||||
|
am.getDistributionDate(), am.getGrams()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.cannamanage.api.controller;
|
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.CreateStaffRequest;
|
||||||
import de.cannamanage.api.dto.staff.StaffResponse;
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
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.TenantContext;
|
||||||
import de.cannamanage.domain.entity.User;
|
import de.cannamanage.domain.entity.User;
|
||||||
import de.cannamanage.domain.enums.StaffPermission;
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.StaffService;
|
import de.cannamanage.service.StaffService;
|
||||||
import de.cannamanage.service.StaffTemplates;
|
import de.cannamanage.service.StaffTemplates;
|
||||||
import de.cannamanage.service.repository.UserRepository;
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
@@ -31,6 +33,7 @@ import java.util.UUID;
|
|||||||
public class StaffController {
|
public class StaffController {
|
||||||
|
|
||||||
private final StaffService staffService;
|
private final StaffService staffService;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -103,6 +106,19 @@ public class StaffController {
|
|||||||
return ResponseEntity.noContent().build();
|
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")
|
@GetMapping("/templates")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Operation(summary = "List available permission templates")
|
@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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the member list report.
|
||||||
|
*/
|
||||||
|
public record MemberListResponse(
|
||||||
|
Instant generatedAt,
|
||||||
|
List<MemberEntryDto> members
|
||||||
|
) {
|
||||||
|
public record MemberEntryDto(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String membershipNumber,
|
||||||
|
String status,
|
||||||
|
LocalDate joinDate,
|
||||||
|
int totalDistributions,
|
||||||
|
Instant lastDistributionDate
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the monthly distribution report.
|
||||||
|
*/
|
||||||
|
public record MonthlyReportResponse(
|
||||||
|
String month,
|
||||||
|
int totalDistributions,
|
||||||
|
BigDecimal totalGrams,
|
||||||
|
int uniqueMembers,
|
||||||
|
BigDecimal averagePerMember,
|
||||||
|
List<StrainSummaryDto> topStrains,
|
||||||
|
List<DailyEntryDto> dailyBreakdown
|
||||||
|
) {
|
||||||
|
public record StrainSummaryDto(String name, BigDecimal totalGrams, int distributionCount) {}
|
||||||
|
public record DailyEntryDto(LocalDate date, BigDecimal grams, int distributions) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the recall/batch trace report.
|
||||||
|
*/
|
||||||
|
public record RecallReportResponse(
|
||||||
|
UUID batchId,
|
||||||
|
String strainName,
|
||||||
|
String batchNumber,
|
||||||
|
LocalDate receivedDate,
|
||||||
|
BigDecimal totalGramsDistributed,
|
||||||
|
List<AffectedMemberDto> affectedMembers
|
||||||
|
) {
|
||||||
|
public record AffectedMemberDto(
|
||||||
|
UUID memberId,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
Instant distributionDate,
|
||||||
|
BigDecimal grams
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public record StaffResponse(
|
|||||||
Set<StaffPermission> permissions,
|
Set<StaffPermission> permissions,
|
||||||
String templateName,
|
String templateName,
|
||||||
boolean active,
|
boolean active,
|
||||||
|
boolean preventionOfficer,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
public static StaffResponse from(StaffAccount staff, User user) {
|
public static StaffResponse from(StaffAccount staff, User user) {
|
||||||
@@ -30,6 +31,7 @@ public record StaffResponse(
|
|||||||
staff.getGrantedPermissions(),
|
staff.getGrantedPermissions(),
|
||||||
null, // templateName not stored; permissions are expanded
|
null, // templateName not stored; permissions are expanded
|
||||||
staff.isActive(),
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
staff.getCreatedAt()
|
staff.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ public record StaffResponse(
|
|||||||
staff.getGrantedPermissions(),
|
staff.getGrantedPermissions(),
|
||||||
null,
|
null,
|
||||||
staff.isActive(),
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
staff.getCreatedAt()
|
staff.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -3,6 +3,7 @@ package de.cannamanage.api.exception;
|
|||||||
import de.cannamanage.api.service.AuthService;
|
import de.cannamanage.api.service.AuthService;
|
||||||
import de.cannamanage.service.exception.BatchNotFoundException;
|
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||||
import de.cannamanage.service.exception.QuotaExceededException;
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -108,6 +109,18 @@ public class GlobalExceptionHandler {
|
|||||||
return problem;
|
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)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom UserDetails principal for member portal sessions.
|
||||||
|
* Carries tenantId and memberId so portal controllers can enforce data scoping.
|
||||||
|
*/
|
||||||
|
public class PortalPrincipal extends User {
|
||||||
|
|
||||||
|
private final UUID tenantId;
|
||||||
|
private final UUID memberId;
|
||||||
|
|
||||||
|
public PortalPrincipal(String username, String password,
|
||||||
|
Collection<? extends GrantedAuthority> authorities,
|
||||||
|
UUID tenantId, UUID memberId) {
|
||||||
|
super(username, password, authorities);
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserDetailsService for portal session-based auth.
|
||||||
|
* Only loads MEMBER-role users who are active. Members log in by email.
|
||||||
|
*/
|
||||||
|
@Service("portalUserDetailsService")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PortalUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email));
|
||||||
|
|
||||||
|
// Only MEMBER role users may use the portal
|
||||||
|
if (user.getRole() != UserRole.ROLE_MEMBER) {
|
||||||
|
throw new UsernameNotFoundException("User is not a member");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be active
|
||||||
|
if (!user.isActive()) {
|
||||||
|
throw new UsernameNotFoundException("User account is inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a linked memberId
|
||||||
|
if (user.getMemberId() == null) {
|
||||||
|
throw new UsernameNotFoundException("User has no linked member profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorities = List.of(new SimpleGrantedAuthority("ROLE_MEMBER"));
|
||||||
|
|
||||||
|
return new PortalPrincipal(
|
||||||
|
user.getEmail(),
|
||||||
|
user.getPasswordHash(),
|
||||||
|
authorities,
|
||||||
|
user.getTenantId(),
|
||||||
|
user.getMemberId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security configuration — Sprint 3: API + Staff portal with JWT.
|
* Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
|
||||||
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
|
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -24,6 +27,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthFilter jwtAuthFilter;
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
private final PortalUserDetailsService portalUserDetailsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API security — stateless JWT authentication.
|
* API security — stateless JWT authentication.
|
||||||
@@ -53,10 +57,52 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public endpoints — Swagger UI, actuator health.
|
* Member portal — session-based authentication with CSRF protection.
|
||||||
|
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
@Order(2)
|
@Order(2)
|
||||||
|
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/portal/**")
|
||||||
|
.csrf(csrf -> csrf
|
||||||
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||||
|
.maximumSessions(1))
|
||||||
|
.userDetailsService(portalUserDetailsService)
|
||||||
|
.formLogin(form -> form
|
||||||
|
.loginProcessingUrl("/portal/login")
|
||||||
|
.successHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"ok\"}");
|
||||||
|
})
|
||||||
|
.failureHandler((request, response, exception) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
|
||||||
|
})
|
||||||
|
.permitAll())
|
||||||
|
.logout(logout -> logout
|
||||||
|
.logoutUrl("/portal/logout")
|
||||||
|
.logoutSuccessHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"logged_out\"}");
|
||||||
|
}))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
|
||||||
|
.requestMatchers("/portal/**").hasRole("MEMBER"));
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public endpoints — Swagger UI, actuator health.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(3)
|
||||||
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
||||||
|
|||||||
@@ -30,3 +30,9 @@ spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
|
|||||||
|
|
||||||
# App base URL (for invite links)
|
# App base URL (for invite links)
|
||||||
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
# Session configuration (member portal)
|
||||||
|
server.servlet.session.timeout=30m
|
||||||
|
server.servlet.session.cookie.same-site=strict
|
||||||
|
server.servlet.session.cookie.http-only=true
|
||||||
|
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
package de.cannamanage.api.controller;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
|
||||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
|
||||||
import de.cannamanage.api.security.JwtAuthFilter;
|
|
||||||
import de.cannamanage.api.security.JwtService;
|
|
||||||
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.domain.enums.UserRole;
|
|
||||||
import de.cannamanage.service.StaffService;
|
|
||||||
import de.cannamanage.service.TokenRevocationService;
|
|
||||||
import de.cannamanage.service.repository.UserRepository;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
|
||||||
import org.springframework.boot.test.mock.bean.MockBean;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
@WebMvcTest(StaffController.class)
|
|
||||||
class StaffControllerTest {
|
|
||||||
|
|
||||||
@Autowired private MockMvc mockMvc;
|
|
||||||
@Autowired private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@MockBean private StaffService staffService;
|
|
||||||
@MockBean private UserRepository userRepository;
|
|
||||||
@MockBean private JwtService jwtService;
|
|
||||||
@MockBean private JwtAuthFilter jwtAuthFilter;
|
|
||||||
@MockBean private TokenRevocationService tokenRevocationService;
|
|
||||||
|
|
||||||
private UUID tenantId;
|
|
||||||
private UUID staffId;
|
|
||||||
private UUID userId;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
tenantId = UUID.randomUUID();
|
|
||||||
staffId = UUID.randomUUID();
|
|
||||||
userId = UUID.randomUUID();
|
|
||||||
TenantContext.setCurrentTenant(tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "ADMIN")
|
|
||||||
void listStaff_returnsStaffList() throws Exception {
|
|
||||||
StaffAccount staff = createStaffAccount();
|
|
||||||
User user = createUser();
|
|
||||||
when(staffService.listStaff(tenantId)).thenReturn(List.of(staff));
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/staff"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$[0].displayName").value("Test Staff"))
|
|
||||||
.andExpect(jsonPath("$[0].email").value("staff@test.de"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "ADMIN")
|
|
||||||
void createStaff_validRequest_returns201() throws Exception {
|
|
||||||
CreateStaffRequest request = new CreateStaffRequest(
|
|
||||||
"new@test.de", "New Staff",
|
|
||||||
EnumSet.of(StaffPermission.VIEW_STOCK), null);
|
|
||||||
|
|
||||||
StaffAccount created = createStaffAccount();
|
|
||||||
when(staffService.createStaff(eq(tenantId), eq("new@test.de"), eq("New Staff"), any(), any()))
|
|
||||||
.thenReturn(created);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/staff")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isCreated())
|
|
||||||
.andExpect(jsonPath("$.displayName").value("Test Staff"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "ADMIN")
|
|
||||||
void createStaff_invalidEmail_returns400() throws Exception {
|
|
||||||
CreateStaffRequest request = new CreateStaffRequest(
|
|
||||||
"not-an-email", "Bad Staff",
|
|
||||||
EnumSet.of(StaffPermission.VIEW_STOCK), null);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/staff")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "ADMIN")
|
|
||||||
void getStaff_returns200() throws Exception {
|
|
||||||
StaffAccount staff = createStaffAccount();
|
|
||||||
User user = createUser();
|
|
||||||
when(staffService.getStaff(tenantId, staffId)).thenReturn(staff);
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/staff/{id}", staffId))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.id").value(staffId.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "ADMIN")
|
|
||||||
void deactivateStaff_returns204() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/v1/staff/{id}", staffId).with(csrf()))
|
|
||||||
.andExpect(status().isNoContent());
|
|
||||||
|
|
||||||
verify(staffService).deactivateStaff(tenantId, staffId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "ADMIN")
|
|
||||||
void listTemplates_returnsTemplateMap() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/v1/staff/templates"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.ausgabe").isArray())
|
|
||||||
.andExpect(jsonPath("$.lager").isArray())
|
|
||||||
.andExpect(jsonPath("$.vorstand").isArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(roles = "MEMBER")
|
|
||||||
void listStaff_asMember_returns403() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/v1/staff"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
private StaffAccount createStaffAccount() {
|
|
||||||
StaffAccount staff = new StaffAccount();
|
|
||||||
staff.setId(staffId);
|
|
||||||
staff.setTenantId(tenantId);
|
|
||||||
staff.setUserId(userId);
|
|
||||||
staff.setDisplayName("Test Staff");
|
|
||||||
staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK));
|
|
||||||
staff.setActive(true);
|
|
||||||
staff.setCreatedAt(Instant.now());
|
|
||||||
return staff;
|
|
||||||
}
|
|
||||||
|
|
||||||
private User createUser() {
|
|
||||||
User user = new User();
|
|
||||||
user.setId(userId);
|
|
||||||
user.setEmail("staff@test.de");
|
|
||||||
user.setRole(UserRole.ROLE_STAFF);
|
|
||||||
user.setActive(true);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for integration tests using Testcontainers PostgreSQL.
|
||||||
|
* Uses RestClient (Spring Boot 4 — TestRestTemplate was removed in Boot 4).
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@Testcontainers
|
||||||
|
@ActiveProfiles("integration")
|
||||||
|
public abstract class AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||||
|
.withDatabaseName("cannamanage_test")
|
||||||
|
.withUsername("test")
|
||||||
|
.withPassword("test");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", postgres::getUsername);
|
||||||
|
registry.add("spring.datasource.password", postgres::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
protected int port;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected ClubRepository clubRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected MemberRepository memberRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a RestClient configured with the test server's base URL.
|
||||||
|
* Configured to NOT throw on 4xx/5xx responses (so tests can assert status codes).
|
||||||
|
*/
|
||||||
|
protected RestClient restClient() {
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl("http://localhost:" + port)
|
||||||
|
.defaultStatusHandler(org.springframework.http.HttpStatusCode::isError, (req, res) -> {
|
||||||
|
// Don't throw — let tests inspect status codes directly
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth helper methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in with given credentials and returns the full LoginResponse.
|
||||||
|
*/
|
||||||
|
protected LoginResponse login(String email, String password) {
|
||||||
|
return restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest(email, password))
|
||||||
|
.retrieve()
|
||||||
|
.body(LoginResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: login and return just the access token.
|
||||||
|
*/
|
||||||
|
protected String getAccessToken(String email, String password) {
|
||||||
|
return login(email, password).accessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test data creation helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a club (tenant) and returns its ID.
|
||||||
|
*/
|
||||||
|
protected UUID createTestClub(String name) {
|
||||||
|
Club club = new Club();
|
||||||
|
club.setName(name);
|
||||||
|
club.setStatus(ClubStatus.ACTIVE);
|
||||||
|
club.setMaxMembers(500);
|
||||||
|
club.setMaxPreventionOfficers(3);
|
||||||
|
club = clubRepository.save(club);
|
||||||
|
return club.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an admin user for the given tenant and returns the User entity.
|
||||||
|
*/
|
||||||
|
protected User createAdminUser(UUID tenantId, String email, String password) {
|
||||||
|
User user = new User();
|
||||||
|
user.setTenantId(tenantId);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setRole(UserRole.ROLE_ADMIN);
|
||||||
|
user.setActive(true);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a member user for the portal (ROLE_MEMBER) linked to a Member entity.
|
||||||
|
*/
|
||||||
|
protected User createMemberUser(UUID tenantId, UUID memberId, String email, String password) {
|
||||||
|
User user = new User();
|
||||||
|
user.setTenantId(tenantId);
|
||||||
|
user.setMemberId(memberId);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setRole(UserRole.ROLE_MEMBER);
|
||||||
|
user.setActive(true);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Member entity via API (requires admin token).
|
||||||
|
*/
|
||||||
|
protected MemberResponse createTestMember(String adminToken, String firstName, String lastName,
|
||||||
|
String email, LocalDate dateOfBirth) {
|
||||||
|
CreateMemberRequest request = new CreateMemberRequest(
|
||||||
|
firstName, lastName, email, dateOfBirth,
|
||||||
|
LocalDate.now(), "M-" + UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
return restClient().post()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(MemberResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Batch entity via API (requires admin token).
|
||||||
|
*/
|
||||||
|
protected BatchResponse createTestBatch(String adminToken, UUID strainId, BigDecimal quantity, String batchCode) {
|
||||||
|
CreateBatchRequest request = new CreateBatchRequest(strainId, quantity, LocalDate.now(), batchCode);
|
||||||
|
return restClient().post()
|
||||||
|
.uri("/api/v1/stock/batches")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(BatchResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Member entity directly in the DB (bypassing API / tenant filter).
|
||||||
|
*/
|
||||||
|
protected Member createMemberDirectly(UUID tenantId, String firstName, String lastName,
|
||||||
|
String email, LocalDate dateOfBirth) {
|
||||||
|
Member member = new Member();
|
||||||
|
member.setTenantId(tenantId);
|
||||||
|
member.setClubId(tenantId);
|
||||||
|
member.setFirstName(firstName);
|
||||||
|
member.setLastName(lastName);
|
||||||
|
member.setEmail(email);
|
||||||
|
member.setDateOfBirth(dateOfBirth);
|
||||||
|
member.setMembershipDate(LocalDate.now());
|
||||||
|
member.setMembershipNumber("M-" + UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
member.setUnder21(java.time.Period.between(dateOfBirth, LocalDate.now()).getYears() < 21);
|
||||||
|
return memberRepository.save(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Full authentication flow.
|
||||||
|
* Tests login, token refresh, revocation, and error cases.
|
||||||
|
*/
|
||||||
|
class AuthIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private static final String ADMIN_EMAIL = "auth-admin@test.de";
|
||||||
|
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Auth Test Club");
|
||||||
|
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Login with valid credentials returns JWT + refresh token")
|
||||||
|
void loginWithValidCredentials_returnsTokens() {
|
||||||
|
LoginResponse response = login(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
assertThat(response).isNotNull();
|
||||||
|
assertThat(response.accessToken()).isNotBlank();
|
||||||
|
assertThat(response.refreshToken()).isNotBlank();
|
||||||
|
assertThat(response.expiresIn()).isEqualTo(3600L);
|
||||||
|
assertThat(response.role()).isEqualTo("ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Access protected endpoint with JWT returns 200")
|
||||||
|
void accessProtectedEndpoint_withValidJwt_returns200() {
|
||||||
|
String token = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Refresh token returns new JWT pair")
|
||||||
|
void refreshToken_returnsNewTokenPair() {
|
||||||
|
LoginResponse loginResponse = login(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
ResponseEntity<LoginResponse> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new RefreshRequest(loginResponse.refreshToken()))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(LoginResponse.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
LoginResponse refreshed = response.getBody();
|
||||||
|
assertThat(refreshed).isNotNull();
|
||||||
|
assertThat(refreshed.accessToken()).isNotBlank();
|
||||||
|
assertThat(refreshed.refreshToken()).isNotBlank();
|
||||||
|
assertThat(refreshed.accessToken()).isNotEqualTo(loginResponse.accessToken());
|
||||||
|
assertThat(refreshed.refreshToken()).isNotEqualTo(loginResponse.refreshToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Old refresh token is invalidated after rotation")
|
||||||
|
void oldRefreshToken_afterRotation_isInvalid() {
|
||||||
|
LoginResponse loginResponse = login(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
String oldRefreshToken = loginResponse.refreshToken();
|
||||||
|
|
||||||
|
// Use refresh token once (rotation)
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new RefreshRequest(oldRefreshToken))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(LoginResponse.class);
|
||||||
|
|
||||||
|
// Try to use the old refresh token again — should fail
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new RefreshRequest(oldRefreshToken))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Login with wrong password returns 401")
|
||||||
|
void loginWithWrongPassword_returns401() {
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest(ADMIN_EMAIL, "WrongPassword!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Login with non-existent email returns 401")
|
||||||
|
void loginWithNonExistentEmail_returns401() {
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest("nobody@test.de", "whatever"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Access protected endpoint without token returns 401/403")
|
||||||
|
void accessProtectedEndpoint_withoutToken_returnsUnauthorized() {
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Inactive user cannot login")
|
||||||
|
void inactiveUser_cannotLogin() {
|
||||||
|
User inactiveUser = new User();
|
||||||
|
inactiveUser.setTenantId(tenantId);
|
||||||
|
inactiveUser.setEmail("inactive@test.de");
|
||||||
|
inactiveUser.setPasswordHash(passwordEncoder.encode("Test123!"));
|
||||||
|
inactiveUser.setRole(UserRole.ROLE_ADMIN);
|
||||||
|
inactiveUser.setActive(false);
|
||||||
|
userRepository.save(inactiveUser);
|
||||||
|
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest("inactive@test.de", "Test123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Portal session-based authentication.
|
||||||
|
* Verifies form login, session cookie, own-data access, and access denial.
|
||||||
|
*/
|
||||||
|
class PortalIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Portal Test Club");
|
||||||
|
|
||||||
|
// Create a member directly in DB
|
||||||
|
Member member = createMemberDirectly(tenantId, "Portal", "User",
|
||||||
|
"portal@test.de", LocalDate.of(1990, 5, 15));
|
||||||
|
memberId = member.getId();
|
||||||
|
|
||||||
|
// Create a MEMBER user linked to the member
|
||||||
|
createMemberUser(tenantId, memberId, "portal@test.de", "PortalPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal login with valid credentials returns 200 + session cookie")
|
||||||
|
void portalLogin_validCredentials_returnsOk() {
|
||||||
|
// Portal login is form-based — POST with x-www-form-urlencoded
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=PortalPass123!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).contains("ok");
|
||||||
|
|
||||||
|
// Session cookie should be set
|
||||||
|
assertThat(response.getHeaders().get("Set-Cookie")).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal dashboard accessible with session")
|
||||||
|
void portalDashboard_withSession_returns200() {
|
||||||
|
// Login first
|
||||||
|
ResponseEntity<String> loginResponse = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=PortalPass123!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Extract session cookie
|
||||||
|
String sessionCookie = loginResponse.getHeaders().getFirst("Set-Cookie");
|
||||||
|
if (sessionCookie != null) {
|
||||||
|
String cookieValue = sessionCookie.split(";")[0];
|
||||||
|
|
||||||
|
ResponseEntity<String> dashResponse = restClient().get()
|
||||||
|
.uri("/portal/dashboard")
|
||||||
|
.header("Cookie", cookieValue)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(dashResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal quota endpoint returns member's quota data")
|
||||||
|
void portalQuota_withSession_returns200() {
|
||||||
|
ResponseEntity<String> loginResponse = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=PortalPass123!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
String sessionCookie = loginResponse.getHeaders().getFirst("Set-Cookie");
|
||||||
|
if (sessionCookie != null) {
|
||||||
|
String cookieValue = sessionCookie.split(";")[0];
|
||||||
|
|
||||||
|
ResponseEntity<String> quotaResponse = restClient().get()
|
||||||
|
.uri("/portal/quota")
|
||||||
|
.header("Cookie", cookieValue)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(quotaResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal access without session returns unauthorized/redirect")
|
||||||
|
void portalAccess_withoutSession_returnsUnauthorized() {
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/portal/dashboard")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal login with invalid credentials returns 401")
|
||||||
|
void portalLogin_invalidCredentials_returns401() {
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=WrongPassword!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Report generation E2E.
|
||||||
|
* Verifies JSON, PDF, and CSV output for monthly reports and recall reports.
|
||||||
|
*/
|
||||||
|
class ReportIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StrainRepository strainRepository;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Report Test Club");
|
||||||
|
createAdminUser(tenantId, "report-admin@test.de", "AdminPass123!");
|
||||||
|
adminToken = getAccessToken("report-admin@test.de", "AdminPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Strain createTestStrain(String name) {
|
||||||
|
Strain strain = new Strain();
|
||||||
|
strain.setTenantId(tenantId);
|
||||||
|
strain.setName(name);
|
||||||
|
strain.setThcPercentage(new BigDecimal("18.5"));
|
||||||
|
strain.setCbdPercentage(new BigDecimal("0.5"));
|
||||||
|
TenantContext.setCurrentTenant(tenantId);
|
||||||
|
strain = strainRepository.save(strain);
|
||||||
|
TenantContext.clear();
|
||||||
|
return strain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Monthly report JSON — returns totals and distribution data")
|
||||||
|
void monthlyReportJson_returnsTotals() {
|
||||||
|
MemberResponse member = createTestMember(adminToken, "Report", "Member",
|
||||||
|
"report-member@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
|
||||||
|
Strain strain = createTestStrain("Test Strain");
|
||||||
|
BatchResponse batch = createTestBatch(adminToken, strain.getId(),
|
||||||
|
new BigDecimal("500.0"), "BATCH-R-001");
|
||||||
|
|
||||||
|
// Create a distribution
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
member.id(), batch.id(), new BigDecimal("5.0"), "Report test"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Get monthly report as JSON
|
||||||
|
String currentMonth = YearMonth.now().toString();
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=json")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody()).contains("totalDistributions");
|
||||||
|
assertThat(response.getBody()).contains("totalGrams");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Monthly report PDF — returns valid PDF bytes")
|
||||||
|
void monthlyReportPdf_returnsValidPdf() {
|
||||||
|
createTestMember(adminToken, "PDF", "Member",
|
||||||
|
"pdf-member@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
|
||||||
|
String currentMonth = YearMonth.now().toString();
|
||||||
|
ResponseEntity<byte[]> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=pdf")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody().length).isGreaterThan(0);
|
||||||
|
// PDF starts with %PDF
|
||||||
|
assertThat(new String(response.getBody(), 0, 4)).isEqualTo("%PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Monthly report CSV — returns UTF-8 BOM + headers")
|
||||||
|
void monthlyReportCsv_returnsValidCsv() {
|
||||||
|
createTestMember(adminToken, "CSV", "Member",
|
||||||
|
"csv-member@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
|
||||||
|
String currentMonth = YearMonth.now().toString();
|
||||||
|
ResponseEntity<byte[]> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=csv")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody().length).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Check UTF-8 BOM (0xEF 0xBB 0xBF)
|
||||||
|
assertThat(response.getBody()[0]).isEqualTo((byte) 0xEF);
|
||||||
|
assertThat(response.getBody()[1]).isEqualTo((byte) 0xBB);
|
||||||
|
assertThat(response.getBody()[2]).isEqualTo((byte) 0xBF);
|
||||||
|
|
||||||
|
// Verify CSV has separator content
|
||||||
|
String csvContent = new String(response.getBody(), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
assertThat(csvContent).contains(";"); // German CSV uses semicolons
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Recall report — returns affected members for a batch")
|
||||||
|
void recallReport_returnsAffectedMembers() {
|
||||||
|
MemberResponse member1 = createTestMember(adminToken, "Recall", "One",
|
||||||
|
"recall1@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
MemberResponse member2 = createTestMember(adminToken, "Recall", "Two",
|
||||||
|
"recall2@test.de", LocalDate.of(1988, 7, 20));
|
||||||
|
|
||||||
|
Strain strain = createTestStrain("Recall Strain");
|
||||||
|
BatchResponse batch = createTestBatch(adminToken, strain.getId(),
|
||||||
|
new BigDecimal("1000.0"), "BATCH-RECALL-001");
|
||||||
|
|
||||||
|
// Both members get distributions from this batch
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
member1.id(), batch.id(), new BigDecimal("3.0"), "recall test 1"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
member2.id(), batch.id(), new BigDecimal("4.0"), "recall test 2"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Generate recall report for the batch
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/recall?batchId=" + batch.id() + "&format=json")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody()).contains("affectedMembers");
|
||||||
|
assertThat(response.getBody()).contains("Recall");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Non-admin cannot access reports")
|
||||||
|
void nonAdmin_cannotAccessReports() {
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=2026-01&format=json")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
|
import de.cannamanage.domain.entity.InviteToken;
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Staff invite → activate → permission check flow.
|
||||||
|
*/
|
||||||
|
class StaffPermissionIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InviteTokenRepository inviteTokenRepository;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Permission Test Club");
|
||||||
|
createAdminUser(tenantId, "perm-admin@test.de", "AdminPass123!");
|
||||||
|
adminToken = getAccessToken("perm-admin@test.de", "AdminPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Full staff lifecycle: invite → set-password → login → access endpoints")
|
||||||
|
void fullStaffLifecycle_inviteToAccess() {
|
||||||
|
// Step 1: Admin creates staff with RECORD_DISTRIBUTION permission
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
"staff1@test.de", "Staff One",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION), null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> createResponse = restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
assertThat(createResponse.getStatusCode().value()).isEqualTo(201);
|
||||||
|
assertThat(createResponse.getBody()).isNotNull();
|
||||||
|
assertThat(createResponse.getBody().email()).isEqualTo("staff1@test.de");
|
||||||
|
|
||||||
|
// Step 2: Get the invite token from DB
|
||||||
|
List<InviteToken> tokens = inviteTokenRepository.findAll();
|
||||||
|
InviteToken inviteToken = tokens.stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("No invite token found"));
|
||||||
|
|
||||||
|
// Step 3: Staff sets password via invite token
|
||||||
|
ResponseEntity<String> setPwResponse = restClient().post()
|
||||||
|
.uri("/api/v1/auth/set-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(setPwResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Step 4: Staff logs in
|
||||||
|
LoginResponse staffLogin = login("staff1@test.de", "StaffPass123!");
|
||||||
|
assertThat(staffLogin.role()).isEqualTo("STAFF");
|
||||||
|
String staffToken = staffLogin.accessToken();
|
||||||
|
|
||||||
|
// Step 5: Staff CAN access distributions endpoint (has RECORD_DISTRIBUTION)
|
||||||
|
ResponseEntity<String> distResponse = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(distResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Step 6: Staff CANNOT access stock endpoint (no VIEW_STOCK permission)
|
||||||
|
ResponseEntity<String> stockResponse = restClient().get()
|
||||||
|
.uri("/api/v1/stock/batches")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(stockResponse.getStatusCode().value()).isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Staff without VIEW_MEMBER_LIST cannot list members")
|
||||||
|
void staffWithoutViewMemberList_cannotListMembers() {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
"staff2@test.de", "Staff Two",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION), null);
|
||||||
|
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
// Activate
|
||||||
|
InviteToken inviteToken = inviteTokenRepository.findAll().stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/auth/set-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
String staffToken = getAccessToken("staff2@test.de", "StaffPass123!");
|
||||||
|
|
||||||
|
// Try to list members — should be forbidden
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Admin can update staff permissions")
|
||||||
|
void admin_canUpdateStaffPermissions() {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
"staff3@test.de", "Staff Three",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION), null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> createResp = restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
UUID staffId = createResp.getBody().id();
|
||||||
|
|
||||||
|
// Update permissions to add VIEW_STOCK
|
||||||
|
UpdateStaffRequest updateRequest = new UpdateStaffRequest(
|
||||||
|
"Staff Three",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK),
|
||||||
|
null, true);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> updateResp = restClient().put()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updateRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
assertThat(updateResp.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(updateResp.getBody().permissions())
|
||||||
|
.contains(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK);
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Multi-tenant data isolation.
|
||||||
|
* Verifies that Tenant A cannot see Tenant B's members (and vice versa).
|
||||||
|
*/
|
||||||
|
class TenantIsolationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private UUID tenantA;
|
||||||
|
private UUID tenantB;
|
||||||
|
private String tokenA;
|
||||||
|
private String tokenB;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantA = createTestClub("Club Alpha");
|
||||||
|
tenantB = createTestClub("Club Beta");
|
||||||
|
|
||||||
|
createAdminUser(tenantA, "admin-a@alpha.de", "AlphaPass123!");
|
||||||
|
createAdminUser(tenantB, "admin-b@beta.de", "BetaPass123!");
|
||||||
|
|
||||||
|
tokenA = getAccessToken("admin-a@alpha.de", "AlphaPass123!");
|
||||||
|
tokenB = getAccessToken("admin-b@beta.de", "BetaPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant A creates members — only visible to Tenant A")
|
||||||
|
void tenantA_createsMembers_onlyVisibleToTenantA() {
|
||||||
|
createTestMember(tokenA, "Anna", "Alpha", "anna@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
createTestMember(tokenA, "Alex", "Alpha", "alex@alpha.de", LocalDate.of(1985, 6, 20));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseA = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenA)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseA.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseA.getBody()).contains("Anna");
|
||||||
|
assertThat(responseA.getBody()).contains("Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant B creates members — only visible to Tenant B")
|
||||||
|
void tenantB_createsMembers_onlyVisibleToTenantB() {
|
||||||
|
createTestMember(tokenB, "Bob", "Beta", "bob@beta.de", LocalDate.of(1992, 3, 10));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseB = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenB)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseB.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseB.getBody()).contains("Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant A cannot see Tenant B's members")
|
||||||
|
void tenantA_cannotSeeTenantB_members() {
|
||||||
|
createTestMember(tokenA, "Anna", "Alpha", "anna2@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
createTestMember(tokenB, "Bob", "Beta", "bob2@beta.de", LocalDate.of(1992, 3, 10));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseA = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenA)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseA.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseA.getBody()).contains("Anna");
|
||||||
|
assertThat(responseA.getBody()).doesNotContain("Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant B cannot see Tenant A's members")
|
||||||
|
void tenantB_cannotSeeTenantA_members() {
|
||||||
|
createTestMember(tokenA, "Anna", "Alpha", "anna3@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
createTestMember(tokenB, "Bob", "Beta", "bob3@beta.de", LocalDate.of(1992, 3, 10));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseB = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenB)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseB.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseB.getBody()).contains("Bob");
|
||||||
|
assertThat(responseB.getBody()).doesNotContain("Anna");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Distributions are isolated between tenants")
|
||||||
|
void distributions_areIsolated_betweenTenants() {
|
||||||
|
MemberResponse memberA = createTestMember(tokenA, "Anna", "Alpha",
|
||||||
|
"anna4@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
|
||||||
|
BatchResponse batchA = createTestBatch(tokenA, UUID.randomUUID(),
|
||||||
|
new BigDecimal("100.0"), "BATCH-A-001");
|
||||||
|
|
||||||
|
// Create distribution for Tenant A
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + tokenA)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
memberA.id(), batchA.id(), new BigDecimal("5.0"), "Test distribution A"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Tenant B's distribution list should be empty
|
||||||
|
ResponseEntity<String> responseB = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + tokenB)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseB.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseB.getBody()).isEqualTo("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
|
import de.cannamanage.domain.entity.InviteToken;
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Token revocation E2E.
|
||||||
|
* Verifies that permission changes and deactivation properly revoke tokens.
|
||||||
|
*/
|
||||||
|
class TokenRevocationIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InviteTokenRepository inviteTokenRepository;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Token Revocation Club");
|
||||||
|
createAdminUser(tenantId, "revoke-admin@test.de", "AdminPass123!");
|
||||||
|
adminToken = getAccessToken("revoke-admin@test.de", "AdminPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Admin changes staff permissions → old JWT is rejected")
|
||||||
|
void adminChangesPermissions_oldJwtRejected() {
|
||||||
|
// Create and activate a staff member
|
||||||
|
String staffEmail = "revoke-staff1@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK));
|
||||||
|
|
||||||
|
// Staff logs in and gets a valid JWT
|
||||||
|
LoginResponse staffLogin = login(staffEmail, "StaffPass123!");
|
||||||
|
String oldStaffToken = staffLogin.accessToken();
|
||||||
|
|
||||||
|
// Verify old token works
|
||||||
|
ResponseEntity<String> beforeChange = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + oldStaffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(beforeChange.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Admin changes staff permissions (triggers revocation)
|
||||||
|
UpdateStaffRequest updateRequest = new UpdateStaffRequest(
|
||||||
|
"Revoke Staff 1",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION),
|
||||||
|
null, true);
|
||||||
|
|
||||||
|
restClient().put()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updateRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
// Old JWT should now be rejected (revoked)
|
||||||
|
ResponseEntity<String> afterChange = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + oldStaffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(afterChange.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Staff logs in again after permission change → gets new working JWT")
|
||||||
|
void staffLoginsAgain_afterPermissionChange_getsWorkingJwt() {
|
||||||
|
String staffEmail = "revoke-staff2@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK));
|
||||||
|
|
||||||
|
login(staffEmail, "StaffPass123!");
|
||||||
|
|
||||||
|
// Admin changes permissions
|
||||||
|
UpdateStaffRequest updateRequest = new UpdateStaffRequest(
|
||||||
|
"Revoke Staff 2",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_MEMBER_LIST),
|
||||||
|
null, true);
|
||||||
|
|
||||||
|
restClient().put()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updateRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
// Staff logs in again — new JWT should work
|
||||||
|
LoginResponse newLogin = login(staffEmail, "StaffPass123!");
|
||||||
|
String newToken = newLogin.accessToken();
|
||||||
|
|
||||||
|
ResponseEntity<String> distResponse = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + newToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(distResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Admin deactivates staff → all tokens revoked → 401")
|
||||||
|
void adminDeactivatesStaff_allTokensRevoked() {
|
||||||
|
String staffEmail = "revoke-staff3@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION));
|
||||||
|
|
||||||
|
LoginResponse staffLogin = login(staffEmail, "StaffPass123!");
|
||||||
|
String staffToken = staffLogin.accessToken();
|
||||||
|
|
||||||
|
// Verify token works before deactivation
|
||||||
|
ResponseEntity<String> before = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(before.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Admin deactivates staff
|
||||||
|
restClient().delete()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(Void.class);
|
||||||
|
|
||||||
|
// Old token should now be rejected
|
||||||
|
ResponseEntity<String> after = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(after.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Deactivated staff cannot login")
|
||||||
|
void deactivatedStaff_cannotLogin() {
|
||||||
|
String staffEmail = "revoke-staff4@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION));
|
||||||
|
|
||||||
|
// Deactivate
|
||||||
|
restClient().delete()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(Void.class);
|
||||||
|
|
||||||
|
// Try to login — should fail
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest(staffEmail, "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper ---
|
||||||
|
|
||||||
|
private UUID createAndActivateStaff(String email, Set<StaffPermission> permissions) {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
email, "Staff " + email.split("@")[0], permissions, null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> createResp = restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
UUID staffId = createResp.getBody().id();
|
||||||
|
|
||||||
|
// Find the invite token
|
||||||
|
List<InviteToken> tokens = inviteTokenRepository.findAll();
|
||||||
|
InviteToken inviteToken = tokens.stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.reduce((first, second) -> second)
|
||||||
|
.orElseThrow(() -> new AssertionError("No invite token found"));
|
||||||
|
|
||||||
|
// Set password to activate
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/auth/set-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
return staffId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Integration test profile — Testcontainers PostgreSQL (properties injected via @DynamicPropertySource)
|
||||||
|
spring.application.name=cannamanage-integration-test
|
||||||
|
|
||||||
|
# Flyway enabled — runs V1-V5 migrations against real PostgreSQL
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migration
|
||||||
|
|
||||||
|
# JPA
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.open-in-view=false
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
|
||||||
|
# JWT test secret (same as application.properties)
|
||||||
|
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
|
# AOP for TenantFilterAspect
|
||||||
|
spring.aop.auto=true
|
||||||
|
spring.aop.proxy-target-class=true
|
||||||
|
|
||||||
|
# Disable mail sending in integration tests
|
||||||
|
spring.mail.host=localhost
|
||||||
|
spring.mail.port=9999
|
||||||
|
spring.mail.properties.mail.smtp.auth=false
|
||||||
|
|
||||||
|
# App base URL
|
||||||
|
app.base-url=http://localhost:8080
|
||||||
|
|
||||||
|
# Session
|
||||||
|
server.servlet.session.timeout=30m
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.commons</groupId>
|
<groupId>org.apache.commons</groupId>
|
||||||
<artifactId>commons-csv</artifactId>
|
<artifactId>commons-csv</artifactId>
|
||||||
<version>1.11.0</version>
|
<version>1.12.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import org.apache.commons.csv.CSVFormat;
|
||||||
|
import org.apache.commons.csv.CSVPrinter;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates CSV report exports using Apache Commons CSV.
|
||||||
|
* All output is UTF-8 with BOM prefix for Excel compatibility.
|
||||||
|
* German column headers for compliance documentation.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CsvReportGenerator {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||||
|
.withZone(ZoneId.of("Europe/Berlin"));
|
||||||
|
private static final String UTF8_BOM = "\uFEFF";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render monthly report as CSV with daily breakdown.
|
||||||
|
*/
|
||||||
|
public byte[] renderMonthlyReport(MonthlyReport report) {
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {
|
||||||
|
|
||||||
|
writer.write(UTF8_BOM);
|
||||||
|
CSVPrinter csv = new CSVPrinter(writer,
|
||||||
|
CSVFormat.DEFAULT.builder()
|
||||||
|
.setHeader("Datum", "Menge (g)", "Ausgaben")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
if (report.getDailyBreakdown() != null) {
|
||||||
|
for (MonthlyReport.DailyEntry entry : report.getDailyBreakdown()) {
|
||||||
|
csv.printRecord(
|
||||||
|
entry.getDate().format(DATE_FMT),
|
||||||
|
entry.getGrams().toPlainString(),
|
||||||
|
entry.getDistributions()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
csv.flush();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("CSV generation failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render member list as CSV.
|
||||||
|
*/
|
||||||
|
public byte[] renderMemberList(MemberListReport report) {
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {
|
||||||
|
|
||||||
|
writer.write(UTF8_BOM);
|
||||||
|
CSVPrinter csv = new CSVPrinter(writer,
|
||||||
|
CSVFormat.DEFAULT.builder()
|
||||||
|
.setHeader("Vorname", "Nachname", "Mitgliedsnr.", "Status",
|
||||||
|
"Beitritt", "Ausgaben gesamt", "Letzte Ausgabe")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
for (MemberListReport.MemberEntry m : report.getMembers()) {
|
||||||
|
csv.printRecord(
|
||||||
|
m.getFirstName(),
|
||||||
|
m.getLastName(),
|
||||||
|
m.getMembershipNumber(),
|
||||||
|
m.getStatus() != null ? m.getStatus().name() : "",
|
||||||
|
m.getJoinDate() != null ? m.getJoinDate().format(DATE_FMT) : "",
|
||||||
|
m.getTotalDistributions(),
|
||||||
|
m.getLastDistributionDate() != null ? DATETIME_FMT.format(m.getLastDistributionDate()) : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
csv.flush();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("CSV generation failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render recall report as CSV.
|
||||||
|
*/
|
||||||
|
public byte[] renderRecallReport(RecallReport report) {
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8)) {
|
||||||
|
|
||||||
|
writer.write(UTF8_BOM);
|
||||||
|
CSVPrinter csv = new CSVPrinter(writer,
|
||||||
|
CSVFormat.DEFAULT.builder()
|
||||||
|
.setHeader("Vorname", "Nachname", "Ausgabedatum", "Menge (g)")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
for (RecallReport.AffectedMember am : report.getAffectedMembers()) {
|
||||||
|
csv.printRecord(
|
||||||
|
am.getFirstName() != null ? am.getFirstName() : "",
|
||||||
|
am.getLastName() != null ? am.getLastName() : "",
|
||||||
|
am.getDistributionDate() != null ? DATETIME_FMT.format(am.getDistributionDate()) : "",
|
||||||
|
am.getGrams().toPlainString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
csv.flush();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("CSV generation failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import com.lowagie.text.Document;
|
||||||
|
import com.lowagie.text.Element;
|
||||||
|
import com.lowagie.text.Font;
|
||||||
|
import com.lowagie.text.Phrase;
|
||||||
|
import com.lowagie.text.Rectangle;
|
||||||
|
import com.lowagie.text.pdf.ColumnText;
|
||||||
|
import com.lowagie.text.pdf.PdfContentByte;
|
||||||
|
import com.lowagie.text.pdf.PdfPageEventHelper;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF footer event handler.
|
||||||
|
* Adds "Erstellt am: dd.MM.yyyy HH:mm" left-aligned and "Seite N" right-aligned
|
||||||
|
* at the bottom of every page.
|
||||||
|
*/
|
||||||
|
public class PdfFooterHandler extends PdfPageEventHelper {
|
||||||
|
|
||||||
|
private static final Font FOOTER_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
|
||||||
|
private static final DateTimeFormatter FOOTER_DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
|
||||||
|
|
||||||
|
private final String generatedTimestamp;
|
||||||
|
|
||||||
|
public PdfFooterHandler() {
|
||||||
|
this.generatedTimestamp = LocalDateTime.now().format(FOOTER_DATE_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEndPage(PdfWriter writer, Document document) {
|
||||||
|
PdfContentByte cb = writer.getDirectContent();
|
||||||
|
Rectangle pageSize = document.getPageSize();
|
||||||
|
float bottom = document.bottomMargin() - 15;
|
||||||
|
|
||||||
|
// Left: "Erstellt am: dd.MM.yyyy HH:mm"
|
||||||
|
ColumnText.showTextAligned(cb, Element.ALIGN_LEFT,
|
||||||
|
new Phrase("Erstellt am: " + generatedTimestamp, FOOTER_FONT),
|
||||||
|
document.leftMargin(), bottom, 0);
|
||||||
|
|
||||||
|
// Right: "Seite N"
|
||||||
|
ColumnText.showTextAligned(cb, Element.ALIGN_RIGHT,
|
||||||
|
new Phrase("Seite " + writer.getPageNumber(), FOOTER_FONT),
|
||||||
|
pageSize.getWidth() - document.rightMargin(), bottom, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import com.lowagie.text.*;
|
||||||
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates PDF reports using OpenPDF (librepdf fork of iText 2.x).
|
||||||
|
* Minimal branding: club name header, report title, tables with light gray headers.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PdfReportGenerator {
|
||||||
|
|
||||||
|
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
|
||||||
|
private static final Font TITLE_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
|
||||||
|
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
|
||||||
|
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD);
|
||||||
|
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
|
||||||
|
private static final Color HEADER_BG = new Color(220, 220, 220);
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||||
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||||
|
.withZone(ZoneId.of("Europe/Berlin"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a monthly distribution report as PDF.
|
||||||
|
*/
|
||||||
|
public byte[] renderMonthlyReport(MonthlyReport report, Club club) {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PdfWriter writer = PdfWriter.getInstance(document, baos);
|
||||||
|
writer.setPageEvent(new PdfFooterHandler());
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
// Club header
|
||||||
|
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
|
||||||
|
clubHeader.setSpacingAfter(5);
|
||||||
|
document.add(clubHeader);
|
||||||
|
|
||||||
|
// Report title
|
||||||
|
Paragraph title = new Paragraph("Monatsbericht — " + report.getMonth().toString(), TITLE_FONT);
|
||||||
|
title.setSpacingAfter(15);
|
||||||
|
document.add(title);
|
||||||
|
|
||||||
|
// Summary table
|
||||||
|
PdfPTable summary = new PdfPTable(2);
|
||||||
|
summary.setWidthPercentage(60);
|
||||||
|
summary.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||||
|
summary.setSpacingAfter(15);
|
||||||
|
addSummaryRow(summary, "Ausgaben gesamt", String.valueOf(report.getTotalDistributions()));
|
||||||
|
addSummaryRow(summary, "Gesamtmenge (g)", report.getTotalGrams().toPlainString());
|
||||||
|
addSummaryRow(summary, "Eindeutige Mitglieder", String.valueOf(report.getUniqueMembers()));
|
||||||
|
addSummaryRow(summary, "Ø pro Mitglied (g)", report.getAveragePerMember().toPlainString());
|
||||||
|
document.add(summary);
|
||||||
|
|
||||||
|
// Top strains
|
||||||
|
if (report.getTopStrains() != null && !report.getTopStrains().isEmpty()) {
|
||||||
|
document.add(new Paragraph("Top Sorten", TITLE_FONT));
|
||||||
|
document.add(Chunk.NEWLINE);
|
||||||
|
|
||||||
|
PdfPTable strainTable = new PdfPTable(3);
|
||||||
|
strainTable.setWidthPercentage(80);
|
||||||
|
strainTable.setSpacingAfter(15);
|
||||||
|
addTableHeader(strainTable, "Sorte", "Menge (g)", "Ausgaben");
|
||||||
|
for (MonthlyReport.StrainSummary s : report.getTopStrains()) {
|
||||||
|
addCell(strainTable, s.getName());
|
||||||
|
addCell(strainTable, s.getTotalGrams().toPlainString());
|
||||||
|
addCell(strainTable, String.valueOf(s.getDistributionCount()));
|
||||||
|
}
|
||||||
|
document.add(strainTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily breakdown
|
||||||
|
if (report.getDailyBreakdown() != null && !report.getDailyBreakdown().isEmpty()) {
|
||||||
|
document.add(new Paragraph("Tagesübersicht", TITLE_FONT));
|
||||||
|
document.add(Chunk.NEWLINE);
|
||||||
|
|
||||||
|
PdfPTable dailyTable = new PdfPTable(3);
|
||||||
|
dailyTable.setWidthPercentage(80);
|
||||||
|
dailyTable.setSpacingAfter(15);
|
||||||
|
addTableHeader(dailyTable, "Datum", "Menge (g)", "Ausgaben");
|
||||||
|
for (MonthlyReport.DailyEntry e : report.getDailyBreakdown()) {
|
||||||
|
addCell(dailyTable, e.getDate().format(DATE_FMT));
|
||||||
|
addCell(dailyTable, e.getGrams().toPlainString());
|
||||||
|
addCell(dailyTable, String.valueOf(e.getDistributions()));
|
||||||
|
}
|
||||||
|
document.add(dailyTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
} catch (DocumentException e) {
|
||||||
|
throw new RuntimeException("PDF generation failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a member list report as PDF.
|
||||||
|
*/
|
||||||
|
public byte[] renderMemberList(MemberListReport report, Club club) {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
Document document = new Document(PageSize.A4.rotate(), 50, 50, 50, 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PdfWriter writer = PdfWriter.getInstance(document, baos);
|
||||||
|
writer.setPageEvent(new PdfFooterHandler());
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
|
||||||
|
clubHeader.setSpacingAfter(5);
|
||||||
|
document.add(clubHeader);
|
||||||
|
|
||||||
|
Paragraph title = new Paragraph("Mitgliederliste", TITLE_FONT);
|
||||||
|
title.setSpacingAfter(15);
|
||||||
|
document.add(title);
|
||||||
|
|
||||||
|
PdfPTable table = new PdfPTable(7);
|
||||||
|
table.setWidthPercentage(100);
|
||||||
|
table.setWidths(new float[]{2f, 2f, 2f, 2f, 1.5f, 1.5f, 2.5f});
|
||||||
|
addTableHeader(table, "Vorname", "Nachname", "Mitgliedsnr.", "Status",
|
||||||
|
"Beitritt", "Ausgaben", "Letzte Ausgabe");
|
||||||
|
|
||||||
|
for (MemberListReport.MemberEntry m : report.getMembers()) {
|
||||||
|
addCell(table, m.getFirstName());
|
||||||
|
addCell(table, m.getLastName());
|
||||||
|
addCell(table, m.getMembershipNumber());
|
||||||
|
addCell(table, m.getStatus() != null ? m.getStatus().name() : "—");
|
||||||
|
addCell(table, m.getJoinDate() != null ? m.getJoinDate().format(DATE_FMT) : "—");
|
||||||
|
addCell(table, String.valueOf(m.getTotalDistributions()));
|
||||||
|
addCell(table, m.getLastDistributionDate() != null
|
||||||
|
? DATETIME_FMT.format(m.getLastDistributionDate()) : "—");
|
||||||
|
}
|
||||||
|
document.add(table);
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
} catch (DocumentException e) {
|
||||||
|
throw new RuntimeException("PDF generation failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a recall/batch trace report as PDF.
|
||||||
|
*/
|
||||||
|
public byte[] renderRecallReport(RecallReport report, Club club) {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PdfWriter writer = PdfWriter.getInstance(document, baos);
|
||||||
|
writer.setPageEvent(new PdfFooterHandler());
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
Paragraph clubHeader = new Paragraph(club.getName(), HEADER_FONT);
|
||||||
|
clubHeader.setSpacingAfter(5);
|
||||||
|
document.add(clubHeader);
|
||||||
|
|
||||||
|
Paragraph title = new Paragraph("Rückruf-Bericht — Charge " + report.getBatchNumber(), TITLE_FONT);
|
||||||
|
title.setSpacingAfter(15);
|
||||||
|
document.add(title);
|
||||||
|
|
||||||
|
// Batch info
|
||||||
|
PdfPTable info = new PdfPTable(2);
|
||||||
|
info.setWidthPercentage(60);
|
||||||
|
info.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||||
|
info.setSpacingAfter(15);
|
||||||
|
addSummaryRow(info, "Sorte", report.getStrainName());
|
||||||
|
addSummaryRow(info, "Chargen-Nr.", report.getBatchNumber());
|
||||||
|
addSummaryRow(info, "Erntedatum",
|
||||||
|
report.getReceivedDate() != null ? report.getReceivedDate().format(DATE_FMT) : "—");
|
||||||
|
addSummaryRow(info, "Verteilte Menge (g)", report.getTotalGramsDistributed().toPlainString());
|
||||||
|
addSummaryRow(info, "Betroffene Mitglieder", String.valueOf(report.getAffectedMembers().size()));
|
||||||
|
document.add(info);
|
||||||
|
|
||||||
|
// Affected members table
|
||||||
|
document.add(new Paragraph("Betroffene Mitglieder", TITLE_FONT));
|
||||||
|
document.add(Chunk.NEWLINE);
|
||||||
|
|
||||||
|
PdfPTable table = new PdfPTable(4);
|
||||||
|
table.setWidthPercentage(100);
|
||||||
|
addTableHeader(table, "Vorname", "Nachname", "Ausgabedatum", "Menge (g)");
|
||||||
|
|
||||||
|
for (RecallReport.AffectedMember am : report.getAffectedMembers()) {
|
||||||
|
addCell(table, am.getFirstName() != null ? am.getFirstName() : "—");
|
||||||
|
addCell(table, am.getLastName() != null ? am.getLastName() : "—");
|
||||||
|
addCell(table, am.getDistributionDate() != null
|
||||||
|
? DATETIME_FMT.format(am.getDistributionDate()) : "—");
|
||||||
|
addCell(table, am.getGrams().toPlainString());
|
||||||
|
}
|
||||||
|
document.add(table);
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
} catch (DocumentException e) {
|
||||||
|
throw new RuntimeException("PDF generation failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper methods ---
|
||||||
|
|
||||||
|
private void addTableHeader(PdfPTable table, String... headers) {
|
||||||
|
for (String h : headers) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
|
||||||
|
cell.setBackgroundColor(HEADER_BG);
|
||||||
|
cell.setPadding(5);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCell(PdfPTable table, String text) {
|
||||||
|
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
|
||||||
|
cell.setPadding(4);
|
||||||
|
table.addCell(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSummaryRow(PdfPTable table, String label, String value) {
|
||||||
|
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_HEADER_FONT));
|
||||||
|
labelCell.setBorder(Rectangle.NO_BORDER);
|
||||||
|
labelCell.setPadding(4);
|
||||||
|
table.addCell(labelCell);
|
||||||
|
|
||||||
|
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
|
||||||
|
valueCell.setBorder(Rectangle.NO_BORDER);
|
||||||
|
valueCell.setPadding(4);
|
||||||
|
table.addCell(valueCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.service.dto.QuotaStatus;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service layer for the member self-service portal.
|
||||||
|
* All methods enforce member-scoped data access — only the specified memberId's data is returned.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class PortalService {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final DistributionRepository distributionRepository;
|
||||||
|
private final ComplianceService complianceService;
|
||||||
|
private final BatchRepository batchRepository;
|
||||||
|
private final StrainRepository strainRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public PortalService(MemberRepository memberRepository,
|
||||||
|
DistributionRepository distributionRepository,
|
||||||
|
ComplianceService complianceService,
|
||||||
|
BatchRepository batchRepository,
|
||||||
|
StrainRepository strainRepository,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.distributionRepository = distributionRepository;
|
||||||
|
this.complianceService = complianceService;
|
||||||
|
this.batchRepository = batchRepository;
|
||||||
|
this.strainRepository = strainRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard: quota summary + last 5 distributions.
|
||||||
|
*/
|
||||||
|
public PortalDashboard getDashboard(UUID tenantId, UUID memberId) {
|
||||||
|
Member member = loadMember(memberId);
|
||||||
|
QuotaStatus quota = complianceService.getQuotaStatus(memberId);
|
||||||
|
|
||||||
|
// Daily usage
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
BigDecimal dailyUsed = distributionRepository.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
|
||||||
|
BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS;
|
||||||
|
|
||||||
|
// Recent 5 distributions
|
||||||
|
List<Distribution> recent = distributionRepository
|
||||||
|
.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId);
|
||||||
|
|
||||||
|
List<PortalDashboard.RecentDistribution> recentDtos = recent.stream()
|
||||||
|
.map(d -> new PortalDashboard.RecentDistribution(
|
||||||
|
d.getDistributedAt(),
|
||||||
|
resolveStrainName(d.getBatchId()),
|
||||||
|
d.getQuantityGrams(),
|
||||||
|
resolveStaffName(d.getRecordedBy())
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new PortalDashboard(
|
||||||
|
member.getFirstName() + " " + member.getLastName(),
|
||||||
|
member.getMembershipNumber(),
|
||||||
|
quota.totalUsed(),
|
||||||
|
quota.remaining(),
|
||||||
|
dailyUsed,
|
||||||
|
dailyLimit.subtract(dailyUsed),
|
||||||
|
recentDtos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile.
|
||||||
|
*/
|
||||||
|
public PortalProfile getProfile(UUID tenantId, UUID memberId) {
|
||||||
|
Member member = loadMember(memberId);
|
||||||
|
return new PortalProfile(
|
||||||
|
member.getFirstName(),
|
||||||
|
member.getLastName(),
|
||||||
|
member.getMembershipNumber(),
|
||||||
|
member.getMembershipDate(),
|
||||||
|
member.getStatus(),
|
||||||
|
member.getEmail()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed quota status for current month.
|
||||||
|
*/
|
||||||
|
public PortalQuota getQuota(UUID tenantId, UUID memberId) {
|
||||||
|
Member member = loadMember(memberId);
|
||||||
|
QuotaStatus quota = complianceService.getQuotaStatus(memberId);
|
||||||
|
|
||||||
|
// Daily usage
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
BigDecimal dailyUsed = distributionRepository.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
|
||||||
|
|
||||||
|
return new PortalQuota(
|
||||||
|
quota.year(),
|
||||||
|
quota.month(),
|
||||||
|
dailyUsed,
|
||||||
|
ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS,
|
||||||
|
quota.totalUsed(),
|
||||||
|
quota.totalAllowed(),
|
||||||
|
member.isUnder21(),
|
||||||
|
ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated distribution history for the member.
|
||||||
|
*/
|
||||||
|
public PortalDistributionHistory getDistributionHistory(UUID tenantId, UUID memberId, Pageable pageable) {
|
||||||
|
Page<Distribution> page = distributionRepository
|
||||||
|
.findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable);
|
||||||
|
|
||||||
|
List<PortalDistributionHistory.DistributionEntry> entries = page.getContent().stream()
|
||||||
|
.map(d -> new PortalDistributionHistory.DistributionEntry(
|
||||||
|
d.getDistributedAt(),
|
||||||
|
resolveStrainName(d.getBatchId()),
|
||||||
|
d.getQuantityGrams(),
|
||||||
|
resolveStaffName(d.getRecordedBy())
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new PortalDistributionHistory(entries, page.getNumber(), page.getTotalPages(), page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member loadMember(UUID memberId) {
|
||||||
|
return memberRepository.findById(memberId)
|
||||||
|
.orElseThrow(() -> new MemberNotFoundException(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveStrainName(UUID batchId) {
|
||||||
|
return batchRepository.findById(batchId)
|
||||||
|
.flatMap(batch -> strainRepository.findById(batch.getStrainId()))
|
||||||
|
.map(Strain::getName)
|
||||||
|
.orElse("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveStaffName(UUID userId) {
|
||||||
|
return userRepository.findById(userId)
|
||||||
|
.map(User::getEmail)
|
||||||
|
.orElse("Unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -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,215 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates report data for compliance and operational reporting.
|
||||||
|
* All methods are read-only transactions scoped to a single tenant.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class ReportService {
|
||||||
|
|
||||||
|
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
|
||||||
|
|
||||||
|
private final DistributionRepository distributionRepository;
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final BatchRepository batchRepository;
|
||||||
|
private final StrainRepository strainRepository;
|
||||||
|
|
||||||
|
public ReportService(DistributionRepository distributionRepository,
|
||||||
|
MemberRepository memberRepository,
|
||||||
|
BatchRepository batchRepository,
|
||||||
|
StrainRepository strainRepository) {
|
||||||
|
this.distributionRepository = distributionRepository;
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.batchRepository = batchRepository;
|
||||||
|
this.strainRepository = strainRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a monthly distribution report for the given tenant and month.
|
||||||
|
*/
|
||||||
|
public MonthlyReport generateMonthlyReport(UUID tenantId, YearMonth month) {
|
||||||
|
LocalDate startDate = month.atDay(1);
|
||||||
|
LocalDate endDate = month.atEndOfMonth().plusDays(1);
|
||||||
|
Instant start = startDate.atStartOfDay(BERLIN).toInstant();
|
||||||
|
Instant end = endDate.atStartOfDay(BERLIN).toInstant();
|
||||||
|
|
||||||
|
List<Distribution> distributions = distributionRepository.findByTenantIdAndDistributedAtBetween(tenantId, start, end);
|
||||||
|
|
||||||
|
MonthlyReport report = new MonthlyReport();
|
||||||
|
report.setMonth(month);
|
||||||
|
report.setTotalDistributions(distributions.size());
|
||||||
|
|
||||||
|
BigDecimal totalGrams = distributions.stream()
|
||||||
|
.map(Distribution::getQuantityGrams)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
report.setTotalGrams(totalGrams);
|
||||||
|
|
||||||
|
Set<UUID> uniqueMemberIds = distributions.stream()
|
||||||
|
.map(Distribution::getMemberId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
report.setUniqueMembers(uniqueMemberIds.size());
|
||||||
|
|
||||||
|
if (!uniqueMemberIds.isEmpty()) {
|
||||||
|
report.setAveragePerMember(totalGrams.divide(
|
||||||
|
BigDecimal.valueOf(uniqueMemberIds.size()), 2, RoundingMode.HALF_UP));
|
||||||
|
} else {
|
||||||
|
report.setAveragePerMember(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top strains by total grams
|
||||||
|
Map<UUID, List<Distribution>> byBatch = distributions.stream()
|
||||||
|
.collect(Collectors.groupingBy(Distribution::getBatchId));
|
||||||
|
|
||||||
|
Map<UUID, BigDecimal> gramsByStrain = new HashMap<>();
|
||||||
|
Map<UUID, Integer> countByStrain = new HashMap<>();
|
||||||
|
|
||||||
|
for (Map.Entry<UUID, List<Distribution>> entry : byBatch.entrySet()) {
|
||||||
|
UUID batchId = entry.getKey();
|
||||||
|
Optional<Batch> batchOpt = batchRepository.findById(batchId);
|
||||||
|
if (batchOpt.isPresent()) {
|
||||||
|
UUID strainId = batchOpt.get().getStrainId();
|
||||||
|
BigDecimal batchGrams = entry.getValue().stream()
|
||||||
|
.map(Distribution::getQuantityGrams)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
gramsByStrain.merge(strainId, batchGrams, BigDecimal::add);
|
||||||
|
countByStrain.merge(strainId, entry.getValue().size(), Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MonthlyReport.StrainSummary> topStrains = gramsByStrain.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.<UUID, BigDecimal>comparingByValue().reversed())
|
||||||
|
.limit(10)
|
||||||
|
.map(e -> {
|
||||||
|
String strainName = strainRepository.findById(e.getKey())
|
||||||
|
.map(Strain::getName)
|
||||||
|
.orElse("Unbekannt");
|
||||||
|
return new MonthlyReport.StrainSummary(strainName, e.getValue(),
|
||||||
|
countByStrain.getOrDefault(e.getKey(), 0));
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
report.setTopStrains(topStrains);
|
||||||
|
|
||||||
|
// Daily breakdown
|
||||||
|
Map<LocalDate, List<Distribution>> byDay = distributions.stream()
|
||||||
|
.collect(Collectors.groupingBy(d ->
|
||||||
|
d.getDistributedAt().atZone(BERLIN).toLocalDate()));
|
||||||
|
|
||||||
|
List<MonthlyReport.DailyEntry> dailyEntries = new ArrayList<>();
|
||||||
|
LocalDate current = startDate;
|
||||||
|
while (current.isBefore(endDate)) {
|
||||||
|
List<Distribution> dayDists = byDay.getOrDefault(current, List.of());
|
||||||
|
BigDecimal dayGrams = dayDists.stream()
|
||||||
|
.map(Distribution::getQuantityGrams)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
dailyEntries.add(new MonthlyReport.DailyEntry(current, dayGrams, dayDists.size()));
|
||||||
|
current = current.plusDays(1);
|
||||||
|
}
|
||||||
|
report.setDailyBreakdown(dailyEntries);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a member list report, optionally filtered by status.
|
||||||
|
*/
|
||||||
|
public MemberListReport generateMemberListReport(UUID tenantId, MemberStatus filterStatus) {
|
||||||
|
List<Member> members;
|
||||||
|
if (filterStatus != null) {
|
||||||
|
members = memberRepository.findByTenantIdAndStatus(tenantId, filterStatus);
|
||||||
|
} else {
|
||||||
|
members = memberRepository.findByTenantId(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
MemberListReport report = new MemberListReport();
|
||||||
|
report.setGeneratedAt(Instant.now());
|
||||||
|
|
||||||
|
List<MemberListReport.MemberEntry> entries = members.stream()
|
||||||
|
.map(m -> {
|
||||||
|
MemberListReport.MemberEntry entry = new MemberListReport.MemberEntry();
|
||||||
|
entry.setId(m.getId());
|
||||||
|
entry.setFirstName(m.getFirstName());
|
||||||
|
entry.setLastName(m.getLastName());
|
||||||
|
entry.setMembershipNumber(m.getMembershipNumber());
|
||||||
|
entry.setStatus(m.getStatus());
|
||||||
|
entry.setJoinDate(m.getMembershipDate());
|
||||||
|
entry.setTotalDistributions((int) distributionRepository.countByTenantIdAndMemberId(tenantId, m.getId()));
|
||||||
|
Distribution latest = distributionRepository.findLatestByTenantIdAndMemberId(tenantId, m.getId());
|
||||||
|
entry.setLastDistributionDate(latest != null ? latest.getDistributedAt() : null);
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
report.setMembers(entries);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a recall report for a specific batch.
|
||||||
|
* Traces all distributions from that batch back to affected members.
|
||||||
|
*/
|
||||||
|
public RecallReport generateRecallReport(UUID tenantId, UUID batchId) {
|
||||||
|
Batch batch = batchRepository.findById(batchId)
|
||||||
|
.orElseThrow(() -> new de.cannamanage.service.exception.BatchNotFoundException(batchId));
|
||||||
|
|
||||||
|
String strainName = strainRepository.findById(batch.getStrainId())
|
||||||
|
.map(Strain::getName)
|
||||||
|
.orElse("Unbekannt");
|
||||||
|
|
||||||
|
List<Distribution> distributions = distributionRepository.findByTenantIdAndBatchId(tenantId, batchId);
|
||||||
|
|
||||||
|
BigDecimal totalDistributed = distributions.stream()
|
||||||
|
.map(Distribution::getQuantityGrams)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
// Resolve member details for each distribution
|
||||||
|
List<RecallReport.AffectedMember> affectedMembers = distributions.stream()
|
||||||
|
.map(d -> {
|
||||||
|
RecallReport.AffectedMember am = new RecallReport.AffectedMember();
|
||||||
|
am.setMemberId(d.getMemberId());
|
||||||
|
am.setDistributionDate(d.getDistributedAt());
|
||||||
|
am.setGrams(d.getQuantityGrams());
|
||||||
|
memberRepository.findById(d.getMemberId()).ifPresent(member -> {
|
||||||
|
am.setFirstName(member.getFirstName());
|
||||||
|
am.setLastName(member.getLastName());
|
||||||
|
});
|
||||||
|
return am;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
RecallReport report = new RecallReport();
|
||||||
|
report.setBatchId(batchId);
|
||||||
|
report.setStrainName(strainName);
|
||||||
|
report.setBatchNumber(batch.getBatchCode());
|
||||||
|
report.setReceivedDate(batch.getHarvestDate());
|
||||||
|
report.setTotalGramsDistributed(totalDistributed);
|
||||||
|
report.setAffectedMembers(affectedMembers);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard overview for portal members — quota summary + recent distributions.
|
||||||
|
*/
|
||||||
|
public record PortalDashboard(
|
||||||
|
String memberName,
|
||||||
|
String membershipNumber,
|
||||||
|
BigDecimal monthlyQuotaUsed,
|
||||||
|
BigDecimal monthlyQuotaRemaining,
|
||||||
|
BigDecimal dailyQuotaUsed,
|
||||||
|
BigDecimal dailyQuotaRemaining,
|
||||||
|
List<RecentDistribution> recentDistributions
|
||||||
|
) {
|
||||||
|
public record RecentDistribution(
|
||||||
|
Instant date,
|
||||||
|
String strainName,
|
||||||
|
BigDecimal grams,
|
||||||
|
String staffName
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated distribution history for a member.
|
||||||
|
*/
|
||||||
|
public record PortalDistributionHistory(
|
||||||
|
List<DistributionEntry> distributions,
|
||||||
|
int page,
|
||||||
|
int totalPages,
|
||||||
|
long totalElements
|
||||||
|
) {
|
||||||
|
public record DistributionEntry(
|
||||||
|
Instant date,
|
||||||
|
String strainName,
|
||||||
|
BigDecimal grams,
|
||||||
|
String staffName
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile information for the portal.
|
||||||
|
*/
|
||||||
|
public record PortalProfile(
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String membershipNumber,
|
||||||
|
LocalDate membershipDate,
|
||||||
|
MemberStatus status,
|
||||||
|
String email
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed quota status for the current month.
|
||||||
|
*/
|
||||||
|
public record PortalQuota(
|
||||||
|
int year,
|
||||||
|
int month,
|
||||||
|
BigDecimal dailyUsed,
|
||||||
|
BigDecimal dailyLimit,
|
||||||
|
BigDecimal monthlyUsed,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
boolean isUnder21,
|
||||||
|
BigDecimal under21MonthlyLimit
|
||||||
|
) {}
|
||||||
+19
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package de.cannamanage.service.model.report;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member list report data model.
|
||||||
|
* Lists all members of a club with distribution statistics.
|
||||||
|
*/
|
||||||
|
public class MemberListReport {
|
||||||
|
|
||||||
|
private Instant generatedAt;
|
||||||
|
private List<MemberEntry> members;
|
||||||
|
|
||||||
|
public Instant getGeneratedAt() { return generatedAt; }
|
||||||
|
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
|
||||||
|
|
||||||
|
public List<MemberEntry> getMembers() { return members; }
|
||||||
|
public void setMembers(List<MemberEntry> members) { this.members = members; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single member entry within the report.
|
||||||
|
*/
|
||||||
|
public static class MemberEntry {
|
||||||
|
private UUID id;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String membershipNumber;
|
||||||
|
private MemberStatus status;
|
||||||
|
private LocalDate joinDate;
|
||||||
|
private int totalDistributions;
|
||||||
|
private Instant lastDistributionDate;
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
|
||||||
|
public String getMembershipNumber() { return membershipNumber; }
|
||||||
|
public void setMembershipNumber(String membershipNumber) { this.membershipNumber = membershipNumber; }
|
||||||
|
|
||||||
|
public MemberStatus getStatus() { return status; }
|
||||||
|
public void setStatus(MemberStatus status) { this.status = status; }
|
||||||
|
|
||||||
|
public LocalDate getJoinDate() { return joinDate; }
|
||||||
|
public void setJoinDate(LocalDate joinDate) { this.joinDate = joinDate; }
|
||||||
|
|
||||||
|
public int getTotalDistributions() { return totalDistributions; }
|
||||||
|
public void setTotalDistributions(int totalDistributions) { this.totalDistributions = totalDistributions; }
|
||||||
|
|
||||||
|
public Instant getLastDistributionDate() { return lastDistributionDate; }
|
||||||
|
public void setLastDistributionDate(Instant lastDistributionDate) { this.lastDistributionDate = lastDistributionDate; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package de.cannamanage.service.model.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly distribution report data model.
|
||||||
|
* Aggregates all distributions for a given month per tenant.
|
||||||
|
*/
|
||||||
|
public class MonthlyReport {
|
||||||
|
|
||||||
|
private YearMonth month;
|
||||||
|
private int totalDistributions;
|
||||||
|
private BigDecimal totalGrams;
|
||||||
|
private int uniqueMembers;
|
||||||
|
private BigDecimal averagePerMember;
|
||||||
|
private List<StrainSummary> topStrains;
|
||||||
|
private List<DailyEntry> dailyBreakdown;
|
||||||
|
|
||||||
|
public YearMonth getMonth() { return month; }
|
||||||
|
public void setMonth(YearMonth month) { this.month = month; }
|
||||||
|
|
||||||
|
public int getTotalDistributions() { return totalDistributions; }
|
||||||
|
public void setTotalDistributions(int totalDistributions) { this.totalDistributions = totalDistributions; }
|
||||||
|
|
||||||
|
public BigDecimal getTotalGrams() { return totalGrams; }
|
||||||
|
public void setTotalGrams(BigDecimal totalGrams) { this.totalGrams = totalGrams; }
|
||||||
|
|
||||||
|
public int getUniqueMembers() { return uniqueMembers; }
|
||||||
|
public void setUniqueMembers(int uniqueMembers) { this.uniqueMembers = uniqueMembers; }
|
||||||
|
|
||||||
|
public BigDecimal getAveragePerMember() { return averagePerMember; }
|
||||||
|
public void setAveragePerMember(BigDecimal averagePerMember) { this.averagePerMember = averagePerMember; }
|
||||||
|
|
||||||
|
public List<StrainSummary> getTopStrains() { return topStrains; }
|
||||||
|
public void setTopStrains(List<StrainSummary> topStrains) { this.topStrains = topStrains; }
|
||||||
|
|
||||||
|
public List<DailyEntry> getDailyBreakdown() { return dailyBreakdown; }
|
||||||
|
public void setDailyBreakdown(List<DailyEntry> dailyBreakdown) { this.dailyBreakdown = dailyBreakdown; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of a single strain's distribution totals within the report period.
|
||||||
|
*/
|
||||||
|
public static class StrainSummary {
|
||||||
|
private String name;
|
||||||
|
private BigDecimal totalGrams;
|
||||||
|
private int distributionCount;
|
||||||
|
|
||||||
|
public StrainSummary() {}
|
||||||
|
|
||||||
|
public StrainSummary(String name, BigDecimal totalGrams, int distributionCount) {
|
||||||
|
this.name = name;
|
||||||
|
this.totalGrams = totalGrams;
|
||||||
|
this.distributionCount = distributionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public BigDecimal getTotalGrams() { return totalGrams; }
|
||||||
|
public void setTotalGrams(BigDecimal totalGrams) { this.totalGrams = totalGrams; }
|
||||||
|
|
||||||
|
public int getDistributionCount() { return distributionCount; }
|
||||||
|
public void setDistributionCount(int distributionCount) { this.distributionCount = distributionCount; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single day's aggregate within the monthly breakdown.
|
||||||
|
*/
|
||||||
|
public static class DailyEntry {
|
||||||
|
private LocalDate date;
|
||||||
|
private BigDecimal grams;
|
||||||
|
private int distributions;
|
||||||
|
|
||||||
|
public DailyEntry() {}
|
||||||
|
|
||||||
|
public DailyEntry(LocalDate date, BigDecimal grams, int distributions) {
|
||||||
|
this.date = date;
|
||||||
|
this.grams = grams;
|
||||||
|
this.distributions = distributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getDate() { return date; }
|
||||||
|
public void setDate(LocalDate date) { this.date = date; }
|
||||||
|
|
||||||
|
public BigDecimal getGrams() { return grams; }
|
||||||
|
public void setGrams(BigDecimal grams) { this.grams = grams; }
|
||||||
|
|
||||||
|
public int getDistributions() { return distributions; }
|
||||||
|
public void setDistributions(int distributions) { this.distributions = distributions; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package de.cannamanage.service.model.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recall report data model.
|
||||||
|
* Traces all distributions from a specific batch back to affected members.
|
||||||
|
* Critical for CanG §26 compliance — enables rapid member notification on contamination.
|
||||||
|
*/
|
||||||
|
public class RecallReport {
|
||||||
|
|
||||||
|
private UUID batchId;
|
||||||
|
private String strainName;
|
||||||
|
private String batchNumber;
|
||||||
|
private LocalDate receivedDate;
|
||||||
|
private BigDecimal totalGramsDistributed;
|
||||||
|
private List<AffectedMember> affectedMembers;
|
||||||
|
|
||||||
|
public UUID getBatchId() { return batchId; }
|
||||||
|
public void setBatchId(UUID batchId) { this.batchId = batchId; }
|
||||||
|
|
||||||
|
public String getStrainName() { return strainName; }
|
||||||
|
public void setStrainName(String strainName) { this.strainName = strainName; }
|
||||||
|
|
||||||
|
public String getBatchNumber() { return batchNumber; }
|
||||||
|
public void setBatchNumber(String batchNumber) { this.batchNumber = batchNumber; }
|
||||||
|
|
||||||
|
public LocalDate getReceivedDate() { return receivedDate; }
|
||||||
|
public void setReceivedDate(LocalDate receivedDate) { this.receivedDate = receivedDate; }
|
||||||
|
|
||||||
|
public BigDecimal getTotalGramsDistributed() { return totalGramsDistributed; }
|
||||||
|
public void setTotalGramsDistributed(BigDecimal totalGramsDistributed) { this.totalGramsDistributed = totalGramsDistributed; }
|
||||||
|
|
||||||
|
public List<AffectedMember> getAffectedMembers() { return affectedMembers; }
|
||||||
|
public void setAffectedMembers(List<AffectedMember> affectedMembers) { this.affectedMembers = affectedMembers; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member who received cannabis from the recalled batch.
|
||||||
|
*/
|
||||||
|
public static class AffectedMember {
|
||||||
|
private UUID memberId;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private Instant distributionDate;
|
||||||
|
private BigDecimal grams;
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
|
||||||
|
public Instant getDistributionDate() { return distributionDate; }
|
||||||
|
public void setDistributionDate(Instant distributionDate) { this.distributionDate = distributionDate; }
|
||||||
|
|
||||||
|
public BigDecimal getGrams() { return grams; }
|
||||||
|
public void setGrams(BigDecimal grams) { this.grams = grams; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -1,6 +1,8 @@
|
|||||||
package de.cannamanage.service.repository;
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
import de.cannamanage.domain.entity.Distribution;
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -8,6 +10,7 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -29,4 +32,36 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
|||||||
@Param("tenantId") UUID tenantId,
|
@Param("tenantId") UUID tenantId,
|
||||||
@Param("after") Instant after
|
@Param("after") Instant after
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all distributions for a tenant within a time range.
|
||||||
|
*/
|
||||||
|
List<Distribution> findByTenantIdAndDistributedAtBetween(UUID tenantId, Instant start, Instant end);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all distributions for a specific batch (for recall reports).
|
||||||
|
*/
|
||||||
|
List<Distribution> findByTenantIdAndBatchId(UUID tenantId, UUID batchId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count distributions for a specific member within a tenant.
|
||||||
|
*/
|
||||||
|
long countByTenantIdAndMemberId(UUID tenantId, UUID memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most recent distribution for a member.
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " +
|
||||||
|
"ORDER BY d.distributedAt DESC LIMIT 1")
|
||||||
|
Distribution findLatestByTenantIdAndMemberId(@Param("tenantId") UUID tenantId, @Param("memberId") UUID memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 5 most recent distributions for a specific member (portal dashboard).
|
||||||
|
*/
|
||||||
|
List<Distribution> findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated distribution history for a member, newest first (portal history).
|
||||||
|
*/
|
||||||
|
Page<Distribution> findByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -5,6 +5,7 @@ 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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -13,4 +14,19 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
|
|||||||
long countByTenantId(UUID tenantId);
|
long countByTenantId(UUID tenantId);
|
||||||
|
|
||||||
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all members for a tenant, optionally filtered by status.
|
||||||
|
*/
|
||||||
|
List<Member> findByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,118 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PdfReportGeneratorTest {
|
||||||
|
|
||||||
|
private PdfReportGenerator generator;
|
||||||
|
private Club club;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
generator = new PdfReportGenerator();
|
||||||
|
club = new Club();
|
||||||
|
club.setName("Grüne Freunde e.V.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRenderMonthlyReport_validPdf() {
|
||||||
|
MonthlyReport report = new MonthlyReport();
|
||||||
|
report.setMonth(YearMonth.of(2026, 3));
|
||||||
|
report.setTotalDistributions(42);
|
||||||
|
report.setTotalGrams(new BigDecimal("210.50"));
|
||||||
|
report.setUniqueMembers(15);
|
||||||
|
report.setAveragePerMember(new BigDecimal("14.03"));
|
||||||
|
report.setTopStrains(List.of(
|
||||||
|
new MonthlyReport.StrainSummary("White Widow", new BigDecimal("80.00"), 18),
|
||||||
|
new MonthlyReport.StrainSummary("Amnesia Haze", new BigDecimal("60.00"), 12)
|
||||||
|
));
|
||||||
|
report.setDailyBreakdown(List.of(
|
||||||
|
new MonthlyReport.DailyEntry(LocalDate.of(2026, 3, 1), new BigDecimal("15.00"), 3),
|
||||||
|
new MonthlyReport.DailyEntry(LocalDate.of(2026, 3, 2), new BigDecimal("8.50"), 2)
|
||||||
|
));
|
||||||
|
|
||||||
|
byte[] pdf = generator.renderMonthlyReport(report, club);
|
||||||
|
|
||||||
|
assertThat(pdf).isNotEmpty();
|
||||||
|
assertThat(pdf).startsWith("%PDF".getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRenderMemberList_validPdf() {
|
||||||
|
MemberListReport report = new MemberListReport();
|
||||||
|
report.setGeneratedAt(Instant.now());
|
||||||
|
|
||||||
|
MemberListReport.MemberEntry entry = new MemberListReport.MemberEntry();
|
||||||
|
entry.setId(UUID.randomUUID());
|
||||||
|
entry.setFirstName("Max");
|
||||||
|
entry.setLastName("Mustermann");
|
||||||
|
entry.setMembershipNumber("M-001");
|
||||||
|
entry.setStatus(MemberStatus.ACTIVE);
|
||||||
|
entry.setJoinDate(LocalDate.of(2025, 6, 1));
|
||||||
|
entry.setTotalDistributions(12);
|
||||||
|
entry.setLastDistributionDate(Instant.parse("2026-03-15T10:00:00Z"));
|
||||||
|
|
||||||
|
report.setMembers(List.of(entry));
|
||||||
|
|
||||||
|
byte[] pdf = generator.renderMemberList(report, club);
|
||||||
|
|
||||||
|
assertThat(pdf).isNotEmpty();
|
||||||
|
assertThat(pdf).startsWith("%PDF".getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRenderRecallReport_validPdf() {
|
||||||
|
RecallReport report = new RecallReport();
|
||||||
|
report.setBatchId(UUID.randomUUID());
|
||||||
|
report.setStrainName("Northern Lights");
|
||||||
|
report.setBatchNumber("BATCH-2026-007");
|
||||||
|
report.setReceivedDate(LocalDate.of(2026, 2, 20));
|
||||||
|
report.setTotalGramsDistributed(new BigDecimal("45.00"));
|
||||||
|
|
||||||
|
RecallReport.AffectedMember am = new RecallReport.AffectedMember();
|
||||||
|
am.setMemberId(UUID.randomUUID());
|
||||||
|
am.setFirstName("Anna");
|
||||||
|
am.setLastName("Schmidt");
|
||||||
|
am.setDistributionDate(Instant.parse("2026-03-05T12:00:00Z"));
|
||||||
|
am.setGrams(new BigDecimal("5.00"));
|
||||||
|
|
||||||
|
report.setAffectedMembers(List.of(am));
|
||||||
|
|
||||||
|
byte[] pdf = generator.renderRecallReport(report, club);
|
||||||
|
|
||||||
|
assertThat(pdf).isNotEmpty();
|
||||||
|
assertThat(pdf).startsWith("%PDF".getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRenderMonthlyReport_emptyReport() {
|
||||||
|
MonthlyReport report = new MonthlyReport();
|
||||||
|
report.setMonth(YearMonth.of(2026, 1));
|
||||||
|
report.setTotalDistributions(0);
|
||||||
|
report.setTotalGrams(BigDecimal.ZERO);
|
||||||
|
report.setUniqueMembers(0);
|
||||||
|
report.setAveragePerMember(BigDecimal.ZERO);
|
||||||
|
report.setTopStrains(List.of());
|
||||||
|
report.setDailyBreakdown(List.of());
|
||||||
|
|
||||||
|
byte[] pdf = generator.renderMonthlyReport(report, club);
|
||||||
|
|
||||||
|
assertThat(pdf).isNotEmpty();
|
||||||
|
assertThat(pdf).startsWith("%PDF".getBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.dto.QuotaStatus;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
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.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
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.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PortalServiceTest {
|
||||||
|
|
||||||
|
@Mock private MemberRepository memberRepository;
|
||||||
|
@Mock private DistributionRepository distributionRepository;
|
||||||
|
@Mock private ComplianceService complianceService;
|
||||||
|
@Mock private BatchRepository batchRepository;
|
||||||
|
@Mock private StrainRepository strainRepository;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks private PortalService portalService;
|
||||||
|
|
||||||
|
private final UUID tenantId = UUID.randomUUID();
|
||||||
|
private final UUID memberId = UUID.randomUUID();
|
||||||
|
private Member testMember;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
testMember = new Member();
|
||||||
|
testMember.setFirstName("Max");
|
||||||
|
testMember.setLastName("Mustermann");
|
||||||
|
testMember.setMembershipNumber("CM-001");
|
||||||
|
testMember.setMembershipDate(LocalDate.of(2025, 1, 15));
|
||||||
|
testMember.setStatus(MemberStatus.ACTIVE);
|
||||||
|
testMember.setEmail("max@example.com");
|
||||||
|
testMember.setUnder21(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDashboard_returnsCorrectQuotaData() {
|
||||||
|
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
|
||||||
|
when(complianceService.getQuotaStatus(memberId)).thenReturn(
|
||||||
|
new QuotaStatus(new BigDecimal("50.0"), new BigDecimal("12.5"),
|
||||||
|
new BigDecimal("37.5"), false, 2026, 6));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(eq(memberId), any(), any()))
|
||||||
|
.thenReturn(new BigDecimal("5.0"));
|
||||||
|
when(distributionRepository.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(dashboard.memberName()).isEqualTo("Max Mustermann");
|
||||||
|
assertThat(dashboard.membershipNumber()).isEqualTo("CM-001");
|
||||||
|
assertThat(dashboard.monthlyQuotaUsed()).isEqualByComparingTo("12.5");
|
||||||
|
assertThat(dashboard.monthlyQuotaRemaining()).isEqualByComparingTo("37.5");
|
||||||
|
assertThat(dashboard.dailyQuotaUsed()).isEqualByComparingTo("5.0");
|
||||||
|
assertThat(dashboard.dailyQuotaRemaining()).isEqualByComparingTo("20.0");
|
||||||
|
assertThat(dashboard.recentDistributions()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProfile_returnsOwnMemberData() {
|
||||||
|
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
|
||||||
|
|
||||||
|
PortalProfile profile = portalService.getProfile(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(profile.firstName()).isEqualTo("Max");
|
||||||
|
assertThat(profile.lastName()).isEqualTo("Mustermann");
|
||||||
|
assertThat(profile.membershipNumber()).isEqualTo("CM-001");
|
||||||
|
assertThat(profile.membershipDate()).isEqualTo(LocalDate.of(2025, 1, 15));
|
||||||
|
assertThat(profile.status()).isEqualTo(MemberStatus.ACTIVE);
|
||||||
|
assertThat(profile.email()).isEqualTo("max@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getQuota_returnsDetailedQuotaStatus() {
|
||||||
|
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
|
||||||
|
when(complianceService.getQuotaStatus(memberId)).thenReturn(
|
||||||
|
new QuotaStatus(new BigDecimal("50.0"), new BigDecimal("20.0"),
|
||||||
|
new BigDecimal("30.0"), false, 2026, 6));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(eq(memberId), any(), any()))
|
||||||
|
.thenReturn(new BigDecimal("10.0"));
|
||||||
|
|
||||||
|
PortalQuota quota = portalService.getQuota(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(quota.year()).isEqualTo(2026);
|
||||||
|
assertThat(quota.month()).isEqualTo(6);
|
||||||
|
assertThat(quota.dailyUsed()).isEqualByComparingTo("10.0");
|
||||||
|
assertThat(quota.dailyLimit()).isEqualByComparingTo(ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS);
|
||||||
|
assertThat(quota.monthlyUsed()).isEqualByComparingTo("20.0");
|
||||||
|
assertThat(quota.monthlyLimit()).isEqualByComparingTo("50.0");
|
||||||
|
assertThat(quota.isUnder21()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDistributionHistory_returnsPaginatedResults() {
|
||||||
|
UUID batchId = UUID.randomUUID();
|
||||||
|
UUID staffId = UUID.randomUUID();
|
||||||
|
UUID strainId = UUID.randomUUID();
|
||||||
|
|
||||||
|
Distribution dist = new Distribution();
|
||||||
|
dist.setDistributedAt(Instant.now());
|
||||||
|
dist.setBatchId(batchId);
|
||||||
|
dist.setQuantityGrams(new BigDecimal("3.5"));
|
||||||
|
dist.setRecordedBy(staffId);
|
||||||
|
|
||||||
|
Batch batch = new Batch();
|
||||||
|
batch.setStrainId(strainId);
|
||||||
|
Strain strain = new Strain();
|
||||||
|
strain.setName("Blue Dream");
|
||||||
|
User staff = new User();
|
||||||
|
staff.setEmail("staff@club.de");
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(0, 20);
|
||||||
|
when(distributionRepository.findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(dist), pageable, 1));
|
||||||
|
when(batchRepository.findById(batchId)).thenReturn(Optional.of(batch));
|
||||||
|
when(strainRepository.findById(strainId)).thenReturn(Optional.of(strain));
|
||||||
|
when(userRepository.findById(staffId)).thenReturn(Optional.of(staff));
|
||||||
|
|
||||||
|
PortalDistributionHistory history = portalService.getDistributionHistory(tenantId, memberId, pageable);
|
||||||
|
|
||||||
|
assertThat(history.totalElements()).isEqualTo(1);
|
||||||
|
assertThat(history.page()).isEqualTo(0);
|
||||||
|
assertThat(history.distributions()).hasSize(1);
|
||||||
|
assertThat(history.distributions().getFirst().strainName()).isEqualTo("Blue Dream");
|
||||||
|
assertThat(history.distributions().getFirst().grams()).isEqualByComparingTo("3.5");
|
||||||
|
assertThat(history.distributions().getFirst().staffName()).isEqualTo("staff@club.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDashboard_memberOnlySeesOwnData() {
|
||||||
|
// A different memberId — service uses the passed memberId, not some other lookup
|
||||||
|
UUID otherMemberId = UUID.randomUUID();
|
||||||
|
Member otherMember = new Member();
|
||||||
|
otherMember.setFirstName("Anna");
|
||||||
|
otherMember.setLastName("Schmidt");
|
||||||
|
otherMember.setMembershipNumber("CM-002");
|
||||||
|
otherMember.setMembershipDate(LocalDate.of(2025, 3, 1));
|
||||||
|
otherMember.setStatus(MemberStatus.ACTIVE);
|
||||||
|
otherMember.setEmail("anna@example.com");
|
||||||
|
otherMember.setUnder21(true);
|
||||||
|
|
||||||
|
when(memberRepository.findById(otherMemberId)).thenReturn(Optional.of(otherMember));
|
||||||
|
when(complianceService.getQuotaStatus(otherMemberId)).thenReturn(
|
||||||
|
new QuotaStatus(new BigDecimal("30.0"), new BigDecimal("5.0"),
|
||||||
|
new BigDecimal("25.0"), true, 2026, 6));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(eq(otherMemberId), any(), any()))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
when(distributionRepository.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(otherMemberId, tenantId))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(tenantId, otherMemberId);
|
||||||
|
|
||||||
|
// Verifies it returns ANNA's data, not MAX's — proving member-scoped isolation
|
||||||
|
assertThat(dashboard.memberName()).isEqualTo("Anna Schmidt");
|
||||||
|
assertThat(dashboard.membershipNumber()).isEqualTo("CM-002");
|
||||||
|
}
|
||||||
|
}
|
||||||
+233
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
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 java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
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.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ReportServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private DistributionRepository distributionRepository;
|
||||||
|
@Mock
|
||||||
|
private MemberRepository memberRepository;
|
||||||
|
@Mock
|
||||||
|
private BatchRepository batchRepository;
|
||||||
|
@Mock
|
||||||
|
private StrainRepository strainRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ReportService reportService;
|
||||||
|
|
||||||
|
private static final UUID TENANT_ID = UUID.randomUUID();
|
||||||
|
private static final UUID MEMBER_1 = UUID.randomUUID();
|
||||||
|
private static final UUID MEMBER_2 = UUID.randomUUID();
|
||||||
|
private static final UUID BATCH_ID = UUID.randomUUID();
|
||||||
|
private static final UUID STRAIN_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGenerateMonthlyReport_withDistributions() {
|
||||||
|
YearMonth month = YearMonth.of(2026, 3);
|
||||||
|
|
||||||
|
Distribution d1 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("3.50"),
|
||||||
|
Instant.parse("2026-03-10T14:00:00Z"));
|
||||||
|
Distribution d2 = createDistribution(MEMBER_2, BATCH_ID, new BigDecimal("5.00"),
|
||||||
|
Instant.parse("2026-03-15T10:00:00Z"));
|
||||||
|
Distribution d3 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("2.50"),
|
||||||
|
Instant.parse("2026-03-15T16:00:00Z"));
|
||||||
|
|
||||||
|
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(TENANT_ID), any(), any()))
|
||||||
|
.thenReturn(List.of(d1, d2, d3));
|
||||||
|
|
||||||
|
Batch batch = createBatch(BATCH_ID, STRAIN_ID, "BATCH-001");
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batch));
|
||||||
|
|
||||||
|
Strain strain = new Strain();
|
||||||
|
strain.setId(STRAIN_ID);
|
||||||
|
strain.setName("White Widow");
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(strain));
|
||||||
|
|
||||||
|
MonthlyReport report = reportService.generateMonthlyReport(TENANT_ID, month);
|
||||||
|
|
||||||
|
assertThat(report.getMonth()).isEqualTo(month);
|
||||||
|
assertThat(report.getTotalDistributions()).isEqualTo(3);
|
||||||
|
assertThat(report.getTotalGrams()).isEqualByComparingTo("11.00");
|
||||||
|
assertThat(report.getUniqueMembers()).isEqualTo(2);
|
||||||
|
assertThat(report.getAveragePerMember()).isEqualByComparingTo("5.50");
|
||||||
|
assertThat(report.getTopStrains()).hasSize(1);
|
||||||
|
assertThat(report.getTopStrains().get(0).getName()).isEqualTo("White Widow");
|
||||||
|
assertThat(report.getDailyBreakdown()).hasSize(31); // March has 31 days
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGenerateMonthlyReport_emptyMonth() {
|
||||||
|
YearMonth month = YearMonth.of(2026, 1);
|
||||||
|
|
||||||
|
when(distributionRepository.findByTenantIdAndDistributedAtBetween(eq(TENANT_ID), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
MonthlyReport report = reportService.generateMonthlyReport(TENANT_ID, month);
|
||||||
|
|
||||||
|
assertThat(report.getTotalDistributions()).isZero();
|
||||||
|
assertThat(report.getTotalGrams()).isEqualByComparingTo("0");
|
||||||
|
assertThat(report.getUniqueMembers()).isZero();
|
||||||
|
assertThat(report.getAveragePerMember()).isEqualByComparingTo("0");
|
||||||
|
assertThat(report.getTopStrains()).isEmpty();
|
||||||
|
assertThat(report.getDailyBreakdown()).hasSize(31);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGenerateMemberListReport_allMembers() {
|
||||||
|
Member m1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
|
||||||
|
Member m2 = createMember(MEMBER_2, "Anna", "Muster", "M-002", MemberStatus.SUSPENDED);
|
||||||
|
|
||||||
|
when(memberRepository.findByTenantId(TENANT_ID)).thenReturn(List.of(m1, m2));
|
||||||
|
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(5L);
|
||||||
|
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_2)).thenReturn(0L);
|
||||||
|
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(null);
|
||||||
|
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_2)).thenReturn(null);
|
||||||
|
|
||||||
|
MemberListReport report = reportService.generateMemberListReport(TENANT_ID, null);
|
||||||
|
|
||||||
|
assertThat(report.getGeneratedAt()).isNotNull();
|
||||||
|
assertThat(report.getMembers()).hasSize(2);
|
||||||
|
assertThat(report.getMembers().get(0).getFirstName()).isEqualTo("Max");
|
||||||
|
assertThat(report.getMembers().get(0).getTotalDistributions()).isEqualTo(5);
|
||||||
|
assertThat(report.getMembers().get(1).getStatus()).isEqualTo(MemberStatus.SUSPENDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGenerateMemberListReport_filteredByStatus() {
|
||||||
|
Member m1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
|
||||||
|
|
||||||
|
when(memberRepository.findByTenantIdAndStatus(TENANT_ID, MemberStatus.ACTIVE))
|
||||||
|
.thenReturn(List.of(m1));
|
||||||
|
when(distributionRepository.countByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(3L);
|
||||||
|
when(distributionRepository.findLatestByTenantIdAndMemberId(TENANT_ID, MEMBER_1)).thenReturn(null);
|
||||||
|
|
||||||
|
MemberListReport report = reportService.generateMemberListReport(TENANT_ID, MemberStatus.ACTIVE);
|
||||||
|
|
||||||
|
assertThat(report.getMembers()).hasSize(1);
|
||||||
|
assertThat(report.getMembers().get(0).getStatus()).isEqualTo(MemberStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGenerateRecallReport_success() {
|
||||||
|
Batch batch = createBatch(BATCH_ID, STRAIN_ID, "BATCH-RECALL-01");
|
||||||
|
batch.setHarvestDate(LocalDate.of(2026, 2, 15));
|
||||||
|
|
||||||
|
Strain strain = new Strain();
|
||||||
|
strain.setId(STRAIN_ID);
|
||||||
|
strain.setName("Amnesia Haze");
|
||||||
|
|
||||||
|
Distribution d1 = createDistribution(MEMBER_1, BATCH_ID, new BigDecimal("5.00"),
|
||||||
|
Instant.parse("2026-03-01T10:00:00Z"));
|
||||||
|
Distribution d2 = createDistribution(MEMBER_2, BATCH_ID, new BigDecimal("3.00"),
|
||||||
|
Instant.parse("2026-03-02T14:00:00Z"));
|
||||||
|
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(strain));
|
||||||
|
when(distributionRepository.findByTenantIdAndBatchId(TENANT_ID, BATCH_ID))
|
||||||
|
.thenReturn(List.of(d1, d2));
|
||||||
|
|
||||||
|
Member member1 = createMember(MEMBER_1, "Max", "Mustermann", "M-001", MemberStatus.ACTIVE);
|
||||||
|
Member member2 = createMember(MEMBER_2, "Anna", "Muster", "M-002", MemberStatus.ACTIVE);
|
||||||
|
when(memberRepository.findById(MEMBER_1)).thenReturn(Optional.of(member1));
|
||||||
|
when(memberRepository.findById(MEMBER_2)).thenReturn(Optional.of(member2));
|
||||||
|
|
||||||
|
RecallReport report = reportService.generateRecallReport(TENANT_ID, BATCH_ID);
|
||||||
|
|
||||||
|
assertThat(report.getBatchId()).isEqualTo(BATCH_ID);
|
||||||
|
assertThat(report.getStrainName()).isEqualTo("Amnesia Haze");
|
||||||
|
assertThat(report.getBatchNumber()).isEqualTo("BATCH-RECALL-01");
|
||||||
|
assertThat(report.getReceivedDate()).isEqualTo(LocalDate.of(2026, 2, 15));
|
||||||
|
assertThat(report.getTotalGramsDistributed()).isEqualByComparingTo("8.00");
|
||||||
|
assertThat(report.getAffectedMembers()).hasSize(2);
|
||||||
|
assertThat(report.getAffectedMembers().get(0).getFirstName()).isEqualTo("Max");
|
||||||
|
assertThat(report.getAffectedMembers().get(1).getGrams()).isEqualByComparingTo("3.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGenerateRecallReport_batchNotFound() {
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reportService.generateRecallReport(TENANT_ID, BATCH_ID))
|
||||||
|
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper methods ---
|
||||||
|
|
||||||
|
private Distribution createDistribution(UUID memberId, UUID batchId, BigDecimal grams, Instant at) {
|
||||||
|
Distribution d = new Distribution();
|
||||||
|
d.setId(UUID.randomUUID());
|
||||||
|
d.setTenantId(TENANT_ID);
|
||||||
|
d.setMemberId(memberId);
|
||||||
|
d.setBatchId(batchId);
|
||||||
|
d.setQuantityGrams(grams);
|
||||||
|
d.setDistributedAt(at);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member createMember(UUID id, String first, String last, String number, MemberStatus status) {
|
||||||
|
Member m = new Member();
|
||||||
|
m.setId(id);
|
||||||
|
m.setTenantId(TENANT_ID);
|
||||||
|
m.setFirstName(first);
|
||||||
|
m.setLastName(last);
|
||||||
|
m.setMembershipNumber(number);
|
||||||
|
m.setStatus(status);
|
||||||
|
m.setMembershipDate(LocalDate.of(2025, 6, 1));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Batch createBatch(UUID id, UUID strainId, String code) {
|
||||||
|
Batch b = new Batch();
|
||||||
|
b.setId(id);
|
||||||
|
b.setTenantId(TENANT_ID);
|
||||||
|
b.setStrainId(strainId);
|
||||||
|
b.setBatchCode(code);
|
||||||
|
b.setQuantityGrams(new BigDecimal("50.00"));
|
||||||
|
b.setStatus(BatchStatus.AVAILABLE);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# CannaManage — Strategic Differentiation Plan
|
||||||
|
|
||||||
|
**Date:** 2026-06-12
|
||||||
|
**Author:** Patrick Plate / Lumen
|
||||||
|
**Status:** Living Document
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Market Position
|
||||||
|
|
||||||
|
### 1.1 Competitive Landscape Summary
|
||||||
|
|
||||||
|
| Competitor | Clubs | Pricing | Key Strength | Key Weakness | Threat Level |
|
||||||
|
|-----------|-------|---------|-------------|-------------|-------------|
|
||||||
|
| **420cloud** | 389+ | Undisclosed (free member app + B2B) | Network effects via free member app, marketplace model | Core features still "Coming Soon" (reports, inventory, IoT) | 🔴 High — first-mover with club count |
|
||||||
|
| **Hanf-App** | Unknown | ~30€/month | Feature-complete: §26 reports, Steuerlogik, SEPA, 2FA | Closed system, no public API, no self-hosting | 🟡 Medium — feature leader but locked ecosystem |
|
||||||
|
| **Cannanas** | Unknown | ~25€/month | Intuitive UX, lower price point | No 2FA, no data export, partial feature set | 🟢 Low — incomplete and weak on security |
|
||||||
|
| **Cannavigia** | Enterprise | Enterprise pricing | GACP/EU-GMP compliance, international (CH/DE/TH) | Overkill for CSCs, targets commercial cultivators | ⚪ None — different market segment |
|
||||||
|
|
||||||
|
**Market dynamics:**
|
||||||
|
- 420cloud is winning on **distribution** (389+ clubs on their map) but not on **features** (many are "Coming Soon")
|
||||||
|
- Hanf-App is winning on **features** but losing on **openness** (walled garden)
|
||||||
|
- The comparison site csc-verwaltung.de exists — getting listed there is table stakes for credibility
|
||||||
|
- Spain (oldest CSC market since 2001) has NO specialized software — pure paper/Excel. Future expansion market.
|
||||||
|
|
||||||
|
### 1.2 Where We Stand Today
|
||||||
|
|
||||||
|
**What we have (Sprint 1-3 delivered):**
|
||||||
|
- ✅ Multi-tenant architecture (tenant_id isolation) — production-grade from day one
|
||||||
|
- ✅ JWT auth with token rotation, revocation, jti blacklist — more secure than Cannanas
|
||||||
|
- ✅ RBAC with 8 granular StaffPermissions — more fine-grained than any competitor
|
||||||
|
- ✅ Staff invite flow with email + set-password
|
||||||
|
- ✅ CanG quota enforcement (25g/day, 50g/month, 30g under-21)
|
||||||
|
- ✅ Stock/batch tracking with full movement history
|
||||||
|
- ✅ Distribution recording with compliance pre-check
|
||||||
|
- ✅ Club settings (prevention officers, email domain whitelist)
|
||||||
|
- ✅ OpenAPI/Swagger documented REST API — no competitor exposes this
|
||||||
|
- ✅ 42+ unit tests with solid coverage
|
||||||
|
|
||||||
|
**What we're missing (honest gaps):**
|
||||||
|
- ❌ No §26 evaluation/report generation (Hanf-App has this)
|
||||||
|
- ❌ No SEPA integration (Hanf-App has this)
|
||||||
|
- ❌ No 2FA/TOTP (Hanf-App has this)
|
||||||
|
- ❌ No frontend (API-only — competitors all have web + mobile)
|
||||||
|
- ❌ No Transportbescheinigung
|
||||||
|
- ❌ No member-facing portal or app
|
||||||
|
- ❌ No self-hosted deployment option yet (Docker Compose planned)
|
||||||
|
- ❌ No public club map or marketplace
|
||||||
|
|
||||||
|
**Assessment:** We have a stronger technical foundation than all competitors (architecture, security, API design) but are behind on user-facing features and market presence. The gap is closable in 2-3 sprints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Differentiators (Moats)
|
||||||
|
|
||||||
|
### 2.1 API-First Architecture (vs. walled gardens)
|
||||||
|
|
||||||
|
**Why this matters:** Every CSC will eventually need integrations — Buchhaltungssoftware (DATEV, lexoffice), SEPA providers (GoCardless, Stripe SEPA), Behörden-APIs for reporting, label printers, scales.
|
||||||
|
|
||||||
|
**Competitive reality:**
|
||||||
|
- 420cloud: No public API. Clubs are locked into their ecosystem.
|
||||||
|
- Hanf-App: No public API. "Integrations" means they built it or it doesn't exist.
|
||||||
|
- CannaManage: Full OpenAPI 3.0 spec, documented endpoints, JWT bearer auth.
|
||||||
|
|
||||||
|
**Strategic value:**
|
||||||
|
1. Third-party developers can build integrations (Buchhaltung connectors, POS systems)
|
||||||
|
2. White-label partners can reskin the frontend with their own brand
|
||||||
|
3. Dachverbände can build dashboards on top of our API
|
||||||
|
4. Developer ecosystem creates switching costs — once integrations exist, clubs can't leave
|
||||||
|
|
||||||
|
**Moat depth:** Medium-high. APIs are easy to build but hard to build an ecosystem around. First-mover advantage matters here.
|
||||||
|
|
||||||
|
### 2.2 Self-Hostable + SaaS Dual-Mode (vs. cloud-only)
|
||||||
|
|
||||||
|
**Why this matters:** German CSCs handle member PII + consumption data. Many clubs are run by privacy activists who don't trust cloud providers with member cannabis consumption records.
|
||||||
|
|
||||||
|
**What we offer:**
|
||||||
|
- **Self-hosted:** Docker Compose for clubs that want data on their own hardware
|
||||||
|
- **Managed SaaS:** Hosted instance for clubs that want zero ops overhead
|
||||||
|
- **Same codebase:** No feature gap between modes
|
||||||
|
|
||||||
|
**Competitive reality:**
|
||||||
|
- 420cloud: Cloud-only. Your member data lives on their servers in Berlin.
|
||||||
|
- Hanf-App: Cloud-only. No self-hosting option.
|
||||||
|
- Cannanas: Cloud-only.
|
||||||
|
- **Nobody in the DE CSC market offers self-hosting.**
|
||||||
|
|
||||||
|
**Strategic value:**
|
||||||
|
1. Captures the privacy-conscious segment that will NEVER use cloud-only
|
||||||
|
2. Data sovereignty argument resonates strongly in German market (DSGVO awareness is high)
|
||||||
|
3. Self-hosted clubs become evangelists in the community ("we control our own data")
|
||||||
|
4. Reduces our infrastructure costs for price-sensitive clubs
|
||||||
|
|
||||||
|
**Moat depth:** High. Competitors would need to re-architect for self-hosting. Their cloud-native assumptions (shared infra, centralized auth) make this very hard to bolt on.
|
||||||
|
|
||||||
|
### 2.3 Multi-Club Federation (vs. single-tenant silos)
|
||||||
|
|
||||||
|
**Why this matters:** Germany has 10+ Dachverbände (umbrella organizations) representing dozens of clubs each. A single contract with a Dachverband = 50+ clubs onboarded simultaneously.
|
||||||
|
|
||||||
|
**What we offer:**
|
||||||
|
- Shared admin dashboard for Dachverband management
|
||||||
|
- Per-club data isolation (our tenant_id architecture already supports this)
|
||||||
|
- Consolidated billing, reporting, compliance overview across all clubs
|
||||||
|
- Role hierarchy: Dachverband Admin → Club Admin → Staff → Member
|
||||||
|
|
||||||
|
**Competitive reality:**
|
||||||
|
- 420cloud: Single-club focus. No federation concept. Each club is independent.
|
||||||
|
- Hanf-App: Single-club accounts. No umbrella org support.
|
||||||
|
- This is a **completely unserved market segment.**
|
||||||
|
|
||||||
|
**Strategic value:**
|
||||||
|
1. Enterprise sales motion: one deal = 50 clubs (vs. selling one-by-one)
|
||||||
|
2. Dachverband lock-in: once the umbrella org standardizes on us, individual clubs can't easily leave
|
||||||
|
3. Consolidated compliance reporting makes the Dachverband look good to regulators
|
||||||
|
4. Higher ARPU per deal, lower CAC
|
||||||
|
|
||||||
|
**Moat depth:** Very high. Multi-tenant federation is architecturally complex. Our `tenant_id` design was built for this from Sprint 1.
|
||||||
|
|
||||||
|
### 2.4 Immutable Audit Trail + PDF Compliance Reports
|
||||||
|
|
||||||
|
**Why this matters:** CanG §26 requires clubs to be inspectable by authorities at any time. Clubs need tamper-evident records proving they followed the law.
|
||||||
|
|
||||||
|
**What we offer:**
|
||||||
|
- Append-only event log for all compliance-relevant actions (distributions, stock changes, member status)
|
||||||
|
- Cryptographic hash chain (each event references the previous hash) — tamper-evident
|
||||||
|
- One-click PDF export for authority inspections
|
||||||
|
- Pre-formatted §26 reports matching regulatory expectations
|
||||||
|
|
||||||
|
**Competitive reality:**
|
||||||
|
- 420cloud: Reports & Analysen listed as "Coming Soon" — not shipped yet
|
||||||
|
- Hanf-App: Has §26 reports (their strongest feature) but no cryptographic audit trail
|
||||||
|
- **We can be FIRST with cryptographic tamper-evidence** — this is a leapfrog opportunity
|
||||||
|
|
||||||
|
**Strategic value:**
|
||||||
|
1. Legal safety argument: "Our records are mathematically provable" vs. "trust our database"
|
||||||
|
2. Authority inspections become trivial: click → PDF → hand over
|
||||||
|
3. Insurance companies may require tamper-evident records in the future
|
||||||
|
4. Creates a "compliance moat" — switching away means losing your audit history
|
||||||
|
|
||||||
|
**Moat depth:** Medium. The PDF reports are easy to copy. The cryptographic hash chain is harder. The brand perception ("the compliance-first platform") is the real moat.
|
||||||
|
|
||||||
|
### 2.5 Fine-Grained RBAC (vs. simple Admin/Member split)
|
||||||
|
|
||||||
|
**What we have:** 8 granular permissions, configurable per staff member:
|
||||||
|
- `MANAGE_MEMBERS`, `VIEW_MEMBERS`, `MANAGE_STOCK`, `DISTRIBUTE`
|
||||||
|
- `VIEW_REPORTS`, `MANAGE_SETTINGS`, `MANAGE_STAFF`, `FULL_ACCESS`
|
||||||
|
|
||||||
|
**Why this matters:** Real CSCs have 5-10 staff with different roles — Ausgabe (distribution), Lager (stock), Vorstand (board), Kassierer (treasurer). You don't want the person doing Ausgabe to have access to financial reports.
|
||||||
|
|
||||||
|
**Competitive reality:**
|
||||||
|
- 420cloud: Basic role system (details unclear)
|
||||||
|
- Hanf-App: Admin/Staff/Member — no granular permissions documented
|
||||||
|
- Cannanas: Simple Admin/Member split
|
||||||
|
- **We have the most fine-grained permission model in the market**
|
||||||
|
|
||||||
|
**Moat depth:** Low-medium. This is copyable, but it's table stakes for enterprise/federation sales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Feature Gap Analysis (Critical)
|
||||||
|
|
||||||
|
### 3.1 Must-Close Gaps (to match Hanf-App)
|
||||||
|
|
||||||
|
These are non-negotiable for market credibility. Without them, clubs will choose Hanf-App.
|
||||||
|
|
||||||
|
| Gap | Competitor Benchmark | Priority | Sprint Target |
|
||||||
|
|-----|---------------------|----------|---------------|
|
||||||
|
| §26 Evaluation + Bestand Reports | Hanf-App ships these | P0 | Sprint 4 |
|
||||||
|
| SEPA Integration (Beitragszahlung) | Hanf-App has full Steuerlogik | P0 | Sprint 5 |
|
||||||
|
| Transportbescheinigung PDF | Hanf-App generates these | P1 | Sprint 5 |
|
||||||
|
| 2FA (TOTP) | Hanf-App has 2FA, Cannanas doesn't | P1 | Sprint 5 |
|
||||||
|
| Frontend (any web UI at all) | All competitors have web + mobile | P0 | Sprint 4-7 |
|
||||||
|
| Member self-service portal | 420cloud has free member app | P1 | Sprint 4 |
|
||||||
|
|
||||||
|
### 3.2 Leapfrog Opportunities (where we can be FIRST)
|
||||||
|
|
||||||
|
These features don't exist in ANY competitor. Shipping them creates differentiation.
|
||||||
|
|
||||||
|
| Opportunity | Why No One Has It | Our Advantage | Effort |
|
||||||
|
|------------|-------------------|---------------|--------|
|
||||||
|
| Public REST API + OpenAPI spec | Competitors are closed platforms | Already built — just document + publish | Low |
|
||||||
|
| Self-hosted Docker deployment | Cloud-only business models | Our architecture supports it | Medium |
|
||||||
|
| Multi-club federation dashboard | Single-tenant architectures | tenant_id design ready | Medium-High |
|
||||||
|
| Immutable audit log (hash chain) | No regulatory pressure yet | ComplianceService foundation exists | Medium |
|
||||||
|
| QR code member ID (offline JWT) | Physical cards are the norm | JwtService already generates tokens | Low |
|
||||||
|
| Migration tool (import from Hanf-App/Cannanas) | They don't want you to leave | We want you to come | Medium |
|
||||||
|
| Offline-capable PWA | Everyone assumes internet | Service Worker + IndexedDB | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Go-to-Market Strategy
|
||||||
|
|
||||||
|
### 4.1 Target Segments (prioritized)
|
||||||
|
|
||||||
|
1. **Privacy-conscious clubs** — Data sovereignty is their #1 requirement. Self-hosting argument wins immediately. These clubs are vocal in forums and will evangelize. *Estimated segment: 15-20% of clubs.*
|
||||||
|
|
||||||
|
2. **Tech-savvy clubs wanting API integrations** — They're building their own tools, frustrated by closed ecosystems. Our API-first approach is exactly what they want. *Estimated segment: 10% of clubs.*
|
||||||
|
|
||||||
|
3. **Dachverbände / umbrella organizations** — Enterprise deals. One contract = 30-80 clubs. Federation feature is our unique selling point. *Estimated orgs: 10-15 nationwide, each with 20-80 member clubs.*
|
||||||
|
|
||||||
|
4. **Clubs frustrated with 420cloud's "Coming Soon" promises** — They signed up, features aren't shipping, they're looking for alternatives. *Growing segment as 420cloud fails to deliver.*
|
||||||
|
|
||||||
|
5. **New clubs not yet committed** — Greenfield. No migration friction. Capture before 420cloud's network effects lock them in. *~100 new clubs forming per quarter in 2026.*
|
||||||
|
|
||||||
|
### 4.2 Pricing Strategy
|
||||||
|
|
||||||
|
**Market context:**
|
||||||
|
- Hanf-App: ~30€/month (feature-complete)
|
||||||
|
- Cannanas: ~25€/month (partial features)
|
||||||
|
- 420cloud: Free member app + undisclosed B2B (likely 20-40€/month)
|
||||||
|
|
||||||
|
**Recommended positioning:**
|
||||||
|
|
||||||
|
| Tier | Price | Includes | Target |
|
||||||
|
|------|-------|----------|--------|
|
||||||
|
| **Community** | Free | API access, 1 staff user, 50 members max | Developer preview, tiny clubs |
|
||||||
|
| **Standard** | 19€/month | Full features, 5 staff, 500 members, cloud-hosted | Single clubs, price-sensitive |
|
||||||
|
| **Professional** | 39€/month | Unlimited staff/members, priority support, SEPA, advanced reports | Established clubs |
|
||||||
|
| **Federation** | 29€/club/month (min 10) | Multi-club dashboard, consolidated billing, dedicated support | Dachverbände |
|
||||||
|
| **Self-Hosted** | 99€/year (license) | Docker Compose, self-managed, community support | Privacy-focused clubs |
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Undercut Hanf-App on Standard tier (19€ vs 30€) — win on price + openness
|
||||||
|
- Federation tier creates volume deals (10 clubs × 29€ = 290€/month per Dachverband)
|
||||||
|
- Self-hosted is cheap enough to attract privacy clubs but still generates revenue
|
||||||
|
- Free tier creates developer ecosystem and word-of-mouth
|
||||||
|
|
||||||
|
### 4.3 Channel Strategy
|
||||||
|
|
||||||
|
| Channel | Action | Priority | Timeline |
|
||||||
|
|---------|--------|----------|----------|
|
||||||
|
| **csc-verwaltung.de** | Get listed on the comparison site | P0 | Once MVP frontend ships |
|
||||||
|
| **CSC Telegram groups** | Active presence, answer compliance questions, soft-sell | P1 | Immediately |
|
||||||
|
| **Dachverbände direct outreach** | Cold outreach with federation pitch deck | P1 | Sprint 6 (after federation ships) |
|
||||||
|
| **GitHub / Dev community** | Open-source API client libraries, public docs | P2 | Sprint 4 |
|
||||||
|
| **CSC founding workshops** | Partner with lawyers/consultants who help clubs form | P2 | Q3 2026 |
|
||||||
|
| **Content marketing** | CanG compliance guides, §26 checklists (SEO play) | P2 | Ongoing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sprint 4+ Roadmap (Competition-Informed)
|
||||||
|
|
||||||
|
### 5.1 Sprint 4: Compliance Reports + Member Portal (IMMEDIATE)
|
||||||
|
|
||||||
|
**Strategic goal:** Ship §26 reports before 420cloud does. They list this as "Coming Soon" — we race them.
|
||||||
|
|
||||||
|
- Complete Sprint 3 remaining phases (4-7): report engine, PDF generation, member portal endpoints
|
||||||
|
- §26-compatible PDF reports (Bestandsmeldung, Abgabenachweis, Mitgliederverzeichnis)
|
||||||
|
- Member self-service portal (view quota, distribution history, membership status)
|
||||||
|
- PWA manifest + service worker (mobile-ready without app stores)
|
||||||
|
- Public API documentation site (Redoc/Swagger UI hosted)
|
||||||
|
|
||||||
|
**Milestone:** A club admin can generate inspection-ready PDFs in one click.
|
||||||
|
|
||||||
|
### 5.2 Sprint 5: SEPA + Transportbescheinigung + 2FA
|
||||||
|
|
||||||
|
**Strategic goal:** Close the critical feature gaps vs. Hanf-App. After this sprint, we have feature parity on compliance.
|
||||||
|
|
||||||
|
- SEPA direct debit integration (GoCardless or Stripe SEPA as provider)
|
||||||
|
- Beitragsverwaltung (echte/unechte Beiträge — real/virtual contribution tracking)
|
||||||
|
- Transportbescheinigung PDF generation (CanG §22 transport certificates)
|
||||||
|
- TOTP-based 2FA (Google Authenticator / Authy compatible)
|
||||||
|
- Immutable audit log with SHA-256 hash chain (compliance moat)
|
||||||
|
|
||||||
|
**Milestone:** Feature parity with Hanf-App on compliance. Surpass them on security (audit trail + 2FA).
|
||||||
|
|
||||||
|
### 5.3 Sprint 6: Federation + Self-Hosting
|
||||||
|
|
||||||
|
**Strategic goal:** Unlock enterprise sales (Dachverbände) and the privacy segment. No competitor can follow here quickly.
|
||||||
|
|
||||||
|
- Multi-club federation dashboard (shared admin view, per-club drill-down)
|
||||||
|
- Docker Compose deployment (self-hosted mode)
|
||||||
|
- Helm chart for Kubernetes (larger orgs / hosting providers)
|
||||||
|
- Club onboarding wizard (guided setup for new clubs)
|
||||||
|
- Data migration tool (CSV import from Hanf-App/Cannanas export formats)
|
||||||
|
- Backup/restore workflow for self-hosted instances
|
||||||
|
|
||||||
|
**Milestone:** First Dachverband deal signed. First self-hosted club running independently.
|
||||||
|
|
||||||
|
### 5.4 Sprint 7: Frontend + PWA
|
||||||
|
|
||||||
|
**Strategic goal:** World-class UX that matches or exceeds Flowhub's speed. Tablet-optimized for Ausgabetisch.
|
||||||
|
|
||||||
|
- **Template:** shadcn-admin (React 19 + Vite + TanStack Router + shadcn/ui)
|
||||||
|
- Quick-Dispensing Card (inspired by Flowhub's "Maui POS" — 20-second checkout)
|
||||||
|
- Compliance dashboard with real-time quota visualization
|
||||||
|
- Member search with instant results + quick-info popover
|
||||||
|
- Batch trace timeline (Metrc-inspired seed-to-sale visualization)
|
||||||
|
- QR code member ID with offline JWT verification (scan → verify → dispense)
|
||||||
|
- Tablet-optimized layouts for Ausgabetisch workflow
|
||||||
|
- Dark mode with green accent theme
|
||||||
|
|
||||||
|
**Milestone:** A distribution takes under 30 seconds from member scan to confirmation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Competitive Intelligence Actions
|
||||||
|
|
||||||
|
- [ ] Monitor 420cloud "Coming Soon" features — when do Berichte & Analysen actually ship?
|
||||||
|
- [ ] Get Hanf-App demo access — document actual UX flow, confirm pricing, identify pain points
|
||||||
|
- [ ] Find 420cloud B2B pricing via LinkedIn outreach / Trustpilot reviews / direct inquiry
|
||||||
|
- [ ] Join 3-5 German CSC Telegram groups — listen for admin pain points and feature requests
|
||||||
|
- [ ] CanG §6/§7/§26 deep legal analysis — what EXACTLY must be reported and in what format?
|
||||||
|
- [ ] Track csc-verwaltung.de monthly for new entrants and feature comparison updates
|
||||||
|
- [ ] Monitor 420cloud's club map growth rate (389 clubs as of June 2026 — check monthly)
|
||||||
|
- [ ] Research Dachverbände: identify top 5, get contact info, understand their tech needs
|
||||||
|
- [ ] Check if any competitor ships a public API within 6 months (would erode our differentiator)
|
||||||
|
- [ ] Analyze Hanf-App's Steuerlogik implementation — can we replicate from CanG legal text alone?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Design Direction
|
||||||
|
|
||||||
|
### 7.1 Color Scheme
|
||||||
|
|
||||||
|
| Role | Color | Hex | Usage |
|
||||||
|
|------|-------|-----|-------|
|
||||||
|
| Primary | Dark Green | `#1a5632` | Headers, nav, primary buttons |
|
||||||
|
| Secondary | Warm Slate | `#475569` | Body text, secondary elements |
|
||||||
|
| Accent | Light Green | `#4ade80` | CTAs, success states, active indicators |
|
||||||
|
| Background | White/Light Gray | `#f8fafc` | Page backgrounds |
|
||||||
|
| Surface | White | `#ffffff` | Cards, panels |
|
||||||
|
| Error | Red | `#ef4444` | Quota warnings, compliance violations |
|
||||||
|
| Dark BG | Deep Slate | `#0f172a` | Dark mode background |
|
||||||
|
| Dark Accent | Emerald | `#10b981` | Dark mode green accents |
|
||||||
|
|
||||||
|
**Rationale:** Professional, trustworthy, not "stoner aesthetic." Think fintech-meets-compliance. The dark green signals cannabis without being cartoonish. The slate keeps it serious.
|
||||||
|
|
||||||
|
### 7.2 UI Patterns (inspired by competitor research)
|
||||||
|
|
||||||
|
| Pattern | Source | Our Implementation |
|
||||||
|
|---------|--------|-------------------|
|
||||||
|
| Quick-Dispensing Card | Flowhub "Maui POS" | Scan member → see quota → select strain → confirm. Under 30 seconds. |
|
||||||
|
| Compliance Dashboard | BioTrack | Real-time quota bars, upcoming report deadlines, compliance health score |
|
||||||
|
| Member Quick-Search | Flowhub | Instant typeahead with photo + quota preview in results |
|
||||||
|
| Batch Trace Timeline | Metrc/BioTrack | Visual timeline from procurement → storage → distribution → consumed |
|
||||||
|
| Report Export Buttons | Hanf-App | Prominent "Export PDF" on every report view. One click, done. |
|
||||||
|
| Mobile Card Layout | Cannanas/Hanf-App | Stack cards vertically on mobile, swipe actions for common tasks |
|
||||||
|
| Status Indicators | All | Traffic-light system: green (compliant), yellow (warning), red (violation) |
|
||||||
|
|
||||||
|
### 7.3 Template Choice
|
||||||
|
|
||||||
|
**Selected:** [shadcn-admin](https://github.com/satnaing/shadcn-admin) (MIT license, 11k+ stars)
|
||||||
|
|
||||||
|
**Why this template:**
|
||||||
|
- SPA architecture matches our REST API backend (no SSR overhead needed)
|
||||||
|
- TanStack Router for type-safe routing
|
||||||
|
- shadcn/ui components are accessible, customizable, and production-ready
|
||||||
|
- Built-in dark mode, responsive layout, sidebar navigation
|
||||||
|
- React 19 + Vite = fast builds, modern DX
|
||||||
|
- MIT license = no restrictions for commercial use
|
||||||
|
|
||||||
|
**What we'll customize:**
|
||||||
|
- Color scheme → our green/slate palette
|
||||||
|
- Navigation → Club admin sections (Members, Stock, Distributions, Reports, Settings)
|
||||||
|
- Dashboard → Compliance overview with quota visualizations
|
||||||
|
- Tables → TanStack Table with server-side pagination (our API already supports pagination)
|
||||||
|
- Forms → React Hook Form + Zod validation (matching our backend validation rules)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Key Decisions Log
|
||||||
|
|
||||||
|
| Decision | Rationale | Date |
|
||||||
|
|----------|-----------|------|
|
||||||
|
| API-first, frontend-second | Technical moat > pretty UI. API is the platform. | Sprint 1 |
|
||||||
|
| Multi-tenant from day one | Federation requires tenant isolation. Retrofitting is impossible. | Sprint 1 |
|
||||||
|
| PostgreSQL over H2 | Production-grade from start. No database migration later. | Sprint 1 |
|
||||||
|
| Spring Boot 4 + Java 17 | LTS, enterprise-proven, strong ecosystem for compliance software | Sprint 1 |
|
||||||
|
| 8 granular permissions | Enterprise readiness. Simple roles don't scale to 10-person staff teams. | Sprint 3 |
|
||||||
|
| JWT with rotation + revocation | Security differentiator. Competitors use basic session cookies. | Sprint 3 |
|
||||||
|
| shadcn-admin for frontend | SPA fits REST API. Modern stack. MIT. High star count = maintained. | Sprint 4 (planned) |
|
||||||
|
| Docker Compose self-hosting | Privacy segment is underserved. Low effort given our architecture. | Sprint 6 (planned) |
|
||||||
Reference in New Issue
Block a user