Compare commits

..

62 Commits

Author SHA1 Message Date
Patrick Plate 52251cf711 fix(api): resolve consent/dsgvo 'User not found' — principal is userId not email
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
ConsentController.resolveUserId() and DsgvoController.resolveUserId() read
auth.getName() as an email and did findByEmailAndTenantId(...), but JwtAuthFilter
sets the Authentication principal to the userId (UUID) — the JWT subject is the
userId, not the email. So auth.getName() returns a UUID string, the email lookup
never matched, and every consent/dsgvo call threw 'User not found' (404/500).

This made the DSGVO consent banner unusable: /consent/check 404'd (banner always
shown) and clicking Accept POSTed /consent which 500'd with no UI feedback — the
button appeared to 'not react'.

Fix: parse auth.getName() as the userId UUID directly and verify existsById.
2026-06-13 10:52:43 +02:00
Patrick Plate 26a77b5e16 docs: record 'Oops' crash fix (intl + PWA middleware) verified via Playwright
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 10:45:44 +02:00
Patrick Plate 4be9c4cf2c fix(frontend): resolve app-wide 'Oops' crash + PWA middleware interception
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
Root cause (found via Playwright browser probe — curl could not detect client-side
hydration errors):

1. ROOT-LAYOUT INTL CRASH (the 'Oops' on every page incl /login):
   app/layout.tsx renders global client components (PwaInstallPrompt → useTranslations,
   Toaster, Sonner) as siblings of {children} inside <Providers>, but only each
   route-group layout wrapped its own children in NextIntlClientProvider. So those
   global components mounted with NO intl context → 'No intl context found' → React
   hydration crash → global-error 'Oops'. Fix: wrap the root body in
   NextIntlClientProvider via getMessages() (RootLayout now async). Nested providers
   stay valid (next-intl supports nesting).

