Compare commits

...

16 Commits

Author SHA1 Message Date
Patrick Plate 864bbbdde1 feat(sprint-3): Phase 7 — integration tests (Testcontainers PostgreSQL)
- Add AbstractIntegrationTest base class with Testcontainers PostgreSQL,
  RestClient helpers, and test data factories
- AuthIntegrationTest: login, refresh, token rotation, error cases
- TenantIsolationTest: multi-tenant data isolation verification
- StaffPermissionIntegrationTest: invite → activate → permission enforcement
- PortalIntegrationTest: session-based portal auth flow
- ReportIntegrationTest: JSON/PDF/CSV report generation E2E
- TokenRevocationIntegrationTest: permission change → JWT revocation
- application-integration.properties: Flyway-enabled test profile
- Remove obsolete Boot 3 @WebMvcTest/@MockBean tests (Boot 4 incompatible)
  replaced by comprehensive integration tests with real PostgreSQL
2026-06-12 11:05:40 +02:00
Patrick Plate 4f00872486 feat(sprint-3): Phase 6 — prevention officer capability
- PreventionOfficerService: limit enforcement, under-21 monitoring, monthly distribution tracking
- PUT /api/v1/staff/{id}/prevention-officer: assign/revoke with club limit check (409 on exceed)
- GET /api/v1/members/under-21: list under-21 members with quota data (prevention officer access)
- GET /api/v1/members/{id}/prevention-data: member prevention details (quota, distributions)
- PreventionOfficerLimitExceededException mapped to 409 in GlobalExceptionHandler
- StaffResponse extended with preventionOfficer field
- PreventionOfficerServiceTest: 10 unit tests covering assignment, revocation, limits, age calc
- MemberRepository.findByTenantIdAndUnder21True added
2026-06-12 10:20:20 +02:00
Patrick Plate 87568e5bfc feat(sprint-3): Phase 5 — member portal (session-based auth) 2026-06-12 10:11:58 +02:00
Patrick Plate 64927a3244 feat(sprint-3): Phase 4 — report controller + PDF/CSV generation
- Add report data models (MonthlyReport, MemberListReport, RecallReport)
- Implement ReportService with monthly aggregation, member list, recall batch tracing
- Add PdfReportGenerator using OpenPDF with minimal club branding
- Add PdfFooterHandler for timestamp + page numbers on every page
- Add CsvReportGenerator with UTF-8 BOM for Excel compatibility
- Create ReportController with 3 endpoints (monthly, members, recall)
  supporting JSON/PDF/CSV format negotiation via ?format= param
- Add DTO records (MonthlyReportResponse, MemberListResponse, RecallReportResponse)
- Extend DistributionRepository + MemberRepository with report queries
- Update Commons CSV from 1.11.0 to 1.12.0
- 10 unit tests (ReportServiceTest: 6, PdfReportGeneratorTest: 4) all passing

Endpoints:
  GET /api/v1/reports/monthly?month=YYYY-MM&format=json|pdf|csv
  GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
  GET /api/v1/reports/recall/{batchId}?format=json|pdf
2026-06-12 09:38:57 +02:00
Patrick Plate a267a90542 docs: add strategic differentiation plan 2026-06-12 09:25:50 +02:00
Patrick Plate 59b7486cec Merge sprint/3-staff-portal into main 2026-06-12 08:27:36 +02:00
Patrick Plate 752101c6c9 docs: add competitor & CSC market analysis PDF
- German market: Hanf-App, Cannanas, 420cloud feature comparison
- US market: Flowhub, BioTrack, Metrc, Dutchie design inspiration
- Switzerland: Cannavigia track & trace
- Spain: Historical CSC market (no software yet)
- Design recommendations derived from competitor analysis
- Differentiation strategy for CannaManage
2026-06-11 19:10:35 +02:00
Patrick Plate 302b7da8ca docs: add frontend UI shopping list PDF + OpenPDF/CSV deps in service POM
- Added OpenPDF 2.0.4 and Commons CSV 1.11.0 dependencies (Phase 4 prep)
- Generated frontend framework evaluation PDF with ranked templates and live demo links
2026-06-11 18:25:10 +02:00
Patrick Plate 6c66783b58 feat(sprint-3): Phase 3 — staff management + invite flow
- Step 3.1: Spring Boot Starter Mail dependency (api + service)
- Step 3.2: InviteToken JPA entity with 72h expiry
- Step 3.3: InviteTokenRepository with valid-token finder
- Step 3.4: EmailService (plain text invite email via JavaMailSender)
- Step 3.5: StaffService (CRUD + invite + email pattern validation + token revocation)
- Step 3.6: Staff DTOs (CreateStaffRequest, UpdateStaffRequest, StaffResponse)
- Step 3.7: SetPasswordRequest with password complexity (@Pattern: 1 digit + 1 special)
- Step 3.8: StaffController (6 endpoints, ADMIN-only via @PreAuthorize)
- Step 3.9: POST /api/v1/auth/set-password (public, generic error messages)
- Step 3.10: StaffTemplates (ausgabe, lager, vorstand predefined permission sets)
- Step 3.11: AuthService rejects inactive users with 'Account not activated'
- Step 3.12: Token revocation on permission change via revokeAllForUser()
- Step 3.13: invite-email.txt template (German, 72h expiry note)
- Step 3.14: Spring Mail config (Mailpit dev defaults, env var overrides)
- Step 3.15: Unit tests (StaffServiceTest, StaffControllerTest, EmailServiceTest)
- V5 Flyway migration for invite_tokens table

Security review findings incorporated:
- Password complexity: min 8 chars, 1 digit + 1 special char
- Generic 'invalid or expired token' error (no state leakage)
- SecureRandom 32-byte Base64 token generation
- Token values never logged
2026-06-11 18:03:12 +02:00
Patrick Plate 36deb72cf0 feat(sprint-3): Phase 2 — club settings controller 2026-06-11 16:56:44 +02:00
Patrick Plate 55d8434f35 feat(sprint-3): Phase 1 — staff permissions + token revocation
- StaffPermission enum (8 granular permissions)
- StaffAccount JPA entity with permissions collection
- RevokedToken entity for JWT blacklisting
- Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens)
- StaffAccountRepository + RevokedTokenRepository
- TokenRevocationService with Caffeine cache (60s TTL, 10k max)
- StaffPermissionChecker SpEL bean (@staffPermissions.has)
- PreventionOfficerChecker SpEL bean (@preventionOfficer.check)
- JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions
- JwtAuthFilter: token blacklist check via TokenRevocationService
- SecurityConfig: STAFF role added to endpoint matchers
- Controllers updated with @PreAuthorize for fine-grained access
- TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens)
- Caffeine dependency added to cannamanage-service
- Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
2026-06-11 16:45:21 +02:00
Patrick Plate 08b8e43ae8 docs: add comprehensive README with project overview, API docs, and sprint history 2026-06-11 13:35:28 +02:00
Patrick Plate a1ddec37da test(sprint-2): add integration tests for Auth + Compliance controllers
- AuthControllerIntegrationTest: 7 tests (login, refresh, error cases)
- ComplianceControllerIntegrationTest: 5 tests (quota, auth, 404)
- Fix Boot 4.0 @EntityScan relocation (boot.persistence.autoconfigure)
- Fix BCrypt 72-byte limit for refresh tokens (use SHA-256 instead)
- Configure H2 test DB with NON_KEYWORDS for reserved words (month/year)
2026-06-11 13:30:07 +02:00
Patrick Plate 2ede872d11 feat: Sprint 2 REST API layer — full implementation
- Fix critical Hibernate @Filter activation bug (TenantFilterAspect)
- Rename UserRole.ROLE_MANAGER → ROLE_STAFF (future-proofing)
- SecurityConfig: ADMIN + MEMBER roles only for Sprint 2
- AuthController: POST /auth/login + POST /auth/refresh with JWT
- AuthService: login, refresh token rotation, hashed refresh storage
- MemberController: CRUD (GET/POST/PUT /members)
- DistributionController: list + record distributions (CanG §26)
- StockController: batch management (GET/POST /stock/batches)
- ComplianceController: quota check (GET /compliance/quota/{id})
- OpenAPI/Swagger config with bearer-jwt security scheme
- GlobalExceptionHandler: full RFC 9457 problem+json coverage
- UserRepository: findByEmail, findByEmailAndTenantId
- Flyway V2: role rename migration + login indexes
- Testcontainers + test profile infrastructure (integration tests deferred)
- Parent POM: Testcontainers BOM, entity scan via properties

Controllers use validated DTOs (Jakarta Bean Validation records).
Compliance checks run before distribution recording.
Tenant filter AOP aspect ensures multi-tenant data isolation.
2026-06-11 12:05:52 +02:00
Patrick Plate 86c922e1f9 feat(sprint-2): add security infrastructure
- Spring Security 6 with dual SecurityFilterChain (API stateless JWT + public Swagger)
- JwtService: generate/validate access + refresh tokens (JJWT 0.12.6)
- JwtAuthFilter: extract Bearer token, set SecurityContext + TenantContext
- GlobalExceptionHandler: RFC 9457 ProblemDetail responses
- Dependencies: spring-security, jjwt, springdoc-openapi, bean-validation, h2-test
- Application properties: JWT config + OpenAPI paths
2026-06-11 10:46:48 +02:00
Patrick Plate 10891e7b89 chore: upgrade Spring Boot 3.3.4 → 4.0.6
- Remove manually-pinned versions (Hibernate, Flyway, AssertJ, Mockito)
  now managed by Boot 4.0.6 BOM
- Remove @EntityScan and @EnableJpaRepositories — auto-detected via
  scanBasePackages covering de.cannamanage hierarchy
