Compare commits
55 Commits
52251cf711
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22ce3f9d49 | |||
| 83b46c8cda | |||
| a686957b09 | |||
| 53931d9d2b | |||
| 51a9d1db58 | |||
| ade9673f02 | |||
| 1c4c4ec708 | |||
| b69e5b1820 | |||
| 4b38c4fa09 | |||
| ad7f4e2b1c | |||
| 6aae17edba | |||
| 970f8eb295 | |||
| dad798a904 | |||
| 52d23053e7 | |||
| 6f5e886bd6 | |||
| f9a87efb7a | |||
| 279487067e | |||
| be932c1930 | |||
| 776149e7d3 | |||
| 6e25914074 | |||
| 90cdac7468 | |||
| fa567c1c3f | |||
| f1959eb3d2 | |||
| 592abc4b6d | |||
| 3b15d7439d | |||
| 59b785b8ed | |||
| 6f7352124d | |||
| 6319552675 | |||
| 8c969c610f | |||
| 5defe42d67 | |||
| 527e9b1219 | |||
| 55110c95af | |||
| 57f418f7c9 | |||
| 87511e0485 | |||
| c3722ab726 | |||
| 3ca231dc9c | |||
| a29c38756c | |||
| 26a77dd269 | |||
| 2d83c4b8a1 | |||
| 61b0cd92be | |||
| e4698827ee | |||
| b22702317a | |||
| 3211ade5be | |||
| 721503b231 | |||
| cfb38e8fc6 | |||
| aabde17532 | |||
| a539ed9eb2 | |||
| 05fd679c4d | |||
| 4aa27cd4f9 | |||
| 706a6e257b | |||
| 329b7abb18 | |||
| 7fe8d4f707 | |||
| 9aaf771469 | |||
| 27690a836e | |||
| cd77eb6448 |
@@ -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
|
||||
+124
-42
@@ -1,51 +1,133 @@
|
||||
name: Deploy to Production
|
||||
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:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Run backend tests
|
||||
run: ./mvnw verify -B -q
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
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: Deploy to production
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: plate-software.de
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /opt/cannamanage
|
||||
git pull origin main
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Wait for backend health
|
||||
sleep 15
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -sf http://127.0.0.1:8080/actuator/health > /dev/null 2>&1; then
|
||||
echo "✅ Deploy successful at $(date)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... attempt $i/5"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "❌ Deploy failed — backend unhealthy"
|
||||
docker compose -f docker-compose.prod.yml logs --tail=30 backend
|
||||
exit 1
|
||||
- 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"
|
||||
|
||||
@@ -15,3 +15,5 @@ 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
|
||||
@@ -1,111 +1,87 @@
|
||||
# CannaManage
|
||||
|
||||
Multi-tenant cannabis club management platform for German **Anbauvereinigungen** (cultivation associations) under CanG §19.
|
||||
|
||||
## Overview
|
||||
|
||||
CannaManage handles member management, distribution tracking, and legal compliance for cannabis cultivation clubs in Germany. It enforces the strict quotas mandated by the Cannabis Act (CanG) — including monthly limits (50g adult / 30g under-21), daily limits (25g), and THC restrictions for minors.
|
||||
Full-stack management platform for German cannabis cultivation associations (Anbauvereinigungen) under the CanG/KCanG regulatory framework.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| Runtime | Java 21 (Temurin) |
|
||||
| Framework | Spring Boot 4.0.6 |
|
||||
| Security | Spring Security 7.0 + JWT (JJWT 0.12.6) |
|
||||
| ORM | Hibernate 7 / JPA |
|
||||
| Database | PostgreSQL (prod), H2 (test) |
|
||||
| Migrations | Flyway 10 |
|
||||
| API Docs | SpringDoc OpenAPI 2.8.6 |
|
||||
| Build | Maven (multi-module) |
|
||||
| Container | Docker Compose (Postgres + app) |
|
||||
| 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-domain/ # JPA entities, enums, TenantContext
|
||||
├── cannamanage-service/ # Business logic, repositories, ComplianceService
|
||||
├── cannamanage-api/ # Spring Boot app, controllers, security, DTOs
|
||||
├── docs/
|
||||
│ └── sprint-2/ # Sprint planning docs
|
||||
└── docker-compose.yml # Local dev environment
|
||||
├── 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
|
||||
```
|
||||
|
||||
## Modules
|
||||
## Local Development
|
||||
|
||||
### cannamanage-domain
|
||||
JPA entities with multi-tenant isolation via `@Filter("tenantFilter")`:
|
||||
- `Member` — club members with age tracking
|
||||
- `Distribution` — cannabis distribution records
|
||||
- `MonthlyQuota` — per-member monthly usage tracking
|
||||
- `Batch` / `Strain` / `StockMovement` — inventory management
|
||||
- `Club` — association registration
|
||||
- `User` — authentication accounts
|
||||
### Prerequisites
|
||||
|
||||
### cannamanage-service
|
||||
- `ComplianceService` — CanG §19 quota enforcement (25 unit tests)
|
||||
- Repositories for all entities
|
||||
- Java 17+
|
||||
- Maven 3.9+
|
||||
- Node.js 22+ with pnpm 10+
|
||||
- Docker & Docker Compose
|
||||
|
||||
### cannamanage-api
|
||||
- **Auth** — JWT login + refresh token rotation (SHA-256 hashed)
|
||||
- **Members** — CRUD for association members
|
||||
- **Distributions** — compliance-gated distribution recording
|
||||
- **Stock** — batch and inventory management
|
||||
- **Compliance** — quota status API
|
||||
- Multi-tenant isolation via `TenantFilterAspect` (Hibernate @Filter activation)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/v1/auth/login` | Public | Login with email + password |
|
||||
| POST | `/api/v1/auth/refresh` | Public | Refresh token rotation |
|
||||
| GET | `/api/v1/compliance/quota/{memberId}` | ADMIN, MEMBER | Monthly quota status |
|
||||
| GET/POST/PUT | `/api/v1/members/**` | ADMIN, MEMBER | Member CRUD |
|
||||
| POST | `/api/v1/distributions/**` | ADMIN, MEMBER | Record distributions |
|
||||
| GET/POST | `/api/v1/stock/**` | ADMIN | Stock management |
|
||||
|
||||
Swagger UI: `http://localhost:8080/swagger-ui.html`
|
||||
|
||||
## Running Locally
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL
|
||||
docker compose up -d
|
||||
docker compose up -d db
|
||||
|
||||
# Run the app
|
||||
JAVA_HOME=/path/to/jdk-21 ./mvnw spring-boot:run -pl cannamanage-api
|
||||
|
||||
# Run all tests (H2 in-memory)
|
||||
JAVA_HOME=/path/to/jdk-21 ./mvnw clean verify
|
||||
# Run Spring Boot
|
||||
mvn spring-boot:run -f cannamanage-api/pom.xml -Dspring-boot.run.profiles=local
|
||||
```
|
||||
|
||||
## Testing
|
||||
### Frontend
|
||||
|
||||
- **37 tests total** — all green
|
||||
- 25 unit tests (`ComplianceServiceTest`) — quota enforcement logic
|
||||
- 7 integration tests (`AuthControllerIntegrationTest`) — full HTTP auth flow
|
||||
- 5 integration tests (`ComplianceControllerIntegrationTest`) — quota API with JWT
|
||||
```bash
|
||||
cd cannamanage-frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Integration tests use `@SpringBootTest(webEnvironment = RANDOM_PORT)` with H2 and Spring's `RestClient`.
|
||||
The frontend runs on http://localhost:3000, backend on http://localhost:8080.
|
||||
|
||||
## Security Model
|
||||
### Full Stack (Docker)
|
||||
|
||||
- **Stateless JWT** — no session, no UserDetailsService
|
||||
- **Roles**: ADMIN (full access), MEMBER (self-service), STAFF (Sprint 3)
|
||||
- **Multi-tenancy**: Hibernate `@Filter` activated per-request via AOP aspect
|
||||
- **Refresh tokens**: SHA-256 hashed (Spring Security 7 enforces BCrypt 72-byte limit)
|
||||
- Token rotation on refresh — old tokens invalidated
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Sprint History
|
||||
## Deployment
|
||||
|
||||
| Sprint | Focus | Status |
|
||||
|--------|-------|--------|
|
||||
| 1 | Domain entities, ComplianceService, 25 tests | ✅ Done |
|
||||
| 2 | REST API, Spring Security, JWT, OpenAPI, integration tests | ✅ Done |
|
||||
| 3 | Member portal, STAFF role, real-time notifications | 📋 Planned |
|
||||
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
|
||||
|
||||
Private — Patrick Plate
|
||||
Proprietary — Patrick Plate
|
||||
|
||||
@@ -140,6 +140,17 @@
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* CannaManage Spring Boot application entry point.
|
||||
@@ -17,6 +18,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||
@EnableScheduling
|
||||
public class CannaManageApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ 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;
|
||||
@@ -24,10 +27,19 @@ import java.util.Map;
|
||||
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<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
if (!loginRateLimiter.tryAcquire(ip)) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of(
|
||||
"error", "rate_limited",
|
||||
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
|
||||
));
|
||||
}
|
||||
LoginResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
@@ -46,4 +58,17 @@ public class AuthController {
|
||||
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));
|
||||
}
|
||||
}
|
||||
+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
|
||||
) {}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -43,7 +44,7 @@ public class ConsentController {
|
||||
@PostMapping
|
||||
@Operation(summary = "Grant consent")
|
||||
public ResponseEntity<ConsentResponse> grantConsent(
|
||||
@RequestBody GrantConsentRequest request,
|
||||
@Valid @RequestBody GrantConsentRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.notification.RegisterDeviceRequest;
|
||||
import de.cannamanage.domain.entity.DeviceToken;
|
||||
import de.cannamanage.service.DeviceRegistrationService;
|
||||
import de.cannamanage.service.push.WebPushSender;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Device token registration endpoints for push notifications.
|
||||
* Any authenticated user can register/unregister their devices.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications/devices")
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceRegistrationController {
|
||||
|
||||
private final DeviceRegistrationService deviceRegistrationService;
|
||||
private final WebPushSender webPushSender;
|
||||
|
||||
/**
|
||||
* Register a device token for push notifications.
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> registerDevice(
|
||||
@Valid @RequestBody RegisterDeviceRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
|
||||
try {
|
||||
DeviceToken device = deviceRegistrationService.registerDevice(
|
||||
userId, request.platform(), request.token(), request.deviceName());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"id", device.getId(),
|
||||
"platform", device.getPlatform().name(),
|
||||
"deviceName", device.getDeviceName() != null ? device.getDeviceName() : "",
|
||||
"createdAt", device.getCreatedAt().toString()
|
||||
));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's registered devices.
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<?> listDevices(@AuthenticationPrincipal UserDetails user) {
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
var devices = deviceRegistrationService.getDevices(userId);
|
||||
|
||||
var items = devices.stream().map(d -> Map.of(
|
||||
"id", (Object) d.getId(),
|
||||
"platform", d.getPlatform().name(),
|
||||
"deviceName", d.getDeviceName() != null ? d.getDeviceName() : "",
|
||||
"lastUsedAt", d.getLastUsedAt() != null ? d.getLastUsedAt().toString() : "",
|
||||
"createdAt", d.getCreatedAt().toString()
|
||||
)).toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of("devices", items));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a device.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> unregisterDevice(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
deviceRegistrationService.unregisterDevice(id, userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VAPID public key for Web Push subscription on the frontend.
|
||||
*/
|
||||
@GetMapping("/vapid-key")
|
||||
public ResponseEntity<Map<String, String>> getVapidKey() {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"publicKey", webPushSender.getPublicKey(),
|
||||
"configured", String.valueOf(webPushSender.isConfigured())
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.DocumentService;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
|
||||
public DocumentController(DocumentService documentService) {
|
||||
this.documentService = documentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the requested document belongs to the caller's current tenant (club).
|
||||
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
|
||||
* just by guessing or enumerating the document UUID.
|
||||
* Returns 404 (not 403) to avoid revealing document existence to other tenants.
|
||||
*/
|
||||
private Document loadOwnedDocument(UUID documentId) {
|
||||
Document doc = documentService.getDocument(documentId);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
|
||||
// Return 404 to prevent information leakage about document existence across tenants
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@PostMapping("/documents/upload")
|
||||
public ResponseEntity<Document> uploadDocument(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam String title,
|
||||
@RequestParam DocumentCategory category,
|
||||
@RequestParam(defaultValue = "ALL_MEMBERS") DocumentAccessLevel accessLevel,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
Principal principal) throws IOException {
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
Document doc = documentService.uploadDocument(clubId, title, category, accessLevel, description, file, userId);
|
||||
return ResponseEntity.ok(doc);
|
||||
}
|
||||
|
||||
@GetMapping("/documents")
|
||||
public ResponseEntity<List<Document>> listDocuments(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam(required = false) DocumentCategory category,
|
||||
@RequestParam(required = false) DocumentAccessLevel accessLevel) {
|
||||
List<Document> docs = documentService.listDocuments(clubId, category, accessLevel);
|
||||
return ResponseEntity.ok(docs);
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{id}/download")
|
||||
public ResponseEntity<byte[]> downloadDocument(@PathVariable UUID id) throws IOException {
|
||||
Document doc = loadOwnedDocument(id);
|
||||
byte[] content = documentService.downloadDocument(id);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"")
|
||||
.contentType(MediaType.parseMediaType(doc.getContentType()))
|
||||
.body(content);
|
||||
}
|
||||
|
||||
@DeleteMapping("/documents/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
|
||||
public ResponseEntity<Void> deleteDocument(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
Principal principal) throws IOException {
|
||||
// Verify the document belongs to the caller's tenant before honouring the delete.
|
||||
// Also reject if the supplied clubId param disagrees with the authenticated tenant.
|
||||
Document doc = loadOwnedDocument(id);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (!clubId.equals(currentTenantId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
documentService.deleteDocument(id, userId, doc.getClubId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/documents/usage")
|
||||
public ResponseEntity<Map<String, Long>> getStorageUsage(@RequestParam UUID clubId) {
|
||||
long usage = documentService.getStorageUsage(clubId);
|
||||
return ResponseEntity.ok(Map.of("bytesUsed", usage));
|
||||
}
|
||||
|
||||
// Portal endpoint — only ALL_MEMBERS documents
|
||||
@GetMapping("/portal/documents")
|
||||
public ResponseEntity<List<Document>> getPortalDocuments(@RequestParam UUID clubId) {
|
||||
List<Document> docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS);
|
||||
return ResponseEntity.ok(docs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
+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()
|
||||
));
|
||||
}
|
||||
}
|
||||
+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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,37 @@
|
||||
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.UUID;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller for compliance and operational reports.
|
||||
@@ -34,15 +45,50 @@ public class ReportController {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +228,49 @@ public class ReportController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
|
||||
@@ -83,7 +83,7 @@ public class StaffController {
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||
@RequestBody UpdateStaffRequest request) {
|
||||
@Valid @RequestBody UpdateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.updateStaff(
|
||||
tenantId, id,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -46,7 +47,7 @@ public class SubscriptionController {
|
||||
@PostMapping("/checkout")
|
||||
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
|
||||
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"))
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Test-only controller for resetting the database to a known seed state.
|
||||
* Only active when cannamanage.test.endpoints.enabled=true (test profile).
|
||||
* NEVER activate this in production.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/test")
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "cannamanage.test.endpoints.enabled", havingValue = "true")
|
||||
public class TestResetController {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
/**
|
||||
* Truncates all application tables and re-seeds with test data.
|
||||
* The Flyway schema_history table is preserved.
|
||||
*/
|
||||
@PostMapping("/reset-db")
|
||||
public ResponseEntity<Void> resetDatabase() {
|
||||
log.info("Test DB reset requested — truncating all tables and re-seeding");
|
||||
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
truncateAllTables(conn);
|
||||
reseed();
|
||||
log.info("Test DB reset complete — seed data re-applied");
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (SQLException e) {
|
||||
log.error("Failed to reset test database", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
private void truncateAllTables(Connection conn) throws SQLException {
|
||||
List<String> tables = getApplicationTables(conn);
|
||||
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
// Disable FK constraints for truncation
|
||||
stmt.execute("SET session_replication_role = 'replica'");
|
||||
|
||||
for (String table : tables) {
|
||||
stmt.execute("TRUNCATE TABLE " + table + " CASCADE");
|
||||
log.debug("Truncated table: {}", table);
|
||||
}
|
||||
|
||||
// Re-enable FK constraints
|
||||
stmt.execute("SET session_replication_role = 'origin'");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> getApplicationTables(Connection conn) throws SQLException {
|
||||
List<String> tables = new ArrayList<>();
|
||||
|
||||
try (Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery(
|
||||
"SELECT tablename FROM pg_tables " +
|
||||
"WHERE schemaname = 'public' " +
|
||||
"AND tablename != 'flyway_schema_history'")) {
|
||||
while (rs.next()) {
|
||||
tables.add(rs.getString("tablename"));
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
private void reseed() {
|
||||
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
|
||||
populator.addScript(new ClassPathResource("db/testdata/R__seed_test_data.sql"));
|
||||
populator.setSeparator(";");
|
||||
populator.execute(dataSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/assign}.
|
||||
* Used by the admin to manually attach a transaction to a member the matching engine missed.
|
||||
*/
|
||||
public record AssignRequest(
|
||||
@NotNull UUID memberId
|
||||
) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import de.cannamanage.service.bankimport.BankImportService.BulkConfirmResult;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Response of {@code POST /sessions/{id}/confirm-all}.
|
||||
* Surfaces the number of transactions that were confirmed, skipped (low confidence /
|
||||
* already confirmed) and failed (e.g. payment creation error) so the UI can give clear feedback.
|
||||
*/
|
||||
public record BulkConfirmResponse(
|
||||
int confirmed,
|
||||
int skipped,
|
||||
int failed,
|
||||
int total
|
||||
) {
|
||||
public static BulkConfirmResponse from(BulkConfirmResult r) {
|
||||
return new BulkConfirmResponse(r.confirmed(), r.skipped(), r.failed(), r.total());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/confirm}.
|
||||
* <p>
|
||||
* The {@code memberId} is required so the caller explicitly acknowledges which member receives
|
||||
* the payment, even when the matching engine had already pre-selected one.
|
||||
*/
|
||||
public record ConfirmRequest(
|
||||
@NotNull UUID memberId
|
||||
) {}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Request body for {@code POST /finance/import/csv-mappings}.
|
||||
* Captures the column layout of a club-specific CSV bank export so future imports can
|
||||
* be parsed without re-mapping.
|
||||
*/
|
||||
public record CreateMappingRequest(
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
@Min(0) @Max(50) int dateColumn,
|
||||
@Min(0) @Max(50) int amountColumn,
|
||||
@Min(0) @Max(50) int referenceColumn,
|
||||
Integer counterpartyColumn,
|
||||
Integer ibanColumn,
|
||||
@Size(max = 4) String delimiter,
|
||||
@Size(max = 32) String dateFormat,
|
||||
@Size(max = 2) String decimalSeparator,
|
||||
@Min(0) @Max(20) Integer skipHeaderRows,
|
||||
@Size(max = 32) String encoding,
|
||||
Boolean isDefault
|
||||
) {}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.BankImportSession;
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import de.cannamanage.domain.enums.ImportSessionStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Summary projection of a {@code BankImportSession} for list views.
|
||||
* Excludes large/sensitive fields ({@code fileHash}, {@code errorMessage} stays).
|
||||
*/
|
||||
public record ImportSessionResponse(
|
||||
UUID id,
|
||||
UUID clubId,
|
||||
String filename,
|
||||
BankFormat format,
|
||||
ImportSessionStatus status,
|
||||
Integer totalTransactions,
|
||||
Integer matchedCount,
|
||||
Integer confirmedCount,
|
||||
Integer skippedCount,
|
||||
UUID uploadedBy,
|
||||
String errorMessage,
|
||||
Instant createdAt,
|
||||
Instant completedAt
|
||||
) {
|
||||
public static ImportSessionResponse from(BankImportSession s) {
|
||||
return new ImportSessionResponse(
|
||||
s.getId(),
|
||||
s.getClubId(),
|
||||
s.getFilename(),
|
||||
s.getFormat(),
|
||||
s.getStatus(),
|
||||
s.getTotalTransactions(),
|
||||
s.getMatchedCount(),
|
||||
s.getConfirmedCount(),
|
||||
s.getSkippedCount(),
|
||||
s.getUploadedBy(),
|
||||
s.getErrorMessage(),
|
||||
s.getCreatedAt(),
|
||||
s.getCompletedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Optional request body for {@code POST /sessions/{id}/transactions/{txnId}/skip}.
|
||||
* The {@code reason} field is free text shown in the audit log and review history.
|
||||
*/
|
||||
public record SkipRequest(
|
||||
String reason
|
||||
) {}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.BankTransaction;
|
||||
import de.cannamanage.domain.enums.MatchStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Single bank-statement transaction shown in the review wizard.
|
||||
*/
|
||||
public record TransactionResponse(
|
||||
UUID id,
|
||||
UUID sessionId,
|
||||
LocalDate bookingDate,
|
||||
LocalDate valueDate,
|
||||
Integer amountCents,
|
||||
String currency,
|
||||
String referenceText,
|
||||
String counterpartyName,
|
||||
String counterpartyIban,
|
||||
String bankReference,
|
||||
MatchStatus matchStatus,
|
||||
Integer matchConfidence,
|
||||
UUID matchedMemberId,
|
||||
UUID matchedPaymentId,
|
||||
String skipReason
|
||||
) {
|
||||
public static TransactionResponse from(BankTransaction t) {
|
||||
return new TransactionResponse(
|
||||
t.getId(),
|
||||
t.getSessionId(),
|
||||
t.getBookingDate(),
|
||||
t.getValueDate(),
|
||||
t.getAmountCents(),
|
||||
t.getCurrency(),
|
||||
t.getReferenceText(),
|
||||
t.getCounterpartyName(),
|
||||
t.getCounterpartyIban(),
|
||||
t.getBankReference(),
|
||||
t.getMatchStatus(),
|
||||
t.getMatchConfidence(),
|
||||
t.getMatchedMemberId(),
|
||||
t.getMatchedPaymentId(),
|
||||
t.getSkipReason()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
) {}
|
||||
+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
|
||||
) {}
|
||||
+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
|
||||
) {
|
||||
}
|
||||
+15
@@ -5,6 +5,7 @@ import de.cannamanage.service.exception.BatchNotFoundException;
|
||||
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||
import de.cannamanage.service.exception.PreventionOfficerLimitExceededException;
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.StorageQuotaExceededException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
@@ -121,6 +122,20 @@ public class GlobalExceptionHandler {
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(StorageQuotaExceededException.class)
|
||||
public ProblemDetail handleStorageQuotaExceeded(StorageQuotaExceededException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.PAYMENT_REQUIRED, ex.getMessage());
|
||||
problem.setTitle("Storage Quota Exceeded");
|
||||
problem.setType(URI.create("urn:cannamanage:error:STORAGE_QUOTA_EXCEEDED"));
|
||||
problem.setProperty("code", "STORAGE_QUOTA_EXCEEDED");
|
||||
problem.setProperty("currentUsage", ex.getCurrentUsage());
|
||||
problem.setProperty("limit", ex.getLimit());
|
||||
problem.setProperty("requestedBytes", ex.getRequestedBytes());
|
||||
problem.setProperty("timestamp", Instant.now().toString());
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -29,6 +30,32 @@ public class JwtService {
|
||||
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
||||
private long refreshTokenExpiry; // seconds (30 days)
|
||||
|
||||
/**
|
||||
* Sentinel value used in the application.properties default. If the runtime JWT secret
|
||||
* matches this string (or is missing/too short) the application must fail to start —
|
||||
* we never want a deployment to silently fall back to a publicly-known dev secret.
|
||||
*/
|
||||
static final String UNCONFIGURED_SECRET_MARKER = "CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP";
|
||||
|
||||
/**
|
||||
* Validate JWT secret on startup — fail fast if the deployment is missing a proper secret.
|
||||
* Runs after Spring property binding (@Value) so we see the effective value.
|
||||
*/
|
||||
@PostConstruct
|
||||
void validateSecret() {
|
||||
if (secretKey == null
|
||||
|| secretKey.isBlank()
|
||||
|| secretKey.length() < 32
|
||||
|| UNCONFIGURED_SECRET_MARKER.equals(secretKey)) {
|
||||
throw new IllegalStateException(
|
||||
"FATAL: JWT secret is not configured or uses the default dev placeholder. "
|
||||
+ "Set the CANNAMANAGE_SECURITY_JWT_SECRET environment variable "
|
||||
+ "(or cannamanage.security.jwt.secret property) to a base64-encoded "
|
||||
+ "256-bit (or larger) random key."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Rate-limits login attempts per client IP using Bucket4j + Caffeine cache.
|
||||
* Allows 5 login attempts per minute per IP; returns 429 when exhausted.
|
||||
*/
|
||||
@Component
|
||||
@Order(1)
|
||||
public class LoginRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String LOGIN_PATH = "/api/v1/auth/login";
|
||||
private static final int CAPACITY = 5;
|
||||
private static final Duration REFILL_PERIOD = Duration.ofMinutes(1);
|
||||
|
||||
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterAccess(Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !LOGIN_PATH.equals(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String clientIp = resolveClientIp(request);
|
||||
Bucket bucket = buckets.get(clientIp, k -> createBucket());
|
||||
|
||||
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||
if (probe.isConsumed()) {
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000 + 1;
|
||||
response.setStatus(429);
|
||||
response.setHeader("Retry-After", String.valueOf(waitSeconds));
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Too many login attempts. Retry after " + waitSeconds + "s\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private Bucket createBucket() {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(CAPACITY)
|
||||
.refillGreedy(CAPACITY, REFILL_PERIOD)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
// Take the first IP in the chain (original client)
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Simple in-memory brute-force protection for the login endpoint.
|
||||
*
|
||||
* <p>Tracks attempts per source IP and rejects further attempts once the
|
||||
* configured threshold ({@link #MAX_ATTEMPTS_PER_WINDOW}) is exceeded within
|
||||
* the current 60-second window. Counters are reset every minute by
|
||||
* {@link #resetCounters()}.
|
||||
*
|
||||
* <p>This deliberately stays in-memory rather than introducing Resilience4j /
|
||||
* Bucket4j for a single endpoint. For multi-instance deployments behind a
|
||||
* load balancer this should be revisited.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LoginRateLimiter {
|
||||
|
||||
/** Maximum failed/total login attempts allowed per IP per window. */
|
||||
public static final int MAX_ATTEMPTS_PER_WINDOW = 5;
|
||||
|
||||
private final ConcurrentHashMap<String, AtomicInteger> attemptsByIp = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Records an attempt and returns {@code true} if the request is allowed
|
||||
* (still within the per-window quota), {@code false} if it must be
|
||||
* rejected with HTTP 429.
|
||||
*/
|
||||
public boolean tryAcquire(String ipAddress) {
|
||||
if (ipAddress == null || ipAddress.isBlank()) {
|
||||
ipAddress = "unknown";
|
||||
}
|
||||
AtomicInteger counter = attemptsByIp.computeIfAbsent(ipAddress, k -> new AtomicInteger(0));
|
||||
int current = counter.incrementAndGet();
|
||||
if (current > MAX_ATTEMPTS_PER_WINDOW) {
|
||||
log.warn("Login rate limit exceeded for IP {} ({} attempts in current window)", ipAddress, current);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all counters every 60 seconds. Fixed-rate scheduler keeps the
|
||||
* implementation predictable and free of timestamp bookkeeping.
|
||||
*/
|
||||
@Scheduled(fixedRate = 60_000L)
|
||||
public void resetCounters() {
|
||||
if (!attemptsByIp.isEmpty()) {
|
||||
log.debug("Resetting login rate-limit counters for {} IPs", attemptsByIp.size());
|
||||
attemptsByIp.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -19,6 +20,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,14 @@ public class SecurityConfig {
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
private final PortalUserDetailsService portalUserDetailsService;
|
||||
|
||||
/**
|
||||
* Comma-separated allowed CORS origins. Defaults to local dev origins; production
|
||||
* deployments override via the {@code CORS_ORIGINS} environment variable
|
||||
* (e.g. {@code https://cannamanage.plate-software.de}).
|
||||
*/
|
||||
@Value("${cannamanage.cors.allowed-origins:http://localhost:3000,http://frontend:3000}")
|
||||
private String allowedOrigins;
|
||||
|
||||
/**
|
||||
* API security — stateless JWT authentication.
|
||||
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
|
||||
@@ -44,9 +54,20 @@ public class SecurityConfig {
|
||||
http
|
||||
.securityMatcher("/api/**")
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
// snyk:ignore java/CsrfProtectionDisabled — Intentional: this filter chain
|
||||
// handles stateless JWT-authenticated API calls only. CSRF attacks exploit
|
||||
// browser-managed session cookies; Bearer token auth is immune because the
|
||||
// token is never sent automatically by the browser. OWASP CSRF Prevention
|
||||
// Cheat Sheet: "If your application does not use cookies for authentication,
|
||||
// CSRF is not a risk." The portal chain (Order 2) correctly enables CSRF via
|
||||
// CookieCsrfTokenRepository for its session-based auth.
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"default-src 'self'; frame-ancestors 'none'"))
|
||||
.frameOptions(frame -> frame.deny()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/api/v1/webhooks/**").permitAll()
|
||||
@@ -58,6 +79,13 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
// Documents endpoint — method-specific matchers for defense-in-depth.
|
||||
// POST (upload) and DELETE restricted to ADMIN/STAFF; GET allowed for all
|
||||
// authenticated roles. Per-document tenant ownership is additionally
|
||||
// enforced in DocumentController via TenantContext.
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@@ -78,6 +106,10 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(1))
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"default-src 'self'; frame-ancestors 'none'"))
|
||||
.frameOptions(frame -> frame.deny()))
|
||||
.userDetailsService(portalUserDetailsService)
|
||||
.formLogin(form -> form
|
||||
.loginProcessingUrl("/portal/login")
|
||||
@@ -128,10 +160,11 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(
|
||||
"http://localhost:3000",
|
||||
"http://frontend:3000"
|
||||
));
|
||||
List<String> origins = Arrays.stream(allowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
config.setAllowedOrigins(origins);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -54,4 +54,56 @@ public class StaffPermissionChecker {
|
||||
.map(staff -> staff.hasPermission(required))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative permission check — throws AccessDeniedException if permission is missing.
|
||||
* Used by controllers that need to guard specific endpoints programmatically.
|
||||
*/
|
||||
public void requirePermission(org.springframework.security.core.userdetails.UserDetails principal, StaffPermission required) {
|
||||
if (principal == null) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
|
||||
}
|
||||
// Convert UserDetails to Authentication-like check
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
boolean isAdmin = principal.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_ADMIN"));
|
||||
if (isAdmin) return;
|
||||
|
||||
boolean isStaff = principal.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_STAFF"));
|
||||
if (!isStaff) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Insufficient permissions");
|
||||
}
|
||||
|
||||
boolean hasPermission = staffAccountRepository.findByUserId(userId)
|
||||
.filter(StaffAccount::isActive)
|
||||
.map(staff -> staff.hasPermission(required))
|
||||
.orElse(false);
|
||||
if (!hasPermission) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the user ID from the authenticated principal.
|
||||
*/
|
||||
public UUID getUserId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||
return UUID.fromString(principal.getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the club ID (tenant) for the authenticated user.
|
||||
*/
|
||||
public UUID getClubId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tenant ID for the authenticated user (alias for getClubId).
|
||||
*/
|
||||
public UUID getTenantId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private static final String INVALID_CREDENTIALS = "Invalid credentials";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -43,14 +45,14 @@ public class AuthService {
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
User user = userRepository.findByEmail(request.email())
|
||||
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
|
||||
.orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("Account not activated");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new AuthenticationException("Invalid credentials");
|
||||
throw new AuthenticationException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
@@ -147,7 +149,7 @@ public class AuthService {
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 not available", e);
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||
|
||||
# Enable Flyway for container startup (fresh DB)
|
||||
spring.flyway.enabled=true
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# JWT secret from environment
|
||||
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||
@@ -17,6 +17,24 @@ management.endpoint.health.show-details=never
|
||||
# drag /actuator/health to DOWN (503), which would mark the container unhealthy.
|
||||
management.health.mail.enabled=false
|
||||
|
||||
# Disable mail in Docker (no SMTP container)
|
||||
spring.mail.host=localhost
|
||||
spring.mail.port=1025
|
||||
# IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production
|
||||
spring.mail.host=${SMTP_HOST:smtp.ionos.de}
|
||||
spring.mail.port=${SMTP_PORT:587}
|
||||
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
|
||||
spring.mail.password=${IONOS_SMTP_PASSWORD:}
|
||||
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:true}
|
||||
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:true}
|
||||
spring.mail.properties.mail.smtp.starttls.required=true
|
||||
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
|
||||
cannamanage.mail.rate-limit=${MAIL_RATE_LIMIT:50}
|
||||
|
||||
# Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
|
||||
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
|
||||
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
|
||||
push.vapid.subject=mailto:admin@cannamanage.de
|
||||
|
||||
# Firebase Cloud Messaging
|
||||
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
|
||||
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
|
||||
|
||||
@@ -53,3 +53,25 @@ springdoc.swagger-ui.enabled=false
|
||||
|
||||
# App base URL
|
||||
app.base-url=https://cannamanage.plate-software.de
|
||||
|
||||
# IONOS SMTP relay (plate-software.de)
|
||||
spring.mail.host=smtp.ionos.de
|
||||
spring.mail.port=587
|
||||
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
|
||||
spring.mail.password=${IONOS_SMTP_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||
spring.mail.properties.mail.smtp.starttls.required=true
|
||||
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
|
||||
cannamanage.mail.rate-limit=50
|
||||
|
||||
# Web Push VAPID keys
|
||||
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
|
||||
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
|
||||
push.vapid.subject=mailto:admin@cannamanage.plate-software.de
|
||||
|
||||
# Firebase Cloud Messaging
|
||||
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
|
||||
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# =============================================
|
||||
# application-test.properties
|
||||
# Profile: test — for integration test environment
|
||||
# Activate with: -Dspring.profiles.active=test
|
||||
# =============================================
|
||||
|
||||
# Database: use docker-compose.test.yml PostgreSQL
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5433/cannamanage_test
|
||||
spring.datasource.username=cannamanage_test
|
||||
spring.datasource.password=test_password
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
|
||||
# Flyway: include test seed data
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.locations=classpath:db/migration,classpath:db/testdata
|
||||
|
||||
# Enable test-only endpoints (TestResetController)
|
||||
cannamanage.test.endpoints.enabled=true
|
||||
|
||||
# Disable schedulers during test runs
|
||||
cannamanage.schedulers.enabled=false
|
||||
|
||||
# JWT: deterministic test secret (base64-encoded 256-bit key)
|
||||
cannamanage.security.jwt.secret=dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0cy1vbmx5LTMyYg==
|
||||
cannamanage.security.jwt.access-token-expiry=3600
|
||||
cannamanage.security.jwt.refresh-token-expiry=86400
|
||||
|
||||
# Logging
|
||||
logging.level.de.cannamanage=DEBUG
|
||||
logging.level.org.flywaydb=INFO
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
@@ -5,7 +5,12 @@ spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
|
||||
spring.flyway.enabled=false
|
||||
|
||||
# JWT Security
|
||||
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||
# DO NOT ship a usable default secret. JwtService.validateSecret() detects the marker below
|
||||
# and refuses to start, forcing every deployment to provide a real base64-encoded 256-bit key
|
||||
# via the CANNAMANAGE_SECURITY_JWT_SECRET environment variable (or override property).
|
||||
# Test/integration profiles pin their own valid dev secret in application-test.properties /
|
||||
# application-integration.properties.
|
||||
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET:CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP}
|
||||
cannamanage.security.jwt.access-token-expiry=3600
|
||||
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||
|
||||
@@ -38,5 +43,19 @@ management.endpoint.health.show-details=never
|
||||
# Session configuration (member portal)
|
||||
server.servlet.session.timeout=30m
|
||||
server.servlet.session.cookie.same-site=strict
|
||||
|
||||
# Schedulers
|
||||
cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true}
|
||||
server.servlet.session.cookie.http-only=true
|
||||
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||
|
||||
# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too
|
||||
spring.servlet.multipart.enabled=true
|
||||
spring.servlet.multipart.max-file-size=5MB
|
||||
spring.servlet.multipart.max-request-size=6MB
|
||||
|
||||
# Security hardening — limit non-multipart request body sizes to prevent DoS via oversized payloads
|
||||
server.tomcat.max-http-form-post-size=2MB
|
||||
|
||||
# CORS allowed origins (comma-separated). Override via CORS_ORIGINS env var in production.
|
||||
cannamanage.cors.allowed-origins=${CORS_ORIGINS:http://localhost:3000,http://frontend:3000}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Sprint 7 Phase 1: Notification sends (admin compose + broadcast tracking)
|
||||
-- Tracks each "send" operation (one admin → many members)
|
||||
|
||||
CREATE TABLE notification_sends (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
link VARCHAR(500),
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
target_type VARCHAR(20) NOT NULL, -- ALL or SELECTED
|
||||
target_count INTEGER NOT NULL,
|
||||
read_count INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE notification_send_recipients (
|
||||
send_id UUID NOT NULL REFERENCES notification_sends(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
notification_id UUID REFERENCES notifications(id),
|
||||
PRIMARY KEY (send_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_sends_tenant ON notification_sends(tenant_id, sent_at DESC);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Sprint 7 Phase 1B: Push notification infrastructure
|
||||
-- Device token registry (Web Push subscriptions + mobile push tokens)
|
||||
|
||||
CREATE TABLE device_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(20) NOT NULL, -- WEB, IOS, ANDROID
|
||||
token TEXT NOT NULL, -- Push subscription JSON (Web) or FCM token (mobile)
|
||||
device_name VARCHAR(100), -- e.g. "Chrome on MacBook", "iPhone 15"
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
|
||||
CREATE INDEX idx_device_tokens_platform ON device_tokens(platform, tenant_id);
|
||||
|
||||
-- Per-user notification channel preferences
|
||||
CREATE TABLE notification_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel VARCHAR(20) NOT NULL, -- IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
UNIQUE(user_id, channel)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- V13: Info Board (Schwarzes Brett) tables
|
||||
|
||||
CREATE TABLE info_board_posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
file_size BIGINT,
|
||||
storage_path VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_read_status (
|
||||
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
read_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (post_id, member_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_info_board_posts_club_id ON info_board_posts(club_id);
|
||||
CREATE INDEX idx_info_board_posts_category ON info_board_posts(category);
|
||||
CREATE INDEX idx_info_board_posts_pinned ON info_board_posts(is_pinned) WHERE is_pinned = TRUE;
|
||||
CREATE INDEX idx_info_board_posts_tenant ON info_board_posts(tenant_id);
|
||||
CREATE INDEX idx_post_attachments_post_id ON post_attachments(post_id);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Sprint 7 Phase 2.5: Club Event Calendar
|
||||
-- Club events with RSVP support, recurring events, and iCal export
|
||||
|
||||
CREATE TABLE club_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_at TIMESTAMP WITH TIME ZONE,
|
||||
location VARCHAR(300),
|
||||
max_attendees INTEGER,
|
||||
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recurrence_rule VARCHAR(100),
|
||||
recurrence_end_date DATE,
|
||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at);
|
||||
CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type);
|
||||
CREATE INDEX idx_club_events_club_id ON club_events(club_id);
|
||||
|
||||
-- Event RSVPs
|
||||
CREATE TABLE event_rsvps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
status VARCHAR(20) NOT NULL,
|
||||
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
UNIQUE(event_id, member_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id);
|
||||
CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id);
|
||||
@@ -0,0 +1,61 @@
|
||||
-- V15: Forum MVP — topics, replies, reactions, reports
|
||||
-- Phase 3 of Sprint 7
|
||||
|
||||
CREATE TABLE forum_topics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
is_locked BOOLEAN DEFAULT FALSE,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
reply_count INTEGER DEFAULT 0,
|
||||
last_reply_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE forum_replies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
topic_id UUID NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
is_edited BOOLEAN DEFAULT FALSE,
|
||||
edited_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE forum_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_type VARCHAR(10) NOT NULL,
|
||||
target_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
reaction_type VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(target_type, target_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE forum_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
target_type VARCHAR(10) NOT NULL,
|
||||
target_id UUID NOT NULL,
|
||||
reporter_id UUID NOT NULL REFERENCES users(id),
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'OPEN',
|
||||
reviewed_by UUID REFERENCES users(id),
|
||||
reviewed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_forum_topics_club_id ON forum_topics(club_id);
|
||||
CREATE INDEX idx_forum_topics_tenant_id ON forum_topics(tenant_id);
|
||||
CREATE INDEX idx_forum_replies_topic_id ON forum_replies(topic_id);
|
||||
CREATE INDEX idx_forum_replies_tenant_id ON forum_replies(tenant_id);
|
||||
CREATE INDEX idx_forum_reactions_target ON forum_reactions(target_type, target_id);
|
||||
CREATE INDEX idx_forum_reports_club_status ON forum_reports(club_id, status);
|
||||
CREATE INDEX idx_forum_reports_tenant_id ON forum_reports(tenant_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- V16: Index for faster email dispatch queries on notification_preferences
|
||||
-- Used by NotificationDispatchService to find users with EMAIL channel enabled per tenant
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_preferences_email_enabled
|
||||
ON notification_preferences(tenant_id, channel, enabled)
|
||||
WHERE channel = 'EMAIL' AND enabled = true;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- V17: Custom mail domains for Enterprise tier clubs
|
||||
-- Allows Enterprise clubs to use a verified custom FROM address for outbound emails
|
||||
|
||||
CREATE TABLE custom_mail_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL UNIQUE,
|
||||
from_address VARCHAR(255) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
verification_token VARCHAR(64) NOT NULL,
|
||||
verified BOOLEAN NOT NULL DEFAULT false,
|
||||
verified_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_custom_mail_domains_tenant ON custom_mail_domains(tenant_id);
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Sprint 8: Treasury / Finance tables
|
||||
-- Fee schedules (Beitragsordnung)
|
||||
CREATE TABLE fee_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
interval VARCHAR(20) NOT NULL,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Member fee assignment
|
||||
CREATE TABLE member_fee_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(member_id, valid_from)
|
||||
);
|
||||
|
||||
-- Payments (Zahlungen)
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
amount_cents INTEGER NOT NULL,
|
||||
payment_method VARCHAR(30) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PAID',
|
||||
period_from DATE NOT NULL,
|
||||
period_to DATE NOT NULL,
|
||||
reference VARCHAR(200),
|
||||
notes TEXT,
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
paid_at TIMESTAMP NOT NULL,
|
||||
voided_at TIMESTAMP,
|
||||
voided_by UUID REFERENCES users(id),
|
||||
void_reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Kassenbuch (cash book / ledger entries) — append-only per §147 AO
|
||||
CREATE TABLE ledger_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
transaction_type VARCHAR(10) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
description VARCHAR(500) NOT NULL,
|
||||
reference VARCHAR(200),
|
||||
payment_id UUID REFERENCES payments(id),
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
transaction_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_fee_schedules_club ON fee_schedules(club_id);
|
||||
CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id);
|
||||
CREATE INDEX idx_member_fee_assignments_member ON member_fee_assignments(member_id);
|
||||
CREATE INDEX idx_member_fee_assignments_tenant ON member_fee_assignments(tenant_id);
|
||||
CREATE INDEX idx_payments_club_member ON payments(club_id, member_id);
|
||||
CREATE INDEX idx_payments_status ON payments(club_id, status);
|
||||
CREATE INDEX idx_payments_period ON payments(club_id, period_from, period_to);
|
||||
CREATE INDEX idx_payments_tenant ON payments(tenant_id);
|
||||
CREATE INDEX idx_ledger_entries_club_date ON ledger_entries(club_id, transaction_date);
|
||||
CREATE INDEX idx_ledger_entries_category ON ledger_entries(club_id, category);
|
||||
CREATE INDEX idx_ledger_entries_tenant ON ledger_entries(tenant_id);
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Sprint 8 Phase 3: Mitgliederversammlung (General Assembly)
|
||||
-- Legal basis: §32 BGB (Mitgliederversammlung), §33 BGB (Satzungsänderung),
|
||||
-- §67 BGB (Vereinsregister), §147 AO (Aufbewahrungspflicht)
|
||||
|
||||
-- General assemblies (Mitgliederversammlungen)
|
||||
CREATE TABLE assemblies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
assembly_type VARCHAR(30) NOT NULL,
|
||||
scheduled_at TIMESTAMP NOT NULL,
|
||||
location VARCHAR(300),
|
||||
invitation_sent_at TIMESTAMP,
|
||||
invitation_deadline DATE,
|
||||
quorum_required INTEGER,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'PLANNED',
|
||||
opened_at TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Agenda items (Tagesordnungspunkte / TOP)
|
||||
CREATE TABLE assembly_agenda_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
item_type VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Attendance
|
||||
CREATE TABLE assembly_attendees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
checked_in_at TIMESTAMP DEFAULT NOW(),
|
||||
proxy_for_member_id UUID REFERENCES members(id),
|
||||
UNIQUE(assembly_id, member_id)
|
||||
);
|
||||
|
||||
-- Votes (Abstimmungen)
|
||||
CREATE TABLE assembly_votes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||
agenda_item_id UUID NOT NULL REFERENCES assembly_agenda_items(id),
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
vote_type VARCHAR(30) NOT NULL,
|
||||
yes_count INTEGER DEFAULT 0,
|
||||
no_count INTEGER DEFAULT 0,
|
||||
abstain_count INTEGER DEFAULT 0,
|
||||
result VARCHAR(20),
|
||||
voted_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Individual vote records (for transparency, not secret ballot)
|
||||
CREATE TABLE assembly_vote_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vote_id UUID NOT NULL REFERENCES assembly_votes(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
decision VARCHAR(10) NOT NULL,
|
||||
voted_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(vote_id, member_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_assemblies_club ON assemblies(club_id);
|
||||
CREATE INDEX idx_assemblies_tenant ON assemblies(tenant_id);
|
||||
CREATE INDEX idx_assemblies_status ON assemblies(club_id, status);
|
||||
CREATE INDEX idx_agenda_items_assembly ON assembly_agenda_items(assembly_id);
|
||||
CREATE INDEX idx_attendees_assembly ON assembly_attendees(assembly_id);
|
||||
CREATE INDEX idx_votes_assembly ON assembly_votes(assembly_id);
|
||||
CREATE INDEX idx_vote_records_vote ON assembly_vote_records(vote_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- V20: Document archive for club documents (Satzung, Protokolle, Verträge, etc.)
|
||||
-- Legal basis: §22 KCanG (Dokumentationspflichten), §147 AO (Aufbewahrungspflichten)
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(300) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
storage_path VARCHAR(500) NOT NULL,
|
||||
access_level VARCHAR(20) NOT NULL DEFAULT 'ALL_MEMBERS',
|
||||
description TEXT,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_club ON documents(club_id);
|
||||
CREATE INDEX idx_documents_category ON documents(club_id, category);
|
||||
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- V21: Board management (Vorstandsverwaltung)
|
||||
-- Legal basis: §26 BGB (Vorstand), §27 BGB (Bestellung/Abberufung), §23 KCanG (Präventionsbeauftragter)
|
||||
|
||||
CREATE TABLE board_positions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE board_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
position_id UUID NOT NULL REFERENCES board_positions(id),
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
elected_at DATE NOT NULL,
|
||||
term_start DATE NOT NULL,
|
||||
term_end DATE,
|
||||
is_current BOOLEAN DEFAULT TRUE,
|
||||
elected_in_assembly_id UUID REFERENCES assemblies(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_board_positions_club ON board_positions(club_id);
|
||||
CREATE INDEX idx_board_positions_tenant ON board_positions(tenant_id);
|
||||
CREATE INDEX idx_board_members_club ON board_members(club_id);
|
||||
CREATE INDEX idx_board_members_tenant ON board_members(tenant_id);
|
||||
CREATE INDEX idx_board_members_current ON board_members(club_id, is_current) WHERE is_current = TRUE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- V22: Add protocol_document_id to assemblies for auto-archive feature
|
||||
ALTER TABLE assemblies ADD COLUMN IF NOT EXISTS protocol_document_id UUID;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Sprint 9: Destruction records per KCanG §22
|
||||
CREATE TABLE destruction_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
batch_id UUID REFERENCES batches(id),
|
||||
amount_grams NUMERIC(8,2) NOT NULL,
|
||||
destruction_method VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
destroyed_at TIMESTAMP NOT NULL,
|
||||
witnessed_by UUID REFERENCES users(id),
|
||||
witness_name VARCHAR(200),
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_destruction_records_tenant ON destruction_records(tenant_id);
|
||||
CREATE INDEX idx_destruction_records_club ON destruction_records(club_id);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Sprint 9: Transport records per KCanG §22 transport documentation
|
||||
CREATE TABLE transport_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
description TEXT NOT NULL,
|
||||
transport_date DATE NOT NULL,
|
||||
from_location VARCHAR(300) NOT NULL,
|
||||
to_location VARCHAR(300) NOT NULL,
|
||||
carrier_name VARCHAR(200) NOT NULL,
|
||||
amount_grams NUMERIC(8,2) NOT NULL,
|
||||
batch_id UUID REFERENCES batches(id),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PLANNED',
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transport_records_tenant ON transport_records(tenant_id);
|
||||
CREATE INDEX idx_transport_records_club ON transport_records(club_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Sprint 9: Propagation sources (seed/cutting tracking per KCanG §16)
|
||||
CREATE TABLE propagation_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
source_type VARCHAR(50) NOT NULL, -- SEED, CUTTING
|
||||
supplier VARCHAR(300),
|
||||
quantity INTEGER NOT NULL,
|
||||
strain_id UUID REFERENCES strains(id),
|
||||
received_at DATE NOT NULL,
|
||||
documentation_reference VARCHAR(200),
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_propagation_sources_tenant ON propagation_sources(tenant_id);
|
||||
CREATE INDEX idx_propagation_sources_club ON propagation_sources(club_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Sprint 9: Prevention activities per KCanG §23 Suchtprävention
|
||||
CREATE TABLE prevention_activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
activity_date DATE NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
participants_count INTEGER,
|
||||
officer_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prevention_activities_tenant ON prevention_activities(tenant_id);
|
||||
CREATE INDEX idx_prevention_activities_club ON prevention_activities(club_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Sprint 9: Generated reports metadata
|
||||
CREATE TABLE generated_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
report_type VARCHAR(50) NOT NULL,
|
||||
report_format VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
file_size BIGINT,
|
||||
storage_path VARCHAR(500),
|
||||
parameters JSONB,
|
||||
generated_by UUID NOT NULL REFERENCES users(id),
|
||||
generated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id);
|
||||
CREATE INDEX idx_generated_reports_club ON generated_reports(club_id);
|
||||
CREATE INDEX idx_generated_reports_type ON generated_reports(club_id, report_type);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Sprint 9: Compliance deadlines tracking
|
||||
CREATE TABLE compliance_deadlines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
area VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
due_date DATE NOT NULL,
|
||||
is_recurring BOOLEAN DEFAULT FALSE,
|
||||
recurrence_rule VARCHAR(50),
|
||||
completed_at TIMESTAMP,
|
||||
completed_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_compliance_deadlines_tenant ON compliance_deadlines(tenant_id);
|
||||
CREATE INDEX idx_compliance_deadlines_club_due ON compliance_deadlines(club_id, due_date);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Sprint 9: Add THC/CBD percentage + strain name to distributions (KCanG §19(4))
|
||||
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS thc_percentage NUMERIC(4,2);
|
||||
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS cbd_percentage NUMERIC(4,2);
|
||||
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS strain_name VARCHAR(200);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Sprint 10: Bank statement import sessions
|
||||
-- Each upload of a bank statement creates one session, which is then matched + reviewed by an admin.
|
||||
-- Status flow: PENDING → IN_REVIEW → COMPLETED (or FAILED at any point).
|
||||
-- Once COMPLETED, the session is immutable per GoBD requirements (§147 AO).
|
||||
CREATE TABLE bank_import_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
format VARCHAR(20) NOT NULL, -- MT940, CAMT053, CSV
|
||||
total_transactions INTEGER NOT NULL DEFAULT 0,
|
||||
matched_count INTEGER NOT NULL DEFAULT 0,
|
||||
confirmed_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, IN_REVIEW, COMPLETED, FAILED
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bank_import_sessions_tenant ON bank_import_sessions(tenant_id);
|
||||
CREATE INDEX idx_bank_import_sessions_club ON bank_import_sessions(club_id);
|
||||
CREATE INDEX idx_bank_import_sessions_status ON bank_import_sessions(club_id, status);
|
||||
CREATE INDEX idx_bank_import_sessions_created ON bank_import_sessions(club_id, created_at DESC);
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Sprint 10: Parsed bank transactions
|
||||
-- One row per transaction in an uploaded bank statement.
|
||||
-- amount_cents: positive = incoming (potential member payment), negative = outgoing (expense).
|
||||
-- match_status drives the review UI: UNMATCHED/SUGGESTED/MATCHED/CONFIRMED/SKIPPED.
|
||||
-- CASCADE on session delete: discarding a draft session also deletes its parsed rows.
|
||||
-- SET NULL on member/payment delete: history is preserved even if the matched entity is removed.
|
||||
CREATE TABLE bank_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
session_id UUID NOT NULL REFERENCES bank_import_sessions(id) ON DELETE CASCADE,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
booking_date DATE NOT NULL,
|
||||
value_date DATE,
|
||||
amount_cents INTEGER NOT NULL, -- positive = incoming, negative = outgoing
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
reference_text TEXT, -- Verwendungszweck
|
||||
counterparty_name VARCHAR(300),
|
||||
counterparty_iban VARCHAR(34),
|
||||
bank_reference VARCHAR(100),
|
||||
match_status VARCHAR(20) NOT NULL DEFAULT 'UNMATCHED',-- UNMATCHED, SUGGESTED, MATCHED, CONFIRMED, SKIPPED
|
||||
match_confidence INTEGER, -- 0-100, only populated when match_status != UNMATCHED
|
||||
matched_member_id UUID REFERENCES members(id) ON DELETE SET NULL,
|
||||
matched_payment_id UUID REFERENCES payments(id) ON DELETE SET NULL,
|
||||
skip_reason VARCHAR(100),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bank_transactions_tenant ON bank_transactions(tenant_id);
|
||||
CREATE INDEX idx_bank_transactions_session ON bank_transactions(session_id);
|
||||
CREATE INDEX idx_bank_transactions_club_status ON bank_transactions(club_id, match_status);
|
||||
CREATE INDEX idx_bank_transactions_member ON bank_transactions(matched_member_id);
|
||||
CREATE INDEX idx_bank_transactions_payment ON bank_transactions(matched_payment_id);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Sprint 10: CSV column mapping templates + member IBAN fields
|
||||
-- CSV files have no standard layout — each bank uses different columns/encodings.
|
||||
-- Admins create a named mapping per bank (e.g. "Sparkasse Export") that the parser reuses.
|
||||
CREATE TABLE csv_column_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL, -- e.g. "Sparkasse Export"
|
||||
date_column INTEGER NOT NULL,
|
||||
amount_column INTEGER NOT NULL,
|
||||
reference_column INTEGER,
|
||||
counterparty_column INTEGER,
|
||||
iban_column INTEGER,
|
||||
delimiter VARCHAR(5) NOT NULL DEFAULT ';',
|
||||
date_format VARCHAR(20) NOT NULL DEFAULT 'dd.MM.yyyy',
|
||||
decimal_separator VARCHAR(1) NOT NULL DEFAULT ',',
|
||||
skip_header_rows INTEGER NOT NULL DEFAULT 1,
|
||||
encoding VARCHAR(20) NOT NULL DEFAULT 'ISO-8859-1',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_csv_column_mappings_tenant ON csv_column_mappings(tenant_id);
|
||||
CREATE INDEX idx_csv_column_mappings_club ON csv_column_mappings(club_id);
|
||||
|
||||
-- Add optional IBAN fields to members.
|
||||
-- Both columns are intentionally NULLABLE — IBAN is only populated after explicit
|
||||
-- BANK_DATA consent (DSGVO Art. 6(1)(a)). ibanConsentDate records when consent was given.
|
||||
-- PostgreSQL adds nullable columns instantly (no table rewrite), safe for production.
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMP;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Sprint 10 Phase 3: Add SHA-256 file hash column for stronger duplicate-import detection.
|
||||
-- The Phase 1 filename-based check is kept as a soft warning; the hash provides hard 409 dedup
|
||||
-- (a renamed copy of the same file is still detected).
|
||||
ALTER TABLE bank_import_sessions
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
|
||||
-- Unique per club to allow the same file to be imported by different tenants in DEV/QA.
|
||||
-- NULL values are allowed for legacy rows created before V33.
|
||||
CREATE UNIQUE INDEX uk_bank_import_sessions_club_hash
|
||||
ON bank_import_sessions(club_id, file_hash)
|
||||
WHERE file_hash IS NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Fix schema drift: members table is missing columns that the JPA Member entity expects.
|
||||
-- user_id: links member to their login user account (nullable, set on portal registration)
|
||||
-- iban: member's IBAN for bank statement matching (Sprint 10, nullable, consent-gated)
|
||||
-- iban_consent_date: timestamp when BANK_DATA consent was granted
|
||||
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMPTZ;
|
||||
|
||||
-- Index for user_id lookups (portal login → member resolution)
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user_id ON members(user_id);
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- Add created_at and updated_at to generated_reports (split from V27 to avoid checksum mismatch)
|
||||
ALTER TABLE generated_reports ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
||||
ALTER TABLE generated_reports ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
@@ -0,0 +1,9 @@
|
||||
-- V36: Add storage quota tracking to clubs
|
||||
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT DEFAULT 0;
|
||||
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_limit_bytes BIGINT DEFAULT 5368709120;
|
||||
-- Default: 5 GB (5 * 1024^3) = Starter tier
|
||||
|
||||
-- Backfill existing clubs with actual usage
|
||||
UPDATE clubs c SET storage_used_bytes = COALESCE(
|
||||
(SELECT SUM(d.file_size) FROM documents d WHERE d.club_id = c.id), 0
|
||||
);
|
||||
@@ -0,0 +1,265 @@
|
||||
-- R__seed_test_data.sql — Repeatable Flyway migration for integration test data
|
||||
-- This file is idempotent: uses ON CONFLICT DO NOTHING for all inserts.
|
||||
-- Activated only when spring.flyway.locations includes classpath:db/testdata
|
||||
|
||||
-- ============================================================
|
||||
-- 1. CLUB
|
||||
-- ============================================================
|
||||
INSERT INTO clubs (id, tenant_id, name, address, license_number, max_members, status, created_at)
|
||||
VALUES (
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'Grüner Daumen e.V.',
|
||||
'Hanfstraße 42, 10115 Berlin',
|
||||
'LIC-2024-GD-001',
|
||||
500,
|
||||
'ACTIVE',
|
||||
'2024-01-01T00:00:00Z'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. MEMBERS (7)
|
||||
-- ============================================================
|
||||
INSERT INTO members (id, tenant_id, club_id, first_name, last_name, email, date_of_birth, membership_date, membership_number, status, is_under_21, prevention_officer, created_at)
|
||||
VALUES
|
||||
('c1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Max', 'Mustermann', 'max@gruener-daumen.de', '1990-05-20', '2024-01-15', 'GD-001', 'ACTIVE', FALSE, FALSE, '2024-01-15T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Anna', 'Schmidt', 'anna@gruener-daumen.de', '1985-11-03', '2024-02-01', 'GD-002', 'ACTIVE', FALSE, FALSE, '2024-02-01T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Jonas', 'Weber', 'jonas@gruener-daumen.de', '2006-03-15', '2024-03-10', 'GD-003', 'ACTIVE', TRUE, FALSE, '2024-03-10T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Maria', 'Müller', 'maria@gruener-daumen.de', '1978-08-22', '2023-06-01', 'GD-004', 'SUSPENDED', FALSE, FALSE, '2023-06-01T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Thomas', 'Müller', 'thomas@gruener-daumen.de', '1992-12-01', '2024-01-20', 'GD-005', 'ACTIVE', FALSE, FALSE, '2024-01-20T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Lisa', 'Bauer', 'lisa@gruener-daumen.de', '1995-07-14', '2024-04-01', 'GD-006', 'ACTIVE', FALSE, FALSE, '2024-04-01T10:00:00Z'),
|
||||
('c1000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Karl', 'Fischer', 'karl@gruener-daumen.de', '1980-02-28', '2023-01-01', 'GD-007', 'EXPELLED', FALSE, FALSE, '2023-01-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. USERS (admin staff account)
|
||||
-- ============================================================
|
||||
INSERT INTO users (id, tenant_id, member_id, email, password_hash, role, active, created_at)
|
||||
VALUES (
|
||||
'b1000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000001',
|
||||
'admin@gruener-daumen.de',
|
||||
'$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
|
||||
'ROLE_ADMIN',
|
||||
TRUE,
|
||||
'2024-01-15T10:00:00Z'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Additional user accounts for members who need to author forum/info-board posts
|
||||
INSERT INTO users (id, tenant_id, member_id, email, password_hash, role, active, created_at)
|
||||
VALUES
|
||||
('b1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000002',
|
||||
'anna.user@gruener-daumen.de', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'ROLE_MEMBER', TRUE, '2024-02-01T10:00:00Z'),
|
||||
('b1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000003',
|
||||
'jonas.user@gruener-daumen.de', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'ROLE_MEMBER', TRUE, '2024-03-10T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. STRAINS (3)
|
||||
-- ============================================================
|
||||
INSERT INTO strains (id, tenant_id, name, thc_percentage, cbd_percentage, description, created_at)
|
||||
VALUES
|
||||
('d1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Northern Lights', 18.50, 0.50, 'Klassische Indica, entspannend und schmerzlindernd', '2024-04-01T10:00:00Z'),
|
||||
('d1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'CBD Critical Mass', 5.00, 12.00, 'CBD-dominante Sorte für medizinische Anwendungen', '2024-04-01T10:00:00Z'),
|
||||
('d1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Amnesia Haze', 22.00, 0.10, 'Starke Sativa mit hohem THC-Gehalt', '2024-04-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. BATCHES (3)
|
||||
-- ============================================================
|
||||
INSERT INTO batches (id, tenant_id, strain_id, quantity_grams, harvest_date, batch_code, status, contamination_flag, created_at)
|
||||
VALUES
|
||||
('e1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'd1000000-0000-0000-0000-000000000001', 500.00, '2024-04-25', 'NL-2024-001', 'AVAILABLE', FALSE, '2024-05-01T10:00:00Z'),
|
||||
('e1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'd1000000-0000-0000-0000-000000000002', 300.00, '2024-05-10', 'CM-2024-001', 'AVAILABLE', FALSE, '2024-05-15T10:00:00Z'),
|
||||
('e1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001',
|
||||
'd1000000-0000-0000-0000-000000000003', 200.00, '2024-03-20', 'AH-2024-001', 'RECALLED', TRUE, '2024-04-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 6. DISTRIBUTIONS (3 recent)
|
||||
-- ============================================================
|
||||
INSERT INTO distributions (id, tenant_id, member_id, batch_id, quantity_grams, distributed_at, recorded_by, notes, thc_percentage, cbd_percentage, strain_name, created_at)
|
||||
VALUES
|
||||
('dd000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000001', 'e1000000-0000-0000-0000-000000000001',
|
||||
5.00, NOW() - INTERVAL '2 days', 'c1000000-0000-0000-0000-000000000001', 'Reguläre Abgabe',
|
||||
18.50, 0.50, 'Northern Lights', NOW() - INTERVAL '2 days'),
|
||||
('dd000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000002', 'e1000000-0000-0000-0000-000000000002',
|
||||
3.00, NOW() - INTERVAL '1 day', 'c1000000-0000-0000-0000-000000000001', 'CBD-Abgabe',
|
||||
5.00, 12.00, 'CBD Critical Mass', NOW() - INTERVAL '1 day'),
|
||||
('dd000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000005', 'e1000000-0000-0000-0000-000000000002',
|
||||
23.00, NOW(), 'c1000000-0000-0000-0000-000000000001', 'Nahe am Monatslimit (25g)',
|
||||
5.00, 12.00, 'CBD Critical Mass', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 7. MONTHLY QUOTAS (Thomas near-quota)
|
||||
-- ============================================================
|
||||
INSERT INTO monthly_quotas (id, tenant_id, member_id, year, month, total_distributed, max_allowed, version, created_at)
|
||||
VALUES
|
||||
('mq000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'c1000000-0000-0000-0000-000000000005',
|
||||
EXTRACT(YEAR FROM NOW())::INT, EXTRACT(MONTH FROM NOW())::INT,
|
||||
23.00, 25.00, 1, NOW())
|
||||
ON CONFLICT (member_id, year, month) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 8. DOCUMENTS (4)
|
||||
-- ============================================================
|
||||
INSERT INTO documents (id, tenant_id, club_id, title, category, filename, content_type, file_size, storage_path, access_level, description, uploaded_by, created_at)
|
||||
VALUES
|
||||
('f1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Vereinssatzung 2024', 'SATZUNG', 'satzung-2024.pdf', 'application/pdf', 245000,
|
||||
'/documents/a0000000/satzung-2024.pdf', 'ALL_MEMBERS', 'Aktuelle Vereinssatzung gemäß §18 KCanG',
|
||||
'b1000000-0000-0000-0000-000000000001', '2024-01-15T10:00:00Z'),
|
||||
('f1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Protokoll MV März 2024', 'PROTOKOLL', 'protokoll-mv-2024-03.pdf', 'application/pdf', 128000,
|
||||
'/documents/a0000000/protokoll-mv-2024-03.pdf', 'ALL_MEMBERS', 'Protokoll der Mitgliederversammlung vom 15.03.2024',
|
||||
'b1000000-0000-0000-0000-000000000001', '2024-03-16T10:00:00Z'),
|
||||
('f1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'KCanG-Genehmigung', 'GENEHMIGUNG', 'kcang-genehmigung.pdf', 'application/pdf', 340000,
|
||||
'/documents/a0000000/kcang-genehmigung.pdf', 'BOARD_ONLY', 'Genehmigungsbescheid nach §11 KCanG',
|
||||
'b1000000-0000-0000-0000-000000000001', '2024-01-10T10:00:00Z'),
|
||||
('f1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Mietvertrag', 'VERTRAG', 'mietvertrag-vereinsheim.pdf', 'application/pdf', 520000,
|
||||
'/documents/a0000000/mietvertrag-vereinsheim.pdf', 'BOARD_ONLY', 'Mietvertrag für Vereinsräume',
|
||||
'b1000000-0000-0000-0000-000000000001', '2023-12-01T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 9. BOARD POSITIONS (3)
|
||||
-- ============================================================
|
||||
INSERT INTO board_positions (id, tenant_id, club_id, title, description, sort_order, is_active, created_at)
|
||||
VALUES
|
||||
('g1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Vorsitzende/r', 'Erste/r Vorsitzende/r des Vereins', 1, TRUE, '2024-01-15T10:00:00Z'),
|
||||
('g1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Kassenführung', 'Schatzmeister/in — Kassenführung und Finanzen', 2, TRUE, '2024-01-15T10:00:00Z'),
|
||||
('g1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Schriftführung', 'Protokollführung und Korrespondenz', 3, TRUE, '2024-01-15T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Board members (Max = Vorsitzender, Anna = Kassenführung, Schriftführung = vacant)
|
||||
INSERT INTO board_members (id, tenant_id, club_id, position_id, member_id, elected_at, term_start, is_current, created_at)
|
||||
VALUES
|
||||
('gm000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'g1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001',
|
||||
'2024-01-15', '2024-01-15', TRUE, '2024-01-15T10:00:00Z'),
|
||||
('gm000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'g1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000002',
|
||||
'2024-01-15', '2024-01-15', TRUE, '2024-01-15T10:00:00Z')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 10. EVENTS (2)
|
||||
-- ============================================================
|
||||
INSERT INTO club_events (id, club_id, title, description, event_type, start_at, end_at, location, created_by, tenant_id, created_at, updated_at)
|
||||
VALUES
|
||||
('ev000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Mitgliederversammlung Q3', 'Ordentliche Mitgliederversammlung mit Vorstandswahl',
|
||||
'ASSEMBLY', NOW() + INTERVAL '14 days', NOW() + INTERVAL '14 days' + INTERVAL '2 hours',
|
||||
'Vereinsheim, Hanfstraße 42', 'b1000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days'),
|
||||
('ev000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Gartentag Mai', 'Gemeinsamer Gartentag — Pflege der Anbauflächen',
|
||||
'SOCIAL', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '4 hours',
|
||||
'Vereinsgarten', 'b1000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '45 days', NOW() - INTERVAL '45 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 11. FORUM TOPICS (2) + REPLIES
|
||||
-- ============================================================
|
||||
INSERT INTO forum_topics (id, club_id, tenant_id, title, content, author_id, reply_count, last_reply_at, created_at)
|
||||
VALUES
|
||||
('ft000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Neue Sorten für Sommer', 'Welche Sorten sollen wir diesen Sommer anbauen? Ich schlage vor, mehr CBD-lastige Sorten zu probieren.',
|
||||
'b1000000-0000-0000-0000-000000000001', 3, NOW() - INTERVAL '2 days', NOW() - INTERVAL '10 days'),
|
||||
('ft000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Bewässerungssystem', 'Hat jemand Erfahrung mit automatischen Bewässerungssystemen für den Indoor-Bereich?',
|
||||
'b1000000-0000-0000-0000-000000000002', 1, NOW() - INTERVAL '5 days', NOW() - INTERVAL '7 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Forum replies
|
||||
INSERT INTO forum_replies (id, topic_id, club_id, tenant_id, content, author_id, created_at)
|
||||
VALUES
|
||||
('fr000000-0000-0000-0000-000000000001', 'ft000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'CBD Critical Mass hat sich bei uns bewährt — guter Ertrag und medizinisch wertvoll!',
|
||||
'b1000000-0000-0000-0000-000000000002', NOW() - INTERVAL '9 days'),
|
||||
('fr000000-0000-0000-0000-000000000002', 'ft000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Finde ich gut! Vielleicht auch Charlotte''s Web als weitere CBD-Option?',
|
||||
'b1000000-0000-0000-0000-000000000003', NOW() - INTERVAL '7 days'),
|
||||
('fr000000-0000-0000-0000-000000000003', 'ft000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Stimme zu — lasst uns in der MV darüber abstimmen.',
|
||||
'b1000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 days'),
|
||||
('fr000000-0000-0000-0000-000000000004', 'ft000000-0000-0000-0000-000000000002',
|
||||
'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Wir nutzen BlueMat-Tropfer — funktioniert super für Erde und Kokos.',
|
||||
'b1000000-0000-0000-0000-000000000001', NOW() - INTERVAL '5 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 12. INFO BOARD POSTS (2)
|
||||
-- ============================================================
|
||||
INSERT INTO info_board_posts (id, club_id, title, content, category, is_pinned, is_archived, author_id, tenant_id, created_at, updated_at)
|
||||
VALUES
|
||||
('ib000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Willkommen neue Mitglieder', 'Herzlich willkommen bei Grüner Daumen e.V.! Bitte lest die Vereinssatzung und meldet euch bei Fragen beim Vorstand.',
|
||||
'GENERAL', TRUE, FALSE, 'b1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
|
||||
('ib000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001',
|
||||
'Öffnungszeiten Sommer', 'Ab Juni gelten erweiterte Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 10-16 Uhr.',
|
||||
'MAINTENANCE', FALSE, FALSE, 'b1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
NOW() - INTERVAL '14 days', NOW() - INTERVAL '14 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 13. GROW ENTRIES (2)
|
||||
-- ============================================================
|
||||
INSERT INTO grow_entries (id, name, strain_id, status, started_at, expected_harvest_at, notes, tenant_id, created_at, updated_at)
|
||||
VALUES
|
||||
('ge000000-0000-0000-0000-000000000001',
|
||||
'Northern Lights Batch #2', 'd1000000-0000-0000-0000-000000000001', 'VEGETATIVE',
|
||||
NOW() - INTERVAL '21 days', NOW() + INTERVAL '49 days',
|
||||
'Zweiter Indoor-Batch NL, 6 Pflanzen',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '21 days', NOW() - INTERVAL '1 day'),
|
||||
('ge000000-0000-0000-0000-000000000002',
|
||||
'CBD Outdoor', 'd1000000-0000-0000-0000-000000000002', 'SEEDLING',
|
||||
NOW() - INTERVAL '7 days', NOW() + INTERVAL '90 days',
|
||||
'Outdoor-Test mit CBD Critical Mass, 4 Pflanzen',
|
||||
'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '7 days', NOW() - INTERVAL '1 day')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 14. COMPLIANCE DEADLINES (3)
|
||||
-- ============================================================
|
||||
INSERT INTO compliance_deadlines (id, tenant_id, club_id, area, title, description, due_date, is_recurring, created_at)
|
||||
VALUES
|
||||
('cd000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'KCANG', 'Jahresbericht', 'Jährlicher Tätigkeitsbericht an die zuständige Behörde gemäß §22 KCanG',
|
||||
(CURRENT_DATE + INTERVAL '60 days')::DATE, TRUE, NOW() - INTERVAL '30 days'),
|
||||
('cd000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'FINANCE', 'EÜR Abgabe', 'Einnahmen-Überschuss-Rechnung an das Finanzamt',
|
||||
(CURRENT_DATE - INTERVAL '5 days')::DATE, FALSE, NOW() - INTERVAL '60 days'),
|
||||
('cd000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001',
|
||||
'VEREIN', 'Mitgliederversammlung', 'Ordentliche Mitgliederversammlung (mindestens 1x jährlich)',
|
||||
(CURRENT_DATE + INTERVAL '14 days')::DATE, TRUE, NOW() - INTERVAL '14 days')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
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.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Security unit tests for {@link DocumentController}.
|
||||
* Verifies tenant isolation (IDOR protection) at the controller layer.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentControllerSecurityTest {
|
||||
|
||||
@Mock
|
||||
private DocumentService documentService;
|
||||
|
||||
@InjectMocks
|
||||
private DocumentController documentController;
|
||||
|
||||
private static final UUID CLUB_A = UUID.fromString("00000000-0000-0000-0000-00000000000a");
|
||||
private static final UUID CLUB_B = UUID.fromString("00000000-0000-0000-0000-00000000000b");
|
||||
private static final UUID DOC_ID = UUID.fromString("00000000-0000-0000-0000-000000000099");
|
||||
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Default tenant context: CLUB_A
|
||||
TenantContext.setCurrentTenant(CLUB_A);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
// --- T-09: Download wrong tenant → 404 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("downloadDocument — wrong tenant throws 404 (IDOR protection)")
|
||||
void testDownload_wrongTenant_returns404() {
|
||||
// Document belongs to CLUB_B but user's tenant is CLUB_A
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_B);
|
||||
doc.setFilename("secret.pdf");
|
||||
doc.setContentType("application/pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
assertThatThrownBy(() -> documentController.downloadDocument(DOC_ID))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
// --- T-10: Download correct tenant → 200 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("downloadDocument — correct tenant returns content")
|
||||
void testDownload_correctTenant_succeeds() throws IOException {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setFilename("report.pdf");
|
||||
doc.setContentType("application/pdf");
|
||||
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_report.pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
when(documentService.downloadDocument(DOC_ID)).thenReturn("test content".getBytes());
|
||||
|
||||
ResponseEntity<byte[]> response = documentController.downloadDocument(DOC_ID);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
// --- T-11: Delete wrong tenant → 404 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — wrong tenant throws 404 (IDOR protection)")
|
||||
void testDelete_wrongTenant_returns404() {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_B);
|
||||
doc.setTitle("Secret Doc");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
|
||||
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_A, principal))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
// --- T-12: Delete correct tenant → 204 ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — correct tenant and matching clubId succeeds")
|
||||
void testDelete_correctTenant_succeeds() throws IOException {
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setTitle("My Doc");
|
||||
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_my.pdf");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
when(principal.getName()).thenReturn(USER_ID.toString());
|
||||
|
||||
ResponseEntity<Void> response = documentController.deleteDocument(DOC_ID, CLUB_A, principal);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
// --- T-13/T-14: Upload role restriction is handled by Spring Security @PreAuthorize,
|
||||
// not testable in a pure unit test. Covered by SecurityConfigIntegrationTest. ---
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteDocument — mismatched clubId param vs tenant throws 404")
|
||||
void testDelete_mismatchedClubIdParam_returns404() {
|
||||
// Document belongs to CLUB_A and tenant is CLUB_A, but clubId param is different
|
||||
Document doc = new Document();
|
||||
doc.setId(DOC_ID);
|
||||
doc.setClubId(CLUB_A);
|
||||
doc.setTitle("Doc");
|
||||
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
|
||||
|
||||
Principal principal = mock(Principal.class);
|
||||
|
||||
// Passing CLUB_B as the clubId param while tenant is CLUB_A
|
||||
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_B, principal))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(ex -> {
|
||||
ResponseStatusException rse = (ResponseStatusException) ex;
|
||||
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package de.cannamanage.api.exception;
|
||||
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.core.MethodParameter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GlobalExceptionHandler} verifying RFC 9457 ProblemDetail
|
||||
* responses and ensuring no internal details (stack traces, paths) are leaked.
|
||||
*/
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new GlobalExceptionHandler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleValidation_returnsStatus400WithFieldErrors() throws Exception {
|
||||
// Simulate a validation failure with field errors
|
||||
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request");
|
||||
bindingResult.addError(new FieldError("request", "email", "must not be blank"));
|
||||
bindingResult.addError(new FieldError("request", "name", "size must be between 2 and 100"));
|
||||
|
||||
// MethodParameter is needed for MethodArgumentNotValidException constructor
|
||||
MethodParameter param = new MethodParameter(
|
||||
this.getClass().getDeclaredMethod("setUp"), -1);
|
||||
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(param, bindingResult);
|
||||
|
||||
ProblemDetail problem = handler.handleValidation(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Bad Request");
|
||||
assertThat(problem.getType().toString()).contains("VALIDATION_FAILED");
|
||||
assertThat(problem.getProperties()).containsKey("errors");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> errors = (List<String>) problem.getProperties().get("errors");
|
||||
assertThat(errors).hasSize(2);
|
||||
assertThat(errors).anyMatch(e -> e.contains("email"));
|
||||
assertThat(errors).anyMatch(e -> e.contains("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleAccessDenied_returnsStatus403WithNoStackTrace() {
|
||||
AccessDeniedException ex = new AccessDeniedException("You shall not pass");
|
||||
|
||||
ProblemDetail problem = handler.handleAccessDenied(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Forbidden");
|
||||
assertThat(problem.getDetail()).isEqualTo("Access denied");
|
||||
// SECURITY: original exception message NOT exposed
|
||||
assertThat(problem.getDetail()).doesNotContain("shall not pass");
|
||||
// SECURITY: no stack trace or internal paths
|
||||
assertThat(problem.getProperties()).doesNotContainKey("stackTrace");
|
||||
assertThat(problem.getProperties()).doesNotContainKey("trace");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleGenericException_returnsStatus500WithGenericMessage() {
|
||||
RuntimeException ex = new RuntimeException(
|
||||
"NullPointerException at com.internal.Service.process(Service.java:42)");
|
||||
|
||||
ProblemDetail problem = handler.handleGeneric(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Internal Server Error");
|
||||
assertThat(problem.getDetail()).isEqualTo("An unexpected error occurred");
|
||||
// SECURITY: internal details NOT leaked
|
||||
assertThat(problem.getDetail()).doesNotContain("NullPointerException");
|
||||
assertThat(problem.getDetail()).doesNotContain("Service.java");
|
||||
assertThat(problem.getDetail()).doesNotContain("com.internal");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("INTERNAL_ERROR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleQuotaExceeded_returnsStatus409WithCode() {
|
||||
QuotaExceededException ex = new QuotaExceededException(
|
||||
QuotaViolationCode.MEMBER_INACTIVE, "Member is inactive");
|
||||
|
||||
ProblemDetail problem = handler.handleQuotaExceeded(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Compliance Violation");
|
||||
assertThat(problem.getDetail()).isEqualTo("Member is inactive");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_INACTIVE");
|
||||
assertThat(problem.getProperties()).containsKey("timestamp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleMemberNotFound_returnsStatus404WithRfc9457Body() {
|
||||
var ex = new de.cannamanage.service.exception.MemberNotFoundException(UUID.randomUUID());
|
||||
|
||||
ProblemDetail problem = handler.handleMemberNotFound(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Not Found");
|
||||
assertThat(problem.getType().toString()).contains("MEMBER_NOT_FOUND");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_NOT_FOUND");
|
||||
assertThat(problem.getProperties()).containsKey("timestamp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAllHandlers_includeTimestamp_neverExposeInternalState() {
|
||||
// Verify that all handlers set the timestamp property
|
||||
ProblemDetail p1 = handler.handleAccessDenied(new AccessDeniedException("x"));
|
||||
ProblemDetail p2 = handler.handleGeneric(new RuntimeException("internal error details"));
|
||||
ProblemDetail p3 = handler.handleQuotaExceeded(
|
||||
new QuotaExceededException(QuotaViolationCode.MEMBER_INACTIVE, "msg"));
|
||||
|
||||
assertThat(p1.getProperties()).containsKey("timestamp");
|
||||
assertThat(p2.getProperties()).containsKey("timestamp");
|
||||
assertThat(p3.getProperties()).containsKey("timestamp");
|
||||
|
||||
// None should expose stack traces or class paths
|
||||
for (ProblemDetail p : List.of(p1, p2, p3)) {
|
||||
assertThat(p.getProperties()).doesNotContainKey("stackTrace");
|
||||
assertThat(p.getProperties()).doesNotContainKey("exception");
|
||||
if (p.getDetail() != null) {
|
||||
assertThat(p.getDetail()).doesNotContain(".java:");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-9
@@ -8,6 +8,7 @@ import de.cannamanage.api.dto.stock.BatchResponse;
|
||||
import de.cannamanage.api.dto.stock.CreateBatchRequest;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
@@ -41,16 +42,31 @@ import java.util.UUID;
|
||||
public abstract class AbstractIntegrationTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withDatabaseName("cannamanage_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test");
|
||||
static PostgreSQLContainer<?> postgres = shouldUseTestcontainers()
|
||||
? new PostgreSQLContainer<>("postgres:16-alpine")
|
||||
.withDatabaseName("cannamanage_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
: null;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
if (postgres != null) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
} else {
|
||||
registry.add("spring.datasource.url", () -> System.getenv("CI_POSTGRES_URL"));
|
||||
registry.add("spring.datasource.username", () -> System.getenv("CI_POSTGRES_USER"));
|
||||
registry.add("spring.datasource.password", () -> System.getenv("CI_POSTGRES_PASSWORD"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Testcontainers locally; skip when CI provides PostgreSQL via service container.
|
||||
*/
|
||||
private static boolean shouldUseTestcontainers() {
|
||||
return System.getenv("CI_POSTGRES_URL") == null;
|
||||
}
|
||||
|
||||
@LocalServerPort
|
||||
@@ -105,16 +121,23 @@ public abstract class AbstractIntegrationTest {
|
||||
// --- Test data creation helpers ---
|
||||
|
||||
/**
|
||||
* Creates a club (tenant) and returns its ID.
|
||||
* Creates a club (tenant) and returns its tenant ID.
|
||||
* IMPORTANT: Sets TenantContext for all subsequent entity creation.
|
||||
* The returned UUID is the tenantId (same value used for all entities).
|
||||
*/
|
||||
protected UUID createTestClub(String name) {
|
||||
// Pre-generate the tenant UUID — all entities will share this
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Club club = new Club();
|
||||
club.setName(name);
|
||||
club.setLicenseNumber("LIC-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
club.setStatus(ClubStatus.ACTIVE);
|
||||
club.setMaxMembers(500);
|
||||
club.setMaxPreventionOfficers(3);
|
||||
club = clubRepository.save(club);
|
||||
return club.getId();
|
||||
// TenantContext remains set — @PrePersist will use it for subsequent entities
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.api.controller.AssemblyController;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.AgendaItemType;
|
||||
import de.cannamanage.domain.enums.AssemblyType;
|
||||
import de.cannamanage.domain.enums.VoteDecision;
|
||||
import de.cannamanage.domain.enums.VoteType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full assembly (Mitgliederversammlung) lifecycle end-to-end.
|
||||
* Tests creation, quorum enforcement, voting with majority thresholds, and protocol generation.
|
||||
*/
|
||||
class AssemblyLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID member1Id;
|
||||
private UUID member2Id;
|
||||
private UUID member3Id;
|
||||
|
||||
private static final String ADMIN_EMAIL = "asm-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Assembly Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create 3 members for quorum and voting tests
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member m1 = createMemberDirectly(tenantId, "Alice", "Meier", "alice@test.de", LocalDate.of(1990, 1, 1));
|
||||
Member m2 = createMemberDirectly(tenantId, "Bob", "Schmidt", "bob@test.de", LocalDate.of(1985, 6, 15));
|
||||
Member m3 = createMemberDirectly(tenantId, "Clara", "Weber", "clara@test.de", LocalDate.of(1992, 9, 30));
|
||||
member1Id = m1.getId();
|
||||
member2Id = m2.getId();
|
||||
member3Id = m3.getId();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Full assembly lifecycle: Create → Add agenda → Start → Vote → Complete")
|
||||
void testFullLifecycle_CreateStartVoteComplete() {
|
||||
// Step 1: Create assembly
|
||||
Instant scheduledAt = Instant.now().plus(1, ChronoUnit.HOURS);
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Ordentliche Mitgliederversammlung 2026",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", scheduledAt.toString(),
|
||||
"location", "Vereinsheim",
|
||||
"quorumRequired", 2,
|
||||
"agendaItems", List.of(
|
||||
Map.of("title", "Kassenbericht", "description", "Bericht des Schatzmeisters", "itemType", "DISCUSSION"),
|
||||
Map.of("title", "Vorstandswahl", "description", "Neuwahl des Vorstands", "itemType", "VOTE")
|
||||
)
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(createResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(createResponse.getBody()).contains("Ordentliche Mitgliederversammlung 2026");
|
||||
|
||||
// Extract assembly ID from response
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
assertThat(assemblyId).isNotNull();
|
||||
|
||||
// Step 2: Check in attendees (quorum = 2, we check in 2 members)
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
|
||||
// Step 3: Start assembly (quorum met with 2 attendees)
|
||||
ResponseEntity<String> startResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(startResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(startResponse.getBody()).contains("IN_PROGRESS");
|
||||
|
||||
// Step 4: Create a vote on the second agenda item
|
||||
// First get assembly detail to find agenda item IDs
|
||||
ResponseEntity<String> detailResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
assertThat(detailResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
String agendaItemId = extractSecondAgendaItemId(detailResponse.getBody());
|
||||
assertThat(agendaItemId).isNotNull();
|
||||
|
||||
Map<String, Object> voteRequest = Map.of(
|
||||
"agendaItemId", agendaItemId,
|
||||
"title", "Vorstandswahl Abstimmung",
|
||||
"description", "Wahl des neuen Vorstands",
|
||||
"voteType", "SIMPLE_MAJORITY"
|
||||
);
|
||||
|
||||
ResponseEntity<String> voteCreateResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/votes")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(voteRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(voteCreateResponse.getStatusCode().value()).isEqualTo(200);
|
||||
String voteId = extractId(voteCreateResponse.getBody());
|
||||
|
||||
// Step 5: Cast votes — both members vote YES (simple majority passes)
|
||||
castVote(voteId, member1Id, "YES");
|
||||
castVote(voteId, member2Id, "YES");
|
||||
|
||||
// Step 6: Close vote
|
||||
ResponseEntity<String> closeVoteResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/close")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(closeVoteResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(closeVoteResponse.getBody()).contains("PASSED");
|
||||
|
||||
// Step 7: Complete assembly
|
||||
ResponseEntity<String> completeResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(completeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(completeResponse.getBody()).contains("COMPLETED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Quorum check: not enough attendees — cannot start")
|
||||
void testQuorumCheck_InsufficientAttendees_CannotStart() {
|
||||
// Create assembly requiring quorum of 3
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Quorum Test Assembly",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||
"location", "Online",
|
||||
"quorumRequired", 3
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
|
||||
// Check in only 2 members (quorum needs 3)
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
|
||||
// Try to start — should fail due to quorum
|
||||
ResponseEntity<String> startResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Expect failure — quorum not met
|
||||
assertThat(startResponse.getStatusCode().value()).isIn(400, 422, 409);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Extraordinary assembly creation succeeds")
|
||||
void testExtraordinaryAssembly_CreationSucceeds() {
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Außerordentliche Versammlung",
|
||||
"assemblyType", "EXTRAORDINARY",
|
||||
"scheduledAt", Instant.now().plus(2, ChronoUnit.DAYS).toString(),
|
||||
"location", "Vereinsheim",
|
||||
"quorumRequired", 2
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("EXTRAORDINARY");
|
||||
assertThat(response.getBody()).contains("Außerordentliche Versammlung");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Vote with SIMPLE_MAJORITY: exact threshold (50%+1) passes")
|
||||
void testVote_SimpleMajority_ExactThreshold_Passes() {
|
||||
// Create and start assembly with 3 attendees
|
||||
String assemblyId = createAndStartAssemblyWith3Attendees();
|
||||
|
||||
// Get first agenda item ID
|
||||
ResponseEntity<String> detailResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String agendaItemId = extractFirstAgendaItemId(detailResponse.getBody());
|
||||
|
||||
// Create vote
|
||||
Map<String, Object> voteRequest = Map.of(
|
||||
"agendaItemId", agendaItemId,
|
||||
"title", "Majority Test",
|
||||
"description", "Testing exact majority threshold",
|
||||
"voteType", "SIMPLE_MAJORITY"
|
||||
);
|
||||
|
||||
ResponseEntity<String> voteResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/votes")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(voteRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String voteId = extractId(voteResponse.getBody());
|
||||
|
||||
// Cast votes: 2 YES, 1 NO — 2/3 > 50% → should pass
|
||||
castVote(voteId, member1Id, "YES");
|
||||
castVote(voteId, member2Id, "YES");
|
||||
castVote(voteId, member3Id, "NO");
|
||||
|
||||
// Close vote
|
||||
ResponseEntity<String> closeResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/close")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(closeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(closeResponse.getBody()).contains("PASSED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Archive assembly generates protocol document (PDF downloadable)")
|
||||
void testComplete_GeneratesProtocol_Downloadable() {
|
||||
// Create, start, and complete assembly
|
||||
String assemblyId = createAndStartAssemblyWith3Attendees();
|
||||
|
||||
// Complete the assembly
|
||||
restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Download protocol PDF
|
||||
ResponseEntity<byte[]> protocolResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/protocol")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(byte[].class);
|
||||
|
||||
assertThat(protocolResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(protocolResponse.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(protocolResponse.getBody()).isNotNull();
|
||||
assertThat(protocolResponse.getBody().length).isGreaterThan(0);
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
private void checkInAttendee(String assemblyId, UUID memberId) {
|
||||
Map<String, Object> request = Map.of("memberId", memberId.toString());
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/attendees")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
private void castVote(String voteId, UUID memberId, String decision) {
|
||||
Map<String, Object> request = Map.of(
|
||||
"memberId", memberId.toString(),
|
||||
"decision", decision
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/cast")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
private String createAndStartAssemblyWith3Attendees() {
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Test Assembly",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||
"location", "Online",
|
||||
"quorumRequired", 2,
|
||||
"agendaItems", List.of(
|
||||
Map.of("title", "Tagesordnungspunkt 1", "description", "Test", "itemType", "VOTE")
|
||||
)
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
|
||||
// Check in 3 attendees
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
checkInAttendee(assemblyId, member3Id);
|
||||
|
||||
// Start assembly
|
||||
restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
return assemblyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "id" field value from a JSON response body.
|
||||
* Simple regex extraction to avoid Jackson dependency in test.
|
||||
*/
|
||||
private String extractId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the second agenda item's ID from the assembly detail response.
|
||||
*/
|
||||
private String extractSecondAgendaItemId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[.*?\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"[^}]*\\}\\s*,\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(2);
|
||||
}
|
||||
// Fallback: try to get any agenda item ID
|
||||
return extractFirstAgendaItemId(jsonBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first agenda item's ID from the assembly detail response.
|
||||
*/
|
||||
private String extractFirstAgendaItemId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
// Look for agendaItems array and extract first ID
|
||||
var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full bank import lifecycle end-to-end.
|
||||
* Tests: upload MT940 → parse → auto-match → confirm → complete,
|
||||
* duplicate file detection, and session abandonment.
|
||||
*/
|
||||
class BankImportLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID memberId;
|
||||
|
||||
private static final String ADMIN_EMAIL = "bank-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
/**
|
||||
* Minimal MT940 statement for testing. Contains one transaction
|
||||
* that can be auto-matched by name/IBAN.
|
||||
*/
|
||||
private static final String SAMPLE_MT940 = """
|
||||
:20:STARTUM
|
||||
:25:10010010/1234567890
|
||||
:28C:0
|
||||
:60F:C260101EUR1000,00
|
||||
:61:2601010101CR50,00N051NONREF
|
||||
:86:116?00GUTSCHRIFT?20Mitgliedsbeitrag?21Januar 2026?32MEIER ALICE?30TESTDE00?31DE89370400440532013000
|
||||
:62F:C260101EUR1050,00
|
||||
-
|
||||
""";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Bank Import Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create a member with IBAN for auto-matching
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member = createMemberDirectly(tenantId, "Alice", "Meier",
|
||||
"alice-bank@test.de", LocalDate.of(1990, 5, 20));
|
||||
member.setIban("DE89370400440532013000");
|
||||
member.setIbanConsentDate(Instant.now());
|
||||
memberRepository.save(member);
|
||||
memberId = member.getId();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Full flow: Upload MT940 → parse → confirm matches → complete")
|
||||
void testFullFlow_UploadMt940_MatchConfirmComplete() {
|
||||
// Step 1: Upload MT940 file
|
||||
String sessionId = uploadMt940(SAMPLE_MT940, "statement_jan.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Step 2: Get session detail — should be OPEN
|
||||
ResponseEntity<String> sessionResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(sessionResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(sessionResponse.getBody()).contains(sessionId);
|
||||
|
||||
// Step 3: List transactions
|
||||
ResponseEntity<String> txnResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(txnResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Step 4: Confirm all matched transactions
|
||||
ResponseEntity<String> confirmAllResponse = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(confirmAllResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Step 5: Complete the session (GoBD seal)
|
||||
ResponseEntity<String> completeResponse = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(completeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(completeResponse.getBody()).contains("COMPLETED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Duplicate file (same SHA-256 hash) rejected on second upload")
|
||||
void testDuplicateUpload_SameFile_Rejected() {
|
||||
// First upload — should succeed
|
||||
String sessionId = uploadMt940(SAMPLE_MT940, "duplicate_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Second upload of same content — should be rejected
|
||||
ResponseEntity<String> duplicateResponse = uploadMt940Raw(SAMPLE_MT940, "duplicate_test_copy.mt940");
|
||||
|
||||
assertThat(duplicateResponse.getStatusCode().value()).isIn(409, 400, 422);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unmatched transactions remain in PENDING status")
|
||||
void testUnmatchedTransactions_RemainPending() {
|
||||
// MT940 with a transaction that won't match any member's IBAN
|
||||
String unmatchedMt940 = """
|
||||
:20:STARTUM
|
||||
:25:10010010/1234567890
|
||||
:28C:0
|
||||
:60F:C260101EUR1000,00
|
||||
:61:2601010101CR75,00N051NONREF
|
||||
:86:116?00GUTSCHRIFT?20Unbekannte Zahlung?21Ref XYZ?32UNBEKANNT PERSON?30NOBANK00?31DE00000000000000000000
|
||||
:62F:C260101EUR1075,00
|
||||
-
|
||||
""";
|
||||
|
||||
String sessionId = uploadMt940(unmatchedMt940, "unmatched_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Get transactions filtered by PENDING/UNMATCHED status
|
||||
ResponseEntity<String> pendingResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions?status=PENDING")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(pendingResponse.getStatusCode().value()).isEqualTo(200);
|
||||
// Should contain at least one transaction (the unmatched one)
|
||||
assertThat(pendingResponse.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Completed session is immutable — cannot be modified")
|
||||
void testImmutability_CompleteSessionCannotBeModified() {
|
||||
// Upload and complete a session
|
||||
String sessionId = uploadMt940(SAMPLE_MT940 + " ", "immutable_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Complete the session
|
||||
restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Try to confirm-all on completed session — should fail (GoBD immutability)
|
||||
ResponseEntity<String> confirmAfterComplete = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(confirmAfterComplete.getStatusCode().value()).isIn(400, 409, 422);
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
/**
|
||||
* Uploads an MT940 file and returns the session ID from the response.
|
||||
*/
|
||||
private String uploadMt940(String content, String filename) {
|
||||
ResponseEntity<String> response = uploadMt940Raw(content, filename);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(201);
|
||||
return extractId(response.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an MT940 file and returns the raw ResponseEntity for assertion.
|
||||
* Uses multipart/form-data upload matching the controller's @RequestParam("file").
|
||||
*/
|
||||
private ResponseEntity<String> uploadMt940Raw(String content, String filename) {
|
||||
byte[] fileBytes = content.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// Use RestClient with multipart — manual boundary construction
|
||||
String boundary = "----TestBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||
String body = "--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n"
|
||||
+ "Content-Type: application/octet-stream\r\n\r\n"
|
||||
+ content + "\r\n"
|
||||
+ "--" + boundary + "--\r\n";
|
||||
|
||||
return restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + boundary)
|
||||
.body(body.getBytes(StandardCharsets.UTF_8))
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "id" field value from a JSON response body.
|
||||
*/
|
||||
private String extractId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||
import de.cannamanage.api.dto.distribution.DistributionResponse;
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.Strain;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.StrainRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full distribution lifecycle end-to-end.
|
||||
* Tests CanG §19 compliance checks (daily/monthly quotas, U21 THC limits, inactive member rejection).
|
||||
*/
|
||||
class DistributionLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private StrainRepository strainRepository;
|
||||
|
||||
@Autowired
|
||||
private BatchRepository batchRepository;
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID memberId;
|
||||
private UUID batchId;
|
||||
|
||||
private static final String ADMIN_EMAIL = "dist-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Distribution Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create an active member (adult, born 1990)
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member = createMemberDirectly(tenantId, "Max", "Muster",
|
||||
"max@test.de", LocalDate.of(1990, 1, 15));
|
||||
memberId = member.getId();
|
||||
|
||||
// Create a strain + batch with stock
|
||||
Strain strain = new Strain();
|
||||
strain.setTenantId(tenantId);
|
||||
strain.setName("Test Strain");
|
||||
strain.setThcPercentage(new BigDecimal("15.0"));
|
||||
strain.setCbdPercentage(new BigDecimal("2.0"));
|
||||
strain = strainRepository.save(strain);
|
||||
|
||||
Batch batch = new Batch();
|
||||
batch.setTenantId(tenantId);
|
||||
batch.setStrainId(strain.getId());
|
||||
batch.setQuantityGrams(new BigDecimal("500.0"));
|
||||
batch.setHarvestDate(LocalDate.now().minusDays(7));
|
||||
batch.setBatchCode("BATCH-TEST-001");
|
||||
batch.setStatus(BatchStatus.AVAILABLE);
|
||||
batch = batchRepository.save(batch);
|
||||
batchId = batch.getId();
|
||||
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Create distribution for member — succeeds and records distribution")
|
||||
void testCreateDistribution_ValidRequest_Succeeds() {
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("5.0"), "Test distribution");
|
||||
|
||||
ResponseEntity<DistributionResponse> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(201);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().memberId()).isEqualTo(memberId);
|
||||
assertThat(response.getBody().batchId()).isEqualTo(batchId);
|
||||
assertThat(response.getBody().quantityGrams()).isEqualByComparingTo(new BigDecimal("5.0"));
|
||||
assertThat(response.getBody().distributedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution respects daily quota (25g) — boundary test at limit")
|
||||
void testCreateDistribution_DailyQuotaExceeded_Rejected() {
|
||||
// First: distribute 24g (just under limit)
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("24.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Second: distribute 1g more (should work — exactly at 25g)
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("1.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response2.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Third: 0.01g more — exceeds daily limit of 25g
|
||||
CreateDistributionRequest request3 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("0.01"), null);
|
||||
|
||||
ResponseEntity<String> response3 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request3)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response3.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution respects monthly quota (50g) — boundary test at limit")
|
||||
void testCreateDistribution_MonthlyQuotaExceeded_Rejected() {
|
||||
// Distribute 25g (daily max) — first day
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("25.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Now try to distribute 25.01g more — would exceed monthly 50g for adults
|
||||
// (in reality this is the same day so daily limit triggers first at 25g,
|
||||
// but the monthly check also applies)
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("25.01"), null);
|
||||
|
||||
ResponseEntity<String> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Should be rejected (either daily or monthly limit)
|
||||
assertThat(response2.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("U21 member gets lower THC restriction — high-THC strain rejected")
|
||||
void testCreateDistribution_Under21HighThc_Rejected() {
|
||||
// Create an under-21 member (born 5 years ago = 5 years old, but set under21=true)
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member youngMember = new Member();
|
||||
youngMember.setTenantId(tenantId);
|
||||
youngMember.setClubId(tenantId);
|
||||
youngMember.setFirstName("Jung");
|
||||
youngMember.setLastName("Mitglied");
|
||||
youngMember.setEmail("jung@test.de");
|
||||
youngMember.setDateOfBirth(LocalDate.now().minusYears(19));
|
||||
youngMember.setMembershipDate(LocalDate.now());
|
||||
youngMember.setMembershipNumber("M-U21-001");
|
||||
youngMember.setUnder21(true);
|
||||
youngMember.setStatus(MemberStatus.ACTIVE);
|
||||
youngMember = memberRepository.save(youngMember);
|
||||
|
||||
// Create a strain with THC > 10% (the U21 limit)
|
||||
Strain highThcStrain = new Strain();
|
||||
highThcStrain.setTenantId(tenantId);
|
||||
highThcStrain.setName("High THC Strain");
|
||||
highThcStrain.setThcPercentage(new BigDecimal("15.0"));
|
||||
highThcStrain.setCbdPercentage(new BigDecimal("1.0"));
|
||||
highThcStrain = strainRepository.save(highThcStrain);
|
||||
|
||||
Batch highThcBatch = new Batch();
|
||||
highThcBatch.setTenantId(tenantId);
|
||||
highThcBatch.setStrainId(highThcStrain.getId());
|
||||
highThcBatch.setQuantityGrams(new BigDecimal("100.0"));
|
||||
highThcBatch.setHarvestDate(LocalDate.now().minusDays(3));
|
||||
highThcBatch.setBatchCode("BATCH-HIGH-THC-001");
|
||||
highThcBatch.setStatus(BatchStatus.AVAILABLE);
|
||||
highThcBatch = batchRepository.save(highThcBatch);
|
||||
TenantContext.clear();
|
||||
|
||||
// Try to distribute high-THC strain to U21 member
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
youngMember.getId(), highThcBatch.getId(), new BigDecimal("3.0"), null);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(422, 400);
|
||||
assertThat(response.getBody()).containsIgnoringCase("THC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution to inactive member is rejected")
|
||||
void testCreateDistribution_InactiveMember_Rejected() {
|
||||
// Create an inactive member
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member inactiveMember = new Member();
|
||||
inactiveMember.setTenantId(tenantId);
|
||||
inactiveMember.setClubId(tenantId);
|
||||
inactiveMember.setFirstName("Inaktiv");
|
||||
inactiveMember.setLastName("Mitglied");
|
||||
inactiveMember.setEmail("inaktiv@test.de");
|
||||
inactiveMember.setDateOfBirth(LocalDate.of(1985, 6, 1));
|
||||
inactiveMember.setMembershipDate(LocalDate.now());
|
||||
inactiveMember.setMembershipNumber("M-INACTIVE-001");
|
||||
inactiveMember.setUnder21(false);
|
||||
inactiveMember.setStatus(MemberStatus.SUSPENDED);
|
||||
inactiveMember = memberRepository.save(inactiveMember);
|
||||
TenantContext.clear();
|
||||
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
inactiveMember.getId(), batchId, new BigDecimal("5.0"), null);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Batch distribution — multiple distributions in sequence succeed within limits")
|
||||
void testCreateDistribution_BatchMultipleMembers_Succeeds() {
|
||||
// Create a second member
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member2 = createMemberDirectly(tenantId, "Anna", "Beispiel",
|
||||
"anna@test.de", LocalDate.of(1992, 3, 20));
|
||||
TenantContext.clear();
|
||||
|
||||
// Distribute to first member
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("10.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Distribute to second member
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
member2.getId(), batchId, new BigDecimal("15.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response2.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Verify both distributions are listed
|
||||
ResponseEntity<String> listResponse = restClient().get()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
assertThat(listResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(listResponse.getBody()).contains(memberId.toString());
|
||||
assertThat(listResponse.getBody()).contains(member2.getId().toString());
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
|
||||
/**
|
||||
* Integration test verifying Flyway migrations apply cleanly to a fresh PostgreSQL database.
|
||||
* Validates schema integrity, idempotency, and expected table existence.
|
||||
*/
|
||||
class MigrationIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
@DisplayName("All Flyway migrations (V1–V34) apply cleanly on fresh database")
|
||||
void testFlywayMigration_AllMigrationsApply_NoErrors() {
|
||||
// The application context starts with Flyway auto-migration enabled,
|
||||
// so if we reach this point, all migrations applied successfully.
|
||||
// Verify via flyway_schema_history table.
|
||||
Integer migrationCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM flyway_schema_history WHERE success = true",
|
||||
Integer.class);
|
||||
|
||||
assertThat(migrationCount).isGreaterThanOrEqualTo(34);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Running Flyway migrate again is idempotent (no new migrations applied)")
|
||||
void testFlywayMigration_Idempotent_SecondRunNoOp() {
|
||||
// Grab a Flyway instance pointing at the same datasource
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.load();
|
||||
|
||||
// Running migrate again should be a no-op (0 new migrations)
|
||||
assertThatNoException().isThrownBy(flyway::migrate);
|
||||
|
||||
// Verify no pending migrations
|
||||
var info = flyway.info();
|
||||
assertThat(info.pending()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Schema contains all expected core tables after migration")
|
||||
void testFlywayMigration_ExpectedTablesExist() {
|
||||
// Spot-check critical tables from various migrations
|
||||
List<String> expectedTables = List.of(
|
||||
"users",
|
||||
"members",
|
||||
"distributions",
|
||||
"clubs",
|
||||
"audit_events",
|
||||
"bank_import_sessions",
|
||||
"assemblies",
|
||||
"forum_topics",
|
||||
"batches",
|
||||
"strains",
|
||||
"monthly_quotas",
|
||||
"bank_transactions",
|
||||
"assembly_votes",
|
||||
"documents"
|
||||
);
|
||||
|
||||
for (String table : expectedTables) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||
"WHERE table_schema = 'public' AND table_name = ?",
|
||||
Integer.class, table);
|
||||
assertThat(count)
|
||||
.as("Table '%s' should exist in the schema", table)
|
||||
.isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying Spring Security filter chain behavior end-to-end.
|
||||
* Tests public endpoints, JWT-protected endpoints, and CORS configuration.
|
||||
*/
|
||||
class SecurityConfigIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private static final String ADMIN_EMAIL = "sec-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "SecurePass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Security Config Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unauthenticated request to public endpoint (actuator/health) returns 200")
|
||||
void testUnauthenticated_PublicEndpoint_Allowed() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unauthenticated request to protected endpoint returns 401/403")
|
||||
void testUnauthenticated_ProtectedEndpoint_Returns401() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/members")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Authenticated request to protected endpoint returns 200")
|
||||
void testAuthenticated_ProtectedEndpoint_Returns200() {
|
||||
String token = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/members")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CORS headers present on OPTIONS preflight request")
|
||||
void testCorsHeaders_PresentOnOptions() {
|
||||
ResponseEntity<String> response = restClient().options()
|
||||
.uri("/api/v1/members")
|
||||
.header(HttpHeaders.ORIGIN, "http://localhost:3000")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Authorization")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Should not be blocked — allowed origin
|
||||
assertThat(response.getStatusCode().value()).isIn(200, 204);
|
||||
assertThat(response.getHeaders().getAccessControlAllowOrigin())
|
||||
.isEqualTo("http://localhost:3000");
|
||||
assertThat(response.getHeaders().getAccessControlAllowMethods())
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link JwtService} covering token generation, parsing,
|
||||
* claim extraction, and security attack vectors.
|
||||
*/
|
||||
class JwtServiceTest {
|
||||
|
||||
private JwtService jwtService;
|
||||
|
||||
// A valid base64-encoded 256-bit secret for testing
|
||||
private static final String TEST_SECRET = Base64.getEncoder().encodeToString(
|
||||
"ThisIsA32ByteSecretKeyForTests!!".getBytes());
|
||||
|
||||
private UUID userId;
|
||||
private UUID tenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
jwtService = new JwtService();
|
||||
setField(jwtService, "secretKey", TEST_SECRET);
|
||||
setField(jwtService, "accessTokenExpiry", 3600L);
|
||||
setField(jwtService, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
userId = UUID.randomUUID();
|
||||
tenantId = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateAccessToken_validClaims_containsExpectedFields() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "test@example.com");
|
||||
|
||||
assertThat(token).isNotNull().isNotBlank();
|
||||
assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString());
|
||||
assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId);
|
||||
assertThat(jwtService.extractRole(token)).isEqualTo("ADMIN");
|
||||
assertThat(jwtService.extractEmail(token)).isEqualTo("test@example.com");
|
||||
assertThat(jwtService.extractJti(token)).isNotNull().isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractUserId_validToken_returnsCorrectUuid() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "user@club.de");
|
||||
|
||||
UUID extracted = jwtService.extractUserId(token);
|
||||
assertThat(extracted).isEqualTo(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractRole_staffToken_returnsStaff() {
|
||||
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de",
|
||||
List.of("MANAGE_MEMBERS", "VIEW_FINANCES"));
|
||||
|
||||
assertThat(jwtService.extractRole(token)).isEqualTo("STAFF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractPermissions_staffToken_returnsPermissionsList() {
|
||||
List<String> permissions = List.of("MANAGE_MEMBERS", "VIEW_FINANCES", "MANAGE_GROW");
|
||||
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de", permissions);
|
||||
|
||||
List<String> extracted = jwtService.extractPermissions(token);
|
||||
assertThat(extracted).containsExactlyInAnyOrderElementsOf(permissions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractPermissions_nonStaffToken_returnsEmptyList() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "admin@club.de");
|
||||
|
||||
List<String> extracted = jwtService.extractPermissions(token);
|
||||
assertThat(extracted).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTenantId_validToken_returnsCorrectTenantUuid() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de");
|
||||
|
||||
UUID extracted = jwtService.extractTenantId(token);
|
||||
assertThat(extracted).isEqualTo(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_freshToken_returnsTrue() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
assertThat(jwtService.isTokenValid(token)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_expiredToken_returnsFalse() throws Exception {
|
||||
// Create a service with 0 second expiry
|
||||
JwtService shortLived = new JwtService();
|
||||
setField(shortLived, "secretKey", TEST_SECRET);
|
||||
setField(shortLived, "accessTokenExpiry", 0L);
|
||||
setField(shortLived, "refreshTokenExpiry", 0L);
|
||||
|
||||
String token = shortLived.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
// Token with 0-second expiry is immediately expired
|
||||
Thread.sleep(50);
|
||||
assertThat(jwtService.isTokenValid(token)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_invalidSignature_returnsFalse() {
|
||||
// Generate token with a different key
|
||||
String differentSecret = Base64.getEncoder().encodeToString(
|
||||
"ACompletelyDifferentKey1234567!!".getBytes());
|
||||
SecretKey wrongKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(differentSecret));
|
||||
|
||||
String forgedToken = Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("tenant_id", tenantId.toString())
|
||||
.claim("role", "ADMIN")
|
||||
.issuedAt(Date.from(Instant.now()))
|
||||
.expiration(Date.from(Instant.now().plusSeconds(3600)))
|
||||
.signWith(wrongKey)
|
||||
.compact();
|
||||
|
||||
assertThat(jwtService.isTokenValid(forgedToken)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_malformedToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid("not.a.valid.jwt.token")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_nullToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_emptyToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid("")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_tamperedPayload_returnsFalse() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de");
|
||||
// Tamper with the payload (second segment) by modifying a character
|
||||
String[] parts = token.split("\\.");
|
||||
// Flip a character in the payload
|
||||
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
|
||||
payloadBytes[5] = (byte) (payloadBytes[5] ^ 0xFF);
|
||||
parts[1] = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadBytes);
|
||||
String tampered = String.join(".", parts);
|
||||
|
||||
assertThat(jwtService.isTokenValid(tampered)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateRefreshToken_containsRefreshType() {
|
||||
String token = jwtService.generateRefreshToken(userId, tenantId);
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString());
|
||||
assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId);
|
||||
assertThat(jwtService.extractJti(token)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateSecret_tooShort_throwsIllegalState() throws Exception {
|
||||
JwtService invalid = new JwtService();
|
||||
setField(invalid, "secretKey", "short");
|
||||
setField(invalid, "accessTokenExpiry", 3600L);
|
||||
setField(invalid, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
assertThatThrownBy(invalid::validateSecret)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JWT secret is not configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateSecret_defaultPlaceholder_throwsIllegalState() throws Exception {
|
||||
JwtService invalid = new JwtService();
|
||||
setField(invalid, "secretKey", JwtService.UNCONFIGURED_SECRET_MARKER);
|
||||
setField(invalid, "accessTokenExpiry", 3600L);
|
||||
setField(invalid, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
assertThatThrownBy(invalid::validateSecret)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JWT secret is not configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractExpirationInstant_returnsNonNullFutureInstant() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
Instant expiration = jwtService.extractExpirationInstant(token);
|
||||
assertThat(expiration).isAfter(Instant.now());
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user