Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52251cf711 | |||
| 26a77b5e16 | |||
| 4be9c4cf2c | |||
| 2347a7a1d9 | |||
| 281adda27c | |||
| dac884c4fe | |||
| 6570ea364a | |||
| 60844efaba | |||
| 8490da4705 | |||
| f6a7143d1b | |||
| 1eead286ba | |||
| 9a4df56eaf | |||
| b57be8a4d8 | |||
| 3e4fdee05b | |||
| 805bc4f00d | |||
| d650987b9a | |||
| 106229e0e3 | |||
| d0c53a912c | |||
| 61707ffe68 | |||
| 1e693e3d2a | |||
| 599514c0db | |||
| 076fd6f9b3 | |||
| 05933a08ca | |||
| 61e481b37b | |||
| 3232d2f7fd | |||
| b38902a7ee | |||
| 4fa068092f | |||
| 8391dbb2cd | |||
| 9373c7ad69 | |||
| 5c02cb0cde | |||
| 4d64576f22 | |||
| d1487539b6 | |||
| 2cc8c89944 | |||
| ed1efccc90 | |||
| be63a84fe8 | |||
| b170bb9d87 | |||
| f42c166329 | |||
| 279f2f6de0 | |||
| dce27a4291 | |||
| 7f99e11d9f | |||
| 09d5ca6db0 | |||
| 02e4bbad18 | |||
| f8f562915e | |||
| 154f79fe60 | |||
| fe6e96dd3f | |||
| a1d4ba44e3 | |||
| 864bbbdde1 | |||
| 4f00872486 | |||
| 87568e5bfc | |||
| 64927a3244 | |||
| a267a90542 | |||
| 59b7486cec | |||
| 752101c6c9 | |||
| 302b7da8ca | |||
| 6c66783b58 | |||
| 36deb72cf0 | |||
| 55d8434f35 | |||
| 08b8e43ae8 | |||
| a1ddec37da | |||
| 2ede872d11 | |||
| 86c922e1f9 | |||
| 10891e7b89 |
@@ -0,0 +1,51 @@
|
|||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '21'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: ./mvnw verify -B -q
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy to production
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: plate-software.de
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/cannamanage
|
||||||
|
git pull origin main
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Wait for backend health
|
||||||
|
sleep 15
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
if curl -sf http://127.0.0.1:8080/actuator/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ Deploy successful at $(date)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting... attempt $i/5"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ Deploy failed — backend unhealthy"
|
||||||
|
docker compose -f docker-compose.prod.yml logs --tail=30 backend
|
||||||
|
exit 1
|
||||||
@@ -7,3 +7,11 @@ target/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
.mvn/wrapper/maven-wrapper.jar
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cannamanage-frontend/node_modules/
|
||||||
|
cannamanage-frontend/.next/
|
||||||
|
cannamanage-frontend/.env.local
|
||||||
|
|
||||||
|
# Production secrets (never commit)
|
||||||
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Multi-stage build for cannamanage-api (Spring Boot + Java 21)
|
||||||
|
# Build context: repo root (needs access to all Maven modules)
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Maven wrapper + POM files first (layer caching)
|
||||||
|
COPY .mvn/ .mvn/
|
||||||
|
COPY mvnw pom.xml ./
|
||||||
|
COPY cannamanage-domain/pom.xml cannamanage-domain/pom.xml
|
||||||
|
COPY cannamanage-service/pom.xml cannamanage-service/pom.xml
|
||||||
|
COPY cannamanage-api/pom.xml cannamanage-api/pom.xml
|
||||||
|
|
||||||
|
# Download dependencies (cached unless POMs change)
|
||||||
|
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B -q 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY cannamanage-domain/src/ cannamanage-domain/src/
|
||||||
|
COPY cannamanage-service/src/ cannamanage-service/src/
|
||||||
|
COPY cannamanage-api/src/ cannamanage-api/src/
|
||||||
|
|
||||||
|
# Build the fat JAR
|
||||||
|
RUN ./mvnw package -pl cannamanage-api -am -DskipTests -B -q
|
||||||
|
|
||||||
|
# --- Runtime stage ---
|
||||||
|
FROM eclipse-temurin:21-jre-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
|
COPY --from=builder /app/cannamanage-api/target/*.jar app.jar
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# CannaManage
|
||||||
|
|
||||||
|
Multi-tenant cannabis club management platform for German **Anbauvereinigungen** (cultivation associations) under CanG §19.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CannaManage handles member management, distribution tracking, and legal compliance for cannabis cultivation clubs in Germany. It enforces the strict quotas mandated by the Cannabis Act (CanG) — including monthly limits (50g adult / 30g under-21), daily limits (25g), and THC restrictions for minors.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Runtime | Java 21 (Temurin) |
|
||||||
|
| Framework | Spring Boot 4.0.6 |
|
||||||
|
| Security | Spring Security 7.0 + JWT (JJWT 0.12.6) |
|
||||||
|
| ORM | Hibernate 7 / JPA |
|
||||||
|
| Database | PostgreSQL (prod), H2 (test) |
|
||||||
|
| Migrations | Flyway 10 |
|
||||||
|
| API Docs | SpringDoc OpenAPI 2.8.6 |
|
||||||
|
| Build | Maven (multi-module) |
|
||||||
|
| Container | Docker Compose (Postgres + app) |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cannamanage/
|
||||||
|
├── cannamanage-domain/ # JPA entities, enums, TenantContext
|
||||||
|
├── cannamanage-service/ # Business logic, repositories, ComplianceService
|
||||||
|
├── cannamanage-api/ # Spring Boot app, controllers, security, DTOs
|
||||||
|
├── docs/
|
||||||
|
│ └── sprint-2/ # Sprint planning docs
|
||||||
|
└── docker-compose.yml # Local dev environment
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### cannamanage-domain
|
||||||
|
JPA entities with multi-tenant isolation via `@Filter("tenantFilter")`:
|
||||||
|
- `Member` — club members with age tracking
|
||||||
|
- `Distribution` — cannabis distribution records
|
||||||
|
- `MonthlyQuota` — per-member monthly usage tracking
|
||||||
|
- `Batch` / `Strain` / `StockMovement` — inventory management
|
||||||
|
- `Club` — association registration
|
||||||
|
- `User` — authentication accounts
|
||||||
|
|
||||||
|
### cannamanage-service
|
||||||
|
- `ComplianceService` — CanG §19 quota enforcement (25 unit tests)
|
||||||
|
- Repositories for all entities
|
||||||
|
|
||||||
|
### cannamanage-api
|
||||||
|
- **Auth** — JWT login + refresh token rotation (SHA-256 hashed)
|
||||||
|
- **Members** — CRUD for association members
|
||||||
|
- **Distributions** — compliance-gated distribution recording
|
||||||
|
- **Stock** — batch and inventory management
|
||||||
|
- **Compliance** — quota status API
|
||||||
|
- Multi-tenant isolation via `TenantFilterAspect` (Hibernate @Filter activation)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| POST | `/api/v1/auth/login` | Public | Login with email + password |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Public | Refresh token rotation |
|
||||||
|
| GET | `/api/v1/compliance/quota/{memberId}` | ADMIN, MEMBER | Monthly quota status |
|
||||||
|
| GET/POST/PUT | `/api/v1/members/**` | ADMIN, MEMBER | Member CRUD |
|
||||||
|
| POST | `/api/v1/distributions/**` | ADMIN, MEMBER | Record distributions |
|
||||||
|
| GET/POST | `/api/v1/stock/**` | ADMIN | Stock management |
|
||||||
|
|
||||||
|
Swagger UI: `http://localhost:8080/swagger-ui.html`
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
JAVA_HOME=/path/to/jdk-21 ./mvnw spring-boot:run -pl cannamanage-api
|
||||||
|
|
||||||
|
# Run all tests (H2 in-memory)
|
||||||
|
JAVA_HOME=/path/to/jdk-21 ./mvnw clean verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **37 tests total** — all green
|
||||||
|
- 25 unit tests (`ComplianceServiceTest`) — quota enforcement logic
|
||||||
|
- 7 integration tests (`AuthControllerIntegrationTest`) — full HTTP auth flow
|
||||||
|
- 5 integration tests (`ComplianceControllerIntegrationTest`) — quota API with JWT
|
||||||
|
|
||||||
|
Integration tests use `@SpringBootTest(webEnvironment = RANDOM_PORT)` with H2 and Spring's `RestClient`.
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
- **Stateless JWT** — no session, no UserDetailsService
|
||||||
|
- **Roles**: ADMIN (full access), MEMBER (self-service), STAFF (Sprint 3)
|
||||||
|
- **Multi-tenancy**: Hibernate `@Filter` activated per-request via AOP aspect
|
||||||
|
- **Refresh tokens**: SHA-256 hashed (Spring Security 7 enforces BCrypt 72-byte limit)
|
||||||
|
- Token rotation on refresh — old tokens invalidated
|
||||||
|
|
||||||
|
## Sprint History
|
||||||
|
|
||||||
|
| Sprint | Focus | Status |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| 1 | Domain entities, ComplianceService, 25 tests | ✅ Done |
|
||||||
|
| 2 | REST API, Spring Security, JWT, OpenAPI, integration tests | ✅ Done |
|
||||||
|
| 3 | Member portal, STAFF role, real-time notifications | 📋 Planned |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private — Patrick Plate
|
||||||
@@ -36,6 +36,18 @@
|
|||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Spring Boot 4 modularized autoconfiguration: FlywayAutoConfiguration
|
||||||
|
moved out of spring-boot-autoconfigure into the dedicated spring-boot-flyway
|
||||||
|
module, which is only brought in by spring-boot-starter-flyway. Without this
|
||||||
|
starter, spring.flyway.enabled=true is inert — migrations never run and
|
||||||
|
Hibernate ddl-auto=validate fails on the empty schema.
|
||||||
|
See: https://spring.io/blog/2025/10/28/modularizing-spring-boot/
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
@@ -46,11 +58,88 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Spring Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Bean Validation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- JWT (JJWT) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- OpenAPI / Swagger UI -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.8.6</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- H2 for unit tests -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Testcontainers PostgreSQL for integration tests -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot Mail (invite flow) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Actuator (health endpoint for Docker) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- WebSocket (STOMP + SockJS for notifications) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ package de.cannamanage.api;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CannaManage Spring Boot application entry point.
|
* CannaManage Spring Boot application entry point.
|
||||||
* REST controllers are deferred to Sprint 2.
|
* Sprint 2: REST API + Spring Security + OpenAPI.
|
||||||
* Sprint 1 focus: compliance engine validation only.
|
*
|
||||||
|
* Multi-module scanning:
|
||||||
|
* - scanBasePackages: component scanning (controllers, services)
|
||||||
|
* - EnableJpaRepositories: Spring Data JPA repository interfaces
|
||||||
|
* - EntityScan: JPA entity detection across modules (Boot 4.0 relocated package)
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
|
||||||
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||||
|
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||||
public class CannaManageApplication {
|
public class CannaManageApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.cannamanage.api.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
|
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Contact;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Info;
|
||||||
|
import io.swagger.v3.oas.annotations.info.License;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||||
|
import io.swagger.v3.oas.annotations.servers.Server;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@OpenAPIDefinition(
|
||||||
|
info = @Info(
|
||||||
|
title = "CannaManage API",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = "Cannabis Social Club Management — CanG Compliance Platform API",
|
||||||
|
contact = @Contact(name = "CannaManage", email = "info@cannamanage.de"),
|
||||||
|
license = @License(name = "Proprietary")
|
||||||
|
),
|
||||||
|
servers = {
|
||||||
|
@Server(url = "/", description = "Current server")
|
||||||
|
},
|
||||||
|
security = @SecurityRequirement(name = "bearer-jwt")
|
||||||
|
)
|
||||||
|
@SecurityScheme(
|
||||||
|
name = "bearer-jwt",
|
||||||
|
type = SecuritySchemeType.HTTP,
|
||||||
|
scheme = "bearer",
|
||||||
|
bearerFormat = "JWT",
|
||||||
|
description = "JWT access token — obtain via POST /api/v1/auth/login"
|
||||||
|
)
|
||||||
|
public class OpenApiConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.cannamanage.api.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||||
|
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket configuration — enables STOMP messaging over SockJS.
|
||||||
|
* Clients connect to /ws, subscribe to /user/queue/notifications for personal notifications.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocketMessageBroker
|
||||||
|
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||||
|
// Enable simple in-memory broker for /topic (broadcast) and /queue (user-specific)
|
||||||
|
config.enableSimpleBroker("/topic", "/queue");
|
||||||
|
// Prefix for @MessageMapping methods
|
||||||
|
config.setApplicationDestinationPrefixes("/app");
|
||||||
|
// User-specific destination prefix
|
||||||
|
config.setUserDestinationPrefix("/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||||
|
// WebSocket endpoint with SockJS fallback
|
||||||
|
registry.addEndpoint("/ws")
|
||||||
|
.setAllowedOriginPatterns("*")
|
||||||
|
.withSockJS();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.AuditEvent;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.AuditEventType;
|
||||||
|
import de.cannamanage.service.AuditService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/audit")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Audit", description = "Immutable audit log (KCanG compliance, 10-year retention)")
|
||||||
|
public class AuditController {
|
||||||
|
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Get paginated audit log",
|
||||||
|
description = "Returns audit events with optional filters. Admin only.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<Page<AuditEventResponse>> getAuditLog(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) AuditEventType eventType,
|
||||||
|
@RequestParam(required = false) String entityType,
|
||||||
|
@RequestParam(required = false) UUID actorId,
|
||||||
|
@RequestParam(required = false) Instant from,
|
||||||
|
@RequestParam(required = false) Instant to
|
||||||
|
) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Page<AuditEvent> events = auditService.getEvents(
|
||||||
|
tenantId, page, size, eventType, entityType, actorId, from, to
|
||||||
|
);
|
||||||
|
Page<AuditEventResponse> response = events.map(AuditEventResponse::from);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export")
|
||||||
|
@Operation(summary = "Export audit log as PDF",
|
||||||
|
description = "Generates a PDF audit report for the specified date range. Admin only.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<byte[]> exportAuditPdf(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
|
||||||
|
) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Instant fromInstant = from.atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
|
||||||
|
Instant toInstant = to.plusDays(1).atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
|
||||||
|
|
||||||
|
byte[] pdf = auditService.exportPdf(tenantId, fromInstant, toInstant);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"audit-log-" + from + "-to-" + to + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for audit events (read-only projection).
|
||||||
|
*/
|
||||||
|
public record AuditEventResponse(
|
||||||
|
UUID id,
|
||||||
|
String eventType,
|
||||||
|
String entityType,
|
||||||
|
UUID entityId,
|
||||||
|
UUID actorId,
|
||||||
|
String actorName,
|
||||||
|
String actorRole,
|
||||||
|
String description,
|
||||||
|
String metadata,
|
||||||
|
String ipAddress,
|
||||||
|
Instant timestamp
|
||||||
|
) {
|
||||||
|
public static AuditEventResponse from(AuditEvent event) {
|
||||||
|
return new AuditEventResponse(
|
||||||
|
event.getId(),
|
||||||
|
event.getEventType().name(),
|
||||||
|
event.getEntityType(),
|
||||||
|
event.getEntityId(),
|
||||||
|
event.getActorId(),
|
||||||
|
event.getActorName(),
|
||||||
|
event.getActorRole(),
|
||||||
|
event.getDescription(),
|
||||||
|
event.getMetadata(),
|
||||||
|
event.getIpAddress(),
|
||||||
|
event.getTimestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||||
|
import de.cannamanage.api.service.AuthService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Authentication", description = "Login and token management")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
|
||||||
|
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
LoginResponse response = authService.login(request);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
@Operation(summary = "Refresh access token", description = "Exchanges a valid refresh token for new token pair")
|
||||||
|
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||||
|
LoginResponse response = authService.refresh(request);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/set-password")
|
||||||
|
@Operation(summary = "Set password via invite token",
|
||||||
|
description = "Public endpoint — validates invite token, sets password, activates account")
|
||||||
|
public ResponseEntity<Map<String, String>> setPassword(@Valid @RequestBody SetPasswordRequest request) {
|
||||||
|
authService.setPassword(request);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.club.ClubResponse;
|
||||||
|
import de.cannamanage.api.dto.club.ClubStatsResponse;
|
||||||
|
import de.cannamanage.api.dto.club.UpdateClubRequest;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.service.ClubService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/clubs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Club Settings", description = "Club configuration and statistics")
|
||||||
|
public class ClubController {
|
||||||
|
|
||||||
|
private final ClubService clubService;
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
@Operation(summary = "Get current club", description = "Returns the club for the current tenant")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<ClubResponse> getMyClub() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Club club = clubService.getClubByTenantId(tenantId);
|
||||||
|
return ResponseEntity.ok(toResponse(club));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/me")
|
||||||
|
@Operation(summary = "Update club settings", description = "Updates the club configuration for the current tenant")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<ClubResponse> updateMyClub(@Valid @RequestBody UpdateClubRequest request) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Club updated = clubService.updateClub(
|
||||||
|
tenantId,
|
||||||
|
request.name(),
|
||||||
|
request.registrationNumber(),
|
||||||
|
request.contactEmail(),
|
||||||
|
request.contactPhone(),
|
||||||
|
request.addressStreet(),
|
||||||
|
request.addressCity(),
|
||||||
|
request.addressPostalCode(),
|
||||||
|
request.addressState(),
|
||||||
|
request.foundedDate(),
|
||||||
|
request.maxPreventionOfficers(),
|
||||||
|
request.allowedEmailPattern()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(toResponse(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me/stats")
|
||||||
|
@Operation(summary = "Get club statistics", description = "Returns aggregated club statistics")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<ClubStatsResponse> getMyClubStats() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||||
|
return ResponseEntity.ok(new ClubStatsResponse(
|
||||||
|
stats.totalMembers(),
|
||||||
|
stats.activeMembers(),
|
||||||
|
stats.totalStaff(),
|
||||||
|
stats.activeStaff(),
|
||||||
|
stats.totalDistributionsThisMonth(),
|
||||||
|
stats.totalGramsDistributedThisMonth(),
|
||||||
|
stats.activeBatches(),
|
||||||
|
stats.preventionOfficerCount()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClubResponse toResponse(Club club) {
|
||||||
|
return new ClubResponse(
|
||||||
|
club.getId(),
|
||||||
|
club.getName(),
|
||||||
|
club.getRegistrationNumber(),
|
||||||
|
club.getContactEmail(),
|
||||||
|
club.getContactPhone(),
|
||||||
|
club.getAddressStreet(),
|
||||||
|
club.getAddressCity(),
|
||||||
|
club.getAddressPostalCode(),
|
||||||
|
club.getAddressState(),
|
||||||
|
club.getFoundedDate(),
|
||||||
|
club.getMaxPreventionOfficers(),
|
||||||
|
club.getAllowedEmailPattern(),
|
||||||
|
club.getStatus(),
|
||||||
|
club.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.compliance.QuotaResponse;
|
||||||
|
import de.cannamanage.service.ComplianceService;
|
||||||
|
import de.cannamanage.service.dto.QuotaStatus;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/compliance")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Compliance", description = "CanG §19 compliance quota checks")
|
||||||
|
public class ComplianceController {
|
||||||
|
|
||||||
|
private final ComplianceService complianceService;
|
||||||
|
|
||||||
|
@GetMapping("/quota/{memberId}")
|
||||||
|
@Operation(summary = "Get member quota status",
|
||||||
|
description = "Returns current monthly remaining quota for a member per CanG §19")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_QUOTA)")
|
||||||
|
public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) {
|
||||||
|
QuotaStatus status = complianceService.getQuotaStatus(memberId);
|
||||||
|
|
||||||
|
QuotaResponse response = new QuotaResponse(
|
||||||
|
status.totalAllowed(),
|
||||||
|
status.totalUsed(),
|
||||||
|
status.remaining(),
|
||||||
|
status.isUnder21(),
|
||||||
|
status.year(),
|
||||||
|
status.month()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Consent;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.ConsentType;
|
||||||
|
import de.cannamanage.service.ConsentService;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/consent")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Consent", description = "DSGVO consent management")
|
||||||
|
public class ConsentController {
|
||||||
|
|
||||||
|
private final ConsentService consentService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Get current user's consents")
|
||||||
|
public ResponseEntity<List<ConsentResponse>> getConsents(Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
List<ConsentResponse> consents = consentService.getUserConsents(userId).stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(consents);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Grant consent")
|
||||||
|
public ResponseEntity<ConsentResponse> grantConsent(
|
||||||
|
@RequestBody GrantConsentRequest request,
|
||||||
|
Authentication auth,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
String ipAddress = httpRequest.getRemoteAddr();
|
||||||
|
String userAgent = httpRequest.getHeader("User-Agent");
|
||||||
|
|
||||||
|
Consent consent = consentService.grantConsent(
|
||||||
|
userId,
|
||||||
|
request.type(),
|
||||||
|
request.version() != null ? request.version() : 1,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(toResponse(consent));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{type}")
|
||||||
|
@Operation(summary = "Revoke consent")
|
||||||
|
public ResponseEntity<Void> revokeConsent(@PathVariable String type, Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
ConsentType consentType = ConsentType.valueOf(type);
|
||||||
|
consentService.revokeConsent(userId, consentType);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/check")
|
||||||
|
@Operation(summary = "Check if user has required DATA_PROCESSING consent")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> checkConsent(Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
boolean hasConsent = consentService.hasRequiredConsents(userId);
|
||||||
|
return ResponseEntity.ok(Map.of("hasDataProcessingConsent", hasConsent));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveUserId(Authentication auth) {
|
||||||
|
// JwtAuthFilter sets the Authentication principal to the userId (the JWT subject),
|
||||||
|
// so auth.getName() is the userId UUID — NOT an email. Parse it directly and verify
|
||||||
|
// the user exists in the current tenant. (Previously this did findByEmailAndTenantId
|
||||||
|
// on auth.getName(), which searched the email column for a UUID → always "User not
|
||||||
|
// found" → 404/500 on every consent call.)
|
||||||
|
UUID userId;
|
||||||
|
try {
|
||||||
|
userId = UUID.fromString(auth.getName());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
if (!userRepository.existsById(userId)) {
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConsentResponse toResponse(Consent consent) {
|
||||||
|
return new ConsentResponse(
|
||||||
|
consent.getId(),
|
||||||
|
consent.getConsentType().name(),
|
||||||
|
consent.isGranted(),
|
||||||
|
consent.getGrantedAt() != null ? consent.getGrantedAt().toString() : null,
|
||||||
|
consent.getRevokedAt() != null ? consent.getRevokedAt().toString() : null,
|
||||||
|
consent.getVersion()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GrantConsentRequest(ConsentType type, Integer version) {}
|
||||||
|
public record ConsentResponse(UUID id, String type, boolean granted, String grantedAt, String revokedAt, int version) {}
|
||||||
|
}
|
||||||
+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,70 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.service.DsgvoService;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
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.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dsgvo")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "DSGVO", description = "Data export and deletion (GDPR Art. 15 & 17)")
|
||||||
|
public class DsgvoController {
|
||||||
|
|
||||||
|
private final DsgvoService dsgvoService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art. 15 DSGVO — Export all personal data as JSON.
|
||||||
|
*/
|
||||||
|
@GetMapping("/export")
|
||||||
|
@Operation(summary = "Export all personal data (Art. 15 DSGVO)")
|
||||||
|
public ResponseEntity<Map<String, Object>> exportData(Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Map<String, Object> data = dsgvoService.exportUserData(userId, tenantId);
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art. 17 DSGVO — Right to erasure.
|
||||||
|
* Anonymizes personal data, deactivates account.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/delete")
|
||||||
|
@Operation(summary = "Delete account and anonymize data (Art. 17 DSGVO)")
|
||||||
|
public ResponseEntity<Map<String, String>> deleteAccount(Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
dsgvoService.deleteUserData(userId);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", "deleted",
|
||||||
|
"message", "Dein Konto wurde gelöscht und deine Daten anonymisiert."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveUserId(Authentication auth) {
|
||||||
|
// JwtAuthFilter sets the Authentication principal to the userId (the JWT subject),
|
||||||
|
// so auth.getName() is the userId UUID — NOT an email. Parse it directly.
|
||||||
|
UUID userId;
|
||||||
|
try {
|
||||||
|
userId = UUID.fromString(auth.getName());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
if (!userRepository.existsById(userId)) {
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.grow.*;
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import de.cannamanage.service.GrowCalendarService;
|
||||||
|
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/grow")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Grow Calendar", description = "Grow lifecycle management with sensors, photos, and feeding")
|
||||||
|
public class GrowCalendarController {
|
||||||
|
|
||||||
|
private final GrowCalendarService growCalendarService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all grow entries")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<List<GrowEntryResponse>> listGrowEntries() {
|
||||||
|
List<GrowEntryResponse> entries = growCalendarService.getGrowEntries().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new grow entry")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> createGrowEntry(@Valid @RequestBody CreateGrowEntryRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.createGrowEntry(
|
||||||
|
request.name(), request.strainId(), request.notes(), request.expectedHarvestAt());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get grow entry detail with stages, sensors, photos, feedings")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<GrowEntryDetailResponse> getGrowEntry(@PathVariable UUID id) {
|
||||||
|
GrowEntry entry = growCalendarService.getGrowEntry(id);
|
||||||
|
List<GrowStageLog> stages = growCalendarService.getStageLogs(id);
|
||||||
|
List<SensorReading> sensors = growCalendarService.getSensorReadings(id);
|
||||||
|
List<GrowPhoto> photos = growCalendarService.getPhotos(id);
|
||||||
|
List<FeedingLog> feedings = growCalendarService.getFeedingLogs(id);
|
||||||
|
|
||||||
|
GrowEntryDetailResponse detail = new GrowEntryDetailResponse(
|
||||||
|
entry.getId(), entry.getName(), entry.getStrainId(), entry.getStatus(),
|
||||||
|
entry.getStartedAt(), entry.getExpectedHarvestAt(), entry.getActualHarvestAt(),
|
||||||
|
entry.getHarvestedGrams(), entry.getLinkedBatchId(), entry.getNotes(),
|
||||||
|
stages.stream().map(this::toStageResponse).toList(),
|
||||||
|
sensors.stream().map(this::toSensorResponse).toList(),
|
||||||
|
photos.stream().map(this::toPhotoResponse).toList(),
|
||||||
|
feedings.stream().map(this::toFeedingResponse).toList()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/stage")
|
||||||
|
@Operation(summary = "Advance to next stage")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> advanceStage(@PathVariable UUID id, @Valid @RequestBody AdvanceStageRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.advanceStage(id, request.stage());
|
||||||
|
return ResponseEntity.ok(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/sensors")
|
||||||
|
@Operation(summary = "Add sensor reading")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<SensorReadingResponse> addSensorReading(@PathVariable UUID id, @Valid @RequestBody AddSensorReadingRequest request) {
|
||||||
|
SensorReading reading = growCalendarService.addSensorReading(id, request.readingType(), request.value(), request.unit());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toSensorResponse(reading));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/photos")
|
||||||
|
@Operation(summary = "Add photo")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowPhotoResponse> addPhoto(@PathVariable UUID id, @Valid @RequestBody AddPhotoRequest request) {
|
||||||
|
GrowPhoto photo = growCalendarService.addPhoto(id, request.filePath(), request.caption());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toPhotoResponse(photo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/feedings")
|
||||||
|
@Operation(summary = "Add feeding log")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<FeedingLogResponse> addFeedingLog(@PathVariable UUID id, @Valid @RequestBody AddFeedingLogRequest request) {
|
||||||
|
FeedingLog feeding = growCalendarService.addFeedingLog(id,
|
||||||
|
request.nutrientName(), request.amountMl(), request.waterLiters(),
|
||||||
|
request.phAfter(), request.ecAfter(), request.notes());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toFeedingResponse(feeding));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/harvest")
|
||||||
|
@Operation(summary = "Complete harvest and link to batch")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> completeHarvest(@PathVariable UUID id, @Valid @RequestBody CompleteHarvestRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.completeHarvest(id, request.harvestedGrams(), request.linkedBatchId());
|
||||||
|
return ResponseEntity.ok(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping helpers ---
|
||||||
|
|
||||||
|
private GrowEntryResponse toResponse(GrowEntry e) {
|
||||||
|
return new GrowEntryResponse(e.getId(), e.getName(), e.getStrainId(), e.getStatus(),
|
||||||
|
e.getStartedAt(), e.getExpectedHarvestAt(), e.getActualHarvestAt(),
|
||||||
|
e.getHarvestedGrams(), e.getLinkedBatchId(), e.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GrowStageLogResponse toStageResponse(GrowStageLog s) {
|
||||||
|
return new GrowStageLogResponse(s.getId(), s.getStage(), s.getStartedAt(), s.getEndedAt(), s.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private SensorReadingResponse toSensorResponse(SensorReading r) {
|
||||||
|
return new SensorReadingResponse(r.getId(), r.getReadingType(), r.getValue(), r.getUnit(), r.getRecordedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GrowPhotoResponse toPhotoResponse(GrowPhoto p) {
|
||||||
|
return new GrowPhotoResponse(p.getId(), p.getFilePath(), p.getCaption(), p.getTakenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedingLogResponse toFeedingResponse(FeedingLog f) {
|
||||||
|
return new FeedingLogResponse(f.getId(), f.getNutrientName(), f.getAmountMl(),
|
||||||
|
f.getWaterLiters(), f.getPhAfter(), f.getEcAfter(), f.getFedAt(), f.getNotes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
||||||
|
import de.cannamanage.api.dto.prevention.PreventionDataResponse;
|
||||||
|
import de.cannamanage.api.dto.prevention.Under21MemberResponse;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Period;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/members")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Members", description = "Club member management")
|
||||||
|
public class MemberController {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
|
||||||
|
public ResponseEntity<List<MemberResponse>> listMembers() {
|
||||||
|
List<MemberResponse> members = memberRepository.findAll().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get member by ID")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
|
||||||
|
public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) {
|
||||||
|
Member member = memberRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||||
|
return ResponseEntity.ok(toResponse(member));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new member")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
|
||||||
|
public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) {
|
||||||
|
Member member = new Member();
|
||||||
|
member.setFirstName(request.firstName());
|
||||||
|
member.setLastName(request.lastName());
|
||||||
|
member.setEmail(request.email());
|
||||||
|
member.setDateOfBirth(request.dateOfBirth());
|
||||||
|
member.setMembershipDate(request.membershipDate());
|
||||||
|
member.setMembershipNumber(request.membershipNumber());
|
||||||
|
member.setClubId(TenantContext.getCurrentTenant()); // club == tenant for MVP
|
||||||
|
member.setUnder21(isUnder21(request.dateOfBirth()));
|
||||||
|
|
||||||
|
Member saved = memberRepository.save(member);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Update a member")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
|
||||||
|
public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody UpdateMemberRequest request) {
|
||||||
|
Member member = memberRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||||
|
|
||||||
|
if (request.firstName() != null) member.setFirstName(request.firstName());
|
||||||
|
if (request.lastName() != null) member.setLastName(request.lastName());
|
||||||
|
if (request.email() != null) member.setEmail(request.email());
|
||||||
|
if (request.dateOfBirth() != null) {
|
||||||
|
member.setDateOfBirth(request.dateOfBirth());
|
||||||
|
member.setUnder21(isUnder21(request.dateOfBirth()));
|
||||||
|
}
|
||||||
|
if (request.membershipNumber() != null) member.setMembershipNumber(request.membershipNumber());
|
||||||
|
if (request.status() != null) member.setStatus(MemberStatus.valueOf(request.status()));
|
||||||
|
|
||||||
|
Member saved = memberRepository.save(member);
|
||||||
|
return ResponseEntity.ok(toResponse(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/under-21")
|
||||||
|
@Operation(summary = "List under-21 members", description = "Returns all under-21 members with current month distribution data. Prevention officer or ADMIN access required.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||||
|
public ResponseEntity<List<Under21MemberResponse>> getUnder21Members() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
List<Member> under21Members = preventionOfficerService.getUnder21Members(tenantId);
|
||||||
|
|
||||||
|
List<Under21MemberResponse> response = under21Members.stream()
|
||||||
|
.map(m -> {
|
||||||
|
int age = preventionOfficerService.calculateAge(m.getDateOfBirth());
|
||||||
|
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, m.getId());
|
||||||
|
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, m.getId());
|
||||||
|
BigDecimal limit = preventionOfficerService.getMonthlyLimit(m);
|
||||||
|
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||||
|
String quotaStatus = remaining.compareTo(BigDecimal.ZERO) > 0 ? "OK" : "EXHAUSTED";
|
||||||
|
return new Under21MemberResponse(
|
||||||
|
m.getId(), m.getFirstName(), m.getLastName(),
|
||||||
|
age, m.getDateOfBirth(), distCount,
|
||||||
|
gramsUsed, limit, quotaStatus
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/prevention-data")
|
||||||
|
@Operation(summary = "Get prevention data for a member", description = "Returns prevention-relevant data for a specific member. Prevention officer or ADMIN access required.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||||
|
public ResponseEntity<PreventionDataResponse> getPreventionData(@PathVariable UUID id) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Member member = memberRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||||
|
|
||||||
|
int age = preventionOfficerService.calculateAge(member.getDateOfBirth());
|
||||||
|
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, member.getId());
|
||||||
|
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, member.getId());
|
||||||
|
BigDecimal limit = preventionOfficerService.getMonthlyLimit(member);
|
||||||
|
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new PreventionDataResponse(
|
||||||
|
member.getId(),
|
||||||
|
member.getFirstName() + " " + member.getLastName(),
|
||||||
|
member.isUnder21(),
|
||||||
|
age,
|
||||||
|
distCount,
|
||||||
|
gramsUsed,
|
||||||
|
limit,
|
||||||
|
remaining
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isUnder21(LocalDate dateOfBirth) {
|
||||||
|
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MemberResponse toResponse(Member m) {
|
||||||
|
return new MemberResponse(
|
||||||
|
m.getId(),
|
||||||
|
m.getFirstName(),
|
||||||
|
m.getLastName(),
|
||||||
|
m.getEmail(),
|
||||||
|
m.getDateOfBirth(),
|
||||||
|
m.getMembershipDate(),
|
||||||
|
m.getMembershipNumber(),
|
||||||
|
m.getStatus(),
|
||||||
|
m.isUnder21(),
|
||||||
|
false // preventionOfficer flag comes from StaffAccount, not Member
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Notification;
|
||||||
|
import de.cannamanage.service.NotificationService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST endpoints for notification management.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/notifications")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotificationController {
|
||||||
|
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's notifications (last 10, unread first).
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> getNotifications(@AuthenticationPrincipal UserDetails user) {
|
||||||
|
UUID userId = UUID.fromString(user.getUsername());
|
||||||
|
List<Notification> notifications = notificationService.getRecentNotifications(userId);
|
||||||
|
long unreadCount = notificationService.getUnreadCount(userId);
|
||||||
|
|
||||||
|
var items = notifications.stream().map(n -> Map.of(
|
||||||
|
"id", (Object) n.getId(),
|
||||||
|
"type", n.getType().name(),
|
||||||
|
"title", n.getTitle(),
|
||||||
|
"message", n.getMessage(),
|
||||||
|
"link", n.getLink() != null ? n.getLink() : "",
|
||||||
|
"read", n.isRead(),
|
||||||
|
"createdAt", n.getCreatedAt().toString()
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"notifications", items,
|
||||||
|
"unreadCount", unreadCount
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a single notification as read.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}/read")
|
||||||
|
public ResponseEntity<Void> markAsRead(@PathVariable UUID id) {
|
||||||
|
notificationService.markAsRead(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read.
|
||||||
|
*/
|
||||||
|
@PutMapping("/read-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> markAllAsRead(@AuthenticationPrincipal UserDetails user) {
|
||||||
|
UUID userId = UUID.fromString(user.getUsername());
|
||||||
|
int updated = notificationService.markAllAsRead(userId);
|
||||||
|
return ResponseEntity.ok(Map.of("updated", updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.security.PortalPrincipal;
|
||||||
|
import de.cannamanage.service.PortalService;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member self-service portal — read-only JSON endpoints.
|
||||||
|
* All data is scoped to the authenticated member via session principal.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/portal")
|
||||||
|
public class PortalController {
|
||||||
|
|
||||||
|
private final PortalService portalService;
|
||||||
|
|
||||||
|
public PortalController(PortalService portalService) {
|
||||||
|
this.portalService = portalService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard: quota summary + recent distributions (last 5).
|
||||||
|
*/
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ResponseEntity<PortalDashboard> dashboard(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile.
|
||||||
|
*/
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<PortalProfile> profile(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalProfile profile = portalService.getProfile(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current month quota status (daily + monthly, used/remaining).
|
||||||
|
*/
|
||||||
|
@GetMapping("/quota")
|
||||||
|
public ResponseEntity<PortalQuota> quota(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Own distribution history, paginated.
|
||||||
|
*/
|
||||||
|
@GetMapping("/distributions")
|
||||||
|
public ResponseEntity<PortalDistributionHistory> distributions(
|
||||||
|
@AuthenticationPrincipal PortalPrincipal principal,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
|
||||||
|
PortalDistributionHistory history = portalService.getDistributionHistory(
|
||||||
|
principal.getTenantId(), principal.getMemberId(), pageable);
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.report.MemberListResponse;
|
||||||
|
import de.cannamanage.api.dto.report.MonthlyReportResponse;
|
||||||
|
import de.cannamanage.api.dto.report.RecallReportResponse;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.CsvReportGenerator;
|
||||||
|
import de.cannamanage.service.PdfReportGenerator;
|
||||||
|
import de.cannamanage.service.ReportService;
|
||||||
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
|
import de.cannamanage.service.model.report.RecallReport;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for compliance and operational reports.
|
||||||
|
* Supports JSON, PDF, and CSV output formats.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/reports")
|
||||||
|
public class ReportController {
|
||||||
|
|
||||||
|
private final ReportService reportService;
|
||||||
|
private final PdfReportGenerator pdfGenerator;
|
||||||
|
private final CsvReportGenerator csvGenerator;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
|
||||||
|
public ReportController(ReportService reportService,
|
||||||
|
PdfReportGenerator pdfGenerator,
|
||||||
|
CsvReportGenerator csvGenerator,
|
||||||
|
ClubRepository clubRepository) {
|
||||||
|
this.reportService = reportService;
|
||||||
|
this.pdfGenerator = pdfGenerator;
|
||||||
|
this.csvGenerator = csvGenerator;
|
||||||
|
this.clubRepository = clubRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly distribution report.
|
||||||
|
* GET /api/v1/reports/monthly?month=2026-03&format=json|pdf|csv
|
||||||
|
*/
|
||||||
|
@GetMapping("/monthly")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> monthlyReport(
|
||||||
|
@RequestParam String month,
|
||||||
|
@RequestParam(defaultValue = "json") String format) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
YearMonth ym = YearMonth.parse(month);
|
||||||
|
MonthlyReport report = reportService.generateMonthlyReport(tenantId, ym);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderMonthlyReport(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"monatsbericht-" + month + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
case "csv" -> {
|
||||||
|
byte[] csv = csvGenerator.renderMonthlyReport(report);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"monatsbericht-" + month + ".csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toMonthlyResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member list report.
|
||||||
|
* GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
|
||||||
|
*/
|
||||||
|
@GetMapping("/members")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> memberListReport(
|
||||||
|
@RequestParam(defaultValue = "json") String format,
|
||||||
|
@RequestParam(required = false) MemberStatus status) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
MemberListReport report = reportService.generateMemberListReport(tenantId, status);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderMemberList(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"mitgliederliste.pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
case "csv" -> {
|
||||||
|
byte[] csv = csvGenerator.renderMemberList(report);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"mitgliederliste.csv\"")
|
||||||
|
.contentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8))
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toMemberListResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recall/batch trace report.
|
||||||
|
* GET /api/v1/reports/recall/{batchId}?format=json|pdf
|
||||||
|
*/
|
||||||
|
@GetMapping("/recall/{batchId}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<?> recallReport(
|
||||||
|
@PathVariable UUID batchId,
|
||||||
|
@RequestParam(defaultValue = "json") String format) {
|
||||||
|
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
RecallReport report = reportService.generateRecallReport(tenantId, batchId);
|
||||||
|
|
||||||
|
return switch (format.toLowerCase()) {
|
||||||
|
case "pdf" -> {
|
||||||
|
Club club = getClub(tenantId);
|
||||||
|
byte[] pdf = pdfGenerator.renderRecallReport(report, club);
|
||||||
|
yield ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"rueckruf-" + batchId + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
default -> ResponseEntity.ok(toRecallResponse(report));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping helpers ---
|
||||||
|
|
||||||
|
private Club getClub(UUID tenantId) {
|
||||||
|
return clubRepository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Club not found for tenant " + tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MonthlyReportResponse toMonthlyResponse(MonthlyReport r) {
|
||||||
|
return new MonthlyReportResponse(
|
||||||
|
r.getMonth().toString(),
|
||||||
|
r.getTotalDistributions(),
|
||||||
|
r.getTotalGrams(),
|
||||||
|
r.getUniqueMembers(),
|
||||||
|
r.getAveragePerMember(),
|
||||||
|
r.getTopStrains().stream()
|
||||||
|
.map(s -> new MonthlyReportResponse.StrainSummaryDto(
|
||||||
|
s.getName(), s.getTotalGrams(), s.getDistributionCount()))
|
||||||
|
.toList(),
|
||||||
|
r.getDailyBreakdown().stream()
|
||||||
|
.map(d -> new MonthlyReportResponse.DailyEntryDto(
|
||||||
|
d.getDate(), d.getGrams(), d.getDistributions()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MemberListResponse toMemberListResponse(MemberListReport r) {
|
||||||
|
return new MemberListResponse(
|
||||||
|
r.getGeneratedAt(),
|
||||||
|
r.getMembers().stream()
|
||||||
|
.map(m -> new MemberListResponse.MemberEntryDto(
|
||||||
|
m.getId(), m.getFirstName(), m.getLastName(),
|
||||||
|
m.getMembershipNumber(),
|
||||||
|
m.getStatus() != null ? m.getStatus().name() : null,
|
||||||
|
m.getJoinDate(), m.getTotalDistributions(),
|
||||||
|
m.getLastDistributionDate()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecallReportResponse toRecallResponse(RecallReport r) {
|
||||||
|
return new RecallReportResponse(
|
||||||
|
r.getBatchId(),
|
||||||
|
r.getStrainName(),
|
||||||
|
r.getBatchNumber(),
|
||||||
|
r.getReceivedDate(),
|
||||||
|
r.getTotalGramsDistributed(),
|
||||||
|
r.getAffectedMembers().stream()
|
||||||
|
.map(am -> new RecallReportResponse.AffectedMemberDto(
|
||||||
|
am.getMemberId(), am.getFirstName(), am.getLastName(),
|
||||||
|
am.getDistributionDate(), am.getGrams()))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.prevention.PreventionOfficerRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
|
import de.cannamanage.domain.entity.StaffAccount;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
|
import de.cannamanage.service.StaffService;
|
||||||
|
import de.cannamanage.service.StaffTemplates;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/staff")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Staff Management", description = "Staff CRUD + invite flow (ADMIN only)")
|
||||||
|
public class StaffController {
|
||||||
|
|
||||||
|
private final StaffService staffService;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "List all active staff members")
|
||||||
|
public ResponseEntity<List<StaffResponse>> listStaff() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
List<StaffAccount> staffList = staffService.listStaff(tenantId);
|
||||||
|
List<StaffResponse> response = staffList.stream()
|
||||||
|
.map(staff -> {
|
||||||
|
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||||
|
String email = user != null ? user.getEmail() : "unknown";
|
||||||
|
return StaffResponse.from(staff, email);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "Create staff member + send invite email")
|
||||||
|
public ResponseEntity<StaffResponse> createStaff(@Valid @RequestBody CreateStaffRequest request) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
StaffAccount staff = staffService.createStaff(
|
||||||
|
tenantId,
|
||||||
|
request.email(),
|
||||||
|
request.displayName(),
|
||||||
|
request.permissions(),
|
||||||
|
request.templateName()
|
||||||
|
);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(StaffResponse.from(staff, request.email()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "Get staff member by ID")
|
||||||
|
public ResponseEntity<StaffResponse> getStaff(@PathVariable UUID id) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
StaffAccount staff = staffService.getStaff(tenantId, id);
|
||||||
|
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||||
|
String email = user != null ? user.getEmail() : "unknown";
|
||||||
|
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||||
|
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||||
|
@RequestBody UpdateStaffRequest request) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
StaffAccount staff = staffService.updateStaff(
|
||||||
|
tenantId, id,
|
||||||
|
request.displayName(),
|
||||||
|
request.permissions(),
|
||||||
|
request.templateName(),
|
||||||
|
request.active()
|
||||||
|
);
|
||||||
|
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||||
|
String email = user != null ? user.getEmail() : "unknown";
|
||||||
|
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "Deactivate staff member (revokes all tokens)")
|
||||||
|
public ResponseEntity<Void> deactivateStaff(@PathVariable UUID id) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
staffService.deactivateStaff(tenantId, id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/prevention-officer")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "Assign or revoke prevention officer status",
|
||||||
|
description = "Sets prevention officer flag on a staff member. Enforces club.maxPreventionOfficers limit on assign.")
|
||||||
|
public ResponseEntity<StaffResponse> setPreventionOfficer(@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody PreventionOfficerRequest request) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
StaffAccount staff = preventionOfficerService.setPreventionOfficer(tenantId, id, request.preventionOfficer());
|
||||||
|
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||||
|
String email = user != null ? user.getEmail() : "unknown";
|
||||||
|
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/templates")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Operation(summary = "List available permission templates")
|
||||||
|
public ResponseEntity<Map<String, Set<StaffPermission>>> listTemplates() {
|
||||||
|
return ResponseEntity.ok(StaffTemplates.getAllTemplates());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/stock/batches")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Stock", description = "Batch and inventory management")
|
||||||
|
public class StockController {
|
||||||
|
|
||||||
|
private final BatchRepository batchRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all batches", description = "Returns all batches for the current tenant")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<List<BatchResponse>> listBatches() {
|
||||||
|
List<BatchResponse> batches = batchRepository.findAll().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(batches);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get batch by ID")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) {
|
||||||
|
Batch batch = batchRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "Batch not found"));
|
||||||
|
return ResponseEntity.ok(toResponse(batch));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) {
|
||||||
|
Batch batch = new Batch();
|
||||||
|
batch.setStrainId(request.strainId());
|
||||||
|
batch.setQuantityGrams(request.quantityGrams());
|
||||||
|
batch.setHarvestDate(request.harvestDate());
|
||||||
|
batch.setBatchCode(request.batchCode());
|
||||||
|
batch.setStatus(BatchStatus.AVAILABLE);
|
||||||
|
|
||||||
|
Batch saved = batchRepository.save(batch);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
private BatchResponse toResponse(Batch b) {
|
||||||
|
return new BatchResponse(
|
||||||
|
b.getId(),
|
||||||
|
b.getStrainId(),
|
||||||
|
b.getQuantityGrams(),
|
||||||
|
b.getHarvestDate(),
|
||||||
|
b.getBatchCode(),
|
||||||
|
b.getStatus(),
|
||||||
|
b.isContaminationFlag()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.service.StripeService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/webhooks")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StripeWebhookController {
|
||||||
|
|
||||||
|
private final StripeService stripeService;
|
||||||
|
|
||||||
|
@PostMapping("/stripe")
|
||||||
|
public ResponseEntity<String> handleStripeWebhook(
|
||||||
|
@RequestBody String payload,
|
||||||
|
@RequestHeader("Stripe-Signature") String sigHeader) {
|
||||||
|
try {
|
||||||
|
stripeService.handleWebhook(payload, sigHeader);
|
||||||
|
return ResponseEntity.ok("ok");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Stripe webhook processing failed: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import com.stripe.exception.StripeException;
|
||||||
|
import de.cannamanage.api.dto.billing.CheckoutRequest;
|
||||||
|
import de.cannamanage.api.dto.billing.SubscriptionResponse;
|
||||||
|
import de.cannamanage.domain.entity.Subscription;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.PlanTier;
|
||||||
|
import de.cannamanage.service.StripeService;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/billing")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Billing", description = "Subscription and payment management")
|
||||||
|
public class SubscriptionController {
|
||||||
|
|
||||||
|
private final StripeService stripeService;
|
||||||
|
private final ClubRepository clubRepository;
|
||||||
|
|
||||||
|
@GetMapping("/subscription")
|
||||||
|
@Operation(summary = "Get current subscription", description = "Returns the current plan and subscription status")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<SubscriptionResponse> getSubscription() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
UUID clubId = clubRepository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
|
||||||
|
.getId();
|
||||||
|
|
||||||
|
return stripeService.getSubscription(clubId)
|
||||||
|
.map(sub -> ResponseEntity.ok(toResponse(sub)))
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/checkout")
|
||||||
|
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
UUID clubId = clubRepository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
|
||||||
|
.getId();
|
||||||
|
|
||||||
|
PlanTier planTier = PlanTier.valueOf(request.planTier().toUpperCase());
|
||||||
|
String checkoutUrl = stripeService.createCheckoutSession(clubId, planTier);
|
||||||
|
return ResponseEntity.ok(Map.of("url", checkoutUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/portal")
|
||||||
|
@Operation(summary = "Create billing portal session", description = "Creates a Stripe Billing Portal session for self-service")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, String>> createPortalSession() throws StripeException {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
UUID clubId = clubRepository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
|
||||||
|
.getId();
|
||||||
|
|
||||||
|
String portalUrl = stripeService.createBillingPortalSession(clubId);
|
||||||
|
return ResponseEntity.ok(Map.of("url", portalUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionResponse toResponse(Subscription sub) {
|
||||||
|
return new SubscriptionResponse(
|
||||||
|
sub.getPlanTier().name(),
|
||||||
|
sub.getStatus().name(),
|
||||||
|
sub.getMemberLimit(),
|
||||||
|
sub.getTrialEndsAt(),
|
||||||
|
sub.getCurrentPeriodStart(),
|
||||||
|
sub.getCurrentPeriodEnd(),
|
||||||
|
sub.getCanceledAt(),
|
||||||
|
sub.getStripeSubscriptionId() != null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
|
package de.cannamanage.api.dto.billing;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CheckoutRequest(
|
||||||
|
@NotBlank String planTier
|
||||||
|
) {}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.api.dto.billing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record SubscriptionResponse(
|
||||||
|
String planTier,
|
||||||
|
String status,
|
||||||
|
int memberLimit,
|
||||||
|
Instant trialEndsAt,
|
||||||
|
Instant currentPeriodStart,
|
||||||
|
Instant currentPeriodEnd,
|
||||||
|
Instant canceledAt,
|
||||||
|
boolean hasStripeSubscription
|
||||||
|
) {}
|
||||||
@@ -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,15 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AddFeedingLogRequest(
|
||||||
|
@NotBlank String nutrientName,
|
||||||
|
@NotNull BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters,
|
||||||
|
BigDecimal phAfter,
|
||||||
|
BigDecimal ecAfter,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record AddPhotoRequest(
|
||||||
|
@NotBlank String filePath,
|
||||||
|
String caption
|
||||||
|
) {}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AddSensorReadingRequest(
|
||||||
|
@NotNull SensorReadingType readingType,
|
||||||
|
@NotNull BigDecimal value,
|
||||||
|
@NotBlank String unit
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AdvanceStageRequest(
|
||||||
|
@NotNull GrowStage stage
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CompleteHarvestRequest(
|
||||||
|
@NotNull BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CreateGrowEntryRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
UUID strainId,
|
||||||
|
String notes,
|
||||||
|
Instant expectedHarvestAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record FeedingLogResponse(
|
||||||
|
UUID id,
|
||||||
|
String nutrientName,
|
||||||
|
BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters,
|
||||||
|
BigDecimal phAfter,
|
||||||
|
BigDecimal ecAfter,
|
||||||
|
Instant fedAt,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowEntryDetailResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID strainId,
|
||||||
|
GrowStage status,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant expectedHarvestAt,
|
||||||
|
Instant actualHarvestAt,
|
||||||
|
BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId,
|
||||||
|
String notes,
|
||||||
|
List<GrowStageLogResponse> stages,
|
||||||
|
List<SensorReadingResponse> sensors,
|
||||||
|
List<GrowPhotoResponse> photos,
|
||||||
|
List<FeedingLogResponse> feedings
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowEntryResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID strainId,
|
||||||
|
GrowStage status,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant expectedHarvestAt,
|
||||||
|
Instant actualHarvestAt,
|
||||||
|
BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowPhotoResponse(
|
||||||
|
UUID id,
|
||||||
|
String filePath,
|
||||||
|
String caption,
|
||||||
|
Instant takenAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowStageLogResponse(
|
||||||
|
UUID id,
|
||||||
|
GrowStage stage,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant endedAt,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record SensorReadingResponse(
|
||||||
|
UUID id,
|
||||||
|
SensorReadingType readingType,
|
||||||
|
BigDecimal value,
|
||||||
|
String unit,
|
||||||
|
Instant recordedAt
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PreventionDataResponse(
|
||||||
|
UUID memberId,
|
||||||
|
String name,
|
||||||
|
boolean isUnder21,
|
||||||
|
int age,
|
||||||
|
long currentMonthDistributions,
|
||||||
|
BigDecimal gramsUsedThisMonth,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
BigDecimal quotaRemaining
|
||||||
|
) {}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record PreventionOfficerRequest(
|
||||||
|
@NotNull Boolean preventionOfficer
|
||||||
|
) {}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record Under21MemberResponse(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
int age,
|
||||||
|
LocalDate dateOfBirth,
|
||||||
|
long totalDistributionsThisMonth,
|
||||||
|
BigDecimal gramsUsedThisMonth,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
String quotaStatus
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the member list report.
|
||||||
|
*/
|
||||||
|
public record MemberListResponse(
|
||||||
|
Instant generatedAt,
|
||||||
|
List<MemberEntryDto> members
|
||||||
|
) {
|
||||||
|
public record MemberEntryDto(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String membershipNumber,
|
||||||
|
String status,
|
||||||
|
LocalDate joinDate,
|
||||||
|
int totalDistributions,
|
||||||
|
Instant lastDistributionDate
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the monthly distribution report.
|
||||||
|
*/
|
||||||
|
public record MonthlyReportResponse(
|
||||||
|
String month,
|
||||||
|
int totalDistributions,
|
||||||
|
BigDecimal totalGrams,
|
||||||
|
int uniqueMembers,
|
||||||
|
BigDecimal averagePerMember,
|
||||||
|
List<StrainSummaryDto> topStrains,
|
||||||
|
List<DailyEntryDto> dailyBreakdown
|
||||||
|
) {
|
||||||
|
public record StrainSummaryDto(String name, BigDecimal totalGrams, int distributionCount) {}
|
||||||
|
public record DailyEntryDto(LocalDate date, BigDecimal grams, int distributions) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the recall/batch trace report.
|
||||||
|
*/
|
||||||
|
public record RecallReportResponse(
|
||||||
|
UUID batchId,
|
||||||
|
String strainName,
|
||||||
|
String batchNumber,
|
||||||
|
LocalDate receivedDate,
|
||||||
|
BigDecimal totalGramsDistributed,
|
||||||
|
List<AffectedMemberDto> affectedMembers
|
||||||
|
) {
|
||||||
|
public record AffectedMemberDto(
|
||||||
|
UUID memberId,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
Instant distributionDate,
|
||||||
|
BigDecimal grams
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.api.dto.staff;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating a new staff member (admin invite flow).
|
||||||
|
*/
|
||||||
|
public record CreateStaffRequest(
|
||||||
|
@NotBlank @Email String email,
|
||||||
|
@NotBlank String displayName,
|
||||||
|
Set<StaffPermission> permissions,
|
||||||
|
String templateName
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.cannamanage.api.dto.staff;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.StaffAccount;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for staff member information.
|
||||||
|
*/
|
||||||
|
public record StaffResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID userId,
|
||||||
|
String email,
|
||||||
|
String displayName,
|
||||||
|
Set<StaffPermission> permissions,
|
||||||
|
String templateName,
|
||||||
|
boolean active,
|
||||||
|
boolean preventionOfficer,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
public static StaffResponse from(StaffAccount staff, User user) {
|
||||||
|
return new StaffResponse(
|
||||||
|
staff.getId(),
|
||||||
|
staff.getUserId(),
|
||||||
|
user.getEmail(),
|
||||||
|
staff.getDisplayName(),
|
||||||
|
staff.getGrantedPermissions(),
|
||||||
|
null, // templateName not stored; permissions are expanded
|
||||||
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
|
staff.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StaffResponse from(StaffAccount staff, String email) {
|
||||||
|
return new StaffResponse(
|
||||||
|
staff.getId(),
|
||||||
|
staff.getUserId(),
|
||||||
|
email,
|
||||||
|
staff.getDisplayName(),
|
||||||
|
staff.getGrantedPermissions(),
|
||||||
|
null,
|
||||||
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
|
staff.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.staff;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for updating an existing staff member.
|
||||||
|
*/
|
||||||
|
public record UpdateStaffRequest(
|
||||||
|
String displayName,
|
||||||
|
Set<StaffPermission> permissions,
|
||||||
|
String templateName,
|
||||||
|
Boolean active
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.api.dto.stock;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record BatchResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID strainId,
|
||||||
|
BigDecimal quantityGrams,
|
||||||
|
LocalDate harvestDate,
|
||||||
|
String batchCode,
|
||||||
|
BatchStatus status,
|
||||||
|
boolean contaminationFlag
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.cannamanage.api.dto.stock;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CreateBatchRequest(
|
||||||
|
@NotNull(message = "Strain ID is required")
|
||||||
|
UUID strainId,
|
||||||
|
|
||||||
|
@NotNull(message = "Quantity in grams is required")
|
||||||
|
@DecimalMin(value = "0.01", message = "Quantity must be greater than zero")
|
||||||
|
BigDecimal quantityGrams,
|
||||||
|
|
||||||
|
LocalDate harvestDate,
|
||||||
|
|
||||||
|
@NotBlank(message = "Batch code is required")
|
||||||
|
String batchCode
|
||||||
|
) {}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package de.cannamanage.api.exception;
|
||||||
|
|
||||||
|
import de.cannamanage.api.service.AuthService;
|
||||||
|
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||||
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global exception handler producing application/problem+json responses.
|
||||||
|
* RFC 9457 compliant.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(AuthService.AuthenticationException.class)
|
||||||
|
public ProblemDetail handleAuthException(AuthService.AuthenticationException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||||
|
problem.setTitle("Authentication Failed");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:AUTHENTICATION_FAILED"));
|
||||||
|
problem.setProperty("code", "AUTHENTICATION_FAILED");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(BadCredentialsException.class)
|
||||||
|
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.UNAUTHORIZED, "Invalid email or password");
|
||||||
|
problem.setTitle("Authentication Failed");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:INVALID_CREDENTIALS"));
|
||||||
|
problem.setProperty("code", "INVALID_CREDENTIALS");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.FORBIDDEN, "Access denied");
|
||||||
|
problem.setTitle("Forbidden");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:ACCESS_DENIED"));
|
||||||
|
problem.setProperty("code", "ACCESS_DENIED");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.BAD_REQUEST, "Validation failed");
|
||||||
|
problem.setTitle("Bad Request");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:VALIDATION_FAILED"));
|
||||||
|
problem.setProperty("code", "VALIDATION_FAILED");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
|
||||||
|
var errors = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
problem.setProperty("errors", errors);
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(QuotaExceededException.class)
|
||||||
|
public ProblemDetail handleQuotaExceeded(QuotaExceededException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problem.setTitle("Compliance Violation");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:QUOTA_EXCEEDED"));
|
||||||
|
problem.setProperty("code", ex.getCode().name());
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MemberNotFoundException.class)
|
||||||
|
public ProblemDetail handleMemberNotFound(MemberNotFoundException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.NOT_FOUND, ex.getMessage());
|
||||||
|
problem.setTitle("Not Found");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:MEMBER_NOT_FOUND"));
|
||||||
|
problem.setProperty("code", "MEMBER_NOT_FOUND");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(BatchNotFoundException.class)
|
||||||
|
public ProblemDetail handleBatchNotFound(BatchNotFoundException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.NOT_FOUND, ex.getMessage());
|
||||||
|
problem.setTitle("Not Found");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:BATCH_NOT_FOUND"));
|
||||||
|
problem.setProperty("code", "BATCH_NOT_FOUND");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(PreventionOfficerLimitExceededException.class)
|
||||||
|
public ProblemDetail handlePreventionOfficerLimitExceeded(PreventionOfficerLimitExceededException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problem.setTitle("Prevention Officer Limit Exceeded");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:PREVENTION_OFFICER_LIMIT_EXCEEDED"));
|
||||||
|
problem.setProperty("code", "PREVENTION_OFFICER_LIMIT_EXCEEDED");
|
||||||
|
problem.setProperty("maxAllowed", ex.getMaxAllowed());
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
|
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.valueOf(ex.getStatusCode().value()), ex.getReason());
|
||||||
|
problem.setTitle(HttpStatus.valueOf(ex.getStatusCode().value()).getReasonPhrase());
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ProblemDetail handleGeneric(Exception ex) {
|
||||||
|
log.error("Unhandled exception", ex);
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
|
||||||
|
problem.setTitle("Internal Server Error");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:INTERNAL_ERROR"));
|
||||||
|
problem.setProperty("code", "INTERNAL_ERROR");
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.service.TokenRevocationService;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT authentication filter.
|
||||||
|
* Extracts Bearer token from Authorization header, validates it,
|
||||||
|
* checks token blacklist (revocation), sets SecurityContext and TenantContext.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final TokenRevocationService tokenRevocationService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
final String authHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String token = authHeader.substring(7);
|
||||||
|
|
||||||
|
if (!jwtService.isTokenValid(token)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check token blacklist (revocation) — skip for portal paths per plan review warning #5
|
||||||
|
String jti = jwtService.extractJti(token);
|
||||||
|
if (jti != null && tokenRevocationService.isRevoked(jti)) {
|
||||||
|
log.debug("Token {} is revoked, rejecting request", jti);
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID userId = jwtService.extractUserId(token);
|
||||||
|
UUID tenantId = jwtService.extractTenantId(token);
|
||||||
|
String role = jwtService.extractRole(token);
|
||||||
|
|
||||||
|
// Set tenant context for schema routing
|
||||||
|
TenantContext.setCurrentTenant(tenantId);
|
||||||
|
|
||||||
|
// Build authentication with role-based authority
|
||||||
|
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
|
||||||
|
var authentication = new UsernamePasswordAuthenticationToken(
|
||||||
|
userId, null, authorities
|
||||||
|
);
|
||||||
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
log.debug("Authenticated user {} for tenant {} with role {}", userId, tenantId, role);
|
||||||
|
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
} finally {
|
||||||
|
TenantContext.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String path = request.getServletPath();
|
||||||
|
return path.startsWith("/api/v1/auth/")
|
||||||
|
|| path.startsWith("/portal/")
|
||||||
|
|| path.startsWith("/swagger-ui")
|
||||||
|
|| path.startsWith("/v3/api-docs");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.io.Decoders;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT token generation and validation service.
|
||||||
|
* Access tokens: 1 hour expiry, includes jti + permissions for STAFF.
|
||||||
|
* Refresh tokens: 30 days expiry.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
@Value("${cannamanage.security.jwt.secret}")
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
@Value("${cannamanage.security.jwt.access-token-expiry:3600}")
|
||||||
|
private long accessTokenExpiry; // seconds
|
||||||
|
|
||||||
|
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
||||||
|
private long refreshTokenExpiry; // seconds (30 days)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
||||||
|
*/
|
||||||
|
public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put("tenant_id", tenantId.toString());
|
||||||
|
claims.put("role", role);
|
||||||
|
claims.put("email", email);
|
||||||
|
claims.put("jti", UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
return buildToken(claims, userId.toString(), accessTokenExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate access token for STAFF role — includes permissions list.
|
||||||
|
*/
|
||||||
|
public String generateStaffAccessToken(UUID userId, UUID tenantId, String email, List<String> permissions) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put("tenant_id", tenantId.toString());
|
||||||
|
claims.put("role", "STAFF");
|
||||||
|
claims.put("email", email);
|
||||||
|
claims.put("jti", UUID.randomUUID().toString());
|
||||||
|
claims.put("permissions", permissions);
|
||||||
|
|
||||||
|
return buildToken(claims, userId.toString(), accessTokenExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateRefreshToken(UUID userId, UUID tenantId) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put("tenant_id", tenantId.toString());
|
||||||
|
claims.put("type", "refresh");
|
||||||
|
claims.put("jti", UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
return buildToken(claims, userId.toString(), refreshTokenExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractSubject(String token) {
|
||||||
|
return extractClaim(token, Claims::getSubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID extractUserId(String token) {
|
||||||
|
return UUID.fromString(extractSubject(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID extractTenantId(String token) {
|
||||||
|
return UUID.fromString(extractClaim(token, claims -> claims.get("tenant_id", String.class)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractRole(String token) {
|
||||||
|
return extractClaim(token, claims -> claims.get("role", String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractEmail(String token) {
|
||||||
|
return extractClaim(token, claims -> claims.get("email", String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the JTI (JWT ID) claim — used for token revocation.
|
||||||
|
*/
|
||||||
|
public String extractJti(String token) {
|
||||||
|
return extractClaim(token, claims -> claims.get("jti", String.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract permissions list from STAFF token.
|
||||||
|
* Returns empty list if not present (non-STAFF tokens).
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<String> extractPermissions(String token) {
|
||||||
|
return extractClaim(token, claims -> {
|
||||||
|
Object perms = claims.get("permissions");
|
||||||
|
if (perms instanceof List<?>) {
|
||||||
|
return (List<String>) perms;
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract token expiration as Instant — used for revocation record.
|
||||||
|
*/
|
||||||
|
public Instant extractExpirationInstant(String token) {
|
||||||
|
Date exp = extractClaim(token, Claims::getExpiration);
|
||||||
|
return exp.toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenValid(String token) {
|
||||||
|
try {
|
||||||
|
extractAllClaims(token);
|
||||||
|
return !isTokenExpired(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenExpired(String token) {
|
||||||
|
return extractExpiration(token).before(Date.from(Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Date extractExpiration(String token) {
|
||||||
|
return extractClaim(token, Claims::getExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
|
||||||
|
final Claims claims = extractAllClaims(token);
|
||||||
|
return resolver.apply(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Claims extractAllClaims(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildToken(Map<String, Object> extraClaims, String subject, long expirySeconds) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(extraClaims)
|
||||||
|
.subject(subject)
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plusSeconds(expirySeconds)))
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SecretKey getSigningKey() {
|
||||||
|
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom UserDetails principal for member portal sessions.
|
||||||
|
* Carries tenantId and memberId so portal controllers can enforce data scoping.
|
||||||
|
*/
|
||||||
|
public class PortalPrincipal extends User {
|
||||||
|
|
||||||
|
private final UUID tenantId;
|
||||||
|
private final UUID memberId;
|
||||||
|
|
||||||
|
public PortalPrincipal(String username, String password,
|
||||||
|
Collection<? extends GrantedAuthority> authorities,
|
||||||
|
UUID tenantId, UUID memberId) {
|
||||||
|
super(username, password, authorities);
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserDetailsService for portal session-based auth.
|
||||||
|
* Only loads MEMBER-role users who are active. Members log in by email.
|
||||||
|
*/
|
||||||
|
@Service("portalUserDetailsService")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PortalUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email));
|
||||||
|
|
||||||
|
// Only MEMBER role users may use the portal
|
||||||
|
if (user.getRole() != UserRole.ROLE_MEMBER) {
|
||||||
|
throw new UsernameNotFoundException("User is not a member");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be active
|
||||||
|
if (!user.isActive()) {
|
||||||
|
throw new UsernameNotFoundException("User account is inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a linked memberId
|
||||||
|
if (user.getMemberId() == null) {
|
||||||
|
throw new UsernameNotFoundException("User has no linked member profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorities = List.of(new SimpleGrantedAuthority("ROLE_MEMBER"));
|
||||||
|
|
||||||
|
return new PortalPrincipal(
|
||||||
|
user.getEmail(),
|
||||||
|
user.getPasswordHash(),
|
||||||
|
authorities,
|
||||||
|
user.getTenantId(),
|
||||||
|
user.getMemberId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,144 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
|
||||||
|
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
private final PortalUserDetailsService portalUserDetailsService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API security — stateless JWT authentication.
|
||||||
|
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(1)
|
||||||
|
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/api/**")
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
|
.requestMatchers("/api/v1/webhooks/**").permitAll()
|
||||||
|
.requestMatchers("/api/v1/billing/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
|
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
|
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||||
|
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
|
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||||
|
.anyRequest().authenticated())
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member portal — session-based authentication with CSRF protection.
|
||||||
|
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(2)
|
||||||
|
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/portal/**")
|
||||||
|
.csrf(csrf -> csrf
|
||||||
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||||
|
.maximumSessions(1))
|
||||||
|
.userDetailsService(portalUserDetailsService)
|
||||||
|
.formLogin(form -> form
|
||||||
|
.loginProcessingUrl("/portal/login")
|
||||||
|
.successHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"ok\"}");
|
||||||
|
})
|
||||||
|
.failureHandler((request, response, exception) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
|
||||||
|
})
|
||||||
|
.permitAll())
|
||||||
|
.logout(logout -> logout
|
||||||
|
.logoutUrl("/portal/logout")
|
||||||
|
.logoutSuccessHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"logged_out\"}");
|
||||||
|
}))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
|
||||||
|
.requestMatchers("/portal/**").hasRole("MEMBER"));
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public endpoints — Swagger UI, actuator health.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(3)
|
||||||
|
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
config.setAllowedOrigins(List.of(
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://frontend:3000"
|
||||||
|
));
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
config.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/api/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,22 @@
|
|||||||
|
# Docker profile — used when running in Docker Compose
|
||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||||
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||||
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
|
||||||
|
# Enable Flyway for container startup (fresh DB)
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
|
||||||
|
# JWT secret from environment
|
||||||
|
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||||
|
|
||||||
|
# Actuator
|
||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
# No SMTP container in this deployment — don't let the mail health indicator
|
||||||
|
# drag /actuator/health to DOWN (503), which would mark the container unhealthy.
|
||||||
|
management.health.mail.enabled=false
|
||||||
|
|
||||||
|
# Disable mail in Docker (no SMTP container)
|
||||||
|
spring.mail.host=localhost
|
||||||
|
spring.mail.port=1025
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Cannamanage — Production Profile
|
||||||
|
# =============================================================================
|
||||||
|
# Activated via: SPRING_PROFILES_ACTIVE=production
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||||
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||||
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
spring.datasource.hikari.maximum-pool-size=10
|
||||||
|
spring.datasource.hikari.minimum-idle=2
|
||||||
|
|
||||||
|
# JPA
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
|
||||||
|
# Flyway
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
|
||||||
|
# JWT Security
|
||||||
|
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
stripe.secret-key=${STRIPE_SECRET_KEY}
|
||||||
|
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
stripe.starter-price-id=${STRIPE_STARTER_PRICE_ID}
|
||||||
|
stripe.pro-price-id=${STRIPE_PRO_PRICE_ID}
|
||||||
|
|
||||||
|
# App
|
||||||
|
app.base-url=${APP_BASE_URL:https://app.cannamanage.de}
|
||||||
|
|
||||||
|
# Error handling — never expose internals
|
||||||
|
server.error.include-message=never
|
||||||
|
server.error.include-stacktrace=never
|
||||||
|
server.error.include-binding-errors=never
|
||||||
|
|
||||||
|
# Actuator — health only, no sensitive details
|
||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
|
||||||
|
# Logging — production levels
|
||||||
|
logging.level.root=WARN
|
||||||
|
logging.level.de.cannamanage=INFO
|
||||||
|
logging.level.org.springframework.security=WARN
|
||||||
|
logging.level.org.hibernate.SQL=OFF
|
||||||
|
|
||||||
|
# Disable Swagger in production
|
||||||
|
springdoc.api-docs.enabled=false
|
||||||
|
springdoc.swagger-ui.enabled=false
|
||||||
|
|
||||||
|
# App base URL
|
||||||
|
app.base-url=https://cannamanage.plate-software.de
|
||||||
@@ -1,4 +1,42 @@
|
|||||||
spring.application.name=cannamanage
|
spring.application.name=cannamanage
|
||||||
# Default profile — override with -Dspring.profiles.active=local
|
# Default profile — override with -Dspring.profiles.active=local
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
|
||||||
spring.flyway.enabled=false
|
spring.flyway.enabled=false
|
||||||
|
|
||||||
|
# JWT Security
|
||||||
|
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
|
# OpenAPI
|
||||||
|
springdoc.api-docs.path=/v3/api-docs
|
||||||
|
springdoc.swagger-ui.path=/swagger-ui.html
|
||||||
|
springdoc.swagger-ui.tags-sorter=alpha
|
||||||
|
springdoc.swagger-ui.operations-sorter=method
|
||||||
|
|
||||||
|
# Enable Spring AOP for TenantFilterAspect
|
||||||
|
spring.aop.auto=true
|
||||||
|
spring.aop.proxy-target-class=true
|
||||||
|
|
||||||
|
# Spring Mail (dev defaults: Mailpit on localhost:1025)
|
||||||
|
spring.mail.host=${SMTP_HOST:localhost}
|
||||||
|
spring.mail.port=${SMTP_PORT:1025}
|
||||||
|
spring.mail.username=${SMTP_USERNAME:}
|
||||||
|
spring.mail.password=${SMTP_PASSWORD:}
|
||||||
|
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:false}
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:false}
|
||||||
|
spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
|
||||||
|
|
||||||
|
# App base URL (for invite links)
|
||||||
|
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
# Actuator
|
||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
|
||||||
|
# Session configuration (member portal)
|
||||||
|
server.servlet.session.timeout=30m
|
||||||
|
server.servlet.session.cookie.same-site=strict
|
||||||
|
server.servlet.session.cookie.http-only=true
|
||||||
|
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- V10: Notifications table for real-time + persistent notification system
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
link VARCHAR(500),
|
||||||
|
read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notifications_user ON notifications(user_id, read, created_at DESC);
|
||||||
|
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id);
|
||||||
+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,21 @@
|
|||||||
|
-- V6: DSGVO Consent Management
|
||||||
|
-- Tracks user consent for data processing, marketing, analytics per GDPR Art. 6/7
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS consents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
consent_type VARCHAR(50) NOT NULL, -- 'DATA_PROCESSING', 'MARKETING', 'ANALYTICS'
|
||||||
|
granted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
granted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1, -- consent text version
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_consents_user ON consents(user_id);
|
||||||
|
CREATE INDEX idx_consents_tenant ON consents(tenant_id);
|
||||||
|
CREATE INDEX idx_consents_type ON consents(consent_type, user_id);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- V7: Stripe subscription management
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
stripe_customer_id VARCHAR(255) NOT NULL,
|
||||||
|
stripe_subscription_id VARCHAR(255),
|
||||||
|
plan_tier VARCHAR(20) NOT NULL DEFAULT 'TRIAL',
|
||||||
|
member_limit INTEGER NOT NULL DEFAULT 500,
|
||||||
|
trial_ends_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
current_period_start TIMESTAMP WITH TIME ZONE,
|
||||||
|
current_period_end TIMESTAMP WITH TIME ZONE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'TRIALING',
|
||||||
|
canceled_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_subscriptions_club ON subscriptions(club_id);
|
||||||
|
CREATE INDEX idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id);
|
||||||
|
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- V8: Immutable Audit Log (KCanG §19 — 10-year retention)
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
actor_id UUID NOT NULL,
|
||||||
|
actor_name VARCHAR(255) NOT NULL,
|
||||||
|
actor_role VARCHAR(20) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
metadata JSONB,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for efficient querying
|
||||||
|
CREATE INDEX idx_audit_timestamp ON audit_events(timestamp DESC);
|
||||||
|
CREATE INDEX idx_audit_entity ON audit_events(entity_type, entity_id);
|
||||||
|
CREATE INDEX idx_audit_actor ON audit_events(actor_id);
|
||||||
|
CREATE INDEX idx_audit_tenant ON audit_events(tenant_id);
|
||||||
|
CREATE INDEX idx_audit_type ON audit_events(event_type);
|
||||||
|
|
||||||
|
-- IMMUTABILITY: Revoke DELETE from application user
|
||||||
|
-- (In production, run as DBA: REVOKE DELETE ON audit_events FROM cannamanage_app;)
|
||||||
|
COMMENT ON TABLE audit_events IS 'Immutable audit log — 10-year retention (KCanG). Application role cannot DELETE.';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Grow entries (one per plant batch lifecycle)
|
||||||
|
CREATE TABLE IF NOT EXISTS grow_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
strain_id UUID REFERENCES strains(id),
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'SEEDLING',
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
expected_harvest_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
actual_harvest_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
harvested_grams NUMERIC(8,1),
|
||||||
|
linked_batch_id UUID REFERENCES batches(id),
|
||||||
|
notes TEXT,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grow stage transitions
|
||||||
|
CREATE TABLE IF NOT EXISTS grow_stage_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
stage VARCHAR(30) NOT NULL,
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sensor readings
|
||||||
|
CREATE TABLE IF NOT EXISTS sensor_readings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
reading_type VARCHAR(30) NOT NULL,
|
||||||
|
value NUMERIC(8,2) NOT NULL,
|
||||||
|
unit VARCHAR(10) NOT NULL,
|
||||||
|
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Photos
|
||||||
|
CREATE TABLE IF NOT EXISTS grow_photos (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
caption VARCHAR(255),
|
||||||
|
taken_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Feeding schedule entries
|
||||||
|
CREATE TABLE IF NOT EXISTS feeding_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
|
||||||
|
nutrient_name VARCHAR(100) NOT NULL,
|
||||||
|
amount_ml NUMERIC(8,1) NOT NULL,
|
||||||
|
water_liters NUMERIC(8,1),
|
||||||
|
ph_after NUMERIC(4,2),
|
||||||
|
ec_after NUMERIC(6,2),
|
||||||
|
fed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_grow_entries_tenant ON grow_entries(tenant_id);
|
||||||
|
CREATE INDEX idx_grow_entries_status ON grow_entries(status);
|
||||||
|
CREATE INDEX idx_sensor_readings_entry ON sensor_readings(grow_entry_id, recorded_at DESC);
|
||||||
|
CREATE INDEX idx_feeding_logs_entry ON feeding_logs(grow_entry_id, fed_at DESC);
|
||||||
|
CREATE INDEX idx_grow_photos_entry ON grow_photos(grow_entry_id);
|
||||||
|
CREATE INDEX idx_grow_stage_logs_entry ON grow_stage_logs(grow_entry_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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||||
|
import de.cannamanage.domain.entity.Club;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.repository.ClubRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for integration tests using Testcontainers PostgreSQL.
|
||||||
|
* Uses RestClient (Spring Boot 4 — TestRestTemplate was removed in Boot 4).
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@Testcontainers
|
||||||
|
@ActiveProfiles("integration")
|
||||||
|
public abstract class AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||||
|
.withDatabaseName("cannamanage_test")
|
||||||
|
.withUsername("test")
|
||||||
|
.withPassword("test");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", postgres::getUsername);
|
||||||
|
registry.add("spring.datasource.password", postgres::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
protected int port;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected ClubRepository clubRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected MemberRepository memberRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a RestClient configured with the test server's base URL.
|
||||||
|
* Configured to NOT throw on 4xx/5xx responses (so tests can assert status codes).
|
||||||
|
*/
|
||||||
|
protected RestClient restClient() {
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl("http://localhost:" + port)
|
||||||
|
.defaultStatusHandler(org.springframework.http.HttpStatusCode::isError, (req, res) -> {
|
||||||
|
// Don't throw — let tests inspect status codes directly
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth helper methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in with given credentials and returns the full LoginResponse.
|
||||||
|
*/
|
||||||
|
protected LoginResponse login(String email, String password) {
|
||||||
|
return restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest(email, password))
|
||||||
|
.retrieve()
|
||||||
|
.body(LoginResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: login and return just the access token.
|
||||||
|
*/
|
||||||
|
protected String getAccessToken(String email, String password) {
|
||||||
|
return login(email, password).accessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test data creation helpers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a club (tenant) and returns its ID.
|
||||||
|
*/
|
||||||
|
protected UUID createTestClub(String name) {
|
||||||
|
Club club = new Club();
|
||||||
|
club.setName(name);
|
||||||
|
club.setStatus(ClubStatus.ACTIVE);
|
||||||
|
club.setMaxMembers(500);
|
||||||
|
club.setMaxPreventionOfficers(3);
|
||||||
|
club = clubRepository.save(club);
|
||||||
|
return club.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an admin user for the given tenant and returns the User entity.
|
||||||
|
*/
|
||||||
|
protected User createAdminUser(UUID tenantId, String email, String password) {
|
||||||
|
User user = new User();
|
||||||
|
user.setTenantId(tenantId);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setRole(UserRole.ROLE_ADMIN);
|
||||||
|
user.setActive(true);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a member user for the portal (ROLE_MEMBER) linked to a Member entity.
|
||||||
|
*/
|
||||||
|
protected User createMemberUser(UUID tenantId, UUID memberId, String email, String password) {
|
||||||
|
User user = new User();
|
||||||
|
user.setTenantId(tenantId);
|
||||||
|
user.setMemberId(memberId);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
|
user.setRole(UserRole.ROLE_MEMBER);
|
||||||
|
user.setActive(true);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Member entity via API (requires admin token).
|
||||||
|
*/
|
||||||
|
protected MemberResponse createTestMember(String adminToken, String firstName, String lastName,
|
||||||
|
String email, LocalDate dateOfBirth) {
|
||||||
|
CreateMemberRequest request = new CreateMemberRequest(
|
||||||
|
firstName, lastName, email, dateOfBirth,
|
||||||
|
LocalDate.now(), "M-" + UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
return restClient().post()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(MemberResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Batch entity via API (requires admin token).
|
||||||
|
*/
|
||||||
|
protected BatchResponse createTestBatch(String adminToken, UUID strainId, BigDecimal quantity, String batchCode) {
|
||||||
|
CreateBatchRequest request = new CreateBatchRequest(strainId, quantity, LocalDate.now(), batchCode);
|
||||||
|
return restClient().post()
|
||||||
|
.uri("/api/v1/stock/batches")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(BatchResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Member entity directly in the DB (bypassing API / tenant filter).
|
||||||
|
*/
|
||||||
|
protected Member createMemberDirectly(UUID tenantId, String firstName, String lastName,
|
||||||
|
String email, LocalDate dateOfBirth) {
|
||||||
|
Member member = new Member();
|
||||||
|
member.setTenantId(tenantId);
|
||||||
|
member.setClubId(tenantId);
|
||||||
|
member.setFirstName(firstName);
|
||||||
|
member.setLastName(lastName);
|
||||||
|
member.setEmail(email);
|
||||||
|
member.setDateOfBirth(dateOfBirth);
|
||||||
|
member.setMembershipDate(LocalDate.now());
|
||||||
|
member.setMembershipNumber("M-" + UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
member.setUnder21(java.time.Period.between(dateOfBirth, LocalDate.now()).getYears() < 21);
|
||||||
|
return memberRepository.save(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Full authentication flow.
|
||||||
|
* Tests login, token refresh, revocation, and error cases.
|
||||||
|
*/
|
||||||
|
class AuthIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private static final String ADMIN_EMAIL = "auth-admin@test.de";
|
||||||
|
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Auth Test Club");
|
||||||
|
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Login with valid credentials returns JWT + refresh token")
|
||||||
|
void loginWithValidCredentials_returnsTokens() {
|
||||||
|
LoginResponse response = login(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
assertThat(response).isNotNull();
|
||||||
|
assertThat(response.accessToken()).isNotBlank();
|
||||||
|
assertThat(response.refreshToken()).isNotBlank();
|
||||||
|
assertThat(response.expiresIn()).isEqualTo(3600L);
|
||||||
|
assertThat(response.role()).isEqualTo("ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Access protected endpoint with JWT returns 200")
|
||||||
|
void accessProtectedEndpoint_withValidJwt_returns200() {
|
||||||
|
String token = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Refresh token returns new JWT pair")
|
||||||
|
void refreshToken_returnsNewTokenPair() {
|
||||||
|
LoginResponse loginResponse = login(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
ResponseEntity<LoginResponse> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new RefreshRequest(loginResponse.refreshToken()))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(LoginResponse.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
LoginResponse refreshed = response.getBody();
|
||||||
|
assertThat(refreshed).isNotNull();
|
||||||
|
assertThat(refreshed.accessToken()).isNotBlank();
|
||||||
|
assertThat(refreshed.refreshToken()).isNotBlank();
|
||||||
|
assertThat(refreshed.accessToken()).isNotEqualTo(loginResponse.accessToken());
|
||||||
|
assertThat(refreshed.refreshToken()).isNotEqualTo(loginResponse.refreshToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Old refresh token is invalidated after rotation")
|
||||||
|
void oldRefreshToken_afterRotation_isInvalid() {
|
||||||
|
LoginResponse loginResponse = login(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
|
String oldRefreshToken = loginResponse.refreshToken();
|
||||||
|
|
||||||
|
// Use refresh token once (rotation)
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new RefreshRequest(oldRefreshToken))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(LoginResponse.class);
|
||||||
|
|
||||||
|
// Try to use the old refresh token again — should fail
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new RefreshRequest(oldRefreshToken))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Login with wrong password returns 401")
|
||||||
|
void loginWithWrongPassword_returns401() {
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest(ADMIN_EMAIL, "WrongPassword!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Login with non-existent email returns 401")
|
||||||
|
void loginWithNonExistentEmail_returns401() {
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest("nobody@test.de", "whatever"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Access protected endpoint without token returns 401/403")
|
||||||
|
void accessProtectedEndpoint_withoutToken_returnsUnauthorized() {
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Inactive user cannot login")
|
||||||
|
void inactiveUser_cannotLogin() {
|
||||||
|
User inactiveUser = new User();
|
||||||
|
inactiveUser.setTenantId(tenantId);
|
||||||
|
inactiveUser.setEmail("inactive@test.de");
|
||||||
|
inactiveUser.setPasswordHash(passwordEncoder.encode("Test123!"));
|
||||||
|
inactiveUser.setRole(UserRole.ROLE_ADMIN);
|
||||||
|
inactiveUser.setActive(false);
|
||||||
|
userRepository.save(inactiveUser);
|
||||||
|
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest("inactive@test.de", "Test123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Portal session-based authentication.
|
||||||
|
* Verifies form login, session cookie, own-data access, and access denial.
|
||||||
|
*/
|
||||||
|
class PortalIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Portal Test Club");
|
||||||
|
|
||||||
|
// Create a member directly in DB
|
||||||
|
Member member = createMemberDirectly(tenantId, "Portal", "User",
|
||||||
|
"portal@test.de", LocalDate.of(1990, 5, 15));
|
||||||
|
memberId = member.getId();
|
||||||
|
|
||||||
|
// Create a MEMBER user linked to the member
|
||||||
|
createMemberUser(tenantId, memberId, "portal@test.de", "PortalPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal login with valid credentials returns 200 + session cookie")
|
||||||
|
void portalLogin_validCredentials_returnsOk() {
|
||||||
|
// Portal login is form-based — POST with x-www-form-urlencoded
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=PortalPass123!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).contains("ok");
|
||||||
|
|
||||||
|
// Session cookie should be set
|
||||||
|
assertThat(response.getHeaders().get("Set-Cookie")).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal dashboard accessible with session")
|
||||||
|
void portalDashboard_withSession_returns200() {
|
||||||
|
// Login first
|
||||||
|
ResponseEntity<String> loginResponse = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=PortalPass123!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Extract session cookie
|
||||||
|
String sessionCookie = loginResponse.getHeaders().getFirst("Set-Cookie");
|
||||||
|
if (sessionCookie != null) {
|
||||||
|
String cookieValue = sessionCookie.split(";")[0];
|
||||||
|
|
||||||
|
ResponseEntity<String> dashResponse = restClient().get()
|
||||||
|
.uri("/portal/dashboard")
|
||||||
|
.header("Cookie", cookieValue)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(dashResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal quota endpoint returns member's quota data")
|
||||||
|
void portalQuota_withSession_returns200() {
|
||||||
|
ResponseEntity<String> loginResponse = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=PortalPass123!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
String sessionCookie = loginResponse.getHeaders().getFirst("Set-Cookie");
|
||||||
|
if (sessionCookie != null) {
|
||||||
|
String cookieValue = sessionCookie.split(";")[0];
|
||||||
|
|
||||||
|
ResponseEntity<String> quotaResponse = restClient().get()
|
||||||
|
.uri("/portal/quota")
|
||||||
|
.header("Cookie", cookieValue)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(quotaResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal access without session returns unauthorized/redirect")
|
||||||
|
void portalAccess_withoutSession_returnsUnauthorized() {
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/portal/dashboard")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Portal login with invalid credentials returns 401")
|
||||||
|
void portalLogin_invalidCredentials_returns401() {
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/portal/login")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body("username=portal@test.de&password=WrongPassword!")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Report generation E2E.
|
||||||
|
* Verifies JSON, PDF, and CSV output for monthly reports and recall reports.
|
||||||
|
*/
|
||||||
|
class ReportIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StrainRepository strainRepository;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Report Test Club");
|
||||||
|
createAdminUser(tenantId, "report-admin@test.de", "AdminPass123!");
|
||||||
|
adminToken = getAccessToken("report-admin@test.de", "AdminPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Strain createTestStrain(String name) {
|
||||||
|
Strain strain = new Strain();
|
||||||
|
strain.setTenantId(tenantId);
|
||||||
|
strain.setName(name);
|
||||||
|
strain.setThcPercentage(new BigDecimal("18.5"));
|
||||||
|
strain.setCbdPercentage(new BigDecimal("0.5"));
|
||||||
|
TenantContext.setCurrentTenant(tenantId);
|
||||||
|
strain = strainRepository.save(strain);
|
||||||
|
TenantContext.clear();
|
||||||
|
return strain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Monthly report JSON — returns totals and distribution data")
|
||||||
|
void monthlyReportJson_returnsTotals() {
|
||||||
|
MemberResponse member = createTestMember(adminToken, "Report", "Member",
|
||||||
|
"report-member@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
|
||||||
|
Strain strain = createTestStrain("Test Strain");
|
||||||
|
BatchResponse batch = createTestBatch(adminToken, strain.getId(),
|
||||||
|
new BigDecimal("500.0"), "BATCH-R-001");
|
||||||
|
|
||||||
|
// Create a distribution
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
member.id(), batch.id(), new BigDecimal("5.0"), "Report test"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Get monthly report as JSON
|
||||||
|
String currentMonth = YearMonth.now().toString();
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=json")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody()).contains("totalDistributions");
|
||||||
|
assertThat(response.getBody()).contains("totalGrams");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Monthly report PDF — returns valid PDF bytes")
|
||||||
|
void monthlyReportPdf_returnsValidPdf() {
|
||||||
|
createTestMember(adminToken, "PDF", "Member",
|
||||||
|
"pdf-member@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
|
||||||
|
String currentMonth = YearMonth.now().toString();
|
||||||
|
ResponseEntity<byte[]> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=pdf")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody().length).isGreaterThan(0);
|
||||||
|
// PDF starts with %PDF
|
||||||
|
assertThat(new String(response.getBody(), 0, 4)).isEqualTo("%PDF");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Monthly report CSV — returns UTF-8 BOM + headers")
|
||||||
|
void monthlyReportCsv_returnsValidCsv() {
|
||||||
|
createTestMember(adminToken, "CSV", "Member",
|
||||||
|
"csv-member@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
|
||||||
|
String currentMonth = YearMonth.now().toString();
|
||||||
|
ResponseEntity<byte[]> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=csv")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody().length).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Check UTF-8 BOM (0xEF 0xBB 0xBF)
|
||||||
|
assertThat(response.getBody()[0]).isEqualTo((byte) 0xEF);
|
||||||
|
assertThat(response.getBody()[1]).isEqualTo((byte) 0xBB);
|
||||||
|
assertThat(response.getBody()[2]).isEqualTo((byte) 0xBF);
|
||||||
|
|
||||||
|
// Verify CSV has separator content
|
||||||
|
String csvContent = new String(response.getBody(), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
assertThat(csvContent).contains(";"); // German CSV uses semicolons
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Recall report — returns affected members for a batch")
|
||||||
|
void recallReport_returnsAffectedMembers() {
|
||||||
|
MemberResponse member1 = createTestMember(adminToken, "Recall", "One",
|
||||||
|
"recall1@test.de", LocalDate.of(1990, 3, 15));
|
||||||
|
MemberResponse member2 = createTestMember(adminToken, "Recall", "Two",
|
||||||
|
"recall2@test.de", LocalDate.of(1988, 7, 20));
|
||||||
|
|
||||||
|
Strain strain = createTestStrain("Recall Strain");
|
||||||
|
BatchResponse batch = createTestBatch(adminToken, strain.getId(),
|
||||||
|
new BigDecimal("1000.0"), "BATCH-RECALL-001");
|
||||||
|
|
||||||
|
// Both members get distributions from this batch
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
member1.id(), batch.id(), new BigDecimal("3.0"), "recall test 1"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
member2.id(), batch.id(), new BigDecimal("4.0"), "recall test 2"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Generate recall report for the batch
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/recall?batchId=" + batch.id() + "&format=json")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody()).contains("affectedMembers");
|
||||||
|
assertThat(response.getBody()).contains("Recall");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Non-admin cannot access reports")
|
||||||
|
void nonAdmin_cannotAccessReports() {
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=2026-01&format=json")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
|
import de.cannamanage.domain.entity.InviteToken;
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Staff invite → activate → permission check flow.
|
||||||
|
*/
|
||||||
|
class StaffPermissionIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InviteTokenRepository inviteTokenRepository;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Permission Test Club");
|
||||||
|
createAdminUser(tenantId, "perm-admin@test.de", "AdminPass123!");
|
||||||
|
adminToken = getAccessToken("perm-admin@test.de", "AdminPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Full staff lifecycle: invite → set-password → login → access endpoints")
|
||||||
|
void fullStaffLifecycle_inviteToAccess() {
|
||||||
|
// Step 1: Admin creates staff with RECORD_DISTRIBUTION permission
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
"staff1@test.de", "Staff One",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION), null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> createResponse = restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
assertThat(createResponse.getStatusCode().value()).isEqualTo(201);
|
||||||
|
assertThat(createResponse.getBody()).isNotNull();
|
||||||
|
assertThat(createResponse.getBody().email()).isEqualTo("staff1@test.de");
|
||||||
|
|
||||||
|
// Step 2: Get the invite token from DB
|
||||||
|
List<InviteToken> tokens = inviteTokenRepository.findAll();
|
||||||
|
InviteToken inviteToken = tokens.stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("No invite token found"));
|
||||||
|
|
||||||
|
// Step 3: Staff sets password via invite token
|
||||||
|
ResponseEntity<String> setPwResponse = restClient().post()
|
||||||
|
.uri("/api/v1/auth/set-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(setPwResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Step 4: Staff logs in
|
||||||
|
LoginResponse staffLogin = login("staff1@test.de", "StaffPass123!");
|
||||||
|
assertThat(staffLogin.role()).isEqualTo("STAFF");
|
||||||
|
String staffToken = staffLogin.accessToken();
|
||||||
|
|
||||||
|
// Step 5: Staff CAN access distributions endpoint (has RECORD_DISTRIBUTION)
|
||||||
|
ResponseEntity<String> distResponse = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(distResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Step 6: Staff CANNOT access stock endpoint (no VIEW_STOCK permission)
|
||||||
|
ResponseEntity<String> stockResponse = restClient().get()
|
||||||
|
.uri("/api/v1/stock/batches")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(stockResponse.getStatusCode().value()).isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Staff without VIEW_MEMBER_LIST cannot list members")
|
||||||
|
void staffWithoutViewMemberList_cannotListMembers() {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
"staff2@test.de", "Staff Two",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION), null);
|
||||||
|
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
// Activate
|
||||||
|
InviteToken inviteToken = inviteTokenRepository.findAll().stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/auth/set-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
String staffToken = getAccessToken("staff2@test.de", "StaffPass123!");
|
||||||
|
|
||||||
|
// Try to list members — should be forbidden
|
||||||
|
ResponseEntity<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Admin can update staff permissions")
|
||||||
|
void admin_canUpdateStaffPermissions() {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
"staff3@test.de", "Staff Three",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION), null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> createResp = restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
UUID staffId = createResp.getBody().id();
|
||||||
|
|
||||||
|
// Update permissions to add VIEW_STOCK
|
||||||
|
UpdateStaffRequest updateRequest = new UpdateStaffRequest(
|
||||||
|
"Staff Three",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK),
|
||||||
|
null, true);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> updateResp = restClient().put()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updateRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
assertThat(updateResp.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(updateResp.getBody().permissions())
|
||||||
|
.contains(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK);
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||||
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
|
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Multi-tenant data isolation.
|
||||||
|
* Verifies that Tenant A cannot see Tenant B's members (and vice versa).
|
||||||
|
*/
|
||||||
|
class TenantIsolationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private UUID tenantA;
|
||||||
|
private UUID tenantB;
|
||||||
|
private String tokenA;
|
||||||
|
private String tokenB;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantA = createTestClub("Club Alpha");
|
||||||
|
tenantB = createTestClub("Club Beta");
|
||||||
|
|
||||||
|
createAdminUser(tenantA, "admin-a@alpha.de", "AlphaPass123!");
|
||||||
|
createAdminUser(tenantB, "admin-b@beta.de", "BetaPass123!");
|
||||||
|
|
||||||
|
tokenA = getAccessToken("admin-a@alpha.de", "AlphaPass123!");
|
||||||
|
tokenB = getAccessToken("admin-b@beta.de", "BetaPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant A creates members — only visible to Tenant A")
|
||||||
|
void tenantA_createsMembers_onlyVisibleToTenantA() {
|
||||||
|
createTestMember(tokenA, "Anna", "Alpha", "anna@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
createTestMember(tokenA, "Alex", "Alpha", "alex@alpha.de", LocalDate.of(1985, 6, 20));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseA = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenA)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseA.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseA.getBody()).contains("Anna");
|
||||||
|
assertThat(responseA.getBody()).contains("Alex");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant B creates members — only visible to Tenant B")
|
||||||
|
void tenantB_createsMembers_onlyVisibleToTenantB() {
|
||||||
|
createTestMember(tokenB, "Bob", "Beta", "bob@beta.de", LocalDate.of(1992, 3, 10));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseB = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenB)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseB.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseB.getBody()).contains("Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant A cannot see Tenant B's members")
|
||||||
|
void tenantA_cannotSeeTenantB_members() {
|
||||||
|
createTestMember(tokenA, "Anna", "Alpha", "anna2@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
createTestMember(tokenB, "Bob", "Beta", "bob2@beta.de", LocalDate.of(1992, 3, 10));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseA = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenA)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseA.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseA.getBody()).contains("Anna");
|
||||||
|
assertThat(responseA.getBody()).doesNotContain("Bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Tenant B cannot see Tenant A's members")
|
||||||
|
void tenantB_cannotSeeTenantA_members() {
|
||||||
|
createTestMember(tokenA, "Anna", "Alpha", "anna3@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
createTestMember(tokenB, "Bob", "Beta", "bob3@beta.de", LocalDate.of(1992, 3, 10));
|
||||||
|
|
||||||
|
ResponseEntity<String> responseB = restClient().get()
|
||||||
|
.uri("/api/v1/members")
|
||||||
|
.header("Authorization", "Bearer " + tokenB)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseB.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseB.getBody()).contains("Bob");
|
||||||
|
assertThat(responseB.getBody()).doesNotContain("Anna");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Distributions are isolated between tenants")
|
||||||
|
void distributions_areIsolated_betweenTenants() {
|
||||||
|
MemberResponse memberA = createTestMember(tokenA, "Anna", "Alpha",
|
||||||
|
"anna4@alpha.de", LocalDate.of(1990, 1, 15));
|
||||||
|
|
||||||
|
BatchResponse batchA = createTestBatch(tokenA, UUID.randomUUID(),
|
||||||
|
new BigDecimal("100.0"), "BATCH-A-001");
|
||||||
|
|
||||||
|
// Create distribution for Tenant A
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + tokenA)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new CreateDistributionRequest(
|
||||||
|
memberA.id(), batchA.id(), new BigDecimal("5.0"), "Test distribution A"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
// Tenant B's distribution list should be empty
|
||||||
|
ResponseEntity<String> responseB = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + tokenB)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(responseB.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(responseB.getBody()).isEqualTo("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
package de.cannamanage.api.integration;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||||
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
|
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||||
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
|
import de.cannamanage.domain.entity.InviteToken;
|
||||||
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test: Token revocation E2E.
|
||||||
|
* Verifies that permission changes and deactivation properly revoke tokens.
|
||||||
|
*/
|
||||||
|
class TokenRevocationIntegrationTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InviteTokenRepository inviteTokenRepository;
|
||||||
|
|
||||||
|
private UUID tenantId;
|
||||||
|
private String adminToken;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
tenantId = createTestClub("Token Revocation Club");
|
||||||
|
createAdminUser(tenantId, "revoke-admin@test.de", "AdminPass123!");
|
||||||
|
adminToken = getAccessToken("revoke-admin@test.de", "AdminPass123!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Admin changes staff permissions → old JWT is rejected")
|
||||||
|
void adminChangesPermissions_oldJwtRejected() {
|
||||||
|
// Create and activate a staff member
|
||||||
|
String staffEmail = "revoke-staff1@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK));
|
||||||
|
|
||||||
|
// Staff logs in and gets a valid JWT
|
||||||
|
LoginResponse staffLogin = login(staffEmail, "StaffPass123!");
|
||||||
|
String oldStaffToken = staffLogin.accessToken();
|
||||||
|
|
||||||
|
// Verify old token works
|
||||||
|
ResponseEntity<String> beforeChange = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + oldStaffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(beforeChange.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Admin changes staff permissions (triggers revocation)
|
||||||
|
UpdateStaffRequest updateRequest = new UpdateStaffRequest(
|
||||||
|
"Revoke Staff 1",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION),
|
||||||
|
null, true);
|
||||||
|
|
||||||
|
restClient().put()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updateRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
// Old JWT should now be rejected (revoked)
|
||||||
|
ResponseEntity<String> afterChange = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + oldStaffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(afterChange.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Staff logs in again after permission change → gets new working JWT")
|
||||||
|
void staffLoginsAgain_afterPermissionChange_getsWorkingJwt() {
|
||||||
|
String staffEmail = "revoke-staff2@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK));
|
||||||
|
|
||||||
|
login(staffEmail, "StaffPass123!");
|
||||||
|
|
||||||
|
// Admin changes permissions
|
||||||
|
UpdateStaffRequest updateRequest = new UpdateStaffRequest(
|
||||||
|
"Revoke Staff 2",
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_MEMBER_LIST),
|
||||||
|
null, true);
|
||||||
|
|
||||||
|
restClient().put()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updateRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
// Staff logs in again — new JWT should work
|
||||||
|
LoginResponse newLogin = login(staffEmail, "StaffPass123!");
|
||||||
|
String newToken = newLogin.accessToken();
|
||||||
|
|
||||||
|
ResponseEntity<String> distResponse = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + newToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(distResponse.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Admin deactivates staff → all tokens revoked → 401")
|
||||||
|
void adminDeactivatesStaff_allTokensRevoked() {
|
||||||
|
String staffEmail = "revoke-staff3@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION));
|
||||||
|
|
||||||
|
LoginResponse staffLogin = login(staffEmail, "StaffPass123!");
|
||||||
|
String staffToken = staffLogin.accessToken();
|
||||||
|
|
||||||
|
// Verify token works before deactivation
|
||||||
|
ResponseEntity<String> before = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(before.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Admin deactivates staff
|
||||||
|
restClient().delete()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(Void.class);
|
||||||
|
|
||||||
|
// Old token should now be rejected
|
||||||
|
ResponseEntity<String> after = restClient().get()
|
||||||
|
.uri("/api/v1/distributions")
|
||||||
|
.header("Authorization", "Bearer " + staffToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
assertThat(after.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Deactivated staff cannot login")
|
||||||
|
void deactivatedStaff_cannotLogin() {
|
||||||
|
String staffEmail = "revoke-staff4@test.de";
|
||||||
|
UUID staffId = createAndActivateStaff(staffEmail,
|
||||||
|
Set.of(StaffPermission.RECORD_DISTRIBUTION));
|
||||||
|
|
||||||
|
// Deactivate
|
||||||
|
restClient().delete()
|
||||||
|
.uri("/api/v1/staff/" + staffId)
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(Void.class);
|
||||||
|
|
||||||
|
// Try to login — should fail
|
||||||
|
ResponseEntity<String> response = restClient().post()
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new LoginRequest(staffEmail, "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper ---
|
||||||
|
|
||||||
|
private UUID createAndActivateStaff(String email, Set<StaffPermission> permissions) {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
email, "Staff " + email.split("@")[0], permissions, null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> createResp = restClient().post()
|
||||||
|
.uri("/api/v1/staff")
|
||||||
|
.header("Authorization", "Bearer " + adminToken)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(createRequest)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(StaffResponse.class);
|
||||||
|
|
||||||
|
UUID staffId = createResp.getBody().id();
|
||||||
|
|
||||||
|
// Find the invite token
|
||||||
|
List<InviteToken> tokens = inviteTokenRepository.findAll();
|
||||||
|
InviteToken inviteToken = tokens.stream()
|
||||||
|
.filter(t -> t.getUsedAt() == null)
|
||||||
|
.reduce((first, second) -> second)
|
||||||
|
.orElseThrow(() -> new AssertionError("No invite token found"));
|
||||||
|
|
||||||
|
// Set password to activate
|
||||||
|
restClient().post()
|
||||||
|
.uri("/api/v1/auth/set-password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!"))
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
return staffId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,31 @@
|
|||||||
|
# Integration test profile — Testcontainers PostgreSQL (properties injected via @DynamicPropertySource)
|
||||||
|
spring.application.name=cannamanage-integration-test
|
||||||
|
|
||||||
|
# Flyway enabled — runs V1-V5 migrations against real PostgreSQL
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migration
|
||||||
|
|
||||||
|
# JPA
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.open-in-view=false
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
|
||||||
|
# JWT test secret (same as application.properties)
|
||||||
|
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
|
# AOP for TenantFilterAspect
|
||||||
|
spring.aop.auto=true
|
||||||
|
spring.aop.proxy-target-class=true
|
||||||
|
|
||||||
|
# Disable mail sending in integration tests
|
||||||
|
spring.mail.host=localhost
|
||||||
|
spring.mail.port=9999
|
||||||
|
spring.mail.properties.mail.smtp.auth=false
|
||||||
|
|
||||||
|
# App base URL
|
||||||
|
app.base-url=http://localhost:8080
|
||||||
|
|
||||||
|
# Session
|
||||||
|
server.servlet.session.timeout=30m
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
spring.application.name=cannamanage-test
|
||||||
|
spring.datasource.url=jdbc:h2:mem:cannamanage_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;NON_KEYWORDS=MONTH,YEAR
|
||||||
|
spring.datasource.username=sa
|
||||||
|
spring.datasource.password=
|
||||||
|
spring.datasource.driver-class-name=org.h2.Driver
|
||||||
|
|
||||||
|
# Let Hibernate create schema from entities
|
||||||
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
|
spring.jpa.open-in-view=false
|
||||||
|
spring.jpa.show-sql=true
|
||||||
|
spring.flyway.enabled=false
|
||||||
|
|
||||||
|
# JWT test secret
|
||||||
|
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
|
# AOP
|
||||||
|
spring.aop.auto=true
|
||||||
|
spring.aop.proxy-target-class=true
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.AuditEventType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable audit log entry.
|
||||||
|
* No setters for fields post-persist — once written, never changed.
|
||||||
|
* 10-year retention per KCanG compliance requirements.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "audit_events")
|
||||||
|
public class AuditEvent {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "event_type", nullable = false, updatable = false, length = 50)
|
||||||
|
private AuditEventType eventType;
|
||||||
|
|
||||||
|
@Column(name = "entity_type", nullable = false, updatable = false, length = 50)
|
||||||
|
private String entityType;
|
||||||
|
|
||||||
|
@Column(name = "entity_id", updatable = false)
|
||||||
|
private UUID entityId;
|
||||||
|
|
||||||
|
@Column(name = "actor_id", nullable = false, updatable = false)
|
||||||
|
private UUID actorId;
|
||||||
|
|
||||||
|
@Column(name = "actor_name", nullable = false, updatable = false)
|
||||||
|
private String actorName;
|
||||||
|
|
||||||
|
@Column(name = "actor_role", nullable = false, updatable = false, length = 20)
|
||||||
|
private String actorRole;
|
||||||
|
|
||||||
|
@Column(name = "description", nullable = false, updatable = false, columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "metadata", updatable = false, columnDefinition = "jsonb")
|
||||||
|
private String metadata;
|
||||||
|
|
||||||
|
@Column(name = "ip_address", updatable = false, length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "timestamp", nullable = false, updatable = false)
|
||||||
|
private Instant timestamp;
|
||||||
|
|
||||||
|
@Column(name = "tenant_id", nullable = false, updatable = false)
|
||||||
|
private UUID tenantId;
|
||||||
|
|
||||||
|
protected AuditEvent() {
|
||||||
|
// JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuditEvent(Builder builder) {
|
||||||
|
this.eventType = builder.eventType;
|
||||||
|
this.entityType = builder.entityType;
|
||||||
|
this.entityId = builder.entityId;
|
||||||
|
this.actorId = builder.actorId;
|
||||||
|
this.actorName = builder.actorName;
|
||||||
|
this.actorRole = builder.actorRole;
|
||||||
|
this.description = builder.description;
|
||||||
|
this.metadata = builder.metadata;
|
||||||
|
this.ipAddress = builder.ipAddress;
|
||||||
|
this.timestamp = Instant.now();
|
||||||
|
this.tenantId = builder.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only getters
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public AuditEventType getEventType() { return eventType; }
|
||||||
|
public String getEntityType() { return entityType; }
|
||||||
|
public UUID getEntityId() { return entityId; }
|
||||||
|
public UUID getActorId() { return actorId; }
|
||||||
|
public String getActorName() { return actorName; }
|
||||||
|
public String getActorRole() { return actorRole; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public String getMetadata() { return metadata; }
|
||||||
|
public String getIpAddress() { return ipAddress; }
|
||||||
|
public Instant getTimestamp() { return timestamp; }
|
||||||
|
public UUID getTenantId() { return tenantId; }
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private AuditEventType eventType;
|
||||||
|
private String entityType;
|
||||||
|
private UUID entityId;
|
||||||
|
private UUID actorId;
|
||||||
|
private String actorName;
|
||||||
|
private String actorRole;
|
||||||
|
private String description;
|
||||||
|
private String metadata;
|
||||||
|
private String ipAddress;
|
||||||
|
private UUID tenantId;
|
||||||
|
|
||||||
|
public Builder eventType(AuditEventType eventType) { this.eventType = eventType; return this; }
|
||||||
|
public Builder entityType(String entityType) { this.entityType = entityType; return this; }
|
||||||
|
public Builder entityId(UUID entityId) { this.entityId = entityId; return this; }
|
||||||
|
public Builder actorId(UUID actorId) { this.actorId = actorId; return this; }
|
||||||
|
public Builder actorName(String actorName) { this.actorName = actorName; return this; }
|
||||||
|
public Builder actorRole(String actorRole) { this.actorRole = actorRole; return this; }
|
||||||
|
public Builder description(String description) { this.description = description; return this; }
|
||||||
|
public Builder metadata(String metadata) { this.metadata = metadata; return this; }
|
||||||
|
public Builder ipAddress(String ipAddress) { this.ipAddress = ipAddress; return this; }
|
||||||
|
public Builder tenantId(UUID tenantId) { this.tenantId = tenantId; return this; }
|
||||||
|
|
||||||
|
public AuditEvent build() {
|
||||||
|
return new AuditEvent(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package de.cannamanage.domain.entity;
|
|||||||
import de.cannamanage.domain.enums.ClubStatus;
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "clubs")
|
@Table(name = "clubs")
|
||||||
public class Club extends AbstractTenantEntity {
|
public class Club extends AbstractTenantEntity {
|
||||||
@@ -10,6 +12,30 @@ public class Club extends AbstractTenantEntity {
|
|||||||
@Column(name = "name", nullable = false, length = 255)
|
@Column(name = "name", nullable = false, length = 255)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "registration_number", length = 100)
|
||||||
|
private String registrationNumber;
|
||||||
|
|
||||||
|
@Column(name = "contact_email", length = 255)
|
||||||
|
private String contactEmail;
|
||||||
|
|
||||||
|
@Column(name = "contact_phone", length = 50)
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
@Column(name = "address_street", length = 255)
|
||||||
|
private String addressStreet;
|
||||||
|
|
||||||
|
@Column(name = "address_city", length = 100)
|
||||||
|
private String addressCity;
|
||||||
|
|
||||||
|
@Column(name = "address_postal_code", length = 20)
|
||||||
|
private String addressPostalCode;
|
||||||
|
|
||||||
|
@Column(name = "address_state", length = 100)
|
||||||
|
private String addressState;
|
||||||
|
|
||||||
|
@Column(name = "founded_date")
|
||||||
|
private LocalDate foundedDate;
|
||||||
|
|
||||||
@Column(name = "address")
|
@Column(name = "address")
|
||||||
private String address;
|
private String address;
|
||||||
|
|
||||||
@@ -19,6 +45,12 @@ public class Club extends AbstractTenantEntity {
|
|||||||
@Column(name = "max_members", nullable = false)
|
@Column(name = "max_members", nullable = false)
|
||||||
private Integer maxMembers = 500;
|
private Integer maxMembers = 500;
|
||||||
|
|
||||||
|
@Column(name = "max_prevention_officers", nullable = false)
|
||||||
|
private Integer maxPreventionOfficers = 2;
|
||||||
|
|
||||||
|
@Column(name = "allowed_email_pattern", length = 255)
|
||||||
|
private String allowedEmailPattern;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false, length = 50)
|
@Column(name = "status", nullable = false, length = 50)
|
||||||
private ClubStatus status = ClubStatus.ACTIVE;
|
private ClubStatus status = ClubStatus.ACTIVE;
|
||||||
@@ -26,6 +58,30 @@ public class Club extends AbstractTenantEntity {
|
|||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = name; }
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getRegistrationNumber() { return registrationNumber; }
|
||||||
|
public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; }
|
||||||
|
|
||||||
|
public String getContactEmail() { return contactEmail; }
|
||||||
|
public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; }
|
||||||
|
|
||||||
|
public String getContactPhone() { return contactPhone; }
|
||||||
|
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
|
||||||
|
|
||||||
|
public String getAddressStreet() { return addressStreet; }
|
||||||
|
public void setAddressStreet(String addressStreet) { this.addressStreet = addressStreet; }
|
||||||
|
|
||||||
|
public String getAddressCity() { return addressCity; }
|
||||||
|
public void setAddressCity(String addressCity) { this.addressCity = addressCity; }
|
||||||
|
|
||||||
|
public String getAddressPostalCode() { return addressPostalCode; }
|
||||||
|
public void setAddressPostalCode(String addressPostalCode) { this.addressPostalCode = addressPostalCode; }
|
||||||
|
|
||||||
|
public String getAddressState() { return addressState; }
|
||||||
|
public void setAddressState(String addressState) { this.addressState = addressState; }
|
||||||
|
|
||||||
|
public LocalDate getFoundedDate() { return foundedDate; }
|
||||||
|
public void setFoundedDate(LocalDate foundedDate) { this.foundedDate = foundedDate; }
|
||||||
|
|
||||||
public String getAddress() { return address; }
|
public String getAddress() { return address; }
|
||||||
public void setAddress(String address) { this.address = address; }
|
public void setAddress(String address) { this.address = address; }
|
||||||
|
|
||||||
@@ -35,6 +91,12 @@ public class Club extends AbstractTenantEntity {
|
|||||||
public Integer getMaxMembers() { return maxMembers; }
|
public Integer getMaxMembers() { return maxMembers; }
|
||||||
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
|
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
|
||||||
|
|
||||||
|
public Integer getMaxPreventionOfficers() { return maxPreventionOfficers; }
|
||||||
|
public void setMaxPreventionOfficers(Integer maxPreventionOfficers) { this.maxPreventionOfficers = maxPreventionOfficers; }
|
||||||
|
|
||||||
|
public String getAllowedEmailPattern() { return allowedEmailPattern; }
|
||||||
|
public void setAllowedEmailPattern(String allowedEmailPattern) { this.allowedEmailPattern = allowedEmailPattern; }
|
||||||
|
|
||||||
public ClubStatus getStatus() { return status; }
|
public ClubStatus getStatus() { return status; }
|
||||||
public void setStatus(ClubStatus status) { this.status = status; }
|
public void setStatus(ClubStatus status) { this.status = status; }
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user