Compare commits

1 Commits

Author SHA1 Message Date
pplate c0dd3cba50 Initial commit 2026-04-06 11:22:05 +02:00
851 changed files with 2 additions and 122052 deletions
-15
View File
@@ -1,15 +0,0 @@
# CannaManage — Environment Variables
# Copy this file to .env and fill in the values.
# NEVER commit .env to git.
# Database
DB_PASSWORD=cannamanage_dev
# JWT Secret — must be valid base64 (used by Decoders.BASE64.decode in JwtService)
# Generate with: openssl rand -base64 48
JWT_SECRET=
# NextAuth / Auth.js secret — minimum 32 characters
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=
AUTH_SECRET=
-195
View File
@@ -1,195 +0,0 @@
name: CI — Build, Lint & Security Scan
# Runs on every push to main. Must pass before deploy.
# Security scans catch CVEs, license issues, and secrets BEFORE they reach prod.
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# Backend: compile + test + dependency audit
# ─────────────────────────────────────────────────────────────────────────────
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cannamanage_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Maven compile
run: ./mvnw compile -B -q -DskipTests -T 1C
- name: Maven test
run: ./mvnw test -B -T 1C
env:
CI_POSTGRES_URL: jdbc:postgresql://localhost:5432/cannamanage_test
CI_POSTGRES_USER: test
CI_POSTGRES_PASSWORD: test
- name: OWASP Dependency-Check (SCA)
run: |
./mvnw org.owasp:dependency-check-maven:check \
-DfailBuildOnCVSS=7 \
-DsuppressionFile=.snyk-maven-suppressions.xml \
-Dformats=JSON,HTML \
-B -q
# failBuildOnCVSS=7: High/Critical CVEs fail the build.
# Suppress known false positives in .snyk-maven-suppressions.xml.
- name: Upload dependency-check report
if: always()
uses: actions/upload-artifact@v3
with:
name: dependency-check-report
path: target/dependency-check-report.*
# ─────────────────────────────────────────────────────────────────────────────
# Frontend: lint + type-check + dependency audit
# ─────────────────────────────────────────────────────────────────────────────
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@10.8.1 --activate
- name: Install dependencies
run: cd cannamanage-frontend && pnpm install --frozen-lockfile
- name: Lint
run: cd cannamanage-frontend && pnpm lint
- name: Type check
run: cd cannamanage-frontend && pnpm type-check
- name: pnpm audit (SCA)
run: |
cd cannamanage-frontend
pnpm audit --audit-level=high
# Fails on High/Critical. Use .pnpmauditrc or --ignore for known exceptions.
# ─────────────────────────────────────────────────────────────────────────────
# Docker image security scan (Trivy)
# ─────────────────────────────────────────────────────────────────────────────
image-scan:
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Build images (parallel)
run: |
set -euo pipefail
docker build -t cannamanage-backend:scan -f Dockerfile.backend . &
PID1=$!
docker build -t cannamanage-frontend:scan -f cannamanage-frontend/Dockerfile cannamanage-frontend/ &
PID2=$!
wait $PID1 $PID2
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Scan backend image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--ignore-unfixed \
--format table \
cannamanage-backend:scan
- name: Scan frontend image
run: |
trivy image \
--severity HIGH,CRITICAL \
--exit-code 1 \
--ignore-unfixed \
--format table \
cannamanage-frontend:scan
- name: Scan backend image (full report — JSON)
if: always()
run: |
trivy image \
--format json \
--output trivy-backend.json \
cannamanage-backend:scan
- name: Scan frontend image (full report — JSON)
if: always()
run: |
trivy image \
--format json \
--output trivy-frontend.json \
cannamanage-frontend:scan
- name: Upload Trivy reports
if: always()
uses: actions/upload-artifact@v3
with:
name: trivy-reports
path: trivy-*.json
# ─────────────────────────────────────────────────────────────────────────────
# Secret detection (Gitleaks)
# ─────────────────────────────────────────────────────────────────────────────
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar -xz -C /usr/local/bin gitleaks
- name: Run Gitleaks
run: |
gitleaks detect \
--source . \
--report-format json \
--report-path gitleaks-report.json \
--exit-code 1
- name: Upload Gitleaks report
if: always()
uses: actions/upload-artifact@v3
with:
name: gitleaks-report
path: gitleaks-report.json
-133
View File
@@ -1,133 +0,0 @@
name: Deploy to TrueNAS
# Auto-deploy on push to main.
# Runs on the self-hosted Gitea Actions runner on TrueNAS.local
# (container: cannamanage-act-runner). The runner mounts the host Docker
# socket into the job container, so `docker compose` commands act on the
# TrueNAS Docker daemon and (re)build/restart the live cannamanage stack.
#
# The job checks the repo out into its own workspace and builds from there,
# so it always deploys exactly the pushed commit — it does NOT depend on the
# old /mnt/VM_SSD_Pool/cannamanage host checkout.
#
# Compose project name is pinned to "cannamanage" so it updates the existing
# containers and reuses the persistent "cannamanage_pgdata" volume on the host.
# Live host ports: frontend 3000, backend 8081->8080 (LAN, healthcheck/debug).
# db is internal-only (no host publish) — reachable as db:5432 on the compose net.
on:
push:
branches: [main]
# Avoid overlapping deploys if pushes land in quick succession.
concurrency:
group: truenas-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
env:
COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p cannamanage
# Production secrets — set in Gitea repo Settings → Actions → Secrets.
# AUTH_SECRET : NextAuth v5 session secret (rotating invalidates sessions)
# JWT_SECRET : base64 backend HMAC key (rotating invalidates all tokens)
# DB_PASSWORD : Postgres role password (must match the live DB role)
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
steps:
- name: Check out pushed commit
uses: actions/checkout@v4
- name: Show toolchain
run: |
set -euo pipefail
docker version --format 'docker {{.Server.Version}}'
docker compose version
# NOTE: Backend tests (mvn test) and frontend lint (pnpm lint) are run locally
# before pushing. The self-hosted act runner uses Docker-in-Docker which doesn't
# support volume mounts for nested containers. Tests remain a local-only gate.
- name: Build images
run: |
set -euo pipefail
$COMPOSE build
- name: Ensure DB up & reconcile role password
run: |
set -euo pipefail
# Start just the db first (idempotent — reuses the running container
# and the persistent cannamanage_pgdata volume).
$COMPOSE up -d db
echo "Waiting for db to accept connections ..."
for i in $(seq 1 20); do
if docker exec cannamanage-db pg_isready -U cannamanage -q; then break; fi
echo " attempt $i/20 — waiting 3s"; sleep 3
done
# POSTGRES_PASSWORD only applies on FIRST volume init, so the existing
# volume still holds the old role password. Force the live role to match
# the rotated ${DB_PASSWORD} so the backend can authenticate. Local
# socket connections inside the container use trust auth (no password).
# Skipped when the secret is unset to avoid blanking the dev password.
if [ -n "${DB_PASSWORD:-}" ]; then
docker exec cannamanage-db psql -U cannamanage -d cannamanage \
-c "ALTER USER cannamanage WITH PASSWORD '${DB_PASSWORD}';"
echo "✅ DB role password reconciled"
else
echo "⚠️ DB_PASSWORD secret not set — leaving role password unchanged"
fi
- name: Roll out stack
run: |
set -euo pipefail
$COMPOSE up -d --remove-orphans
- name: Wait for backend health
run: |
set -euo pipefail
echo "Waiting for backend health on :8081 ..."
for i in $(seq 1 20); do
if wget -q -O /dev/null http://192.168.188.119:8081/actuator/health; then
echo "✅ Backend healthy after ${i} attempt(s)"
exit 0
fi
echo " attempt $i/20 — waiting 6s"
sleep 6
done
echo "❌ Backend did not become healthy — recent logs:"
$COMPOSE logs --tail=40 backend
exit 1
- name: Verify frontend
run: |
set -euo pipefail
# Probe the frontend on its own loopback INSIDE the container via the
# bundled node runtime. This is network-namespace-independent (no
# reliance on the host port being wired during a mid-recreate window,
# which caused a transient false-failure previously) and needs no
# wget/curl in the image. Any HTTP status < 500 counts as "up" — the
# root path returns 307 -> /login when unauthenticated, which is healthy.
echo "Waiting for frontend on container loopback :3000 ..."
for i in $(seq 1 20); do
if docker exec cannamanage-frontend node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"; then
echo "✅ Frontend responding after ${i} attempt(s)"
exit 0
fi
echo " attempt $i/20 — waiting 5s"
sleep 5
done
echo "❌ Frontend did not respond — recent logs:"
$COMPOSE logs --tail=40 frontend
exit 1
- name: Prune dangling images
run: docker image prune -f || true
- name: Deployment summary
run: |
echo "=== CannaManage deployed to TrueNAS ==="
echo "Commit: ${GITHUB_SHA}"
echo "Backend: http://192.168.188.119:8081"
echo "Frontend: http://192.168.188.119:3000"
-19
View File
@@ -1,19 +0,0 @@
target/
*.class
*.jar
*.war
.idea/
*.iml
.DS_Store
*.swp
.mvn/wrapper/maven-wrapper.jar
# Frontend
cannamanage-frontend/node_modules/
cannamanage-frontend/.next/
cannamanage-frontend/.env.local
# Production secrets (never commit)
.env
~/
~/
-3
View File
@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
@@ -1,189 +0,0 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- navigation "Navigation Bar" [ref=e3]:
- generic [ref=e4]:
- link "Home" [ref=e5] [cursor=pointer]:
- /url: /
- img [ref=e6]
- link "Explore" [ref=e7] [cursor=pointer]:
- /url: /explore/repos
- link "Help" [ref=e8] [cursor=pointer]:
- /url: https://docs.gitea.com
- generic [ref=e9]:
- link "Register" [ref=e10] [cursor=pointer]:
- /url: /user/sign_up
- img [ref=e11]
- generic [ref=e13]: Register
- link "Sign In" [ref=e14] [cursor=pointer]:
- /url: /user/login
- img [ref=e15]
- generic [ref=e17]: Sign In
- generic [ref=e18]:
- generic [ref=e19]:
- generic [ref=e21]:
- generic [ref=e22]:
- img [ref=e24]
- generic [ref=e27]:
- link "pplate" [ref=e28] [cursor=pointer]:
- /url: /pplate
- text: /
- link "cannamanage" [ref=e29] [cursor=pointer]:
- /url: /pplate/cannamanage
- generic [ref=e30]:
- link "RSS Feed" [ref=e31] [cursor=pointer]:
- /url: /pplate/cannamanage.rss
- img [ref=e32]
- generic "Sign in to watch this repository." [ref=e35] [cursor=pointer]:
- button "Watch" [disabled]:
- img
- generic: Watch
- link "1" [ref=e36]:
- /url: /pplate/cannamanage/watchers
- generic "Sign in to star this repository." [ref=e38] [cursor=pointer]:
- button "Star" [disabled]:
- img
- generic: Star
- link "0" [ref=e39]:
- /url: /pplate/cannamanage/stars
- generic "Sign in to fork this repository.":
- generic:
- img
- generic: Fork
- link "0":
- /url: /pplate/cannamanage/forks
- navigation [ref=e41]:
- generic [ref=e42]:
- link "Code" [ref=e43] [cursor=pointer]:
- /url: /pplate/cannamanage/src/
- img [ref=e44]
- generic [ref=e46]: Code
- link "Issues 9" [ref=e47] [cursor=pointer]:
- /url: /pplate/cannamanage/issues
- img [ref=e48]
- generic [ref=e51]: Issues
- generic [ref=e52]: "9"
- link "Pull Requests" [ref=e53] [cursor=pointer]:
- /url: /pplate/cannamanage/pulls
- img [ref=e54]
- generic [ref=e56]: Pull Requests
- link "Actions" [ref=e57] [cursor=pointer]:
- /url: /pplate/cannamanage/actions
- img [ref=e58]
- generic [ref=e60]: Actions
- link "Packages" [ref=e61] [cursor=pointer]:
- /url: /pplate/cannamanage/packages
- img [ref=e62]
- generic [ref=e64]: Packages
- link "Projects" [ref=e65] [cursor=pointer]:
- /url: /pplate/cannamanage/projects
- img [ref=e66]
- generic [ref=e68]: Projects
- link "Releases" [ref=e69] [cursor=pointer]:
- /url: /pplate/cannamanage/releases
- img [ref=e70]
- generic [ref=e72]: Releases
- link "Wiki" [ref=e73] [cursor=pointer]:
- /url: /pplate/cannamanage/wiki
- img [ref=e74]
- generic [ref=e76]: Wiki
- link "Activity" [ref=e77] [cursor=pointer]:
- /url: /pplate/cannamanage/activity
- img [ref=e78]
- generic [ref=e80]: Activity
- generic [ref=e83]:
- generic [ref=e84]:
- generic [ref=e86]:
- generic "Failure" [ref=e87]:
- img [ref=e88]
- 'heading "ci(deploy): auto-deploy to TrueNAS via self-hosted Gitea Actions runner" [level=2] [ref=e90]'
- generic [ref=e91]:
- generic [ref=e92]:
- link "deploy.yml" [ref=e93] [cursor=pointer]:
- /url: /pplate/cannamanage/actions/?workflow=deploy.yml
- text: ":"
- text: Commit
- link "3b15d7439d" [ref=e94] [cursor=pointer]:
- /url: /pplate/cannamanage/commit/3b15d7439dceb6cb073f871a0955b0acd31630ee
- text: pushed by
- link "pplate" [ref=e95] [cursor=pointer]:
- /url: /pplate
- link "main" [ref=e97] [cursor=pointer]:
- /url: /pplate/cannamanage/src/branch/main
- generic [ref=e98]:
- generic [ref=e99]:
- link "Summary" [ref=e100] [cursor=pointer]:
- /url: /pplate/cannamanage/actions/runs/29
- img [ref=e101]
- generic [ref=e103]: Summary
- generic [ref=e105]: All jobs
- list [ref=e106]:
- listitem [ref=e107]:
- link "Failure deploy 3s" [ref=e108] [cursor=pointer]:
- /url: /pplate/cannamanage/actions/runs/29/jobs/57
- generic "Failure" [ref=e109]:
- img [ref=e110]
- generic [ref=e112]: deploy
- generic [ref=e113]: 3s
- generic [ref=e115]: Run Details
- list [ref=e116]:
- listitem [ref=e117]:
- link "Workflow file" [ref=e118] [cursor=pointer]:
- /url: /pplate/cannamanage/actions/runs/29/workflow
- img [ref=e119]
- generic [ref=e121]: Workflow file
- generic [ref=e123]:
- generic [ref=e124]:
- generic [ref=e125]:
- text: Triggered via push •
- generic "Jun 16, 2026, 6:52 PM" [ref=e126]: 1 hour ago
- generic [ref=e127]:
- generic "Failure" [ref=e128]:
- img [ref=e129]
- generic [ref=e131]: Failure
- text:
- generic [ref=e132]: "Total duration: 3s"
- generic [ref=e133]:
- generic [ref=e134]:
- heading "Workflow Dependencies" [level=4] [ref=e135]
- generic [ref=e136]:
- text: 1 jobs • 0 dependencies
- generic [ref=e137]: • 0% success
- generic [ref=e138]:
- button "Already at 100% zoom" [disabled]:
- img
- button "Reset view" [ref=e139] [cursor=pointer]:
- img [ref=e140]
- button "Zoom out (Ctrl/Cmd + scroll on graph)" [ref=e142] [cursor=pointer]:
- img [ref=e143]
- img [ref=e147]:
- generic "deploy" [ref=e148] [cursor=pointer]:
- generic:
- generic:
- generic "failure":
- img
- generic [ref=e151]:
- generic: deploy
- generic: 3s
- group "Footer" [ref=e152]:
- contentinfo "About Software" [ref=e153]:
- link "Powered by Gitea" [ref=e154] [cursor=pointer]:
- /url: https://about.gitea.com
- generic [ref=e155]: "Version: 1.26.2"
- generic [ref=e156]:
- text: "Page:"
- strong [ref=e157]: 3ms
- text: "Template:"
- strong [ref=e158]: 1ms
- group "Links" [ref=e159]:
- menu [ref=e160] [cursor=pointer]:
- generic [ref=e162]:
- img [ref=e163]
- text: Auto
- menu [ref=e165] [cursor=pointer]:
- generic [ref=e166]:
- img [ref=e167]
- text: English
- link "Licenses" [ref=e169] [cursor=pointer]:
- /url: /assets/licenses.txt
- link "API" [ref=e170] [cursor=pointer]:
- /url: /api/swagger
-19
View File
@@ -1,19 +0,0 @@
# Snyk (https://snyk.io) policy file — managed by Lumen
# Ignores documented false positives and accepted risks.
version: v1.25.0
language-settings:
java:
countUntriaged: false
ignore:
# CSRF disabled on stateless JWT API chain — intentional and correct per OWASP:
# "If your application does not use cookies for authentication, CSRF is not a risk."
# The API security filter chain (Order 1) uses Authorization: Bearer tokens only.
# The portal filter chain (Order 2) correctly enables CSRF via CookieCsrfTokenRepository.
SNYK-JAVA-ORGSPRINGFRAMEWORKSECURITY-CSRF:
- 'cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java':
reason: >-
Stateless JWT API — CSRF not applicable. Browser never auto-sends
Bearer tokens. Portal chain has CSRF enabled via CookieCsrfTokenRepository.
expires: 2027-06-19T00:00:00.000Z
created: 2026-06-19T07:00:00.000Z
-38
View File
@@ -1,38 +0,0 @@
# 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"]
+2 -86
View File
@@ -1,87 +1,3 @@
# CannaManage
# cannamanage
Full-stack management platform for German cannabis cultivation associations (Anbauvereinigungen) under the CanG/KCanG regulatory framework.
## Tech Stack
| Layer | Technology |
|-------|-----------|
| **Frontend** | Next.js 15, React 19, TypeScript, Tailwind CSS 4, shadcn/ui |
| **Backend** | Spring Boot 3.5, Java 17, Spring Security (JWT + session) |
| **Database** | PostgreSQL 16, Flyway migrations |
| **Infrastructure** | Docker Compose, Gitea Actions CI/CD, TrueNAS deployment |
## Project Structure
```
cannamanage/
├── cannamanage-api/ # Spring Boot REST API (entry point)
├── cannamanage-service/ # Business logic layer
├── cannamanage-domain/ # JPA entities, enums, value objects
├── cannamanage-frontend/ # Next.js frontend (pnpm)
├── deploy/ # Deployment scripts & nginx config
├── docker-compose.yml # Local development stack
└── .gitea/workflows/ # CI/CD pipeline
```
## Local Development
### Prerequisites
- Java 17+
- Maven 3.9+
- Node.js 22+ with pnpm 10+
- Docker & Docker Compose
### Backend
```bash
# Start PostgreSQL
docker compose up -d db
# Run Spring Boot
mvn spring-boot:run -f cannamanage-api/pom.xml -Dspring-boot.run.profiles=local
```
### Frontend
```bash
cd cannamanage-frontend
pnpm install
pnpm dev
```
The frontend runs on http://localhost:3000, backend on http://localhost:8080.
### Full Stack (Docker)
```bash
docker compose up --build
```
## Deployment
Push to `main` triggers the Gitea Actions CI pipeline which:
1. Runs backend tests (`mvn test`)
2. Runs frontend lint (`pnpm lint`)
3. Builds Docker images
4. Deploys to TrueNAS via Docker Compose
5. Verifies backend health + frontend availability
Manual deploy:
```bash
cd deploy && ./deploy.sh
```
## Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `CANNAMANAGE_SECURITY_JWT_SECRET` | JWT signing key (base64, 256-bit) | — (required) |
| `CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:3000` |
| `SMTP_HOST` / `SMTP_PORT` | Mail server for invites | `localhost:1025` |
| `SCHEDULERS_ENABLED` | Enable background jobs | `true` |
## License
Proprietary — Patrick Plate
CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
-172
View File
@@ -1,172 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cannamanage-api</artifactId>
<name>CannaManage — API (Spring Boot Entry Point)</name>
<dependencies>
<dependency>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-domain</artifactId>
</dependency>
<dependency>
<groupId>de.cannamanage</groupId>
<artifactId>cannamanage-service</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!--
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>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- JWT (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- OpenAPI / Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<!-- H2 for unit tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers PostgreSQL for integration tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Mail (invite flow) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 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>
<!-- Rate limiting (Bucket4j + Caffeine cache) -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1,27 +0,0 @@
package de.cannamanage.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* CannaManage Spring Boot application entry point.
* Sprint 2: REST API + Spring Security + OpenAPI.
*
* Multi-module scanning:
* - scanBasePackages: component scanning (controllers, services)
* - EnableJpaRepositories: Spring Data JPA repository interfaces
* - EntityScan: JPA entity detection across modules (Boot 4.0 relocated package)
*/
@SpringBootApplication(scanBasePackages = "de.cannamanage")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
@EnableScheduling
public class CannaManageApplication {
public static void main(String[] args) {
SpringApplication.run(CannaManageApplication.class, args);
}
}
@@ -1,35 +0,0 @@
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 {
}
@@ -1,34 +0,0 @@
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();
}
}
@@ -1,330 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.AssemblyProtocolService;
import de.cannamanage.service.AssemblyService;
import de.cannamanage.service.AssemblyService.AgendaItemInput;
import de.cannamanage.service.repository.MemberRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.time.Instant;
import java.util.*;
/**
* REST controller for general assembly (Mitgliederversammlung) management.
* Admin endpoints require MANAGE_ASSEMBLIES permission.
* Portal endpoints allow members to view assemblies they're invited to.
*/
@RestController
@RequestMapping("/api/v1")
public class AssemblyController {
private final AssemblyService assemblyService;
private final AssemblyProtocolService protocolService;
private final StaffPermissionChecker permissionChecker;
private final MemberRepository memberRepository;
public AssemblyController(AssemblyService assemblyService,
AssemblyProtocolService protocolService,
StaffPermissionChecker permissionChecker,
MemberRepository memberRepository) {
this.assemblyService = assemblyService;
this.protocolService = protocolService;
this.permissionChecker = permissionChecker;
this.memberRepository = memberRepository;
}
// === Admin Endpoints ===
@PostMapping("/assemblies")
public ResponseEntity<AssemblyResponse> createAssembly(
@Valid @RequestBody CreateAssemblyRequest request,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
var clubId = permissionChecker.getClubId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var agendaItems = request.agendaItems() != null
? request.agendaItems().stream()
.map(a -> new AgendaItemInput(a.title(), a.description(), a.itemType()))
.toList()
: List.<AgendaItemInput>of();
var assembly = assemblyService.createAssembly(clubId, request.title(), request.assemblyType(),
request.scheduledAt(), request.location(), request.quorumRequired(), userId, agendaItems);
return ResponseEntity.ok(toResponse(assembly));
}
@GetMapping("/assemblies")
public ResponseEntity<List<AssemblyResponse>> listAssemblies(@AuthenticationPrincipal UserDetails user) {
var clubId = permissionChecker.getClubId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assemblies = assemblyService.getAssemblies(clubId);
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
}
@GetMapping("/assemblies/{id}")
public ResponseEntity<AssemblyDetailResponse> getAssemblyDetail(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.getAssemblyDetail(id);
var agendaItems = assemblyService.getAgendaItems(id);
var attendees = assemblyService.getAttendees(id);
var votes = assemblyService.getVotes(id);
var quorum = assemblyService.calculateQuorum(id);
return ResponseEntity.ok(new AssemblyDetailResponse(
toResponse(assembly),
agendaItems.stream().map(this::toAgendaResponse).toList(),
attendees.stream().map(this::toAttendeeResponse).toList(),
votes.stream().map(this::toVoteResponse).toList(),
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
));
}
@PutMapping("/assemblies/{id}")
public ResponseEntity<AssemblyResponse> updateAssembly(
@PathVariable UUID id,
@Valid @RequestBody UpdateAssemblyRequest request,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.updateAssembly(id, request.title(), request.scheduledAt(),
request.location(), request.quorumRequired());
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/invite")
public ResponseEntity<AssemblyResponse> sendInvitations(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.sendInvitations(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/cancel")
public ResponseEntity<AssemblyResponse> cancelAssembly(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.cancelAssembly(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/start")
public ResponseEntity<AssemblyResponse> startAssembly(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.startAssembly(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/complete")
public ResponseEntity<AssemblyResponse> completeAssembly(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.completeAssembly(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/attendees")
public ResponseEntity<AttendeeResponse> checkInAttendee(
@PathVariable UUID id,
@Valid @RequestBody CheckInRequest request,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var attendee = assemblyService.checkInAttendee(id, request.memberId(), request.proxyForMemberId());
return ResponseEntity.ok(toAttendeeResponse(attendee));
}
@GetMapping("/assemblies/{id}/attendees")
public ResponseEntity<List<AttendeeResponse>> listAttendees(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var attendees = assemblyService.getAttendees(id);
return ResponseEntity.ok(attendees.stream().map(this::toAttendeeResponse).toList());
}
@PostMapping("/assemblies/{id}/votes")
public ResponseEntity<VoteResponse> createVote(
@PathVariable UUID id,
@Valid @RequestBody CreateVoteRequest request,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var vote = assemblyService.createVote(id, request.agendaItemId(), request.title(),
request.description(), request.voteType());
return ResponseEntity.ok(toVoteResponse(vote));
}
@PostMapping("/assemblies/votes/{voteId}/cast")
public ResponseEntity<VoteResponse> castVote(
@PathVariable UUID voteId,
@Valid @RequestBody CastVoteRequest request,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
var vote = assemblyService.castVote(voteId, request.memberId(), request.decision(), userId);
return ResponseEntity.ok(toVoteResponse(vote));
}
@PostMapping("/assemblies/votes/{voteId}/close")
public ResponseEntity<VoteResponse> closeVote(
@PathVariable UUID voteId,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var vote = assemblyService.closeVote(voteId);
return ResponseEntity.ok(toVoteResponse(vote));
}
@GetMapping("/assemblies/{id}/protocol")
public ResponseEntity<byte[]> downloadProtocol(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
byte[] pdf = protocolService.generateProtocol(id);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=protokoll-" + id + ".pdf")
.body(pdf);
}
// === Portal Endpoints ===
@GetMapping("/portal/assemblies")
public ResponseEntity<List<AssemblyResponse>> portalListAssemblies(
@AuthenticationPrincipal UserDetails user) {
var tenantId = permissionChecker.getTenantId(user);
var assemblies = assemblyService.getUpcomingAssemblies(tenantId);
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
}
@GetMapping("/portal/assemblies/{id}")
public ResponseEntity<AssemblyDetailResponse> portalGetAssemblyDetail(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var assembly = assemblyService.getAssemblyDetail(id);
var agendaItems = assemblyService.getAgendaItems(id);
var attendees = assemblyService.getAttendees(id);
var votes = assemblyService.getVotes(id);
var quorum = assemblyService.calculateQuorum(id);
return ResponseEntity.ok(new AssemblyDetailResponse(
toResponse(assembly),
agendaItems.stream().map(this::toAgendaResponse).toList(),
attendees.stream().map(this::toAttendeeResponse).toList(),
votes.stream().map(this::toVoteResponse).toList(),
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
));
}
// === DTOs ===
record CreateAssemblyRequest(
@NotBlank String title,
@NotNull AssemblyType assemblyType,
@NotNull Instant scheduledAt,
String location,
Integer quorumRequired,
List<AgendaItemRequest> agendaItems
) {}
record AgendaItemRequest(
@NotBlank String title,
String description,
@NotNull AgendaItemType itemType
) {}
record UpdateAssemblyRequest(
String title,
Instant scheduledAt,
String location,
Integer quorumRequired
) {}
record CheckInRequest(@NotNull UUID memberId, UUID proxyForMemberId) {}
record CreateVoteRequest(
@NotNull UUID agendaItemId,
@NotBlank String title,
String description,
@NotNull VoteType voteType
) {}
record CastVoteRequest(@NotNull UUID memberId, @NotNull VoteDecision decision) {}
record AssemblyResponse(
UUID id, String title, AssemblyType assemblyType, Instant scheduledAt,
String location, AssemblyStatus status, Instant invitationSentAt,
Integer quorumRequired, Instant openedAt, Instant closedAt, Instant createdAt
) {}
record AssemblyDetailResponse(
AssemblyResponse assembly,
List<AgendaItemResponse> agendaItems,
List<AttendeeResponse> attendees,
List<VoteResponse> votes,
QuorumResponse quorum
) {}
record AgendaItemResponse(UUID id, int position, String title, String description, AgendaItemType itemType) {}
record AttendeeResponse(UUID id, UUID memberId, Instant checkedInAt, UUID proxyForMemberId) {}
record VoteResponse(UUID id, UUID agendaItemId, String title, String description, VoteType voteType,
int yesCount, int noCount, int abstainCount, VoteResult result, Instant votedAt) {}
record QuorumResponse(long attendees, long totalMembers, int required, boolean quorumMet) {}
// === Mappers ===
private AssemblyResponse toResponse(Assembly a) {
return new AssemblyResponse(a.getId(), a.getTitle(), a.getAssemblyType(), a.getScheduledAt(),
a.getLocation(), a.getStatus(), a.getInvitationSentAt(), a.getQuorumRequired(),
a.getOpenedAt(), a.getClosedAt(), a.getCreatedAt());
}
private AgendaItemResponse toAgendaResponse(AssemblyAgendaItem i) {
return new AgendaItemResponse(i.getId(), i.getPosition(), i.getTitle(), i.getDescription(), i.getItemType());
}
private AttendeeResponse toAttendeeResponse(AssemblyAttendee a) {
return new AttendeeResponse(a.getId(), a.getMemberId(), a.getCheckedInAt(), a.getProxyForMemberId());
}
private VoteResponse toVoteResponse(AssemblyVote v) {
return new VoteResponse(v.getId(), v.getAgendaItemId(), v.getTitle(), v.getDescription(),
v.getVoteType(), v.getYesCount(), v.getNoCount(), v.getAbstainCount(),
v.getResult(), v.getVotedAt());
}
}
@@ -1,105 +0,0 @@
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()
);
}
}
}
@@ -1,74 +0,0 @@
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.security.LoginRateLimiter;
import de.cannamanage.api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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;
private final LoginRateLimiter loginRateLimiter;
@PostMapping("/login")
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
String ip = resolveClientIp(httpRequest);
if (!loginRateLimiter.tryAcquire(ip)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of(
"error", "rate_limited",
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
));
}
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."));
}
/**
* Returns the originating client IP, honouring X-Forwarded-For when present
* (so reverse-proxy / load-balancer setups still get per-client rate limits).
*/
private String resolveClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
int comma = xff.indexOf(',');
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
}
return request.getRemoteAddr();
}
}
@@ -1,314 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.bankimport.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.BankImportSession;
import de.cannamanage.domain.entity.BankTransaction;
import de.cannamanage.domain.entity.CsvColumnMapping;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MatchStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.bankimport.BankImportService;
import de.cannamanage.service.repository.CsvColumnMappingRepository;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — REST endpoints for the bank statement import wizard.
*
* <p>All endpoints live under {@code /api/v1/finance/import/*}. Access requires
* either {@link StaffPermission#FINANCE_IMPORT} or {@link StaffPermission#MANAGE_FINANCES}
* (ADMIN role always passes). Tenant scoping is implicit via {@link TenantContext}.
*
* <p>Endpoint overview:
* <ul>
* <li>{@code POST /finance/import/sessions} — multipart upload + parse (optional {@code mappingId} query)</li>
* <li>{@code GET /finance/import/sessions} — list all sessions for the tenant</li>
* <li>{@code GET /finance/import/sessions/{id}} — single session detail</li>
* <li>{@code GET /finance/import/sessions/{id}/transactions} — transactions, optional {@code ?status=} filter</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/confirm} — create payment from match</li>
* <li>{@code POST /finance/import/sessions/{id}/confirm-all} — bulk-confirm high-confidence matches</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/assign} — manual member assignment</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/skip} — drop transaction with reason</li>
* <li>{@code POST /finance/import/sessions/{id}/complete} — seal session (GoBD immutability)</li>
* <li>{@code GET /finance/import/csv-mappings} — list saved CSV mapping templates</li>
* <li>{@code POST /finance/import/csv-mappings} — create a CSV mapping template</li>
* <li>{@code DELETE /finance/import/csv-mappings/{id}} — remove a CSV mapping template</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/v1")
public class BankImportController {
private final BankImportService bankImportService;
private final StaffPermissionChecker permissionChecker;
private final CsvColumnMappingRepository mappingRepository;
public BankImportController(BankImportService bankImportService,
StaffPermissionChecker permissionChecker,
CsvColumnMappingRepository mappingRepository) {
this.bankImportService = bankImportService;
this.permissionChecker = permissionChecker;
this.mappingRepository = mappingRepository;
}
// === Sessions ===
/**
* Upload a bank statement file and parse it. Returns the persisted session with
* matching results so the frontend can immediately render the review table.
*/
@PostMapping(value = "/finance/import/sessions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportSessionResponse> uploadSession(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "mappingId", required = false) UUID mappingId,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
CsvColumnMapping mapping = null;
if (mappingId != null) {
mapping = mappingRepository.findById(mappingId)
.filter(m -> clubId.equals(m.getClubId()))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
}
BankImportSession session = bankImportService.uploadAndParse(clubId, userId, file, mapping);
return ResponseEntity.status(HttpStatus.CREATED).body(ImportSessionResponse.from(session));
}
/** List all import sessions for the current tenant, newest first. */
@GetMapping("/finance/import/sessions")
public ResponseEntity<List<ImportSessionResponse>> listSessions(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
List<ImportSessionResponse> sessions = bankImportService.getSessions(clubId).stream()
.map(ImportSessionResponse::from)
.toList();
return ResponseEntity.ok(sessions);
}
/** Detail view of a single session. */
@GetMapping("/finance/import/sessions/{id}")
public ResponseEntity<ImportSessionResponse> getSession(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
BankImportSession session = bankImportService.getSession(id);
ensureSameTenant(session.getClubId());
return ResponseEntity.ok(ImportSessionResponse.from(session));
}
/**
* Transactions belonging to a session, optionally filtered by match status.
* Drives the review table (typically called with {@code ?status=MATCHED} then
* with no filter for the full audit listing).
*/
@GetMapping("/finance/import/sessions/{id}/transactions")
public ResponseEntity<List<TransactionResponse>> listTransactions(
@PathVariable UUID id,
@RequestParam(value = "status", required = false) MatchStatus status,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
BankImportSession session = bankImportService.getSession(id);
ensureSameTenant(session.getClubId());
List<TransactionResponse> txns = bankImportService.getTransactions(id, status).stream()
.map(TransactionResponse::from)
.toList();
return ResponseEntity.ok(txns);
}
/** Confirm a single matched transaction → creates a {@code Payment} via {@code FinanceService}. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/confirm")
public ResponseEntity<TransactionResponse> confirmMatch(
@PathVariable UUID id,
@PathVariable UUID txnId,
@Valid @RequestBody ConfirmRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankTransaction txn = bankImportService.confirmMatch(id, txnId, request.memberId(), userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Bulk-confirm every {@code MATCHED} transaction with confidence ≥ 90 in the session. */
@PostMapping("/finance/import/sessions/{id}/confirm-all")
public ResponseEntity<BulkConfirmResponse> confirmAll(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankImportService.BulkConfirmResult result = bankImportService.confirmAllMatched(id, userId);
return ResponseEntity.ok(BulkConfirmResponse.from(result));
}
/** Manual assignment for unmatched transactions — sets {@code MATCHED} 100% but does NOT create a Payment yet. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/assign")
public ResponseEntity<TransactionResponse> assignManually(
@PathVariable UUID id,
@PathVariable UUID txnId,
@Valid @RequestBody AssignRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankTransaction txn = bankImportService.manualAssign(id, txnId, request.memberId(), userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Skip a transaction (e.g. refund, fee, non-member deposit) — stored with reason for audit trail. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/skip")
public ResponseEntity<TransactionResponse> skipTransaction(
@PathVariable UUID id,
@PathVariable UUID txnId,
@RequestBody(required = false) SkipRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
String reason = request != null ? request.reason() : null;
BankTransaction txn = bankImportService.skipTransaction(id, txnId, reason, userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Seal the session — sets status {@code COMPLETED}, after which no further mutations are permitted (GoBD §147 AO). */
@PostMapping("/finance/import/sessions/{id}/complete")
public ResponseEntity<ImportSessionResponse> completeSession(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankImportSession session = bankImportService.completeSession(id, userId);
return ResponseEntity.ok(ImportSessionResponse.from(session));
}
// === CSV Column Mappings ===
/** List saved CSV mapping templates for the current tenant. */
@GetMapping("/finance/import/csv-mappings")
public ResponseEntity<List<CsvColumnMapping>> listMappings(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(mappingRepository.findByClubId(clubId));
}
/**
* Create a new CSV mapping template. If {@code isDefault} is true, the existing
* default mapping (if any) is cleared so only one template stays default per club.
*/
@PostMapping("/finance/import/csv-mappings")
public ResponseEntity<CsvColumnMapping> createMapping(@Valid @RequestBody CreateMappingRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
CsvColumnMapping mapping = new CsvColumnMapping();
mapping.setClubId(clubId);
mapping.setName(request.name());
mapping.setDateColumn(request.dateColumn());
mapping.setAmountColumn(request.amountColumn());
mapping.setReferenceColumn(request.referenceColumn());
mapping.setCounterpartyColumn(request.counterpartyColumn());
mapping.setIbanColumn(request.ibanColumn());
if (request.delimiter() != null) {
mapping.setDelimiter(request.delimiter());
}
if (request.dateFormat() != null) {
mapping.setDateFormat(request.dateFormat());
}
if (request.decimalSeparator() != null) {
mapping.setDecimalSeparator(request.decimalSeparator());
}
if (request.skipHeaderRows() != null) {
mapping.setSkipHeaderRows(request.skipHeaderRows());
}
if (request.encoding() != null) {
mapping.setEncoding(request.encoding());
}
boolean wantsDefault = Boolean.TRUE.equals(request.isDefault());
mapping.setIsDefault(wantsDefault);
if (wantsDefault) {
Optional<CsvColumnMapping> existingDefault = mappingRepository.findByClubIdAndIsDefaultTrue(clubId);
existingDefault.ifPresent(existing -> {
existing.setIsDefault(false);
mappingRepository.save(existing);
});
}
CsvColumnMapping saved = mappingRepository.save(mapping);
log.info("CSV mapping created: id={} name={} club={}", saved.getId(), saved.getName(), clubId);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
/** Delete a CSV mapping template — only the owner tenant may delete. */
@DeleteMapping("/finance/import/csv-mappings/{id}")
public ResponseEntity<Void> deleteMapping(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
CsvColumnMapping mapping = mappingRepository.findById(id)
.filter(m -> clubId.equals(m.getClubId()))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
mappingRepository.delete(mapping);
log.info("CSV mapping deleted: id={} club={}", id, clubId);
return ResponseEntity.noContent().build();
}
// === Helpers ===
/**
* Permission gate that accepts either {@link StaffPermission#FINANCE_IMPORT} or
* {@link StaffPermission#MANAGE_FINANCES}. ADMIN passes both automatically inside
* {@link StaffPermissionChecker}.
*/
private void requireImportPermission(UserDetails principal) {
try {
permissionChecker.requirePermission(principal, StaffPermission.FINANCE_IMPORT);
} catch (AccessDeniedException denied) {
// Fall back to MANAGE_FINANCES — finance admins are implicitly authorized.
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
}
}
/**
* Defence-in-depth tenant check on top of Hibernate {@code @Filter} —
* ensures path-parameter IDs from one tenant cannot reach another tenant's session.
*/
private void ensureSameTenant(UUID sessionClubId) {
UUID currentTenant = TenantContext.getCurrentTenant();
if (sessionClubId == null || !sessionClubId.equals(currentTenant)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Import-Session nicht gefunden.");
}
}
}
@@ -1,100 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.BoardMember;
import de.cannamanage.domain.entity.BoardPosition;
import de.cannamanage.service.BoardService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
public class BoardController {
private final BoardService boardService;
public BoardController(BoardService boardService) {
this.boardService = boardService;
}
// --- Positions ---
@PostMapping("/board/positions")
public ResponseEntity<BoardPosition> createPosition(
@RequestParam UUID clubId,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
String description = (String) body.get("description");
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : 0;
BoardPosition pos = boardService.createPosition(clubId, title, description, sortOrder);
return ResponseEntity.ok(pos);
}
@GetMapping("/board/positions")
public ResponseEntity<List<BoardPosition>> getPositions(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getPositions(clubId));
}
@PutMapping("/board/positions/{id}")
public ResponseEntity<BoardPosition> updatePosition(
@PathVariable UUID id,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
String description = (String) body.get("description");
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : null;
Boolean isActive = body.containsKey("isActive") ? (Boolean) body.get("isActive") : null;
BoardPosition pos = boardService.updatePosition(id, title, description, sortOrder, isActive);
return ResponseEntity.ok(pos);
}
// --- Board Members ---
@PostMapping("/board/members")
public ResponseEntity<BoardMember> electBoardMember(
@RequestParam UUID clubId,
@RequestBody Map<String, Object> body,
Principal principal) {
UUID positionId = UUID.fromString((String) body.get("positionId"));
UUID memberId = UUID.fromString((String) body.get("memberId"));
LocalDate electedAt = LocalDate.parse((String) body.get("electedAt"));
LocalDate termStart = LocalDate.parse((String) body.get("termStart"));
LocalDate termEnd = body.get("termEnd") != null ? LocalDate.parse((String) body.get("termEnd")) : null;
UUID assemblyId = body.get("assemblyId") != null ? UUID.fromString((String) body.get("assemblyId")) : null;
UUID userId = UUID.fromString(principal.getName());
BoardMember bm = boardService.electBoardMember(clubId, positionId, memberId,
electedAt, termStart, termEnd, assemblyId, userId);
return ResponseEntity.ok(bm);
}
@GetMapping("/board")
public ResponseEntity<List<BoardMember>> getCurrentBoard(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
}
@GetMapping("/board/history")
public ResponseEntity<List<BoardMember>> getBoardHistory(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getBoardHistory(clubId));
}
@DeleteMapping("/board/members/{id}")
public ResponseEntity<Void> removeBoardMember(
@PathVariable UUID id,
@RequestParam UUID clubId,
Principal principal) {
UUID userId = UUID.fromString(principal.getName());
boardService.removeBoardMember(id, userId, clubId);
return ResponseEntity.noContent().build();
}
// Portal endpoint
@GetMapping("/portal/board")
public ResponseEntity<List<BoardMember>> getPortalBoard(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
}
}
@@ -1,94 +0,0 @@
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()
);
}
}
@@ -1,44 +0,0 @@
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);
}
}
@@ -1,73 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.domain.enums.ComplianceStatus;
import de.cannamanage.service.ComplianceDashboardService;
import de.cannamanage.service.RetentionService;
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.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Compliance Dashboard controller.
* Provides traffic-light compliance status, upcoming/overdue deadlines,
* and retention management endpoints.
*/
@RestController
@RequestMapping("/api/v1/compliance/dashboard")
@RequiredArgsConstructor
@Tag(name = "Compliance Dashboard", description = "Compliance status overview and retention management")
public class ComplianceDashboardController {
private final ComplianceDashboardService dashboardService;
private final RetentionService retentionService;
@GetMapping
@Operation(summary = "Get compliance dashboard status",
description = "Returns traffic-light status per compliance area + upcoming and overdue deadlines")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<ComplianceDashboardResponse> getDashboard(
@RequestParam(defaultValue = "30") int upcomingDays) {
UUID clubId = TenantContext.getCurrentTenant();
Map<ComplianceArea, ComplianceStatus> statusMap = dashboardService.getComplianceStatus(clubId);
List<ComplianceDeadline> upcoming = dashboardService.getUpcomingDeadlines(clubId, upcomingDays);
List<ComplianceDeadline> overdue = dashboardService.getOverdueDeadlines(clubId);
return ResponseEntity.ok(new ComplianceDashboardResponse(statusMap, upcoming, overdue));
}
@GetMapping("/retention")
@Operation(summary = "Get retention report",
description = "Shows what was deleted, what will be deleted, and retention schedule")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<RetentionService.RetentionReport> getRetentionReport() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(retentionService.getRetentionReport(clubId));
}
@PostMapping("/retention/preview")
@Operation(summary = "Preview retention actions (dry-run)",
description = "Shows what WOULD be affected by retention processing without making changes")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<RetentionService.RetentionPreview> previewRetention() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(retentionService.previewRetention(clubId));
}
public record ComplianceDashboardResponse(
Map<ComplianceArea, ComplianceStatus> status,
List<ComplianceDeadline> upcomingDeadlines,
List<ComplianceDeadline> overdueDeadlines
) {}
}
@@ -1,98 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
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.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* REST controller for compliance deadline management.
* Powers the compliance dashboard traffic-light system.
*/
@RestController
@RequestMapping("/api/v1/compliance/deadlines")
@RequiredArgsConstructor
@Tag(name = "Compliance Deadlines", description = "Manage compliance deadlines and due dates")
public class ComplianceDeadlineController {
private final ComplianceDeadlineRepository deadlineRepository;
@GetMapping
@Operation(summary = "List all deadlines (upcoming + overdue)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<ComplianceDeadline>> listDeadlines() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(deadlineRepository.findByTenantIdOrderByDueDateAsc(tenantId));
}
@PostMapping
@Operation(summary = "Create a new compliance deadline")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> createDeadline(@Valid @RequestBody CreateDeadlineRequest request) {
ComplianceDeadline deadline = new ComplianceDeadline();
deadline.setClubId(request.clubId());
deadline.setArea(request.area());
deadline.setTitle(request.title());
deadline.setDescription(request.description());
deadline.setDueDate(request.dueDate());
deadline.setIsRecurring(request.isRecurring() != null ? request.isRecurring() : false);
deadline.setRecurrenceRule(request.recurrenceRule());
return ResponseEntity.ok(deadlineRepository.save(deadline));
}
@PostMapping("/{id}/complete")
@Operation(summary = "Mark a deadline as complete")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> completeDeadline(
@PathVariable UUID id,
@Valid @RequestBody CompleteDeadlineRequest request) {
ComplianceDeadline deadline = deadlineRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Deadline not found: " + id));
deadline.setCompletedAt(Instant.now());
deadline.setCompletedBy(request.completedBy());
return ResponseEntity.ok(deadlineRepository.save(deadline));
}
@GetMapping("/overdue")
@Operation(summary = "List overdue (incomplete, past due date) deadlines")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<ComplianceDeadline>> listOverdue() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(
deadlineRepository.findByTenantIdAndCompletedAtIsNullOrderByDueDateAsc(tenantId)
.stream()
.filter(d -> d.getDueDate().isBefore(LocalDate.now()))
.toList()
);
}
public record CreateDeadlineRequest(
UUID clubId,
ComplianceArea area,
String title,
String description,
LocalDate dueDate,
Boolean isRecurring,
String recurrenceRule
) {}
public record CompleteDeadlineRequest(
UUID completedBy
) {}
}
@@ -1,191 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.DestructionMethod;
import de.cannamanage.domain.enums.TransportStatus;
import de.cannamanage.service.repository.*;
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.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* REST controller for KCanG §22 compliance records:
* destruction, transport, propagation sources, and prevention activities.
*/
@RestController
@RequestMapping("/api/v1/compliance")
@RequiredArgsConstructor
@Tag(name = "Compliance Records", description = "KCanG §22 record keeping for destruction, transport, propagation & prevention")
public class ComplianceRecordsController {
private final DestructionRecordRepository destructionRecordRepository;
private final TransportRecordRepository transportRecordRepository;
private final PropagationSourceRepository propagationSourceRepository;
private final PreventionActivityRepository preventionActivityRepository;
// === Destruction Records ===
@PostMapping("/destruction-records")
@Operation(summary = "Record a cannabis destruction event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<DestructionRecord> recordDestruction(@Valid @RequestBody CreateDestructionRequest request) {
DestructionRecord record = new DestructionRecord();
record.setClubId(request.clubId());
record.setBatchId(request.batchId());
record.setAmountGrams(request.amountGrams());
record.setDestructionMethod(request.destructionMethod());
record.setDescription(request.description());
record.setDestroyedAt(request.destroyedAt() != null ? request.destroyedAt() : Instant.now());
record.setWitnessedBy(request.witnessedBy());
record.setWitnessName(request.witnessName());
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(destructionRecordRepository.save(record));
}
@GetMapping("/destruction-records")
@Operation(summary = "List destruction records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<DestructionRecord>> listDestructionRecords() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(destructionRecordRepository.findByTenantIdOrderByDestroyedAtDesc(tenantId));
}
// === Transport Records ===
@PostMapping("/transport-records")
@Operation(summary = "Record a cannabis transport event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<TransportRecord> recordTransport(@Valid @RequestBody CreateTransportRequest request) {
TransportRecord record = new TransportRecord();
record.setClubId(request.clubId());
record.setDescription(request.description());
record.setTransportDate(request.transportDate());
record.setFromLocation(request.fromLocation());
record.setToLocation(request.toLocation());
record.setCarrierName(request.carrierName());
record.setAmountGrams(request.amountGrams());
record.setBatchId(request.batchId());
record.setStatus(request.status() != null ? request.status() : TransportStatus.PLANNED);
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(transportRecordRepository.save(record));
}
@GetMapping("/transport-records")
@Operation(summary = "List transport records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<TransportRecord>> listTransportRecords() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(transportRecordRepository.findByTenantIdOrderByTransportDateDesc(tenantId));
}
// === Propagation Sources ===
@PostMapping("/propagation-sources")
@Operation(summary = "Record a propagation source (seed/cutting receipt)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PropagationSource> recordPropagationSource(@Valid @RequestBody CreatePropagationSourceRequest request) {
PropagationSource record = new PropagationSource();
record.setClubId(request.clubId());
record.setSourceType(request.sourceType());
record.setSupplier(request.supplier());
record.setQuantity(request.quantity());
record.setStrainId(request.strainId());
record.setReceivedAt(request.receivedAt());
record.setDocumentationReference(request.documentationReference());
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(propagationSourceRepository.save(record));
}
@GetMapping("/propagation-sources")
@Operation(summary = "List propagation sources for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<PropagationSource>> listPropagationSources() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(propagationSourceRepository.findByTenantIdOrderByReceivedAtDesc(tenantId));
}
// === Prevention Activities ===
@PostMapping("/prevention-activities")
@Operation(summary = "Record a prevention/education activity per KCanG §23")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PreventionActivity> recordPreventionActivity(@Valid @RequestBody CreatePreventionActivityRequest request) {
PreventionActivity record = new PreventionActivity();
record.setClubId(request.clubId());
record.setActivityDate(request.activityDate());
record.setTitle(request.title());
record.setDescription(request.description());
record.setParticipantsCount(request.participantsCount());
record.setOfficerId(request.officerId());
return ResponseEntity.ok(preventionActivityRepository.save(record));
}
@GetMapping("/prevention-activities")
@Operation(summary = "List prevention activities for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<PreventionActivity>> listPreventionActivities() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(preventionActivityRepository.findByTenantIdOrderByActivityDateDesc(tenantId));
}
// === Request DTOs (inner records) ===
public record CreateDestructionRequest(
UUID clubId,
UUID batchId,
BigDecimal amountGrams,
DestructionMethod destructionMethod,
String description,
Instant destroyedAt,
UUID witnessedBy,
String witnessName,
UUID recordedBy
) {}
public record CreateTransportRequest(
UUID clubId,
String description,
LocalDate transportDate,
String fromLocation,
String toLocation,
String carrierName,
BigDecimal amountGrams,
UUID batchId,
TransportStatus status,
UUID recordedBy
) {}
public record CreatePropagationSourceRequest(
UUID clubId,
String sourceType,
String supplier,
Integer quantity,
UUID strainId,
LocalDate receivedAt,
String documentationReference,
UUID recordedBy
) {}
public record CreatePreventionActivityRequest(
UUID clubId,
LocalDate activityDate,
String title,
String description,
Integer participantsCount,
UUID officerId
) {}
}
@@ -1,112 +0,0 @@
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 jakarta.validation.Valid;
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(
@Valid @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) {}
}
@@ -1,95 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.RegisterDeviceRequest;
import de.cannamanage.domain.entity.DeviceToken;
import de.cannamanage.service.DeviceRegistrationService;
import de.cannamanage.service.push.WebPushSender;
import jakarta.validation.Valid;
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.Map;
import java.util.UUID;
/**
* Device token registration endpoints for push notifications.
* Any authenticated user can register/unregister their devices.
*/
@RestController
@RequestMapping("/api/v1/notifications/devices")
@RequiredArgsConstructor
public class DeviceRegistrationController {
private final DeviceRegistrationService deviceRegistrationService;
private final WebPushSender webPushSender;
/**
* Register a device token for push notifications.
*/
@PostMapping
public ResponseEntity<Map<String, Object>> registerDevice(
@Valid @RequestBody RegisterDeviceRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
try {
DeviceToken device = deviceRegistrationService.registerDevice(
userId, request.platform(), request.token(), request.deviceName());
return ResponseEntity.ok(Map.of(
"id", device.getId(),
"platform", device.getPlatform().name(),
"deviceName", device.getDeviceName() != null ? device.getDeviceName() : "",
"createdAt", device.getCreatedAt().toString()
));
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
/**
* List user's registered devices.
*/
@GetMapping
public ResponseEntity<?> listDevices(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
var devices = deviceRegistrationService.getDevices(userId);
var items = devices.stream().map(d -> Map.of(
"id", (Object) d.getId(),
"platform", d.getPlatform().name(),
"deviceName", d.getDeviceName() != null ? d.getDeviceName() : "",
"lastUsedAt", d.getLastUsedAt() != null ? d.getLastUsedAt().toString() : "",
"createdAt", d.getCreatedAt().toString()
)).toList();
return ResponseEntity.ok(Map.of("devices", items));
}
/**
* Unregister a device.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> unregisterDevice(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
deviceRegistrationService.unregisterDevice(id, userId);
return ResponseEntity.noContent().build();
}
/**
* Get the VAPID public key for Web Push subscription on the frontend.
*/
@GetMapping("/vapid-key")
public ResponseEntity<Map<String, String>> getVapidKey() {
return ResponseEntity.ok(Map.of(
"publicKey", webPushSender.getPublicKey(),
"configured", String.valueOf(webPushSender.isConfigured())
));
}
}
@@ -1,78 +0,0 @@
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()
);
}
}
@@ -1,113 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.Document;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import de.cannamanage.service.DocumentService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")
public class DocumentController {
private final DocumentService documentService;
public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}
/**
* Verify the requested document belongs to the caller's current tenant (club).
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
* just by guessing or enumerating the document UUID.
* Returns 404 (not 403) to avoid revealing document existence to other tenants.
*/
private Document loadOwnedDocument(UUID documentId) {
Document doc = documentService.getDocument(documentId);
UUID currentTenantId = TenantContext.getCurrentTenant();
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
// Return 404 to prevent information leakage about document existence across tenants
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
}
return doc;
}
@PostMapping("/documents/upload")
public ResponseEntity<Document> uploadDocument(
@RequestParam UUID clubId,
@RequestParam String title,
@RequestParam DocumentCategory category,
@RequestParam(defaultValue = "ALL_MEMBERS") DocumentAccessLevel accessLevel,
@RequestParam(required = false) String description,
@RequestParam("file") MultipartFile file,
Principal principal) throws IOException {
UUID userId = UUID.fromString(principal.getName());
Document doc = documentService.uploadDocument(clubId, title, category, accessLevel, description, file, userId);
return ResponseEntity.ok(doc);
}
@GetMapping("/documents")
public ResponseEntity<List<Document>> listDocuments(
@RequestParam UUID clubId,
@RequestParam(required = false) DocumentCategory category,
@RequestParam(required = false) DocumentAccessLevel accessLevel) {
List<Document> docs = documentService.listDocuments(clubId, category, accessLevel);
return ResponseEntity.ok(docs);
}
@GetMapping("/documents/{id}/download")
public ResponseEntity<byte[]> downloadDocument(@PathVariable UUID id) throws IOException {
Document doc = loadOwnedDocument(id);
byte[] content = documentService.downloadDocument(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"")
.contentType(MediaType.parseMediaType(doc.getContentType()))
.body(content);
}
@DeleteMapping("/documents/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public ResponseEntity<Void> deleteDocument(
@PathVariable UUID id,
@RequestParam UUID clubId,
Principal principal) throws IOException {
// Verify the document belongs to the caller's tenant before honouring the delete.
// Also reject if the supplied clubId param disagrees with the authenticated tenant.
Document doc = loadOwnedDocument(id);
UUID currentTenantId = TenantContext.getCurrentTenant();
if (!clubId.equals(currentTenantId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
}
UUID userId = UUID.fromString(principal.getName());
documentService.deleteDocument(id, userId, doc.getClubId());
return ResponseEntity.noContent().build();
}
@GetMapping("/documents/usage")
public ResponseEntity<Map<String, Long>> getStorageUsage(@RequestParam UUID clubId) {
long usage = documentService.getStorageUsage(clubId);
return ResponseEntity.ok(Map.of("bytesUsed", usage));
}
// Portal endpoint — only ALL_MEMBERS documents
@GetMapping("/portal/documents")
public ResponseEntity<List<Document>> getPortalDocuments(@RequestParam UUID clubId) {
List<Document> docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS);
return ResponseEntity.ok(docs);
}
}
@@ -1,70 +0,0 @@
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;
}
}
@@ -1,224 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.event.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.ClubEvent;
import de.cannamanage.domain.entity.EventRsvp;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.RsvpStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.EventService;
import de.cannamanage.service.repository.EventRsvpRepository;
import de.cannamanage.service.repository.MemberRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.time.Instant;
import java.util.*;
/**
* REST controller for club event management.
* Admin endpoints require MANAGE_INFO_BOARD permission.
* Portal endpoints are accessible to authenticated members.
*/
@RestController
@RequestMapping("/api/v1")
public class EventController {
private final EventService eventService;
private final EventRsvpRepository rsvpRepository;
private final MemberRepository memberRepository;
private final StaffPermissionChecker permissionChecker;
public EventController(EventService eventService,
EventRsvpRepository rsvpRepository,
MemberRepository memberRepository,
StaffPermissionChecker permissionChecker) {
this.eventService = eventService;
this.rsvpRepository = rsvpRepository;
this.memberRepository = memberRepository;
this.permissionChecker = permissionChecker;
}
// === Admin endpoints ===
@PostMapping("/events")
public ResponseEntity<EventResponse> createEvent(@Valid @RequestBody CreateEventRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
boolean postToInfoBoard = request.postToInfoBoard() == null || request.postToInfoBoard();
ClubEvent event = eventService.createEvent(
clubId, request.title(), request.description(), request.eventType(),
request.startAt(), request.endAt(), request.location(), request.maxAttendees(),
request.recurring(), request.recurrenceRule(), request.recurrenceEndDate(),
userId, postToInfoBoard
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(event, null));
}
@GetMapping("/events")
public ResponseEntity<List<EventResponse>> listEvents(
@RequestParam Instant from,
@RequestParam Instant to,
@AuthenticationPrincipal UserDetails principal) {
List<ClubEvent> events = eventService.listEvents(from, to);
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
List<EventResponse> responses = events.stream()
.map(e -> toResponse(e, memberId))
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/events/{id}")
public ResponseEntity<EventResponse> getEvent(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
ClubEvent event = eventService.getEvent(id);
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
return ResponseEntity.ok(toResponse(event, memberId));
}
@PutMapping("/events/{id}")
public ResponseEntity<EventResponse> updateEvent(@PathVariable UUID id,
@Valid @RequestBody UpdateEventRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
ClubEvent event = eventService.updateEvent(id, request.title(), request.description(),
request.eventType(), request.startAt(), request.endAt(), request.location(),
request.maxAttendees(), request.recurring(), request.recurrenceRule(),
request.recurrenceEndDate());
return ResponseEntity.ok(toResponse(event, null));
}
@DeleteMapping("/events/{id}")
public ResponseEntity<Void> cancelEvent(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
eventService.cancelEvent(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/events/{id}/rsvp")
public ResponseEntity<?> rsvp(@PathVariable UUID id,
@Valid @RequestBody RsvpRequest request,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
if (memberId == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
EventRsvp rsvp = eventService.rsvp(id, memberId, request.status());
return ResponseEntity.ok(Map.of(
"status", rsvp.getStatus(),
"respondedAt", rsvp.getRespondedAt()
));
} catch (IllegalStateException e) {
if ("EVENT_FULL".equals(e.getMessage())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "EVENT_FULL", "message", "Veranstaltung ist ausgebucht"));
}
throw e;
}
}
@GetMapping("/events/{id}/attendees")
public ResponseEntity<List<RsvpResponse>> getAttendees(@PathVariable UUID id) {
List<EventRsvp> rsvps = eventService.getAttendees(id);
List<RsvpResponse> responses = rsvps.stream()
.map(r -> {
String memberName = memberRepository.findById(r.getMemberId())
.map(m -> m.getFirstName() + " " + m.getLastName())
.orElse("Unknown");
return new RsvpResponse(r.getMemberId(), memberName, r.getStatus(), r.getRespondedAt());
})
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/events/{id}/ical")
public ResponseEntity<String> downloadIcal(@PathVariable UUID id) {
String ical = eventService.generateIcal(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"event.ics\"")
.contentType(MediaType.parseMediaType("text/calendar"))
.body(ical);
}
// === Portal endpoints ===
@GetMapping("/portal/events")
public ResponseEntity<List<EventResponse>> portalEvents(@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
List<ClubEvent> events = eventService.listUpcomingEvents(10);
List<EventResponse> responses = events.stream()
.map(e -> toResponse(e, memberId))
.toList();
return ResponseEntity.ok(responses);
}
@PostMapping("/portal/events/{id}/rsvp")
public ResponseEntity<?> portalRsvp(@PathVariable UUID id,
@Valid @RequestBody RsvpRequest request,
@AuthenticationPrincipal UserDetails principal) {
return rsvp(id, request, principal);
}
// === Helpers ===
private EventResponse toResponse(ClubEvent event, UUID memberId) {
Map<RsvpStatus, Long> counts = new HashMap<>();
RsvpStatus myStatus = null;
if (event.getId() != null) {
try {
counts = eventService.getAttendeeCounts(event.getId());
if (memberId != null) {
myStatus = rsvpRepository.findByEventIdAndMemberId(event.getId(), memberId)
.map(EventRsvp::getStatus)
.orElse(null);
}
} catch (Exception e) {
// Virtual expanded events may not have a DB id
}
}
return new EventResponse(
event.getId(),
event.getTitle(),
event.getDescription(),
event.getEventType(),
event.getStartAt(),
event.getEndAt(),
event.getLocation(),
event.getMaxAttendees(),
event.isRecurring(),
event.getRecurrenceRule(),
event.getRecurrenceEndDate(),
event.getCreatedBy(),
event.getCreatedAt(),
counts,
myStatus
);
}
private UUID getMemberIdForUser(UUID userId) {
return memberRepository.findByUserId(userId)
.map(m -> m.getId())
.orElse(null);
}
}
@@ -1,365 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.finance.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.PaymentStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.FinanceService;
import de.cannamanage.service.FinancialReportService;
import de.cannamanage.service.ReceiptPdfService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PaymentRepository;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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.time.LocalDate;
import java.util.*;
/**
* REST controller for club treasury management.
* Admin endpoints require MANAGE_FINANCES or VIEW_FINANCES permission.
* Portal endpoints allow members to view their own payment history and balance.
*/
@RestController
@RequestMapping("/api/v1")
public class FinanceController {
private final FinanceService financeService;
private final StaffPermissionChecker permissionChecker;
private final MemberRepository memberRepository;
private final ReceiptPdfService receiptPdfService;
private final FinancialReportService financialReportService;
private final ClubRepository clubRepository;
public FinanceController(FinanceService financeService,
StaffPermissionChecker permissionChecker,
MemberRepository memberRepository,
ReceiptPdfService receiptPdfService,
FinancialReportService financialReportService,
ClubRepository clubRepository) {
this.financeService = financeService;
this.permissionChecker = permissionChecker;
this.memberRepository = memberRepository;
this.receiptPdfService = receiptPdfService;
this.financialReportService = financialReportService;
this.clubRepository = clubRepository;
}
// === Fee Schedules ===
@PostMapping("/finance/fee-schedules")
public ResponseEntity<FeeSchedule> createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
FeeSchedule schedule = financeService.createFeeSchedule(
clubId, request.name(), request.amountCents(), request.interval(),
request.isDefault() != null && request.isDefault()
);
return ResponseEntity.status(HttpStatus.CREATED).body(schedule);
}
@GetMapping("/finance/fee-schedules")
public ResponseEntity<List<FeeSchedule>> listFeeSchedules(@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getActiveFeeSchedules(clubId));
}
@PutMapping("/finance/fee-schedules/{id}")
public ResponseEntity<FeeSchedule> updateFeeSchedule(@PathVariable UUID id,
@Valid @RequestBody UpdateFeeScheduleRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
FeeSchedule updated = financeService.updateFeeSchedule(
id, request.name(), request.amountCents(), request.interval(), request.isDefault()
);
return ResponseEntity.ok(updated);
}
@PostMapping("/finance/fee-schedules/{id}/deactivate")
public ResponseEntity<Void> deactivateFeeSchedule(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
financeService.deactivateFeeSchedule(id);
return ResponseEntity.noContent().build();
}
// === Fee Assignment ===
@PostMapping("/finance/members/{memberId}/assign-fee")
public ResponseEntity<MemberFeeAssignment> assignFeeSchedule(@PathVariable UUID memberId,
@Valid @RequestBody AssignFeeRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
MemberFeeAssignment assignment = financeService.assignFeeSchedule(
memberId, clubId, request.feeScheduleId(), request.validFrom()
);
return ResponseEntity.status(HttpStatus.CREATED).body(assignment);
}
// === Payments ===
@PostMapping("/finance/payments")
public ResponseEntity<Payment> recordPayment(@Valid @RequestBody RecordPaymentRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
Payment payment = financeService.recordPayment(
clubId, request.memberId(), request.amountCents(), request.paymentMethod(),
request.periodFrom(), request.periodTo(), request.reference(), request.notes(), userId
);
return ResponseEntity.status(HttpStatus.CREATED).body(payment);
}
@GetMapping("/finance/payments")
public ResponseEntity<Page<Payment>> listPayments(
@RequestParam(required = false) UUID memberId,
@RequestParam(required = false) PaymentStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<Payment> result;
if (memberId != null) {
result = financeService.getPaymentsByMember(clubId, memberId, pageable);
} else if (status != null) {
result = financeService.getPaymentsByStatus(clubId, status, pageable);
} else {
result = financeService.getPayments(clubId, pageable);
}
return ResponseEntity.ok(result);
}
@PostMapping("/finance/payments/{id}/void")
public ResponseEntity<Payment> voidPayment(@PathVariable UUID id,
@Valid @RequestBody VoidPaymentRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID userId = UUID.fromString(principal.getUsername());
Payment voided = financeService.voidPayment(id, userId, request.reason());
return ResponseEntity.ok(voided);
}
// === Expenses ===
@PostMapping("/finance/expenses")
public ResponseEntity<LedgerEntry> recordExpense(@Valid @RequestBody RecordExpenseRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
LedgerEntry entry = financeService.recordExpense(
clubId, request.category(), request.amountCents(),
request.description(), request.reference(), userId, request.transactionDate()
);
return ResponseEntity.status(HttpStatus.CREATED).body(entry);
}
// === Ledger / Kassenbuch ===
@GetMapping("/finance/ledger")
public ResponseEntity<Page<LedgerEntry>> getLedger(
@RequestParam LocalDate from,
@RequestParam LocalDate to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "transactionDate"));
return ResponseEntity.ok(financeService.getLedgerEntries(clubId, from, to, pageable));
}
// === Financial Summary ===
@GetMapping("/finance/summary")
public ResponseEntity<Map<String, Object>> getFinancialSummary(
@RequestParam LocalDate from,
@RequestParam LocalDate to,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getFinancialSummary(clubId, from, to));
}
// === Outstanding ===
@GetMapping("/finance/outstanding")
public ResponseEntity<List<Map<String, Object>>> getOutstandingMembers(
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getOutstandingMembers(clubId));
}
// === Member Balance (Admin) ===
@GetMapping("/finance/members/{memberId}/balance")
public ResponseEntity<Map<String, Object>> getMemberBalance(@PathVariable UUID memberId,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
}
// === Portal Endpoints (member self-service) ===
@GetMapping("/portal/finance/payments")
public ResponseEntity<Page<Payment>> getMyPayments(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return ResponseEntity.ok(financeService.getPaymentsByMember(clubId, memberId, pageable));
}
@GetMapping("/portal/finance/balance")
public ResponseEntity<Map<String, Object>> getMyBalance(@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
}
// === Receipt PDF Download ===
@GetMapping("/finance/payments/{id}/receipt")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
Payment payment = financeService.getPaymentById(id)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
Member member = memberRepository.findById(payment.getMemberId())
.orElseThrow(() -> new NoSuchElementException("Member not found"));
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
String filename = "Quittung-" + (payment.getReference() != null
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
// === Annual Report PDF ===
@GetMapping("/finance/reports/annual")
public ResponseEntity<byte[]> downloadAnnualReport(@RequestParam int year,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
FinancialReportService.AnnualReportData reportData = financeService.buildAnnualReportData(clubId, year);
byte[] pdf = financialReportService.generateAnnualReport(reportData, club);
String filename = "Jahresabschluss-" + year + "-" + club.getName().replaceAll("\\s+", "_") + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
// === Kassenbuch CSV Export ===
@GetMapping("/finance/ledger/export")
public ResponseEntity<byte[]> exportLedgerCsv(@RequestParam LocalDate from,
@RequestParam LocalDate to,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
byte[] csv = financeService.exportLedgerCsv(clubId, from, to);
String filename = "Kassenbuch-" + from + "-" + to + ".csv";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=ISO-8859-1"))
.contentLength(csv.length)
.body(csv);
}
// === Portal: Receipt download (own payments only) ===
@GetMapping("/portal/finance/payments/{id}/receipt")
public ResponseEntity<byte[]> downloadMyReceipt(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
Payment payment = financeService.getPaymentById(id)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
// Verify payment belongs to the requesting member
if (!payment.getMemberId().equals(memberId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NoSuchElementException("Member not found"));
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
String filename = "Quittung-" + (payment.getReference() != null
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
return memberRepository.findByUserId(userId)
.map(Member::getId)
.orElseThrow(() -> new NoSuchElementException("Member not found for user: " + userId));
}
}
@@ -1,224 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.ForumService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Forum controller — admin and portal endpoints for forum topics, replies, reactions, and reports.
*/
@RestController
@RequestMapping("/api/v1")
public class ForumController {
private final ForumService forumService;
public ForumController(ForumService forumService) {
this.forumService = forumService;
}
// ---- Admin Topic Endpoints ----
@PostMapping("/forum/topics")
public ResponseEntity<ForumTopic> createTopic(@Valid @RequestBody CreateTopicRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
ForumTopic topic = forumService.createTopic(clubId, request.title(), request.content(), userId);
return ResponseEntity.ok(topic);
}
@GetMapping("/forum/topics")
public ResponseEntity<Page<ForumTopic>> getTopics(@RequestHeader("X-Club-Id") UUID clubId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
}
@GetMapping("/forum/topics/{id}")
public ResponseEntity<ForumTopic> getTopic(@PathVariable UUID id) {
return forumService.getTopic(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/forum/topics/{id}/lock")
public ResponseEntity<ForumTopic> lockTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.lockTopic(id, userId));
}
@PostMapping("/forum/topics/{id}/unlock")
public ResponseEntity<ForumTopic> unlockTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.unlockTopic(id, userId));
}
@PostMapping("/forum/topics/{id}/pin")
public ResponseEntity<ForumTopic> pinTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.pinTopic(id, userId));
}
@PostMapping("/forum/topics/{id}/unpin")
public ResponseEntity<ForumTopic> unpinTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.unpinTopic(id, userId));
}
@DeleteMapping("/forum/topics/{id}")
public ResponseEntity<Void> deleteTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId,
@RequestParam(required = false) String reason) {
forumService.deleteTopic(id, userId, reason);
return ResponseEntity.noContent().build();
}
// ---- Reply Endpoints ----
@GetMapping("/forum/topics/{topicId}/replies")
public ResponseEntity<Page<ForumReply>> getReplies(@PathVariable UUID topicId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
}
@PostMapping("/forum/topics/{topicId}/replies")
public ResponseEntity<ForumReply> createReply(@PathVariable UUID topicId,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReply reply = forumService.createReply(topicId, request.content(), userId);
return ResponseEntity.ok(reply);
}
@PutMapping("/forum/replies/{id}")
public ResponseEntity<ForumReply> editReply(@PathVariable UUID id,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReply reply = forumService.editReply(id, request.content(), userId);
return ResponseEntity.ok(reply);
}
@DeleteMapping("/forum/replies/{id}")
public ResponseEntity<Void> deleteReply(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
forumService.deleteReply(id, userId);
return ResponseEntity.noContent().build();
}
// ---- Reaction Endpoints ----
@PostMapping("/forum/reactions")
public ResponseEntity<Map<String, Object>> toggleReaction(@Valid @RequestBody ReactionRequest request,
@RequestHeader("X-User-Id") UUID userId) {
var result = forumService.toggleReaction(
request.targetType(), request.targetId(), userId, request.reactionType());
boolean active = result.isPresent();
return ResponseEntity.ok(Map.of("active", active, "reactionType", request.reactionType().name()));
}
// ---- Report Endpoints ----
@PostMapping("/forum/reports")
public ResponseEntity<Map<String, String>> reportContent(@Valid @RequestBody ReportRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
return ResponseEntity.ok(Map.of("status", "reported"));
}
@GetMapping("/forum/reports")
public ResponseEntity<Page<ForumReport>> getReports(@RequestHeader("X-Club-Id") UUID clubId,
@RequestParam(defaultValue = "OPEN") ReportStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(forumService.getReports(clubId, status, page, size));
}
@GetMapping("/forum/reports/count")
public ResponseEntity<Map<String, Long>> getOpenReportCount(@RequestHeader("X-Club-Id") UUID clubId) {
return ResponseEntity.ok(Map.of("count", forumService.getOpenReportCount(clubId)));
}
@PostMapping("/forum/reports/{id}/review")
public ResponseEntity<ForumReport> reviewReport(@PathVariable UUID id,
@Valid @RequestBody ReviewReportRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReport report = forumService.reviewReport(id, userId, request.status());
return ResponseEntity.ok(report);
}
// ---- Portal Endpoints (member-scoped, same logic) ----
@PostMapping("/portal/forum/topics")
public ResponseEntity<ForumTopic> portalCreateTopic(@Valid @RequestBody CreateTopicRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.createTopic(clubId, request.title(), request.content(), userId));
}
@GetMapping("/portal/forum/topics")
public ResponseEntity<Page<ForumTopic>> portalGetTopics(@RequestHeader("X-Club-Id") UUID clubId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
}
@GetMapping("/portal/forum/topics/{id}")
public ResponseEntity<ForumTopic> portalGetTopic(@PathVariable UUID id) {
return forumService.getTopic(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/portal/forum/topics/{topicId}/replies")
public ResponseEntity<Page<ForumReply>> portalGetReplies(@PathVariable UUID topicId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
}
@PostMapping("/portal/forum/topics/{topicId}/replies")
public ResponseEntity<ForumReply> portalCreateReply(@PathVariable UUID topicId,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.createReply(topicId, request.content(), userId));
}
@PutMapping("/portal/forum/replies/{id}")
public ResponseEntity<ForumReply> portalEditReply(@PathVariable UUID id,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.editReply(id, request.content(), userId));
}
@PostMapping("/portal/forum/reactions")
public ResponseEntity<Map<String, Object>> portalToggleReaction(@Valid @RequestBody ReactionRequest request,
@RequestHeader("X-User-Id") UUID userId) {
var result = forumService.toggleReaction(
request.targetType(), request.targetId(), userId, request.reactionType());
return ResponseEntity.ok(Map.of("active", result.isPresent(), "reactionType", request.reactionType().name()));
}
@PostMapping("/portal/forum/reports")
public ResponseEntity<Map<String, String>> portalReportContent(@Valid @RequestBody ReportRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
return ResponseEntity.ok(Map.of("status", "reported"));
}
// ---- Request Records ----
public record CreateTopicRequest(String title, String content) {}
public record CreateReplyRequest(String content) {}
public record ReactionRequest(ForumTargetType targetType, UUID targetId, ReactionType reactionType) {}
public record ReportRequest(ForumTargetType targetType, UUID targetId, String reason) {}
public record ReviewReportRequest(ReportStatus status) {}
}
@@ -1,135 +0,0 @@
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());
}
}
@@ -1,213 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.InfoBoardPost;
import de.cannamanage.domain.enums.InfoBoardCategory;
import de.cannamanage.service.InfoBoardService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
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.Map;
import java.util.UUID;
/**
* Info Board (Schwarzes Brett) endpoints for admin and portal.
*/
@RestController
@RequiredArgsConstructor
public class InfoBoardController {
private final InfoBoardService infoBoardService;
// ============================================================
// ADMIN ENDPOINTS (require MANAGE_INFO_BOARD permission)
// ============================================================
/**
* Create a new info board post.
*/
@PostMapping("/api/v1/info-board")
public ResponseEntity<?> createPost(
@Valid @RequestBody CreatePostRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID authorId = UUID.fromString(user.getUsername());
InfoBoardPost post = infoBoardService.createPost(
request.clubId(), request.title(), request.content(),
request.category(), request.pinned() != null && request.pinned(), authorId);
return ResponseEntity.ok(toResponse(post));
}
/**
* List posts (admin view with optional filters).
*/
@GetMapping("/api/v1/info-board")
public ResponseEntity<?> listPosts(
@RequestParam UUID clubId,
@RequestParam(required = false) InfoBoardCategory category,
@RequestParam(defaultValue = "false") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, includeArchived, page, size);
var items = posts.getContent().stream().map(this::toResponse).toList();
return ResponseEntity.ok(Map.of(
"posts", items,
"totalElements", posts.getTotalElements(),
"totalPages", posts.getTotalPages(),
"page", posts.getNumber()
));
}
/**
* Get a single post.
*/
@GetMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> getPost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.getPost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Update a post.
*/
@PutMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> updatePost(
@PathVariable UUID id,
@Valid @RequestBody UpdatePostRequest request) {
InfoBoardPost post = infoBoardService.updatePost(
id, request.title(), request.content(), request.category(), request.pinned());
return ResponseEntity.ok(toResponse(post));
}
/**
* Delete a post.
*/
@DeleteMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> deletePost(@PathVariable UUID id) {
infoBoardService.deletePost(id);
return ResponseEntity.ok(Map.of("deleted", true));
}
/**
* Archive a post.
*/
@PostMapping("/api/v1/info-board/{id}/archive")
public ResponseEntity<?> archivePost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.archivePost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Unarchive a post.
*/
@PostMapping("/api/v1/info-board/{id}/unarchive")
public ResponseEntity<?> unarchivePost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.unarchivePost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Toggle pin status.
*/
@PostMapping("/api/v1/info-board/{id}/pin")
public ResponseEntity<?> togglePin(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.togglePin(id);
return ResponseEntity.ok(toResponse(post));
}
// ============================================================
// PORTAL ENDPOINTS (member access)
// ============================================================
/**
* Get posts for the member's club (non-archived, pinned first).
*/
@GetMapping("/api/v1/portal/info-board")
public ResponseEntity<?> getPortalPosts(
@RequestParam UUID clubId,
@RequestParam(required = false) InfoBoardCategory category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, false, page, size);
var items = posts.getContent().stream().map(this::toResponse).toList();
return ResponseEntity.ok(Map.of(
"posts", items,
"totalElements", posts.getTotalElements(),
"totalPages", posts.getTotalPages(),
"page", posts.getNumber()
));
}
/**
* Mark a post as read.
*/
@PostMapping("/api/v1/portal/info-board/{id}/read")
public ResponseEntity<?> markAsRead(
@PathVariable UUID id,
@RequestParam UUID memberId) {
infoBoardService.markAsRead(id, memberId);
return ResponseEntity.ok(Map.of("read", true));
}
/**
* Get unread post count for badge display.
*/
@GetMapping("/api/v1/portal/info-board/unread-count")
public ResponseEntity<?> getUnreadCount(
@RequestParam UUID clubId,
@RequestParam UUID memberId) {
long count = infoBoardService.getUnreadCount(clubId, memberId);
return ResponseEntity.ok(Map.of("unreadCount", count));
}
// ============================================================
// DTOs
// ============================================================
public record CreatePostRequest(
@NotNull UUID clubId,
@NotBlank @Size(max = 200) String title,
@NotBlank String content,
@NotNull InfoBoardCategory category,
Boolean pinned
) {}
public record UpdatePostRequest(
@Size(max = 200) String title,
String content,
InfoBoardCategory category,
Boolean pinned
) {}
// ============================================================
// Response mapping
// ============================================================
private Map<String, Object> toResponse(InfoBoardPost post) {
return Map.of(
"id", post.getId(),
"clubId", post.getClubId(),
"title", post.getTitle(),
"content", post.getContent(),
"category", post.getCategory().name(),
"pinned", post.isPinned(),
"archived", post.isArchived(),
"authorId", post.getAuthorId(),
"createdAt", post.getCreatedAt().toString(),
"updatedAt", post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : ""
);
}
}
@@ -1,103 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.CustomMailDomain;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.CustomMailDomainService;
import de.cannamanage.service.PlanTierService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* REST controller for Enterprise custom email domain management.
* All endpoints require ADMIN role + Enterprise tier.
*/
@RestController
@RequestMapping("/api/v1/settings/mail")
public class MailSettingsController {
private final CustomMailDomainService customMailDomainService;
private final PlanTierService planTierService;
public MailSettingsController(CustomMailDomainService customMailDomainService,
PlanTierService planTierService) {
this.customMailDomainService = customMailDomainService;
this.planTierService = planTierService;
}
/**
* Set a custom FROM address for the club's outbound emails.
* Enterprise tier only.
*/
@PostMapping("/custom-domain")
public ResponseEntity<MailDomainStatusResponse> setCustomDomain(
@Valid @RequestBody CustomMailDomainRequest request) {
UUID tenantId = TenantContext.getCurrentTenantId();
CustomMailDomain domain = customMailDomainService.setCustomDomain(tenantId, request.fromAddress());
return ResponseEntity.ok(toResponse(domain));
}
/**
* Get current custom domain status.
*/
@GetMapping("/custom-domain")
public ResponseEntity<MailDomainStatusResponse> getCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
planTierService.requireEnterpriseTier(tenantId);
return customMailDomainService.getCustomDomain(tenantId)
.map(domain -> ResponseEntity.ok(toResponse(domain)))
.orElse(ResponseEntity.noContent().build());
}
/**
* Trigger DNS verification for the custom domain.
*/
@PostMapping("/custom-domain/verify")
public ResponseEntity<MailDomainStatusResponse> verifyCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
CustomMailDomain domain = customMailDomainService.verifyDomain(tenantId);
return ResponseEntity.ok(toResponse(domain));
}
/**
* Remove custom domain configuration (revert to platform default).
*/
@DeleteMapping("/custom-domain")
public ResponseEntity<Void> removeCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
planTierService.requireEnterpriseTier(tenantId);
customMailDomainService.removeCustomDomain(tenantId);
return ResponseEntity.noContent().build();
}
private MailDomainStatusResponse toResponse(CustomMailDomain domain) {
return new MailDomainStatusResponse(
domain.getFromAddress(),
domain.getDomain(),
domain.isVerified(),
domain.getVerificationToken(),
domain.getVerifiedAt() != null ? domain.getVerifiedAt().toString() : null,
"cannamanage-verify=" + domain.getVerificationToken()
);
}
// --- DTOs ---
public record CustomMailDomainRequest(
@NotBlank @Email String fromAddress
) {}
public record MailDomainStatusResponse(
String fromAddress,
String domain,
boolean verified,
String verificationToken,
String verifiedAt,
String requiredDnsTxtRecord
) {}
}
@@ -1,166 +0,0 @@
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
);
}
}
@@ -1,85 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.ComposeNotificationRequest;
import de.cannamanage.domain.entity.NotificationSend;
import de.cannamanage.domain.enums.TargetType;
import de.cannamanage.service.NotificationService;
import de.cannamanage.service.repository.NotificationSendRepository;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
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.Map;
import java.util.UUID;
/**
* Admin notification compose endpoints.
* Requires SEND_NOTIFICATIONS permission (checked via StaffPermissionChecker).
*/
@RestController
@RequestMapping("/api/v1/notifications")
@RequiredArgsConstructor
public class NotificationComposeController {
private final NotificationService notificationService;
private final NotificationSendRepository notificationSendRepository;
/**
* Compose and send a notification (broadcast or targeted).
*/
@PostMapping("/compose")
public ResponseEntity<Map<String, Object>> composeAndSend(
@Valid @RequestBody ComposeNotificationRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID authorId = UUID.fromString(user.getUsername());
NotificationSend send;
if (request.targetType() == TargetType.ALL) {
send = notificationService.sendBroadcast(
request.title(), request.message(), request.link(), authorId);
} else {
if (request.recipientIds() == null || request.recipientIds().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "recipientIds required for SELECTED target type"));
}
send = notificationService.sendToSelected(
request.title(), request.message(), request.link(), authorId, request.recipientIds());
}
return ResponseEntity.ok(Map.of(
"id", send.getId(),
"targetType", send.getTargetType().name(),
"targetCount", send.getTargetCount(),
"sentAt", send.getSentAt().toString()
));
}
/**
* List sent notifications (paginated).
*/
@GetMapping("/sends")
public ResponseEntity<?> listSends(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
var sends = notificationSendRepository.findAllByOrderBySentAtDesc(PageRequest.of(page, size));
var items = sends.getContent().stream().map(s -> Map.of(
"id", (Object) s.getId(),
"title", s.getTitle(),
"targetType", s.getTargetType().name(),
"targetCount", s.getTargetCount(),
"readCount", s.getReadCount(),
"sentAt", s.getSentAt().toString()
)).toList();
return ResponseEntity.ok(Map.of(
"sends", items,
"totalElements", sends.getTotalElements(),
"totalPages", sends.getTotalPages()
));
}
}
@@ -1,68 +0,0 @@
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));
}
}
@@ -1,72 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.UpdatePreferencesRequest;
import de.cannamanage.domain.enums.NotificationChannel;
import de.cannamanage.service.NotificationPreferenceService;
import jakarta.validation.Valid;
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.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Notification preferences endpoints.
* Any authenticated user can view/update their notification channel preferences.
*/
@RestController
@RequestMapping("/api/v1/notifications/preferences")
@RequiredArgsConstructor
public class NotificationPreferenceController {
private final NotificationPreferenceService preferenceService;
/**
* Get user's notification channel preferences.
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getPreferences(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
var prefs = preferenceService.getOrCreatePreferences(userId);
var prefsMap = prefs.stream().collect(Collectors.toMap(
p -> p.getChannel().name(),
p -> (Object) p.isEnabled()
));
return ResponseEntity.ok(Map.of("preferences", prefsMap));
}
/**
* Update notification channel preferences.
* IN_APP cannot be disabled (server-side enforcement).
*/
@PutMapping
public ResponseEntity<?> updatePreferences(
@Valid @RequestBody UpdatePreferencesRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
try {
for (var entry : request.preferences().entrySet()) {
preferenceService.updatePreference(userId, entry.getKey(), entry.getValue());
}
// Return updated preferences
var prefs = preferenceService.getOrCreatePreferences(userId);
var prefsMap = prefs.stream().collect(Collectors.toMap(
p -> p.getChannel().name(),
p -> (Object) p.isEnabled()
));
return ResponseEntity.ok(Map.of("preferences", prefsMap));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
@@ -1,72 +0,0 @@
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);
}
}
@@ -1,288 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.report.AuthorityExportRequest;
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.entity.User;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.CsvReportGenerator;
import de.cannamanage.service.PdfReportGenerator;
import de.cannamanage.service.ReportGeneratorService;
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.report.AuthorityExportService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.UserRepository;
import jakarta.validation.Valid;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.time.YearMonth;
import java.util.*;
/**
* 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;
private final ReportGeneratorService reportGeneratorService;
private final AuthorityExportService authorityExportService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public ReportController(ReportService reportService,
PdfReportGenerator pdfGenerator,
CsvReportGenerator csvGenerator,
ClubRepository clubRepository,
ReportGeneratorService reportGeneratorService,
AuthorityExportService authorityExportService,
UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.reportService = reportService;
this.pdfGenerator = pdfGenerator;
this.csvGenerator = csvGenerator;
this.clubRepository = clubRepository;
this.reportGeneratorService = reportGeneratorService;
this.authorityExportService = authorityExportService;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
/**
* List all available report types with their supported export formats.
* GET /api/v1/reports/types
*/
@GetMapping("/types")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<List<Map<String, Object>>> listReportTypes() {
Map<ReportType, Set<ExportFormat>> availableTypes = reportGeneratorService.getAvailableTypes();
List<Map<String, Object>> response = new ArrayList<>();
for (var entry : availableTypes.entrySet()) {
Map<String, Object> typeInfo = new LinkedHashMap<>();
typeInfo.put("type", entry.getKey().name());
typeInfo.put("formats", entry.getValue().stream()
.map(ExportFormat::name)
.sorted()
.toList());
response.add(typeInfo);
}
return ResponseEntity.ok(response);
}
/**
* 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()
);
}
/**
* Full Authority Export (Behörden-Export) — THE HERO FEATURE.
* Generates a streaming ZIP containing all compliance documents.
* Requires re-authentication (password re-entry) + mandatory reason.
* Rate limited: max 1 export per hour per tenant.
*
* POST /api/v1/reports/authority-export
*/
@PostMapping("/authority-export")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<StreamingResponseBody> authorityExport(
@Valid @RequestBody AuthorityExportRequest request,
@AuthenticationPrincipal UUID userId) {
UUID tenantId = TenantContext.getCurrentTenant();
// Rate limit check
if (authorityExportService.isRateLimited(tenantId)) {
return ResponseEntity.status(429)
.header("Retry-After", "3600")
.build();
}
// Re-authentication: verify password against BCrypt hash
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalStateException("Authenticated user not found"));
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
return ResponseEntity.status(403).build();
}
// Stream the ZIP
StreamingResponseBody responseBody = outputStream ->
authorityExportService.streamAuthorityExport(
outputStream, tenantId, request.year(), userId, request.reason());
String filename = "Behoerden_Export_" + request.year() + "_" + tenantId.toString().substring(0, 8) + ".zip";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(responseBody);
}
private RecallReportResponse toRecallResponse(RecallReport r) {
return new RecallReportResponse(
r.getBatchId(),
r.getStrainName(),
r.getBatchNumber(),
r.getReceivedDate(),
r.getTotalGramsDistributed(),
r.getAffectedMembers().stream()
.map(am -> new RecallReportResponse.AffectedMemberDto(
am.getMemberId(), am.getFirstName(), am.getLastName(),
am.getDistributionDate(), am.getGrams()))
.toList()
);
}
}
@@ -1,128 +0,0 @@
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,
@Valid @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());
}
}
@@ -1,74 +0,0 @@
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()
);
}
}
@@ -1,34 +0,0 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.StorageQuotaService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* REST controller for storage quota information.
* Provides endpoint to check current storage usage for the caller's club.
* Club ID is extracted from the JWT/tenant context — not from request params.
*/
@RestController
@RequestMapping("/api/v1/storage")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public class StorageController {
private final StorageQuotaService storageQuotaService;
public StorageController(StorageQuotaService storageQuotaService) {
this.storageQuotaService = storageQuotaService;
}
@GetMapping("/usage")
public ResponseEntity<StorageQuotaService.StorageUsageDTO> getUsage() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
}
}
@@ -1,29 +0,0 @@
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());
}
}
}
@@ -1,86 +0,0 @@
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 jakarta.validation.Valid;
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(@Valid @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
);
}
}
@@ -1,93 +0,0 @@
package de.cannamanage.api.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/**
* Test-only controller for resetting the database to a known seed state.
* Only active when cannamanage.test.endpoints.enabled=true (test profile).
* NEVER activate this in production.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/test")
@RequiredArgsConstructor
@ConditionalOnProperty(name = "cannamanage.test.endpoints.enabled", havingValue = "true")
public class TestResetController {
private final DataSource dataSource;
/**
* Truncates all application tables and re-seeds with test data.
* The Flyway schema_history table is preserved.
*/
@PostMapping("/reset-db")
public ResponseEntity<Void> resetDatabase() {
log.info("Test DB reset requested — truncating all tables and re-seeding");
try (Connection conn = dataSource.getConnection()) {
truncateAllTables(conn);
reseed();
log.info("Test DB reset complete — seed data re-applied");
return ResponseEntity.ok().build();
} catch (SQLException e) {
log.error("Failed to reset test database", e);
return ResponseEntity.internalServerError().build();
}
}
private void truncateAllTables(Connection conn) throws SQLException {
List<String> tables = getApplicationTables(conn);
try (Statement stmt = conn.createStatement()) {
// Disable FK constraints for truncation
stmt.execute("SET session_replication_role = 'replica'");
for (String table : tables) {
stmt.execute("TRUNCATE TABLE " + table + " CASCADE");
log.debug("Truncated table: {}", table);
}
// Re-enable FK constraints
stmt.execute("SET session_replication_role = 'origin'");
}
}
private List<String> getApplicationTables(Connection conn) throws SQLException {
List<String> tables = new ArrayList<>();
try (Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT tablename FROM pg_tables " +
"WHERE schemaname = 'public' " +
"AND tablename != 'flyway_schema_history'")) {
while (rs.next()) {
tables.add(rs.getString("tablename"));
}
}
return tables;
}
private void reseed() {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.addScript(new ClassPathResource("db/testdata/R__seed_test_data.sql"));
populator.setSeparator(";");
populator.execute(dataSource);
}
}
@@ -1,13 +0,0 @@
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
) {}
@@ -1,8 +0,0 @@
package de.cannamanage.api.dto.auth;
public record LoginResponse(
String accessToken,
String refreshToken,
long expiresIn,
String role
) {}
@@ -1,8 +0,0 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.NotBlank;
public record RefreshRequest(
@NotBlank(message = "Refresh token is required")
String refreshToken
) {}
@@ -1,18 +0,0 @@
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
) {}
@@ -1,13 +0,0 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/assign}.
* Used by the admin to manually attach a transaction to a member the matching engine missed.
*/
public record AssignRequest(
@NotNull UUID memberId
) {}
@@ -1,19 +0,0 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.service.bankimport.BankImportService.BulkConfirmResult;
/**
* Sprint 10 Phase 3 — Response of {@code POST /sessions/{id}/confirm-all}.
* Surfaces the number of transactions that were confirmed, skipped (low confidence /
* already confirmed) and failed (e.g. payment creation error) so the UI can give clear feedback.
*/
public record BulkConfirmResponse(
int confirmed,
int skipped,
int failed,
int total
) {
public static BulkConfirmResponse from(BulkConfirmResult r) {
return new BulkConfirmResponse(r.confirmed(), r.skipped(), r.failed(), r.total());
}
}
@@ -1,15 +0,0 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/confirm}.
* <p>
* The {@code memberId} is required so the caller explicitly acknowledges which member receives
* the payment, even when the matching engine had already pre-selected one.
*/
public record ConfirmRequest(
@NotNull UUID memberId
) {}
@@ -1,26 +0,0 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /finance/import/csv-mappings}.
* Captures the column layout of a club-specific CSV bank export so future imports can
* be parsed without re-mapping.
*/
public record CreateMappingRequest(
@NotBlank @Size(max = 100) String name,
@Min(0) @Max(50) int dateColumn,
@Min(0) @Max(50) int amountColumn,
@Min(0) @Max(50) int referenceColumn,
Integer counterpartyColumn,
Integer ibanColumn,
@Size(max = 4) String delimiter,
@Size(max = 32) String dateFormat,
@Size(max = 2) String decimalSeparator,
@Min(0) @Max(20) Integer skipHeaderRows,
@Size(max = 32) String encoding,
Boolean isDefault
) {}
@@ -1,46 +0,0 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.domain.entity.BankImportSession;
import de.cannamanage.domain.enums.BankFormat;
import de.cannamanage.domain.enums.ImportSessionStatus;
import java.time.Instant;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Summary projection of a {@code BankImportSession} for list views.
* Excludes large/sensitive fields ({@code fileHash}, {@code errorMessage} stays).
*/
public record ImportSessionResponse(
UUID id,
UUID clubId,
String filename,
BankFormat format,
ImportSessionStatus status,
Integer totalTransactions,
Integer matchedCount,
Integer confirmedCount,
Integer skippedCount,
UUID uploadedBy,
String errorMessage,
Instant createdAt,
Instant completedAt
) {
public static ImportSessionResponse from(BankImportSession s) {
return new ImportSessionResponse(
s.getId(),
s.getClubId(),
s.getFilename(),
s.getFormat(),
s.getStatus(),
s.getTotalTransactions(),
s.getMatchedCount(),
s.getConfirmedCount(),
s.getSkippedCount(),
s.getUploadedBy(),
s.getErrorMessage(),
s.getCreatedAt(),
s.getCompletedAt()
);
}
}
@@ -1,9 +0,0 @@
package de.cannamanage.api.dto.bankimport;
/**
* Sprint 10 Phase 3 — Optional request body for {@code POST /sessions/{id}/transactions/{txnId}/skip}.
* The {@code reason} field is free text shown in the audit log and review history.
*/
public record SkipRequest(
String reason
) {}
@@ -1,48 +0,0 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.domain.entity.BankTransaction;
import de.cannamanage.domain.enums.MatchStatus;
import java.time.LocalDate;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Single bank-statement transaction shown in the review wizard.
*/
public record TransactionResponse(
UUID id,
UUID sessionId,
LocalDate bookingDate,
LocalDate valueDate,
Integer amountCents,
String currency,
String referenceText,
String counterpartyName,
String counterpartyIban,
String bankReference,
MatchStatus matchStatus,
Integer matchConfidence,
UUID matchedMemberId,
UUID matchedPaymentId,
String skipReason
) {
public static TransactionResponse from(BankTransaction t) {
return new TransactionResponse(
t.getId(),
t.getSessionId(),
t.getBookingDate(),
t.getValueDate(),
t.getAmountCents(),
t.getCurrency(),
t.getReferenceText(),
t.getCounterpartyName(),
t.getCounterpartyIban(),
t.getBankReference(),
t.getMatchStatus(),
t.getMatchConfidence(),
t.getMatchedMemberId(),
t.getMatchedPaymentId(),
t.getSkipReason()
);
}
}
@@ -1,7 +0,0 @@
package de.cannamanage.api.dto.billing;
import jakarta.validation.constraints.NotBlank;
public record CheckoutRequest(
@NotBlank String planTier
) {}
@@ -1,14 +0,0 @@
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
) {}
@@ -1,24 +0,0 @@
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
) {}
@@ -1,14 +0,0 @@
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
) {}
@@ -1,34 +0,0 @@
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
) {}
@@ -1,12 +0,0 @@
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
) {}
@@ -1,21 +0,0 @@
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
) {}
@@ -1,15 +0,0 @@
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
) {}
@@ -1,24 +0,0 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
public record CreateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate,
Boolean postToInfoBoard // defaults to true if null
) {}
@@ -1,28 +0,0 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import de.cannamanage.domain.enums.RsvpStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Map;
import java.util.UUID;
public record EventResponse(
UUID id,
String title,
String description,
EventType eventType,
Instant startAt,
Instant endAt,
String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate,
UUID createdBy,
Instant createdAt,
Map<RsvpStatus, Long> attendeeCounts,
RsvpStatus myRsvpStatus
) {}
@@ -1,8 +0,0 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.RsvpStatus;
import jakarta.validation.constraints.NotNull;
public record RsvpRequest(
@NotNull RsvpStatus status
) {}
@@ -1,13 +0,0 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.RsvpStatus;
import java.time.Instant;
import java.util.UUID;
public record RsvpResponse(
UUID memberId,
String memberName,
RsvpStatus status,
Instant respondedAt
) {}
@@ -1,23 +0,0 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
public record UpdateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate
) {}
@@ -1,11 +0,0 @@
package de.cannamanage.api.dto.finance;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.UUID;
public record AssignFeeRequest(
@NotNull UUID feeScheduleId,
@NotNull LocalDate validFrom
) {}
@@ -1,13 +0,0 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.FeeInterval;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateFeeScheduleRequest(
@NotBlank String name,
@NotNull @Min(1) Integer amountCents,
@NotNull FeeInterval interval,
Boolean isDefault
) {}
@@ -1,16 +0,0 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.ExpenseCategory;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record RecordExpenseRequest(
@NotNull ExpenseCategory category,
@NotNull @Min(1) Integer amountCents,
@NotBlank String description,
String reference,
@NotNull LocalDate transactionDate
) {}
@@ -1,18 +0,0 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.PaymentMethod;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.UUID;
public record RecordPaymentRequest(
@NotNull UUID memberId,
@NotNull @Min(1) Integer amountCents,
@NotNull PaymentMethod paymentMethod,
@NotNull LocalDate periodFrom,
@NotNull LocalDate periodTo,
String reference,
String notes
) {}
@@ -1,10 +0,0 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.FeeInterval;
public record UpdateFeeScheduleRequest(
String name,
Integer amountCents,
FeeInterval interval,
Boolean isDefault
) {}
@@ -1,7 +0,0 @@
package de.cannamanage.api.dto.finance;
import jakarta.validation.constraints.NotBlank;
public record VoidPaymentRequest(
@NotBlank String reason
) {}
@@ -1,15 +0,0 @@
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
) {}
@@ -1,8 +0,0 @@
package de.cannamanage.api.dto.grow;
import jakarta.validation.constraints.NotBlank;
public record AddPhotoRequest(
@NotBlank String filePath,
String caption
) {}
@@ -1,13 +0,0 @@
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
) {}
@@ -1,8 +0,0 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.GrowStage;
import jakarta.validation.constraints.NotNull;
public record AdvanceStageRequest(
@NotNull GrowStage stage
) {}
@@ -1,11 +0,0 @@
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
) {}
@@ -1,13 +0,0 @@
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
) {}
@@ -1,16 +0,0 @@
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
) {}
@@ -1,25 +0,0 @@
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
) {}
@@ -1,20 +0,0 @@
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
) {}
@@ -1,11 +0,0 @@
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
) {}
@@ -1,14 +0,0 @@
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
) {}
@@ -1,15 +0,0 @@
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
) {}
@@ -1,30 +0,0 @@
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
) {}
@@ -1,19 +0,0 @@
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
) {}
@@ -1,17 +0,0 @@
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
) {}
@@ -1,19 +0,0 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.TargetType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;
/**
* Request DTO for composing and sending a notification.
*/
public record ComposeNotificationRequest(
@NotBlank String title,
@NotBlank String message,
String link,
@NotNull TargetType targetType,
List<UUID> recipientIds
) {}
@@ -1,14 +0,0 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.DevicePlatform;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request DTO for registering a push notification device token.
*/
public record RegisterDeviceRequest(
@NotNull DevicePlatform platform,
@NotBlank String token,
String deviceName
) {}
@@ -1,12 +0,0 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.NotificationChannel;
import java.util.Map;
/**
* Request DTO for updating notification preferences.
*/
public record UpdatePreferencesRequest(
Map<NotificationChannel, Boolean> preferences
) {}
@@ -1,15 +0,0 @@
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
) {}
@@ -1,7 +0,0 @@
package de.cannamanage.api.dto.prevention;
import jakarta.validation.constraints.NotNull;
public record PreventionOfficerRequest(
@NotNull Boolean preventionOfficer
) {}
@@ -1,17 +0,0 @@
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
) {}
@@ -1,16 +0,0 @@
package de.cannamanage.api.dto.report;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Request body for the authority export endpoint.
* Requires re-authentication (password) and a mandatory reason for the audit trail.
*/
public record AuthorityExportRequest(
@NotNull Integer year,
@NotBlank @Size(min = 1, max = 500) String password,
@NotBlank @Size(min = 10, max = 500) String reason
) {
}
@@ -1,25 +0,0 @@
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
) {}
}
@@ -1,21 +0,0 @@
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) {}
}

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