Compare commits
106 Commits
59b7486cec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22ce3f9d49 | |||
| 83b46c8cda | |||
| a686957b09 | |||
| 53931d9d2b | |||
| 51a9d1db58 | |||
| ade9673f02 | |||
| 1c4c4ec708 | |||
| b69e5b1820 | |||
| 4b38c4fa09 | |||
| ad7f4e2b1c | |||
| 6aae17edba | |||
| 970f8eb295 | |||
| dad798a904 | |||
| 52d23053e7 | |||
| 6f5e886bd6 | |||
| f9a87efb7a | |||
| 279487067e | |||
| be932c1930 | |||
| 776149e7d3 | |||
| 6e25914074 | |||
| 90cdac7468 | |||
| fa567c1c3f | |||
| f1959eb3d2 | |||
| 592abc4b6d | |||
| 3b15d7439d | |||
| 59b785b8ed | |||
| 6f7352124d | |||
| 6319552675 | |||
| 8c969c610f | |||
| 5defe42d67 | |||
| 527e9b1219 | |||
| 55110c95af | |||
| 57f418f7c9 | |||
| 87511e0485 | |||
| c3722ab726 | |||
| 3ca231dc9c | |||
| a29c38756c | |||
| 26a77dd269 | |||
| 2d83c4b8a1 | |||
| 61b0cd92be | |||
| e4698827ee | |||
| b22702317a | |||
| 3211ade5be | |||
| 721503b231 | |||
| cfb38e8fc6 | |||
| aabde17532 | |||
| a539ed9eb2 | |||
| 05fd679c4d | |||
| 4aa27cd4f9 | |||
| 706a6e257b | |||
| 329b7abb18 | |||
| 7fe8d4f707 | |||
| 9aaf771469 | |||
| 27690a836e | |||
| cd77eb6448 | |||
| 52251cf711 | |||
| 26a77b5e16 | |||
| 4be9c4cf2c | |||
| 2347a7a1d9 | |||
| 281adda27c | |||
| dac884c4fe | |||
| 6570ea364a | |||
| 60844efaba | |||
| 8490da4705 | |||
| f6a7143d1b | |||
| 1eead286ba | |||
| 9a4df56eaf | |||
| b57be8a4d8 | |||
| 3e4fdee05b | |||
| 805bc4f00d | |||
| d650987b9a | |||
| 106229e0e3 | |||
| d0c53a912c | |||
| 61707ffe68 | |||
| 1e693e3d2a | |||
| 599514c0db | |||
| 076fd6f9b3 | |||
| 05933a08ca | |||
| 61e481b37b | |||
| 3232d2f7fd | |||
| b38902a7ee | |||
| 4fa068092f | |||
| 8391dbb2cd | |||
| 9373c7ad69 | |||
| 5c02cb0cde | |||
| 4d64576f22 | |||
| d1487539b6 | |||
| 2cc8c89944 | |||
| ed1efccc90 | |||
| be63a84fe8 | |||
| b170bb9d87 | |||
| f42c166329 | |||
| 279f2f6de0 | |||
| dce27a4291 | |||
| 7f99e11d9f | |||
| 09d5ca6db0 | |||
| 02e4bbad18 | |||
| f8f562915e | |||
| 154f79fe60 | |||
| fe6e96dd3f | |||
| a1d4ba44e3 | |||
| 864bbbdde1 | |||
| 4f00872486 | |||
| 87568e5bfc | |||
| 64927a3244 | |||
| a267a90542 |
@@ -0,0 +1,15 @@
|
|||||||
|
# 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=
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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"
|
||||||
+10
@@ -7,3 +7,13 @@ target/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
.mvn/wrapper/maven-wrapper.jar
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cannamanage-frontend/node_modules/
|
||||||
|
cannamanage-frontend/.next/
|
||||||
|
cannamanage-frontend/.env.local
|
||||||
|
|
||||||
|
# Production secrets (never commit)
|
||||||
|
.env
|
||||||
|
~/
|
||||||
|
~/
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
- 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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Multi-stage build for cannamanage-api (Spring Boot + Java 21)
|
||||||
|
# Build context: repo root (needs access to all Maven modules)
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jdk-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Maven wrapper + POM files first (layer caching)
|
||||||
|
COPY .mvn/ .mvn/
|
||||||
|
COPY mvnw pom.xml ./
|
||||||
|
COPY cannamanage-domain/pom.xml cannamanage-domain/pom.xml
|
||||||
|
COPY cannamanage-service/pom.xml cannamanage-service/pom.xml
|
||||||
|
COPY cannamanage-api/pom.xml cannamanage-api/pom.xml
|
||||||
|
|
||||||
|
# Download dependencies (cached unless POMs change)
|
||||||
|
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B -q 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY cannamanage-domain/src/ cannamanage-domain/src/
|
||||||
|
COPY cannamanage-service/src/ cannamanage-service/src/
|
||||||
|
COPY cannamanage-api/src/ cannamanage-api/src/
|
||||||
|
|
||||||
|
# Build the fat JAR
|
||||||
|
RUN ./mvnw package -pl cannamanage-api -am -DskipTests -B -q
|
||||||
|
|
||||||
|
# --- Runtime stage ---
|
||||||
|
FROM eclipse-temurin:21-jre-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
|
COPY --from=builder /app/cannamanage-api/target/*.jar app.jar
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -1,111 +1,87 @@
|
|||||||
# CannaManage
|
# CannaManage
|
||||||
|
|
||||||
Multi-tenant cannabis club management platform for German **Anbauvereinigungen** (cultivation associations) under CanG §19.
|
Full-stack management platform for German cannabis cultivation associations (Anbauvereinigungen) under the CanG/KCanG regulatory framework.
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CannaManage handles member management, distribution tracking, and legal compliance for cannabis cultivation clubs in Germany. It enforces the strict quotas mandated by the Cannabis Act (CanG) — including monthly limits (50g adult / 30g under-21), daily limits (25g), and THC restrictions for minors.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Component | Technology |
|
| Layer | Technology |
|
||||||
|-----------|-----------|
|
|-------|-----------|
|
||||||
| Runtime | Java 21 (Temurin) |
|
| **Frontend** | Next.js 15, React 19, TypeScript, Tailwind CSS 4, shadcn/ui |
|
||||||
| Framework | Spring Boot 4.0.6 |
|
| **Backend** | Spring Boot 3.5, Java 17, Spring Security (JWT + session) |
|
||||||
| Security | Spring Security 7.0 + JWT (JJWT 0.12.6) |
|
| **Database** | PostgreSQL 16, Flyway migrations |
|
||||||
| ORM | Hibernate 7 / JPA |
|
| **Infrastructure** | Docker Compose, Gitea Actions CI/CD, TrueNAS deployment |
|
||||||
| Database | PostgreSQL (prod), H2 (test) |
|
|
||||||
| Migrations | Flyway 10 |
|
|
||||||
| API Docs | SpringDoc OpenAPI 2.8.6 |
|
|
||||||
| Build | Maven (multi-module) |
|
|
||||||
| Container | Docker Compose (Postgres + app) |
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cannamanage/
|
cannamanage/
|
||||||
├── cannamanage-domain/ # JPA entities, enums, TenantContext
|
├── cannamanage-api/ # Spring Boot REST API (entry point)
|
||||||
├── cannamanage-service/ # Business logic, repositories, ComplianceService
|
├── cannamanage-service/ # Business logic layer
|
||||||
├── cannamanage-api/ # Spring Boot app, controllers, security, DTOs
|
├── cannamanage-domain/ # JPA entities, enums, value objects
|
||||||
├── docs/
|
├── cannamanage-frontend/ # Next.js frontend (pnpm)
|
||||||
│ └── sprint-2/ # Sprint planning docs
|
├── deploy/ # Deployment scripts & nginx config
|
||||||
└── docker-compose.yml # Local dev environment
|
├── docker-compose.yml # Local development stack
|
||||||
|
└── .gitea/workflows/ # CI/CD pipeline
|
||||||
```
|
```
|
||||||
|
|
||||||
## Modules
|
## Local Development
|
||||||
|
|
||||||
### cannamanage-domain
|
### Prerequisites
|
||||||
JPA entities with multi-tenant isolation via `@Filter("tenantFilter")`:
|
|
||||||
- `Member` — club members with age tracking
|
|
||||||
- `Distribution` — cannabis distribution records
|
|
||||||
- `MonthlyQuota` — per-member monthly usage tracking
|
|
||||||
- `Batch` / `Strain` / `StockMovement` — inventory management
|
|
||||||
- `Club` — association registration
|
|
||||||
- `User` — authentication accounts
|
|
||||||
|
|
||||||
### cannamanage-service
|
- Java 17+
|
||||||
- `ComplianceService` — CanG §19 quota enforcement (25 unit tests)
|
- Maven 3.9+
|
||||||
- Repositories for all entities
|
- Node.js 22+ with pnpm 10+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
|
||||||
### cannamanage-api
|
### Backend
|
||||||
- **Auth** — JWT login + refresh token rotation (SHA-256 hashed)
|
|
||||||
- **Members** — CRUD for association members
|
|
||||||
- **Distributions** — compliance-gated distribution recording
|
|
||||||
- **Stock** — batch and inventory management
|
|
||||||
- **Compliance** — quota status API
|
|
||||||
- Multi-tenant isolation via `TenantFilterAspect` (Hibernate @Filter activation)
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| POST | `/api/v1/auth/login` | Public | Login with email + password |
|
|
||||||
| POST | `/api/v1/auth/refresh` | Public | Refresh token rotation |
|
|
||||||
| GET | `/api/v1/compliance/quota/{memberId}` | ADMIN, MEMBER | Monthly quota status |
|
|
||||||
| GET/POST/PUT | `/api/v1/members/**` | ADMIN, MEMBER | Member CRUD |
|
|
||||||
| POST | `/api/v1/distributions/**` | ADMIN, MEMBER | Record distributions |
|
|
||||||
| GET/POST | `/api/v1/stock/**` | ADMIN | Stock management |
|
|
||||||
|
|
||||||
Swagger UI: `http://localhost:8080/swagger-ui.html`
|
|
||||||
|
|
||||||
## Running Locally
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start PostgreSQL
|
# Start PostgreSQL
|
||||||
docker compose up -d
|
docker compose up -d db
|
||||||
|
|
||||||
# Run the app
|
# Run Spring Boot
|
||||||
JAVA_HOME=/path/to/jdk-21 ./mvnw spring-boot:run -pl cannamanage-api
|
mvn spring-boot:run -f cannamanage-api/pom.xml -Dspring-boot.run.profiles=local
|
||||||
|
|
||||||
# Run all tests (H2 in-memory)
|
|
||||||
JAVA_HOME=/path/to/jdk-21 ./mvnw clean verify
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
### Frontend
|
||||||
|
|
||||||
- **37 tests total** — all green
|
```bash
|
||||||
- 25 unit tests (`ComplianceServiceTest`) — quota enforcement logic
|
cd cannamanage-frontend
|
||||||
- 7 integration tests (`AuthControllerIntegrationTest`) — full HTTP auth flow
|
pnpm install
|
||||||
- 5 integration tests (`ComplianceControllerIntegrationTest`) — quota API with JWT
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
Integration tests use `@SpringBootTest(webEnvironment = RANDOM_PORT)` with H2 and Spring's `RestClient`.
|
The frontend runs on http://localhost:3000, backend on http://localhost:8080.
|
||||||
|
|
||||||
## Security Model
|
### Full Stack (Docker)
|
||||||
|
|
||||||
- **Stateless JWT** — no session, no UserDetailsService
|
```bash
|
||||||
- **Roles**: ADMIN (full access), MEMBER (self-service), STAFF (Sprint 3)
|
docker compose up --build
|
||||||
- **Multi-tenancy**: Hibernate `@Filter` activated per-request via AOP aspect
|
```
|
||||||
- **Refresh tokens**: SHA-256 hashed (Spring Security 7 enforces BCrypt 72-byte limit)
|
|
||||||
- Token rotation on refresh — old tokens invalidated
|
|
||||||
|
|
||||||
## Sprint History
|
## Deployment
|
||||||
|
|
||||||
| Sprint | Focus | Status |
|
Push to `main` triggers the Gitea Actions CI pipeline which:
|
||||||
|--------|-------|--------|
|
1. Runs backend tests (`mvn test`)
|
||||||
| 1 | Domain entities, ComplianceService, 25 tests | ✅ Done |
|
2. Runs frontend lint (`pnpm lint`)
|
||||||
| 2 | REST API, Spring Security, JWT, OpenAPI, integration tests | ✅ Done |
|
3. Builds Docker images
|
||||||
| 3 | Member portal, STAFF role, real-time notifications | 📋 Planned |
|
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
|
## License
|
||||||
|
|
||||||
Private — Patrick Plate
|
Proprietary — Patrick Plate
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Spring Boot 4 modularized autoconfiguration: FlywayAutoConfiguration
|
||||||
|
moved out of spring-boot-autoconfigure into the dedicated spring-boot-flyway
|
||||||
|
module, which is only brought in by spring-boot-starter-flyway. Without this
|
||||||
|
starter, spring.flyway.enabled=true is inert — migrations never run and
|
||||||
|
Hibernate ddl-auto=validate fails on the empty schema.
|
||||||
|
See: https://spring.io/blog/2025/10/28/modularizing-spring-boot/
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
@@ -118,6 +130,27 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-mail</artifactId>
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CannaManage Spring Boot application entry point.
|
* CannaManage Spring Boot application entry point.
|
||||||
@@ -17,6 +18,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
|||||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||||
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||||
|
@EnableScheduling
|
||||||
public class CannaManageApplication {
|
public class CannaManageApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.cannamanage.api.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||||
|
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket configuration — enables STOMP messaging over SockJS.
|
||||||
|
* Clients connect to /ws, subscribe to /user/queue/notifications for personal notifications.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocketMessageBroker
|
||||||
|
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||||
|
// Enable simple in-memory broker for /topic (broadcast) and /queue (user-specific)
|
||||||
|
config.enableSimpleBroker("/topic", "/queue");
|
||||||
|
// Prefix for @MessageMapping methods
|
||||||
|
config.setApplicationDestinationPrefixes("/app");
|
||||||
|
// User-specific destination prefix
|
||||||
|
config.setUserDestinationPrefix("/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||||
|
// WebSocket endpoint with SockJS fallback
|
||||||
|
registry.addEndpoint("/ws")
|
||||||
|
.setAllowedOriginPatterns("*")
|
||||||
|
.withSockJS();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.AuditEvent;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.enums.AuditEventType;
|
||||||
|
import de.cannamanage.service.AuditService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/audit")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Audit", description = "Immutable audit log (KCanG compliance, 10-year retention)")
|
||||||
|
public class AuditController {
|
||||||
|
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Get paginated audit log",
|
||||||
|
description = "Returns audit events with optional filters. Admin only.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||||
|
public ResponseEntity<Page<AuditEventResponse>> getAuditLog(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) AuditEventType eventType,
|
||||||
|
@RequestParam(required = false) String entityType,
|
||||||
|
@RequestParam(required = false) UUID actorId,
|
||||||
|
@RequestParam(required = false) Instant from,
|
||||||
|
@RequestParam(required = false) Instant to
|
||||||
|
) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Page<AuditEvent> events = auditService.getEvents(
|
||||||
|
tenantId, page, size, eventType, entityType, actorId, from, to
|
||||||
|
);
|
||||||
|
Page<AuditEventResponse> response = events.map(AuditEventResponse::from);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export")
|
||||||
|
@Operation(summary = "Export audit log as PDF",
|
||||||
|
description = "Generates a PDF audit report for the specified date range. Admin only.")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<byte[]> exportAuditPdf(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
|
||||||
|
) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Instant fromInstant = from.atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
|
||||||
|
Instant toInstant = to.plusDays(1).atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
|
||||||
|
|
||||||
|
byte[] pdf = auditService.exportPdf(tenantId, fromInstant, toInstant);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"audit-log-" + from + "-to-" + to + ".pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for audit events (read-only projection).
|
||||||
|
*/
|
||||||
|
public record AuditEventResponse(
|
||||||
|
UUID id,
|
||||||
|
String eventType,
|
||||||
|
String entityType,
|
||||||
|
UUID entityId,
|
||||||
|
UUID actorId,
|
||||||
|
String actorName,
|
||||||
|
String actorRole,
|
||||||
|
String description,
|
||||||
|
String metadata,
|
||||||
|
String ipAddress,
|
||||||
|
Instant timestamp
|
||||||
|
) {
|
||||||
|
public static AuditEventResponse from(AuditEvent event) {
|
||||||
|
return new AuditEventResponse(
|
||||||
|
event.getId(),
|
||||||
|
event.getEventType().name(),
|
||||||
|
event.getEntityType(),
|
||||||
|
event.getEntityId(),
|
||||||
|
event.getActorId(),
|
||||||
|
event.getActorName(),
|
||||||
|
event.getActorRole(),
|
||||||
|
event.getDescription(),
|
||||||
|
event.getMetadata(),
|
||||||
|
event.getIpAddress(),
|
||||||
|
event.getTimestamp()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import de.cannamanage.api.dto.auth.LoginRequest;
|
|||||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||||
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||||
|
import de.cannamanage.api.security.LoginRateLimiter;
|
||||||
import de.cannamanage.api.service.AuthService;
|
import de.cannamanage.api.service.AuthService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@@ -24,10 +27,19 @@ import java.util.Map;
|
|||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
private final LoginRateLimiter loginRateLimiter;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
|
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
|
||||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
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);
|
LoginResponse response = authService.login(request);
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
@@ -46,4 +58,17 @@ public class AuthController {
|
|||||||
authService.setPassword(request);
|
authService.setPassword(request);
|
||||||
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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) {}
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
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())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.service.DsgvoService;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dsgvo")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "DSGVO", description = "Data export and deletion (GDPR Art. 15 & 17)")
|
||||||
|
public class DsgvoController {
|
||||||
|
|
||||||
|
private final DsgvoService dsgvoService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art. 15 DSGVO — Export all personal data as JSON.
|
||||||
|
*/
|
||||||
|
@GetMapping("/export")
|
||||||
|
@Operation(summary = "Export all personal data (Art. 15 DSGVO)")
|
||||||
|
public ResponseEntity<Map<String, Object>> exportData(Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
|
Map<String, Object> data = dsgvoService.exportUserData(userId, tenantId);
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art. 17 DSGVO — Right to erasure.
|
||||||
|
* Anonymizes personal data, deactivates account.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/delete")
|
||||||
|
@Operation(summary = "Delete account and anonymize data (Art. 17 DSGVO)")
|
||||||
|
public ResponseEntity<Map<String, String>> deleteAccount(Authentication auth) {
|
||||||
|
UUID userId = resolveUserId(auth);
|
||||||
|
dsgvoService.deleteUserData(userId);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", "deleted",
|
||||||
|
"message", "Dein Konto wurde gelöscht und deine Daten anonymisiert."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveUserId(Authentication auth) {
|
||||||
|
// JwtAuthFilter sets the Authentication principal to the userId (the JWT subject),
|
||||||
|
// so auth.getName() is the userId UUID — NOT an email. Parse it directly.
|
||||||
|
UUID userId;
|
||||||
|
try {
|
||||||
|
userId = UUID.fromString(auth.getName());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
if (!userRepository.existsById(userId)) {
|
||||||
|
throw new ResponseStatusException(NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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) {}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.dto.grow.*;
|
||||||
|
import de.cannamanage.domain.entity.*;
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import de.cannamanage.service.GrowCalendarService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/grow")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Grow Calendar", description = "Grow lifecycle management with sensors, photos, and feeding")
|
||||||
|
public class GrowCalendarController {
|
||||||
|
|
||||||
|
private final GrowCalendarService growCalendarService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all grow entries")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<List<GrowEntryResponse>> listGrowEntries() {
|
||||||
|
List<GrowEntryResponse> entries = growCalendarService.getGrowEntries().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new grow entry")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> createGrowEntry(@Valid @RequestBody CreateGrowEntryRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.createGrowEntry(
|
||||||
|
request.name(), request.strainId(), request.notes(), request.expectedHarvestAt());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get grow entry detail with stages, sensors, photos, feedings")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||||
|
public ResponseEntity<GrowEntryDetailResponse> getGrowEntry(@PathVariable UUID id) {
|
||||||
|
GrowEntry entry = growCalendarService.getGrowEntry(id);
|
||||||
|
List<GrowStageLog> stages = growCalendarService.getStageLogs(id);
|
||||||
|
List<SensorReading> sensors = growCalendarService.getSensorReadings(id);
|
||||||
|
List<GrowPhoto> photos = growCalendarService.getPhotos(id);
|
||||||
|
List<FeedingLog> feedings = growCalendarService.getFeedingLogs(id);
|
||||||
|
|
||||||
|
GrowEntryDetailResponse detail = new GrowEntryDetailResponse(
|
||||||
|
entry.getId(), entry.getName(), entry.getStrainId(), entry.getStatus(),
|
||||||
|
entry.getStartedAt(), entry.getExpectedHarvestAt(), entry.getActualHarvestAt(),
|
||||||
|
entry.getHarvestedGrams(), entry.getLinkedBatchId(), entry.getNotes(),
|
||||||
|
stages.stream().map(this::toStageResponse).toList(),
|
||||||
|
sensors.stream().map(this::toSensorResponse).toList(),
|
||||||
|
photos.stream().map(this::toPhotoResponse).toList(),
|
||||||
|
feedings.stream().map(this::toFeedingResponse).toList()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/stage")
|
||||||
|
@Operation(summary = "Advance to next stage")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> advanceStage(@PathVariable UUID id, @Valid @RequestBody AdvanceStageRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.advanceStage(id, request.stage());
|
||||||
|
return ResponseEntity.ok(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/sensors")
|
||||||
|
@Operation(summary = "Add sensor reading")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<SensorReadingResponse> addSensorReading(@PathVariable UUID id, @Valid @RequestBody AddSensorReadingRequest request) {
|
||||||
|
SensorReading reading = growCalendarService.addSensorReading(id, request.readingType(), request.value(), request.unit());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toSensorResponse(reading));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/photos")
|
||||||
|
@Operation(summary = "Add photo")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowPhotoResponse> addPhoto(@PathVariable UUID id, @Valid @RequestBody AddPhotoRequest request) {
|
||||||
|
GrowPhoto photo = growCalendarService.addPhoto(id, request.filePath(), request.caption());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toPhotoResponse(photo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/feedings")
|
||||||
|
@Operation(summary = "Add feeding log")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<FeedingLogResponse> addFeedingLog(@PathVariable UUID id, @Valid @RequestBody AddFeedingLogRequest request) {
|
||||||
|
FeedingLog feeding = growCalendarService.addFeedingLog(id,
|
||||||
|
request.nutrientName(), request.amountMl(), request.waterLiters(),
|
||||||
|
request.phAfter(), request.ecAfter(), request.notes());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(toFeedingResponse(feeding));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/harvest")
|
||||||
|
@Operation(summary = "Complete harvest and link to batch")
|
||||||
|
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||||
|
public ResponseEntity<GrowEntryResponse> completeHarvest(@PathVariable UUID id, @Valid @RequestBody CompleteHarvestRequest request) {
|
||||||
|
GrowEntry entry = growCalendarService.completeHarvest(id, request.harvestedGrams(), request.linkedBatchId());
|
||||||
|
return ResponseEntity.ok(toResponse(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mapping helpers ---
|
||||||
|
|
||||||
|
private GrowEntryResponse toResponse(GrowEntry e) {
|
||||||
|
return new GrowEntryResponse(e.getId(), e.getName(), e.getStrainId(), e.getStatus(),
|
||||||
|
e.getStartedAt(), e.getExpectedHarvestAt(), e.getActualHarvestAt(),
|
||||||
|
e.getHarvestedGrams(), e.getLinkedBatchId(), e.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GrowStageLogResponse toStageResponse(GrowStageLog s) {
|
||||||
|
return new GrowStageLogResponse(s.getId(), s.getStage(), s.getStartedAt(), s.getEndedAt(), s.getNotes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private SensorReadingResponse toSensorResponse(SensorReading r) {
|
||||||
|
return new SensorReadingResponse(r.getId(), r.getReadingType(), r.getValue(), r.getUnit(), r.getRecordedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GrowPhotoResponse toPhotoResponse(GrowPhoto p) {
|
||||||
|
return new GrowPhotoResponse(p.getId(), p.getFilePath(), p.getCaption(), p.getTakenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeedingLogResponse toFeedingResponse(FeedingLog f) {
|
||||||
|
return new FeedingLogResponse(f.getId(), f.getNutrientName(), f.getAmountMl(),
|
||||||
|
f.getWaterLiters(), f.getPhAfter(), f.getEcAfter(), f.getFedAt(), f.getNotes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ package de.cannamanage.api.controller;
|
|||||||
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||||
import de.cannamanage.api.dto.member.MemberResponse;
|
import de.cannamanage.api.dto.member.MemberResponse;
|
||||||
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
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.Member;
|
||||||
import de.cannamanage.domain.entity.TenantContext;
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
import de.cannamanage.domain.enums.MemberStatus;
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.repository.MemberRepository;
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -17,6 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -29,6 +33,7 @@ import java.util.UUID;
|
|||||||
public class MemberController {
|
public class MemberController {
|
||||||
|
|
||||||
private final MemberRepository memberRepository;
|
private final MemberRepository memberRepository;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
||||||
@@ -89,6 +94,57 @@ public class MemberController {
|
|||||||
return ResponseEntity.ok(toResponse(saved));
|
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) {
|
private boolean isUnder21(LocalDate dateOfBirth) {
|
||||||
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
||||||
}
|
}
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Notification;
|
||||||
|
import de.cannamanage.service.NotificationService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST endpoints for notification management.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/notifications")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NotificationController {
|
||||||
|
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's notifications (last 10, unread first).
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> getNotifications(@AuthenticationPrincipal UserDetails user) {
|
||||||
|
UUID userId = UUID.fromString(user.getUsername());
|
||||||
|
List<Notification> notifications = notificationService.getRecentNotifications(userId);
|
||||||
|
long unreadCount = notificationService.getUnreadCount(userId);
|
||||||
|
|
||||||
|
var items = notifications.stream().map(n -> Map.of(
|
||||||
|
"id", (Object) n.getId(),
|
||||||
|
"type", n.getType().name(),
|
||||||
|
"title", n.getTitle(),
|
||||||
|
"message", n.getMessage(),
|
||||||
|
"link", n.getLink() != null ? n.getLink() : "",
|
||||||
|
"read", n.isRead(),
|
||||||
|
"createdAt", n.getCreatedAt().toString()
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"notifications", items,
|
||||||
|
"unreadCount", unreadCount
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a single notification as read.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}/read")
|
||||||
|
public ResponseEntity<Void> markAsRead(@PathVariable UUID id) {
|
||||||
|
notificationService.markAsRead(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read.
|
||||||
|
*/
|
||||||
|
@PutMapping("/read-all")
|
||||||
|
public ResponseEntity<Map<String, Object>> markAllAsRead(@AuthenticationPrincipal UserDetails user) {
|
||||||
|
UUID userId = UUID.fromString(user.getUsername());
|
||||||
|
int updated = notificationService.markAllAsRead(userId);
|
||||||
|
return ResponseEntity.ok(Map.of("updated", updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.security.PortalPrincipal;
|
||||||
|
import de.cannamanage.service.PortalService;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member self-service portal — read-only JSON endpoints.
|
||||||
|
* All data is scoped to the authenticated member via session principal.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/portal")
|
||||||
|
public class PortalController {
|
||||||
|
|
||||||
|
private final PortalService portalService;
|
||||||
|
|
||||||
|
public PortalController(PortalService portalService) {
|
||||||
|
this.portalService = portalService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard: quota summary + recent distributions (last 5).
|
||||||
|
*/
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ResponseEntity<PortalDashboard> dashboard(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile.
|
||||||
|
*/
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<PortalProfile> profile(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalProfile profile = portalService.getProfile(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current month quota status (daily + monthly, used/remaining).
|
||||||
|
*/
|
||||||
|
@GetMapping("/quota")
|
||||||
|
public ResponseEntity<PortalQuota> quota(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Own distribution history, paginated.
|
||||||
|
*/
|
||||||
|
@GetMapping("/distributions")
|
||||||
|
public ResponseEntity<PortalDistributionHistory> distributions(
|
||||||
|
@AuthenticationPrincipal PortalPrincipal principal,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
|
||||||
|
PortalDistributionHistory history = portalService.getDistributionHistory(
|
||||||
|
principal.getTenantId(), principal.getMemberId(), pageable);
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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,5 +1,6 @@
|
|||||||
package de.cannamanage.api.controller;
|
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.CreateStaffRequest;
|
||||||
import de.cannamanage.api.dto.staff.StaffResponse;
|
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||||
@@ -7,6 +8,7 @@ import de.cannamanage.domain.entity.StaffAccount;
|
|||||||
import de.cannamanage.domain.entity.TenantContext;
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
import de.cannamanage.domain.entity.User;
|
import de.cannamanage.domain.entity.User;
|
||||||
import de.cannamanage.domain.enums.StaffPermission;
|
import de.cannamanage.domain.enums.StaffPermission;
|
||||||
|
import de.cannamanage.service.PreventionOfficerService;
|
||||||
import de.cannamanage.service.StaffService;
|
import de.cannamanage.service.StaffService;
|
||||||
import de.cannamanage.service.StaffTemplates;
|
import de.cannamanage.service.StaffTemplates;
|
||||||
import de.cannamanage.service.repository.UserRepository;
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
@@ -31,6 +33,7 @@ import java.util.UUID;
|
|||||||
public class StaffController {
|
public class StaffController {
|
||||||
|
|
||||||
private final StaffService staffService;
|
private final StaffService staffService;
|
||||||
|
private final PreventionOfficerService preventionOfficerService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -80,7 +83,7 @@ public class StaffController {
|
|||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||||
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||||
@RequestBody UpdateStaffRequest request) {
|
@Valid @RequestBody UpdateStaffRequest request) {
|
||||||
UUID tenantId = TenantContext.getCurrentTenant();
|
UUID tenantId = TenantContext.getCurrentTenant();
|
||||||
StaffAccount staff = staffService.updateStaff(
|
StaffAccount staff = staffService.updateStaff(
|
||||||
tenantId, id,
|
tenantId, id,
|
||||||
@@ -103,6 +106,19 @@ public class StaffController {
|
|||||||
return ResponseEntity.noContent().build();
|
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")
|
@GetMapping("/templates")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Operation(summary = "List available permission templates")
|
@Operation(summary = "List available permission templates")
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.service.StripeService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/webhooks")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StripeWebhookController {
|
||||||
|
|
||||||
|
private final StripeService stripeService;
|
||||||
|
|
||||||
|
@PostMapping("/stripe")
|
||||||
|
public ResponseEntity<String> handleStripeWebhook(
|
||||||
|
@RequestBody String payload,
|
||||||
|
@RequestHeader("Stripe-Signature") String sigHeader) {
|
||||||
|
try {
|
||||||
|
stripeService.handleWebhook(payload, sigHeader);
|
||||||
|
return ResponseEntity.ok("ok");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.error("Stripe webhook processing failed: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.cannamanage.api.dto.billing;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CheckoutRequest(
|
||||||
|
@NotBlank String planTier
|
||||||
|
) {}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.api.dto.billing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record SubscriptionResponse(
|
||||||
|
String planTier,
|
||||||
|
String status,
|
||||||
|
int memberLimit,
|
||||||
|
Instant trialEndsAt,
|
||||||
|
Instant currentPeriodStart,
|
||||||
|
Instant currentPeriodEnd,
|
||||||
|
Instant canceledAt,
|
||||||
|
boolean hasStripeSubscription
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.cannamanage.api.dto.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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.event;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.RsvpStatus;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record RsvpRequest(
|
||||||
|
@NotNull RsvpStatus status
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package de.cannamanage.api.dto.finance;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.FeeInterval;
|
||||||
|
|
||||||
|
public record UpdateFeeScheduleRequest(
|
||||||
|
String name,
|
||||||
|
Integer amountCents,
|
||||||
|
FeeInterval interval,
|
||||||
|
Boolean isDefault
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.cannamanage.api.dto.finance;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record VoidPaymentRequest(
|
||||||
|
@NotBlank String reason
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AddFeedingLogRequest(
|
||||||
|
@NotBlank String nutrientName,
|
||||||
|
@NotNull BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters,
|
||||||
|
BigDecimal phAfter,
|
||||||
|
BigDecimal ecAfter,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record AddPhotoRequest(
|
||||||
|
@NotBlank String filePath,
|
||||||
|
String caption
|
||||||
|
) {}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record AddSensorReadingRequest(
|
||||||
|
@NotNull SensorReadingType readingType,
|
||||||
|
@NotNull BigDecimal value,
|
||||||
|
@NotBlank String unit
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AdvanceStageRequest(
|
||||||
|
@NotNull GrowStage stage
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CompleteHarvestRequest(
|
||||||
|
@NotNull BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CreateGrowEntryRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
UUID strainId,
|
||||||
|
String notes,
|
||||||
|
Instant expectedHarvestAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record FeedingLogResponse(
|
||||||
|
UUID id,
|
||||||
|
String nutrientName,
|
||||||
|
BigDecimal amountMl,
|
||||||
|
BigDecimal waterLiters,
|
||||||
|
BigDecimal phAfter,
|
||||||
|
BigDecimal ecAfter,
|
||||||
|
Instant fedAt,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowEntryDetailResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID strainId,
|
||||||
|
GrowStage status,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant expectedHarvestAt,
|
||||||
|
Instant actualHarvestAt,
|
||||||
|
BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId,
|
||||||
|
String notes,
|
||||||
|
List<GrowStageLogResponse> stages,
|
||||||
|
List<SensorReadingResponse> sensors,
|
||||||
|
List<GrowPhotoResponse> photos,
|
||||||
|
List<FeedingLogResponse> feedings
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowEntryResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
UUID strainId,
|
||||||
|
GrowStage status,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant expectedHarvestAt,
|
||||||
|
Instant actualHarvestAt,
|
||||||
|
BigDecimal harvestedGrams,
|
||||||
|
UUID linkedBatchId,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowPhotoResponse(
|
||||||
|
UUID id,
|
||||||
|
String filePath,
|
||||||
|
String caption,
|
||||||
|
Instant takenAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.GrowStage;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record GrowStageLogResponse(
|
||||||
|
UUID id,
|
||||||
|
GrowStage stage,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant endedAt,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.grow;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.SensorReadingType;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record SensorReadingResponse(
|
||||||
|
UUID id,
|
||||||
|
SensorReadingType readingType,
|
||||||
|
BigDecimal value,
|
||||||
|
String unit,
|
||||||
|
Instant recordedAt
|
||||||
|
) {}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PreventionDataResponse(
|
||||||
|
UUID memberId,
|
||||||
|
String name,
|
||||||
|
boolean isUnder21,
|
||||||
|
int age,
|
||||||
|
long currentMonthDistributions,
|
||||||
|
BigDecimal gramsUsedThisMonth,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
BigDecimal quotaRemaining
|
||||||
|
) {}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record PreventionOfficerRequest(
|
||||||
|
@NotNull Boolean preventionOfficer
|
||||||
|
) {}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.api.dto.prevention;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record Under21MemberResponse(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
int age,
|
||||||
|
LocalDate dateOfBirth,
|
||||||
|
long totalDistributionsThisMonth,
|
||||||
|
BigDecimal gramsUsedThisMonth,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
String quotaStatus
|
||||||
|
) {}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the member list report.
|
||||||
|
*/
|
||||||
|
public record MemberListResponse(
|
||||||
|
Instant generatedAt,
|
||||||
|
List<MemberEntryDto> members
|
||||||
|
) {
|
||||||
|
public record MemberEntryDto(
|
||||||
|
UUID id,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String membershipNumber,
|
||||||
|
String status,
|
||||||
|
LocalDate joinDate,
|
||||||
|
int totalDistributions,
|
||||||
|
Instant lastDistributionDate
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the monthly distribution report.
|
||||||
|
*/
|
||||||
|
public record MonthlyReportResponse(
|
||||||
|
String month,
|
||||||
|
int totalDistributions,
|
||||||
|
BigDecimal totalGrams,
|
||||||
|
int uniqueMembers,
|
||||||
|
BigDecimal averagePerMember,
|
||||||
|
List<StrainSummaryDto> topStrains,
|
||||||
|
List<DailyEntryDto> dailyBreakdown
|
||||||
|
) {
|
||||||
|
public record StrainSummaryDto(String name, BigDecimal totalGrams, int distributionCount) {}
|
||||||
|
public record DailyEntryDto(LocalDate date, BigDecimal grams, int distributions) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.cannamanage.api.dto.report;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response DTO for the recall/batch trace report.
|
||||||
|
*/
|
||||||
|
public record RecallReportResponse(
|
||||||
|
UUID batchId,
|
||||||
|
String strainName,
|
||||||
|
String batchNumber,
|
||||||
|
LocalDate receivedDate,
|
||||||
|
BigDecimal totalGramsDistributed,
|
||||||
|
List<AffectedMemberDto> affectedMembers
|
||||||
|
) {
|
||||||
|
public record AffectedMemberDto(
|
||||||
|
UUID memberId,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
Instant distributionDate,
|
||||||
|
BigDecimal grams
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public record StaffResponse(
|
|||||||
Set<StaffPermission> permissions,
|
Set<StaffPermission> permissions,
|
||||||
String templateName,
|
String templateName,
|
||||||
boolean active,
|
boolean active,
|
||||||
|
boolean preventionOfficer,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
public static StaffResponse from(StaffAccount staff, User user) {
|
public static StaffResponse from(StaffAccount staff, User user) {
|
||||||
@@ -30,6 +31,7 @@ public record StaffResponse(
|
|||||||
staff.getGrantedPermissions(),
|
staff.getGrantedPermissions(),
|
||||||
null, // templateName not stored; permissions are expanded
|
null, // templateName not stored; permissions are expanded
|
||||||
staff.isActive(),
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
staff.getCreatedAt()
|
staff.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ public record StaffResponse(
|
|||||||
staff.getGrantedPermissions(),
|
staff.getGrantedPermissions(),
|
||||||
null,
|
null,
|
||||||
staff.isActive(),
|
staff.isActive(),
|
||||||
|
staff.isPreventionOfficer(),
|
||||||
staff.getCreatedAt()
|
staff.getCreatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -3,7 +3,9 @@ package de.cannamanage.api.exception;
|
|||||||
import de.cannamanage.api.service.AuthService;
|
import de.cannamanage.api.service.AuthService;
|
||||||
import de.cannamanage.service.exception.BatchNotFoundException;
|
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||||
import de.cannamanage.service.exception.QuotaExceededException;
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
|
import de.cannamanage.service.exception.StorageQuotaExceededException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ProblemDetail;
|
import org.springframework.http.ProblemDetail;
|
||||||
@@ -108,6 +110,32 @@ public class GlobalExceptionHandler {
|
|||||||
return problem;
|
return problem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(PreventionOfficerLimitExceededException.class)
|
||||||
|
public ProblemDetail handlePreventionOfficerLimitExceeded(PreventionOfficerLimitExceededException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problem.setTitle("Prevention Officer Limit Exceeded");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:PREVENTION_OFFICER_LIMIT_EXCEEDED"));
|
||||||
|
problem.setProperty("code", "PREVENTION_OFFICER_LIMIT_EXCEEDED");
|
||||||
|
problem.setProperty("maxAllowed", ex.getMaxAllowed());
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(StorageQuotaExceededException.class)
|
||||||
|
public ProblemDetail handleStorageQuotaExceeded(StorageQuotaExceededException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.PAYMENT_REQUIRED, ex.getMessage());
|
||||||
|
problem.setTitle("Storage Quota Exceeded");
|
||||||
|
problem.setType(URI.create("urn:cannamanage:error:STORAGE_QUOTA_EXCEEDED"));
|
||||||
|
problem.setProperty("code", "STORAGE_QUOTA_EXCEEDED");
|
||||||
|
problem.setProperty("currentUsage", ex.getCurrentUsage());
|
||||||
|
problem.setProperty("limit", ex.getLimit());
|
||||||
|
problem.setProperty("requestedBytes", ex.getRequestedBytes());
|
||||||
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(ResponseStatusException.class)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
|
|||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import io.jsonwebtoken.io.Decoders;
|
import io.jsonwebtoken.io.Decoders;
|
||||||
import io.jsonwebtoken.security.Keys;
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -29,6 +30,32 @@ public class JwtService {
|
|||||||
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
||||||
private long refreshTokenExpiry; // seconds (30 days)
|
private long refreshTokenExpiry; // seconds (30 days)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel value used in the application.properties default. If the runtime JWT secret
|
||||||
|
* matches this string (or is missing/too short) the application must fail to start —
|
||||||
|
* we never want a deployment to silently fall back to a publicly-known dev secret.
|
||||||
|
*/
|
||||||
|
static final String UNCONFIGURED_SECRET_MARKER = "CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate JWT secret on startup — fail fast if the deployment is missing a proper secret.
|
||||||
|
* Runs after Spring property binding (@Value) so we see the effective value.
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
void validateSecret() {
|
||||||
|
if (secretKey == null
|
||||||
|
|| secretKey.isBlank()
|
||||||
|
|| secretKey.length() < 32
|
||||||
|
|| UNCONFIGURED_SECRET_MARKER.equals(secretKey)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"FATAL: JWT secret is not configured or uses the default dev placeholder. "
|
||||||
|
+ "Set the CANNAMANAGE_SECURITY_JWT_SECRET environment variable "
|
||||||
|
+ "(or cannamanage.security.jwt.secret property) to a base64-encoded "
|
||||||
|
+ "256-bit (or larger) random key."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import io.github.bucket4j.ConsumptionProbe;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-limits login attempts per client IP using Bucket4j + Caffeine cache.
|
||||||
|
* Allows 5 login attempts per minute per IP; returns 429 when exhausted.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(1)
|
||||||
|
public class LoginRateLimitFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String LOGIN_PATH = "/api/v1/auth/login";
|
||||||
|
private static final int CAPACITY = 5;
|
||||||
|
private static final Duration REFILL_PERIOD = Duration.ofMinutes(1);
|
||||||
|
|
||||||
|
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
|
||||||
|
.maximumSize(10_000)
|
||||||
|
.expireAfterAccess(Duration.ofMinutes(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
if (!"POST".equalsIgnoreCase(request.getMethod()) || !LOGIN_PATH.equals(request.getRequestURI())) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String clientIp = resolveClientIp(request);
|
||||||
|
Bucket bucket = buckets.get(clientIp, k -> createBucket());
|
||||||
|
|
||||||
|
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||||
|
if (probe.isConsumed()) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
} else {
|
||||||
|
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000 + 1;
|
||||||
|
response.setStatus(429);
|
||||||
|
response.setHeader("Retry-After", String.valueOf(waitSeconds));
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().write("{\"error\":\"Too many login attempts. Retry after " + waitSeconds + "s\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bucket createBucket() {
|
||||||
|
return Bucket.builder()
|
||||||
|
.addLimit(Bandwidth.builder()
|
||||||
|
.capacity(CAPACITY)
|
||||||
|
.refillGreedy(CAPACITY, REFILL_PERIOD)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveClientIp(HttpServletRequest request) {
|
||||||
|
String xff = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xff != null && !xff.isBlank()) {
|
||||||
|
// Take the first IP in the chain (original client)
|
||||||
|
return xff.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory brute-force protection for the login endpoint.
|
||||||
|
*
|
||||||
|
* <p>Tracks attempts per source IP and rejects further attempts once the
|
||||||
|
* configured threshold ({@link #MAX_ATTEMPTS_PER_WINDOW}) is exceeded within
|
||||||
|
* the current 60-second window. Counters are reset every minute by
|
||||||
|
* {@link #resetCounters()}.
|
||||||
|
*
|
||||||
|
* <p>This deliberately stays in-memory rather than introducing Resilience4j /
|
||||||
|
* Bucket4j for a single endpoint. For multi-instance deployments behind a
|
||||||
|
* load balancer this should be revisited.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class LoginRateLimiter {
|
||||||
|
|
||||||
|
/** Maximum failed/total login attempts allowed per IP per window. */
|
||||||
|
public static final int MAX_ATTEMPTS_PER_WINDOW = 5;
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, AtomicInteger> attemptsByIp = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an attempt and returns {@code true} if the request is allowed
|
||||||
|
* (still within the per-window quota), {@code false} if it must be
|
||||||
|
* rejected with HTTP 429.
|
||||||
|
*/
|
||||||
|
public boolean tryAcquire(String ipAddress) {
|
||||||
|
if (ipAddress == null || ipAddress.isBlank()) {
|
||||||
|
ipAddress = "unknown";
|
||||||
|
}
|
||||||
|
AtomicInteger counter = attemptsByIp.computeIfAbsent(ipAddress, k -> new AtomicInteger(0));
|
||||||
|
int current = counter.incrementAndGet();
|
||||||
|
if (current > MAX_ATTEMPTS_PER_WINDOW) {
|
||||||
|
log.warn("Login rate limit exceeded for IP {} ({} attempts in current window)", ipAddress, current);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all counters every 60 seconds. Fixed-rate scheduler keeps the
|
||||||
|
* implementation predictable and free of timestamp bookkeeping.
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 60_000L)
|
||||||
|
public void resetCounters() {
|
||||||
|
if (!attemptsByIp.isEmpty()) {
|
||||||
|
log.debug("Resetting login rate-limit counters for {} IPs", attemptsByIp.size());
|
||||||
|
attemptsByIp.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom UserDetails principal for member portal sessions.
|
||||||
|
* Carries tenantId and memberId so portal controllers can enforce data scoping.
|
||||||
|
*/
|
||||||
|
public class PortalPrincipal extends User {
|
||||||
|
|
||||||
|
private final UUID tenantId;
|
||||||
|
private final UUID memberId;
|
||||||
|
|
||||||
|
public PortalPrincipal(String username, String password,
|
||||||
|
Collection<? extends GrantedAuthority> authorities,
|
||||||
|
UUID tenantId, UUID memberId) {
|
||||||
|
super(username, password, authorities);
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserDetailsService for portal session-based auth.
|
||||||
|
* Only loads MEMBER-role users who are active. Members log in by email.
|
||||||
|
*/
|
||||||
|
@Service("portalUserDetailsService")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PortalUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email));
|
||||||
|
|
||||||
|
// Only MEMBER role users may use the portal
|
||||||
|
if (user.getRole() != UserRole.ROLE_MEMBER) {
|
||||||
|
throw new UsernameNotFoundException("User is not a member");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be active
|
||||||
|
if (!user.isActive()) {
|
||||||
|
throw new UsernameNotFoundException("User account is inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a linked memberId
|
||||||
|
if (user.getMemberId() == null) {
|
||||||
|
throw new UsernameNotFoundException("User has no linked member profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorities = List.of(new SimpleGrantedAuthority("ROLE_MEMBER"));
|
||||||
|
|
||||||
|
return new PortalPrincipal(
|
||||||
|
user.getEmail(),
|
||||||
|
user.getPasswordHash(),
|
||||||
|
authorities,
|
||||||
|
user.getTenantId(),
|
||||||
|
user.getMemberId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.cannamanage.api.security;
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
@@ -12,10 +14,18 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security configuration — Sprint 3: API + Staff portal with JWT.
|
* Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
|
||||||
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
|
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -24,6 +34,15 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthFilter jwtAuthFilter;
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
private final PortalUserDetailsService portalUserDetailsService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated allowed CORS origins. Defaults to local dev origins; production
|
||||||
|
* deployments override via the {@code CORS_ORIGINS} environment variable
|
||||||
|
* (e.g. {@code https://cannamanage.plate-software.de}).
|
||||||
|
*/
|
||||||
|
@Value("${cannamanage.cors.allowed-origins:http://localhost:3000,http://frontend:3000}")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API security — stateless JWT authentication.
|
* API security — stateless JWT authentication.
|
||||||
@@ -34,11 +53,25 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.securityMatcher("/api/**")
|
.securityMatcher("/api/**")
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
// snyk:ignore java/CsrfProtectionDisabled — Intentional: this filter chain
|
||||||
|
// handles stateless JWT-authenticated API calls only. CSRF attacks exploit
|
||||||
|
// browser-managed session cookies; Bearer token auth is immune because the
|
||||||
|
// token is never sent automatically by the browser. OWASP CSRF Prevention
|
||||||
|
// Cheat Sheet: "If your application does not use cookies for authentication,
|
||||||
|
// CSRF is not a risk." The portal chain (Order 2) correctly enables CSRF via
|
||||||
|
// CookieCsrfTokenRepository for its session-based auth.
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.headers(headers -> headers
|
||||||
|
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||||
|
"default-src 'self'; frame-ancestors 'none'"))
|
||||||
|
.frameOptions(frame -> frame.deny()))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
|
.requestMatchers("/api/v1/webhooks/**").permitAll()
|
||||||
|
.requestMatchers("/api/v1/billing/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
@@ -46,17 +79,70 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||||
|
// Documents endpoint — method-specific matchers for defense-in-depth.
|
||||||
|
// POST (upload) and DELETE restricted to ADMIN/STAFF; GET allowed for all
|
||||||
|
// authenticated roles. Per-document tenant ownership is additionally
|
||||||
|
// enforced in DocumentController via TenantContext.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member portal — session-based authentication with CSRF protection.
|
||||||
|
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(2)
|
||||||
|
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/portal/**")
|
||||||
|
.csrf(csrf -> csrf
|
||||||
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||||
|
.maximumSessions(1))
|
||||||
|
.headers(headers -> headers
|
||||||
|
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||||
|
"default-src 'self'; frame-ancestors 'none'"))
|
||||||
|
.frameOptions(frame -> frame.deny()))
|
||||||
|
.userDetailsService(portalUserDetailsService)
|
||||||
|
.formLogin(form -> form
|
||||||
|
.loginProcessingUrl("/portal/login")
|
||||||
|
.successHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"ok\"}");
|
||||||
|
})
|
||||||
|
.failureHandler((request, response, exception) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
|
||||||
|
})
|
||||||
|
.permitAll())
|
||||||
|
.logout(logout -> logout
|
||||||
|
.logoutUrl("/portal/logout")
|
||||||
|
.logoutSuccessHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"logged_out\"}");
|
||||||
|
}))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
|
||||||
|
.requestMatchers("/portal/**").hasRole("MEMBER"));
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public endpoints — Swagger UI, actuator health.
|
* Public endpoints — Swagger UI, actuator health.
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
@Order(2)
|
@Order(3)
|
||||||
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
||||||
@@ -70,4 +156,22 @@ public class SecurityConfig {
|
|||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
List<String> origins = Arrays.stream(allowedOrigins.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.toList();
|
||||||
|
config.setAllowedOrigins(origins);
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
config.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/api/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,4 +54,56 @@ public class StaffPermissionChecker {
|
|||||||
.map(staff -> staff.hasPermission(required))
|
.map(staff -> staff.hasPermission(required))
|
||||||
.orElse(false);
|
.orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative permission check — throws AccessDeniedException if permission is missing.
|
||||||
|
* Used by controllers that need to guard specific endpoints programmatically.
|
||||||
|
*/
|
||||||
|
public void requirePermission(org.springframework.security.core.userdetails.UserDetails principal, StaffPermission required) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
|
||||||
|
}
|
||||||
|
// Convert UserDetails to Authentication-like check
|
||||||
|
UUID userId = UUID.fromString(principal.getUsername());
|
||||||
|
boolean isAdmin = principal.getAuthorities().stream()
|
||||||
|
.map(GrantedAuthority::getAuthority)
|
||||||
|
.anyMatch(a -> a.equals("ROLE_ADMIN"));
|
||||||
|
if (isAdmin) return;
|
||||||
|
|
||||||
|
boolean isStaff = principal.getAuthorities().stream()
|
||||||
|
.map(GrantedAuthority::getAuthority)
|
||||||
|
.anyMatch(a -> a.equals("ROLE_STAFF"));
|
||||||
|
if (!isStaff) {
|
||||||
|
throw new org.springframework.security.access.AccessDeniedException("Insufficient permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasPermission = staffAccountRepository.findByUserId(userId)
|
||||||
|
.filter(StaffAccount::isActive)
|
||||||
|
.map(staff -> staff.hasPermission(required))
|
||||||
|
.orElse(false);
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the user ID from the authenticated principal.
|
||||||
|
*/
|
||||||
|
public UUID getUserId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||||
|
return UUID.fromString(principal.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the club ID (tenant) for the authenticated user.
|
||||||
|
*/
|
||||||
|
public UUID getClubId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||||
|
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tenant ID for the authenticated user (alias for getClubId).
|
||||||
|
*/
|
||||||
|
public UUID getTenantId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||||
|
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import java.util.UUID;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final String INVALID_CREDENTIALS = "Invalid credentials";
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
@@ -43,14 +45,14 @@ public class AuthService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public LoginResponse login(LoginRequest request) {
|
public LoginResponse login(LoginRequest request) {
|
||||||
User user = userRepository.findByEmail(request.email())
|
User user = userRepository.findByEmail(request.email())
|
||||||
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
|
.orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS));
|
||||||
|
|
||||||
if (!user.isActive()) {
|
if (!user.isActive()) {
|
||||||
throw new AuthenticationException("Account not activated");
|
throw new AuthenticationException("Account not activated");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||||
throw new AuthenticationException("Invalid credentials");
|
throw new AuthenticationException(INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
@@ -147,7 +149,7 @@ public class AuthService {
|
|||||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
return HexFormat.of().formatHex(hash);
|
return HexFormat.of().formatHex(hash);
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
throw new RuntimeException("SHA-256 not available", e);
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Docker profile — used when running in Docker Compose
|
||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||||
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||||
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
|
||||||
|
# Enable Flyway for container startup (fresh DB)
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
|
||||||
|
# JWT secret from environment
|
||||||
|
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||||
|
|
||||||
|
# Actuator
|
||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
# No SMTP container in this deployment — don't let the mail health indicator
|
||||||
|
# drag /actuator/health to DOWN (503), which would mark the container unhealthy.
|
||||||
|
management.health.mail.enabled=false
|
||||||
|
|
||||||
|
# IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production
|
||||||
|
spring.mail.host=${SMTP_HOST:smtp.ionos.de}
|
||||||
|
spring.mail.port=${SMTP_PORT:587}
|
||||||
|
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
|
||||||
|
spring.mail.password=${IONOS_SMTP_PASSWORD:}
|
||||||
|
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:true}
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:true}
|
||||||
|
spring.mail.properties.mail.smtp.starttls.required=true
|
||||||
|
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||||
|
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||||
|
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
|
||||||
|
cannamanage.mail.rate-limit=${MAIL_RATE_LIMIT:50}
|
||||||
|
|
||||||
|
# Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
|
||||||
|
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
|
||||||
|
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
|
||||||
|
push.vapid.subject=mailto:admin@cannamanage.de
|
||||||
|
|
||||||
|
# Firebase Cloud Messaging
|
||||||
|
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
|
||||||
|
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Cannamanage — Production Profile
|
||||||
|
# =============================================================================
|
||||||
|
# Activated via: SPRING_PROFILES_ACTIVE=production
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database
|
||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||||
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||||
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
spring.datasource.hikari.maximum-pool-size=10
|
||||||
|
spring.datasource.hikari.minimum-idle=2
|
||||||
|
|
||||||
|
# JPA
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
|
||||||
|
# Flyway
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
|
||||||
|
# JWT Security
|
||||||
|
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
stripe.secret-key=${STRIPE_SECRET_KEY}
|
||||||
|
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
|
||||||
|
stripe.starter-price-id=${STRIPE_STARTER_PRICE_ID}
|
||||||
|
stripe.pro-price-id=${STRIPE_PRO_PRICE_ID}
|
||||||
|
|
||||||
|
# App
|
||||||
|
app.base-url=${APP_BASE_URL:https://app.cannamanage.de}
|
||||||
|
|
||||||
|
# Error handling — never expose internals
|
||||||
|
server.error.include-message=never
|
||||||
|
server.error.include-stacktrace=never
|
||||||
|
server.error.include-binding-errors=never
|
||||||
|
|
||||||
|
# Actuator — health only, no sensitive details
|
||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
|
||||||
|
# Logging — production levels
|
||||||
|
logging.level.root=WARN
|
||||||
|
logging.level.de.cannamanage=INFO
|
||||||
|
logging.level.org.springframework.security=WARN
|
||||||
|
logging.level.org.hibernate.SQL=OFF
|
||||||
|
|
||||||
|
# Disable Swagger in production
|
||||||
|
springdoc.api-docs.enabled=false
|
||||||
|
springdoc.swagger-ui.enabled=false
|
||||||
|
|
||||||
|
# App base URL
|
||||||
|
app.base-url=https://cannamanage.plate-software.de
|
||||||
|
|
||||||
|
# IONOS SMTP relay (plate-software.de)
|
||||||
|
spring.mail.host=smtp.ionos.de
|
||||||
|
spring.mail.port=587
|
||||||
|
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
|
||||||
|
spring.mail.password=${IONOS_SMTP_PASSWORD}
|
||||||
|
spring.mail.properties.mail.smtp.auth=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||||
|
spring.mail.properties.mail.smtp.starttls.required=true
|
||||||
|
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||||
|
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||||
|
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
|
||||||
|
cannamanage.mail.rate-limit=50
|
||||||
|
|
||||||
|
# Web Push VAPID keys
|
||||||
|
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
|
||||||
|
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
|
||||||
|
push.vapid.subject=mailto:admin@cannamanage.plate-software.de
|
||||||
|
|
||||||
|
# Firebase Cloud Messaging
|
||||||
|
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
|
||||||
|
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# =============================================
|
||||||
|
# application-test.properties
|
||||||
|
# Profile: test — for integration test environment
|
||||||
|
# Activate with: -Dspring.profiles.active=test
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
# Database: use docker-compose.test.yml PostgreSQL
|
||||||
|
spring.datasource.url=jdbc:postgresql://localhost:5433/cannamanage_test
|
||||||
|
spring.datasource.username=cannamanage_test
|
||||||
|
spring.datasource.password=test_password
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
|
||||||
|
# Flyway: include test seed data
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migration,classpath:db/testdata
|
||||||
|
|
||||||
|
# Enable test-only endpoints (TestResetController)
|
||||||
|
cannamanage.test.endpoints.enabled=true
|
||||||
|
|
||||||
|
# Disable schedulers during test runs
|
||||||
|
cannamanage.schedulers.enabled=false
|
||||||
|
|
||||||
|
# JWT: deterministic test secret (base64-encoded 256-bit key)
|
||||||
|
cannamanage.security.jwt.secret=dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0cy1vbmx5LTMyYg==
|
||||||
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
|
cannamanage.security.jwt.refresh-token-expiry=86400
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging.level.de.cannamanage=DEBUG
|
||||||
|
logging.level.org.flywaydb=INFO
|
||||||
|
logging.level.org.springframework.security=DEBUG
|
||||||
@@ -5,7 +5,12 @@ spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
|
|||||||
spring.flyway.enabled=false
|
spring.flyway.enabled=false
|
||||||
|
|
||||||
# JWT Security
|
# JWT Security
|
||||||
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
# DO NOT ship a usable default secret. JwtService.validateSecret() detects the marker below
|
||||||
|
# and refuses to start, forcing every deployment to provide a real base64-encoded 256-bit key
|
||||||
|
# via the CANNAMANAGE_SECURITY_JWT_SECRET environment variable (or override property).
|
||||||
|
# Test/integration profiles pin their own valid dev secret in application-test.properties /
|
||||||
|
# application-integration.properties.
|
||||||
|
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET:CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP}
|
||||||
cannamanage.security.jwt.access-token-expiry=3600
|
cannamanage.security.jwt.access-token-expiry=3600
|
||||||
cannamanage.security.jwt.refresh-token-expiry=2592000
|
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||||
|
|
||||||
@@ -30,3 +35,27 @@ spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
|
|||||||
|
|
||||||
# App base URL (for invite links)
|
# App base URL (for invite links)
|
||||||
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
# Actuator
|
||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
|
||||||
|
# Session configuration (member portal)
|
||||||
|
server.servlet.session.timeout=30m
|
||||||
|
server.servlet.session.cookie.same-site=strict
|
||||||
|
|
||||||
|
# Schedulers
|
||||||
|
cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true}
|
||||||
|
server.servlet.session.cookie.http-only=true
|
||||||
|
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||||
|
|
||||||
|
# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too
|
||||||
|
spring.servlet.multipart.enabled=true
|
||||||
|
spring.servlet.multipart.max-file-size=5MB
|
||||||
|
spring.servlet.multipart.max-request-size=6MB
|
||||||
|
|
||||||
|
# Security hardening — limit non-multipart request body sizes to prevent DoS via oversized payloads
|
||||||
|
server.tomcat.max-http-form-post-size=2MB
|
||||||
|
|
||||||
|
# CORS allowed origins (comma-separated). Override via CORS_ORIGINS env var in production.
|
||||||
|
cannamanage.cors.allowed-origins=${CORS_ORIGINS:http://localhost:3000,http://frontend:3000}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- V10: Notifications table for real-time + persistent notification system
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
link VARCHAR(500),
|
||||||
|
read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notifications_user ON notifications(user_id, read, created_at DESC);
|
||||||
|
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Sprint 7 Phase 1: Notification sends (admin compose + broadcast tracking)
|
||||||
|
-- Tracks each "send" operation (one admin → many members)
|
||||||
|
|
||||||
|
CREATE TABLE notification_sends (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
link VARCHAR(500),
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
target_type VARCHAR(20) NOT NULL, -- ALL or SELECTED
|
||||||
|
target_count INTEGER NOT NULL,
|
||||||
|
read_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notification_send_recipients (
|
||||||
|
send_id UUID NOT NULL REFERENCES notification_sends(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
notification_id UUID REFERENCES notifications(id),
|
||||||
|
PRIMARY KEY (send_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notification_sends_tenant ON notification_sends(tenant_id, sent_at DESC);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Sprint 7 Phase 1B: Push notification infrastructure
|
||||||
|
-- Device token registry (Web Push subscriptions + mobile push tokens)
|
||||||
|
|
||||||
|
CREATE TABLE device_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
platform VARCHAR(20) NOT NULL, -- WEB, IOS, ANDROID
|
||||||
|
token TEXT NOT NULL, -- Push subscription JSON (Web) or FCM token (mobile)
|
||||||
|
device_name VARCHAR(100), -- e.g. "Chrome on MacBook", "iPhone 15"
|
||||||
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
UNIQUE(user_id, token)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
|
||||||
|
CREATE INDEX idx_device_tokens_platform ON device_tokens(platform, tenant_id);
|
||||||
|
|
||||||
|
-- Per-user notification channel preferences
|
||||||
|
CREATE TABLE notification_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL, -- IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
UNIQUE(user_id, channel)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- V13: Info Board (Schwarzes Brett) tables
|
||||||
|
|
||||||
|
CREATE TABLE info_board_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
category VARCHAR(50) NOT NULL,
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE post_attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100),
|
||||||
|
file_size BIGINT,
|
||||||
|
storage_path VARCHAR(500) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
tenant_id UUID NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE post_read_status (
|
||||||
|
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (post_id, member_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_info_board_posts_club_id ON info_board_posts(club_id);
|
||||||
|
CREATE INDEX idx_info_board_posts_category ON info_board_posts(category);
|
||||||
|
CREATE INDEX idx_info_board_posts_pinned ON info_board_posts(is_pinned) WHERE is_pinned = TRUE;
|
||||||
|
CREATE INDEX idx_info_board_posts_tenant ON info_board_posts(tenant_id);
|
||||||
|
CREATE INDEX idx_post_attachments_post_id ON post_attachments(post_id);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user