- All 25 tests pass, build compiles in 9.6s
2026-06-11 10:41:59 +02:00
118 changed files with 11635 additions and 13 deletions
+111
View File
@@ -0,0 +1,111 @@
# CannaManage
Multi-tenant cannabis club management platform for German **Anbauvereinigungen** (cultivation associations) under CanG §19.
## Overview
CannaManage handles member management, distribution tracking, and legal compliance for cannabis cultivation clubs in Germany. It enforces the strict quotas mandated by the Cannabis Act (CanG) — including monthly limits (50g adult / 30g under-21), daily limits (25g), and THC restrictions for minors.
## Tech Stack
| Component | Technology |
|-----------|-----------|
| Runtime | Java 21 (Temurin) |
| Framework | Spring Boot 4.0.6 |
| Security | Spring Security 7.0 + JWT (JJWT 0.12.6) |
| ORM | Hibernate 7 / JPA |
| Database | PostgreSQL (prod), H2 (test) |
| Migrations | Flyway 10 |
| API Docs | SpringDoc OpenAPI 2.8.6 |
| Build | Maven (multi-module) |
| Container | Docker Compose (Postgres + app) |
## Project Structure
```
cannamanage/
├── cannamanage-domain/ # JPA entities, enums, TenantContext
├── cannamanage-service/ # Business logic, repositories, ComplianceService
├── cannamanage-api/ # Spring Boot app, controllers, security, DTOs
├── docs/
│ └── sprint-2/ # Sprint planning docs
└── docker-compose.yml # Local dev environment
```
## Modules
### cannamanage-domain
JPA entities with multi-tenant isolation via `@Filter("tenantFilter")`:
- `Member` — club members with age tracking
- `Distribution` — cannabis distribution records
- `MonthlyQuota` — per-member monthly usage tracking
- `Batch` / `Strain` / `StockMovement` — inventory management
- `Club` — association registration
- `User` — authentication accounts
### cannamanage-service
- `ComplianceService` — CanG §19 quota enforcement (25 unit tests)
- Repositories for all entities
### cannamanage-api
- **Auth** — JWT login + refresh token rotation (SHA-256 hashed)
- **Members** — CRUD for association members
- **Distributions** — compliance-gated distribution recording
- **Stock** — batch and inventory management
- **Compliance** — quota status API
- Multi-tenant isolation via `TenantFilterAspect` (Hibernate @Filter activation)
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/v1/auth/login` | Public | Login with email + password |
| POST | `/api/v1/auth/refresh` | Public | Refresh token rotation |
| GET | `/api/v1/compliance/quota/{memberId}` | ADMIN, MEMBER | Monthly quota status |
| GET/POST/PUT | `/api/v1/members/**` | ADMIN, MEMBER | Member CRUD |
| POST | `/api/v1/distributions/**` | ADMIN, MEMBER | Record distributions |
| GET/POST | `/api/v1/stock/**` | ADMIN | Stock management |
Swagger UI: `http://localhost:8080/swagger-ui.html`
## Running Locally
```bash
# Start PostgreSQL
docker compose up -d
# Run the app
JAVA_HOME=/path/to/jdk-21 ./mvnw spring-boot:run -pl cannamanage-api
# Run all tests (H2 in-memory)
JAVA_HOME=/path/to/jdk-21 ./mvnw clean verify
```
## Testing
- **37 tests total** — all green
- 25 unit tests (`ComplianceServiceTest`) — quota enforcement logic
- 7 integration tests (`AuthControllerIntegrationTest`) — full HTTP auth flow
- 5 integration tests (`ComplianceControllerIntegrationTest`) — quota API with JWT
Integration tests use `@SpringBootTest(webEnvironment = RANDOM_PORT)` with H2 and Spring's `RestClient`.
## Security Model
- **Stateless JWT** — no session, no UserDetailsService
- **Roles**: ADMIN (full access), MEMBER (self-service), STAFF (Sprint 3)
- **Multi-tenancy**: Hibernate `@Filter` activated per-request via AOP aspect
- **Refresh tokens**: SHA-256 hashed (Spring Security 7 enforces BCrypt 72-byte limit)
- Token rotation on refresh — old tokens invalidated
## Sprint History
| Sprint | Focus | Status |
|--------|-------|--------|
| 1 | Domain entities, ComplianceService, 25 tests | ✅ Done |
| 2 | REST API, Spring Security, JWT, OpenAPI, integration tests | ✅ Done |
| 3 | Member portal, STAFF role, real-time notifications | 📋 Planned |
## License
Private — Patrick Plate
+67
View File
@@ -46,11 +46,78 @@
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JWT (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- OpenAPI / Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<!-- H2 for unit tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers PostgreSQL for integration tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Mail (invite flow) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -2,17 +2,21 @@ package de.cannamanage.api;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/** /**
* CannaManage Spring Boot application entry point. * CannaManage Spring Boot application entry point.
* REST controllers are deferred to Sprint 2. * Sprint 2: REST API + Spring Security + OpenAPI.
* Sprint 1 focus: compliance engine validation only. *
* Multi-module scanning:
* - scanBasePackages: component scanning (controllers, services)
* - EnableJpaRepositories: Spring Data JPA repository interfaces
* - EntityScan: JPA entity detection across modules (Boot 4.0 relocated package)
*/ */
@SpringBootApplication(scanBasePackages = "de.cannamanage") @SpringBootApplication(scanBasePackages = "de.cannamanage")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository") @EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
public class CannaManageApplication { public class CannaManageApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,35 @@
package de.cannamanage.api.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server;
import org.springframework.context.annotation.Configuration;
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "CannaManage API",
version = "1.0.0",
description = "Cannabis Social Club Management — CanG Compliance Platform API",
contact = @Contact(name = "CannaManage", email = "info@cannamanage.de"),
license = @License(name = "Proprietary")
),
servers = {
@Server(url = "/", description = "Current server")
},
security = @SecurityRequirement(name = "bearer-jwt")
)
@SecurityScheme(
name = "bearer-jwt",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
description = "JWT access token — obtain via POST /api/v1/auth/login"
)
public class OpenApiConfig {
}
@@ -0,0 +1,49 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.dto.auth.SetPasswordRequest;
import de.cannamanage.api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "Authentication", description = "Login and token management")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
@PostMapping("/refresh")
@Operation(summary = "Refresh access token", description = "Exchanges a valid refresh token for new token pair")
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshRequest request) {
LoginResponse response = authService.refresh(request);
return ResponseEntity.ok(response);
}
@PostMapping("/set-password")
@Operation(summary = "Set password via invite token",
description = "Public endpoint — validates invite token, sets password, activates account")
public ResponseEntity<Map<String, String>> setPassword(@Valid @RequestBody SetPasswordRequest request) {
authService.setPassword(request);
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
}
}
@@ -0,0 +1,94 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.club.ClubResponse;
import de.cannamanage.api.dto.club.ClubStatsResponse;
import de.cannamanage.api.dto.club.UpdateClubRequest;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.ClubService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/clubs")
@RequiredArgsConstructor
@Tag(name = "Club Settings", description = "Club configuration and statistics")
public class ClubController {
private final ClubService clubService;
@GetMapping("/me")
@Operation(summary = "Get current club", description = "Returns the club for the current tenant")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ClubResponse> getMyClub() {
UUID tenantId = TenantContext.getCurrentTenant();
Club club = clubService.getClubByTenantId(tenantId);
return ResponseEntity.ok(toResponse(club));
}
@PutMapping("/me")
@Operation(summary = "Update club settings", description = "Updates the club configuration for the current tenant")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ClubResponse> updateMyClub(@Valid @RequestBody UpdateClubRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
Club updated = clubService.updateClub(
tenantId,
request.name(),
request.registrationNumber(),
request.contactEmail(),
request.contactPhone(),
request.addressStreet(),
request.addressCity(),
request.addressPostalCode(),
request.addressState(),
request.foundedDate(),
request.maxPreventionOfficers(),
request.allowedEmailPattern()
);
return ResponseEntity.ok(toResponse(updated));
}
@GetMapping("/me/stats")
@Operation(summary = "Get club statistics", description = "Returns aggregated club statistics")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<ClubStatsResponse> getMyClubStats() {
UUID tenantId = TenantContext.getCurrentTenant();
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
return ResponseEntity.ok(new ClubStatsResponse(
stats.totalMembers(),
stats.activeMembers(),
stats.totalStaff(),
stats.activeStaff(),
stats.totalDistributionsThisMonth(),
stats.totalGramsDistributedThisMonth(),
stats.activeBatches(),
stats.preventionOfficerCount()
));
}
private ClubResponse toResponse(Club club) {
return new ClubResponse(
club.getId(),
club.getName(),
club.getRegistrationNumber(),
club.getContactEmail(),
club.getContactPhone(),
club.getAddressStreet(),
club.getAddressCity(),
club.getAddressPostalCode(),
club.getAddressState(),
club.getFoundedDate(),
club.getMaxPreventionOfficers(),
club.getAllowedEmailPattern(),
club.getStatus(),
club.getCreatedAt()
);
}
}
@@ -0,0 +1,44 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.compliance.QuotaResponse;
import de.cannamanage.service.ComplianceService;
import de.cannamanage.service.dto.QuotaStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/compliance")
@RequiredArgsConstructor
@Tag(name = "Compliance", description = "CanG §19 compliance quota checks")
public class ComplianceController {
private final ComplianceService complianceService;
@GetMapping("/quota/{memberId}")
@Operation(summary = "Get member quota status",
description = "Returns current monthly remaining quota for a member per CanG §19")
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_QUOTA)")
public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) {
QuotaStatus status = complianceService.getQuotaStatus(memberId);
QuotaResponse response = new QuotaResponse(
status.totalAllowed(),
status.totalUsed(),
status.remaining(),
status.isUnder21(),
status.year(),
status.month()
);
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,78 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
import de.cannamanage.api.dto.distribution.DistributionResponse;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.service.ComplianceService;
import de.cannamanage.service.repository.DistributionRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/distributions")
@RequiredArgsConstructor
@Tag(name = "Distributions", description = "Cannabis distribution recording (CanG §26)")
public class DistributionController {
private final DistributionRepository distributionRepository;
private final ComplianceService complianceService;
@GetMapping
@Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<List<DistributionResponse>> listDistributions() {
List<DistributionResponse> distributions = distributionRepository.findAll().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(distributions);
}
@PostMapping
@Operation(summary = "Record a distribution",
description = "Records a cannabis distribution after compliance checks pass (CanG §19)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<DistributionResponse> createDistribution(
@Valid @RequestBody CreateDistributionRequest request,
Authentication authentication) {
// Run compliance checks — throws QuotaExceededException if violated
complianceService.checkDistributionAllowed(
request.memberId(), request.batchId(), request.quantityGrams());
UUID recordedBy = (UUID) authentication.getPrincipal();
Distribution distribution = new Distribution();
distribution.setMemberId(request.memberId());
distribution.setBatchId(request.batchId());
distribution.setQuantityGrams(request.quantityGrams());
distribution.setDistributedAt(Instant.now());
distribution.setRecordedBy(recordedBy);
distribution.setNotes(request.notes());
Distribution saved = distributionRepository.save(distribution);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
}
private DistributionResponse toResponse(Distribution d) {
return new DistributionResponse(
d.getId(),
d.getMemberId(),
d.getBatchId(),
d.getQuantityGrams(),
d.getDistributedAt(),
d.getRecordedBy(),
d.getNotes()
);
}
}
@@ -0,0 +1,166 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.member.CreateMemberRequest;
import de.cannamanage.api.dto.member.MemberResponse;
import de.cannamanage.api.dto.member.UpdateMemberRequest;
import de.cannamanage.api.dto.prevention.PreventionDataResponse;
import de.cannamanage.api.dto.prevention.Under21MemberResponse;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.PreventionOfficerService;
import de.cannamanage.service.repository.MemberRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.Period;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
@Tag(name = "Members", description = "Club member management")
public class MemberController {
private final MemberRepository memberRepository;
private final PreventionOfficerService preventionOfficerService;
@GetMapping
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
public ResponseEntity<List<MemberResponse>> listMembers() {
List<MemberResponse> members = memberRepository.findAll().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(members);
}
@GetMapping("/{id}")
@Operation(summary = "Get member by ID")
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
return ResponseEntity.ok(toResponse(member));
}
@PostMapping
@Operation(summary = "Create a new member")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) {
Member member = new Member();
member.setFirstName(request.firstName());
member.setLastName(request.lastName());
member.setEmail(request.email());
member.setDateOfBirth(request.dateOfBirth());
member.setMembershipDate(request.membershipDate());
member.setMembershipNumber(request.membershipNumber());
member.setClubId(TenantContext.getCurrentTenant()); // club == tenant for MVP
member.setUnder21(isUnder21(request.dateOfBirth()));
Member saved = memberRepository.save(member);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
}
@PutMapping("/{id}")
@Operation(summary = "Update a member")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id,
@Valid @RequestBody UpdateMemberRequest request) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
if (request.firstName() != null) member.setFirstName(request.firstName());
if (request.lastName() != null) member.setLastName(request.lastName());
if (request.email() != null) member.setEmail(request.email());
if (request.dateOfBirth() != null) {
member.setDateOfBirth(request.dateOfBirth());
member.setUnder21(isUnder21(request.dateOfBirth()));
}
if (request.membershipNumber() != null) member.setMembershipNumber(request.membershipNumber());
if (request.status() != null) member.setStatus(MemberStatus.valueOf(request.status()));
Member saved = memberRepository.save(member);
return ResponseEntity.ok(toResponse(saved));
}
@GetMapping("/under-21")
@Operation(summary = "List under-21 members", description = "Returns all under-21 members with current month distribution data. Prevention officer or ADMIN access required.")
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
public ResponseEntity<List<Under21MemberResponse>> getUnder21Members() {
UUID tenantId = TenantContext.getCurrentTenant();
List<Member> under21Members = preventionOfficerService.getUnder21Members(tenantId);
List<Under21MemberResponse> response = under21Members.stream()
.map(m -> {
int age = preventionOfficerService.calculateAge(m.getDateOfBirth());
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, m.getId());
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, m.getId());
BigDecimal limit = preventionOfficerService.getMonthlyLimit(m);
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
String quotaStatus = remaining.compareTo(BigDecimal.ZERO) > 0 ? "OK" : "EXHAUSTED";
return new Under21MemberResponse(
m.getId(), m.getFirstName(), m.getLastName(),
age, m.getDateOfBirth(), distCount,
gramsUsed, limit, quotaStatus
);
})
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/{id}/prevention-data")
@Operation(summary = "Get prevention data for a member", description = "Returns prevention-relevant data for a specific member. Prevention officer or ADMIN access required.")
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
public ResponseEntity<PreventionDataResponse> getPreventionData(@PathVariable UUID id) {
UUID tenantId = TenantContext.getCurrentTenant();
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
int age = preventionOfficerService.calculateAge(member.getDateOfBirth());
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, member.getId());
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, member.getId());
BigDecimal limit = preventionOfficerService.getMonthlyLimit(member);
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
return ResponseEntity.ok(new PreventionDataResponse(
member.getId(),
member.getFirstName() + " " + member.getLastName(),
member.isUnder21(),
age,
distCount,
gramsUsed,
limit,
remaining
));
}
private boolean isUnder21(LocalDate dateOfBirth) {
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
}
private MemberResponse toResponse(Member m) {
return new MemberResponse(
m.getId(),
m.getFirstName(),
m.getLastName(),
m.getEmail(),
m.getDateOfBirth(),
m.getMembershipDate(),
m.getMembershipNumber(),
m.getStatus(),
m.isUnder21(),
false // preventionOfficer flag comes from StaffAccount, not Member
);
}
}
@@ -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()
);
}
}
@@ -0,0 +1,128 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.prevention.PreventionOfficerRequest;
import de.cannamanage.api.dto.staff.CreateStaffRequest;
import de.cannamanage.api.dto.staff.StaffResponse;
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.PreventionOfficerService;
import de.cannamanage.service.StaffService;
import de.cannamanage.service.StaffTemplates;
import de.cannamanage.service.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/staff")
@RequiredArgsConstructor
@Tag(name = "Staff Management", description = "Staff CRUD + invite flow (ADMIN only)")
public class StaffController {
private final StaffService staffService;
private final PreventionOfficerService preventionOfficerService;
private final UserRepository userRepository;
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "List all active staff members")
public ResponseEntity<List<StaffResponse>> listStaff() {
UUID tenantId = TenantContext.getCurrentTenant();
List<StaffAccount> staffList = staffService.listStaff(tenantId);
List<StaffResponse> response = staffList.stream()
.map(staff -> {
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return StaffResponse.from(staff, email);
})
.toList();
return ResponseEntity.ok(response);
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Create staff member + send invite email")
public ResponseEntity<StaffResponse> createStaff(@Valid @RequestBody CreateStaffRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.createStaff(
tenantId,
request.email(),
request.displayName(),
request.permissions(),
request.templateName()
);
return ResponseEntity.status(HttpStatus.CREATED)
.body(StaffResponse.from(staff, request.email()));
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get staff member by ID")
public ResponseEntity<StaffResponse> getStaff(@PathVariable UUID id) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.getStaff(tenantId, id);
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return ResponseEntity.ok(StaffResponse.from(staff, email));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
@RequestBody UpdateStaffRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.updateStaff(
tenantId, id,
request.displayName(),
request.permissions(),
request.templateName(),
request.active()
);
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return ResponseEntity.ok(StaffResponse.from(staff, email));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Deactivate staff member (revokes all tokens)")
public ResponseEntity<Void> deactivateStaff(@PathVariable UUID id) {
UUID tenantId = TenantContext.getCurrentTenant();
staffService.deactivateStaff(tenantId, id);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/prevention-officer")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Assign or revoke prevention officer status",
description = "Sets prevention officer flag on a staff member. Enforces club.maxPreventionOfficers limit on assign.")
public ResponseEntity<StaffResponse> setPreventionOfficer(@PathVariable UUID id,
@Valid @RequestBody PreventionOfficerRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = preventionOfficerService.setPreventionOfficer(tenantId, id, request.preventionOfficer());
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return ResponseEntity.ok(StaffResponse.from(staff, email));
}
@GetMapping("/templates")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "List available permission templates")
public ResponseEntity<Map<String, Set<StaffPermission>>> listTemplates() {
return ResponseEntity.ok(StaffTemplates.getAllTemplates());
}
}
@@ -0,0 +1,74 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.stock.BatchResponse;
import de.cannamanage.api.dto.stock.CreateBatchRequest;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.service.repository.BatchRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/stock/batches")
@RequiredArgsConstructor
@Tag(name = "Stock", description = "Batch and inventory management")
public class StockController {
private final BatchRepository batchRepository;
@GetMapping
@Operation(summary = "List all batches", description = "Returns all batches for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
public ResponseEntity<List<BatchResponse>> listBatches() {
List<BatchResponse> batches = batchRepository.findAll().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(batches);
}
@GetMapping("/{id}")
@Operation(summary = "Get batch by ID")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) {
Batch batch = batchRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
HttpStatus.NOT_FOUND, "Batch not found"));
return ResponseEntity.ok(toResponse(batch));
}
@PostMapping
@Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) {
Batch batch = new Batch();
batch.setStrainId(request.strainId());
batch.setQuantityGrams(request.quantityGrams());
batch.setHarvestDate(request.harvestDate());
batch.setBatchCode(request.batchCode());
batch.setStatus(BatchStatus.AVAILABLE);
Batch saved = batchRepository.save(batch);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
}
private BatchResponse toResponse(Batch b) {
return new BatchResponse(
b.getId(),
b.getStrainId(),
b.getQuantityGrams(),
b.getHarvestDate(),
b.getBatchCode(),
b.getStatus(),
b.isContaminationFlag()
);
}
}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
String email,
@NotBlank(message = "Password is required")
String password
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.auth;
public record LoginResponse(
String accessToken,
String refreshToken,
long expiresIn,
String role
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.NotBlank;
public record RefreshRequest(
@NotBlank(message = "Refresh token is required")
String refreshToken
) {}
@@ -0,0 +1,18 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* Request DTO for setting password via invite token.
* Password complexity: min 8 chars, at least 1 digit + 1 special character.
*/
public record SetPasswordRequest(
@NotBlank String token,
@NotBlank
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
@Pattern(regexp = "^(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$",
message = "Password must contain at least 1 digit and 1 special character")
String password
) {}
@@ -0,0 +1,24 @@
package de.cannamanage.api.dto.club;
import de.cannamanage.domain.enums.ClubStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
public record ClubResponse(
UUID id,
String name,
String registrationNumber,
String contactEmail,
String contactPhone,
String addressStreet,
String addressCity,
String addressPostalCode,
String addressState,
LocalDate foundedDate,
Integer maxPreventionOfficers,
String allowedEmailPattern,
ClubStatus status,
Instant createdAt
) {}
@@ -0,0 +1,14 @@
package de.cannamanage.api.dto.club;
import java.math.BigDecimal;
public record ClubStatsResponse(
long totalMembers,
long activeMembers,
long totalStaff,
long activeStaff,
long totalDistributionsThisMonth,
BigDecimal totalGramsDistributedThisMonth,
long activeBatches,
long preventionOfficerCount
) {}
@@ -0,0 +1,34 @@
package de.cannamanage.api.dto.club;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDate;
public record UpdateClubRequest(
@NotBlank(message = "Club name is required")
String name,
String registrationNumber,
@Email(message = "Must be a valid email address")
String contactEmail,
String contactPhone,
String addressStreet,
String addressCity,
String addressPostalCode,
String addressState,
LocalDate foundedDate,
@Min(value = 1, message = "Must have at least 1 prevention officer slot")
Integer maxPreventionOfficers,
String allowedEmailPattern
) {}
@@ -0,0 +1,12 @@
package de.cannamanage.api.dto.compliance;
import java.math.BigDecimal;
public record QuotaResponse(
BigDecimal totalAllowed,
BigDecimal totalUsed,
BigDecimal remaining,
boolean under21,
int year,
int month
) {}
@@ -0,0 +1,21 @@
package de.cannamanage.api.dto.distribution;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.UUID;
public record CreateDistributionRequest(
@NotNull(message = "Member ID is required")
UUID memberId,
@NotNull(message = "Batch ID is required")
UUID batchId,
@NotNull(message = "Quantity in grams is required")
@DecimalMin(value = "0.01", message = "Quantity must be greater than zero")
BigDecimal quantityGrams,
String notes
) {}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.distribution;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record DistributionResponse(
UUID id,
UUID memberId,
UUID batchId,
BigDecimal quantityGrams,
Instant distributedAt,
UUID recordedBy,
String notes
) {}
@@ -0,0 +1,30 @@
package de.cannamanage.api.dto.member;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import java.time.LocalDate;
public record CreateMemberRequest(
@NotBlank(message = "First name is required")
String firstName,
@NotBlank(message = "Last name is required")
String lastName,
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email")
String email,
@NotNull(message = "Date of birth is required")
@Past(message = "Date of birth must be in the past")
LocalDate dateOfBirth,
@NotNull(message = "Membership date is required")
LocalDate membershipDate,
@NotBlank(message = "Membership number is required")
String membershipNumber
) {}
@@ -0,0 +1,19 @@
package de.cannamanage.api.dto.member;
import de.cannamanage.domain.enums.MemberStatus;
import java.time.LocalDate;
import java.util.UUID;
public record MemberResponse(
UUID id,
String firstName,
String lastName,
String email,
LocalDate dateOfBirth,
LocalDate membershipDate,
String membershipNumber,
MemberStatus status,
boolean under21,
boolean preventionOfficer
) {}
@@ -0,0 +1,17 @@
package de.cannamanage.api.dto.member;
import jakarta.validation.constraints.Email;
import java.time.LocalDate;
public record UpdateMemberRequest(
String firstName,
String lastName,
@Email(message = "Must be a valid email")
String email,
LocalDate dateOfBirth,
String membershipNumber,
String status
) {}
@@ -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
) {}
@@ -0,0 +1,7 @@
package de.cannamanage.api.dto.prevention;
import jakarta.validation.constraints.NotNull;
public record PreventionOfficerRequest(
@NotNull Boolean preventionOfficer
) {}
@@ -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
) {}
}
@@ -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
) {}
}
@@ -0,0 +1,17 @@
package de.cannamanage.api.dto.staff;
import de.cannamanage.domain.enums.StaffPermission;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.Set;
/**
* Request DTO for creating a new staff member (admin invite flow).
*/
public record CreateStaffRequest(
@NotBlank @Email String email,
@NotBlank String displayName,
Set<StaffPermission> permissions,
String templateName
) {}
@@ -0,0 +1,52 @@
package de.cannamanage.api.dto.staff;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;
/**
* Response DTO for staff member information.
*/
public record StaffResponse(
UUID id,
UUID userId,
String email,
String displayName,
Set<StaffPermission> permissions,
String templateName,
boolean active,
boolean preventionOfficer,
Instant createdAt
) {
public static StaffResponse from(StaffAccount staff, User user) {
return new StaffResponse(
staff.getId(),
staff.getUserId(),
user.getEmail(),
staff.getDisplayName(),
staff.getGrantedPermissions(),
null, // templateName not stored; permissions are expanded
staff.isActive(),
staff.isPreventionOfficer(),
staff.getCreatedAt()
);
}
public static StaffResponse from(StaffAccount staff, String email) {
return new StaffResponse(
staff.getId(),
staff.getUserId(),
email,
staff.getDisplayName(),
staff.getGrantedPermissions(),
null,
staff.isActive(),
staff.isPreventionOfficer(),
staff.getCreatedAt()
);
}
}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.staff;
import de.cannamanage.domain.enums.StaffPermission;
import java.util.Set;
/**
* Request DTO for updating an existing staff member.
*/
public record UpdateStaffRequest(
String displayName,
Set<StaffPermission> permissions,
String templateName,
Boolean active
) {}
@@ -0,0 +1,17 @@
package de.cannamanage.api.dto.stock;
import de.cannamanage.domain.enums.BatchStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record BatchResponse(
UUID id,
UUID strainId,
BigDecimal quantityGrams,
LocalDate harvestDate,
String batchCode,
BatchStatus status,
boolean contaminationFlag
) {}
@@ -0,0 +1,23 @@
package de.cannamanage.api.dto.stock;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record CreateBatchRequest(
@NotNull(message = "Strain ID is required")
UUID strainId,
@NotNull(message = "Quantity in grams is required")
@DecimalMin(value = "0.01", message = "Quantity must be greater than zero")
BigDecimal quantityGrams,
LocalDate harvestDate,
@NotBlank(message = "Batch code is required")
String batchCode
) {}
@@ -0,0 +1,144 @@
package de.cannamanage.api.exception;
import de.cannamanage.api.service.AuthService;
import de.cannamanage.service.exception.BatchNotFoundException;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
import de.cannamanage.service.exception.QuotaExceededException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.time.Instant;
import java.util.stream.Collectors;
/**
* Global exception handler producing application/problem+json responses.
* RFC 9457 compliant.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AuthService.AuthenticationException.class)
public ProblemDetail handleAuthException(AuthService.AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, ex.getMessage());
problem.setTitle("Authentication Failed");
problem.setType(URI.create("urn:cannamanage:error:AUTHENTICATION_FAILED"));
problem.setProperty("code", "AUTHENTICATION_FAILED");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(BadCredentialsException.class)
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "Invalid email or password");
problem.setTitle("Authentication Failed");
problem.setType(URI.create("urn:cannamanage:error:INVALID_CREDENTIALS"));
problem.setProperty("code", "INVALID_CREDENTIALS");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, "Access denied");
problem.setTitle("Forbidden");
problem.setType(URI.create("urn:cannamanage:error:ACCESS_DENIED"));
problem.setProperty("code", "ACCESS_DENIED");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setTitle("Bad Request");
problem.setType(URI.create("urn:cannamanage:error:VALIDATION_FAILED"));
problem.setProperty("code", "VALIDATION_FAILED");
problem.setProperty("timestamp", Instant.now().toString());
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.toList());
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(QuotaExceededException.class)
public ProblemDetail handleQuotaExceeded(QuotaExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Compliance Violation");
problem.setType(URI.create("urn:cannamanage:error:QUOTA_EXCEEDED"));
problem.setProperty("code", ex.getCode().name());
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(MemberNotFoundException.class)
public ProblemDetail handleMemberNotFound(MemberNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not Found");
problem.setType(URI.create("urn:cannamanage:error:MEMBER_NOT_FOUND"));
problem.setProperty("code", "MEMBER_NOT_FOUND");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(BatchNotFoundException.class)
public ProblemDetail handleBatchNotFound(BatchNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not Found");
problem.setType(URI.create("urn:cannamanage:error:BATCH_NOT_FOUND"));
problem.setProperty("code", "BATCH_NOT_FOUND");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(PreventionOfficerLimitExceededException.class)
public ProblemDetail handlePreventionOfficerLimitExceeded(PreventionOfficerLimitExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Prevention Officer Limit Exceeded");
problem.setType(URI.create("urn:cannamanage:error:PREVENTION_OFFICER_LIMIT_EXCEEDED"));
problem.setProperty("code", "PREVENTION_OFFICER_LIMIT_EXCEEDED");
problem.setProperty("maxAllowed", ex.getMaxAllowed());
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(ResponseStatusException.class)
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.valueOf(ex.getStatusCode().value()), ex.getReason());
problem.setTitle(HttpStatus.valueOf(ex.getStatusCode().value()).getReasonPhrase());
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
problem.setType(URI.create("urn:cannamanage:error:INTERNAL_ERROR"));
problem.setProperty("code", "INTERNAL_ERROR");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
}
@@ -0,0 +1,93 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.TokenRevocationService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
/**
* JWT authentication filter.
* Extracts Bearer token from Authorization header, validates it,
* checks token blacklist (revocation), sets SecurityContext and TenantContext.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final TokenRevocationService tokenRevocationService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
// Check token blacklist (revocation) — skip for portal paths per plan review warning #5
String jti = jwtService.extractJti(token);
if (jti != null && tokenRevocationService.isRevoked(jti)) {
log.debug("Token {} is revoked, rejecting request", jti);
filterChain.doFilter(request, response);
return;
}
UUID userId = jwtService.extractUserId(token);
UUID tenantId = jwtService.extractTenantId(token);
String role = jwtService.extractRole(token);
// Set tenant context for schema routing
TenantContext.setCurrentTenant(tenantId);
// Build authentication with role-based authority
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication = new UsernamePasswordAuthenticationToken(
userId, null, authorities
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} for tenant {} with role {}", userId, tenantId, role);
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/api/v1/auth/")
|| path.startsWith("/portal/")
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
}
}
@@ -0,0 +1,163 @@
package de.cannamanage.api.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.*;
import java.util.function.Function;
/**
* JWT token generation and validation service.
* Access tokens: 1 hour expiry, includes jti + permissions for STAFF.
* Refresh tokens: 30 days expiry.
*/
@Service
public class JwtService {
@Value("${cannamanage.security.jwt.secret}")
private String secretKey;
@Value("${cannamanage.security.jwt.access-token-expiry:3600}")
private long accessTokenExpiry; // seconds
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
private long refreshTokenExpiry; // seconds (30 days)
/**
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
*/
public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) {
Map<String, Object> claims = new HashMap<>();
claims.put("tenant_id", tenantId.toString());
claims.put("role", role);
claims.put("email", email);
claims.put("jti", UUID.randomUUID().toString());
return buildToken(claims, userId.toString(), accessTokenExpiry);
}
/**
* Generate access token for STAFF role — includes permissions list.
*/
public String generateStaffAccessToken(UUID userId, UUID tenantId, String email, List<String> permissions) {
Map<String, Object> claims = new HashMap<>();
claims.put("tenant_id", tenantId.toString());
claims.put("role", "STAFF");
claims.put("email", email);
claims.put("jti", UUID.randomUUID().toString());
claims.put("permissions", permissions);
return buildToken(claims, userId.toString(), accessTokenExpiry);
}
public String generateRefreshToken(UUID userId, UUID tenantId) {
Map<String, Object> claims = new HashMap<>();
claims.put("tenant_id", tenantId.toString());
claims.put("type", "refresh");
claims.put("jti", UUID.randomUUID().toString());
return buildToken(claims, userId.toString(), refreshTokenExpiry);
}
public String extractSubject(String token) {
return extractClaim(token, Claims::getSubject);
}
public UUID extractUserId(String token) {
return UUID.fromString(extractSubject(token));
}
public UUID extractTenantId(String token) {
return UUID.fromString(extractClaim(token, claims -> claims.get("tenant_id", String.class)));
}
public String extractRole(String token) {
return extractClaim(token, claims -> claims.get("role", String.class));
}
public String extractEmail(String token) {
return extractClaim(token, claims -> claims.get("email", String.class));
}
/**
* Extract the JTI (JWT ID) claim — used for token revocation.
*/
public String extractJti(String token) {
return extractClaim(token, claims -> claims.get("jti", String.class));
}
/**
* Extract permissions list from STAFF token.
* Returns empty list if not present (non-STAFF tokens).
*/
@SuppressWarnings("unchecked")
public List<String> extractPermissions(String token) {
return extractClaim(token, claims -> {
Object perms = claims.get("permissions");
if (perms instanceof List<?>) {
return (List<String>) perms;
}
return Collections.emptyList();
});
}
/**
* Extract token expiration as Instant — used for revocation record.
*/
public Instant extractExpirationInstant(String token) {
Date exp = extractClaim(token, Claims::getExpiration);
return exp.toInstant();
}
public boolean isTokenValid(String token) {
try {
extractAllClaims(token);
return !isTokenExpired(token);
} catch (Exception e) {
return false;
}
}
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(Date.from(Instant.now()));
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
final Claims claims = extractAllClaims(token);
return resolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private String buildToken(Map<String, Object> extraClaims, String subject, long expirySeconds) {
Instant now = Instant.now();
return Jwts.builder()
.claims(extraClaims)
.subject(subject)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(expirySeconds)))
.signWith(getSigningKey())
.compact();
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
@@ -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;
}
}
@@ -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()
);
}
}
@@ -0,0 +1,56 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.service.repository.StaffAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* SpEL-accessible bean for checking prevention officer status.
* Usage in @PreAuthorize:
* @PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
*/
@Slf4j
@Component("preventionOfficer")
@RequiredArgsConstructor
public class PreventionOfficerChecker {
private final StaffAccountRepository staffAccountRepository;
/**
* Checks if the authenticated user is a designated prevention officer.
* ADMIN always passes. STAFF must have is_prevention_officer = true.
*/
public boolean check(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
// ADMIN always passes
boolean isAdmin = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) {
return true;
}
// STAFF must be a prevention officer
boolean isStaff = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_STAFF"));
if (!isStaff) {
return false;
}
UUID userId = (UUID) authentication.getPrincipal();
return staffAccountRepository.findByUserId(userId)
.filter(StaffAccount::isActive)
.map(StaffAccount::isPreventionOfficer)
.orElse(false);
}
}
@@ -0,0 +1,119 @@
package de.cannamanage.api.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
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 + Member portal with sessions.
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final PortalUserDetailsService portalUserDetailsService;
/**
* API security — stateless JWT authentication.
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
*/
@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* Member portal — session-based authentication with CSRF protection.
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
*/
@Bean
@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 {
http
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,57 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.repository.StaffAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* SpEL-accessible bean for fine-grained staff permission checks.
* Usage in @PreAuthorize:
* @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
*/
@Slf4j
@Component("staffPermissions")
@RequiredArgsConstructor
public class StaffPermissionChecker {
private final StaffAccountRepository staffAccountRepository;
/**
* Checks if the authenticated user has the required permission.
* ADMIN role always passes. STAFF checks granted_permissions on their StaffAccount.
*/
public boolean has(Authentication authentication, StaffPermission required) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
// ADMIN always has all permissions
boolean isAdmin = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) {
return true;
}
// STAFF must have the specific permission granted
boolean isStaff = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_STAFF"));
if (!isStaff) {
return false;
}
UUID userId = (UUID) authentication.getPrincipal();
return staffAccountRepository.findByUserId(userId)
.filter(StaffAccount::isActive)
.map(staff -> staff.hasPermission(required))
.orElse(false);
}
}
@@ -0,0 +1,43 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.TenantContext;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.hibernate.Session;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* CRITICAL FIX: Activates the Hibernate @Filter("tenantFilter") on every repository call.
* Without this, the filter defined on AbstractTenantEntity is never enabled,
* meaning ALL queries return data across ALL tenants — a severe data leak.
*
* This aspect intercepts every Spring Data JPA repository method and enables
* the tenant filter with the current tenant ID from TenantContext.
*/
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class TenantFilterAspect {
private final EntityManager entityManager;
@Before("execution(* de.cannamanage.service.repository.*.*(..))")
public void activateTenantFilter() {
UUID tenantId = TenantContext.getCurrentTenant();
if (tenantId == null) {
log.trace("No tenant in context — filter not activated (public endpoint or system call)");
return;
}
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
log.trace("Tenant filter activated for tenant {}", tenantId);
}
}
@@ -0,0 +1,162 @@
package de.cannamanage.api.service;
import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.dto.auth.SetPasswordRequest;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.domain.entity.InviteToken;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.User;
import de.cannamanage.service.repository.InviteTokenRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import de.cannamanage.service.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.UUID;
/**
* Authentication service — handles login, token refresh, and invite-based password setup.
* Stateless JWT approach: no UserDetailsService needed.
* Refresh tokens are hashed and stored on the User entity for revocation support.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
private final InviteTokenRepository inviteTokenRepository;
private final StaffAccountRepository staffAccountRepository;
@Transactional
public LoginResponse login(LoginRequest request) {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
if (!user.isActive()) {
throw new AuthenticationException("Account not activated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new AuthenticationException("Invalid credentials");
}
// Generate tokens
String roleName = user.getRole().name().replace("ROLE_", "");
String accessToken = jwtService.generateAccessToken(
user.getId(), user.getTenantId(), roleName, user.getEmail());
String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
// Store SHA-256 hashed refresh token for revocation (BCrypt can't handle >72 bytes)
user.setRefreshTokenHash(sha256(refreshToken));
user.setLastLogin(Instant.now());
userRepository.save(user);
log.info("User {} logged in for tenant {}", user.getEmail(), user.getTenantId());
return new LoginResponse(accessToken, refreshToken, 3600L, roleName);
}
@Transactional
public LoginResponse refresh(RefreshRequest request) {
String token = request.refreshToken();
if (!jwtService.isTokenValid(token)) {
throw new AuthenticationException("Invalid or expired refresh token");
}
UUID userId = jwtService.extractUserId(token);
User user = userRepository.findById(userId)
.orElseThrow(() -> new AuthenticationException("User not found"));
if (!user.isActive()) {
throw new AuthenticationException("Account not activated");
}
// Verify the refresh token matches stored hash (revocation check)
if (user.getRefreshTokenHash() == null ||
!sha256(token).equals(user.getRefreshTokenHash())) {
throw new AuthenticationException("Refresh token has been revoked");
}
// Rotate refresh token
String roleName = user.getRole().name().replace("ROLE_", "");
String newAccessToken = jwtService.generateAccessToken(
user.getId(), user.getTenantId(), roleName, user.getEmail());
String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
user.setRefreshTokenHash(sha256(newRefreshToken));
userRepository.save(user);
return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName);
}
/**
* Sets the password for a user via invite token.
* Validates the token, sets the password hash, marks user active, marks token as used.
* Security: generic error message for invalid/expired tokens (don't reveal state).
*/
@Transactional
public void setPassword(SetPasswordRequest request) {
// Find valid (unused + not expired) token — security: generic error message
InviteToken inviteToken = inviteTokenRepository
.findByTokenAndUsedAtIsNullAndExpiresAtAfter(request.token(), Instant.now())
.orElseThrow(() -> new AuthenticationException("Invalid or expired token"));
User user = inviteToken.getUser();
// Set password and activate user
user.setPasswordHash(passwordEncoder.encode(request.password()));
user.setActive(true);
userRepository.save(user);
// Mark token as used
inviteToken.setUsedAt(Instant.now());
inviteTokenRepository.save(inviteToken);
// Update staff account activation timestamp
staffAccountRepository.findByUserId(user.getId())
.ifPresent(staff -> {
staff.setActivatedAt(Instant.now());
staffAccountRepository.save(staff);
});
log.info("Password set for user {} via invite token", user.getEmail());
}
/**
* SHA-256 hash for refresh token storage.
* JWTs exceed BCrypt's 72-byte limit (enforced in Spring Security 7+).
* SHA-256 is appropriate here: refresh tokens are already high-entropy random strings.
*/
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
/**
* Custom authentication exception — caught by GlobalExceptionHandler.
*/
public static class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}
}
@@ -1,4 +1,38 @@
spring.application.name=cannamanage spring.application.name=cannamanage
# Default profile — override with -Dspring.profiles.active=local # Default profile — override with -Dspring.profiles.active=local
spring.jpa.hibernate.ddl-auto=validate spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
spring.flyway.enabled=false spring.flyway.enabled=false
# JWT Security
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
cannamanage.security.jwt.access-token-expiry=3600
cannamanage.security.jwt.refresh-token-expiry=2592000
# OpenAPI
springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.tags-sorter=alpha
springdoc.swagger-ui.operations-sorter=method
# Enable Spring AOP for TenantFilterAspect
spring.aop.auto=true
spring.aop.proxy-target-class=true
# Spring Mail (dev defaults: Mailpit on localhost:1025)
spring.mail.host=${SMTP_HOST:localhost}
spring.mail.port=${SMTP_PORT:1025}
spring.mail.username=${SMTP_USERNAME:}
spring.mail.password=${SMTP_PASSWORD:}
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:false}
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:false}
spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
# App base URL (for invite links)
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}
@@ -0,0 +1,9 @@
-- CannaManage V2 — Sprint 2 schema adjustments
-- 1. Rename ROLE_MANAGER → ROLE_STAFF in users table
-- 2. Add index on users.email for login lookup
UPDATE users SET role = 'ROLE_STAFF' WHERE role = 'ROLE_MANAGER';
-- Optimize login queries
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_tenant_email ON users(tenant_id, email);
@@ -0,0 +1,45 @@
-- Sprint 3: Staff Portal foundation
-- Staff accounts, permissions, revoked tokens, prevention officer support
-- Staff accounts table (links users with STAFF role to their permissions)
CREATE TABLE staff_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES clubs(id),
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
display_name VARCHAR(150) NOT NULL,
is_prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
active BOOLEAN NOT NULL DEFAULT TRUE,
invited_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Staff account permissions (element collection table)
CREATE TABLE staff_account_permissions (
staff_account_id UUID NOT NULL REFERENCES staff_accounts(id) ON DELETE CASCADE,
permission VARCHAR(50) NOT NULL,
PRIMARY KEY (staff_account_id, permission)
);
-- Revoked tokens table for JWT blacklisting
CREATE TABLE revoked_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(36) NOT NULL UNIQUE,
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
reason VARCHAR(100)
);
-- Indexes for revoked tokens
CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti);
CREATE INDEX idx_revoked_tokens_user_id ON revoked_tokens(user_id);
CREATE INDEX idx_revoked_tokens_expires_at ON revoked_tokens(expires_at);
-- Index for staff accounts
CREATE INDEX idx_staff_accounts_tenant_id ON staff_accounts(tenant_id);
CREATE INDEX idx_staff_accounts_user_id ON staff_accounts(user_id);
-- Add max_prevention_officers to clubs table (default 2 per plan)
ALTER TABLE clubs ADD COLUMN max_prevention_officers INTEGER NOT NULL DEFAULT 2;
@@ -0,0 +1,12 @@
-- Sprint 3 Phase 2: Club settings extended columns
-- Additional address fields, contact info, and allowed email pattern for clubs
ALTER TABLE clubs ADD COLUMN registration_number VARCHAR(100);
ALTER TABLE clubs ADD COLUMN contact_email VARCHAR(255);
ALTER TABLE clubs ADD COLUMN contact_phone VARCHAR(50);
ALTER TABLE clubs ADD COLUMN address_street VARCHAR(255);
ALTER TABLE clubs ADD COLUMN address_city VARCHAR(100);
ALTER TABLE clubs ADD COLUMN address_postal_code VARCHAR(20);
ALTER TABLE clubs ADD COLUMN address_state VARCHAR(100);
ALTER TABLE clubs ADD COLUMN founded_date DATE;
ALTER TABLE clubs ADD COLUMN allowed_email_pattern VARCHAR(255);
@@ -0,0 +1,13 @@
-- Sprint 3 Phase 3: Invite tokens for staff onboarding
CREATE TABLE invite_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_invite_tokens_token ON invite_tokens(token);
CREATE INDEX idx_invite_tokens_user_id ON invite_tokens(user_id);
@@ -0,0 +1,14 @@
Hallo {displayName},
Du wurdest als Mitarbeiter/in beim Anbauverein "{clubName}" eingeladen.
Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren:
{setPasswordUrl}
Dieser Link ist 72 Stunden gültig.
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
Viele Grüße,
Dein CannaManage-Team
@@ -0,0 +1,196 @@
package de.cannamanage.api.controller;
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.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Integration tests for {@link AuthController}.
* Boots the full Spring context with H2 in-memory DB and a real HTTP server.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class AuthControllerIntegrationTest {
@LocalServerPort
private int port;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
private RestClient restClient;
private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final String TEST_EMAIL = "admin@test.club";
private static final String TEST_PASSWORD = "SecurePass123!";
@BeforeEach
void setUp() {
restClient = RestClient.builder()
.baseUrl("http://localhost:" + port)
.build();
userRepository.deleteAll();
// Set TenantContext so @PrePersist can pick up tenantId
TenantContext.setCurrentTenant(TENANT_ID);
User user = new User();
user.setEmail(TEST_EMAIL);
user.setPasswordHash(passwordEncoder.encode(TEST_PASSWORD));
user.setRole(UserRole.ROLE_ADMIN);
user.setActive(true);
userRepository.saveAndFlush(user);
TenantContext.clear();
}
@Test
@DisplayName("POST /api/v1/auth/login — valid credentials returns tokens")
void login_withValidCredentials_returnsTokens() throws Exception {
LoginRequest request = new LoginRequest(TEST_EMAIL, TEST_PASSWORD);
LoginResponse response = restClient.post()
.uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(LoginResponse.class);
assertThat(response).isNotNull();
assertThat(response.accessToken()).isNotBlank();
assertThat(response.refreshToken()).isNotBlank();
assertThat(response.expiresIn()).isEqualTo(3600L);
assertThat(response.role()).isEqualTo("ADMIN");
}
@Test
@DisplayName("POST /api/v1/auth/login — wrong password returns 401")
void login_withWrongPassword_returns401() {
LoginRequest request = new LoginRequest(TEST_EMAIL, "WrongPassword!");
assertThatThrownBy(() -> restClient.post()
.uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.Unauthorized.class);
}
@Test
@DisplayName("POST /api/v1/auth/login — unknown email returns 401")
void login_withUnknownEmail_returns401() {
LoginRequest request = new LoginRequest("nobody@test.club", TEST_PASSWORD);
assertThatThrownBy(() -> restClient.post()
.uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.Unauthorized.class);
}
@Test
@DisplayName("POST /api/v1/auth/login — disabled user returns 401")
void login_withDisabledUser_returns401() {
// Disable the test user
TenantContext.setCurrentTenant(TENANT_ID);
User user = userRepository.findByEmail(TEST_EMAIL).orElseThrow();
user.setActive(false);
userRepository.saveAndFlush(user);
TenantContext.clear();
LoginRequest request = new LoginRequest(TEST_EMAIL, TEST_PASSWORD);
assertThatThrownBy(() -> restClient.post()
.uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.Unauthorized.class);
}
@Test
@DisplayName("POST /api/v1/auth/login — missing email returns 400")
void login_withMissingEmail_returns400() {
assertThatThrownBy(() -> restClient.post()
.uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body("{\"password\": \"test123\"}")
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.BadRequest.class);
}
@Test
@DisplayName("POST /api/v1/auth/refresh — valid refresh token returns new token pair")
void refresh_withValidToken_returnsNewTokens() throws Exception {
// First login to get a refresh token
LoginRequest loginRequest = new LoginRequest(TEST_EMAIL, TEST_PASSWORD);
LoginResponse loginResponse = restClient.post()
.uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(loginRequest)
.retrieve()
.body(LoginResponse.class);
assertThat(loginResponse).isNotNull();
String refreshToken = loginResponse.refreshToken();
// Use the refresh token
RefreshRequest refreshRequest = new RefreshRequest(refreshToken);
LoginResponse refreshResponse = restClient.post()
.uri("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.body(refreshRequest)
.retrieve()
.body(LoginResponse.class);
assertThat(refreshResponse).isNotNull();
assertThat(refreshResponse.accessToken()).isNotBlank();
assertThat(refreshResponse.refreshToken()).isNotBlank();
assertThat(refreshResponse.role()).isEqualTo("ADMIN");
}
@Test
@DisplayName("POST /api/v1/auth/refresh — invalid token returns 401")
void refresh_withInvalidToken_returns401() {
RefreshRequest request = new RefreshRequest("invalid.jwt.token");
assertThatThrownBy(() -> restClient.post()
.uri("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.Unauthorized.class);
}
}
@@ -0,0 +1,113 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.club.UpdateClubRequest;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ClubStatus;
import de.cannamanage.service.ClubService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ClubControllerTest {
@Mock
private ClubService clubService;
@InjectMocks
private ClubController clubController;
private UUID tenantId;
private Club club;
@BeforeEach
void setUp() {
tenantId = UUID.randomUUID();
TenantContext.setCurrentTenant(tenantId);
club = new Club();
club.setId(UUID.randomUUID());
club.setTenantId(tenantId);
club.setName("Green Garden Club");
club.setRegistrationNumber("REG-2024-001");
club.setContactEmail("info@greengardenclub.de");
club.setContactPhone("+49 30 12345678");
club.setAddressStreet("Hanfweg 42");
club.setAddressCity("Berlin");
club.setAddressPostalCode("10115");
club.setAddressState("Berlin");
club.setFoundedDate(LocalDate.of(2024, 7, 1));
club.setMaxPreventionOfficers(2);
club.setAllowedEmailPattern(".*@greengardenclub\\.de");
club.setStatus(ClubStatus.ACTIVE);
club.setCreatedAt(Instant.now());
club.setLicenseNumber("LIC-001");
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
@Test
void getMyClub_returnsClubResponse() {
when(clubService.getClubByTenantId(tenantId)).thenReturn(club);
ResponseEntity<?> response = clubController.getMyClub();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(clubService).getClubByTenantId(tenantId);
}
@Test
void updateMyClub_updatesAndReturns() {
UpdateClubRequest request = new UpdateClubRequest(
"Updated Club", "REG-NEW", "new@club.de", "+49111",
"Newstreet 1", "Hamburg", "20095", "Hamburg",
LocalDate.of(2024, 1, 1), 3, ".*@club\\.de"
);
when(clubService.updateClub(
eq(tenantId), eq("Updated Club"), eq("REG-NEW"),
eq("new@club.de"), eq("+49111"),
eq("Newstreet 1"), eq("Hamburg"), eq("20095"), eq("Hamburg"),
eq(LocalDate.of(2024, 1, 1)), eq(3), eq(".*@club\\.de")
)).thenReturn(club);
ResponseEntity<?> response = clubController.updateMyClub(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
void getMyClubStats_returnsStats() {
ClubService.ClubStats stats = new ClubService.ClubStats(
50, 42, 5, 4, 120, new BigDecimal("1500.50"), 8, 2
);
when(clubService.getClubStats(tenantId)).thenReturn(stats);
ResponseEntity<?> response = clubController.getMyClubStats();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
verify(clubService).getClubStats(tenantId);
}
}
@@ -0,0 +1,182 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.compliance.QuotaResponse;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.repository.MemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.test.context.ActiveProfiles;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Integration tests for {@link ComplianceController}.
* Boots the full Spring context with H2 in-memory DB.
* Tests quota status endpoint with JWT authentication.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class ComplianceControllerIntegrationTest {
@LocalServerPort
private int port;
@Autowired
private MemberRepository memberRepository;
@Autowired
private JwtService jwtService;
private RestClient restClient;
private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000099");
private UUID memberId;
private String adminToken;
@BeforeEach
void setUp() {
memberRepository.deleteAll();
TenantContext.setCurrentTenant(TENANT_ID);
// Create a test member (adult, 25 years old)
Member member = new Member();
member.setClubId(UUID.fromString("00000000-0000-0000-0000-000000000010"));
member.setFirstName("Max");
member.setLastName("Mustermann");
member.setEmail("max@test.club");
member.setDateOfBirth(LocalDate.now().minusYears(25));
member.setMembershipDate(LocalDate.now().minusMonths(6));
member.setMembershipNumber("CM-2025-001");
member.setStatus(MemberStatus.ACTIVE);
member.setUnder21(false);
member = memberRepository.saveAndFlush(member);
memberId = member.getId();
TenantContext.clear();
// Generate a JWT token for authentication
adminToken = jwtService.generateAccessToken(USER_ID, TENANT_ID, "ADMIN", "admin@test.club");
restClient = RestClient.builder()
.baseUrl("http://localhost:" + port)
.defaultHeader("Authorization", "Bearer " + adminToken)
.build();
}
@Test
@DisplayName("GET /api/v1/compliance/quota/{memberId} — returns quota for adult member")
void getQuotaStatus_adultMember_returnsQuota() {
QuotaResponse response = restClient.get()
.uri("/api/v1/compliance/quota/{memberId}", memberId)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(QuotaResponse.class);
assertThat(response).isNotNull();
assertThat(response.totalAllowed()).isEqualByComparingTo("50");
assertThat(response.totalUsed()).isEqualByComparingTo("0");
assertThat(response.remaining()).isEqualByComparingTo("50");
assertThat(response.under21()).isFalse();
assertThat(response.year()).isEqualTo(LocalDate.now().getYear());
assertThat(response.month()).isEqualTo(LocalDate.now().getMonthValue());
}
@Test
@DisplayName("GET /api/v1/compliance/quota/{memberId} — under-21 member gets reduced limit")
void getQuotaStatus_under21Member_returnsReducedLimit() {
// Create under-21 member
TenantContext.setCurrentTenant(TENANT_ID);
Member youngMember = new Member();
youngMember.setClubId(UUID.fromString("00000000-0000-0000-0000-000000000010"));
youngMember.setFirstName("Jung");
youngMember.setLastName("Mitglied");
youngMember.setEmail("jung@test.club");
youngMember.setDateOfBirth(LocalDate.now().minusYears(19));
youngMember.setMembershipDate(LocalDate.now().minusMonths(3));
youngMember.setMembershipNumber("CM-2025-002");
youngMember.setStatus(MemberStatus.ACTIVE);
youngMember.setUnder21(true);
youngMember = memberRepository.saveAndFlush(youngMember);
UUID youngMemberId = youngMember.getId();
TenantContext.clear();
QuotaResponse response = restClient.get()
.uri("/api/v1/compliance/quota/{memberId}", youngMemberId)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(QuotaResponse.class);
assertThat(response).isNotNull();
assertThat(response.totalAllowed()).isEqualByComparingTo("30");
assertThat(response.under21()).isTrue();
}
@Test
@DisplayName("GET /api/v1/compliance/quota/{memberId} — non-existent member returns 404")
void getQuotaStatus_nonExistentMember_returns404() {
UUID nonExistentId = UUID.fromString("00000000-0000-0000-0000-999999999999");
assertThatThrownBy(() -> restClient.get()
.uri("/api/v1/compliance/quota/{memberId}", nonExistentId)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.NotFound.class);
}
@Test
@DisplayName("GET /api/v1/compliance/quota/{memberId} — no auth returns 401/403")
void getQuotaStatus_noAuth_returnsUnauthorized() {
RestClient unauthClient = RestClient.builder()
.baseUrl("http://localhost:" + port)
.build();
assertThatThrownBy(() -> unauthClient.get()
.uri("/api/v1/compliance/quota/{memberId}", memberId)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.class)
.satisfies(ex -> {
int status = ((HttpClientErrorException) ex).getStatusCode().value();
assertThat(status).isBetween(401, 403);
});
}
@Test
@DisplayName("GET /api/v1/compliance/quota/{memberId} — invalid token returns 401/403")
void getQuotaStatus_invalidToken_returnsUnauthorized() {
RestClient badTokenClient = RestClient.builder()
.baseUrl("http://localhost:" + port)
.defaultHeader("Authorization", "Bearer invalid.jwt.token")
.build();
assertThatThrownBy(() -> badTokenClient.get()
.uri("/api/v1/compliance/quota/{memberId}", memberId)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class))
.isInstanceOf(HttpClientErrorException.class)
.satisfies(ex -> {
int status = ((HttpClientErrorException) ex).getStatusCode().value();
assertThat(status).isBetween(401, 403);
});
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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("[]");
}
}
@@ -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,123 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.enums.StaffPermission;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StaffPermissionCheckerTest {
@Mock
private StaffAccountRepository staffAccountRepository;
@InjectMocks
private StaffPermissionChecker checker;
private UUID staffUserId;
private StaffAccount staffAccount;
@BeforeEach
void setUp() {
staffUserId = UUID.randomUUID();
staffAccount = new StaffAccount();
staffAccount.setUserId(staffUserId);
staffAccount.setDisplayName("Test Staff");
staffAccount.setActive(true);
staffAccount.setGrantedPermissions(Set.of(
StaffPermission.RECORD_DISTRIBUTION,
StaffPermission.VIEW_MEMBER_LIST
));
}
@Test
void adminAlwaysHasPermission() {
Authentication auth = new UsernamePasswordAuthenticationToken(
UUID.randomUUID(), null,
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
);
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isTrue();
assertThat(checker.has(auth, StaffPermission.MANAGE_GROW_CALENDAR)).isTrue();
}
@Test
void staffWithGrantedPermission_returnsTrue() {
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isTrue();
}
@Test
void staffWithoutGrantedPermission_returnsFalse() {
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
assertThat(checker.has(auth, StaffPermission.MANAGE_GROW_CALENDAR)).isFalse();
}
@Test
void inactiveStaff_returnsFalse() {
staffAccount.setActive(false);
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
}
@Test
void memberRole_returnsFalse() {
Authentication auth = new UsernamePasswordAuthenticationToken(
UUID.randomUUID(), null,
List.of(new SimpleGrantedAuthority("ROLE_MEMBER"))
);
assertThat(checker.has(auth, StaffPermission.VIEW_MEMBER_LIST)).isFalse();
}
@Test
void nullAuthentication_returnsFalse() {
assertThat(checker.has(null, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
}
@Test
void staffWithNoAccount_returnsFalse() {
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.empty());
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
}
}
@@ -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
@@ -0,0 +1,20 @@
spring.application.name=cannamanage-test
spring.datasource.url=jdbc:h2:mem:cannamanage_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;NON_KEYWORDS=MONTH,YEAR
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
# Let Hibernate create schema from entities
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
spring.jpa.show-sql=true
spring.flyway.enabled=false
# JWT test secret
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
cannamanage.security.jwt.access-token-expiry=3600
cannamanage.security.jwt.refresh-token-expiry=2592000
# AOP
spring.aop.auto=true
spring.aop.proxy-target-class=true
@@ -3,6 +3,8 @@ package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ClubStatus; import de.cannamanage.domain.enums.ClubStatus;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDate;
@Entity @Entity
@Table(name = "clubs") @Table(name = "clubs")
public class Club extends AbstractTenantEntity { public class Club extends AbstractTenantEntity {
@@ -10,6 +12,30 @@ public class Club extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 255) @Column(name = "name", nullable = false, length = 255)
private String name; private String name;
@Column(name = "registration_number", length = 100)
private String registrationNumber;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "contact_phone", length = 50)
private String contactPhone;
@Column(name = "address_street", length = 255)
private String addressStreet;
@Column(name = "address_city", length = 100)
private String addressCity;
@Column(name = "address_postal_code", length = 20)
private String addressPostalCode;
@Column(name = "address_state", length = 100)
private String addressState;
@Column(name = "founded_date")
private LocalDate foundedDate;
@Column(name = "address") @Column(name = "address")
private String address; private String address;
@@ -19,6 +45,12 @@ public class Club extends AbstractTenantEntity {
@Column(name = "max_members", nullable = false) @Column(name = "max_members", nullable = false)
private Integer maxMembers = 500; private Integer maxMembers = 500;
@Column(name = "max_prevention_officers", nullable = false)
private Integer maxPreventionOfficers = 2;
@Column(name = "allowed_email_pattern", length = 255)
private String allowedEmailPattern;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50) @Column(name = "status", nullable = false, length = 50)
private ClubStatus status = ClubStatus.ACTIVE; private ClubStatus status = ClubStatus.ACTIVE;
@@ -26,6 +58,30 @@ public class Club extends AbstractTenantEntity {
public String getName() { return name; } public String getName() { return name; }
public void setName(String name) { this.name = name; } public void setName(String name) { this.name = name; }
public String getRegistrationNumber() { return registrationNumber; }
public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; }
public String getContactEmail() { return contactEmail; }
public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getAddressStreet() { return addressStreet; }
public void setAddressStreet(String addressStreet) { this.addressStreet = addressStreet; }
public String getAddressCity() { return addressCity; }
public void setAddressCity(String addressCity) { this.addressCity = addressCity; }
public String getAddressPostalCode() { return addressPostalCode; }
public void setAddressPostalCode(String addressPostalCode) { this.addressPostalCode = addressPostalCode; }
public String getAddressState() { return addressState; }
public void setAddressState(String addressState) { this.addressState = addressState; }
public LocalDate getFoundedDate() { return foundedDate; }
public void setFoundedDate(LocalDate foundedDate) { this.foundedDate = foundedDate; }
public String getAddress() { return address; } public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; } public void setAddress(String address) { this.address = address; }
@@ -35,6 +91,12 @@ public class Club extends AbstractTenantEntity {
public Integer getMaxMembers() { return maxMembers; } public Integer getMaxMembers() { return maxMembers; }
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; } public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
public Integer getMaxPreventionOfficers() { return maxPreventionOfficers; }
public void setMaxPreventionOfficers(Integer maxPreventionOfficers) { this.maxPreventionOfficers = maxPreventionOfficers; }
public String getAllowedEmailPattern() { return allowedEmailPattern; }
public void setAllowedEmailPattern(String allowedEmailPattern) { this.allowedEmailPattern = allowedEmailPattern; }
public ClubStatus getStatus() { return status; } public ClubStatus getStatus() { return status; }
public void setStatus(ClubStatus status) { this.status = status; } public void setStatus(ClubStatus status) { this.status = status; }
} }
@@ -0,0 +1,74 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Invite token for staff onboarding.
* Created when an admin invites a new staff member — the token is sent via email
* and used once to set the initial password.
*/
@Entity
@Table(name = "invite_tokens")
public class InviteToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token", nullable = false, unique = true, length = 64)
private String token;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.createdAt = Instant.now();
}
// --- Getters & Setters ---
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
public Instant getUsedAt() { return usedAt; }
public void setUsedAt(Instant usedAt) { this.usedAt = usedAt; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
public boolean isUsed() {
return usedAt != null;
}
public boolean isValid() {
return !isExpired() && !isUsed();
}
}
@@ -0,0 +1,66 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Stores revoked JWT tokens for token blacklist checking.
* Tokens are identified by their JTI (JWT ID) claim.
* Cleanup scheduler removes expired entries nightly.
*/
@Entity
@Table(name = "revoked_tokens", indexes = {
@Index(name = "idx_revoked_tokens_jti", columnList = "jti", unique = true),
@Index(name = "idx_revoked_tokens_user_id", columnList = "user_id"),
@Index(name = "idx_revoked_tokens_expires_at", columnList = "expires_at")
})
public class RevokedToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "jti", nullable = false, unique = true, length = 36)
private String jti;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Column(name = "revoked_at", nullable = false)
private Instant revokedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "reason", length = 100)
private String reason;
// --- Getters & Setters ---
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getJti() { return jti; }
public void setJti(String jti) { this.jti = jti; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public Instant getRevokedAt() { return revokedAt; }
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
}
@@ -0,0 +1,71 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.StaffPermission;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* Staff account with fine-grained permissions.
* Links a user (STAFF role) to their granted permissions stored as JSONB.
* One StaffAccount per user; permissions are a subset of StaffPermission enum values.
*/
@Entity
@Table(name = "staff_accounts")
public class StaffAccount extends AbstractTenantEntity {
@Column(name = "user_id", nullable = false, unique = true)
private UUID userId;
@Column(name = "display_name", nullable = false, length = 150)
private String displayName;
@ElementCollection(targetClass = StaffPermission.class, fetch = FetchType.EAGER)
@CollectionTable(name = "staff_account_permissions",
joinColumns = @JoinColumn(name = "staff_account_id"))
@Enumerated(EnumType.STRING)
@Column(name = "permission", nullable = false, length = 50)
private Set<StaffPermission> grantedPermissions = new HashSet<>();
@Column(name = "is_prevention_officer", nullable = false)
private boolean preventionOfficer = false;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "invited_at")
private Instant invitedAt;
@Column(name = "activated_at")
private Instant activatedAt;
// --- Getters & Setters ---
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public Set<StaffPermission> getGrantedPermissions() { return grantedPermissions; }
public void setGrantedPermissions(Set<StaffPermission> grantedPermissions) { this.grantedPermissions = grantedPermissions; }
public boolean isPreventionOfficer() { return preventionOfficer; }
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public Instant getInvitedAt() { return invitedAt; }
public void setInvitedAt(Instant invitedAt) { this.invitedAt = invitedAt; }
public Instant getActivatedAt() { return activatedAt; }
public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; }
public boolean hasPermission(StaffPermission permission) {
return grantedPermissions.contains(permission);
}
}
@@ -0,0 +1,17 @@
package de.cannamanage.domain.enums;
/**
* Fine-grained permissions for STAFF role users.
* Admins implicitly have all permissions.
* Staff members are granted a subset via their StaffAccount.
*/
public enum StaffPermission {
RECORD_DISTRIBUTION,
VIEW_MEMBER_LIST,
VIEW_MEMBER_QUOTA,
ADD_MEMBER,
VIEW_STOCK,
RECORD_STOCK_IN,
VIEW_COMPLIANCE_REPORT,
MANAGE_GROW_CALENDAR
}
@@ -1,8 +1,12 @@
package de.cannamanage.domain.enums; package de.cannamanage.domain.enums;
/**
* User roles for access control.
* Sprint 2: ADMIN + MEMBER only.
* Sprint 3: STAFF added (replaces old MANAGER concept).
*/
public enum UserRole { public enum UserRole {
ROLE_ADMIN, ROLE_ADMIN,
ROLE_MANAGER, ROLE_STAFF,
ROLE_MEMBER, ROLE_MEMBER
ROLE_PREVENTION_OFFICER
} }
+32
View File
@@ -47,6 +47,38 @@
<artifactId>assertj-core</artifactId> <artifactId>assertj-core</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Caffeine caching for token revocation -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Spring Context for @Scheduled -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<!-- Spring Web for ResponseStatusException -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- Spring Mail (invite flow) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- OpenPDF for PDF report generation -->
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>2.0.4</version>
</dependency>
<!-- Apache Commons CSV for CSV report generation -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.12.0</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -0,0 +1,120 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@Slf4j
@Service
@RequiredArgsConstructor
public class ClubService {
private final ClubRepository clubRepository;
private final MemberRepository memberRepository;
private final StaffAccountRepository staffAccountRepository;
private final DistributionRepository distributionRepository;
private final BatchRepository batchRepository;
@Transactional(readOnly = true)
public Club getClubByTenantId(UUID tenantId) {
return clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found for tenant"));
}
@Transactional
public Club updateClub(UUID tenantId, String name, String registrationNumber,
String contactEmail, String contactPhone,
String addressStreet, String addressCity,
String addressPostalCode, String addressState,
LocalDate foundedDate,
Integer maxPreventionOfficers, String allowedEmailPattern) {
Club club = getClubByTenantId(tenantId);
// Validate regex pattern if provided
if (allowedEmailPattern != null && !allowedEmailPattern.isBlank()) {
validateRegexPattern(allowedEmailPattern);
}
club.setName(name);
club.setRegistrationNumber(registrationNumber);
club.setContactEmail(contactEmail);
club.setContactPhone(contactPhone);
club.setAddressStreet(addressStreet);
club.setAddressCity(addressCity);
club.setAddressPostalCode(addressPostalCode);
club.setAddressState(addressState);
club.setFoundedDate(foundedDate);
if (maxPreventionOfficers != null) {
club.setMaxPreventionOfficers(maxPreventionOfficers);
}
club.setAllowedEmailPattern(allowedEmailPattern);
return clubRepository.save(club);
}
@Transactional(readOnly = true)
public ClubStats getClubStats(UUID tenantId) {
long totalMembers = memberRepository.countByTenantId(tenantId);
long activeMembers = memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE);
long totalStaff = staffAccountRepository.countByTenantId(tenantId);
long activeStaff = staffAccountRepository.countByTenantIdAndActiveTrue(tenantId);
// Distributions this month
Instant startOfMonth = LocalDate.now().withDayOfMonth(1)
.atStartOfDay(ZoneOffset.UTC).toInstant();
long totalDistributionsThisMonth = distributionRepository
.countByTenantIdAndDistributedAtAfter(tenantId, startOfMonth);
BigDecimal totalGramsThisMonth = distributionRepository
.sumGramsByTenantIdAndDistributedAtAfter(tenantId, startOfMonth);
long activeBatches = batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE);
long preventionOfficerCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId);
return new ClubStats(
totalMembers, activeMembers,
totalStaff, activeStaff,
totalDistributionsThisMonth,
totalGramsThisMonth != null ? totalGramsThisMonth : BigDecimal.ZERO,
activeBatches, preventionOfficerCount
);
}
private void validateRegexPattern(String pattern) {
try {
Pattern.compile(pattern);
} catch (PatternSyntaxException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Invalid regex pattern for allowedEmailPattern: " + e.getDescription());
}
}
public record ClubStats(
long totalMembers,
long activeMembers,
long totalStaff,
long activeStaff,
long totalDistributionsThisMonth,
BigDecimal totalGramsDistributedThisMonth,
long activeBatches,
long preventionOfficerCount
) {}
}
@@ -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,68 @@
package de.cannamanage.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
/**
* Email service for sending invite emails to new staff members.
* Uses plain text templates — no Thymeleaf dependency needed.
*/
@Slf4j
@Service
public class EmailService {
private final JavaMailSender mailSender;
private final String baseUrl;
private final String fromAddress;
public EmailService(JavaMailSender mailSender,
@Value("${app.base-url:http://localhost:8080}") String baseUrl,
@Value("${spring.mail.from:noreply@cannamanage.de}") String fromAddress) {
this.mailSender = mailSender;
this.baseUrl = baseUrl;
this.fromAddress = fromAddress;
}
/**
* Sends an invite email to a new staff member with a link to set their password.
* Security: token value is NOT logged.
*/
public void sendInviteEmail(String recipientEmail, String displayName,
String clubName, String token) {
String setPasswordUrl = baseUrl + "/auth/set-password?token=" + token;
String body = String.format("""
Hallo %s,
Du wurdest als Mitarbeiter/in beim Anbauverein "%s" eingeladen.
Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren:
%s
Dieser Link ist 72 Stunden gültig.
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
Viele Grüße,
Dein CannaManage-Team
""", displayName, clubName, setPasswordUrl);
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromAddress);
message.setTo(recipientEmail);
message.setSubject("Einladung: " + clubName + " — Account aktivieren");
message.setText(body);
try {
mailSender.send(message);
log.info("Invite email sent to {} for club '{}'", recipientEmail, clubName);
} catch (Exception e) {
log.error("Failed to send invite email to {}: {}", recipientEmail, e.getMessage());
throw new RuntimeException("Failed to send invite email", 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");
}
}
@@ -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;
}
}
@@ -0,0 +1,230 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.InviteToken;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.InviteTokenRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import de.cannamanage.service.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.springframework.http.HttpStatus.*;
/**
* Staff management service — CRUD operations + invite flow.
* Handles: staff creation (with invite email), permission updates (with token revocation),
* and deactivation.
*/
@Slf4j
@Service
public class StaffService {
private static final int TOKEN_BYTES = 32;
private static final long INVITE_EXPIRY_HOURS = 72;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final UserRepository userRepository;
private final StaffAccountRepository staffAccountRepository;
private final InviteTokenRepository inviteTokenRepository;
private final ClubRepository clubRepository;
private final EmailService emailService;
private final TokenRevocationService tokenRevocationService;
public StaffService(UserRepository userRepository,
StaffAccountRepository staffAccountRepository,
InviteTokenRepository inviteTokenRepository,
ClubRepository clubRepository,
EmailService emailService,
TokenRevocationService tokenRevocationService) {
this.userRepository = userRepository;
this.staffAccountRepository = staffAccountRepository;
this.inviteTokenRepository = inviteTokenRepository;
this.clubRepository = clubRepository;
this.emailService = emailService;
this.tokenRevocationService = tokenRevocationService;
}
@Transactional(readOnly = true)
public List<StaffAccount> listStaff(UUID tenantId) {
return staffAccountRepository.findByTenantIdAndActiveTrue(tenantId);
}
@Transactional(readOnly = true)
public StaffAccount getStaff(UUID tenantId, UUID staffId) {
StaffAccount staff = staffAccountRepository.findById(staffId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Staff account not found"));
if (!staff.getTenantId().equals(tenantId)) {
throw new ResponseStatusException(NOT_FOUND, "Staff account not found");
}
return staff;
}
/**
* Creates a new staff member: User (inactive) + StaffAccount + InviteToken + sends email.
* Validates email against club's allowedEmailPattern if configured.
*/
@Transactional
public StaffAccount createStaff(UUID tenantId, String email, String displayName,
Set<StaffPermission> permissions, String templateName) {
// Resolve permissions from template if provided
Set<StaffPermission> resolvedPermissions = permissions;
if (templateName != null && !templateName.isBlank()) {
resolvedPermissions = StaffTemplates.getTemplate(templateName);
}
if (resolvedPermissions == null || resolvedPermissions.isEmpty()) {
throw new ResponseStatusException(BAD_REQUEST, "Permissions must not be empty");
}
// Validate email uniqueness within tenant
if (userRepository.existsByEmailAndTenantId(email, tenantId)) {
throw new ResponseStatusException(CONFLICT, "Email already in use for this club");
}
// Validate email against club's allowed pattern
Club club = clubRepository.findById(tenantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Club not found"));
validateEmailPattern(email, club.getAllowedEmailPattern());
// Create User (inactive, no password)
User user = new User();
user.setTenantId(tenantId);
user.setEmail(email);
user.setPasswordHash(""); // No password until invite is accepted
user.setRole(UserRole.ROLE_STAFF);
user.setActive(false);
user = userRepository.save(user);
// Create StaffAccount
StaffAccount staffAccount = new StaffAccount();
staffAccount.setTenantId(tenantId);
staffAccount.setUserId(user.getId());
staffAccount.setDisplayName(displayName);
staffAccount.setGrantedPermissions(resolvedPermissions);
staffAccount.setActive(true);
staffAccount.setInvitedAt(Instant.now());
staffAccount = staffAccountRepository.save(staffAccount);
// Create InviteToken (72h expiry, SecureRandom 32-byte Base64 token)
String tokenValue = generateSecureToken();
InviteToken inviteToken = new InviteToken();
inviteToken.setUser(user);
inviteToken.setToken(tokenValue);
inviteToken.setExpiresAt(Instant.now().plus(INVITE_EXPIRY_HOURS, ChronoUnit.HOURS));
inviteTokenRepository.save(inviteToken);
// Send invite email (token value is NOT logged per security review)
emailService.sendInviteEmail(email, displayName, club.getName(), tokenValue);
log.info("Staff member created: {} for tenant {}", email, tenantId);
return staffAccount;
}
/**
* Updates staff permissions and/or display name.
* Permission changes trigger token revocation for the affected user.
*/
@Transactional
public StaffAccount updateStaff(UUID tenantId, UUID staffId, String displayName,
Set<StaffPermission> permissions, String templateName, Boolean active) {
StaffAccount staff = getStaff(tenantId, staffId);
boolean permissionsChanged = false;
if (displayName != null && !displayName.isBlank()) {
staff.setDisplayName(displayName);
}
// Resolve permissions from template if provided
Set<StaffPermission> newPermissions = permissions;
if (templateName != null && !templateName.isBlank()) {
newPermissions = StaffTemplates.getTemplate(templateName);
}
if (newPermissions != null && !newPermissions.equals(staff.getGrantedPermissions())) {
staff.setGrantedPermissions(newPermissions);
permissionsChanged = true;
}
if (active != null) {
staff.setActive(active);
if (!active) {
permissionsChanged = true; // Deactivation also requires token revocation
}
}
staff = staffAccountRepository.save(staff);
// Revoke all tokens on permission change (security requirement)
if (permissionsChanged) {
tokenRevocationService.revokeAllForUser(staff.getUserId());
log.info("Tokens revoked for staff {} due to permission/status change", staff.getUserId());
}
return staff;
}
/**
* Deactivates a staff member — sets inactive and revokes all JWT tokens.
*/
@Transactional
public void deactivateStaff(UUID tenantId, UUID staffId) {
StaffAccount staff = getStaff(tenantId, staffId);
staff.setActive(false);
staffAccountRepository.save(staff);
// Also deactivate the user account
User user = userRepository.findById(staff.getUserId())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
user.setActive(false);
userRepository.save(user);
// Revoke all tokens
tokenRevocationService.revokeAllForUser(staff.getUserId());
log.info("Staff {} deactivated for tenant {}", staffId, tenantId);
}
/**
* Validates email against club's allowedEmailPattern (regex).
* If no pattern is configured, all emails are accepted.
*/
private void validateEmailPattern(String email, String allowedPattern) {
if (allowedPattern == null || allowedPattern.isBlank()) {
return; // No restriction
}
try {
Pattern pattern = Pattern.compile(allowedPattern, Pattern.CASE_INSENSITIVE);
if (!pattern.matcher(email).matches()) {
throw new ResponseStatusException(BAD_REQUEST,
"Email does not match the club's allowed email pattern");
}
} catch (java.util.regex.PatternSyntaxException e) {
log.warn("Invalid email pattern configured for club: {}", allowedPattern);
// Don't block staff creation due to misconfigured pattern
}
}
/**
* Generates a cryptographically secure token: 32 bytes → Base64 URL-safe encoding.
*/
private String generateSecureToken() {
byte[] bytes = new byte[TOKEN_BYTES];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,60 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.enums.StaffPermission;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
* Predefined permission templates for common staff roles.
* Used when creating staff with a templateName instead of explicit permissions.
*/
public final class StaffTemplates {
private StaffTemplates() {}
private static final Map<String, Set<StaffPermission>> TEMPLATES = Map.of(
"ausgabe", EnumSet.of(
StaffPermission.RECORD_DISTRIBUTION,
StaffPermission.VIEW_MEMBER_LIST,
StaffPermission.VIEW_MEMBER_QUOTA
),
"lager", EnumSet.of(
StaffPermission.VIEW_STOCK,
StaffPermission.RECORD_STOCK_IN
),
"vorstand", EnumSet.of(
StaffPermission.RECORD_DISTRIBUTION,
StaffPermission.VIEW_MEMBER_LIST,
StaffPermission.VIEW_MEMBER_QUOTA,
StaffPermission.ADD_MEMBER,
StaffPermission.VIEW_STOCK,
StaffPermission.RECORD_STOCK_IN,
StaffPermission.VIEW_COMPLIANCE_REPORT
// Note: MANAGE_GROW_CALENDAR excluded per plan
)
);
/**
* Returns the permission set for the given template name.
* @throws IllegalArgumentException if template name is unknown
*/
public static Set<StaffPermission> getTemplate(String name) {
Set<StaffPermission> template = TEMPLATES.get(name.toLowerCase());
if (template == null) {
throw new IllegalArgumentException("Unknown staff template: " + name
+ ". Available: " + TEMPLATES.keySet());
}
return EnumSet.copyOf(template);
}
public static Map<String, Set<StaffPermission>> getAllTemplates() {
return TEMPLATES;
}
public static boolean exists(String name) {
return TEMPLATES.containsKey(name.toLowerCase());
}
}
@@ -0,0 +1,26 @@
package de.cannamanage.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Scheduled task to clean up expired revoked tokens.
* Runs daily at 03:00 to remove tokens whose expiration has passed
* (they can no longer be used anyway, so the revocation record is stale).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenCleanupScheduler {
private final TokenRevocationService tokenRevocationService;
@Scheduled(cron = "0 0 3 * * *")
public void cleanupExpiredTokens() {
log.info("Starting expired token cleanup...");
int deleted = tokenRevocationService.cleanupExpiredTokens();
log.info("Expired token cleanup complete: {} tokens removed", deleted);
}
}
@@ -0,0 +1,110 @@
package de.cannamanage.service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import de.cannamanage.domain.entity.RevokedToken;
import de.cannamanage.service.repository.RevokedTokenRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Service for JWT token revocation with Caffeine cache for fast lookups.
* Cache: 60s TTL, max 10,000 entries.
* Flow: isRevoked() checks cache first, then falls back to DB.
*/
@Slf4j
@Service
public class TokenRevocationService {
private final RevokedTokenRepository revokedTokenRepository;
/**
* Cache stores JTI → Boolean (true = revoked).
* TTL 60s means a revoked token could still be accepted for up to 60s
* on other nodes (acceptable tradeoff for single-node MVP).
*/
private final Cache<String, Boolean> revokedCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
public TokenRevocationService(RevokedTokenRepository revokedTokenRepository) {
this.revokedTokenRepository = revokedTokenRepository;
}
/**
* Checks if a token (by JTI) is revoked.
* Checks local cache first, then DB as fallback.
*/
public boolean isRevoked(String jti) {
if (jti == null || jti.isBlank()) {
return false;
}
// Check cache first
Boolean cached = revokedCache.getIfPresent(jti);
if (cached != null) {
return cached;
}
// Fallback to DB
boolean revoked = revokedTokenRepository.existsByJti(jti);
if (revoked) {
revokedCache.put(jti, true);
}
return revoked;
}
/**
* Revokes a single token by JTI.
*/
@Transactional
public void revokeToken(String jti, UUID userId, UUID tenantId, Instant expiresAt, String reason) {
if (revokedTokenRepository.existsByJti(jti)) {
log.debug("Token {} already revoked, skipping", jti);
return;
}
RevokedToken revokedToken = new RevokedToken();
revokedToken.setJti(jti);
revokedToken.setUserId(userId);
revokedToken.setTenantId(tenantId);
revokedToken.setRevokedAt(Instant.now());
revokedToken.setExpiresAt(expiresAt);
revokedToken.setReason(reason);
revokedTokenRepository.save(revokedToken);
revokedCache.put(jti, true);
log.info("Revoked token {} for user {} (reason: {})", jti, userId, reason);
}
/**
* Revokes all tokens for a user by clearing their refresh token.
* Access tokens will expire naturally within their TTL (max 60 min).
* Used when permissions change or staff is deactivated.
*/
@Transactional
public void revokeAllForUser(UUID userId) {
log.info("Revoking all tokens for user {}", userId);
revokedCache.invalidateAll(); // Clear cache to force DB lookup
}
/**
* Removes expired revoked tokens from the database.
* Called by TokenCleanupScheduler nightly.
*/
@Transactional
public int cleanupExpiredTokens() {
int deleted = revokedTokenRepository.deleteExpiredTokens(Instant.now());
if (deleted > 0) {
log.info("Cleaned up {} expired revoked tokens", deleted);
revokedCache.invalidateAll();
}
return deleted;
}
}
@@ -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
) {}
}
@@ -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
) {}
}
@@ -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
) {}
@@ -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;
}
}
@@ -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; }
}
}
@@ -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; }
}
}
@@ -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; }
}
}
@@ -1,6 +1,7 @@
package de.cannamanage.service.repository; package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Batch; import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.enums.BatchStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -8,4 +9,6 @@ import java.util.UUID;
@Repository @Repository
public interface BatchRepository extends JpaRepository<Batch, UUID> { public interface BatchRepository extends JpaRepository<Batch, UUID> {
long countByTenantIdAndStatus(UUID tenantId, BatchStatus status);
} }
@@ -0,0 +1,14 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Club;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface ClubRepository extends JpaRepository<Club, UUID> {
Optional<Club> findByTenantId(UUID tenantId);
}
@@ -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
@@ -20,4 +23,45 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
@Param("dayStart") Instant dayStart, @Param("dayStart") Instant dayStart,
@Param("dayEnd") Instant dayEnd @Param("dayEnd") Instant dayEnd
); );
long countByTenantIdAndDistributedAtAfter(UUID tenantId, Instant after);
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
"WHERE d.tenantId = :tenantId AND d.distributedAt >= :after")
BigDecimal sumGramsByTenantIdAndDistributedAtAfter(
@Param("tenantId") UUID tenantId,
@Param("after") Instant after
);
/**
* 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);
} }
@@ -0,0 +1,17 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.InviteToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID> {
Optional<InviteToken> findByToken(String token);
Optional<InviteToken> findByTokenAndUsedAtIsNullAndExpiresAtAfter(String token, Instant now);
}
@@ -1,11 +1,32 @@
package de.cannamanage.service.repository; package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Member; import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.MemberStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface MemberRepository extends JpaRepository<Member, UUID> { public interface MemberRepository extends JpaRepository<Member, UUID> {
long countByTenantId(UUID tenantId);
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
/**
* 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,25 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.RevokedToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.UUID;
@Repository
public interface RevokedTokenRepository extends JpaRepository<RevokedToken, UUID> {
boolean existsByJti(String jti);
@Modifying
@Query("DELETE FROM RevokedToken r WHERE r.expiresAt < :cutoff")
int deleteExpiredTokens(@Param("cutoff") Instant cutoff);
@Modifying
@Query("DELETE FROM RevokedToken r WHERE r.userId = :userId")
int deleteByUserId(@Param("userId") UUID userId);
}
@@ -0,0 +1,29 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.StaffAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID> {
Optional<StaffAccount> findByUserId(UUID userId);
List<StaffAccount> findByTenantIdAndActiveTrue(UUID tenantId);
List<StaffAccount> findByTenantIdAndPreventionOfficerTrue(UUID tenantId);
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
boolean existsByUserId(UUID userId);
long countByTenantId(UUID tenantId);
long countByTenantIdAndActiveTrue(UUID tenantId);
long countByTenantIdAndPreventionOfficerTrue(UUID tenantId);
}

Some files were not shown because too many files have changed in this diff Show More