Compare commits

..

12 Commits

Author SHA1 Message Date
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
Patrick Plate fa1eaf64e0 feat(sprint-1): CannaManage foundation — compliance engine, JPA entities, tests TC-001→TC-025
- Maven multi-module project (parent + domain + service + api)
- AbstractTenantEntity with Hibernate @Filter for multi-tenancy (explicit getters/setters, Java 25 compatible)
- TenantContext ThreadLocal for request-scoped tenant isolation
- 8 JPA entities: Club, Member, Strain, Batch, Distribution, MonthlyQuota, StockMovement, User
- ComplianceConstants with CanG §19 limits (25g/day adult, 50g/month adult, 30g/month under-21, 10% THC cap)
- ComplianceService: checkDistributionAllowed() with fail-fast sequential CanG checks
- Unit tests TC-001→TC-025: 25/25 passing, 100% line+branch coverage on ComplianceService (JaCoCo 0.8.13)
- Flyway V1__initial_schema.sql: all 8 tables + indexes
- docker-compose.yml: PostgreSQL 16 local dev
- application-local.properties: local profile configuration

Closes #1 #2 #3 #4 #5 #6 #7 #8 #9 #10
2026-04-12 20:30:12 +02:00
114 changed files with 9850 additions and 2 deletions
+9
View File
@@ -0,0 +1,9 @@
target/
*.class
*.jar
*.war
.idea/
*.iml
.DS_Store
*.swp
.mvn/wrapper/maven-wrapper.jar
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
+110 -2
View File
@@ -1,3 +1,111 @@
# cannamanage # CannaManage
CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen) 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
+139
View File
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cannamanage-api</artifactId>
<name>CannaManage — API (Spring Boot Entry Point)</name>
<dependencies>
<dependency>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-domain</artifactId>
</dependency>
<dependency>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-service</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</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>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,25 @@
package de.cannamanage.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* CannaManage Spring Boot application entry point.
* Sprint 2: REST API + Spring Security + OpenAPI.
*
* 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")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
public class CannaManageApplication {
public static void main(String[] args) {
SpringApplication.run(CannaManageApplication.class, 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,110 @@
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.domain.entity.Member;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MemberStatus;
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.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;
@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));
}
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,112 @@
package de.cannamanage.api.controller;
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.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 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();
}
@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,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,49 @@
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,
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.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.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,131 @@
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.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(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,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,73 @@
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;
/**
* Security configuration — Sprint 3: API + Staff portal with JWT.
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
/**
* 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();
}
/**
* Public endpoints — Swagger UI, actuator health.
*/
@Bean
@Order(2)
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);
}
}
}
@@ -0,0 +1,8 @@
spring.datasource.url=jdbc:postgresql://localhost:5432/cannamanage
spring.datasource.username=cannamanage
spring.datasource.password=dev_password_change_in_prod
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
logging.level.de.cannamanage=DEBUG
logging.level.org.flywaydb=INFO
@@ -0,0 +1,32 @@
spring.application.name=cannamanage
# Default profile — override with -Dspring.profiles.active=local
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
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}
@@ -0,0 +1,120 @@
-- CannaManage V1 Initial Schema
-- Implements all tables required for CanG §19 compliance tracking
-- Clubs (root of tenant hierarchy)
CREATE TABLE clubs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
address TEXT,
license_number VARCHAR(100) NOT NULL UNIQUE,
max_members INT NOT NULL DEFAULT 500,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Members
CREATE TABLE members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
date_of_birth DATE NOT NULL,
membership_date DATE NOT NULL DEFAULT CURRENT_DATE,
membership_number VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
is_under_21 BOOLEAN NOT NULL DEFAULT FALSE,
prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(email, tenant_id),
UNIQUE(membership_number, tenant_id)
);
-- Strains
CREATE TABLE strains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
thc_percentage NUMERIC(5,2) NOT NULL,
cbd_percentage NUMERIC(5,2) NOT NULL DEFAULT 0.00,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Batches
CREATE TABLE batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
strain_id UUID NOT NULL REFERENCES strains(id),
quantity_grams NUMERIC(10,2) NOT NULL,
harvest_date DATE,
batch_code VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'AVAILABLE',
contamination_flag BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(batch_code, tenant_id)
);
-- Distributions (immutable — append-only for CanG §26 compliance)
CREATE TABLE distributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
batch_id UUID NOT NULL REFERENCES batches(id),
quantity_grams NUMERIC(10,2) NOT NULL,
distributed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
recorded_by UUID NOT NULL REFERENCES members(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Monthly quotas (one row per member per calendar month)
CREATE TABLE monthly_quotas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
year INT NOT NULL,
month INT NOT NULL CHECK (month >= 1 AND month <= 12),
total_distributed NUMERIC(10,2) NOT NULL DEFAULT 0.00,
max_allowed NUMERIC(10,2) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(member_id, year, month)
);
-- Stock movements (audit journal)
CREATE TABLE stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
batch_id UUID NOT NULL REFERENCES batches(id),
movement_type VARCHAR(50) NOT NULL,
quantity_grams NUMERIC(10,2) NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users (login identities — Sprint 2 auth)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID REFERENCES members(id),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'ROLE_MEMBER',
last_login TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT TRUE,
refresh_token_hash VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(email, tenant_id)
);
-- Performance indexes
CREATE INDEX idx_members_club_id ON members(club_id);
CREATE INDEX idx_members_tenant_id ON members(tenant_id);
CREATE INDEX idx_distributions_member_id ON distributions(member_id);
CREATE INDEX idx_distributions_tenant_id ON distributions(tenant_id);
CREATE INDEX idx_distributions_distributed_at ON distributions(distributed_at);
CREATE INDEX idx_monthly_quotas_member_month ON monthly_quotas(member_id, year, month);
CREATE INDEX idx_batches_tenant_status ON batches(tenant_id, status);
@@ -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,166 @@
package de.cannamanage.api.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.cannamanage.api.dto.staff.CreateStaffRequest;
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
import de.cannamanage.api.security.JwtAuthFilter;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.StaffService;
import de.cannamanage.service.TokenRevocationService;
import de.cannamanage.service.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(StaffController.class)
class StaffControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private StaffService staffService;
@MockBean private UserRepository userRepository;
@MockBean private JwtService jwtService;
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean private TokenRevocationService tokenRevocationService;
private UUID tenantId;
private UUID staffId;
private UUID userId;
@BeforeEach
void setUp() {
tenantId = UUID.randomUUID();
staffId = UUID.randomUUID();
userId = UUID.randomUUID();
TenantContext.setCurrentTenant(tenantId);
}
@Test
@WithMockUser(roles = "ADMIN")
void listStaff_returnsStaffList() throws Exception {
StaffAccount staff = createStaffAccount();
User user = createUser();
when(staffService.listStaff(tenantId)).thenReturn(List.of(staff));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/staff"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].displayName").value("Test Staff"))
.andExpect(jsonPath("$[0].email").value("staff@test.de"));
}
@Test
@WithMockUser(roles = "ADMIN")
void createStaff_validRequest_returns201() throws Exception {
CreateStaffRequest request = new CreateStaffRequest(
"new@test.de", "New Staff",
EnumSet.of(StaffPermission.VIEW_STOCK), null);
StaffAccount created = createStaffAccount();
when(staffService.createStaff(eq(tenantId), eq("new@test.de"), eq("New Staff"), any(), any()))
.thenReturn(created);
mockMvc.perform(post("/api/v1/staff")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.displayName").value("Test Staff"));
}
@Test
@WithMockUser(roles = "ADMIN")
void createStaff_invalidEmail_returns400() throws Exception {
CreateStaffRequest request = new CreateStaffRequest(
"not-an-email", "Bad Staff",
EnumSet.of(StaffPermission.VIEW_STOCK), null);
mockMvc.perform(post("/api/v1/staff")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(roles = "ADMIN")
void getStaff_returns200() throws Exception {
StaffAccount staff = createStaffAccount();
User user = createUser();
when(staffService.getStaff(tenantId, staffId)).thenReturn(staff);
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/staff/{id}", staffId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(staffId.toString()));
}
@Test
@WithMockUser(roles = "ADMIN")
void deactivateStaff_returns204() throws Exception {
mockMvc.perform(delete("/api/v1/staff/{id}", staffId).with(csrf()))
.andExpect(status().isNoContent());
verify(staffService).deactivateStaff(tenantId, staffId);
}
@Test
@WithMockUser(roles = "ADMIN")
void listTemplates_returnsTemplateMap() throws Exception {
mockMvc.perform(get("/api/v1/staff/templates"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ausgabe").isArray())
.andExpect(jsonPath("$.lager").isArray())
.andExpect(jsonPath("$.vorstand").isArray());
}
@Test
@WithMockUser(roles = "MEMBER")
void listStaff_asMember_returns403() throws Exception {
mockMvc.perform(get("/api/v1/staff"))
.andExpect(status().isForbidden());
}
private StaffAccount createStaffAccount() {
StaffAccount staff = new StaffAccount();
staff.setId(staffId);
staff.setTenantId(tenantId);
staff.setUserId(userId);
staff.setDisplayName("Test Staff");
staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK));
staff.setActive(true);
staff.setCreatedAt(Instant.now());
return staff;
}
private User createUser() {
User user = new User();
user.setId(userId);
user.setEmail("staff@test.de");
user.setRole(UserRole.ROLE_STAFF);
user.setActive(true);
return user;
}
}
@@ -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,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
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cannamanage-domain</artifactId>
<name>CannaManage — Domain (JPA Entities)</name>
<dependencies>
<!-- JPA API only — implementation provided by Spring Boot in runtime -->
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<!-- Hibernate core for @FilterDef, @Filter -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</dependency>
<!-- Validation API -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<!-- Lombok for boilerplate reduction -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,33 @@
package de.cannamanage.domain.constants;
import java.math.BigDecimal;
/**
* CanG (Cannabisgesetz) compliance limits.
* All limits are defined as per §19 CanG (Cannabisgesetz) for social cannabis clubs.
* These are immutable constants — changes require legal review.
*/
public final class ComplianceConstants {
// CanG §19(2) — adult daily distribution limit
public static final BigDecimal ADULT_DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
// CanG §19(2) — adult monthly distribution limit
public static final BigDecimal ADULT_MONTHLY_LIMIT_GRAMS = new BigDecimal("50.0");
// CanG §19(3) — under-21 monthly distribution limit
public static final BigDecimal UNDER21_MONTHLY_LIMIT_GRAMS = new BigDecimal("30.0");
// CanG §19(4) — maximum THC percentage allowed for under-21 members
public static final BigDecimal UNDER21_MAX_THC_PERCENTAGE = new BigDecimal("10.0");
// Minimum age for club membership (§5 CanG)
public static final int MINIMUM_MEMBERSHIP_AGE = 18;
// Age threshold below which stricter limits apply
public static final int UNDER21_THRESHOLD_AGE = 21;
private ComplianceConstants() {
// Utility class — do not instantiate
}
}
@@ -0,0 +1,49 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import java.time.Instant;
import java.util.UUID;
/**
* Base class for all tenant-scoped entities.
* Applies a Hibernate @Filter to ensure all queries automatically scope to the current tenant.
* The tenantId is set automatically from TenantContext on persist and cannot be updated.
*/
@MappedSuperclass
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = UUID.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class AbstractTenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.tenantId = TenantContext.getCurrentTenant();
this.createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
@@ -0,0 +1,54 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.BatchStatus;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "batches",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"batch_code", "tenant_id"})
}
)
public class Batch extends AbstractTenantEntity {
@Column(name = "strain_id", nullable = false)
private UUID strainId;
@Column(name = "quantity_grams", nullable = false, precision = 10, scale = 2)
private BigDecimal quantityGrams;
@Column(name = "harvest_date")
private LocalDate harvestDate;
@Column(name = "batch_code", nullable = false, length = 100)
private String batchCode;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private BatchStatus status = BatchStatus.AVAILABLE;
@Column(name = "contamination_flag", nullable = false)
private boolean contaminationFlag = false;
public UUID getStrainId() { return strainId; }
public void setStrainId(UUID strainId) { this.strainId = strainId; }
public BigDecimal getQuantityGrams() { return quantityGrams; }
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
public LocalDate getHarvestDate() { return harvestDate; }
public void setHarvestDate(LocalDate harvestDate) { this.harvestDate = harvestDate; }
public String getBatchCode() { return batchCode; }
public void setBatchCode(String batchCode) { this.batchCode = batchCode; }
public BatchStatus getStatus() { return status; }
public void setStatus(BatchStatus status) { this.status = status; }
public boolean isContaminationFlag() { return contaminationFlag; }
public void setContaminationFlag(boolean contaminationFlag) { this.contaminationFlag = contaminationFlag; }
}
@@ -0,0 +1,102 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ClubStatus;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "clubs")
public class Club extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 255)
private String name;
@Column(name = "registration_number", length = 100)
private String registrationNumber;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "contact_phone", length = 50)
private String contactPhone;
@Column(name = "address_street", length = 255)
private String addressStreet;
@Column(name = "address_city", length = 100)
private String addressCity;
@Column(name = "address_postal_code", length = 20)
private String addressPostalCode;
@Column(name = "address_state", length = 100)
private String addressState;
@Column(name = "founded_date")
private LocalDate foundedDate;
@Column(name = "address")
private String address;
@Column(name = "license_number", nullable = false, unique = true, length = 100)
private String licenseNumber;
@Column(name = "max_members", nullable = false)
private Integer maxMembers = 500;
@Column(name = "max_prevention_officers", nullable = false)
private Integer maxPreventionOfficers = 2;
@Column(name = "allowed_email_pattern", length = 255)
private String allowedEmailPattern;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private ClubStatus status = ClubStatus.ACTIVE;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getRegistrationNumber() { return registrationNumber; }
public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; }
public String getContactEmail() { return contactEmail; }
public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getAddressStreet() { return addressStreet; }
public void setAddressStreet(String addressStreet) { this.addressStreet = addressStreet; }
public String getAddressCity() { return addressCity; }
public void setAddressCity(String addressCity) { this.addressCity = addressCity; }
public String getAddressPostalCode() { return addressPostalCode; }
public void setAddressPostalCode(String addressPostalCode) { this.addressPostalCode = addressPostalCode; }
public String getAddressState() { return addressState; }
public void setAddressState(String addressState) { this.addressState = addressState; }
public LocalDate getFoundedDate() { return foundedDate; }
public void setFoundedDate(LocalDate foundedDate) { this.foundedDate = foundedDate; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getLicenseNumber() { return licenseNumber; }
public void setLicenseNumber(String licenseNumber) { this.licenseNumber = licenseNumber; }
public Integer getMaxMembers() { return maxMembers; }
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
public Integer getMaxPreventionOfficers() { return maxPreventionOfficers; }
public void setMaxPreventionOfficers(Integer maxPreventionOfficers) { this.maxPreventionOfficers = maxPreventionOfficers; }
public String getAllowedEmailPattern() { return allowedEmailPattern; }
public void setAllowedEmailPattern(String allowedEmailPattern) { this.allowedEmailPattern = allowedEmailPattern; }
public ClubStatus getStatus() { return status; }
public void setStatus(ClubStatus status) { this.status = status; }
}
@@ -0,0 +1,52 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Immutable distribution record — all fields are updatable=false.
* Required for CanG §26 record-keeping obligations.
*/
@Entity
@Table(name = "distributions")
public class Distribution extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false, updatable = false)
private UUID memberId;
@Column(name = "batch_id", nullable = false, updatable = false)
private UUID batchId;
@Column(name = "quantity_grams", nullable = false, updatable = false, precision = 10, scale = 2)
private BigDecimal quantityGrams;
@Column(name = "distributed_at", nullable = false, updatable = false)
private Instant distributedAt;
@Column(name = "recorded_by", nullable = false, updatable = false)
private UUID recordedBy;
@Column(name = "notes", updatable = false)
private String notes;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public BigDecimal getQuantityGrams() { return quantityGrams; }
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
public Instant getDistributedAt() { return distributedAt; }
public void setDistributedAt(Instant distributedAt) { this.distributedAt = distributedAt; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
}
@@ -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,78 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.MemberStatus;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "members",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"email", "tenant_id"}),
@UniqueConstraint(columnNames = {"membership_number", "tenant_id"})
}
)
public class Member extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "first_name", nullable = false, length = 100)
private String firstName;
@Column(name = "last_name", nullable = false, length = 100)
private String lastName;
@Column(name = "email", nullable = false, length = 255)
private String email;
@Column(name = "date_of_birth", nullable = false)
private LocalDate dateOfBirth;
@Column(name = "membership_date", nullable = false)
private LocalDate membershipDate;
@Column(name = "membership_number", nullable = false, length = 50)
private String membershipNumber;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private MemberStatus status = MemberStatus.ACTIVE;
@Column(name = "is_under_21", nullable = false)
private boolean under21 = false;
@Column(name = "prevention_officer", nullable = false)
private boolean preventionOfficer = false;
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
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 getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDate getDateOfBirth() { return dateOfBirth; }
public void setDateOfBirth(LocalDate dateOfBirth) { this.dateOfBirth = dateOfBirth; }
public LocalDate getMembershipDate() { return membershipDate; }
public void setMembershipDate(LocalDate membershipDate) { this.membershipDate = membershipDate; }
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 boolean isUnder21() { return under21; }
public void setUnder21(boolean under21) { this.under21 = under21; }
public boolean isPreventionOfficer() { return preventionOfficer; }
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
}
@@ -0,0 +1,57 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Tracks monthly distribution totals per member per calendar month.
* One row per (member_id, year, month) — unique constraint enforced at DB level.
* @Version for optimistic locking — concurrent distribution processing must not corrupt totals.
*/
@Entity
@Table(name = "monthly_quotas",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"member_id", "year", "month"})
}
)
public class MonthlyQuota extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "year", nullable = false)
private Integer year;
@Column(name = "month", nullable = false)
private Integer month;
@Column(name = "total_distributed", nullable = false, precision = 10, scale = 2)
private BigDecimal totalDistributed = BigDecimal.ZERO;
@Column(name = "max_allowed", nullable = false, precision = 10, scale = 2)
private BigDecimal maxAllowed;
@Version
@Column(name = "version", nullable = false)
private Long version = 0L;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public Integer getYear() { return year; }
public void setYear(Integer year) { this.year = year; }
public Integer getMonth() { return month; }
public void setMonth(Integer month) { this.month = month; }
public BigDecimal getTotalDistributed() { return totalDistributed; }
public void setTotalDistributed(BigDecimal totalDistributed) { this.totalDistributed = totalDistributed; }
public BigDecimal getMaxAllowed() { return maxAllowed; }
public void setMaxAllowed(BigDecimal maxAllowed) { this.maxAllowed = maxAllowed; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
}
@@ -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,37 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.StockMovementType;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.UUID;
@Entity
@Table(name = "stock_movements")
public class StockMovement extends AbstractTenantEntity {
@Column(name = "batch_id", nullable = false)
private UUID batchId;
@Enumerated(EnumType.STRING)
@Column(name = "movement_type", nullable = false, length = 50)
private StockMovementType movementType;
@Column(name = "quantity_grams", nullable = false, precision = 10, scale = 2)
private BigDecimal quantityGrams;
@Column(name = "reason")
private String reason;
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public StockMovementType getMovementType() { return movementType; }
public void setMovementType(StockMovementType movementType) { this.movementType = movementType; }
public BigDecimal getQuantityGrams() { return quantityGrams; }
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
}
@@ -0,0 +1,34 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "strains")
public class Strain extends AbstractTenantEntity {
@Column(name = "name", nullable = false, length = 255)
private String name;
@Column(name = "thc_percentage", nullable = false, precision = 5, scale = 2)
private BigDecimal thcPercentage;
@Column(name = "cbd_percentage", nullable = false, precision = 5, scale = 2)
private BigDecimal cbdPercentage = BigDecimal.ZERO;
@Column(name = "description")
private String description;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getThcPercentage() { return thcPercentage; }
public void setThcPercentage(BigDecimal thcPercentage) { this.thcPercentage = thcPercentage; }
public BigDecimal getCbdPercentage() { return cbdPercentage; }
public void setCbdPercentage(BigDecimal cbdPercentage) { this.cbdPercentage = cbdPercentage; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
@@ -0,0 +1,27 @@
package de.cannamanage.domain.entity;
import java.util.UUID;
/**
* ThreadLocal holder for the current tenant's UUID.
* Must be set at the start of each request (e.g., via a servlet filter or Spring interceptor)
* and cleared at the end to prevent tenant leakage.
*/
public final class TenantContext {
private static final ThreadLocal<UUID> CURRENT_TENANT = new ThreadLocal<>();
private TenantContext() {}
public static UUID getCurrentTenant() {
return CURRENT_TENANT.get();
}
public static void setCurrentTenant(UUID tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
@@ -0,0 +1,59 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.UserRole;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"email", "tenant_id"})
}
)
public class User extends AbstractTenantEntity {
@Column(name = "member_id")
private UUID memberId;
@Column(name = "email", nullable = false, length = 255)
private String email;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 50)
private UserRole role = UserRole.ROLE_MEMBER;
@Column(name = "last_login")
private Instant lastLogin;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash;
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public UserRole getRole() { return role; }
public void setRole(UserRole role) { this.role = role; }
public Instant getLastLogin() { return lastLogin; }
public void setLastLogin(Instant lastLogin) { this.lastLogin = lastLogin; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public String getRefreshTokenHash() { return refreshTokenHash; }
public void setRefreshTokenHash(String refreshTokenHash) { this.refreshTokenHash = refreshTokenHash; }
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum BatchStatus {
AVAILABLE,
EXHAUSTED,
RECALLED,
QUARANTINED
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum ClubStatus {
ACTIVE,
SUSPENDED,
REVOKED,
PENDING
}
@@ -0,0 +1,9 @@
package de.cannamanage.domain.enums;
public enum MemberStatus {
ACTIVE,
SUSPENDED,
EXPELLED,
PENDING_APPROVAL,
RESIGNED
}
@@ -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
}
@@ -0,0 +1,8 @@
package de.cannamanage.domain.enums;
public enum StockMovementType {
IN,
OUT,
RECALL,
ADJUSTMENT
}
@@ -0,0 +1,12 @@
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 {
ROLE_ADMIN,
ROLE_STAFF,
ROLE_MEMBER
}
+135
View File
@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cannamanage-service</artifactId>
<name>CannaManage — Service (Business Logic)</name>
<dependencies>
<!-- Internal domain -->
<dependency>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-domain</artifactId>
</dependency>
<!-- Spring Data JPA for repository interfaces -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring TX annotations -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</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.11.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>CLASS</element>
<includes>
<include>de.cannamanage.service.ComplianceService</include>
</includes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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,210 @@
package de.cannamanage.service;
import de.cannamanage.domain.constants.ComplianceConstants;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.MonthlyQuota;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.dto.ComplianceCheckResult;
import de.cannamanage.service.dto.QuotaStatus;
import de.cannamanage.service.exception.BatchNotFoundException;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.exception.QuotaExceededException;
import de.cannamanage.service.exception.QuotaViolationCode;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.MonthlyQuotaRepository;
import de.cannamanage.service.repository.StrainRepository;
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.UUID;
/**
* Core compliance engine implementing CanG §19 distribution rules.
* All checks are fail-fast — the first violation throws immediately.
*/
@Service
@Transactional
public class ComplianceService {
private final MemberRepository memberRepository;
private final DistributionRepository distributionRepository;
private final BatchRepository batchRepository;
private final MonthlyQuotaRepository monthlyQuotaRepository;
private final StrainRepository strainRepository;
public ComplianceService(
MemberRepository memberRepository,
DistributionRepository distributionRepository,
BatchRepository batchRepository,
MonthlyQuotaRepository monthlyQuotaRepository,
StrainRepository strainRepository) {
this.memberRepository = memberRepository;
this.distributionRepository = distributionRepository;
this.batchRepository = batchRepository;
this.monthlyQuotaRepository = monthlyQuotaRepository;
this.strainRepository = strainRepository;
}
/**
* Checks whether a distribution is permitted under CanG §19.
* Sequential fail-fast checks in legally mandated order.
*/
public ComplianceCheckResult checkDistributionAllowed(
UUID memberId, UUID batchId, BigDecimal quantityGrams) {
// 1. Load member
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(memberId));
// 2. Member must be ACTIVE
if (member.getStatus() != MemberStatus.ACTIVE) {
throw new QuotaExceededException(
QuotaViolationCode.MEMBER_INACTIVE,
"Member " + memberId + " is not active (status=" + member.getStatus() + ")");
}
// 3. Batch must be AVAILABLE
Batch batch = batchRepository.findById(batchId)
.orElseThrow(() -> new BatchNotFoundException(batchId));
if (batch.getStatus() != BatchStatus.AVAILABLE) {
throw new QuotaExceededException(
QuotaViolationCode.BATCH_UNAVAILABLE,
"Batch " + batchId + " is not available (status=" + batch.getStatus() + ")");
}
// 4. Load strain
Strain strain = strainRepository.findById(batch.getStrainId())
.orElseThrow(() -> new BatchNotFoundException(batch.getStrainId()));
// 5. Under-21 THC restriction (CanG §19(4))
if (member.isUnder21() &&
strain.getThcPercentage().compareTo(ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE) > 0) {
throw new QuotaExceededException(
QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21,
"Under-21 member cannot receive strain with THC% > " +
ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE);
}
// 6. Daily limit check (CanG §19(2))
LocalDate today = LocalDate.now(ZoneOffset.UTC);
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
BigDecimal todayDistributed = distributionRepository
.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS;
if (todayDistributed.add(quantityGrams).compareTo(dailyLimit) > 0) {
throw new QuotaExceededException(
QuotaViolationCode.QUOTA_EXCEEDED_DAILY,
"Daily limit of " + dailyLimit + "g would be exceeded. " +
"Already distributed today: " + todayDistributed + "g");
}
// 7. Monthly limit check (CanG §19(2)/(3))
int year = today.getYear();
int month = today.getMonthValue();
BigDecimal monthlyLimit = member.isUnder21()
? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
: ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
MonthlyQuota quota = monthlyQuotaRepository
.findByMemberIdAndYearAndMonth(memberId, year, month)
.orElseGet(() -> createNewQuota(memberId, year, month, monthlyLimit));
if (quota.getTotalDistributed().add(quantityGrams).compareTo(quota.getMaxAllowed()) > 0) {
throw new QuotaExceededException(
QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY,
"Monthly limit of " + quota.getMaxAllowed() + "g would be exceeded. " +
"Already distributed this month: " + quota.getTotalDistributed() + "g");
}
// 8. All checks passed
BigDecimal remainingDaily = dailyLimit
.subtract(todayDistributed)
.subtract(quantityGrams);
BigDecimal remainingMonthly = quota.getMaxAllowed()
.subtract(quota.getTotalDistributed())
.subtract(quantityGrams);
return new ComplianceCheckResult(true, remainingDaily, remainingMonthly, member.isUnder21());
}
@Transactional(readOnly = true)
public QuotaStatus getQuotaStatus(UUID memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(memberId));
LocalDate today = LocalDate.now(ZoneOffset.UTC);
int year = today.getYear();
int month = today.getMonthValue();
BigDecimal monthlyLimit = member.isUnder21()
? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
: ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
MonthlyQuota quota = monthlyQuotaRepository
.findByMemberIdAndYearAndMonth(memberId, year, month)
.orElseGet(() -> {
MonthlyQuota empty = new MonthlyQuota();
empty.setTotalDistributed(BigDecimal.ZERO);
empty.setMaxAllowed(monthlyLimit);
return empty;
});
BigDecimal remaining = quota.getMaxAllowed().subtract(quota.getTotalDistributed());
return new QuotaStatus(
quota.getMaxAllowed(),
quota.getTotalDistributed(),
remaining,
member.isUnder21(),
year,
month
);
}
public void validateMembershipAge(LocalDate dateOfBirth) {
LocalDate today = LocalDate.now(ZoneOffset.UTC);
int age = today.getYear() - dateOfBirth.getYear();
if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) {
age--;
}
if (age < ComplianceConstants.MINIMUM_MEMBERSHIP_AGE) {
throw new QuotaExceededException(
QuotaViolationCode.MEMBER_INACTIVE,
"Applicant is under minimum membership age of " +
ComplianceConstants.MINIMUM_MEMBERSHIP_AGE);
}
}
public boolean isUnder21(LocalDate dateOfBirth) {
LocalDate today = LocalDate.now(ZoneOffset.UTC);
int age = today.getYear() - dateOfBirth.getYear();
if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) {
age--;
}
return age < ComplianceConstants.UNDER21_THRESHOLD_AGE;
}
private MonthlyQuota createNewQuota(UUID memberId, int year, int month, BigDecimal maxAllowed) {
MonthlyQuota quota = new MonthlyQuota();
quota.setMemberId(memberId);
quota.setYear(year);
quota.setMonth(month);
quota.setTotalDistributed(BigDecimal.ZERO);
quota.setMaxAllowed(maxAllowed);
return monthlyQuotaRepository.save(quota);
}
}
@@ -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,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,15 @@
package de.cannamanage.service.dto;
import java.math.BigDecimal;
/**
* Result of a compliance check for a potential cannabis distribution.
* When {@code allowed} is true, {@code remainingDaily} and {@code remainingMonthly}
* reflect the quota after the requested distribution would be applied.
*/
public record ComplianceCheckResult(
boolean allowed,
BigDecimal remainingDaily,
BigDecimal remainingMonthly,
boolean isUnder21
) {}
@@ -0,0 +1,15 @@
package de.cannamanage.service.dto;
import java.math.BigDecimal;
/**
* Current quota status for a member in a given calendar month.
*/
public record QuotaStatus(
BigDecimal totalAllowed,
BigDecimal totalUsed,
BigDecimal remaining,
boolean isUnder21,
int year,
int month
) {}
@@ -0,0 +1,10 @@
package de.cannamanage.service.exception;
import java.util.UUID;
public class BatchNotFoundException extends RuntimeException {
public BatchNotFoundException(UUID batchId) {
super("Batch not found: " + batchId);
}
}
@@ -0,0 +1,10 @@
package de.cannamanage.service.exception;
import java.util.UUID;
public class MemberNotFoundException extends RuntimeException {
public MemberNotFoundException(UUID memberId) {
super("Member not found: " + memberId);
}
}
@@ -0,0 +1,20 @@
package de.cannamanage.service.exception;
/**
* Thrown when a cannabis distribution would violate a CanG compliance rule.
* The {@code code} field identifies which specific rule was violated,
* enabling the API layer to return structured error responses.
*/
public class QuotaExceededException extends RuntimeException {
private final QuotaViolationCode code;
public QuotaExceededException(QuotaViolationCode code, String message) {
super(message);
this.code = code;
}
public QuotaViolationCode getCode() {
return code;
}
}
@@ -0,0 +1,9 @@
package de.cannamanage.service.exception;
public enum QuotaViolationCode {
MEMBER_INACTIVE,
QUOTA_EXCEEDED_DAILY,
QUOTA_EXCEEDED_MONTHLY,
HIGH_THC_RESTRICTED_UNDER_21,
BATCH_UNAVAILABLE
}
@@ -0,0 +1,14 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.enums.BatchStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
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);
}
@@ -0,0 +1,32 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Distribution;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Repository
public interface DistributionRepository extends JpaRepository<Distribution, UUID> {
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
"WHERE d.memberId = :memberId AND d.distributedAt >= :dayStart AND d.distributedAt < :dayEnd")
BigDecimal sumQuantityByMemberAndDay(
@Param("memberId") UUID memberId,
@Param("dayStart") Instant dayStart,
@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
);
}
@@ -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);
}
@@ -0,0 +1,16 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.MemberStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface MemberRepository extends JpaRepository<Member, UUID> {
long countByTenantId(UUID tenantId);
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
}
@@ -0,0 +1,14 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.MonthlyQuota;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface MonthlyQuotaRepository extends JpaRepository<MonthlyQuota, UUID> {
Optional<MonthlyQuota> findByMemberIdAndYearAndMonth(UUID memberId, int year, int month);
}
@@ -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);
}
@@ -0,0 +1,11 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Strain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface StrainRepository extends JpaRepository<Strain, UUID> {
}
@@ -0,0 +1,18 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmailAndTenantId(String email, UUID tenantId);
Optional<User> findByEmail(String email);
boolean existsByEmailAndTenantId(String email, UUID tenantId);
}
@@ -0,0 +1,185 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.ClubStatus;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ClubServiceTest {
@Mock
private ClubRepository clubRepository;
@Mock
private MemberRepository memberRepository;
@Mock
private StaffAccountRepository staffAccountRepository;
@Mock
private DistributionRepository distributionRepository;
@Mock
private BatchRepository batchRepository;
@InjectMocks
private ClubService clubService;
private UUID tenantId;
private Club club;
@BeforeEach
void setUp() {
tenantId = UUID.randomUUID();
club = new Club();
club.setId(UUID.randomUUID());
club.setTenantId(tenantId);
club.setName("Test Club");
club.setLicenseNumber("LIC-001");
club.setMaxPreventionOfficers(2);
club.setStatus(ClubStatus.ACTIVE);
}
@Test
void getClubByTenantId_found() {
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
Club result = clubService.getClubByTenantId(tenantId);
assertThat(result).isEqualTo(club);
verify(clubRepository).findByTenantId(tenantId);
}
@Test
void getClubByTenantId_notFound_throws404() {
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> clubService.getClubByTenantId(tenantId))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Club not found for tenant");
}
@Test
void updateClub_success() {
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
when(clubRepository.save(any(Club.class))).thenReturn(club);
Club result = clubService.updateClub(
tenantId, "Updated Club", "REG-123",
"info@club.de", "+49123456",
"Mainstreet 1", "Berlin", "10115", "Berlin",
LocalDate.of(2024, 1, 15), 3, ".*@club\\.de"
);
assertThat(result.getName()).isEqualTo("Updated Club");
assertThat(result.getRegistrationNumber()).isEqualTo("REG-123");
assertThat(result.getContactEmail()).isEqualTo("info@club.de");
assertThat(result.getMaxPreventionOfficers()).isEqualTo(3);
assertThat(result.getAllowedEmailPattern()).isEqualTo(".*@club\\.de");
verify(clubRepository).save(club);
}
@Test
void updateClub_invalidRegex_throwsBadRequest() {
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
assertThatThrownBy(() -> clubService.updateClub(
tenantId, "Club", null, null, null,
null, null, null, null, null, null, "[invalid"
))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Invalid regex pattern");
}
@Test
void updateClub_nullPattern_accepted() {
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
when(clubRepository.save(any(Club.class))).thenReturn(club);
Club result = clubService.updateClub(
tenantId, "Club", null, null, null,
null, null, null, null, null, null, null
);
assertThat(result).isNotNull();
verify(clubRepository).save(club);
}
@Test
void updateClub_blankPattern_accepted() {
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
when(clubRepository.save(any(Club.class))).thenReturn(club);
Club result = clubService.updateClub(
tenantId, "Club", null, null, null,
null, null, null, null, null, null, " "
);
assertThat(result).isNotNull();
verify(clubRepository).save(club);
}
@Test
void getClubStats_returnsAggregatedStats() {
when(memberRepository.countByTenantId(tenantId)).thenReturn(50L);
when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(42L);
when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(5L);
when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(4L);
when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
.thenReturn(120L);
when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
.thenReturn(new BigDecimal("1500.50"));
when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(8L);
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(2L);
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
assertThat(stats.totalMembers()).isEqualTo(50L);
assertThat(stats.activeMembers()).isEqualTo(42L);
assertThat(stats.totalStaff()).isEqualTo(5L);
assertThat(stats.activeStaff()).isEqualTo(4L);
assertThat(stats.totalDistributionsThisMonth()).isEqualTo(120L);
assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo("1500.50");
assertThat(stats.activeBatches()).isEqualTo(8L);
assertThat(stats.preventionOfficerCount()).isEqualTo(2L);
}
@Test
void getClubStats_nullGrams_returnsZero() {
when(memberRepository.countByTenantId(tenantId)).thenReturn(0L);
when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(0L);
when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(0L);
when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(0L);
when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
.thenReturn(0L);
when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
.thenReturn(null);
when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(0L);
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(0L);
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo(BigDecimal.ZERO);
}
}
@@ -0,0 +1,467 @@
package de.cannamanage.service;
import de.cannamanage.domain.constants.ComplianceConstants;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.MonthlyQuota;
import de.cannamanage.domain.entity.Strain;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.exception.QuotaExceededException;
import de.cannamanage.service.exception.QuotaViolationCode;
import de.cannamanage.service.repository.BatchRepository;
import de.cannamanage.service.repository.DistributionRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.MonthlyQuotaRepository;
import de.cannamanage.service.repository.StrainRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest {
@Mock MemberRepository memberRepository;
@Mock DistributionRepository distributionRepository;
@Mock BatchRepository batchRepository;
@Mock MonthlyQuotaRepository monthlyQuotaRepository;
@Mock StrainRepository strainRepository;
@InjectMocks ComplianceService complianceService;
private static final UUID ADULT_MEMBER_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID UNDER21_MEMBER_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID BATCH_ID = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final UUID STRAIN_ID = UUID.fromString("44444444-4444-4444-4444-444444444444");
private static final UUID HIGH_THC_STRAIN_ID = UUID.fromString("55555555-5555-5555-5555-555555555555");
private Member adultMember;
private Member under21Member;
private Batch availableBatch;
private Strain normalStrain;
private Strain highThcStrain;
@BeforeEach
void setUp() {
adultMember = new Member();
adultMember.setStatus(MemberStatus.ACTIVE);
adultMember.setUnder21(false);
under21Member = new Member();
under21Member.setStatus(MemberStatus.ACTIVE);
under21Member.setUnder21(true);
normalStrain = new Strain();
normalStrain.setThcPercentage(new BigDecimal("8.0"));
normalStrain.setCbdPercentage(new BigDecimal("2.0"));
highThcStrain = new Strain();
highThcStrain.setThcPercentage(new BigDecimal("22.0"));
highThcStrain.setCbdPercentage(BigDecimal.ZERO);
availableBatch = new Batch();
availableBatch.setStatus(BatchStatus.AVAILABLE);
availableBatch.setStrainId(STRAIN_ID);
}
// TC-001: Adult at monthly limit → QUOTA_EXCEEDED_MONTHLY
@Test
@DisplayName("TC-001: Adult at 50g monthly usage → QUOTA_EXCEEDED_MONTHLY")
void tc001_adultAtMonthlyLimit() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(BigDecimal.ZERO);
MonthlyQuota quota = buildQuota("50.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
}
// TC-002: Under-21 at monthly limit → QUOTA_EXCEEDED_MONTHLY
@Test
@DisplayName("TC-002: Under-21 at 30g monthly usage → QUOTA_EXCEEDED_MONTHLY")
void tc002_under21AtMonthlyLimit() {
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(BigDecimal.ZERO);
MonthlyQuota quota = buildQuota("30.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
}
// TC-003: Adult at daily limit → QUOTA_EXCEEDED_DAILY
@Test
@DisplayName("TC-003: Adult at 25g today requesting 1g → QUOTA_EXCEEDED_DAILY")
void tc003_adultAtDailyLimit() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(new BigDecimal("25.0"));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
}
// TC-004: Under-21 + high THC → HIGH_THC_RESTRICTED_UNDER_21
@Test
@DisplayName("TC-004: Under-21 + 22% THC batch → HIGH_THC_RESTRICTED_UNDER_21")
void tc004_under21HighThcStrain() {
Batch highThcBatch = new Batch();
highThcBatch.setStatus(BatchStatus.AVAILABLE);
highThcBatch.setStrainId(HIGH_THC_STRAIN_ID);
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(highThcBatch));
when(strainRepository.findById(HIGH_THC_STRAIN_ID)).thenReturn(Optional.of(highThcStrain));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21);
}
// TC-005: Adult at 49g requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)
@Test
@DisplayName("TC-005: Adult at 49g monthly requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)")
void tc005_adultAt49gRequesting2g() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(BigDecimal.ZERO);
MonthlyQuota quota = buildQuota("49.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("2.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
}
// TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0
@Test
@DisplayName("TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0")
void tc006_adultAt0gRequesting25g_allowed() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(BigDecimal.ZERO);
MonthlyQuota quota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("25.0"));
assertThat(result.allowed()).isTrue();
assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(result.remainingMonthly()).isEqualByComparingTo(new BigDecimal("25.0"));
assertThat(result.isUnder21()).isFalse();
}
// TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0
@Test
@DisplayName("TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0")
void tc007_adultAt24dot9gRequesting0dot1g_allowed() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(new BigDecimal("24.9"));
MonthlyQuota quota = buildQuota("24.9", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.1"));
assertThat(result.allowed()).isTrue();
assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
}
// TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)
@Test
@DisplayName("TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)")
void tc008_adultAt24dot9gRequesting0dot2g_dailyExceeded() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(new BigDecimal("24.9"));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.2")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
}
// TC-009: SUSPENDED member → MEMBER_INACTIVE
@Test
@DisplayName("TC-009: SUSPENDED member → MEMBER_INACTIVE")
void tc009_suspendedMember() {
Member suspended = new Member();
suspended.setStatus(MemberStatus.SUSPENDED);
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(suspended));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
}
// TC-010: EXPELLED member → MEMBER_INACTIVE
@Test
@DisplayName("TC-010: EXPELLED member → MEMBER_INACTIVE")
void tc010_expelledMember() {
Member expelled = new Member();
expelled.setStatus(MemberStatus.EXPELLED);
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(expelled));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
}
// TC-011: No existing quota → creates new quota (createNewQuota path)
@Test
@DisplayName("TC-011: No existing quota → creates new quota, distribution allowed")
void tc011_noExistingQuota_createsNewAndAllows() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(BigDecimal.ZERO);
// Return empty — triggers createNewQuota
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.empty());
MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("10.0"));
assertThat(result.allowed()).isTrue();
assertThat(result.isUnder21()).isFalse();
}
// TC-012: Under-21 no existing quota → creates quota with under-21 limit
@Test
@DisplayName("TC-012: Under-21 no existing quota → creates quota with 30g limit")
void tc012_under21NoExistingQuota_createsNewWithUnder21Limit() {
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
.thenReturn(BigDecimal.ZERO);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.empty());
MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);
var result = complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"));
assertThat(result.allowed()).isTrue();
assertThat(result.isUnder21()).isTrue();
}
// TC-013: BATCH_UNAVAILABLE — batch is RECALLED
@Test
@DisplayName("TC-013: RECALLED batch → BATCH_UNAVAILABLE")
void tc013_recalledBatch() {
Batch recalled = new Batch();
recalled.setStatus(BatchStatus.RECALLED);
recalled.setStrainId(STRAIN_ID);
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(recalled));
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.BATCH_UNAVAILABLE);
}
// TC-014: getQuotaStatus — adult member with existing quota
@Test
@DisplayName("TC-014: getQuotaStatus — adult with existing quota returns correct status")
void tc014_getQuotaStatus_adultWithExistingQuota() {
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
MonthlyQuota quota = buildQuota("20.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
var status = complianceService.getQuotaStatus(ADULT_MEMBER_ID);
assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
assertThat(status.totalUsed()).isEqualByComparingTo(new BigDecimal("20.0"));
assertThat(status.remaining()).isEqualByComparingTo(new BigDecimal("30.0"));
assertThat(status.isUnder21()).isFalse();
}
// TC-015: getQuotaStatus — under-21 with no existing quota
@Test
@DisplayName("TC-015: getQuotaStatus — under-21 with no quota returns 30g limit")
void tc015_getQuotaStatus_under21NoExistingQuota() {
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.empty());
var status = complianceService.getQuotaStatus(UNDER21_MEMBER_ID);
assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
assertThat(status.totalUsed()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(status.isUnder21()).isTrue();
}
// TC-016: getQuotaStatus — member not found throws exception
@Test
@DisplayName("TC-016: getQuotaStatus — unknown member ID throws MemberNotFoundException")
void tc016_getQuotaStatus_memberNotFound() {
UUID unknown = UUID.fromString("99999999-9999-9999-9999-999999999999");
when(memberRepository.findById(unknown)).thenReturn(Optional.empty());
assertThatThrownBy(() -> complianceService.getQuotaStatus(unknown))
.isInstanceOf(MemberNotFoundException.class);
}
// TC-017: validateMembershipAge — 18-year-old is allowed
@Test
@DisplayName("TC-017: validateMembershipAge — 18-year-old is allowed")
void tc017_validateMembershipAge_18YearsOld_allowed() {
// Just before birthday this year to keep age = 18
LocalDate dob = LocalDate.now().minusYears(18).minusDays(1);
// Should not throw
complianceService.validateMembershipAge(dob);
}
// TC-018: validateMembershipAge — 17-year-old is rejected
@Test
@DisplayName("TC-018: validateMembershipAge — 17-year-old is rejected")
void tc018_validateMembershipAge_17YearsOld_rejected() {
LocalDate dob = LocalDate.now().minusYears(17);
assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
}
// TC-019: isUnder21 — 20-year-old returns true
@Test
@DisplayName("TC-019: isUnder21 — 20-year-old returns true")
void tc019_isUnder21_20YearsOld_returnsTrue() {
LocalDate dob = LocalDate.now().minusYears(20).minusDays(1);
assertThat(complianceService.isUnder21(dob)).isTrue();
}
// TC-020: isUnder21 — 21-year-old returns false
@Test
@DisplayName("TC-020: isUnder21 — 21-year-old returns false")
void tc020_isUnder21_21YearsOld_returnsFalse() {
LocalDate dob = LocalDate.now().minusYears(21).minusDays(1);
assertThat(complianceService.isUnder21(dob)).isFalse();
}
// TC-021: member not found in checkDistributionAllowed → MemberNotFoundException
@Test
@DisplayName("TC-021: Unknown member ID in checkDistributionAllowed → MemberNotFoundException")
void tc021_memberNotFoundInCheck() {
UUID unknown = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
when(memberRepository.findById(unknown)).thenReturn(Optional.empty());
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(unknown, BATCH_ID, new BigDecimal("5.0")))
.isInstanceOf(de.cannamanage.service.exception.MemberNotFoundException.class);
}
// TC-022: batch not found in checkDistributionAllowed → BatchNotFoundException
@Test
@DisplayName("TC-022: Unknown batch ID in checkDistributionAllowed → BatchNotFoundException")
void tc022_batchNotFoundInCheck() {
UUID unknownBatch = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(unknownBatch)).thenReturn(Optional.empty());
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, unknownBatch, new BigDecimal("5.0")))
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
}
// TC-023: strain not found for batch → BatchNotFoundException
@Test
@DisplayName("TC-023: Strain not found for batch → BatchNotFoundException")
void tc023_strainNotFoundForBatch() {
UUID unknownStrain = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
Batch batchWithUnknownStrain = new Batch();
batchWithUnknownStrain.setStatus(BatchStatus.AVAILABLE);
batchWithUnknownStrain.setStrainId(unknownStrain);
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batchWithUnknownStrain));
when(strainRepository.findById(unknownStrain)).thenReturn(Optional.empty());
assertThatThrownBy(() ->
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
}
// TC-024: validateMembershipAge — birthday not yet occurred this year (age-- branch)
@Test
@DisplayName("TC-024: validateMembershipAge — birthday later this year → age is 17, rejected")
void tc024_validateMembershipAge_birthdayLaterThisYear() {
// Person who will turn 18 tomorrow — today they are 17 → should throw
LocalDate dob = LocalDate.now().plusDays(1).minusYears(18);
assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
.isInstanceOf(QuotaExceededException.class)
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
}
// TC-025: isUnder21 — birthday not yet occurred this year → age-- branch
@Test
@DisplayName("TC-025: isUnder21 — person turns 21 tomorrow → still under 21 today")
void tc025_isUnder21_birthdayTomorrow_stillUnder21() {
// Person who will turn 21 tomorrow — today they are 20 → still under 21
LocalDate dob = LocalDate.now().plusDays(1).minusYears(21);
assertThat(complianceService.isUnder21(dob)).isTrue();
}
// Helper
private MonthlyQuota buildQuota(String totalDistributed, BigDecimal maxAllowed) {
MonthlyQuota quota = new MonthlyQuota();
quota.setTotalDistributed(new BigDecimal(totalDistributed));
quota.setMaxAllowed(maxAllowed);
return quota;
}
}

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