Compare commits
117 Commits
| 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 | |||
| 59b7486cec | |||
| 752101c6c9 | |||
| 302b7da8ca | |||
| 6c66783b58 | |||
| 36deb72cf0 | |||
| 55d8434f35 | |||
| 08b8e43ae8 | |||
| a1ddec37da | |||
| 2ede872d11 | |||
| 86c922e1f9 | |||
| 10891e7b89 |
@@ -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
|
||||
*.swp
|
||||
.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"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# CannaManage
|
||||
|
||||
Full-stack management platform for German cannabis cultivation associations (Anbauvereinigungen) under the CanG/KCanG regulatory framework.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Frontend** | Next.js 15, React 19, TypeScript, Tailwind CSS 4, shadcn/ui |
|
||||
| **Backend** | Spring Boot 3.5, Java 17, Spring Security (JWT + session) |
|
||||
| **Database** | PostgreSQL 16, Flyway migrations |
|
||||
| **Infrastructure** | Docker Compose, Gitea Actions CI/CD, TrueNAS deployment |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cannamanage/
|
||||
├── cannamanage-api/ # Spring Boot REST API (entry point)
|
||||
├── cannamanage-service/ # Business logic layer
|
||||
├── cannamanage-domain/ # JPA entities, enums, value objects
|
||||
├── cannamanage-frontend/ # Next.js frontend (pnpm)
|
||||
├── deploy/ # Deployment scripts & nginx config
|
||||
├── docker-compose.yml # Local development stack
|
||||
└── .gitea/workflows/ # CI/CD pipeline
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Java 17+
|
||||
- Maven 3.9+
|
||||
- Node.js 22+ with pnpm 10+
|
||||
- Docker & Docker Compose
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL
|
||||
docker compose up -d db
|
||||
|
||||
# Run Spring Boot
|
||||
mvn spring-boot:run -f cannamanage-api/pom.xml -Dspring-boot.run.profiles=local
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd cannamanage-frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The frontend runs on http://localhost:3000, backend on http://localhost:8080.
|
||||
|
||||
### Full Stack (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Push to `main` triggers the Gitea Actions CI pipeline which:
|
||||
1. Runs backend tests (`mvn test`)
|
||||
2. Runs frontend lint (`pnpm lint`)
|
||||
3. Builds Docker images
|
||||
4. Deploys to TrueNAS via Docker Compose
|
||||
5. Verifies backend health + frontend availability
|
||||
|
||||
Manual deploy:
|
||||
```bash
|
||||
cd deploy && ./deploy.sh
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `CANNAMANAGE_SECURITY_JWT_SECRET` | JWT signing key (base64, 256-bit) | — (required) |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:3000` |
|
||||
| `SMTP_HOST` / `SMTP_PORT` | Mail server for invites | `localhost:1025` |
|
||||
| `SCHEDULERS_ENABLED` | Enable background jobs | `true` |
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — Patrick Plate
|
||||
@@ -36,6 +36,18 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!--
|
||||
Spring Boot 4 modularized autoconfiguration: FlywayAutoConfiguration
|
||||
moved out of spring-boot-autoconfigure into the dedicated spring-boot-flyway
|
||||
module, which is only brought in by spring-boot-starter-flyway. Without this
|
||||
starter, spring.flyway.enabled=true is inert — migrations never run and
|
||||
Hibernate ddl-auto=validate fails on the empty schema.
|
||||
See: https://spring.io/blog/2025/10/28/modularizing-spring-boot/
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-flyway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
@@ -46,11 +58,99 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<!-- Spring Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<!-- Bean Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<!-- JWT (JJWT) -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- OpenAPI / Swagger UI -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.8.6</version>
|
||||
</dependency>
|
||||
<!-- H2 for unit tests -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Testcontainers PostgreSQL for integration tests -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Spring Boot Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Spring Boot Mail (invite flow) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- Actuator (health endpoint for Docker) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<!-- WebSocket (STOMP + SockJS for notifications) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<!-- Rate limiting (Bucket4j + Caffeine cache) -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>3.1.8</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -2,17 +2,23 @@ package de.cannamanage.api;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* CannaManage Spring Boot application entry point.
|
||||
* REST controllers are deferred to Sprint 2.
|
||||
* Sprint 1 focus: compliance engine validation only.
|
||||
* Sprint 2: REST API + Spring Security + OpenAPI.
|
||||
*
|
||||
* Multi-module scanning:
|
||||
* - scanBasePackages: component scanning (controllers, services)
|
||||
* - EnableJpaRepositories: Spring Data JPA repository interfaces
|
||||
* - EntityScan: JPA entity detection across modules (Boot 4.0 relocated package)
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||
@EnableScheduling
|
||||
public class CannaManageApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.cannamanage.api.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.info.Contact;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.annotations.info.License;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.annotations.servers.Server;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@OpenAPIDefinition(
|
||||
info = @Info(
|
||||
title = "CannaManage API",
|
||||
version = "1.0.0",
|
||||
description = "Cannabis Social Club Management — CanG Compliance Platform API",
|
||||
contact = @Contact(name = "CannaManage", email = "info@cannamanage.de"),
|
||||
license = @License(name = "Proprietary")
|
||||
),
|
||||
servers = {
|
||||
@Server(url = "/", description = "Current server")
|
||||
},
|
||||
security = @SecurityRequirement(name = "bearer-jwt")
|
||||
)
|
||||
@SecurityScheme(
|
||||
name = "bearer-jwt",
|
||||
type = SecuritySchemeType.HTTP,
|
||||
scheme = "bearer",
|
||||
bearerFormat = "JWT",
|
||||
description = "JWT access token — obtain via POST /api/v1/auth/login"
|
||||
)
|
||||
public class OpenApiConfig {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.cannamanage.api.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
/**
|
||||
* WebSocket configuration — enables STOMP messaging over SockJS.
|
||||
* Clients connect to /ws, subscribe to /user/queue/notifications for personal notifications.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// Enable simple in-memory broker for /topic (broadcast) and /queue (user-specific)
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
// Prefix for @MessageMapping methods
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
// User-specific destination prefix
|
||||
config.setUserDestinationPrefix("/user");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// WebSocket endpoint with SockJS fallback
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||
import de.cannamanage.api.security.LoginRateLimiter;
|
||||
import de.cannamanage.api.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Authentication", description = "Login and token management")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
if (!loginRateLimiter.tryAcquire(ip)) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of(
|
||||
"error", "rate_limited",
|
||||
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
|
||||
));
|
||||
}
|
||||
LoginResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
@Operation(summary = "Refresh access token", description = "Exchanges a valid refresh token for new token pair")
|
||||
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||
LoginResponse response = authService.refresh(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/set-password")
|
||||
@Operation(summary = "Set password via invite token",
|
||||
description = "Public endpoint — validates invite token, sets password, activates account")
|
||||
public ResponseEntity<Map<String, String>> setPassword(@Valid @RequestBody SetPasswordRequest request) {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the originating client IP, honouring X-Forwarded-For when present
|
||||
* (so reverse-proxy / load-balancer setups still get per-client rate limits).
|
||||
*/
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
int comma = xff.indexOf(',');
|
||||
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.club.ClubResponse;
|
||||
import de.cannamanage.api.dto.club.ClubStatsResponse;
|
||||
import de.cannamanage.api.dto.club.UpdateClubRequest;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.service.ClubService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/clubs")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Club Settings", description = "Club configuration and statistics")
|
||||
public class ClubController {
|
||||
|
||||
private final ClubService clubService;
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "Get current club", description = "Returns the club for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ClubResponse> getMyClub() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Club club = clubService.getClubByTenantId(tenantId);
|
||||
return ResponseEntity.ok(toResponse(club));
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
@Operation(summary = "Update club settings", description = "Updates the club configuration for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ClubResponse> updateMyClub(@Valid @RequestBody UpdateClubRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Club updated = clubService.updateClub(
|
||||
tenantId,
|
||||
request.name(),
|
||||
request.registrationNumber(),
|
||||
request.contactEmail(),
|
||||
request.contactPhone(),
|
||||
request.addressStreet(),
|
||||
request.addressCity(),
|
||||
request.addressPostalCode(),
|
||||
request.addressState(),
|
||||
request.foundedDate(),
|
||||
request.maxPreventionOfficers(),
|
||||
request.allowedEmailPattern()
|
||||
);
|
||||
return ResponseEntity.ok(toResponse(updated));
|
||||
}
|
||||
|
||||
@GetMapping("/me/stats")
|
||||
@Operation(summary = "Get club statistics", description = "Returns aggregated club statistics")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||
public ResponseEntity<ClubStatsResponse> getMyClubStats() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||
return ResponseEntity.ok(new ClubStatsResponse(
|
||||
stats.totalMembers(),
|
||||
stats.activeMembers(),
|
||||
stats.totalStaff(),
|
||||
stats.activeStaff(),
|
||||
stats.totalDistributionsThisMonth(),
|
||||
stats.totalGramsDistributedThisMonth(),
|
||||
stats.activeBatches(),
|
||||
stats.preventionOfficerCount()
|
||||
));
|
||||
}
|
||||
|
||||
private ClubResponse toResponse(Club club) {
|
||||
return new ClubResponse(
|
||||
club.getId(),
|
||||
club.getName(),
|
||||
club.getRegistrationNumber(),
|
||||
club.getContactEmail(),
|
||||
club.getContactPhone(),
|
||||
club.getAddressStreet(),
|
||||
club.getAddressCity(),
|
||||
club.getAddressPostalCode(),
|
||||
club.getAddressState(),
|
||||
club.getFoundedDate(),
|
||||
club.getMaxPreventionOfficers(),
|
||||
club.getAllowedEmailPattern(),
|
||||
club.getStatus(),
|
||||
club.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.compliance.QuotaResponse;
|
||||
import de.cannamanage.service.ComplianceService;
|
||||
import de.cannamanage.service.dto.QuotaStatus;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/compliance")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Compliance", description = "CanG §19 compliance quota checks")
|
||||
public class ComplianceController {
|
||||
|
||||
private final ComplianceService complianceService;
|
||||
|
||||
@GetMapping("/quota/{memberId}")
|
||||
@Operation(summary = "Get member quota status",
|
||||
description = "Returns current monthly remaining quota for a member per CanG §19")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_QUOTA)")
|
||||
public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) {
|
||||
QuotaStatus status = complianceService.getQuotaStatus(memberId);
|
||||
|
||||
QuotaResponse response = new QuotaResponse(
|
||||
status.totalAllowed(),
|
||||
status.totalUsed(),
|
||||
status.remaining(),
|
||||
status.isUnder21(),
|
||||
status.year(),
|
||||
status.month()
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
+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())
|
||||
));
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||
import de.cannamanage.api.dto.distribution.DistributionResponse;
|
||||
import de.cannamanage.domain.entity.Distribution;
|
||||
import de.cannamanage.service.ComplianceService;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/distributions")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Distributions", description = "Cannabis distribution recording (CanG §26)")
|
||||
public class DistributionController {
|
||||
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final ComplianceService complianceService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
|
||||
public ResponseEntity<List<DistributionResponse>> listDistributions() {
|
||||
List<DistributionResponse> distributions = distributionRepository.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(distributions);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Record a distribution",
|
||||
description = "Records a cannabis distribution after compliance checks pass (CanG §19)")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
|
||||
public ResponseEntity<DistributionResponse> createDistribution(
|
||||
@Valid @RequestBody CreateDistributionRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
// Run compliance checks — throws QuotaExceededException if violated
|
||||
complianceService.checkDistributionAllowed(
|
||||
request.memberId(), request.batchId(), request.quantityGrams());
|
||||
|
||||
UUID recordedBy = (UUID) authentication.getPrincipal();
|
||||
|
||||
Distribution distribution = new Distribution();
|
||||
distribution.setMemberId(request.memberId());
|
||||
distribution.setBatchId(request.batchId());
|
||||
distribution.setQuantityGrams(request.quantityGrams());
|
||||
distribution.setDistributedAt(Instant.now());
|
||||
distribution.setRecordedBy(recordedBy);
|
||||
distribution.setNotes(request.notes());
|
||||
|
||||
Distribution saved = distributionRepository.save(distribution);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
|
||||
}
|
||||
|
||||
private DistributionResponse toResponse(Distribution d) {
|
||||
return new DistributionResponse(
|
||||
d.getId(),
|
||||
d.getMemberId(),
|
||||
d.getBatchId(),
|
||||
d.getQuantityGrams(),
|
||||
d.getDistributedAt(),
|
||||
d.getRecordedBy(),
|
||||
d.getNotes()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.member.CreateMemberRequest;
|
||||
import de.cannamanage.api.dto.member.MemberResponse;
|
||||
import de.cannamanage.api.dto.member.UpdateMemberRequest;
|
||||
import de.cannamanage.api.dto.prevention.PreventionDataResponse;
|
||||
import de.cannamanage.api.dto.prevention.Under21MemberResponse;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.PreventionOfficerService;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/members")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Members", description = "Club member management")
|
||||
public class MemberController {
|
||||
|
||||
private final MemberRepository memberRepository;
|
||||
private final PreventionOfficerService preventionOfficerService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
|
||||
public ResponseEntity<List<MemberResponse>> listMembers() {
|
||||
List<MemberResponse> members = memberRepository.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(members);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get member by ID")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
|
||||
public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) {
|
||||
Member member = memberRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||
return ResponseEntity.ok(toResponse(member));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new member")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
|
||||
public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) {
|
||||
Member member = new Member();
|
||||
member.setFirstName(request.firstName());
|
||||
member.setLastName(request.lastName());
|
||||
member.setEmail(request.email());
|
||||
member.setDateOfBirth(request.dateOfBirth());
|
||||
member.setMembershipDate(request.membershipDate());
|
||||
member.setMembershipNumber(request.membershipNumber());
|
||||
member.setClubId(TenantContext.getCurrentTenant()); // club == tenant for MVP
|
||||
member.setUnder21(isUnder21(request.dateOfBirth()));
|
||||
|
||||
Member saved = memberRepository.save(member);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update a member")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
|
||||
public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateMemberRequest request) {
|
||||
Member member = memberRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||
|
||||
if (request.firstName() != null) member.setFirstName(request.firstName());
|
||||
if (request.lastName() != null) member.setLastName(request.lastName());
|
||||
if (request.email() != null) member.setEmail(request.email());
|
||||
if (request.dateOfBirth() != null) {
|
||||
member.setDateOfBirth(request.dateOfBirth());
|
||||
member.setUnder21(isUnder21(request.dateOfBirth()));
|
||||
}
|
||||
if (request.membershipNumber() != null) member.setMembershipNumber(request.membershipNumber());
|
||||
if (request.status() != null) member.setStatus(MemberStatus.valueOf(request.status()));
|
||||
|
||||
Member saved = memberRepository.save(member);
|
||||
return ResponseEntity.ok(toResponse(saved));
|
||||
}
|
||||
|
||||
@GetMapping("/under-21")
|
||||
@Operation(summary = "List under-21 members", description = "Returns all under-21 members with current month distribution data. Prevention officer or ADMIN access required.")
|
||||
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||
public ResponseEntity<List<Under21MemberResponse>> getUnder21Members() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
List<Member> under21Members = preventionOfficerService.getUnder21Members(tenantId);
|
||||
|
||||
List<Under21MemberResponse> response = under21Members.stream()
|
||||
.map(m -> {
|
||||
int age = preventionOfficerService.calculateAge(m.getDateOfBirth());
|
||||
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, m.getId());
|
||||
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, m.getId());
|
||||
BigDecimal limit = preventionOfficerService.getMonthlyLimit(m);
|
||||
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||
String quotaStatus = remaining.compareTo(BigDecimal.ZERO) > 0 ? "OK" : "EXHAUSTED";
|
||||
return new Under21MemberResponse(
|
||||
m.getId(), m.getFirstName(), m.getLastName(),
|
||||
age, m.getDateOfBirth(), distCount,
|
||||
gramsUsed, limit, quotaStatus
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/prevention-data")
|
||||
@Operation(summary = "Get prevention data for a member", description = "Returns prevention-relevant data for a specific member. Prevention officer or ADMIN access required.")
|
||||
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||
public ResponseEntity<PreventionDataResponse> getPreventionData(@PathVariable UUID id) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Member member = memberRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||
|
||||
int age = preventionOfficerService.calculateAge(member.getDateOfBirth());
|
||||
long distCount = preventionOfficerService.countCurrentMonthDistributions(tenantId, member.getId());
|
||||
BigDecimal gramsUsed = preventionOfficerService.sumCurrentMonthGrams(tenantId, member.getId());
|
||||
BigDecimal limit = preventionOfficerService.getMonthlyLimit(member);
|
||||
BigDecimal remaining = limit.subtract(gramsUsed).max(BigDecimal.ZERO);
|
||||
|
||||
return ResponseEntity.ok(new PreventionDataResponse(
|
||||
member.getId(),
|
||||
member.getFirstName() + " " + member.getLastName(),
|
||||
member.isUnder21(),
|
||||
age,
|
||||
distCount,
|
||||
gramsUsed,
|
||||
limit,
|
||||
remaining
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isUnder21(LocalDate dateOfBirth) {
|
||||
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
|
||||
}
|
||||
|
||||
private MemberResponse toResponse(Member m) {
|
||||
return new MemberResponse(
|
||||
m.getId(),
|
||||
m.getFirstName(),
|
||||
m.getLastName(),
|
||||
m.getEmail(),
|
||||
m.getDateOfBirth(),
|
||||
m.getMembershipDate(),
|
||||
m.getMembershipNumber(),
|
||||
m.getStatus(),
|
||||
m.isUnder21(),
|
||||
false // preventionOfficer flag comes from StaffAccount, not Member
|
||||
);
|
||||
}
|
||||
}
|
||||
+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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.prevention.PreventionOfficerRequest;
|
||||
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.PreventionOfficerService;
|
||||
import de.cannamanage.service.StaffService;
|
||||
import de.cannamanage.service.StaffTemplates;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/staff")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Staff Management", description = "Staff CRUD + invite flow (ADMIN only)")
|
||||
public class StaffController {
|
||||
|
||||
private final StaffService staffService;
|
||||
private final PreventionOfficerService preventionOfficerService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List all active staff members")
|
||||
public ResponseEntity<List<StaffResponse>> listStaff() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
List<StaffAccount> staffList = staffService.listStaff(tenantId);
|
||||
List<StaffResponse> response = staffList.stream()
|
||||
.map(staff -> {
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return StaffResponse.from(staff, email);
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Create staff member + send invite email")
|
||||
public ResponseEntity<StaffResponse> createStaff(@Valid @RequestBody CreateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.createStaff(
|
||||
tenantId,
|
||||
request.email(),
|
||||
request.displayName(),
|
||||
request.permissions(),
|
||||
request.templateName()
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(StaffResponse.from(staff, request.email()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get staff member by ID")
|
||||
public ResponseEntity<StaffResponse> getStaff(@PathVariable UUID id) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.getStaff(tenantId, id);
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.updateStaff(
|
||||
tenantId, id,
|
||||
request.displayName(),
|
||||
request.permissions(),
|
||||
request.templateName(),
|
||||
request.active()
|
||||
);
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Deactivate staff member (revokes all tokens)")
|
||||
public ResponseEntity<Void> deactivateStaff(@PathVariable UUID id) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
staffService.deactivateStaff(tenantId, id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/prevention-officer")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Assign or revoke prevention officer status",
|
||||
description = "Sets prevention officer flag on a staff member. Enforces club.maxPreventionOfficers limit on assign.")
|
||||
public ResponseEntity<StaffResponse> setPreventionOfficer(@PathVariable UUID id,
|
||||
@Valid @RequestBody PreventionOfficerRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = preventionOfficerService.setPreventionOfficer(tenantId, id, request.preventionOfficer());
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||
}
|
||||
|
||||
@GetMapping("/templates")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List available permission templates")
|
||||
public ResponseEntity<Map<String, Set<StaffPermission>>> listTemplates() {
|
||||
return ResponseEntity.ok(StaffTemplates.getAllTemplates());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.stock.BatchResponse;
|
||||
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/stock/batches")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Stock", description = "Batch and inventory management")
|
||||
public class StockController {
|
||||
|
||||
private final BatchRepository batchRepository;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all batches", description = "Returns all batches for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||
public ResponseEntity<List<BatchResponse>> listBatches() {
|
||||
List<BatchResponse> batches = batchRepository.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(batches);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get batch by ID")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||
public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) {
|
||||
Batch batch = batchRepository.findById(id)
|
||||
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "Batch not found"));
|
||||
return ResponseEntity.ok(toResponse(batch));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) {
|
||||
Batch batch = new Batch();
|
||||
batch.setStrainId(request.strainId());
|
||||
batch.setQuantityGrams(request.quantityGrams());
|
||||
batch.setHarvestDate(request.harvestDate());
|
||||
batch.setBatchCode(request.batchCode());
|
||||
batch.setStatus(BatchStatus.AVAILABLE);
|
||||
|
||||
Batch saved = batchRepository.save(batch);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
|
||||
}
|
||||
|
||||
private BatchResponse toResponse(Batch b) {
|
||||
return new BatchResponse(
|
||||
b.getId(),
|
||||
b.getStrainId(),
|
||||
b.getQuantityGrams(),
|
||||
b.getHarvestDate(),
|
||||
b.getBatchCode(),
|
||||
b.getStatus(),
|
||||
b.isContaminationFlag()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.auth;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Must be a valid email address")
|
||||
String email,
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
String password
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.cannamanage.api.dto.auth;
|
||||
|
||||
public record LoginResponse(
|
||||
String accessToken,
|
||||
String refreshToken,
|
||||
long expiresIn,
|
||||
String role
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.cannamanage.api.dto.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RefreshRequest(
|
||||
@NotBlank(message = "Refresh token is required")
|
||||
String refreshToken
|
||||
) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.cannamanage.api.dto.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request DTO for setting password via invite token.
|
||||
* Password complexity: min 8 chars, at least 1 digit + 1 special character.
|
||||
*/
|
||||
public record SetPasswordRequest(
|
||||
@NotBlank String token,
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
@Pattern(regexp = "^(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$",
|
||||
message = "Password must contain at least 1 digit and 1 special character")
|
||||
String password
|
||||
) {}
|
||||
@@ -0,0 +1,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.club;
|
||||
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClubResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
String registrationNumber,
|
||||
String contactEmail,
|
||||
String contactPhone,
|
||||
String addressStreet,
|
||||
String addressCity,
|
||||
String addressPostalCode,
|
||||
String addressState,
|
||||
LocalDate foundedDate,
|
||||
Integer maxPreventionOfficers,
|
||||
String allowedEmailPattern,
|
||||
ClubStatus status,
|
||||
Instant createdAt
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.api.dto.club;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record ClubStatsResponse(
|
||||
long totalMembers,
|
||||
long activeMembers,
|
||||
long totalStaff,
|
||||
long activeStaff,
|
||||
long totalDistributionsThisMonth,
|
||||
BigDecimal totalGramsDistributedThisMonth,
|
||||
long activeBatches,
|
||||
long preventionOfficerCount
|
||||
) {}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.cannamanage.api.dto.club;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record UpdateClubRequest(
|
||||
@NotBlank(message = "Club name is required")
|
||||
String name,
|
||||
|
||||
String registrationNumber,
|
||||
|
||||
@Email(message = "Must be a valid email address")
|
||||
String contactEmail,
|
||||
|
||||
String contactPhone,
|
||||
|
||||
String addressStreet,
|
||||
|
||||
String addressCity,
|
||||
|
||||
String addressPostalCode,
|
||||
|
||||
String addressState,
|
||||
|
||||
LocalDate foundedDate,
|
||||
|
||||
@Min(value = 1, message = "Must have at least 1 prevention officer slot")
|
||||
Integer maxPreventionOfficers,
|
||||
|
||||
String allowedEmailPattern
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.cannamanage.api.dto.compliance;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record QuotaResponse(
|
||||
BigDecimal totalAllowed,
|
||||
BigDecimal totalUsed,
|
||||
BigDecimal remaining,
|
||||
boolean under21,
|
||||
int year,
|
||||
int month
|
||||
) {}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package de.cannamanage.api.dto.distribution;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreateDistributionRequest(
|
||||
@NotNull(message = "Member ID is required")
|
||||
UUID memberId,
|
||||
|
||||
@NotNull(message = "Batch ID is required")
|
||||
UUID batchId,
|
||||
|
||||
@NotNull(message = "Quantity in grams is required")
|
||||
@DecimalMin(value = "0.01", message = "Quantity must be greater than zero")
|
||||
BigDecimal quantityGrams,
|
||||
|
||||
String notes
|
||||
) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.distribution;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DistributionResponse(
|
||||
UUID id,
|
||||
UUID memberId,
|
||||
UUID batchId,
|
||||
BigDecimal quantityGrams,
|
||||
Instant distributedAt,
|
||||
UUID recordedBy,
|
||||
String notes
|
||||
) {}
|
||||
@@ -0,0 +1,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
|
||||
) {}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.cannamanage.api.dto.member;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Past;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateMemberRequest(
|
||||
@NotBlank(message = "First name is required")
|
||||
String firstName,
|
||||
|
||||
@NotBlank(message = "Last name is required")
|
||||
String lastName,
|
||||
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Must be a valid email")
|
||||
String email,
|
||||
|
||||
@NotNull(message = "Date of birth is required")
|
||||
@Past(message = "Date of birth must be in the past")
|
||||
LocalDate dateOfBirth,
|
||||
|
||||
@NotNull(message = "Membership date is required")
|
||||
LocalDate membershipDate,
|
||||
|
||||
@NotBlank(message = "Membership number is required")
|
||||
String membershipNumber
|
||||
) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.cannamanage.api.dto.member;
|
||||
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record MemberResponse(
|
||||
UUID id,
|
||||
String firstName,
|
||||
String lastName,
|
||||
String email,
|
||||
LocalDate dateOfBirth,
|
||||
LocalDate membershipDate,
|
||||
String membershipNumber,
|
||||
MemberStatus status,
|
||||
boolean under21,
|
||||
boolean preventionOfficer
|
||||
) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.cannamanage.api.dto.member;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record UpdateMemberRequest(
|
||||
String firstName,
|
||||
String lastName,
|
||||
|
||||
@Email(message = "Must be a valid email")
|
||||
String email,
|
||||
|
||||
LocalDate dateOfBirth,
|
||||
String membershipNumber,
|
||||
String status
|
||||
) {}
|
||||
+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
|
||||
) {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user