Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59b7486cec | |||
| 752101c6c9 | |||
| 302b7da8ca | |||
| 6c66783b58 | |||
| 36deb72cf0 | |||
| 55d8434f35 | |||
| 08b8e43ae8 | |||
| a1ddec37da | |||
| 2ede872d11 | |||
| 86c922e1f9 | |||
| 10891e7b89 | |||
| fa1eaf64e0 |
@@ -0,0 +1,9 @@
|
|||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
+3
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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
|
||||||
|
) {}
|
||||||
+21
@@ -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
|
||||||
|
) {}
|
||||||
+15
@@ -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
|
||||||
|
) {}
|
||||||
+131
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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);
|
||||||
+9
@@ -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
|
||||||
+196
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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
|
||||||
@@ -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>
|
||||||
+33
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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
|
||||||
|
) {}
|
||||||
+10
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ClubRepository extends JpaRepository<Club, UUID> {
|
||||||
|
|
||||||
|
Optional<Club> findByTenantId(UUID tenantId);
|
||||||
|
}
|
||||||
+32
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
+16
@@ -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);
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
+25
@@ -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);
|
||||||
|
}
|
||||||
+29
@@ -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);
|
||||||
|
}
|
||||||
+11
@@ -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> {
|
||||||
|
}
|
||||||
+18
@@ -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
Reference in New Issue
Block a user