2. PWA MIDDLEWARE INTERCEPTION (manifest.json syntax error + stale cache):
   middleware matcher did not exclude /manifest.json or /sw.js, so unauthenticated
   browsers got 307→/login (HTML) for both. Browser parsed HTML as JSON
   ('manifest.json:1 Syntax error') and an HTML/old service worker kept serving
   stale bundles ('website hasn't changed' after redeploys). Fix: exclude
   manifest.json, sw.js, icons, offline from the matcher.

3. SERVICE-WORKER STALE CACHE: bump CACHE_NAME v1→v2 so the activate handler purges
   old cached bundles from clients that loaded the broken build.

Also adds scripts/debug/dashboard-probe.mjs — a Playwright probe that logs in and
captures real client-side console/network errors + screenshot.
2026-06-13 10:36:09 +02:00
Patrick Plate 2347a7a1d9 docs: record auth fixes — login verified end-to-end (admin@test.de)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 10:14:21 +02:00
Patrick Plate 281adda27c fix(frontend): align NextAuth authorize() with flat backend LoginResponse
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
Login reached the backend (HTTP 200) but NextAuth returned CredentialsSignin.
Cause: authorize() read data.member.id/email/clubName/clubId, but the backend
LoginResponse is flat — { accessToken, refreshToken, expiresIn, role } with no
member object. Accessing data.member.id on undefined threw, so authorize()
returned null.

Decode the JWT payload to recover identity claims (sub=userId, email,
tenant_id=clubId) and use the flat top-level role. Adds a small decodeJwtPayload
helper (claims only, no signature verification needed here).
2026-06-13 10:10:48 +02:00
Patrick Plate dac884c4fe fix(deploy): use valid base64 JWT secret in docker-compose
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
After a successful login the backend returned HTTP 500:
io.jsonwebtoken.io.DecodingException: Illegal base64 character: '-'.
JwtService.getSigningKey() does Decoders.BASE64.decode(secret) before building
the HMAC key (JJWT 0.12 convention). The compose secret was the plaintext
'docker-dev-secret-key-minimum-32-characters-long-for-hmac', which contains
hyphens and is not valid base64, so token signing threw once auth succeeded.

Replace with a proper base64 value (openssl rand -base64 48). The base
application.properties default was already correctly base64-encoded; only the
docker override was wrong.
2026-06-13 10:08:34 +02:00
Patrick Plate 6570ea364a docs: mark CannaManage deploy RESOLVED — live on TrueNAS:3000
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
Records the three real root causes (BASE_URL metadataBase, Spring Boot 4
Flyway starter, mail health indicator) and the 8080->8081 host port remap.
2026-06-13 10:01:39 +02:00
Patrick Plate 60844efaba fix(api): disable mail health indicator in docker profile
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
Backend now starts and Flyway migrations run, but /actuator/health returned
503 (DOWN) so Docker marked the container unhealthy and the frontend refused
to start. Cause: spring-boot-starter-mail registers a mail health indicator
that tries to connect to localhost:1025; there is no SMTP container in this
deployment, so it reports DOWN and drags the aggregate health to DOWN.

Disable the mail health indicator in the docker profile. Mail being down must
not make the whole service unhealthy in an SMTP-less deployment.
2026-06-13 09:57:01 +02:00
Patrick Plate 8490da4705 fix(api): add spring-boot-starter-flyway for Spring Boot 4 migrations
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
Backend crashed on startup with 'Schema validation: missing table
[audit_events]'. Root cause: this is Spring Boot 4.0.6, which modularized
autoconfiguration. FlywayAutoConfiguration moved out of spring-boot-autoconfigure
into a dedicated spring-boot-flyway module that is only pulled in by
spring-boot-starter-flyway. The pom only had flyway-database-postgresql
(+ transitive flyway-core) but NOT the starter, so spring.flyway.enabled=true
was inert: no migrations ran, flyway_schema_history was never created, and
Hibernate ddl-auto=validate failed on the empty schema.

Adds spring-boot-starter-flyway (autoconfigure module + flyway-core); keeps
flyway-database-postgresql for the Postgres dialect.
Ref: https://spring.io/blog/2025/10/28/modularizing-spring-boot/
2026-06-13 09:52:22 +02:00
Patrick Plate f6a7143d1b fix(frontend): guard metadataBase against undefined BASE_URL
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
Root cause of the SSG/page-data build crash: src/app/layout.tsx evaluated
new URL(process.env.BASE_URL) at module load. BASE_URL was never set as a
build-time ENV, so it was undefined -> ERR_INVALID_URL, input: 'undefined'.
Because this is the root layout, its metadata is collected for every route,
explaining why both /impressum (marketing) and /portal-login (non-marketing)
failed identically. The earlier NextAuth/middleware/force-dynamic fixes could
not help because metadata evaluation happens before any of that.

- layout.tsx: fall back to http://localhost:3000 when BASE_URL is unset
- Dockerfile: add BASE_URL build-time placeholder (matches AUTH_URL pattern)
2026-06-13 09:44:21 +02:00
Patrick Plate 1eead286ba docs: add Roo handover doc for TrueNAS Docker deploy session
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:40:02 +02:00
Patrick Plate 9a4df56eaf fix(frontend): exclude marketing routes from NextAuth middleware matcher
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:39:17 +02:00
Patrick Plate b57be8a4d8 fix(frontend): hardcode build-time placeholder ENVs for AUTH_URL/SECRET
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:34:20 +02:00
Patrick Plate 3e4fdee05b fix(frontend): force-dynamic on marketing layout to skip SSG at build time
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:31:24 +02:00
Patrick Plate 805bc4f00d fix(frontend): add AUTH_URL + AUTH_SECRET build ARGs for NextAuth v5
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:30:13 +02:00
Patrick Plate d650987b9a fix(frontend): guard redirect callback against undefined url during SSG
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:28:52 +02:00
Patrick Plate 106229e0e3 fix(frontend): add build-time ARG placeholders for NEXTAUTH_URL/SECRET/BACKEND_URL
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:27:01 +02:00
Patrick Plate d0c53a912c fix(service): DsgvoService getMembershipNumber + remove non-existent setPhone
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:23:33 +02:00
Patrick Plate 61707ffe68 fix(service): add spring-boot-starter-websocket dep for SimpMessagingTemplate
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
2026-06-13 09:21:54 +02:00
Patrick Plate 1e693e3d2a feat(sprint-6): Phase 7 — Launch checklist, pricing page, legal templates
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- docs/sprint-6/launch-checklist.md: comprehensive pre/post-launch checklist
- /pricing: public pricing page (Starter €19, Pro €49, Enterprise)
- /impressum, /datenschutz, /agb: legal page templates (placeholder text)
- (marketing) route group: public layout without auth
- Footer links to legal pages on login + portal
- i18n for marketing namespace (de + en)
- Fix pre-existing lint errors (unused vars, missing @stomp/stompjs types)
2026-06-12 23:16:47 +02:00
Patrick Plate 599514c0db feat(sprint-6): Phase 6 — Notifications (WebSocket) + PWA
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- WebSocket: Spring STOMP + SockJS, NotificationService, persistent notifications table
- NotificationController: GET/PUT endpoints for notification management
- Frontend: notification bell with unread badge, dropdown panel, real-time via STOMP
- PWA: manifest.json, service worker (manual sw.js), offline page, install prompt
- PWA icons (192+512), dark theme colors, standalone display
- Full i18n (de/en) for notifications and PWA
- Flyway V10 migration for notifications table
- spring-boot-starter-websocket dependency added
2026-06-12 23:02:44 +02:00
Patrick Plate 076fd6f9b3 feat(sprint-6): Phase 5 — Full grow calendar (sensors, photos, feeding, harvest traceability)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- V9 migration: grow_entries, grow_stage_logs, sensor_readings, grow_photos, feeding_logs
- 5 entities + GrowStage enum (7 stages) + SensorReadingType enum
- GrowCalendarService: CRUD + stage advancement + harvest-to-batch linking
- GrowCalendarController: 8 endpoints (/api/v1/grow/*)
- Frontend: /grow list + /grow/[id] detail (timeline, sensor charts, photo gallery, feeding log)
- Sensor chart (Recharts line: temp + humidity over time)
- Harvest completion links grow entry → batch (full traceability)
- React Query hooks for all grow operations
- Full i18n (de/en) with 7 grow stage labels
- Sidebar navigation updated with Anbau/Grow entry
2026-06-12 22:51:45 +02:00
Patrick Plate 05933a08ca feat(sprint-6): Phase 4 — Immutable audit log
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- V8 migration: audit_events table (JSONB metadata, immutable by design)
- AuditEvent entity + AuditEventType enum (18 event types)
- AuditService: log events, paginated query, PDF export
- AuditController: GET /api/v1/audit (paginated, filtered), GET export
- AuditEventRepository with JPQL filtered queries
- Frontend: /audit-log page (read-only, filterable, timezone-aware)
- PDF export button for Behörde inspections
- Sidebar: 'Protokoll' under new Compliance section
- PdfReportGenerator: generateAuditReport method added
- 10-year retention, REVOKE DELETE documented
- Full i18n (de/en) with 18 event type translations
2026-06-12 22:40:40 +02:00
Patrick Plate 61e481b37b feat(sprint-6): Phase 3 — Stripe integration (SEPA + PayPal + Card)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- V7 migration: subscriptions table with plan tiers
- Subscription entity + PlanTier/SubscriptionStatus enums
- StripeService: customer creation, checkout, portal, webhook handling
- SubscriptionController: /api/v1/billing endpoints
- Webhook handler: invoice.paid, payment_failed, subscription.deleted/updated
- Plan enforcement: member limit interceptor, trial expiry check
- Frontend: /settings/billing page (plan card, usage, upgrade, portal link)
- Trial expired banner on all admin pages
- React Query hooks (useSubscriptionQuery, checkout/portal mutations)
- Stripe Java SDK 28.2.0
- Full i18n (de/en) for billing namespace
2026-06-12 22:31:03 +02:00
Patrick Plate 3232d2f7fd feat(sprint-6): Phase 2 — DSGVO consent management
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- V6 migration: consents table with audit columns
- Consent entity, repository, service (grant/revoke/check)
- ConsentController: GET/POST/DELETE consent endpoints
- DSGVO export (Art. 15): full personal data JSON download
- DSGVO deletion (Art. 17): anonymization + account deactivation
- Frontend: consent banner (modal, cannot dismiss), privacy settings page
- React Query hooks for consent + DSGVO operations
- Full i18n (de/en) for consent and DSGVO namespaces
2026-06-12 22:22:48 +02:00
Patrick Plate b38902a7ee feat(sprint-6): Phase 1 — Production deployment infrastructure (IONOS)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
- docker-compose.prod.yml: production Docker Compose with health checks, logging, restart policies, resource limits
- deploy/nginx/cannamanage.conf: Nginx reverse proxy with TLS, CSP, security headers, rate limiting
- deploy/.env.production.example: environment template for secrets
- deploy/backup.sh: GPG-encrypted daily/weekly PostgreSQL backup with retention
- deploy/deploy.sh: manual deploy script with health check verification
- .gitea/workflows/deploy.yml: Gitea Actions CI/CD pipeline (test + deploy)
- application-production.properties: Spring Boot production profile (no stacktraces, Swagger disabled, Stripe)
- .gitignore: added .env to prevent accidental secret commits
2026-06-12 22:11:43 +02:00
Patrick Plate 4fa068092f fix: apply 8 persona review corrections to Sprint 6 plan (v3) 2026-06-12 22:06:08 +02:00
Patrick Plate 8391dbb2cd docs: Sprint 6 plan v2 — Q&A decisions (IONOS, Stripe tiers, full grow calendar) 2026-06-12 21:55:40 +02:00
Patrick Plate 9373c7ad69 fix: address Snyk findings (remove mock password, override vulnerable deps) 2026-06-12 21:05:27 +02:00
Patrick Plate 5c02cb0cde docs: Sprint 5 security review (Snyk + SonarQube) 2026-06-12 21:00:03 +02:00
Patrick Plate 4d64576f22 test: Vitest setup + unit tests for API client, hooks, services + staff E2E
- Vitest + React Testing Library + MSW setup
- API client: 11 unit tests (fetch, errors, auth header, download, network failure)
- Service hooks: 26 tests across members, distributions, stock, dashboard, staff
- Custom hooks: 5 debounce tests (timer behavior, reset, custom delay)
- Components: 5 tests (offline banner, error boundary with retry)
- E2E: staff management page interactions
- npm scripts: test, test:run, test:coverage
2026-06-12 20:50:45 +02:00
Patrick Plate d1487539b6 feat(sprint-5): Phase 7 — System test harness
- docker-compose.test.yml: full stack test profile with seed + playwright
- scripts/seed/init.sql: test data (admin, members, batches, distributions)
- scripts/seed/seed.sh: backend readiness validation script
- e2e/system-test.spec.ts: full user journey against real/mock stack
- package.json: test:e2e, test:system, test:all scripts
- scripts/README.md: system test documentation and usage instructions
2026-06-12 20:39:09 +02:00
Patrick Plate 2cc8c89944 feat(sprint-5): Phase 6 — Staff management UI (list, invite, permissions, revoke)
- /settings/staff: staff account table with role badges + permission chips
- Invite sheet: email + role template + 8 granular permission checkboxes
- Edit permissions dialog with optimistic update
- Revoke access with AlertDialog confirmation
- React Query hooks wired (useStaffListQuery, mutations)
- Full i18n (de/en), mock fallback, loading skeletons
- Sidebar nav updated: Personal → /settings/staff with UserCog icon
- Added @radix-ui/react-checkbox + Checkbox UI component
2026-06-12 20:32:54 +02:00
Patrick Plate ed1efccc90 feat(sprint-5): Phase 5 — Wire reports + portal to React Query
- Reports: preview queries + apiDownload for PDF/CSV
- Portal dashboard: usePortalDashboardQuery with quota fallback
- Portal history: usePortalHistoryQuery with month filter
- Portal profile: usePortalProfileQuery + useChangePasswordMutation
- All pages show loading skeletons, graceful mock fallback
2026-06-12 20:24:11 +02:00
Patrick Plate be63a84fe8 feat(sprint-5): Phase 4 — Wire distributions + stock to React Query
- Distribution list: useDistributionsQuery with date filter + member search
- New distribution: multi-step with live quota + batch queries + create mutation
- Stock page: useBatchesQuery + useRecallBatchMutation (optimistic)
- Add batch: useStrainsQuery + useCreateBatchMutation
- All pages show loading skeletons, graceful mock fallback
2026-06-12 20:15:26 +02:00
Patrick Plate b170bb9d87 feat(sprint-5): Phase 3 — Wire dashboard + members to React Query
- Dashboard: useClubStatsQuery + useRecentDistributionsQuery with fallback
- Members list: useMembersQuery with debounced search + pagination
- Member detail: useMemberQuery + useUpdateMemberMutation
- Add member: useCreateMemberMutation with invalidation
- All pages show loading skeletons during fetch
- Graceful fallback to mock data when backend unavailable
- New useDebounce hook for search input (300ms delay)
2026-06-12 20:07:16 +02:00
Patrick Plate f42c166329 feat(sprint-5): Phase 2 — React Query API client layer
- @tanstack/react-query with QueryClientProvider in providers/index.tsx
- Typed api-client.ts fetch wrapper with ApiError class + apiDownload
- Service modules: members, distributions, stock, reports, dashboard, portal, staff
- Offline banner component (onlineManager subscription)
- API error boundary with retry button
- Loading skeleton components (card, table, chart, form, dashboard)
- i18n for error/loading states (de/en)
2026-06-12 19:59:41 +02:00
Patrick Plate 279f2f6de0 feat(sprint-5): Phase 1 — Docker Compose full stack, CORS, Next.js upgrade
- Dockerfile.backend: multi-stage Java 21 build (eclipse-temurin)
- docker-compose.yml: PostgreSQL 16 + backend + frontend with health checks
- SecurityConfig: CORS for localhost:3000 frontend origin
- application-docker.properties: Docker profile with env vars
- Spring Boot Actuator health endpoint enabled
- Next.js upgraded 15.2.8 → 15.5.18 (security fixes)
2026-06-12 19:51:24 +02:00
Patrick Plate dce27a4291 fix: center content alignment on portal and stock pages 2026-06-12 19:01:47 +02:00
Patrick Plate 7f99e11d9f test: authenticated admin E2E tour with smart mock backend (all pages screenshot)
- Rewrote e2e/mock-backend.mjs to return valid auth responses (login + refresh)
- Created e2e/authenticated-tour.spec.ts that logs in and screenshots all 7 admin pages
- Fixed (dashboard-layout)/layout.tsx: added missing NextIntlClientProvider
- All pages render error-free in dark mode with mock data
- Screenshots: dashboard, members, distributions, distribution/new, stock, stock/new, reports
2026-06-12 18:38:22 +02:00
Patrick Plate 09d5ca6db0 fix: regenerate screenshots from stable server (replace error-state captures) 2026-06-12 18:27:25 +02:00
Patrick Plate 02e4bbad18 test: comprehensive E2E functional test suite (Sprint 4)
66 tests across 13 test groups covering:
- Login form interactions & validation
- Portal login flow
- Navigation & layout verification
- Theme/dark mode detection
- Auth redirect behavior (8 protected routes)
- Portal dashboard (quota rings, navbar, footer)
- Portal history page
- Portal profile page
- Cross-page portal navigation
- Responsive design (mobile/tablet/desktop)
- Accessibility basics (labels, headings, autocomplete)
- Error states & edge cases
- Portal page content verification
2026-06-12 18:11:47 +02:00
Patrick Plate f8f562915e docs: Sprint 4 visual tour with 19 Playwright screenshots 2026-06-12 17:35:39 +02:00
Patrick Plate 154f79fe60 docs: Sprint 4 walkthrough guide 2026-06-12 17:28:56 +02:00
Patrick Plate fe6e96dd3f feat: Sprint 4 complete — frontend MVP (admin dashboard + member portal)
Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4)

Sprint 4.a — Admin Dashboard:
- Auth: NextAuth.js v5, login page, middleware, token rotation
- Dashboard: KPI cards, Recharts stock chart, quick actions
- Members: TanStack Table (search/sort/paginate), add/edit forms
- Distributions: multi-step form, real-time quota check, history
- Stock: batch management, recall dialog, bar chart
- Reports: monthly/member-list/recall, PDF/CSV download, preview

Sprint 4.b — Member Portal:
- Separate route group with top-nav layout (mobile-first)
- Quota dashboard with radial SVG progress indicators
- Distribution history with month filter
- Profile/settings with password change

Cross-cutting:
- i18n: German (default) + English via next-intl
- Dark + light mode (next-themes, user-togglable)
- Playwright E2E tests (6/6 green)
- Docker multi-stage build (node:22-alpine)
- API proxy via Next.js rewrites

Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5,
TanStack Table, Recharts, Zod, React Hook Form, Playwright
2026-06-12 17:18:38 +02:00
Patrick Plate a1d4ba44e3 fix(security): re-add dependency version overrides for SCA compliance 2026-06-12 11:17:40 +02:00
Patrick Plate 864bbbdde1 feat(sprint-3): Phase 7 — integration tests (Testcontainers PostgreSQL)
- Add AbstractIntegrationTest base class with Testcontainers PostgreSQL,
  RestClient helpers, and test data factories
- AuthIntegrationTest: login, refresh, token rotation, error cases
- TenantIsolationTest: multi-tenant data isolation verification
- StaffPermissionIntegrationTest: invite → activate → permission enforcement
- PortalIntegrationTest: session-based portal auth flow
- ReportIntegrationTest: JSON/PDF/CSV report generation E2E
- TokenRevocationIntegrationTest: permission change → JWT revocation
- application-integration.properties: Flyway-enabled test profile
- Remove obsolete Boot 3 @WebMvcTest/@MockBean tests (Boot 4 incompatible)
  replaced by comprehensive integration tests with real PostgreSQL
2026-06-12 11:05:40 +02:00
Patrick Plate 4f00872486 feat(sprint-3): Phase 6 — prevention officer capability
- PreventionOfficerService: limit enforcement, under-21 monitoring, monthly distribution tracking
- PUT /api/v1/staff/{id}/prevention-officer: assign/revoke with club limit check (409 on exceed)
- GET /api/v1/members/under-21: list under-21 members with quota data (prevention officer access)
- GET /api/v1/members/{id}/prevention-data: member prevention details (quota, distributions)
- PreventionOfficerLimitExceededException mapped to 409 in GlobalExceptionHandler
- StaffResponse extended with preventionOfficer field
- PreventionOfficerServiceTest: 10 unit tests covering assignment, revocation, limits, age calc
- MemberRepository.findByTenantIdAndUnder21True added
2026-06-12 10:20:20 +02:00
Patrick Plate 87568e5bfc feat(sprint-3): Phase 5 — member portal (session-based auth) 2026-06-12 10:11:58 +02:00
Patrick Plate 64927a3244 feat(sprint-3): Phase 4 — report controller + PDF/CSV generation
- Add report data models (MonthlyReport, MemberListReport, RecallReport)
- Implement ReportService with monthly aggregation, member list, recall batch tracing
- Add PdfReportGenerator using OpenPDF with minimal club branding
- Add PdfFooterHandler for timestamp + page numbers on every page
- Add CsvReportGenerator with UTF-8 BOM for Excel compatibility
- Create ReportController with 3 endpoints (monthly, members, recall)
  supporting JSON/PDF/CSV format negotiation via ?format= param
- Add DTO records (MonthlyReportResponse, MemberListResponse, RecallReportResponse)
- Extend DistributionRepository + MemberRepository with report queries
- Update Commons CSV from 1.11.0 to 1.12.0
- 10 unit tests (ReportServiceTest: 6, PdfReportGeneratorTest: 4) all passing

Endpoints:
  GET /api/v1/reports/monthly?month=YYYY-MM&format=json|pdf|csv
  GET /api/v1/reports/members?format=json|pdf|csv&status=ACTIVE
  GET /api/v1/reports/recall/{batchId}?format=json|pdf
2026-06-12 09:38:57 +02:00
Patrick Plate a267a90542 docs: add strategic differentiation plan 2026-06-12 09:25:50 +02:00
Patrick Plate 59b7486cec Merge sprint/3-staff-portal into main 2026-06-12 08:27:36 +02:00
Patrick Plate 752101c6c9 docs: add competitor & CSC market analysis PDF
- German market: Hanf-App, Cannanas, 420cloud feature comparison
- US market: Flowhub, BioTrack, Metrc, Dutchie design inspiration
- Switzerland: Cannavigia track & trace
- Spain: Historical CSC market (no software yet)
- Design recommendations derived from competitor analysis
- Differentiation strategy for CannaManage
2026-06-11 19:10:35 +02:00
Patrick Plate 302b7da8ca docs: add frontend UI shopping list PDF + OpenPDF/CSV deps in service POM
- Added OpenPDF 2.0.4 and Commons CSV 1.11.0 dependencies (Phase 4 prep)
- Generated frontend framework evaluation PDF with ranked templates and live demo links
2026-06-11 18:25:10 +02:00
Patrick Plate 6c66783b58 feat(sprint-3): Phase 3 — staff management + invite flow
- Step 3.1: Spring Boot Starter Mail dependency (api + service)
- Step 3.2: InviteToken JPA entity with 72h expiry
- Step 3.3: InviteTokenRepository with valid-token finder
- Step 3.4: EmailService (plain text invite email via JavaMailSender)
- Step 3.5: StaffService (CRUD + invite + email pattern validation + token revocation)
- Step 3.6: Staff DTOs (CreateStaffRequest, UpdateStaffRequest, StaffResponse)
- Step 3.7: SetPasswordRequest with password complexity (@Pattern: 1 digit + 1 special)
- Step 3.8: StaffController (6 endpoints, ADMIN-only via @PreAuthorize)
- Step 3.9: POST /api/v1/auth/set-password (public, generic error messages)
- Step 3.10: StaffTemplates (ausgabe, lager, vorstand predefined permission sets)
- Step 3.11: AuthService rejects inactive users with 'Account not activated'
- Step 3.12: Token revocation on permission change via revokeAllForUser()
- Step 3.13: invite-email.txt template (German, 72h expiry note)
- Step 3.14: Spring Mail config (Mailpit dev defaults, env var overrides)
- Step 3.15: Unit tests (StaffServiceTest, StaffControllerTest, EmailServiceTest)
- V5 Flyway migration for invite_tokens table

Security review findings incorporated:
- Password complexity: min 8 chars, 1 digit + 1 special char
- Generic 'invalid or expired token' error (no state leakage)
- SecureRandom 32-byte Base64 token generation
- Token values never logged
2026-06-11 18:03:12 +02:00
Patrick Plate 36deb72cf0 feat(sprint-3): Phase 2 — club settings controller 2026-06-11 16:56:44 +02:00
Patrick Plate 55d8434f35 feat(sprint-3): Phase 1 — staff permissions + token revocation
- StaffPermission enum (8 granular permissions)
- StaffAccount JPA entity with permissions collection
- RevokedToken entity for JWT blacklisting
- Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens)
- StaffAccountRepository + RevokedTokenRepository
- TokenRevocationService with Caffeine cache (60s TTL, 10k max)
- StaffPermissionChecker SpEL bean (@staffPermissions.has)
- PreventionOfficerChecker SpEL bean (@preventionOfficer.check)
- JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions
- JwtAuthFilter: token blacklist check via TokenRevocationService
- SecurityConfig: STAFF role added to endpoint matchers
- Controllers updated with @PreAuthorize for fine-grained access
- TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens)
- Caffeine dependency added to cannamanage-service
- Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
2026-06-11 16:45:21 +02:00
Patrick Plate 08b8e43ae8 docs: add comprehensive README with project overview, API docs, and sprint history 2026-06-11 13:35:28 +02:00
Patrick Plate a1ddec37da test(sprint-2): add integration tests for Auth + Compliance controllers
- AuthControllerIntegrationTest: 7 tests (login, refresh, error cases)
- ComplianceControllerIntegrationTest: 5 tests (quota, auth, 404)
- Fix Boot 4.0 @EntityScan relocation (boot.persistence.autoconfigure)
- Fix BCrypt 72-byte limit for refresh tokens (use SHA-256 instead)
- Configure H2 test DB with NON_KEYWORDS for reserved words (month/year)
2026-06-11 13:30:07 +02:00
Patrick Plate 2ede872d11 feat: Sprint 2 REST API layer — full implementation
- Fix critical Hibernate @Filter activation bug (TenantFilterAspect)
- Rename UserRole.ROLE_MANAGER → ROLE_STAFF (future-proofing)
- SecurityConfig: ADMIN + MEMBER roles only for Sprint 2
- AuthController: POST /auth/login + POST /auth/refresh with JWT
- AuthService: login, refresh token rotation, hashed refresh storage
- MemberController: CRUD (GET/POST/PUT /members)
- DistributionController: list + record distributions (CanG §26)
- StockController: batch management (GET/POST /stock/batches)
- ComplianceController: quota check (GET /compliance/quota/{id})
- OpenAPI/Swagger config with bearer-jwt security scheme
- GlobalExceptionHandler: full RFC 9457 problem+json coverage
- UserRepository: findByEmail, findByEmailAndTenantId
- Flyway V2: role rename migration + login indexes
- Testcontainers + test profile infrastructure (integration tests deferred)
- Parent POM: Testcontainers BOM, entity scan via properties

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

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