Compare commits

...

29 Commits

Author SHA1 Message Date
Patrick Plate 6f7352124d fix(security): hardening — rate limiting, CORS config, audit safety, CSP headers, validation
Deploy to Production / test (push) Failing after 10m44s
Deploy to Production / deploy (push) Has been skipped
- Fix 1: Login rate limiting (5 attempts/min/IP) on POST /api/v1/auth/login
  - New LoginRateLimiter (ConcurrentHashMap + @Scheduled reset every 60s)
  - HTTP 429 with German message on exceed
  - Client IP via X-Forwarded-For with proxy fallback
  - @EnableScheduling on CannaManageApplication

- Fix 2: CORS origins configurable via cannamanage.cors.allowed-origins env var
  - Defaults to localhost + docker frontend for dev
  - SecurityConfig reads with @Value, splits comma-separated list

- Fix 3: Audit JSON safety — replaced manual string concat with Jackson ObjectMapper
  - New AuditService.toMetadataJson(Map) helper
  - RetentionService and AuthorityExportService refactored

- Fix 4: Tomcat max-http-form-post-size=2MB prevents DoS via oversized payloads

- Fix 5: @Valid added to @RequestBody on 17+ endpoints across
  ComplianceRecordsController, FinanceController, ConsentController,
  StaffController, ComplianceDeadlineController, SubscriptionController,
  ForumController (admin + portal)

- Fix 6: Content-Security-Policy 'default-src \'self\'; frame-ancestors \'none\''
  + frameOptions(deny) on both API + portal filter chains
2026-06-15 19:29:32 +02:00
Patrick Plate 6319552675 fix(security): resolve 4 production blockers from final review
- IDOR (HIGH): DocumentController download/delete now verify document.clubId matches TenantContext; returns 403 on mismatch via new loadOwnedDocument() helper
- Path Traversal (HIGH): DocumentService.sanitizeFilename() strips path components, removes control/reserved chars, caps at 200 chars, falls back to UUID. Applied to uploadDocument() and archiveProtocol()
- JWT Dev Secret (HIGH): @PostConstruct guard in JwtService throws IllegalStateException if secret null/<32 chars/equals fail-loud marker. application.properties default replaced with CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP placeholder (env var CANNAMANAGE_SECURITY_JWT_SECRET set in docker-compose.yml; test profiles have their own valid secrets)
- SecurityConfig (MEDIUM): explicit /api/v1/documents/** matcher with hasAnyRole(ADMIN, STAFF, MEMBER) for defense-in-depth

Verified: Docker rebuild healthy, backend starts cleanly (JWT guard accepts env var), Playwright 203 pass (2 pre-existing login failures unrelated — dev compose profile has no seed users; admin@test.de only loaded via docker-compose.test.yml)
2026-06-15 19:11:35 +02:00
Patrick Plate 8c969c610f feat(sprint10): Phase 4+5 — Frontend import wizard + integration testing
Phase 4 — Frontend Import Wizard:
- bank-import.ts service: types (BankImportSession, BankTransaction,
  CsvColumnMapping, ImportSessionStatus, MatchStatus) + 12 React Query hooks
  (sessions, transactions, mappings, upload/confirm/skip/assign/complete)
- /finance/import page: 4-step wizard (Upload -> Map -> Review -> Confirm)
  * Drag-and-drop upload with bank format auto-detect (MT940/CAMT.053/CSV)
  * CSV column mapping editor (saves as reusable mapping)
  * Review table with color-coded MATCHED/SUGGESTED/UNMATCHED/CONFIRMED rows,
    confidence % badges, member-assign Combobox, skip/confirm/bulk-confirm
  * Completion summary + import history table with resume action
- de.json + en.json: full bankImport.* namespace (steps, upload, map, review,
  complete, history, status, sessionStatus, actions, errors)
- Navigation: Finanzen converted to nested submenu (Uebersicht + Import)

Phase 5 — Integration Testing:
- docker compose down -v + up -d --build (clean rebuild)
- Playwright e2e/sprint10-system-test.spec.ts: verifies /finance/import
  unauthenticated -> /login?callbackUrl=%2Ffinance%2Fimport (PASS)
- Backend health + frontend route registration verified

Bugfix bundled (blocked backend startup):
- PaymentRepository: countOverdueByClubId* queries referenced non-existent
  Payment.dueDate column (regression from Sprint 9 Phase 6, commit 57f418f).
  Switched to Payment.periodTo (the implicit due date for billing periods).
2026-06-15 18:33:40 +02:00
Patrick Plate 5defe42d67 feat(sprint10): Phase 3 — BankImportService + REST API
Implements the orchestrator and REST endpoints for the bank statement
import wizard (Sprint 10 Phase 3).

Service layer (cannamanage-service):
- BankImportService: upload → SHA-256 dedup → parse → match → persist
  in two transactional steps (file I/O outside @Transactional, persist
  in @Transactional helper). Methods: uploadAndParse, confirmMatch,
  confirmAllMatched (≥90% confidence), manualAssign, skipTransaction,
  completeSession, query helpers.
- GoBD §147 AO immutability guard: assertSessionMutable() rejects any
  mutation on COMPLETED/FAILED sessions with German error messages.
- Hard 5MB upload cap enforced before parsing.
- Audit events: BANK_IMPORT_STARTED / BANK_PAYMENT_CONFIRMED /
  BANK_IMPORT_COMPLETED. Uploader notified via NotificationService.

REST layer (cannamanage-api):
- BankImportController under /api/v1/finance/import/*:
  POST sessions (multipart), GET sessions/single/transactions(?status=),
  POST {id}/transactions/{txnId}/confirm|assign|skip,
  POST {id}/confirm-all, POST {id}/complete,
  GET/POST/DELETE csv-mappings.
- Permission: FINANCE_IMPORT with MANAGE_FINANCES fallback.
- Defence-in-depth tenant check on every path-parameter ID.

DTOs (cannamanage-api/dto/bankimport):
- ImportSessionResponse, TransactionResponse, ConfirmRequest,
  AssignRequest, SkipRequest, BulkConfirmResponse, CreateMappingRequest.

Persistence:
- V33__bank_import_file_hash.sql: adds file_hash VARCHAR(64) + unique
  partial index (club_id, file_hash) for duplicate-upload detection.
- BankImportSession.fileHash field, repository.existsByClubIdAndFileHash.

Configuration:
- application.properties: multipart enabled, max-file-size=5MB,
  max-request-size=6MB.

Build: mvn package -DskipTests  (cannamanage-api fat JAR 92MB).
2026-06-15 17:47:27 +02:00
Patrick Plate 527e9b1219 feat(sprint10): Phase 2 — Payment matching engine with confidence scoring 2026-06-15 17:30:28 +02:00
Patrick Plate 55110c95af feat(sprint10): Phase 1 — Data model + bank statement parsers (MT940, CAMT.053, CSV)
Implements the Sprint 10 Phase 1 foundation for the Smart Payment Import feature:

Domain layer:
- 3 new enums: BankFormat (MT940, CAMT053, CSV), ImportSessionStatus, MatchStatus
- StaffPermission.FINANCE_IMPORT
- AuditEventType: BANK_IMPORT_STARTED/COMPLETED/FAILED + BANK_PAYMENT_CONFIRMED
- NotificationType.BANK_IMPORT_COMPLETED
- ConsentType.BANK_DATA (DSGVO consent for IBAN storage)
- 3 new entities: BankImportSession, BankTransaction, CsvColumnMapping
- Member: + iban (VARCHAR 34) + ibanConsentDate
- MemberStatus.LEFT (semantic alias for RESIGNED, referenced by Sprint 9 RetentionService)

Persistence:
- V30__bank_import_sessions.sql
- V31__bank_transactions.sql
- V32__csv_column_mappings.sql (also adds iban + iban_consent_date to members)
- 3 Spring Data repositories

Parser infrastructure (cannamanage-service/src/main/java/de/cannamanage/service/bankimport):
- BankStatementParser interface (Strategy pattern, Spring-injected list)
- ParsedTransaction + ParseResult records
- BankStatementParseException (parse errors)
- Mt940Parser: custom state machine, CENTURY_BOUNDARY=70 for YY→YYYY, proprietary
  header tolerance (skips lines before first :20: for StarMoney/WISO/Hibiscus wrappers)
- Camt053Parser: StAX streaming with XXE hardening (IS_SUPPORTING_EXTERNAL_ENTITIES,
  SUPPORT_DTD, IS_REPLACING_ENTITY_REFERENCES all false); supports camt.053.001.02
  and camt.053.001.08 namespaces
- CsvBankParser: Apache Commons CSV with configurable columns per club; German number
  format ("1.234,56"); ISO-8859-1 default encoding
- BankStatementParserService: filename-extension hint + content probe; throws
  UnrecognizedFormatException when no parser claims the file

Build verified via Docker (cannamanage-api:sprint10-phase1).

Sprint 9 fix (incidental, required to compile):
- Added MemberStatus.LEFT (Sprint 9 RetentionService referenced it but the enum
  value was missing)
- MemberListRegistryGenerator: added LEFT to formatStatus() switch (mapped to
  "Ausgetreten", same as RESIGNED)

Sprint 10 docs: analysis, plan, plan-review, testplan.

Co-Authored-By: Lumen <lumen@cannamanage.de>
2026-06-15 17:21:55 +02:00
Patrick Plate 57f418f7c9 feat(sprint9): Phase 6 — Compliance dashboard, RetentionService, testing
Backend:
- ComplianceDashboardService: traffic-light status per ComplianceArea
  (KCANG/FINANCE/DSGVO/VEREIN) based on deadlines, payments, board positions
- RetentionService: scheduled anonymization of expired member data (KCanG §24,
  5 years), with dry-run preview and retention report endpoints
- ComplianceDeadlineSeeder: seeds 5 standard recurring deadlines on club creation
- ComplianceDashboardController: GET /api/v1/compliance/dashboard,
  GET /retention, POST /retention/preview
- Repository additions: countOverdue, countActive board positions/members

Frontend:
- /compliance page with traffic-light status cards per area
- Overdue deadlines section (highlighted red) with 'days overdue' badges
- Upcoming deadlines with 'days until due' badges and 'Complete' buttons
- Retention info cards (KCanG §24: 5y, AO §147: 10y, DSGVO: 2y)
- Navigation: added 'Compliance-Status' to sidebar under Compliance group
- compliance-dashboard.ts service with mock data for dev mode

Build verified: pnpm build passes clean.
2026-06-15 14:12:01 +02:00
Patrick Plate 87511e0485 feat(sprint9): Phase 5 — Berichtszentrale, sidebar reorg, dashboard enhancement
- Sidebar: reorganized into 4 collapsible groups (Betrieb, Kommunikation, Verwaltung, Compliance)
- Berichtszentrale: new /reports-center page with report cards grouped by category (Finance, KCanG, DSGVO, Admin), format selector, date range pickers, Behörden-Export dialog with password protection
- Dashboard: added Outstanding Payments and Monthly Income KPI cards, Upcoming Events widget, Latest Announcements widget, conditional alert cards
- Pricing: fixed mobile overflow at 375px viewport on comparison table
- Frontend service: new compliance-reports.ts with React Query hooks for report generation, authority export, and download
- i18n: added reportsCenter.* and dashboard widget keys to de.json and en.json
2026-06-15 13:45:48 +02:00
Patrick Plate c3722ab726 feat(sprint9): Phase 4 — DSGVO templates + Verein admin reports
Implements 7 report generators for Phase 4:

DSGVO templates:
- VvtReportGenerator: Art. 30 DSGVO Verarbeitungsverzeichnis (PDF)
- TomReportGenerator: Art. 32 DSGVO Technisch-organisatorische Maßnahmen (PDF)
- DsfaReportGenerator: Art. 35 DSGVO Datenschutz-Folgenabschätzung (PDF)
- LoeschkonzeptGenerator: Löschkonzept with retention rules (PDF + JSON)
- BreachNotificationGenerator: Art. 33/34 DSGVO 72h breach notification (PDF)

Verein administration:
- MemberListRegistryGenerator: §67 BGB Mitgliederliste for Amtsgericht (PDF)
- BoardChangeGenerator: §67 BGB Vorstandsänderung notification (PDF)

Also adds:
- BreachReportParameters record for breach notification input
- MemberRepository.findByClubIdOrderByLastNameAscFirstNameAsc()
2026-06-15 13:22:46 +02:00
Patrick Plate 3ca231dc9c feat(sprint9): Phase 3 — KCanG compliance reports + Behörden-Export
Implemented 6 KCanG compliance report generators and the hero
Behörden-Export feature:

- AnnualAuthorityReportGenerator: Multi-section §22 KCanG annual report
  (9 sections: Vereinsdaten, Mitgliederstatistik, Anbauübersicht,
  Weitergabe-Statistik, Bestandsführung, Vernichtung, Transport,
  Prävention, Jugendschutz)
- DistributionLogGenerator: §19(4) distribution log (PDF+CSV,
  anonymized member data per DSGVO)
- DestructionProtocolGenerator: §22 destruction protocol with
  signature lines and sequential numbering
- TransportCertificateGenerator: §22(4) transport documentation
- BestandsfuehrungGenerator: Stock flow report (PDF+CSV) with
  per-batch breakdown
- PreventionActivityReportGenerator: §23 prevention activities

Authority Export (Behörden-Export) — THE HERO FEATURE:
- AuthorityExportService: Streaming ZIP generation via ZipOutputStream
- Re-authentication required (password re-entry + BCrypt verification)
- Mandatory reason field stored in audit trail
- Rate limited: max 1 export per hour per tenant
- ZIP contains all compliance PDFs + anonymized member JSON + manifest
- Memory-efficient: PDFs generated and streamed sequentially

Endpoint: POST /api/v1/reports/authority-export
Request: { year, password, reason }
Response: StreamingResponseBody (application/zip)

Also enhanced repositories:
- DestructionRecordRepository: date-range queries + sum aggregation
- TransportRecordRepository: date-range queries
2026-06-15 12:53:12 +02:00
Patrick Plate a29c38756c feat(sprint9): Phase 2 — Financial report generators (EÜR, Kassenbuch, Beitragsbescheinigung)
Implements Sprint 9 Phase 2 financial report generators:

- MemberReportParameters: new parameter record for per-member reports
- EurReportGenerator: Einnahmen-Überschuss-Rechnung (§4(3) EStG)
  - PDF: professional layout with income/expense sections, monthly breakdown
  - CSV: semicolon-delimited, ISO-8859-1, German decimal format
  - JSON: ELSTER-compatible structure for Steuerberater import
- KassenbuchExportGenerator: GoBD-compliant cash book export
  - PDF: landscape A4, running balance, sequential Beleg-Nr
  - CSV: GoBD-compliant format with injection prevention
  - Includes opening balance calculation and period totals
- BeitragsbescheinigungGenerator: membership fee confirmation per member
  - PDF: club letterhead, payment table, signature lines
  - For member tax purposes (Sonderausgaben)
- ReportGeneratorService: added getAvailableTypes() method
- ReportController: added GET /api/v1/reports/types endpoint

All generators are @Service beans auto-discovered by ReportGeneratorService.
Docker build verified green.
2026-06-15 12:22:53 +02:00
Patrick Plate 26a77dd269 feat(sprint9): Phase 1 — Data model + ReportGenerator infrastructure
- 7 new enums: ReportType, ExportFormat, DestructionMethod, TransportStatus,
  ComplianceArea, ComplianceStatus, RetentionCategory
- Extended: StaffPermission (+3), AuditEventType (+5), NotificationType (+2)
- Flyway V23-V29: destruction_records, transport_records, propagation_sources,
  prevention_activities, generated_reports, compliance_deadlines, distribution THC/CBD
- 6 new JPA entities extending AbstractTenantEntity
- 6 new Spring Data repositories with tenant-scoped queries
- ReportGenerator<T> interface + ReportGeneratorService (auto-discovery, format dispatch)
- ComplianceRecordsController (CRUD for destruction/transport/propagation/prevention)
- ComplianceDeadlineController (create, list, complete, overdue)
- DateRangeReportParameters record for report generation
2026-06-15 12:01:06 +02:00
Patrick Plate 2d83c4b8a1 fix: resolve Sprint 8 compilation issues, Docker build green 2026-06-15 09:57:32 +02:00
Patrick Plate 61b0cd92be feat(sprint8): Phase 5+6 — Integration, schedulers, tier enforcement, testing
Phase 5 — Integration:
- PaymentReminderScheduler: monthly cron at 9am, sends PAYMENT_REMINDER
  and PAYMENT_OVERDUE (30+ days) notifications, audit logged
- BoardTermScheduler: daily cron at 8am, sends BOARD_TERM_EXPIRING
  notification 30 days before term end (1-day window for single delivery)
- Assembly protocol auto-archive: completeAssembly() generates PDF via
  AssemblyProtocolService and stores it in DocumentService.archiveProtocol()
- PlanTierService: Sprint 8 limits added (Kassenbuch 3mo starter, 1 MV/year
  starter, 100MB/1GB/unlimited document storage)
- Notification wiring: PAYMENT_RECEIVED sent to member on recordPayment(),
  sendToAllMembers() added to NotificationService for assembly invitations
- Seed data: 2 fee schedules (Regular €30, Reduced €15), 4 fee assignments,
  3 sample payments, 2 board positions, 2 board members

Phase 6 — Testing infrastructure:
- V22 migration: protocol_document_id on assemblies table
- Schedulers disabled in test profile (cannamanage.schedulers.enabled=false)
- Scheduler property configurable via SCHEDULERS_ENABLED env var

Additional fixes (pre-existing Phase 4 issues):
- AssemblyProtocolService: fixed Document import ambiguity (OpenPDF vs entity)
- AuditService: added convenience overloads for UUID actorId/clubId
- ReceiptPdfService: fixed getReceiptNumber/getMemberNumber/getPaymentDate
- StaffPermissionChecker: added getUserId/getClubId/getTenantId helpers
- FinanceService: added getPaymentById, exportLedgerCsv methods
2026-06-15 09:22:49 +02:00
Patrick Plate e4698827ee feat(sprint8): Phase 4 — Dokumentenarchiv + Vorstandsverwaltung
Backend:
- V20 migration: documents table with category, access_level, file storage
- V21 migration: board_positions + board_members with term tracking
- Document entity + DocumentCategory/DocumentAccessLevel enums
- BoardPosition + BoardMember entities
- Extended AuditEventType (DOCUMENT_UPLOADED/DELETED, BOARD_MEMBER_ELECTED/REMOVED)
- Extended StaffPermission (MANAGE_DOCUMENTS)
- Extended NotificationType (BOARD_TERM_EXPIRING)
- DocumentService: upload, list, download, delete, storage usage
- BoardService: positions CRUD, elect/remove members, current/history
- DocumentController: multipart upload, filtered list, download, delete, portal
- BoardController: positions, elect, remove, current board, history, portal

Frontend:
- documents.ts + board.ts service layers
- Admin /documents page: grouped by category, upload dialog, filter, download/delete
- Admin /board page: current board cards, position management, elect member dialog
- Navigation: added Dokumente + Vorstand to sidebar
- i18n: documents.* + board.* keys in de.json + en.json
2026-06-15 08:53:38 +02:00
Patrick Plate b22702317a feat(sprint8): Phase 3 — Mitgliederversammlung (assemblies, voting, protocol PDF)
Backend:
- V19 migration: assemblies, assembly_agenda_items, assembly_attendees, assembly_votes, assembly_vote_records
- Enums: AssemblyType, AssemblyStatus, AgendaItemType, VoteType, VoteDecision, VoteResult
- Entities: Assembly, AssemblyAgendaItem, AssemblyAttendee, AssemblyVote, AssemblyVoteRecord
- Repositories: Assembly, AgendaItem, Attendee, Vote, VoteRecord
- AssemblyService: full lifecycle (create, invite, start, attend, vote, quorum, complete)
- AssemblyProtocolService: OpenPDF protocol generation (§147 AO compliant)
- AssemblyController: admin + portal endpoints
- Extended: AuditEventType, NotificationType, StaffPermission

Frontend:
- Assembly service with full API client and TypeScript types
- Admin assemblies list page with create dialog (agenda builder)
- Admin assembly detail page (quorum, agenda, votes, attendees)
- Navigation: Versammlungen with Gavel icon (after Finanzen)

Legal basis: §32-§40 BGB (Mitgliederversammlung), §147 AO (retention)
2026-06-15 08:39:10 +02:00
Patrick Plate 3211ade5be feat(sprint8): Phase 2 — Treasury frontend + PDF receipts
Backend:
- ReceiptPdfService: Generates Quittung PDF per payment (OpenPDF, A4)
- FinancialReportService: Annual financial report PDF (Jahresabschluss)
- FinanceController: Added receipt download, annual report, CSV export endpoints
- Portal receipt download with member ownership verification

Frontend:
- src/services/finance.ts: Complete React Query service (types, hooks, mutations)
- /finance: Dashboard with KPI cards, recent transactions, outstanding members
- /finance/payments: Payment list with filtering, void, receipt download
- /finance/kassenbuch: Kassenbuch ledger with date range, CSV export
- /finance/fee-schedules: Fee schedule CRUD with interval management
- /finance/reports: Annual report PDF download
- /portal/finance: Member self-service balance + payment history + receipts

Navigation & i18n:
- Added Finanzen (Wallet icon) to admin sidebar
- Portal finance page for member payments
- Comprehensive de.json + en.json finance keys (~100 translations)
2026-06-15 08:24:43 +02:00
Patrick Plate 721503b231 feat(sprint8): Phase 1 — Treasury backend (fee schedules, payments, Kassenbuch)
- Extend StaffPermission with MANAGE_FINANCES, VIEW_FINANCES
- Extend AuditEventType with PAYMENT_RECORDED, PAYMENT_VOIDED, FEE_SCHEDULE_CREATED, FEE_SCHEDULE_UPDATED, EXPENSE_RECORDED
- Extend NotificationType with PAYMENT_REMINDER, PAYMENT_OVERDUE, PAYMENT_RECEIVED
- New enums: PaymentMethod, PaymentStatus, TransactionType, FeeInterval, ExpenseCategory
- V18 Flyway migration: fee_schedules, member_fee_assignments, payments, ledger_entries tables
- Entities: FeeSchedule, MemberFeeAssignment, Payment, LedgerEntry
- Repositories with financial queries (balance, outstanding, period sums)
- FinanceService: fee schedule CRUD, record/void payments, expenses, Kassenbuch, summaries
- FinanceController: 14 admin endpoints + 2 portal self-service endpoints
- LedgerEntry is append-only per §147 AO (no update/delete)
- All amounts in cents (Integer) to avoid floating-point precision issues
2026-06-15 08:00:04 +02:00
Patrick Plate cfb38e8fc6 test: authenticated admin E2E suite + accessibility + visual regression baselines
- Global setup: authenticates as admin, saves storageState for reuse
- playwright.config.ts: 3 projects (setup, authenticated, unauthenticated)
- authenticated-admin.spec.ts: 16 admin pages tested with real auth session
- accessibility.spec.ts: axe-core scans on all admin, public, and portal pages
- visual-regression.spec.ts: dark mode baselines for key pages (toHaveScreenshot)
- @axe-core/playwright added as devDependency
- .gitignore updated: excludes .auth/ and test-results/

Full suite: 262 tests passing (setup:1, authenticated:52, unauthenticated:209)
2026-06-13 22:30:29 +02:00
Patrick Plate aabde17532 feat(sprint7): Phase 4 — Integration (SMTP, tier enforcement, WebSocket)
Phase 4 implementation:
- 4.1 IONOS SMTP email configuration (production + docker profiles)
- 4.2 Portal navigation update (info board, events, forum links)
- 4.3 Tier enforcement: PlanTierService (forum=Pro+, info board limits)
- 4.4 WebSocket real-time updates (WebSocketEventPublisher)
- 4.5 EmailService: notification, event reminder, info board templates + rate limiting
- 4.6 Enterprise custom FROM: CustomMailDomain entity, DNS verification, controller

New files:
- PlanTierService: tier checks for forum/info board/enterprise features
- NotificationDispatchService: EMAIL channel dispatch via preferences
- WebSocketEventPublisher: STOMP topic push for forum/info board/events
- CustomMailDomainService: DNS TXT record verification for custom FROM
- MailSettingsController: Enterprise custom domain API endpoints
- CustomMailDomain entity + repository
- V16 migration: email dispatch index
- V17 migration: custom_mail_domains table
- Frontend: use-forum-subscription + use-info-board-subscription hooks
- Portal navbar: added info board, events, forum navigation items
- i18n: added portal nav translations (de + en)

Also fixed pre-existing Phase 2.5/3 compilation issues:
- Member entity: added userId field
- AuditService: added convenience overloads (logEvent, 4-param log)
- AuditEventType: added INFO_BOARD_POST_UPDATED, INFO_BOARD_POST_DELETED
- QuotaViolationCode: added TIER_UPGRADE_REQUIRED
- StaffPermissionChecker: added requirePermission(UserDetails, ...)
- TenantContext: added getCurrentTenantId() alias
- MemberRepository: added findByUserId, findByClubId, findAllByClubId
- EmailServiceTest: updated for new constructor signature
2026-06-13 20:51:10 +02:00
Patrick Plate a539ed9eb2 feat(sprint7): Phase 3 — Forum MVP
- Flyway V15: forum_topics, forum_replies, forum_reactions, forum_reports tables
- Enums: ForumTargetType, ReactionType, ReportStatus
- Extended AuditEventType with FORUM_REPLY_CREATED, FORUM_REPORT_REVIEWED
- Entities: ForumTopic, ForumReply, ForumReaction, ForumReport
- Repositories: ForumTopicRepository, ForumReplyRepository, ForumReactionRepository, ForumReportRepository
- ForumService: full CRUD, moderation (lock/pin/delete), 60-min edit window,
  toggle reactions, content reporting, notifications on new topics/replies
- ForumController: admin + portal endpoints (topics, replies, reactions, reports, moderation)
- Frontend: forum.ts service with React Query hooks (admin + portal)
- Frontend: Admin forum page with topic list, moderation actions (lock/pin/delete)
- Frontend: Portal forum page with topic list, reply thread, reactions, report
- Navigation: added Forum with MessageSquare icon
- i18n: forum.* keys in de.json and en.json
2026-06-13 20:31:17 +02:00
Patrick Plate 05fd679c4d feat(sprint7): Phase 2.5 — Club Event Calendar
- Flyway V14: club_events + event_rsvps tables with reminder_sent tracking
- Enums: EventType, RsvpStatus, RecurrenceRule + extend AuditEventType/NotificationType
- Entities: ClubEvent (extends AbstractTenantEntity), EventRsvp (unique event+member)
- Repositories: ClubEventRepository, EventRsvpRepository with date-range and status queries
- EventService: CRUD, RSVP with maxAttendees enforcement (409 if full), iCal RFC 5545 generation, recurring event virtual expansion, notifications on create/cancel, auto-post to Info Board
- EventReminderScheduler: hourly check, 24h reminder to ACCEPTED/MAYBE attendees
- EventController: admin CRUD (MANAGE_INFO_BOARD permission), portal upcoming events, RSVP endpoint, iCal download (text/calendar), attendee list
- Frontend: events.ts service (React Query hooks matching apiClient pattern), admin calendar page (month grid with event dots, create dialog, event cards), portal events page (RSVP buttons, capacity display)
- Navigation: added Kalender with Calendar icon
- i18n: events.* keys in de.json and en.json
- UI: added @radix-ui/react-switch + Switch component
2026-06-13 20:16:56 +02:00
Patrick Plate 4aa27cd4f9 feat(sprint7): Phase 2 — Info Board (Schwarzes Brett)
Backend:
- V13 Flyway migration: info_board_posts, post_attachments, post_read_status tables
- InfoBoardPost entity with category enum (EVENT, RULE, GENERAL, MAINTENANCE)
- PostAttachment entity (table created, upload deferred to later)
- PostReadStatus entity with composite key (post_id, member_id)
- InfoBoardPostRepository with paginated queries + unread count
- InfoBoardService: CRUD, pin/archive, mark-as-read, notification dispatch
- InfoBoardController: admin CRUD + portal read/unread endpoints
- Integration with NotificationService and AuditService

Frontend:
- info-board.ts service with React Query hooks for all endpoints
- Admin Info Board page at /info-board with create dialog, filters, pin/archive/delete
- Navigation: added 'Schwarzes Brett' to admin sidebar
- i18n: added infoBoard.* keys to de.json and en.json
- Fixed pre-existing prettier issues in notification-compose.ts
- Fixed BufferSource type issue in push-subscription.ts
2026-06-13 19:41:20 +02:00
Patrick Plate 706a6e257b feat(sprint7): Phase 1 — notifications enhancement + push infrastructure
Phase 1 (Notification Enhancement):
- Extended NotificationType enum (ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, FORUM_MENTION)
- Extended StaffPermission enum (SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, MODERATE_FORUM)
- Extended AuditEventType with Sprint 7 events
- Flyway V11: notification_sends + notification_send_recipients tables
- NotificationSend + NotificationSendRecipient entities
- NotificationSendRepository + NotificationSendRecipientRepository
- Extended NotificationService with sendBroadcast() and sendToSelected()
- NotificationComposeController (POST /compose, GET /sends)
- ComposeNotificationRequest DTO

Phase 1B (Push Infrastructure):
- Flyway V12: device_tokens + notification_preferences tables
- DeviceToken entity + DevicePlatform enum
- NotificationPreference entity + NotificationChannel enum
- DeviceTokenRepository + NotificationPreferenceRepository
- DeviceRegistrationService (register/unregister/list devices, max 10 per user)
- NotificationPreferenceService (get/create defaults, update, IN_APP always on)
- NotificationDispatchService (multi-channel fan-out: WebSocket, Web Push, FCM, Email)
- WebPushSender (VAPID-based, simplified for MVP)
- FcmPushSender (graceful degradation if not configured)
- PushPayload DTO
- DeviceRegistrationController (POST/GET/DELETE /devices, GET /vapid-key)
- NotificationPreferenceController (GET/PUT /preferences)
- ConsentType extended (NOTIFICATION_PUSH, NOTIFICATION_EMAIL)
- TargetType enum (ALL, SELECTED)

Frontend:
- Updated sw.js with push event handler + notification click handler
- push-subscription.ts (subscribeToPush, unsubscribe, permission helpers)
- notification-compose.ts service (compose, sends, devices, preferences APIs)
- i18n keys (de.json + en.json) for compose, preferences, push, devices

Configuration:
- application-docker.properties: VAPID + FCM push config properties
- MemberRepository: added findAllActiveUserIds() for broadcast
2026-06-13 19:25:19 +02:00
Patrick Plate 329b7abb18 fix: replace shadboard.svg with Cannabis leaf icon from lucide-react
Uses the same Cannabis icon as the login page for consistent branding.
Removed unused next/image imports.
2026-06-13 17:53:02 +02:00
Patrick Plate 7fe8d4f707 fix: rebrand Shadboard → CannaManage, staff permissions UX
- Sidebar, footer, bottom-bar-header: replaced 'Shadboard' with 'CannaManage'
- Footer: removed 'Designed by Qualiora' attribution
- Staff permissions: single-column layout, alphabetically sorted by label
- Edit permissions dialog: useEffect syncs state when dialog opens
  (fixes pre-fill not working when controlled externally)
2026-06-13 17:45:31 +02:00
Patrick Plate 9aaf771469 fix: consent banner fails open on API error (500/403)
The consent check endpoint (/consent/check) returns 500 via the
proxy when the backend returns 403 (missing JWT forwarding).
Previously this caused the banner to show permanently since
consentCheck was undefined. Now isError = true hides the banner
(fail-open strategy — don't block users when backend is unavailable).
2026-06-13 17:30:19 +02:00
Patrick Plate 27690a836e fix: consent banner dismiss on decline + short viewport layout
Bug 1: Clicking 'Ablehnen' now properly dismisses the dialog by calling
the delete account mutation and signing out (previously it redirected to
/settings/privacy which re-rendered the banner in a loop).

Bug 2: Restructured the dialog layout with flex-col + overflow-y-auto on
the content area only. Header and action buttons are pinned (shrink-0)
so they're always accessible on short viewports. Added max-h constraint
with min() to cap at 600px or 90vh.
2026-06-13 17:11:20 +02:00
Patrick Plate cd77eb6448 fix: correct BCrypt hash in seed SQL and fix Playwright test selectors
Root cause: The BCrypt hash in init.sql was the famous Stack Overflow
hash of 'password' (a0),
not the hash of 'test123' as documented.

Also fixed three test issues in system-test.spec.ts:
1. waitForURL regex /dashboard|\//' matched any URL with '/' (instant resolve)
   → replaced with predicate that waits for URL to not contain /login
2. Reports locator used invalid Playwright selector syntax
   → fixed to use proper :has-text() selector for 'Berichte'
3. Navigation test used 'nav a' but app uses shadcn data-sidebar
   → broadened selector to include [data-sidebar] a
4. Console error filter excluded only favicon/maps/hydration
   → also exclude 'Failed to load resource' and 'MISSING_MESSAGE'
   (pre-existing issues from incomplete API endpoints)
2026-06-13 17:01:56 +02:00
357 changed files with 48084 additions and 356 deletions
@@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* CannaManage Spring Boot application entry point.
@@ -17,6 +18,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = "de.cannamanage")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
@EnableScheduling
public class CannaManageApplication {
public static void main(String[] args) {
@@ -0,0 +1,330 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.AssemblyProtocolService;
import de.cannamanage.service.AssemblyService;
import de.cannamanage.service.AssemblyService.AgendaItemInput;
import de.cannamanage.service.repository.MemberRepository;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.*;
/**
* REST controller for general assembly (Mitgliederversammlung) management.
* Admin endpoints require MANAGE_ASSEMBLIES permission.
* Portal endpoints allow members to view assemblies they're invited to.
*/
@RestController
@RequestMapping("/api/v1")
public class AssemblyController {
private final AssemblyService assemblyService;
private final AssemblyProtocolService protocolService;
private final StaffPermissionChecker permissionChecker;
private final MemberRepository memberRepository;
public AssemblyController(AssemblyService assemblyService,
AssemblyProtocolService protocolService,
StaffPermissionChecker permissionChecker,
MemberRepository memberRepository) {
this.assemblyService = assemblyService;
this.protocolService = protocolService;
this.permissionChecker = permissionChecker;
this.memberRepository = memberRepository;
}
// === Admin Endpoints ===
@PostMapping("/assemblies")
public ResponseEntity<AssemblyResponse> createAssembly(
@Valid @RequestBody CreateAssemblyRequest request,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
var clubId = permissionChecker.getClubId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var agendaItems = request.agendaItems() != null
? request.agendaItems().stream()
.map(a -> new AgendaItemInput(a.title(), a.description(), a.itemType()))
.toList()
: List.<AgendaItemInput>of();
var assembly = assemblyService.createAssembly(clubId, request.title(), request.assemblyType(),
request.scheduledAt(), request.location(), request.quorumRequired(), userId, agendaItems);
return ResponseEntity.ok(toResponse(assembly));
}
@GetMapping("/assemblies")
public ResponseEntity<List<AssemblyResponse>> listAssemblies(@AuthenticationPrincipal UserDetails user) {
var clubId = permissionChecker.getClubId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assemblies = assemblyService.getAssemblies(clubId);
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
}
@GetMapping("/assemblies/{id}")
public ResponseEntity<AssemblyDetailResponse> getAssemblyDetail(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.getAssemblyDetail(id);
var agendaItems = assemblyService.getAgendaItems(id);
var attendees = assemblyService.getAttendees(id);
var votes = assemblyService.getVotes(id);
var quorum = assemblyService.calculateQuorum(id);
return ResponseEntity.ok(new AssemblyDetailResponse(
toResponse(assembly),
agendaItems.stream().map(this::toAgendaResponse).toList(),
attendees.stream().map(this::toAttendeeResponse).toList(),
votes.stream().map(this::toVoteResponse).toList(),
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
));
}
@PutMapping("/assemblies/{id}")
public ResponseEntity<AssemblyResponse> updateAssembly(
@PathVariable UUID id,
@Valid @RequestBody UpdateAssemblyRequest request,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.updateAssembly(id, request.title(), request.scheduledAt(),
request.location(), request.quorumRequired());
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/invite")
public ResponseEntity<AssemblyResponse> sendInvitations(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.sendInvitations(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/cancel")
public ResponseEntity<AssemblyResponse> cancelAssembly(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.cancelAssembly(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/start")
public ResponseEntity<AssemblyResponse> startAssembly(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.startAssembly(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/complete")
public ResponseEntity<AssemblyResponse> completeAssembly(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var assembly = assemblyService.completeAssembly(id, userId);
return ResponseEntity.ok(toResponse(assembly));
}
@PostMapping("/assemblies/{id}/attendees")
public ResponseEntity<AttendeeResponse> checkInAttendee(
@PathVariable UUID id,
@Valid @RequestBody CheckInRequest request,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var attendee = assemblyService.checkInAttendee(id, request.memberId(), request.proxyForMemberId());
return ResponseEntity.ok(toAttendeeResponse(attendee));
}
@GetMapping("/assemblies/{id}/attendees")
public ResponseEntity<List<AttendeeResponse>> listAttendees(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var attendees = assemblyService.getAttendees(id);
return ResponseEntity.ok(attendees.stream().map(this::toAttendeeResponse).toList());
}
@PostMapping("/assemblies/{id}/votes")
public ResponseEntity<VoteResponse> createVote(
@PathVariable UUID id,
@Valid @RequestBody CreateVoteRequest request,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var vote = assemblyService.createVote(id, request.agendaItemId(), request.title(),
request.description(), request.voteType());
return ResponseEntity.ok(toVoteResponse(vote));
}
@PostMapping("/assemblies/votes/{voteId}/cast")
public ResponseEntity<VoteResponse> castVote(
@PathVariable UUID voteId,
@Valid @RequestBody CastVoteRequest request,
@AuthenticationPrincipal UserDetails user) {
var userId = permissionChecker.getUserId(user);
var vote = assemblyService.castVote(voteId, request.memberId(), request.decision(), userId);
return ResponseEntity.ok(toVoteResponse(vote));
}
@PostMapping("/assemblies/votes/{voteId}/close")
public ResponseEntity<VoteResponse> closeVote(
@PathVariable UUID voteId,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
var vote = assemblyService.closeVote(voteId);
return ResponseEntity.ok(toVoteResponse(vote));
}
@GetMapping("/assemblies/{id}/protocol")
public ResponseEntity<byte[]> downloadProtocol(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
byte[] pdf = protocolService.generateProtocol(id);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=protokoll-" + id + ".pdf")
.body(pdf);
}
// === Portal Endpoints ===
@GetMapping("/portal/assemblies")
public ResponseEntity<List<AssemblyResponse>> portalListAssemblies(
@AuthenticationPrincipal UserDetails user) {
var tenantId = permissionChecker.getTenantId(user);
var assemblies = assemblyService.getUpcomingAssemblies(tenantId);
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
}
@GetMapping("/portal/assemblies/{id}")
public ResponseEntity<AssemblyDetailResponse> portalGetAssemblyDetail(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
var assembly = assemblyService.getAssemblyDetail(id);
var agendaItems = assemblyService.getAgendaItems(id);
var attendees = assemblyService.getAttendees(id);
var votes = assemblyService.getVotes(id);
var quorum = assemblyService.calculateQuorum(id);
return ResponseEntity.ok(new AssemblyDetailResponse(
toResponse(assembly),
agendaItems.stream().map(this::toAgendaResponse).toList(),
attendees.stream().map(this::toAttendeeResponse).toList(),
votes.stream().map(this::toVoteResponse).toList(),
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
));
}
// === DTOs ===
record CreateAssemblyRequest(
@NotBlank String title,
@NotNull AssemblyType assemblyType,
@NotNull Instant scheduledAt,
String location,
Integer quorumRequired,
List<AgendaItemRequest> agendaItems
) {}
record AgendaItemRequest(
@NotBlank String title,
String description,
@NotNull AgendaItemType itemType
) {}
record UpdateAssemblyRequest(
String title,
Instant scheduledAt,
String location,
Integer quorumRequired
) {}
record CheckInRequest(@NotNull UUID memberId, UUID proxyForMemberId) {}
record CreateVoteRequest(
@NotNull UUID agendaItemId,
@NotBlank String title,
String description,
@NotNull VoteType voteType
) {}
record CastVoteRequest(@NotNull UUID memberId, @NotNull VoteDecision decision) {}
record AssemblyResponse(
UUID id, String title, AssemblyType assemblyType, Instant scheduledAt,
String location, AssemblyStatus status, Instant invitationSentAt,
Integer quorumRequired, Instant openedAt, Instant closedAt, Instant createdAt
) {}
record AssemblyDetailResponse(
AssemblyResponse assembly,
List<AgendaItemResponse> agendaItems,
List<AttendeeResponse> attendees,
List<VoteResponse> votes,
QuorumResponse quorum
) {}
record AgendaItemResponse(UUID id, int position, String title, String description, AgendaItemType itemType) {}
record AttendeeResponse(UUID id, UUID memberId, Instant checkedInAt, UUID proxyForMemberId) {}
record VoteResponse(UUID id, UUID agendaItemId, String title, String description, VoteType voteType,
int yesCount, int noCount, int abstainCount, VoteResult result, Instant votedAt) {}
record QuorumResponse(long attendees, long totalMembers, int required, boolean quorumMet) {}
// === Mappers ===
private AssemblyResponse toResponse(Assembly a) {
return new AssemblyResponse(a.getId(), a.getTitle(), a.getAssemblyType(), a.getScheduledAt(),
a.getLocation(), a.getStatus(), a.getInvitationSentAt(), a.getQuorumRequired(),
a.getOpenedAt(), a.getClosedAt(), a.getCreatedAt());
}
private AgendaItemResponse toAgendaResponse(AssemblyAgendaItem i) {
return new AgendaItemResponse(i.getId(), i.getPosition(), i.getTitle(), i.getDescription(), i.getItemType());
}
private AttendeeResponse toAttendeeResponse(AssemblyAttendee a) {
return new AttendeeResponse(a.getId(), a.getMemberId(), a.getCheckedInAt(), a.getProxyForMemberId());
}
private VoteResponse toVoteResponse(AssemblyVote v) {
return new VoteResponse(v.getId(), v.getAgendaItemId(), v.getTitle(), v.getDescription(),
v.getVoteType(), v.getYesCount(), v.getNoCount(), v.getAbstainCount(),
v.getResult(), v.getVotedAt());
}
}
@@ -4,11 +4,14 @@ import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.dto.auth.SetPasswordRequest;
import de.cannamanage.api.security.LoginRateLimiter;
import de.cannamanage.api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -24,10 +27,19 @@ import java.util.Map;
public class AuthController {
private final AuthService authService;
private final LoginRateLimiter loginRateLimiter;
@PostMapping("/login")
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
String ip = resolveClientIp(httpRequest);
if (!loginRateLimiter.tryAcquire(ip)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of(
"error", "rate_limited",
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
));
}
LoginResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
@@ -46,4 +58,17 @@ public class AuthController {
authService.setPassword(request);
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
}
/**
* Returns the originating client IP, honouring X-Forwarded-For when present
* (so reverse-proxy / load-balancer setups still get per-client rate limits).
*/
private String resolveClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
int comma = xff.indexOf(',');
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,314 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.bankimport.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.BankImportSession;
import de.cannamanage.domain.entity.BankTransaction;
import de.cannamanage.domain.entity.CsvColumnMapping;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MatchStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.bankimport.BankImportService;
import de.cannamanage.service.repository.CsvColumnMappingRepository;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — REST endpoints for the bank statement import wizard.
*
* <p>All endpoints live under {@code /api/v1/finance/import/*}. Access requires
* either {@link StaffPermission#FINANCE_IMPORT} or {@link StaffPermission#MANAGE_FINANCES}
* (ADMIN role always passes). Tenant scoping is implicit via {@link TenantContext}.
*
* <p>Endpoint overview:
* <ul>
* <li>{@code POST /finance/import/sessions} — multipart upload + parse (optional {@code mappingId} query)</li>
* <li>{@code GET /finance/import/sessions} — list all sessions for the tenant</li>
* <li>{@code GET /finance/import/sessions/{id}} — single session detail</li>
* <li>{@code GET /finance/import/sessions/{id}/transactions} — transactions, optional {@code ?status=} filter</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/confirm} — create payment from match</li>
* <li>{@code POST /finance/import/sessions/{id}/confirm-all} — bulk-confirm high-confidence matches</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/assign} — manual member assignment</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/skip} — drop transaction with reason</li>
* <li>{@code POST /finance/import/sessions/{id}/complete} — seal session (GoBD immutability)</li>
* <li>{@code GET /finance/import/csv-mappings} — list saved CSV mapping templates</li>
* <li>{@code POST /finance/import/csv-mappings} — create a CSV mapping template</li>
* <li>{@code DELETE /finance/import/csv-mappings/{id}} — remove a CSV mapping template</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/v1")
public class BankImportController {
private final BankImportService bankImportService;
private final StaffPermissionChecker permissionChecker;
private final CsvColumnMappingRepository mappingRepository;
public BankImportController(BankImportService bankImportService,
StaffPermissionChecker permissionChecker,
CsvColumnMappingRepository mappingRepository) {
this.bankImportService = bankImportService;
this.permissionChecker = permissionChecker;
this.mappingRepository = mappingRepository;
}
// === Sessions ===
/**
* Upload a bank statement file and parse it. Returns the persisted session with
* matching results so the frontend can immediately render the review table.
*/
@PostMapping(value = "/finance/import/sessions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportSessionResponse> uploadSession(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "mappingId", required = false) UUID mappingId,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
CsvColumnMapping mapping = null;
if (mappingId != null) {
mapping = mappingRepository.findById(mappingId)
.filter(m -> clubId.equals(m.getClubId()))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
}
BankImportSession session = bankImportService.uploadAndParse(clubId, userId, file, mapping);
return ResponseEntity.status(HttpStatus.CREATED).body(ImportSessionResponse.from(session));
}
/** List all import sessions for the current tenant, newest first. */
@GetMapping("/finance/import/sessions")
public ResponseEntity<List<ImportSessionResponse>> listSessions(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
List<ImportSessionResponse> sessions = bankImportService.getSessions(clubId).stream()
.map(ImportSessionResponse::from)
.toList();
return ResponseEntity.ok(sessions);
}
/** Detail view of a single session. */
@GetMapping("/finance/import/sessions/{id}")
public ResponseEntity<ImportSessionResponse> getSession(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
BankImportSession session = bankImportService.getSession(id);
ensureSameTenant(session.getClubId());
return ResponseEntity.ok(ImportSessionResponse.from(session));
}
/**
* Transactions belonging to a session, optionally filtered by match status.
* Drives the review table (typically called with {@code ?status=MATCHED} then
* with no filter for the full audit listing).
*/
@GetMapping("/finance/import/sessions/{id}/transactions")
public ResponseEntity<List<TransactionResponse>> listTransactions(
@PathVariable UUID id,
@RequestParam(value = "status", required = false) MatchStatus status,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
BankImportSession session = bankImportService.getSession(id);
ensureSameTenant(session.getClubId());
List<TransactionResponse> txns = bankImportService.getTransactions(id, status).stream()
.map(TransactionResponse::from)
.toList();
return ResponseEntity.ok(txns);
}
/** Confirm a single matched transaction → creates a {@code Payment} via {@code FinanceService}. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/confirm")
public ResponseEntity<TransactionResponse> confirmMatch(
@PathVariable UUID id,
@PathVariable UUID txnId,
@Valid @RequestBody ConfirmRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankTransaction txn = bankImportService.confirmMatch(id, txnId, request.memberId(), userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Bulk-confirm every {@code MATCHED} transaction with confidence ≥ 90 in the session. */
@PostMapping("/finance/import/sessions/{id}/confirm-all")
public ResponseEntity<BulkConfirmResponse> confirmAll(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankImportService.BulkConfirmResult result = bankImportService.confirmAllMatched(id, userId);
return ResponseEntity.ok(BulkConfirmResponse.from(result));
}
/** Manual assignment for unmatched transactions — sets {@code MATCHED} 100% but does NOT create a Payment yet. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/assign")
public ResponseEntity<TransactionResponse> assignManually(
@PathVariable UUID id,
@PathVariable UUID txnId,
@Valid @RequestBody AssignRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankTransaction txn = bankImportService.manualAssign(id, txnId, request.memberId(), userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Skip a transaction (e.g. refund, fee, non-member deposit) — stored with reason for audit trail. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/skip")
public ResponseEntity<TransactionResponse> skipTransaction(
@PathVariable UUID id,
@PathVariable UUID txnId,
@RequestBody(required = false) SkipRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
String reason = request != null ? request.reason() : null;
BankTransaction txn = bankImportService.skipTransaction(id, txnId, reason, userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Seal the session — sets status {@code COMPLETED}, after which no further mutations are permitted (GoBD §147 AO). */
@PostMapping("/finance/import/sessions/{id}/complete")
public ResponseEntity<ImportSessionResponse> completeSession(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankImportSession session = bankImportService.completeSession(id, userId);
return ResponseEntity.ok(ImportSessionResponse.from(session));
}
// === CSV Column Mappings ===
/** List saved CSV mapping templates for the current tenant. */
@GetMapping("/finance/import/csv-mappings")
public ResponseEntity<List<CsvColumnMapping>> listMappings(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(mappingRepository.findByClubId(clubId));
}
/**
* Create a new CSV mapping template. If {@code isDefault} is true, the existing
* default mapping (if any) is cleared so only one template stays default per club.
*/
@PostMapping("/finance/import/csv-mappings")
public ResponseEntity<CsvColumnMapping> createMapping(@Valid @RequestBody CreateMappingRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
CsvColumnMapping mapping = new CsvColumnMapping();
mapping.setClubId(clubId);
mapping.setName(request.name());
mapping.setDateColumn(request.dateColumn());
mapping.setAmountColumn(request.amountColumn());
mapping.setReferenceColumn(request.referenceColumn());
mapping.setCounterpartyColumn(request.counterpartyColumn());
mapping.setIbanColumn(request.ibanColumn());
if (request.delimiter() != null) {
mapping.setDelimiter(request.delimiter());
}
if (request.dateFormat() != null) {
mapping.setDateFormat(request.dateFormat());
}
if (request.decimalSeparator() != null) {
mapping.setDecimalSeparator(request.decimalSeparator());
}
if (request.skipHeaderRows() != null) {
mapping.setSkipHeaderRows(request.skipHeaderRows());
}
if (request.encoding() != null) {
mapping.setEncoding(request.encoding());
}
boolean wantsDefault = Boolean.TRUE.equals(request.isDefault());
mapping.setIsDefault(wantsDefault);
if (wantsDefault) {
Optional<CsvColumnMapping> existingDefault = mappingRepository.findByClubIdAndIsDefaultTrue(clubId);
existingDefault.ifPresent(existing -> {
existing.setIsDefault(false);
mappingRepository.save(existing);
});
}
CsvColumnMapping saved = mappingRepository.save(mapping);
log.info("CSV mapping created: id={} name={} club={}", saved.getId(), saved.getName(), clubId);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
/** Delete a CSV mapping template — only the owner tenant may delete. */
@DeleteMapping("/finance/import/csv-mappings/{id}")
public ResponseEntity<Void> deleteMapping(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
CsvColumnMapping mapping = mappingRepository.findById(id)
.filter(m -> clubId.equals(m.getClubId()))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
mappingRepository.delete(mapping);
log.info("CSV mapping deleted: id={} club={}", id, clubId);
return ResponseEntity.noContent().build();
}
// === Helpers ===
/**
* Permission gate that accepts either {@link StaffPermission#FINANCE_IMPORT} or
* {@link StaffPermission#MANAGE_FINANCES}. ADMIN passes both automatically inside
* {@link StaffPermissionChecker}.
*/
private void requireImportPermission(UserDetails principal) {
try {
permissionChecker.requirePermission(principal, StaffPermission.FINANCE_IMPORT);
} catch (AccessDeniedException denied) {
// Fall back to MANAGE_FINANCES — finance admins are implicitly authorized.
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
}
}
/**
* Defence-in-depth tenant check on top of Hibernate {@code @Filter} —
* ensures path-parameter IDs from one tenant cannot reach another tenant's session.
*/
private void ensureSameTenant(UUID sessionClubId) {
UUID currentTenant = TenantContext.getCurrentTenant();
if (sessionClubId == null || !sessionClubId.equals(currentTenant)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Import-Session nicht gefunden.");
}
}
}
@@ -0,0 +1,100 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.BoardMember;
import de.cannamanage.domain.entity.BoardPosition;
import de.cannamanage.service.BoardService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
public class BoardController {
private final BoardService boardService;
public BoardController(BoardService boardService) {
this.boardService = boardService;
}
// --- Positions ---
@PostMapping("/board/positions")
public ResponseEntity<BoardPosition> createPosition(
@RequestParam UUID clubId,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
String description = (String) body.get("description");
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : 0;
BoardPosition pos = boardService.createPosition(clubId, title, description, sortOrder);
return ResponseEntity.ok(pos);
}
@GetMapping("/board/positions")
public ResponseEntity<List<BoardPosition>> getPositions(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getPositions(clubId));
}
@PutMapping("/board/positions/{id}")
public ResponseEntity<BoardPosition> updatePosition(
@PathVariable UUID id,
@RequestBody Map<String, Object> body) {
String title = (String) body.get("title");
String description = (String) body.get("description");
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : null;
Boolean isActive = body.containsKey("isActive") ? (Boolean) body.get("isActive") : null;
BoardPosition pos = boardService.updatePosition(id, title, description, sortOrder, isActive);
return ResponseEntity.ok(pos);
}
// --- Board Members ---
@PostMapping("/board/members")
public ResponseEntity<BoardMember> electBoardMember(
@RequestParam UUID clubId,
@RequestBody Map<String, Object> body,
Principal principal) {
UUID positionId = UUID.fromString((String) body.get("positionId"));
UUID memberId = UUID.fromString((String) body.get("memberId"));
LocalDate electedAt = LocalDate.parse((String) body.get("electedAt"));
LocalDate termStart = LocalDate.parse((String) body.get("termStart"));
LocalDate termEnd = body.get("termEnd") != null ? LocalDate.parse((String) body.get("termEnd")) : null;
UUID assemblyId = body.get("assemblyId") != null ? UUID.fromString((String) body.get("assemblyId")) : null;
UUID userId = UUID.fromString(principal.getName());
BoardMember bm = boardService.electBoardMember(clubId, positionId, memberId,
electedAt, termStart, termEnd, assemblyId, userId);
return ResponseEntity.ok(bm);
}
@GetMapping("/board")
public ResponseEntity<List<BoardMember>> getCurrentBoard(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
}
@GetMapping("/board/history")
public ResponseEntity<List<BoardMember>> getBoardHistory(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getBoardHistory(clubId));
}
@DeleteMapping("/board/members/{id}")
public ResponseEntity<Void> removeBoardMember(
@PathVariable UUID id,
@RequestParam UUID clubId,
Principal principal) {
UUID userId = UUID.fromString(principal.getName());
boardService.removeBoardMember(id, userId, clubId);
return ResponseEntity.noContent().build();
}
// Portal endpoint
@GetMapping("/portal/board")
public ResponseEntity<List<BoardMember>> getPortalBoard(@RequestParam UUID clubId) {
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
}
}
@@ -0,0 +1,73 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.domain.enums.ComplianceStatus;
import de.cannamanage.service.ComplianceDashboardService;
import de.cannamanage.service.RetentionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Compliance Dashboard controller.
* Provides traffic-light compliance status, upcoming/overdue deadlines,
* and retention management endpoints.
*/
@RestController
@RequestMapping("/api/v1/compliance/dashboard")
@RequiredArgsConstructor
@Tag(name = "Compliance Dashboard", description = "Compliance status overview and retention management")
public class ComplianceDashboardController {
private final ComplianceDashboardService dashboardService;
private final RetentionService retentionService;
@GetMapping
@Operation(summary = "Get compliance dashboard status",
description = "Returns traffic-light status per compliance area + upcoming and overdue deadlines")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<ComplianceDashboardResponse> getDashboard(
@RequestParam(defaultValue = "30") int upcomingDays) {
UUID clubId = TenantContext.getCurrentTenant();
Map<ComplianceArea, ComplianceStatus> statusMap = dashboardService.getComplianceStatus(clubId);
List<ComplianceDeadline> upcoming = dashboardService.getUpcomingDeadlines(clubId, upcomingDays);
List<ComplianceDeadline> overdue = dashboardService.getOverdueDeadlines(clubId);
return ResponseEntity.ok(new ComplianceDashboardResponse(statusMap, upcoming, overdue));
}
@GetMapping("/retention")
@Operation(summary = "Get retention report",
description = "Shows what was deleted, what will be deleted, and retention schedule")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<RetentionService.RetentionReport> getRetentionReport() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(retentionService.getRetentionReport(clubId));
}
@PostMapping("/retention/preview")
@Operation(summary = "Preview retention actions (dry-run)",
description = "Shows what WOULD be affected by retention processing without making changes")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<RetentionService.RetentionPreview> previewRetention() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(retentionService.previewRetention(clubId));
}
public record ComplianceDashboardResponse(
Map<ComplianceArea, ComplianceStatus> status,
List<ComplianceDeadline> upcomingDeadlines,
List<ComplianceDeadline> overdueDeadlines
) {}
}
@@ -0,0 +1,98 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.ComplianceDeadline;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.ComplianceArea;
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* REST controller for compliance deadline management.
* Powers the compliance dashboard traffic-light system.
*/
@RestController
@RequestMapping("/api/v1/compliance/deadlines")
@RequiredArgsConstructor
@Tag(name = "Compliance Deadlines", description = "Manage compliance deadlines and due dates")
public class ComplianceDeadlineController {
private final ComplianceDeadlineRepository deadlineRepository;
@GetMapping
@Operation(summary = "List all deadlines (upcoming + overdue)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<ComplianceDeadline>> listDeadlines() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(deadlineRepository.findByTenantIdOrderByDueDateAsc(tenantId));
}
@PostMapping
@Operation(summary = "Create a new compliance deadline")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> createDeadline(@Valid @RequestBody CreateDeadlineRequest request) {
ComplianceDeadline deadline = new ComplianceDeadline();
deadline.setClubId(request.clubId());
deadline.setArea(request.area());
deadline.setTitle(request.title());
deadline.setDescription(request.description());
deadline.setDueDate(request.dueDate());
deadline.setIsRecurring(request.isRecurring() != null ? request.isRecurring() : false);
deadline.setRecurrenceRule(request.recurrenceRule());
return ResponseEntity.ok(deadlineRepository.save(deadline));
}
@PostMapping("/{id}/complete")
@Operation(summary = "Mark a deadline as complete")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<ComplianceDeadline> completeDeadline(
@PathVariable UUID id,
@Valid @RequestBody CompleteDeadlineRequest request) {
ComplianceDeadline deadline = deadlineRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Deadline not found: " + id));
deadline.setCompletedAt(Instant.now());
deadline.setCompletedBy(request.completedBy());
return ResponseEntity.ok(deadlineRepository.save(deadline));
}
@GetMapping("/overdue")
@Operation(summary = "List overdue (incomplete, past due date) deadlines")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<ComplianceDeadline>> listOverdue() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(
deadlineRepository.findByTenantIdAndCompletedAtIsNullOrderByDueDateAsc(tenantId)
.stream()
.filter(d -> d.getDueDate().isBefore(LocalDate.now()))
.toList()
);
}
public record CreateDeadlineRequest(
UUID clubId,
ComplianceArea area,
String title,
String description,
LocalDate dueDate,
Boolean isRecurring,
String recurrenceRule
) {}
public record CompleteDeadlineRequest(
UUID completedBy
) {}
}
@@ -0,0 +1,191 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.DestructionMethod;
import de.cannamanage.domain.enums.TransportStatus;
import de.cannamanage.service.repository.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* REST controller for KCanG §22 compliance records:
* destruction, transport, propagation sources, and prevention activities.
*/
@RestController
@RequestMapping("/api/v1/compliance")
@RequiredArgsConstructor
@Tag(name = "Compliance Records", description = "KCanG §22 record keeping for destruction, transport, propagation & prevention")
public class ComplianceRecordsController {
private final DestructionRecordRepository destructionRecordRepository;
private final TransportRecordRepository transportRecordRepository;
private final PropagationSourceRepository propagationSourceRepository;
private final PreventionActivityRepository preventionActivityRepository;
// === Destruction Records ===
@PostMapping("/destruction-records")
@Operation(summary = "Record a cannabis destruction event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<DestructionRecord> recordDestruction(@Valid @RequestBody CreateDestructionRequest request) {
DestructionRecord record = new DestructionRecord();
record.setClubId(request.clubId());
record.setBatchId(request.batchId());
record.setAmountGrams(request.amountGrams());
record.setDestructionMethod(request.destructionMethod());
record.setDescription(request.description());
record.setDestroyedAt(request.destroyedAt() != null ? request.destroyedAt() : Instant.now());
record.setWitnessedBy(request.witnessedBy());
record.setWitnessName(request.witnessName());
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(destructionRecordRepository.save(record));
}
@GetMapping("/destruction-records")
@Operation(summary = "List destruction records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<DestructionRecord>> listDestructionRecords() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(destructionRecordRepository.findByTenantIdOrderByDestroyedAtDesc(tenantId));
}
// === Transport Records ===
@PostMapping("/transport-records")
@Operation(summary = "Record a cannabis transport event")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<TransportRecord> recordTransport(@Valid @RequestBody CreateTransportRequest request) {
TransportRecord record = new TransportRecord();
record.setClubId(request.clubId());
record.setDescription(request.description());
record.setTransportDate(request.transportDate());
record.setFromLocation(request.fromLocation());
record.setToLocation(request.toLocation());
record.setCarrierName(request.carrierName());
record.setAmountGrams(request.amountGrams());
record.setBatchId(request.batchId());
record.setStatus(request.status() != null ? request.status() : TransportStatus.PLANNED);
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(transportRecordRepository.save(record));
}
@GetMapping("/transport-records")
@Operation(summary = "List transport records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<TransportRecord>> listTransportRecords() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(transportRecordRepository.findByTenantIdOrderByTransportDateDesc(tenantId));
}
// === Propagation Sources ===
@PostMapping("/propagation-sources")
@Operation(summary = "Record a propagation source (seed/cutting receipt)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PropagationSource> recordPropagationSource(@Valid @RequestBody CreatePropagationSourceRequest request) {
PropagationSource record = new PropagationSource();
record.setClubId(request.clubId());
record.setSourceType(request.sourceType());
record.setSupplier(request.supplier());
record.setQuantity(request.quantity());
record.setStrainId(request.strainId());
record.setReceivedAt(request.receivedAt());
record.setDocumentationReference(request.documentationReference());
record.setRecordedBy(request.recordedBy());
return ResponseEntity.ok(propagationSourceRepository.save(record));
}
@GetMapping("/propagation-sources")
@Operation(summary = "List propagation sources for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<PropagationSource>> listPropagationSources() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(propagationSourceRepository.findByTenantIdOrderByReceivedAtDesc(tenantId));
}
// === Prevention Activities ===
@PostMapping("/prevention-activities")
@Operation(summary = "Record a prevention/education activity per KCanG §23")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
public ResponseEntity<PreventionActivity> recordPreventionActivity(@Valid @RequestBody CreatePreventionActivityRequest request) {
PreventionActivity record = new PreventionActivity();
record.setClubId(request.clubId());
record.setActivityDate(request.activityDate());
record.setTitle(request.title());
record.setDescription(request.description());
record.setParticipantsCount(request.participantsCount());
record.setOfficerId(request.officerId());
return ResponseEntity.ok(preventionActivityRepository.save(record));
}
@GetMapping("/prevention-activities")
@Operation(summary = "List prevention activities for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
public ResponseEntity<List<PreventionActivity>> listPreventionActivities() {
UUID tenantId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(preventionActivityRepository.findByTenantIdOrderByActivityDateDesc(tenantId));
}
// === Request DTOs (inner records) ===
public record CreateDestructionRequest(
UUID clubId,
UUID batchId,
BigDecimal amountGrams,
DestructionMethod destructionMethod,
String description,
Instant destroyedAt,
UUID witnessedBy,
String witnessName,
UUID recordedBy
) {}
public record CreateTransportRequest(
UUID clubId,
String description,
LocalDate transportDate,
String fromLocation,
String toLocation,
String carrierName,
BigDecimal amountGrams,
UUID batchId,
TransportStatus status,
UUID recordedBy
) {}
public record CreatePropagationSourceRequest(
UUID clubId,
String sourceType,
String supplier,
Integer quantity,
UUID strainId,
LocalDate receivedAt,
String documentationReference,
UUID recordedBy
) {}
public record CreatePreventionActivityRequest(
UUID clubId,
LocalDate activityDate,
String title,
String description,
Integer participantsCount,
UUID officerId
) {}
}
@@ -9,6 +9,7 @@ import de.cannamanage.service.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -43,7 +44,7 @@ public class ConsentController {
@PostMapping
@Operation(summary = "Grant consent")
public ResponseEntity<ConsentResponse> grantConsent(
@RequestBody GrantConsentRequest request,
@Valid @RequestBody GrantConsentRequest request,
Authentication auth,
HttpServletRequest httpRequest) {
UUID userId = resolveUserId(auth);
@@ -0,0 +1,95 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.RegisterDeviceRequest;
import de.cannamanage.domain.entity.DeviceToken;
import de.cannamanage.service.DeviceRegistrationService;
import de.cannamanage.service.push.WebPushSender;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Device token registration endpoints for push notifications.
* Any authenticated user can register/unregister their devices.
*/
@RestController
@RequestMapping("/api/v1/notifications/devices")
@RequiredArgsConstructor
public class DeviceRegistrationController {
private final DeviceRegistrationService deviceRegistrationService;
private final WebPushSender webPushSender;
/**
* Register a device token for push notifications.
*/
@PostMapping
public ResponseEntity<Map<String, Object>> registerDevice(
@Valid @RequestBody RegisterDeviceRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
try {
DeviceToken device = deviceRegistrationService.registerDevice(
userId, request.platform(), request.token(), request.deviceName());
return ResponseEntity.ok(Map.of(
"id", device.getId(),
"platform", device.getPlatform().name(),
"deviceName", device.getDeviceName() != null ? device.getDeviceName() : "",
"createdAt", device.getCreatedAt().toString()
));
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
/**
* List user's registered devices.
*/
@GetMapping
public ResponseEntity<?> listDevices(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
var devices = deviceRegistrationService.getDevices(userId);
var items = devices.stream().map(d -> Map.of(
"id", (Object) d.getId(),
"platform", d.getPlatform().name(),
"deviceName", d.getDeviceName() != null ? d.getDeviceName() : "",
"lastUsedAt", d.getLastUsedAt() != null ? d.getLastUsedAt().toString() : "",
"createdAt", d.getCreatedAt().toString()
)).toList();
return ResponseEntity.ok(Map.of("devices", items));
}
/**
* Unregister a device.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> unregisterDevice(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
deviceRegistrationService.unregisterDevice(id, userId);
return ResponseEntity.noContent().build();
}
/**
* Get the VAPID public key for Web Push subscription on the frontend.
*/
@GetMapping("/vapid-key")
public ResponseEntity<Map<String, String>> getVapidKey() {
return ResponseEntity.ok(Map.of(
"publicKey", webPushSender.getPublicKey(),
"configured", String.valueOf(webPushSender.isConfigured())
));
}
}
@@ -0,0 +1,109 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.Document;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import de.cannamanage.service.DocumentService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
public class DocumentController {
private final DocumentService documentService;
public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}
/**
* Verify the requested document belongs to the caller's current tenant (club).
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
* just by guessing or enumerating the document UUID.
*/
private Document loadOwnedDocument(UUID documentId) {
Document doc = documentService.getDocument(documentId);
UUID currentTenantId = TenantContext.getCurrentTenant();
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
}
return doc;
}
@PostMapping("/documents/upload")
public ResponseEntity<Document> uploadDocument(
@RequestParam UUID clubId,
@RequestParam String title,
@RequestParam DocumentCategory category,
@RequestParam(defaultValue = "ALL_MEMBERS") DocumentAccessLevel accessLevel,
@RequestParam(required = false) String description,
@RequestParam("file") MultipartFile file,
Principal principal) throws IOException {
UUID userId = UUID.fromString(principal.getName());
Document doc = documentService.uploadDocument(clubId, title, category, accessLevel, description, file, userId);
return ResponseEntity.ok(doc);
}
@GetMapping("/documents")
public ResponseEntity<List<Document>> listDocuments(
@RequestParam UUID clubId,
@RequestParam(required = false) DocumentCategory category,
@RequestParam(required = false) DocumentAccessLevel accessLevel) {
List<Document> docs = documentService.listDocuments(clubId, category, accessLevel);
return ResponseEntity.ok(docs);
}
@GetMapping("/documents/{id}/download")
public ResponseEntity<byte[]> downloadDocument(@PathVariable UUID id) throws IOException {
Document doc = loadOwnedDocument(id);
byte[] content = documentService.downloadDocument(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"")
.contentType(MediaType.parseMediaType(doc.getContentType()))
.body(content);
}
@DeleteMapping("/documents/{id}")
public ResponseEntity<Void> deleteDocument(
@PathVariable UUID id,
@RequestParam UUID clubId,
Principal principal) throws IOException {
// Verify the document belongs to the caller's tenant before honouring the delete.
// Also reject if the supplied clubId param disagrees with the authenticated tenant.
Document doc = loadOwnedDocument(id);
UUID currentTenantId = TenantContext.getCurrentTenant();
if (!clubId.equals(currentTenantId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
}
UUID userId = UUID.fromString(principal.getName());
documentService.deleteDocument(id, userId, doc.getClubId());
return ResponseEntity.noContent().build();
}
@GetMapping("/documents/usage")
public ResponseEntity<Map<String, Long>> getStorageUsage(@RequestParam UUID clubId) {
long usage = documentService.getStorageUsage(clubId);
return ResponseEntity.ok(Map.of("bytesUsed", usage));
}
// Portal endpoint — only ALL_MEMBERS documents
@GetMapping("/portal/documents")
public ResponseEntity<List<Document>> getPortalDocuments(@RequestParam UUID clubId) {
List<Document> docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS);
return ResponseEntity.ok(docs);
}
}
@@ -0,0 +1,224 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.event.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.ClubEvent;
import de.cannamanage.domain.entity.EventRsvp;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.RsvpStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.EventService;
import de.cannamanage.service.repository.EventRsvpRepository;
import de.cannamanage.service.repository.MemberRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.*;
/**
* REST controller for club event management.
* Admin endpoints require MANAGE_INFO_BOARD permission.
* Portal endpoints are accessible to authenticated members.
*/
@RestController
@RequestMapping("/api/v1")
public class EventController {
private final EventService eventService;
private final EventRsvpRepository rsvpRepository;
private final MemberRepository memberRepository;
private final StaffPermissionChecker permissionChecker;
public EventController(EventService eventService,
EventRsvpRepository rsvpRepository,
MemberRepository memberRepository,
StaffPermissionChecker permissionChecker) {
this.eventService = eventService;
this.rsvpRepository = rsvpRepository;
this.memberRepository = memberRepository;
this.permissionChecker = permissionChecker;
}
// === Admin endpoints ===
@PostMapping("/events")
public ResponseEntity<EventResponse> createEvent(@Valid @RequestBody CreateEventRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
boolean postToInfoBoard = request.postToInfoBoard() == null || request.postToInfoBoard();
ClubEvent event = eventService.createEvent(
clubId, request.title(), request.description(), request.eventType(),
request.startAt(), request.endAt(), request.location(), request.maxAttendees(),
request.recurring(), request.recurrenceRule(), request.recurrenceEndDate(),
userId, postToInfoBoard
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(event, null));
}
@GetMapping("/events")
public ResponseEntity<List<EventResponse>> listEvents(
@RequestParam Instant from,
@RequestParam Instant to,
@AuthenticationPrincipal UserDetails principal) {
List<ClubEvent> events = eventService.listEvents(from, to);
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
List<EventResponse> responses = events.stream()
.map(e -> toResponse(e, memberId))
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/events/{id}")
public ResponseEntity<EventResponse> getEvent(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
ClubEvent event = eventService.getEvent(id);
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
return ResponseEntity.ok(toResponse(event, memberId));
}
@PutMapping("/events/{id}")
public ResponseEntity<EventResponse> updateEvent(@PathVariable UUID id,
@Valid @RequestBody UpdateEventRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
ClubEvent event = eventService.updateEvent(id, request.title(), request.description(),
request.eventType(), request.startAt(), request.endAt(), request.location(),
request.maxAttendees(), request.recurring(), request.recurrenceRule(),
request.recurrenceEndDate());
return ResponseEntity.ok(toResponse(event, null));
}
@DeleteMapping("/events/{id}")
public ResponseEntity<Void> cancelEvent(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
eventService.cancelEvent(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/events/{id}/rsvp")
public ResponseEntity<?> rsvp(@PathVariable UUID id,
@Valid @RequestBody RsvpRequest request,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
if (memberId == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
EventRsvp rsvp = eventService.rsvp(id, memberId, request.status());
return ResponseEntity.ok(Map.of(
"status", rsvp.getStatus(),
"respondedAt", rsvp.getRespondedAt()
));
} catch (IllegalStateException e) {
if ("EVENT_FULL".equals(e.getMessage())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "EVENT_FULL", "message", "Veranstaltung ist ausgebucht"));
}
throw e;
}
}
@GetMapping("/events/{id}/attendees")
public ResponseEntity<List<RsvpResponse>> getAttendees(@PathVariable UUID id) {
List<EventRsvp> rsvps = eventService.getAttendees(id);
List<RsvpResponse> responses = rsvps.stream()
.map(r -> {
String memberName = memberRepository.findById(r.getMemberId())
.map(m -> m.getFirstName() + " " + m.getLastName())
.orElse("Unknown");
return new RsvpResponse(r.getMemberId(), memberName, r.getStatus(), r.getRespondedAt());
})
.toList();
return ResponseEntity.ok(responses);
}
@GetMapping("/events/{id}/ical")
public ResponseEntity<String> downloadIcal(@PathVariable UUID id) {
String ical = eventService.generateIcal(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"event.ics\"")
.contentType(MediaType.parseMediaType("text/calendar"))
.body(ical);
}
// === Portal endpoints ===
@GetMapping("/portal/events")
public ResponseEntity<List<EventResponse>> portalEvents(@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID memberId = getMemberIdForUser(userId);
List<ClubEvent> events = eventService.listUpcomingEvents(10);
List<EventResponse> responses = events.stream()
.map(e -> toResponse(e, memberId))
.toList();
return ResponseEntity.ok(responses);
}
@PostMapping("/portal/events/{id}/rsvp")
public ResponseEntity<?> portalRsvp(@PathVariable UUID id,
@Valid @RequestBody RsvpRequest request,
@AuthenticationPrincipal UserDetails principal) {
return rsvp(id, request, principal);
}
// === Helpers ===
private EventResponse toResponse(ClubEvent event, UUID memberId) {
Map<RsvpStatus, Long> counts = new HashMap<>();
RsvpStatus myStatus = null;
if (event.getId() != null) {
try {
counts = eventService.getAttendeeCounts(event.getId());
if (memberId != null) {
myStatus = rsvpRepository.findByEventIdAndMemberId(event.getId(), memberId)
.map(EventRsvp::getStatus)
.orElse(null);
}
} catch (Exception e) {
// Virtual expanded events may not have a DB id
}
}
return new EventResponse(
event.getId(),
event.getTitle(),
event.getDescription(),
event.getEventType(),
event.getStartAt(),
event.getEndAt(),
event.getLocation(),
event.getMaxAttendees(),
event.isRecurring(),
event.getRecurrenceRule(),
event.getRecurrenceEndDate(),
event.getCreatedBy(),
event.getCreatedAt(),
counts,
myStatus
);
}
private UUID getMemberIdForUser(UUID userId) {
return memberRepository.findByUserId(userId)
.map(m -> m.getId())
.orElse(null);
}
}
@@ -0,0 +1,365 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.finance.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.PaymentStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.FinanceService;
import de.cannamanage.service.FinancialReportService;
import de.cannamanage.service.ReceiptPdfService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import de.cannamanage.service.repository.PaymentRepository;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.*;
/**
* REST controller for club treasury management.
* Admin endpoints require MANAGE_FINANCES or VIEW_FINANCES permission.
* Portal endpoints allow members to view their own payment history and balance.
*/
@RestController
@RequestMapping("/api/v1")
public class FinanceController {
private final FinanceService financeService;
private final StaffPermissionChecker permissionChecker;
private final MemberRepository memberRepository;
private final ReceiptPdfService receiptPdfService;
private final FinancialReportService financialReportService;
private final ClubRepository clubRepository;
public FinanceController(FinanceService financeService,
StaffPermissionChecker permissionChecker,
MemberRepository memberRepository,
ReceiptPdfService receiptPdfService,
FinancialReportService financialReportService,
ClubRepository clubRepository) {
this.financeService = financeService;
this.permissionChecker = permissionChecker;
this.memberRepository = memberRepository;
this.receiptPdfService = receiptPdfService;
this.financialReportService = financialReportService;
this.clubRepository = clubRepository;
}
// === Fee Schedules ===
@PostMapping("/finance/fee-schedules")
public ResponseEntity<FeeSchedule> createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
FeeSchedule schedule = financeService.createFeeSchedule(
clubId, request.name(), request.amountCents(), request.interval(),
request.isDefault() != null && request.isDefault()
);
return ResponseEntity.status(HttpStatus.CREATED).body(schedule);
}
@GetMapping("/finance/fee-schedules")
public ResponseEntity<List<FeeSchedule>> listFeeSchedules(@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getActiveFeeSchedules(clubId));
}
@PutMapping("/finance/fee-schedules/{id}")
public ResponseEntity<FeeSchedule> updateFeeSchedule(@PathVariable UUID id,
@Valid @RequestBody UpdateFeeScheduleRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
FeeSchedule updated = financeService.updateFeeSchedule(
id, request.name(), request.amountCents(), request.interval(), request.isDefault()
);
return ResponseEntity.ok(updated);
}
@PostMapping("/finance/fee-schedules/{id}/deactivate")
public ResponseEntity<Void> deactivateFeeSchedule(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
financeService.deactivateFeeSchedule(id);
return ResponseEntity.noContent().build();
}
// === Fee Assignment ===
@PostMapping("/finance/members/{memberId}/assign-fee")
public ResponseEntity<MemberFeeAssignment> assignFeeSchedule(@PathVariable UUID memberId,
@Valid @RequestBody AssignFeeRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
MemberFeeAssignment assignment = financeService.assignFeeSchedule(
memberId, clubId, request.feeScheduleId(), request.validFrom()
);
return ResponseEntity.status(HttpStatus.CREATED).body(assignment);
}
// === Payments ===
@PostMapping("/finance/payments")
public ResponseEntity<Payment> recordPayment(@Valid @RequestBody RecordPaymentRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
Payment payment = financeService.recordPayment(
clubId, request.memberId(), request.amountCents(), request.paymentMethod(),
request.periodFrom(), request.periodTo(), request.reference(), request.notes(), userId
);
return ResponseEntity.status(HttpStatus.CREATED).body(payment);
}
@GetMapping("/finance/payments")
public ResponseEntity<Page<Payment>> listPayments(
@RequestParam(required = false) UUID memberId,
@RequestParam(required = false) PaymentStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<Payment> result;
if (memberId != null) {
result = financeService.getPaymentsByMember(clubId, memberId, pageable);
} else if (status != null) {
result = financeService.getPaymentsByStatus(clubId, status, pageable);
} else {
result = financeService.getPayments(clubId, pageable);
}
return ResponseEntity.ok(result);
}
@PostMapping("/finance/payments/{id}/void")
public ResponseEntity<Payment> voidPayment(@PathVariable UUID id,
@Valid @RequestBody VoidPaymentRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID userId = UUID.fromString(principal.getUsername());
Payment voided = financeService.voidPayment(id, userId, request.reason());
return ResponseEntity.ok(voided);
}
// === Expenses ===
@PostMapping("/finance/expenses")
public ResponseEntity<LedgerEntry> recordExpense(@Valid @RequestBody RecordExpenseRequest request,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
LedgerEntry entry = financeService.recordExpense(
clubId, request.category(), request.amountCents(),
request.description(), request.reference(), userId, request.transactionDate()
);
return ResponseEntity.status(HttpStatus.CREATED).body(entry);
}
// === Ledger / Kassenbuch ===
@GetMapping("/finance/ledger")
public ResponseEntity<Page<LedgerEntry>> getLedger(
@RequestParam LocalDate from,
@RequestParam LocalDate to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "transactionDate"));
return ResponseEntity.ok(financeService.getLedgerEntries(clubId, from, to, pageable));
}
// === Financial Summary ===
@GetMapping("/finance/summary")
public ResponseEntity<Map<String, Object>> getFinancialSummary(
@RequestParam LocalDate from,
@RequestParam LocalDate to,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getFinancialSummary(clubId, from, to));
}
// === Outstanding ===
@GetMapping("/finance/outstanding")
public ResponseEntity<List<Map<String, Object>>> getOutstandingMembers(
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getOutstandingMembers(clubId));
}
// === Member Balance (Admin) ===
@GetMapping("/finance/members/{memberId}/balance")
public ResponseEntity<Map<String, Object>> getMemberBalance(@PathVariable UUID memberId,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
}
// === Portal Endpoints (member self-service) ===
@GetMapping("/portal/finance/payments")
public ResponseEntity<Page<Payment>> getMyPayments(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return ResponseEntity.ok(financeService.getPaymentsByMember(clubId, memberId, pageable));
}
@GetMapping("/portal/finance/balance")
public ResponseEntity<Map<String, Object>> getMyBalance(@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
}
// === Receipt PDF Download ===
@GetMapping("/finance/payments/{id}/receipt")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
Payment payment = financeService.getPaymentById(id)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
Member member = memberRepository.findById(payment.getMemberId())
.orElseThrow(() -> new NoSuchElementException("Member not found"));
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
String filename = "Quittung-" + (payment.getReference() != null
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
// === Annual Report PDF ===
@GetMapping("/finance/reports/annual")
public ResponseEntity<byte[]> downloadAnnualReport(@RequestParam int year,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
FinancialReportService.AnnualReportData reportData = financeService.buildAnnualReportData(clubId, year);
byte[] pdf = financialReportService.generateAnnualReport(reportData, club);
String filename = "Jahresabschluss-" + year + "-" + club.getName().replaceAll("\\s+", "_") + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
// === Kassenbuch CSV Export ===
@GetMapping("/finance/ledger/export")
public ResponseEntity<byte[]> exportLedgerCsv(@RequestParam LocalDate from,
@RequestParam LocalDate to,
@AuthenticationPrincipal UserDetails principal) {
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
UUID clubId = TenantContext.getCurrentTenant();
byte[] csv = financeService.exportLedgerCsv(clubId, from, to);
String filename = "Kassenbuch-" + from + "-" + to + ".csv";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=ISO-8859-1"))
.contentLength(csv.length)
.body(csv);
}
// === Portal: Receipt download (own payments only) ===
@GetMapping("/portal/finance/payments/{id}/receipt")
public ResponseEntity<byte[]> downloadMyReceipt(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
UUID userId = UUID.fromString(principal.getUsername());
UUID clubId = TenantContext.getCurrentTenant();
UUID memberId = getMemberIdForUser(userId, clubId);
Payment payment = financeService.getPaymentById(id)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
// Verify payment belongs to the requesting member
if (!payment.getMemberId().equals(memberId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NoSuchElementException("Member not found"));
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found"));
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
String filename = "Quittung-" + (payment.getReference() != null
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(pdf);
}
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
return memberRepository.findByUserId(userId)
.map(Member::getId)
.orElseThrow(() -> new NoSuchElementException("Member not found for user: " + userId));
}
}
@@ -0,0 +1,224 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.ForumService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Forum controller — admin and portal endpoints for forum topics, replies, reactions, and reports.
*/
@RestController
@RequestMapping("/api/v1")
public class ForumController {
private final ForumService forumService;
public ForumController(ForumService forumService) {
this.forumService = forumService;
}
// ---- Admin Topic Endpoints ----
@PostMapping("/forum/topics")
public ResponseEntity<ForumTopic> createTopic(@Valid @RequestBody CreateTopicRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
ForumTopic topic = forumService.createTopic(clubId, request.title(), request.content(), userId);
return ResponseEntity.ok(topic);
}
@GetMapping("/forum/topics")
public ResponseEntity<Page<ForumTopic>> getTopics(@RequestHeader("X-Club-Id") UUID clubId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
}
@GetMapping("/forum/topics/{id}")
public ResponseEntity<ForumTopic> getTopic(@PathVariable UUID id) {
return forumService.getTopic(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/forum/topics/{id}/lock")
public ResponseEntity<ForumTopic> lockTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.lockTopic(id, userId));
}
@PostMapping("/forum/topics/{id}/unlock")
public ResponseEntity<ForumTopic> unlockTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.unlockTopic(id, userId));
}
@PostMapping("/forum/topics/{id}/pin")
public ResponseEntity<ForumTopic> pinTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.pinTopic(id, userId));
}
@PostMapping("/forum/topics/{id}/unpin")
public ResponseEntity<ForumTopic> unpinTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.unpinTopic(id, userId));
}
@DeleteMapping("/forum/topics/{id}")
public ResponseEntity<Void> deleteTopic(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId,
@RequestParam(required = false) String reason) {
forumService.deleteTopic(id, userId, reason);
return ResponseEntity.noContent().build();
}
// ---- Reply Endpoints ----
@GetMapping("/forum/topics/{topicId}/replies")
public ResponseEntity<Page<ForumReply>> getReplies(@PathVariable UUID topicId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
}
@PostMapping("/forum/topics/{topicId}/replies")
public ResponseEntity<ForumReply> createReply(@PathVariable UUID topicId,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReply reply = forumService.createReply(topicId, request.content(), userId);
return ResponseEntity.ok(reply);
}
@PutMapping("/forum/replies/{id}")
public ResponseEntity<ForumReply> editReply(@PathVariable UUID id,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReply reply = forumService.editReply(id, request.content(), userId);
return ResponseEntity.ok(reply);
}
@DeleteMapping("/forum/replies/{id}")
public ResponseEntity<Void> deleteReply(@PathVariable UUID id,
@RequestHeader("X-User-Id") UUID userId) {
forumService.deleteReply(id, userId);
return ResponseEntity.noContent().build();
}
// ---- Reaction Endpoints ----
@PostMapping("/forum/reactions")
public ResponseEntity<Map<String, Object>> toggleReaction(@Valid @RequestBody ReactionRequest request,
@RequestHeader("X-User-Id") UUID userId) {
var result = forumService.toggleReaction(
request.targetType(), request.targetId(), userId, request.reactionType());
boolean active = result.isPresent();
return ResponseEntity.ok(Map.of("active", active, "reactionType", request.reactionType().name()));
}
// ---- Report Endpoints ----
@PostMapping("/forum/reports")
public ResponseEntity<Map<String, String>> reportContent(@Valid @RequestBody ReportRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
return ResponseEntity.ok(Map.of("status", "reported"));
}
@GetMapping("/forum/reports")
public ResponseEntity<Page<ForumReport>> getReports(@RequestHeader("X-Club-Id") UUID clubId,
@RequestParam(defaultValue = "OPEN") ReportStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(forumService.getReports(clubId, status, page, size));
}
@GetMapping("/forum/reports/count")
public ResponseEntity<Map<String, Long>> getOpenReportCount(@RequestHeader("X-Club-Id") UUID clubId) {
return ResponseEntity.ok(Map.of("count", forumService.getOpenReportCount(clubId)));
}
@PostMapping("/forum/reports/{id}/review")
public ResponseEntity<ForumReport> reviewReport(@PathVariable UUID id,
@Valid @RequestBody ReviewReportRequest request,
@RequestHeader("X-User-Id") UUID userId) {
ForumReport report = forumService.reviewReport(id, userId, request.status());
return ResponseEntity.ok(report);
}
// ---- Portal Endpoints (member-scoped, same logic) ----
@PostMapping("/portal/forum/topics")
public ResponseEntity<ForumTopic> portalCreateTopic(@Valid @RequestBody CreateTopicRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.createTopic(clubId, request.title(), request.content(), userId));
}
@GetMapping("/portal/forum/topics")
public ResponseEntity<Page<ForumTopic>> portalGetTopics(@RequestHeader("X-Club-Id") UUID clubId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
}
@GetMapping("/portal/forum/topics/{id}")
public ResponseEntity<ForumTopic> portalGetTopic(@PathVariable UUID id) {
return forumService.getTopic(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/portal/forum/topics/{topicId}/replies")
public ResponseEntity<Page<ForumReply>> portalGetReplies(@PathVariable UUID topicId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
}
@PostMapping("/portal/forum/topics/{topicId}/replies")
public ResponseEntity<ForumReply> portalCreateReply(@PathVariable UUID topicId,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.createReply(topicId, request.content(), userId));
}
@PutMapping("/portal/forum/replies/{id}")
public ResponseEntity<ForumReply> portalEditReply(@PathVariable UUID id,
@Valid @RequestBody CreateReplyRequest request,
@RequestHeader("X-User-Id") UUID userId) {
return ResponseEntity.ok(forumService.editReply(id, request.content(), userId));
}
@PostMapping("/portal/forum/reactions")
public ResponseEntity<Map<String, Object>> portalToggleReaction(@Valid @RequestBody ReactionRequest request,
@RequestHeader("X-User-Id") UUID userId) {
var result = forumService.toggleReaction(
request.targetType(), request.targetId(), userId, request.reactionType());
return ResponseEntity.ok(Map.of("active", result.isPresent(), "reactionType", request.reactionType().name()));
}
@PostMapping("/portal/forum/reports")
public ResponseEntity<Map<String, String>> portalReportContent(@Valid @RequestBody ReportRequest request,
@RequestHeader("X-Club-Id") UUID clubId,
@RequestHeader("X-User-Id") UUID userId) {
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
return ResponseEntity.ok(Map.of("status", "reported"));
}
// ---- Request Records ----
public record CreateTopicRequest(String title, String content) {}
public record CreateReplyRequest(String content) {}
public record ReactionRequest(ForumTargetType targetType, UUID targetId, ReactionType reactionType) {}
public record ReportRequest(ForumTargetType targetType, UUID targetId, String reason) {}
public record ReviewReportRequest(ReportStatus status) {}
}
@@ -0,0 +1,213 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.InfoBoardPost;
import de.cannamanage.domain.enums.InfoBoardCategory;
import de.cannamanage.service.InfoBoardService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Info Board (Schwarzes Brett) endpoints for admin and portal.
*/
@RestController
@RequiredArgsConstructor
public class InfoBoardController {
private final InfoBoardService infoBoardService;
// ============================================================
// ADMIN ENDPOINTS (require MANAGE_INFO_BOARD permission)
// ============================================================
/**
* Create a new info board post.
*/
@PostMapping("/api/v1/info-board")
public ResponseEntity<?> createPost(
@Valid @RequestBody CreatePostRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID authorId = UUID.fromString(user.getUsername());
InfoBoardPost post = infoBoardService.createPost(
request.clubId(), request.title(), request.content(),
request.category(), request.pinned() != null && request.pinned(), authorId);
return ResponseEntity.ok(toResponse(post));
}
/**
* List posts (admin view with optional filters).
*/
@GetMapping("/api/v1/info-board")
public ResponseEntity<?> listPosts(
@RequestParam UUID clubId,
@RequestParam(required = false) InfoBoardCategory category,
@RequestParam(defaultValue = "false") boolean includeArchived,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, includeArchived, page, size);
var items = posts.getContent().stream().map(this::toResponse).toList();
return ResponseEntity.ok(Map.of(
"posts", items,
"totalElements", posts.getTotalElements(),
"totalPages", posts.getTotalPages(),
"page", posts.getNumber()
));
}
/**
* Get a single post.
*/
@GetMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> getPost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.getPost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Update a post.
*/
@PutMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> updatePost(
@PathVariable UUID id,
@Valid @RequestBody UpdatePostRequest request) {
InfoBoardPost post = infoBoardService.updatePost(
id, request.title(), request.content(), request.category(), request.pinned());
return ResponseEntity.ok(toResponse(post));
}
/**
* Delete a post.
*/
@DeleteMapping("/api/v1/info-board/{id}")
public ResponseEntity<?> deletePost(@PathVariable UUID id) {
infoBoardService.deletePost(id);
return ResponseEntity.ok(Map.of("deleted", true));
}
/**
* Archive a post.
*/
@PostMapping("/api/v1/info-board/{id}/archive")
public ResponseEntity<?> archivePost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.archivePost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Unarchive a post.
*/
@PostMapping("/api/v1/info-board/{id}/unarchive")
public ResponseEntity<?> unarchivePost(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.unarchivePost(id);
return ResponseEntity.ok(toResponse(post));
}
/**
* Toggle pin status.
*/
@PostMapping("/api/v1/info-board/{id}/pin")
public ResponseEntity<?> togglePin(@PathVariable UUID id) {
InfoBoardPost post = infoBoardService.togglePin(id);
return ResponseEntity.ok(toResponse(post));
}
// ============================================================
// PORTAL ENDPOINTS (member access)
// ============================================================
/**
* Get posts for the member's club (non-archived, pinned first).
*/
@GetMapping("/api/v1/portal/info-board")
public ResponseEntity<?> getPortalPosts(
@RequestParam UUID clubId,
@RequestParam(required = false) InfoBoardCategory category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, false, page, size);
var items = posts.getContent().stream().map(this::toResponse).toList();
return ResponseEntity.ok(Map.of(
"posts", items,
"totalElements", posts.getTotalElements(),
"totalPages", posts.getTotalPages(),
"page", posts.getNumber()
));
}
/**
* Mark a post as read.
*/
@PostMapping("/api/v1/portal/info-board/{id}/read")
public ResponseEntity<?> markAsRead(
@PathVariable UUID id,
@RequestParam UUID memberId) {
infoBoardService.markAsRead(id, memberId);
return ResponseEntity.ok(Map.of("read", true));
}
/**
* Get unread post count for badge display.
*/
@GetMapping("/api/v1/portal/info-board/unread-count")
public ResponseEntity<?> getUnreadCount(
@RequestParam UUID clubId,
@RequestParam UUID memberId) {
long count = infoBoardService.getUnreadCount(clubId, memberId);
return ResponseEntity.ok(Map.of("unreadCount", count));
}
// ============================================================
// DTOs
// ============================================================
public record CreatePostRequest(
@NotNull UUID clubId,
@NotBlank @Size(max = 200) String title,
@NotBlank String content,
@NotNull InfoBoardCategory category,
Boolean pinned
) {}
public record UpdatePostRequest(
@Size(max = 200) String title,
String content,
InfoBoardCategory category,
Boolean pinned
) {}
// ============================================================
// Response mapping
// ============================================================
private Map<String, Object> toResponse(InfoBoardPost post) {
return Map.of(
"id", post.getId(),
"clubId", post.getClubId(),
"title", post.getTitle(),
"content", post.getContent(),
"category", post.getCategory().name(),
"pinned", post.isPinned(),
"archived", post.isArchived(),
"authorId", post.getAuthorId(),
"createdAt", post.getCreatedAt().toString(),
"updatedAt", post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : ""
);
}
}
@@ -0,0 +1,103 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.CustomMailDomain;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.CustomMailDomainService;
import de.cannamanage.service.PlanTierService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* REST controller for Enterprise custom email domain management.
* All endpoints require ADMIN role + Enterprise tier.
*/
@RestController
@RequestMapping("/api/v1/settings/mail")
public class MailSettingsController {
private final CustomMailDomainService customMailDomainService;
private final PlanTierService planTierService;
public MailSettingsController(CustomMailDomainService customMailDomainService,
PlanTierService planTierService) {
this.customMailDomainService = customMailDomainService;
this.planTierService = planTierService;
}
/**
* Set a custom FROM address for the club's outbound emails.
* Enterprise tier only.
*/
@PostMapping("/custom-domain")
public ResponseEntity<MailDomainStatusResponse> setCustomDomain(
@Valid @RequestBody CustomMailDomainRequest request) {
UUID tenantId = TenantContext.getCurrentTenantId();
CustomMailDomain domain = customMailDomainService.setCustomDomain(tenantId, request.fromAddress());
return ResponseEntity.ok(toResponse(domain));
}
/**
* Get current custom domain status.
*/
@GetMapping("/custom-domain")
public ResponseEntity<MailDomainStatusResponse> getCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
planTierService.requireEnterpriseTier(tenantId);
return customMailDomainService.getCustomDomain(tenantId)
.map(domain -> ResponseEntity.ok(toResponse(domain)))
.orElse(ResponseEntity.noContent().build());
}
/**
* Trigger DNS verification for the custom domain.
*/
@PostMapping("/custom-domain/verify")
public ResponseEntity<MailDomainStatusResponse> verifyCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
CustomMailDomain domain = customMailDomainService.verifyDomain(tenantId);
return ResponseEntity.ok(toResponse(domain));
}
/**
* Remove custom domain configuration (revert to platform default).
*/
@DeleteMapping("/custom-domain")
public ResponseEntity<Void> removeCustomDomain() {
UUID tenantId = TenantContext.getCurrentTenantId();
planTierService.requireEnterpriseTier(tenantId);
customMailDomainService.removeCustomDomain(tenantId);
return ResponseEntity.noContent().build();
}
private MailDomainStatusResponse toResponse(CustomMailDomain domain) {
return new MailDomainStatusResponse(
domain.getFromAddress(),
domain.getDomain(),
domain.isVerified(),
domain.getVerificationToken(),
domain.getVerifiedAt() != null ? domain.getVerifiedAt().toString() : null,
"cannamanage-verify=" + domain.getVerificationToken()
);
}
// --- DTOs ---
public record CustomMailDomainRequest(
@NotBlank @Email String fromAddress
) {}
public record MailDomainStatusResponse(
String fromAddress,
String domain,
boolean verified,
String verificationToken,
String verifiedAt,
String requiredDnsTxtRecord
) {}
}
@@ -0,0 +1,85 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.ComposeNotificationRequest;
import de.cannamanage.domain.entity.NotificationSend;
import de.cannamanage.domain.enums.TargetType;
import de.cannamanage.service.NotificationService;
import de.cannamanage.service.repository.NotificationSendRepository;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Admin notification compose endpoints.
* Requires SEND_NOTIFICATIONS permission (checked via StaffPermissionChecker).
*/
@RestController
@RequestMapping("/api/v1/notifications")
@RequiredArgsConstructor
public class NotificationComposeController {
private final NotificationService notificationService;
private final NotificationSendRepository notificationSendRepository;
/**
* Compose and send a notification (broadcast or targeted).
*/
@PostMapping("/compose")
public ResponseEntity<Map<String, Object>> composeAndSend(
@Valid @RequestBody ComposeNotificationRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID authorId = UUID.fromString(user.getUsername());
NotificationSend send;
if (request.targetType() == TargetType.ALL) {
send = notificationService.sendBroadcast(
request.title(), request.message(), request.link(), authorId);
} else {
if (request.recipientIds() == null || request.recipientIds().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "recipientIds required for SELECTED target type"));
}
send = notificationService.sendToSelected(
request.title(), request.message(), request.link(), authorId, request.recipientIds());
}
return ResponseEntity.ok(Map.of(
"id", send.getId(),
"targetType", send.getTargetType().name(),
"targetCount", send.getTargetCount(),
"sentAt", send.getSentAt().toString()
));
}
/**
* List sent notifications (paginated).
*/
@GetMapping("/sends")
public ResponseEntity<?> listSends(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
var sends = notificationSendRepository.findAllByOrderBySentAtDesc(PageRequest.of(page, size));
var items = sends.getContent().stream().map(s -> Map.of(
"id", (Object) s.getId(),
"title", s.getTitle(),
"targetType", s.getTargetType().name(),
"targetCount", s.getTargetCount(),
"readCount", s.getReadCount(),
"sentAt", s.getSentAt().toString()
)).toList();
return ResponseEntity.ok(Map.of(
"sends", items,
"totalElements", sends.getTotalElements(),
"totalPages", sends.getTotalPages()
));
}
}
@@ -0,0 +1,72 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.UpdatePreferencesRequest;
import de.cannamanage.domain.enums.NotificationChannel;
import de.cannamanage.service.NotificationPreferenceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Notification preferences endpoints.
* Any authenticated user can view/update their notification channel preferences.
*/
@RestController
@RequestMapping("/api/v1/notifications/preferences")
@RequiredArgsConstructor
public class NotificationPreferenceController {
private final NotificationPreferenceService preferenceService;
/**
* Get user's notification channel preferences.
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getPreferences(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
var prefs = preferenceService.getOrCreatePreferences(userId);
var prefsMap = prefs.stream().collect(Collectors.toMap(
p -> p.getChannel().name(),
p -> (Object) p.isEnabled()
));
return ResponseEntity.ok(Map.of("preferences", prefsMap));
}
/**
* Update notification channel preferences.
* IN_APP cannot be disabled (server-side enforcement).
*/
@PutMapping
public ResponseEntity<?> updatePreferences(
@Valid @RequestBody UpdatePreferencesRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
try {
for (var entry : request.preferences().entrySet()) {
preferenceService.updatePreference(userId, entry.getKey(), entry.getValue());
}
// Return updated preferences
var prefs = preferenceService.getOrCreatePreferences(userId);
var prefsMap = prefs.stream().collect(Collectors.toMap(
p -> p.getChannel().name(),
p -> (Object) p.isEnabled()
));
return ResponseEntity.ok(Map.of("preferences", prefsMap));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
@@ -1,26 +1,37 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.report.AuthorityExportRequest;
import de.cannamanage.api.dto.report.MemberListResponse;
import de.cannamanage.api.dto.report.MonthlyReportResponse;
import de.cannamanage.api.dto.report.RecallReportResponse;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.CsvReportGenerator;
import de.cannamanage.service.PdfReportGenerator;
import de.cannamanage.service.ReportGeneratorService;
import de.cannamanage.service.ReportService;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import de.cannamanage.service.report.AuthorityExportService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.UserRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.time.YearMonth;
import java.util.UUID;
import java.util.*;
/**
* REST controller for compliance and operational reports.
@@ -34,15 +45,50 @@ public class ReportController {
private final PdfReportGenerator pdfGenerator;
private final CsvReportGenerator csvGenerator;
private final ClubRepository clubRepository;
private final ReportGeneratorService reportGeneratorService;
private final AuthorityExportService authorityExportService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public ReportController(ReportService reportService,
PdfReportGenerator pdfGenerator,
CsvReportGenerator csvGenerator,
ClubRepository clubRepository) {
ClubRepository clubRepository,
ReportGeneratorService reportGeneratorService,
AuthorityExportService authorityExportService,
UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.reportService = reportService;
this.pdfGenerator = pdfGenerator;
this.csvGenerator = csvGenerator;
this.clubRepository = clubRepository;
this.reportGeneratorService = reportGeneratorService;
this.authorityExportService = authorityExportService;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
/**
* List all available report types with their supported export formats.
* GET /api/v1/reports/types
*/
@GetMapping("/types")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<List<Map<String, Object>>> listReportTypes() {
Map<ReportType, Set<ExportFormat>> availableTypes = reportGeneratorService.getAvailableTypes();
List<Map<String, Object>> response = new ArrayList<>();
for (var entry : availableTypes.entrySet()) {
Map<String, Object> typeInfo = new LinkedHashMap<>();
typeInfo.put("type", entry.getKey().name());
typeInfo.put("formats", entry.getValue().stream()
.map(ExportFormat::name)
.sorted()
.toList());
response.add(typeInfo);
}
return ResponseEntity.ok(response);
}
/**
@@ -182,6 +228,49 @@ public class ReportController {
);
}
/**
* Full Authority Export (Behörden-Export) — THE HERO FEATURE.
* Generates a streaming ZIP containing all compliance documents.
* Requires re-authentication (password re-entry) + mandatory reason.
* Rate limited: max 1 export per hour per tenant.
*
* POST /api/v1/reports/authority-export
*/
@PostMapping("/authority-export")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<StreamingResponseBody> authorityExport(
@Valid @RequestBody AuthorityExportRequest request,
@AuthenticationPrincipal UUID userId) {
UUID tenantId = TenantContext.getCurrentTenant();
// Rate limit check
if (authorityExportService.isRateLimited(tenantId)) {
return ResponseEntity.status(429)
.header("Retry-After", "3600")
.build();
}
// Re-authentication: verify password against BCrypt hash
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalStateException("Authenticated user not found"));
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
return ResponseEntity.status(403).build();
}
// Stream the ZIP
StreamingResponseBody responseBody = outputStream ->
authorityExportService.streamAuthorityExport(
outputStream, tenantId, request.year(), userId, request.reason());
String filename = "Behoerden_Export_" + request.year() + "_" + tenantId.toString().substring(0, 8) + ".zip";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(responseBody);
}
private RecallReportResponse toRecallResponse(RecallReport r) {
return new RecallReportResponse(
r.getBatchId(),
@@ -83,7 +83,7 @@ public class StaffController {
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
@RequestBody UpdateStaffRequest request) {
@Valid @RequestBody UpdateStaffRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.updateStaff(
tenantId, id,
@@ -10,6 +10,7 @@ import de.cannamanage.service.StripeService;
import de.cannamanage.service.repository.ClubRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -46,7 +47,7 @@ public class SubscriptionController {
@PostMapping("/checkout")
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
public ResponseEntity<Map<String, String>> createCheckout(@Valid @RequestBody CheckoutRequest request) throws StripeException {
UUID tenantId = TenantContext.getCurrentTenant();
UUID clubId = clubRepository.findByTenantId(tenantId)
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/assign}.
* Used by the admin to manually attach a transaction to a member the matching engine missed.
*/
public record AssignRequest(
@NotNull UUID memberId
) {}
@@ -0,0 +1,19 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.service.bankimport.BankImportService.BulkConfirmResult;
/**
* Sprint 10 Phase 3 — Response of {@code POST /sessions/{id}/confirm-all}.
* Surfaces the number of transactions that were confirmed, skipped (low confidence /
* already confirmed) and failed (e.g. payment creation error) so the UI can give clear feedback.
*/
public record BulkConfirmResponse(
int confirmed,
int skipped,
int failed,
int total
) {
public static BulkConfirmResponse from(BulkConfirmResult r) {
return new BulkConfirmResponse(r.confirmed(), r.skipped(), r.failed(), r.total());
}
}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/confirm}.
* <p>
* The {@code memberId} is required so the caller explicitly acknowledges which member receives
* the payment, even when the matching engine had already pre-selected one.
*/
public record ConfirmRequest(
@NotNull UUID memberId
) {}
@@ -0,0 +1,26 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /finance/import/csv-mappings}.
* Captures the column layout of a club-specific CSV bank export so future imports can
* be parsed without re-mapping.
*/
public record CreateMappingRequest(
@NotBlank @Size(max = 100) String name,
@Min(0) @Max(50) int dateColumn,
@Min(0) @Max(50) int amountColumn,
@Min(0) @Max(50) int referenceColumn,
Integer counterpartyColumn,
Integer ibanColumn,
@Size(max = 4) String delimiter,
@Size(max = 32) String dateFormat,
@Size(max = 2) String decimalSeparator,
@Min(0) @Max(20) Integer skipHeaderRows,
@Size(max = 32) String encoding,
Boolean isDefault
) {}
@@ -0,0 +1,46 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.domain.entity.BankImportSession;
import de.cannamanage.domain.enums.BankFormat;
import de.cannamanage.domain.enums.ImportSessionStatus;
import java.time.Instant;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Summary projection of a {@code BankImportSession} for list views.
* Excludes large/sensitive fields ({@code fileHash}, {@code errorMessage} stays).
*/
public record ImportSessionResponse(
UUID id,
UUID clubId,
String filename,
BankFormat format,
ImportSessionStatus status,
Integer totalTransactions,
Integer matchedCount,
Integer confirmedCount,
Integer skippedCount,
UUID uploadedBy,
String errorMessage,
Instant createdAt,
Instant completedAt
) {
public static ImportSessionResponse from(BankImportSession s) {
return new ImportSessionResponse(
s.getId(),
s.getClubId(),
s.getFilename(),
s.getFormat(),
s.getStatus(),
s.getTotalTransactions(),
s.getMatchedCount(),
s.getConfirmedCount(),
s.getSkippedCount(),
s.getUploadedBy(),
s.getErrorMessage(),
s.getCreatedAt(),
s.getCompletedAt()
);
}
}
@@ -0,0 +1,9 @@
package de.cannamanage.api.dto.bankimport;
/**
* Sprint 10 Phase 3 — Optional request body for {@code POST /sessions/{id}/transactions/{txnId}/skip}.
* The {@code reason} field is free text shown in the audit log and review history.
*/
public record SkipRequest(
String reason
) {}
@@ -0,0 +1,48 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.domain.entity.BankTransaction;
import de.cannamanage.domain.enums.MatchStatus;
import java.time.LocalDate;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Single bank-statement transaction shown in the review wizard.
*/
public record TransactionResponse(
UUID id,
UUID sessionId,
LocalDate bookingDate,
LocalDate valueDate,
Integer amountCents,
String currency,
String referenceText,
String counterpartyName,
String counterpartyIban,
String bankReference,
MatchStatus matchStatus,
Integer matchConfidence,
UUID matchedMemberId,
UUID matchedPaymentId,
String skipReason
) {
public static TransactionResponse from(BankTransaction t) {
return new TransactionResponse(
t.getId(),
t.getSessionId(),
t.getBookingDate(),
t.getValueDate(),
t.getAmountCents(),
t.getCurrency(),
t.getReferenceText(),
t.getCounterpartyName(),
t.getCounterpartyIban(),
t.getBankReference(),
t.getMatchStatus(),
t.getMatchConfidence(),
t.getMatchedMemberId(),
t.getMatchedPaymentId(),
t.getSkipReason()
);
}
}
@@ -0,0 +1,24 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
public record CreateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate,
Boolean postToInfoBoard // defaults to true if null
) {}
@@ -0,0 +1,28 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import de.cannamanage.domain.enums.RsvpStatus;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Map;
import java.util.UUID;
public record EventResponse(
UUID id,
String title,
String description,
EventType eventType,
Instant startAt,
Instant endAt,
String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate,
UUID createdBy,
Instant createdAt,
Map<RsvpStatus, Long> attendeeCounts,
RsvpStatus myRsvpStatus
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.RsvpStatus;
import jakarta.validation.constraints.NotNull;
public record RsvpRequest(
@NotNull RsvpStatus status
) {}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.RsvpStatus;
import java.time.Instant;
import java.util.UUID;
public record RsvpResponse(
UUID memberId,
String memberName,
RsvpStatus status,
Instant respondedAt
) {}
@@ -0,0 +1,23 @@
package de.cannamanage.api.dto.event;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDate;
public record UpdateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule,
LocalDate recurrenceEndDate
) {}
@@ -0,0 +1,11 @@
package de.cannamanage.api.dto.finance;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.UUID;
public record AssignFeeRequest(
@NotNull UUID feeScheduleId,
@NotNull LocalDate validFrom
) {}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.FeeInterval;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record CreateFeeScheduleRequest(
@NotBlank String name,
@NotNull @Min(1) Integer amountCents,
@NotNull FeeInterval interval,
Boolean isDefault
) {}
@@ -0,0 +1,16 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.ExpenseCategory;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record RecordExpenseRequest(
@NotNull ExpenseCategory category,
@NotNull @Min(1) Integer amountCents,
@NotBlank String description,
String reference,
@NotNull LocalDate transactionDate
) {}
@@ -0,0 +1,18 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.PaymentMethod;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.UUID;
public record RecordPaymentRequest(
@NotNull UUID memberId,
@NotNull @Min(1) Integer amountCents,
@NotNull PaymentMethod paymentMethod,
@NotNull LocalDate periodFrom,
@NotNull LocalDate periodTo,
String reference,
String notes
) {}
@@ -0,0 +1,10 @@
package de.cannamanage.api.dto.finance;
import de.cannamanage.domain.enums.FeeInterval;
public record UpdateFeeScheduleRequest(
String name,
Integer amountCents,
FeeInterval interval,
Boolean isDefault
) {}
@@ -0,0 +1,7 @@
package de.cannamanage.api.dto.finance;
import jakarta.validation.constraints.NotBlank;
public record VoidPaymentRequest(
@NotBlank String reason
) {}
@@ -0,0 +1,19 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.TargetType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;
/**
* Request DTO for composing and sending a notification.
*/
public record ComposeNotificationRequest(
@NotBlank String title,
@NotBlank String message,
String link,
@NotNull TargetType targetType,
List<UUID> recipientIds
) {}
@@ -0,0 +1,14 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.DevicePlatform;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request DTO for registering a push notification device token.
*/
public record RegisterDeviceRequest(
@NotNull DevicePlatform platform,
@NotBlank String token,
String deviceName
) {}
@@ -0,0 +1,12 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.NotificationChannel;
import java.util.Map;
/**
* Request DTO for updating notification preferences.
*/
public record UpdatePreferencesRequest(
Map<NotificationChannel, Boolean> preferences
) {}
@@ -0,0 +1,16 @@
package de.cannamanage.api.dto.report;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Request body for the authority export endpoint.
* Requires re-authentication (password) and a mandatory reason for the audit trail.
*/
public record AuthorityExportRequest(
@NotNull Integer year,
@NotBlank @Size(min = 1, max = 500) String password,
@NotBlank @Size(min = 10, max = 500) String reason
) {
}
@@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -29,6 +30,32 @@ public class JwtService {
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
private long refreshTokenExpiry; // seconds (30 days)
/**
* Sentinel value used in the application.properties default. If the runtime JWT secret
* matches this string (or is missing/too short) the application must fail to start —
* we never want a deployment to silently fall back to a publicly-known dev secret.
*/
static final String UNCONFIGURED_SECRET_MARKER = "CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP";
/**
* Validate JWT secret on startup — fail fast if the deployment is missing a proper secret.
* Runs after Spring property binding (@Value) so we see the effective value.
*/
@PostConstruct
void validateSecret() {
if (secretKey == null
|| secretKey.isBlank()
|| secretKey.length() < 32
|| UNCONFIGURED_SECRET_MARKER.equals(secretKey)) {
throw new IllegalStateException(
"FATAL: JWT secret is not configured or uses the default dev placeholder. "
+ "Set the CANNAMANAGE_SECURITY_JWT_SECRET environment variable "
+ "(or cannamanage.security.jwt.secret property) to a base64-encoded "
+ "256-bit (or larger) random key."
);
}
}
/**
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
*/
@@ -0,0 +1,60 @@
package de.cannamanage.api.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Simple in-memory brute-force protection for the login endpoint.
*
* <p>Tracks attempts per source IP and rejects further attempts once the
* configured threshold ({@link #MAX_ATTEMPTS_PER_WINDOW}) is exceeded within
* the current 60-second window. Counters are reset every minute by
* {@link #resetCounters()}.
*
* <p>This deliberately stays in-memory rather than introducing Resilience4j /
* Bucket4j for a single endpoint. For multi-instance deployments behind a
* load balancer this should be revisited.
*/
@Slf4j
@Component
public class LoginRateLimiter {
/** Maximum failed/total login attempts allowed per IP per window. */
public static final int MAX_ATTEMPTS_PER_WINDOW = 5;
private final ConcurrentHashMap<String, AtomicInteger> attemptsByIp = new ConcurrentHashMap<>();
/**
* Records an attempt and returns {@code true} if the request is allowed
* (still within the per-window quota), {@code false} if it must be
* rejected with HTTP 429.
*/
public boolean tryAcquire(String ipAddress) {
if (ipAddress == null || ipAddress.isBlank()) {
ipAddress = "unknown";
}
AtomicInteger counter = attemptsByIp.computeIfAbsent(ipAddress, k -> new AtomicInteger(0));
int current = counter.incrementAndGet();
if (current > MAX_ATTEMPTS_PER_WINDOW) {
log.warn("Login rate limit exceeded for IP {} ({} attempts in current window)", ipAddress, current);
return false;
}
return true;
}
/**
* Resets all counters every 60 seconds. Fixed-rate scheduler keeps the
* implementation predictable and free of timestamp bookkeeping.
*/
@Scheduled(fixedRate = 60_000L)
public void resetCounters() {
if (!attemptsByIp.isEmpty()) {
log.debug("Resetting login rate-limit counters for {} IPs", attemptsByIp.size());
attemptsByIp.clear();
}
}
}
@@ -19,6 +19,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.List;
/**
@@ -34,6 +35,14 @@ public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final PortalUserDetailsService portalUserDetailsService;
/**
* Comma-separated allowed CORS origins. Defaults to local dev origins; production
* deployments override via the {@code CORS_ORIGINS} environment variable
* (e.g. {@code https://cannamanage.plate-software.de}).
*/
@Value("${cannamanage.cors.allowed-origins:http://localhost:3000,http://frontend:3000}")
private String allowedOrigins;
/**
* API security — stateless JWT authentication.
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
@@ -47,6 +56,10 @@ public class SecurityConfig {
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'"))
.frameOptions(frame -> frame.deny()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/webhooks/**").permitAll()
@@ -58,6 +71,10 @@ public class SecurityConfig {
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
// Documents endpoint — explicit listing for defense-in-depth so it can
// never accidentally end up in a permitAll() rule above. Per-document
// tenant ownership is additionally enforced in DocumentController.
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
@@ -78,6 +95,10 @@ public class SecurityConfig {
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'"))
.frameOptions(frame -> frame.deny()))
.userDetailsService(portalUserDetailsService)
.formLogin(form -> form
.loginProcessingUrl("/portal/login")
@@ -128,10 +149,11 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://frontend:3000"
));
List<String> origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
config.setAllowedOrigins(origins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
@@ -54,4 +54,56 @@ public class StaffPermissionChecker {
.map(staff -> staff.hasPermission(required))
.orElse(false);
}
/**
* Imperative permission check — throws AccessDeniedException if permission is missing.
* Used by controllers that need to guard specific endpoints programmatically.
*/
public void requirePermission(org.springframework.security.core.userdetails.UserDetails principal, StaffPermission required) {
if (principal == null) {
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
}
// Convert UserDetails to Authentication-like check
UUID userId = UUID.fromString(principal.getUsername());
boolean isAdmin = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) return;
boolean isStaff = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_STAFF"));
if (!isStaff) {
throw new org.springframework.security.access.AccessDeniedException("Insufficient permissions");
}
boolean hasPermission = staffAccountRepository.findByUserId(userId)
.filter(StaffAccount::isActive)
.map(staff -> staff.hasPermission(required))
.orElse(false);
if (!hasPermission) {
throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required);
}
}
/**
* Extract the user ID from the authenticated principal.
*/
public UUID getUserId(org.springframework.security.core.userdetails.UserDetails principal) {
return UUID.fromString(principal.getUsername());
}
/**
* Get the club ID (tenant) for the authenticated user.
*/
public UUID getClubId(org.springframework.security.core.userdetails.UserDetails principal) {
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
}
/**
* Get the tenant ID for the authenticated user (alias for getClubId).
*/
public UUID getTenantId(org.springframework.security.core.userdetails.UserDetails principal) {
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
}
}
@@ -5,7 +5,7 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
# Enable Flyway for container startup (fresh DB)
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.hibernate.ddl-auto=update
# JWT secret from environment
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
@@ -17,6 +17,24 @@ management.endpoint.health.show-details=never
# drag /actuator/health to DOWN (503), which would mark the container unhealthy.
management.health.mail.enabled=false
# Disable mail in Docker (no SMTP container)
spring.mail.host=localhost
spring.mail.port=1025
# IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production
spring.mail.host=${SMTP_HOST:smtp.ionos.de}
spring.mail.port=${SMTP_PORT:587}
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
spring.mail.password=${IONOS_SMTP_PASSWORD:}
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:true}
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:true}
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
cannamanage.mail.rate-limit=${MAIL_RATE_LIMIT:50}
# Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
push.vapid.subject=mailto:admin@cannamanage.de
# Firebase Cloud Messaging
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
@@ -53,3 +53,25 @@ springdoc.swagger-ui.enabled=false
# App base URL
app.base-url=https://cannamanage.plate-software.de
# IONOS SMTP relay (plate-software.de)
spring.mail.host=smtp.ionos.de
spring.mail.port=587
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
spring.mail.password=${IONOS_SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
cannamanage.mail.rate-limit=50
# Web Push VAPID keys
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
push.vapid.subject=mailto:admin@cannamanage.plate-software.de
# Firebase Cloud Messaging
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
@@ -5,7 +5,12 @@ spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
spring.flyway.enabled=false
# JWT Security
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
# DO NOT ship a usable default secret. JwtService.validateSecret() detects the marker below
# and refuses to start, forcing every deployment to provide a real base64-encoded 256-bit key
# via the CANNAMANAGE_SECURITY_JWT_SECRET environment variable (or override property).
# Test/integration profiles pin their own valid dev secret in application-test.properties /
# application-integration.properties.
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET:CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP}
cannamanage.security.jwt.access-token-expiry=3600
cannamanage.security.jwt.refresh-token-expiry=2592000
@@ -38,5 +43,19 @@ management.endpoint.health.show-details=never
# Session configuration (member portal)
server.servlet.session.timeout=30m
server.servlet.session.cookie.same-site=strict
# Schedulers
cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true}
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB
# Security hardening — limit non-multipart request body sizes to prevent DoS via oversized payloads
server.tomcat.max-http-form-post-size=2MB
# CORS allowed origins (comma-separated). Override via CORS_ORIGINS env var in production.
cannamanage.cors.allowed-origins=${CORS_ORIGINS:http://localhost:3000,http://frontend:3000}
@@ -0,0 +1,25 @@
-- Sprint 7 Phase 1: Notification sends (admin compose + broadcast tracking)
-- Tracks each "send" operation (one admin → many members)
CREATE TABLE notification_sends (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
link VARCHAR(500),
author_id UUID NOT NULL REFERENCES users(id),
target_type VARCHAR(20) NOT NULL, -- ALL or SELECTED
target_count INTEGER NOT NULL,
read_count INTEGER NOT NULL DEFAULT 0,
sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE notification_send_recipients (
send_id UUID NOT NULL REFERENCES notification_sends(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
notification_id UUID REFERENCES notifications(id),
PRIMARY KEY (send_id, user_id)
);
CREATE INDEX idx_notification_sends_tenant ON notification_sends(tenant_id, sent_at DESC);
@@ -0,0 +1,31 @@
-- Sprint 7 Phase 1B: Push notification infrastructure
-- Device token registry (Web Push subscriptions + mobile push tokens)
CREATE TABLE device_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(20) NOT NULL, -- WEB, IOS, ANDROID
token TEXT NOT NULL, -- Push subscription JSON (Web) or FCM token (mobile)
device_name VARCHAR(100), -- e.g. "Chrome on MacBook", "iPhone 15"
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(user_id, token)
);
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX idx_device_tokens_platform ON device_tokens(platform, tenant_id);
-- Per-user notification channel preferences
CREATE TABLE notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL, -- IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(user_id, channel)
);
CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id);
@@ -0,0 +1,39 @@
-- V13: Info Board (Schwarzes Brett) tables
CREATE TABLE info_board_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
category VARCHAR(50) NOT NULL,
is_pinned BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
author_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
tenant_id UUID NOT NULL
);
CREATE TABLE post_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100),
file_size BIGINT,
storage_path VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
tenant_id UUID NOT NULL
);
CREATE TABLE post_read_status (
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
read_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (post_id, member_id)
);
CREATE INDEX idx_info_board_posts_club_id ON info_board_posts(club_id);
CREATE INDEX idx_info_board_posts_category ON info_board_posts(category);
CREATE INDEX idx_info_board_posts_pinned ON info_board_posts(is_pinned) WHERE is_pinned = TRUE;
CREATE INDEX idx_info_board_posts_tenant ON info_board_posts(tenant_id);
CREATE INDEX idx_post_attachments_post_id ON post_attachments(post_id);
@@ -0,0 +1,41 @@
-- Sprint 7 Phase 2.5: Club Event Calendar
-- Club events with RSVP support, recurring events, and iCal export
CREATE TABLE club_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(200) NOT NULL,
description TEXT,
event_type VARCHAR(50) NOT NULL,
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
end_at TIMESTAMP WITH TIME ZONE,
location VARCHAR(300),
max_attendees INTEGER,
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule VARCHAR(100),
recurrence_end_date DATE,
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at);
CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type);
CREATE INDEX idx_club_events_club_id ON club_events(club_id);
-- Event RSVPs
CREATE TABLE event_rsvps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id),
status VARCHAR(20) NOT NULL,
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(event_id, member_id)
);
CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id);
CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id);
@@ -0,0 +1,61 @@
-- V15: Forum MVP — topics, replies, reactions, reports
-- Phase 3 of Sprint 7
CREATE TABLE forum_topics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
author_id UUID NOT NULL REFERENCES users(id),
is_locked BOOLEAN DEFAULT FALSE,
is_pinned BOOLEAN DEFAULT FALSE,
reply_count INTEGER DEFAULT 0,
last_reply_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE forum_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic_id UUID NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
content TEXT NOT NULL,
author_id UUID NOT NULL REFERENCES users(id),
is_edited BOOLEAN DEFAULT FALSE,
edited_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE forum_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
target_type VARCHAR(10) NOT NULL,
target_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
reaction_type VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(target_type, target_id, user_id)
);
CREATE TABLE forum_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
target_type VARCHAR(10) NOT NULL,
target_id UUID NOT NULL,
reporter_id UUID NOT NULL REFERENCES users(id),
reason TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'OPEN',
reviewed_by UUID REFERENCES users(id),
reviewed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_forum_topics_club_id ON forum_topics(club_id);
CREATE INDEX idx_forum_topics_tenant_id ON forum_topics(tenant_id);
CREATE INDEX idx_forum_replies_topic_id ON forum_replies(topic_id);
CREATE INDEX idx_forum_replies_tenant_id ON forum_replies(tenant_id);
CREATE INDEX idx_forum_reactions_target ON forum_reactions(target_type, target_id);
CREATE INDEX idx_forum_reports_club_status ON forum_reports(club_id, status);
CREATE INDEX idx_forum_reports_tenant_id ON forum_reports(tenant_id);
@@ -0,0 +1,6 @@
-- V16: Index for faster email dispatch queries on notification_preferences
-- Used by NotificationDispatchService to find users with EMAIL channel enabled per tenant
CREATE INDEX IF NOT EXISTS idx_notification_preferences_email_enabled
ON notification_preferences(tenant_id, channel, enabled)
WHERE channel = 'EMAIL' AND enabled = true;
@@ -0,0 +1,15 @@
-- V17: Custom mail domains for Enterprise tier clubs
-- Allows Enterprise clubs to use a verified custom FROM address for outbound emails
CREATE TABLE custom_mail_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL UNIQUE,
from_address VARCHAR(255) NOT NULL,
domain VARCHAR(255) NOT NULL,
verification_token VARCHAR(64) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT false,
verified_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_custom_mail_domains_tenant ON custom_mail_domains(tenant_id);
@@ -0,0 +1,77 @@
-- Sprint 8: Treasury / Finance tables
-- Fee schedules (Beitragsordnung)
CREATE TABLE fee_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
amount_cents INTEGER NOT NULL,
interval VARCHAR(20) NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Member fee assignment
CREATE TABLE member_fee_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(member_id, valid_from)
);
-- Payments (Zahlungen)
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
amount_cents INTEGER NOT NULL,
payment_method VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PAID',
period_from DATE NOT NULL,
period_to DATE NOT NULL,
reference VARCHAR(200),
notes TEXT,
recorded_by UUID NOT NULL REFERENCES users(id),
paid_at TIMESTAMP NOT NULL,
voided_at TIMESTAMP,
voided_by UUID REFERENCES users(id),
void_reason TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Kassenbuch (cash book / ledger entries) — append-only per §147 AO
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
transaction_type VARCHAR(10) NOT NULL,
category VARCHAR(50) NOT NULL,
amount_cents INTEGER NOT NULL,
description VARCHAR(500) NOT NULL,
reference VARCHAR(200),
payment_id UUID REFERENCES payments(id),
recorded_by UUID NOT NULL REFERENCES users(id),
transaction_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_fee_schedules_club ON fee_schedules(club_id);
CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id);
CREATE INDEX idx_member_fee_assignments_member ON member_fee_assignments(member_id);
CREATE INDEX idx_member_fee_assignments_tenant ON member_fee_assignments(tenant_id);
CREATE INDEX idx_payments_club_member ON payments(club_id, member_id);
CREATE INDEX idx_payments_status ON payments(club_id, status);
CREATE INDEX idx_payments_period ON payments(club_id, period_from, period_to);
CREATE INDEX idx_payments_tenant ON payments(tenant_id);
CREATE INDEX idx_ledger_entries_club_date ON ledger_entries(club_id, transaction_date);
CREATE INDEX idx_ledger_entries_category ON ledger_entries(club_id, category);
CREATE INDEX idx_ledger_entries_tenant ON ledger_entries(tenant_id);
@@ -0,0 +1,79 @@
-- Sprint 8 Phase 3: Mitgliederversammlung (General Assembly)
-- Legal basis: §32 BGB (Mitgliederversammlung), §33 BGB (Satzungsänderung),
-- §67 BGB (Vereinsregister), §147 AO (Aufbewahrungspflicht)
-- General assemblies (Mitgliederversammlungen)
CREATE TABLE assemblies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(200) NOT NULL,
assembly_type VARCHAR(30) NOT NULL,
scheduled_at TIMESTAMP NOT NULL,
location VARCHAR(300),
invitation_sent_at TIMESTAMP,
invitation_deadline DATE,
quorum_required INTEGER,
status VARCHAR(30) NOT NULL DEFAULT 'PLANNED',
opened_at TIMESTAMP,
closed_at TIMESTAMP,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Agenda items (Tagesordnungspunkte / TOP)
CREATE TABLE assembly_agenda_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
title VARCHAR(300) NOT NULL,
description TEXT,
item_type VARCHAR(30) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Attendance
CREATE TABLE assembly_attendees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id),
checked_in_at TIMESTAMP DEFAULT NOW(),
proxy_for_member_id UUID REFERENCES members(id),
UNIQUE(assembly_id, member_id)
);
-- Votes (Abstimmungen)
CREATE TABLE assembly_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
agenda_item_id UUID NOT NULL REFERENCES assembly_agenda_items(id),
title VARCHAR(300) NOT NULL,
description TEXT,
vote_type VARCHAR(30) NOT NULL,
yes_count INTEGER DEFAULT 0,
no_count INTEGER DEFAULT 0,
abstain_count INTEGER DEFAULT 0,
result VARCHAR(20),
voted_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- Individual vote records (for transparency, not secret ballot)
CREATE TABLE assembly_vote_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vote_id UUID NOT NULL REFERENCES assembly_votes(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id),
decision VARCHAR(10) NOT NULL,
voted_at TIMESTAMP DEFAULT NOW(),
UNIQUE(vote_id, member_id)
);
-- Indexes
CREATE INDEX idx_assemblies_club ON assemblies(club_id);
CREATE INDEX idx_assemblies_tenant ON assemblies(tenant_id);
CREATE INDEX idx_assemblies_status ON assemblies(club_id, status);
CREATE INDEX idx_agenda_items_assembly ON assembly_agenda_items(assembly_id);
CREATE INDEX idx_attendees_assembly ON assembly_attendees(assembly_id);
CREATE INDEX idx_votes_assembly ON assembly_votes(assembly_id);
CREATE INDEX idx_vote_records_vote ON assembly_vote_records(vote_id);
@@ -0,0 +1,23 @@
-- V20: Document archive for club documents (Satzung, Protokolle, Verträge, etc.)
-- Legal basis: §22 KCanG (Dokumentationspflichten), §147 AO (Aufbewahrungspflichten)
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(300) NOT NULL,
category VARCHAR(50) NOT NULL,
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL,
file_size BIGINT NOT NULL,
storage_path VARCHAR(500) NOT NULL,
access_level VARCHAR(20) NOT NULL DEFAULT 'ALL_MEMBERS',
description TEXT,
uploaded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_documents_club ON documents(club_id);
CREATE INDEX idx_documents_category ON documents(club_id, category);
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
@@ -0,0 +1,33 @@
-- V21: Board management (Vorstandsverwaltung)
-- Legal basis: §26 BGB (Vorstand), §27 BGB (Bestellung/Abberufung), §23 KCanG (Präventionsbeauftragter)
CREATE TABLE board_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(100) NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE board_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
position_id UUID NOT NULL REFERENCES board_positions(id),
member_id UUID NOT NULL REFERENCES members(id),
elected_at DATE NOT NULL,
term_start DATE NOT NULL,
term_end DATE,
is_current BOOLEAN DEFAULT TRUE,
elected_in_assembly_id UUID REFERENCES assemblies(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_board_positions_club ON board_positions(club_id);
CREATE INDEX idx_board_positions_tenant ON board_positions(tenant_id);
CREATE INDEX idx_board_members_club ON board_members(club_id);
CREATE INDEX idx_board_members_tenant ON board_members(tenant_id);
CREATE INDEX idx_board_members_current ON board_members(club_id, is_current) WHERE is_current = TRUE;
@@ -0,0 +1,2 @@
-- V22: Add protocol_document_id to assemblies for auto-archive feature
ALTER TABLE assemblies ADD COLUMN IF NOT EXISTS protocol_document_id UUID;
@@ -0,0 +1,18 @@
-- Sprint 9: Destruction records per KCanG §22
CREATE TABLE destruction_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
batch_id UUID REFERENCES batches(id),
amount_grams NUMERIC(8,2) NOT NULL,
destruction_method VARCHAR(50) NOT NULL,
description TEXT,
destroyed_at TIMESTAMP NOT NULL,
witnessed_by UUID REFERENCES users(id),
witness_name VARCHAR(200),
recorded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_destruction_records_tenant ON destruction_records(tenant_id);
CREATE INDEX idx_destruction_records_club ON destruction_records(club_id);
@@ -0,0 +1,19 @@
-- Sprint 9: Transport records per KCanG §22 transport documentation
CREATE TABLE transport_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
description TEXT NOT NULL,
transport_date DATE NOT NULL,
from_location VARCHAR(300) NOT NULL,
to_location VARCHAR(300) NOT NULL,
carrier_name VARCHAR(200) NOT NULL,
amount_grams NUMERIC(8,2) NOT NULL,
batch_id UUID REFERENCES batches(id),
status VARCHAR(50) NOT NULL DEFAULT 'PLANNED',
recorded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_transport_records_tenant ON transport_records(tenant_id);
CREATE INDEX idx_transport_records_club ON transport_records(club_id);
@@ -0,0 +1,17 @@
-- Sprint 9: Propagation sources (seed/cutting tracking per KCanG §16)
CREATE TABLE propagation_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
source_type VARCHAR(50) NOT NULL, -- SEED, CUTTING
supplier VARCHAR(300),
quantity INTEGER NOT NULL,
strain_id UUID REFERENCES strains(id),
received_at DATE NOT NULL,
documentation_reference VARCHAR(200),
recorded_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_propagation_sources_tenant ON propagation_sources(tenant_id);
CREATE INDEX idx_propagation_sources_club ON propagation_sources(club_id);
@@ -0,0 +1,15 @@
-- Sprint 9: Prevention activities per KCanG §23 Suchtprävention
CREATE TABLE prevention_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
activity_date DATE NOT NULL,
title VARCHAR(300) NOT NULL,
description TEXT,
participants_count INTEGER,
officer_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_prevention_activities_tenant ON prevention_activities(tenant_id);
CREATE INDEX idx_prevention_activities_club ON prevention_activities(club_id);
@@ -0,0 +1,18 @@
-- Sprint 9: Generated reports metadata
CREATE TABLE generated_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
report_type VARCHAR(50) NOT NULL,
report_format VARCHAR(10) NOT NULL,
title VARCHAR(300) NOT NULL,
file_size BIGINT,
storage_path VARCHAR(500),
parameters JSONB,
generated_by UUID NOT NULL REFERENCES users(id),
generated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id);
CREATE INDEX idx_generated_reports_club ON generated_reports(club_id);
CREATE INDEX idx_generated_reports_type ON generated_reports(club_id, report_type);
@@ -0,0 +1,18 @@
-- Sprint 9: Compliance deadlines tracking
CREATE TABLE compliance_deadlines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
area VARCHAR(50) NOT NULL,
title VARCHAR(300) NOT NULL,
description TEXT,
due_date DATE NOT NULL,
is_recurring BOOLEAN DEFAULT FALSE,
recurrence_rule VARCHAR(50),
completed_at TIMESTAMP,
completed_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_compliance_deadlines_tenant ON compliance_deadlines(tenant_id);
CREATE INDEX idx_compliance_deadlines_club_due ON compliance_deadlines(club_id, due_date);
@@ -0,0 +1,4 @@
-- Sprint 9: Add THC/CBD percentage + strain name to distributions (KCanG §19(4))
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS thc_percentage NUMERIC(4,2);
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS cbd_percentage NUMERIC(4,2);
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS strain_name VARCHAR(200);
@@ -0,0 +1,25 @@
-- Sprint 10: Bank statement import sessions
-- Each upload of a bank statement creates one session, which is then matched + reviewed by an admin.
-- Status flow: PENDING → IN_REVIEW → COMPLETED (or FAILED at any point).
-- Once COMPLETED, the session is immutable per GoBD requirements (§147 AO).
CREATE TABLE bank_import_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
format VARCHAR(20) NOT NULL, -- MT940, CAMT053, CSV
total_transactions INTEGER NOT NULL DEFAULT 0,
matched_count INTEGER NOT NULL DEFAULT 0,
confirmed_count INTEGER NOT NULL DEFAULT 0,
skipped_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, IN_REVIEW, COMPLETED, FAILED
uploaded_by UUID NOT NULL REFERENCES users(id),
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
completed_at TIMESTAMP
);
CREATE INDEX idx_bank_import_sessions_tenant ON bank_import_sessions(tenant_id);
CREATE INDEX idx_bank_import_sessions_club ON bank_import_sessions(club_id);
CREATE INDEX idx_bank_import_sessions_status ON bank_import_sessions(club_id, status);
CREATE INDEX idx_bank_import_sessions_created ON bank_import_sessions(club_id, created_at DESC);
@@ -0,0 +1,32 @@
-- Sprint 10: Parsed bank transactions
-- One row per transaction in an uploaded bank statement.
-- amount_cents: positive = incoming (potential member payment), negative = outgoing (expense).
-- match_status drives the review UI: UNMATCHED/SUGGESTED/MATCHED/CONFIRMED/SKIPPED.
-- CASCADE on session delete: discarding a draft session also deletes its parsed rows.
-- SET NULL on member/payment delete: history is preserved even if the matched entity is removed.
CREATE TABLE bank_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
session_id UUID NOT NULL REFERENCES bank_import_sessions(id) ON DELETE CASCADE,
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
booking_date DATE NOT NULL,
value_date DATE,
amount_cents INTEGER NOT NULL, -- positive = incoming, negative = outgoing
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
reference_text TEXT, -- Verwendungszweck
counterparty_name VARCHAR(300),
counterparty_iban VARCHAR(34),
bank_reference VARCHAR(100),
match_status VARCHAR(20) NOT NULL DEFAULT 'UNMATCHED',-- UNMATCHED, SUGGESTED, MATCHED, CONFIRMED, SKIPPED
match_confidence INTEGER, -- 0-100, only populated when match_status != UNMATCHED
matched_member_id UUID REFERENCES members(id) ON DELETE SET NULL,
matched_payment_id UUID REFERENCES payments(id) ON DELETE SET NULL,
skip_reason VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_bank_transactions_tenant ON bank_transactions(tenant_id);
CREATE INDEX idx_bank_transactions_session ON bank_transactions(session_id);
CREATE INDEX idx_bank_transactions_club_status ON bank_transactions(club_id, match_status);
CREATE INDEX idx_bank_transactions_member ON bank_transactions(matched_member_id);
CREATE INDEX idx_bank_transactions_payment ON bank_transactions(matched_payment_id);
@@ -0,0 +1,31 @@
-- Sprint 10: CSV column mapping templates + member IBAN fields
-- CSV files have no standard layout — each bank uses different columns/encodings.
-- Admins create a named mapping per bank (e.g. "Sparkasse Export") that the parser reuses.
CREATE TABLE csv_column_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- e.g. "Sparkasse Export"
date_column INTEGER NOT NULL,
amount_column INTEGER NOT NULL,
reference_column INTEGER,
counterparty_column INTEGER,
iban_column INTEGER,
delimiter VARCHAR(5) NOT NULL DEFAULT ';',
date_format VARCHAR(20) NOT NULL DEFAULT 'dd.MM.yyyy',
decimal_separator VARCHAR(1) NOT NULL DEFAULT ',',
skip_header_rows INTEGER NOT NULL DEFAULT 1,
encoding VARCHAR(20) NOT NULL DEFAULT 'ISO-8859-1',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_csv_column_mappings_tenant ON csv_column_mappings(tenant_id);
CREATE INDEX idx_csv_column_mappings_club ON csv_column_mappings(club_id);
-- Add optional IBAN fields to members.
-- Both columns are intentionally NULLABLE — IBAN is only populated after explicit
-- BANK_DATA consent (DSGVO Art. 6(1)(a)). ibanConsentDate records when consent was given.
-- PostgreSQL adds nullable columns instantly (no table rewrite), safe for production.
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34);
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMP;
@@ -0,0 +1,11 @@
-- Sprint 10 Phase 3: Add SHA-256 file hash column for stronger duplicate-import detection.
-- The Phase 1 filename-based check is kept as a soft warning; the hash provides hard 409 dedup
-- (a renamed copy of the same file is still detected).
ALTER TABLE bank_import_sessions
ADD COLUMN file_hash VARCHAR(64);
-- Unique per club to allow the same file to be imported by different tenants in DEV/QA.
-- NULL values are allowed for legacy rows created before V33.
CREATE UNIQUE INDEX uk_bank_import_sessions_club_hash
ON bank_import_sessions(club_id, file_hash)
WHERE file_hash IS NOT NULL;
@@ -18,3 +18,6 @@ cannamanage.security.jwt.refresh-token-expiry=2592000
# AOP
spring.aop.auto=true
spring.aop.proxy-target-class=true
# Disable schedulers in tests
cannamanage.schedulers.enabled=false
@@ -0,0 +1,115 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.AssemblyStatus;
import de.cannamanage.domain.enums.AssemblyType;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* General assembly (Mitgliederversammlung) entity.
* Legal basis: §32 BGB (decision-making organ), §36 BGB (notice period).
*/
@Entity
@Table(name = "assemblies", indexes = {
@Index(name = "idx_assemblies_club", columnList = "club_id"),
@Index(name = "idx_assemblies_tenant", columnList = "tenant_id"),
@Index(name = "idx_assemblies_status", columnList = "club_id, status")
})
public class Assembly extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Enumerated(EnumType.STRING)
@Column(name = "assembly_type", nullable = false, length = 30)
private AssemblyType assemblyType;
@Column(name = "scheduled_at", nullable = false)
private Instant scheduledAt;
@Column(name = "location", length = 300)
private String location;
@Column(name = "invitation_sent_at")
private Instant invitationSentAt;
@Column(name = "invitation_deadline")
private LocalDate invitationDeadline;
@Column(name = "quorum_required")
private Integer quorumRequired;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
private AssemblyStatus status = AssemblyStatus.PLANNED;
@Column(name = "opened_at")
private Instant openedAt;
@Column(name = "closed_at")
private Instant closedAt;
@Column(name = "protocol_document_id")
private UUID protocolDocumentId;
@Column(name = "created_by", nullable = false)
private UUID createdBy;
@Column(name = "updated_at")
private Instant updatedAt;
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public AssemblyType getAssemblyType() { return assemblyType; }
public void setAssemblyType(AssemblyType assemblyType) { this.assemblyType = assemblyType; }
public Instant getScheduledAt() { return scheduledAt; }
public void setScheduledAt(Instant scheduledAt) { this.scheduledAt = scheduledAt; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public Instant getInvitationSentAt() { return invitationSentAt; }
public void setInvitationSentAt(Instant invitationSentAt) { this.invitationSentAt = invitationSentAt; }
public LocalDate getInvitationDeadline() { return invitationDeadline; }
public void setInvitationDeadline(LocalDate invitationDeadline) { this.invitationDeadline = invitationDeadline; }
public Integer getQuorumRequired() { return quorumRequired; }
public void setQuorumRequired(Integer quorumRequired) { this.quorumRequired = quorumRequired; }
public AssemblyStatus getStatus() { return status; }
public void setStatus(AssemblyStatus status) { this.status = status; }
public Instant getOpenedAt() { return openedAt; }
public void setOpenedAt(Instant openedAt) { this.openedAt = openedAt; }
public Instant getClosedAt() { return closedAt; }
public void setClosedAt(Instant closedAt) { this.closedAt = closedAt; }
public UUID getCreatedBy() { return createdBy; }
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
public UUID getProtocolDocumentId() { return protocolDocumentId; }
public void setProtocolDocumentId(UUID protocolDocumentId) { this.protocolDocumentId = protocolDocumentId; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,69 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.AgendaItemType;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Agenda item (Tagesordnungspunkt / TOP) for a general assembly.
*/
@Entity
@Table(name = "assembly_agenda_items", indexes = {
@Index(name = "idx_agenda_items_assembly", columnList = "assembly_id")
})
public class AssemblyAgendaItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "assembly_id", nullable = false)
private UUID assemblyId;
@Column(name = "position", nullable = false)
private Integer position;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "item_type", nullable = false, length = 30)
private AgendaItemType itemType;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.createdAt = Instant.now();
}
// Getters and setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getAssemblyId() { return assemblyId; }
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
public Integer getPosition() { return position; }
public void setPosition(Integer position) { this.position = position; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public AgendaItemType getItemType() { return itemType; }
public void setItemType(AgendaItemType itemType) { this.itemType = itemType; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
@@ -0,0 +1,60 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Attendance record for a general assembly.
* Supports proxy voting (Vollmacht) via proxyForMemberId.
*/
@Entity
@Table(name = "assembly_attendees", indexes = {
@Index(name = "idx_attendees_assembly", columnList = "assembly_id")
}, uniqueConstraints = {
@UniqueConstraint(name = "uq_attendee_assembly_member", columnNames = {"assembly_id", "member_id"})
})
public class AssemblyAttendee {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "assembly_id", nullable = false)
private UUID assemblyId;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "checked_in_at")
private Instant checkedInAt;
@Column(name = "proxy_for_member_id")
private UUID proxyForMemberId;
@PrePersist
void onCreate() {
if (this.checkedInAt == null) {
this.checkedInAt = Instant.now();
}
}
// Getters and setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getAssemblyId() { return assemblyId; }
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public Instant getCheckedInAt() { return checkedInAt; }
public void setCheckedInAt(Instant checkedInAt) { this.checkedInAt = checkedInAt; }
public UUID getProxyForMemberId() { return proxyForMemberId; }
public void setProxyForMemberId(UUID proxyForMemberId) { this.proxyForMemberId = proxyForMemberId; }
}
@@ -0,0 +1,101 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.VoteResult;
import de.cannamanage.domain.enums.VoteType;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Vote (Abstimmung) entity for a specific agenda item.
*/
@Entity
@Table(name = "assembly_votes", indexes = {
@Index(name = "idx_votes_assembly", columnList = "assembly_id")
})
public class AssemblyVote {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "assembly_id", nullable = false)
private UUID assemblyId;
@Column(name = "agenda_item_id", nullable = false)
private UUID agendaItemId;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "vote_type", nullable = false, length = 30)
private VoteType voteType;
@Column(name = "yes_count", nullable = false)
private int yesCount = 0;
@Column(name = "no_count", nullable = false)
private int noCount = 0;
@Column(name = "abstain_count", nullable = false)
private int abstainCount = 0;
@Enumerated(EnumType.STRING)
@Column(name = "result", length = 20)
private VoteResult result;
@Column(name = "voted_at")
private Instant votedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.createdAt = Instant.now();
}
// Getters and setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getAssemblyId() { return assemblyId; }
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
public UUID getAgendaItemId() { return agendaItemId; }
public void setAgendaItemId(UUID agendaItemId) { this.agendaItemId = agendaItemId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public VoteType getVoteType() { return voteType; }
public void setVoteType(VoteType voteType) { this.voteType = voteType; }
public int getYesCount() { return yesCount; }
public void setYesCount(int yesCount) { this.yesCount = yesCount; }
public int getNoCount() { return noCount; }
public void setNoCount(int noCount) { this.noCount = noCount; }
public int getAbstainCount() { return abstainCount; }
public void setAbstainCount(int abstainCount) { this.abstainCount = abstainCount; }
public VoteResult getResult() { return result; }
public void setResult(VoteResult result) { this.result = result; }
public Instant getVotedAt() { return votedAt; }
public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
@@ -0,0 +1,62 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.VoteDecision;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Individual vote record — records each member's decision on a vote.
* NOT secret ballot: each member's vote is recorded (standard for most Vereinsversammlungen).
*/
@Entity
@Table(name = "assembly_vote_records", indexes = {
@Index(name = "idx_vote_records_vote", columnList = "vote_id")
}, uniqueConstraints = {
@UniqueConstraint(name = "uq_vote_record_vote_member", columnNames = {"vote_id", "member_id"})
})
public class AssemblyVoteRecord {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "vote_id", nullable = false)
private UUID voteId;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Enumerated(EnumType.STRING)
@Column(name = "decision", nullable = false, length = 10)
private VoteDecision decision;
@Column(name = "voted_at", nullable = false)
private Instant votedAt;
@PrePersist
void onCreate() {
if (this.votedAt == null) {
this.votedAt = Instant.now();
}
}
// Getters and setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getVoteId() { return voteId; }
public void setVoteId(UUID voteId) { this.voteId = voteId; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public VoteDecision getDecision() { return decision; }
public void setDecision(VoteDecision decision) { this.decision = decision; }
public Instant getVotedAt() { return votedAt; }
public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; }
}
@@ -0,0 +1,102 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.BankFormat;
import de.cannamanage.domain.enums.ImportSessionStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Sprint 10 — One upload of a bank statement file. Owns the parsed {@link BankTransaction}s
* and tracks review progress until the admin marks the session COMPLETED.
* <p>
* Status lifecycle: {@code PENDING} → {@code IN_REVIEW} → {@code COMPLETED}.
* After COMPLETED the session is immutable per GoBD (§147 AO).
* {@code FAILED} is a terminal state for parse errors or discarded sessions.
*/
@Entity
@Table(name = "bank_import_sessions")
public class BankImportSession extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "filename", nullable = false, length = 255)
private String filename;
@Enumerated(EnumType.STRING)
@Column(name = "format", nullable = false, length = 20)
private BankFormat format;
@Column(name = "total_transactions", nullable = false)
private Integer totalTransactions = 0;
@Column(name = "matched_count", nullable = false)
private Integer matchedCount = 0;
@Column(name = "confirmed_count", nullable = false)
private Integer confirmedCount = 0;
@Column(name = "skipped_count", nullable = false)
private Integer skippedCount = 0;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ImportSessionStatus status = ImportSessionStatus.PENDING;
@Column(name = "uploaded_by", nullable = false)
private UUID uploadedBy;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "completed_at")
private Instant completedAt;
/**
* SHA-256 hex digest of the uploaded file content (Sprint 10 Phase 3).
* Used together with {@code clubId} to reject byte-identical re-uploads (HTTP 409),
* even when the file has been renamed. Nullable for legacy rows created before V33.
*/
@Column(name = "file_hash", length = 64)
private String fileHash;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public BankFormat getFormat() { return format; }
public void setFormat(BankFormat format) { this.format = format; }
public Integer getTotalTransactions() { return totalTransactions; }
public void setTotalTransactions(Integer totalTransactions) { this.totalTransactions = totalTransactions; }
public Integer getMatchedCount() { return matchedCount; }
public void setMatchedCount(Integer matchedCount) { this.matchedCount = matchedCount; }
public Integer getConfirmedCount() { return confirmedCount; }
public void setConfirmedCount(Integer confirmedCount) { this.confirmedCount = confirmedCount; }
public Integer getSkippedCount() { return skippedCount; }
public void setSkippedCount(Integer skippedCount) { this.skippedCount = skippedCount; }
public ImportSessionStatus getStatus() { return status; }
public void setStatus(ImportSessionStatus status) { this.status = status; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Instant getCompletedAt() { return completedAt; }
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
public String getFileHash() { return fileHash; }
public void setFileHash(String fileHash) { this.fileHash = fileHash; }
}
@@ -0,0 +1,124 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.MatchStatus;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
/**
* Sprint 10 — One transaction line parsed from a bank statement file.
* <p>
* Sign convention for {@code amountCents}: <strong>positive = incoming</strong>
* (potential member payment), <strong>negative = outgoing</strong> (expense).
* <p>
* Match flow: parser writes UNMATCHED → matching engine sets MATCHED/SUGGESTED →
* admin sets CONFIRMED or SKIPPED. CONFIRMED links to the created {@link Payment}
* via {@code matchedPaymentId}.
* <p>
* Foreign keys to {@code members} and {@code payments} are not modelled as JPA
* relationships to keep the entity flat (UUIDs only) — consistent with the rest
* of the schema (see {@link Payment} for the same pattern). Deletes use
* {@code SET NULL} so transaction history survives member/payment removal.
*/
@Entity
@Table(name = "bank_transactions")
public class BankTransaction extends AbstractTenantEntity {
@Column(name = "session_id", nullable = false, updatable = false)
private UUID sessionId;
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "booking_date", nullable = false)
private LocalDate bookingDate;
@Column(name = "value_date")
private LocalDate valueDate;
/** Positive = incoming, negative = outgoing. Stored in cents to avoid floating-point. */
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Column(name = "currency", nullable = false, length = 3)
private String currency = "EUR";
/** German "Verwendungszweck" — free-text payment reference. */
@Column(name = "reference_text", columnDefinition = "TEXT")
private String referenceText;
@Column(name = "counterparty_name", length = 300)
private String counterpartyName;
@Column(name = "counterparty_iban", length = 34)
private String counterpartyIban;
/** Bank's own internal transaction reference (EREF, KREF, MREF). */
@Column(name = "bank_reference", length = 100)
private String bankReference;
@Enumerated(EnumType.STRING)
@Column(name = "match_status", nullable = false, length = 20)
private MatchStatus matchStatus = MatchStatus.UNMATCHED;
/** 0-100; only meaningful when {@link #matchStatus} is not UNMATCHED. */
@Column(name = "match_confidence")
private Integer matchConfidence;
@Column(name = "matched_member_id")
private UUID matchedMemberId;
@Column(name = "matched_payment_id")
private UUID matchedPaymentId;
@Column(name = "skip_reason", length = 100)
private String skipReason;
// Getters and setters
public UUID getSessionId() { return sessionId; }
public void setSessionId(UUID sessionId) { this.sessionId = sessionId; }
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public LocalDate getBookingDate() { return bookingDate; }
public void setBookingDate(LocalDate bookingDate) { this.bookingDate = bookingDate; }
public LocalDate getValueDate() { return valueDate; }
public void setValueDate(LocalDate valueDate) { this.valueDate = valueDate; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getReferenceText() { return referenceText; }
public void setReferenceText(String referenceText) { this.referenceText = referenceText; }
public String getCounterpartyName() { return counterpartyName; }
public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; }
public String getCounterpartyIban() { return counterpartyIban; }
public void setCounterpartyIban(String counterpartyIban) { this.counterpartyIban = counterpartyIban; }
public String getBankReference() { return bankReference; }
public void setBankReference(String bankReference) { this.bankReference = bankReference; }
public MatchStatus getMatchStatus() { return matchStatus; }
public void setMatchStatus(MatchStatus matchStatus) { this.matchStatus = matchStatus; }
public Integer getMatchConfidence() { return matchConfidence; }
public void setMatchConfidence(Integer matchConfidence) { this.matchConfidence = matchConfidence; }
public UUID getMatchedMemberId() { return matchedMemberId; }
public void setMatchedMemberId(UUID matchedMemberId) { this.matchedMemberId = matchedMemberId; }
public UUID getMatchedPaymentId() { return matchedPaymentId; }
public void setMatchedPaymentId(UUID matchedPaymentId) { this.matchedPaymentId = matchedPaymentId; }
public String getSkipReason() { return skipReason; }
public void setSkipReason(String skipReason) { this.skipReason = skipReason; }
}
@@ -0,0 +1,69 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* Board member assignment — links a member to a board position for a term.
* Legal basis: §27 BGB (Bestellung/Abberufung des Vorstands).
*/
@Entity
@Table(name = "board_members", indexes = {
@Index(name = "idx_board_members_club", columnList = "club_id"),
@Index(name = "idx_board_members_tenant", columnList = "tenant_id")
})
public class BoardMember extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "position_id", nullable = false)
private UUID positionId;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "elected_at", nullable = false)
private LocalDate electedAt;
@Column(name = "term_start", nullable = false)
private LocalDate termStart;
@Column(name = "term_end")
private LocalDate termEnd;
@Column(name = "is_current", nullable = false)
private Boolean isCurrent = true;
@Column(name = "elected_in_assembly_id")
private UUID electedInAssemblyId;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public UUID getPositionId() { return positionId; }
public void setPositionId(UUID positionId) { this.positionId = positionId; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public LocalDate getElectedAt() { return electedAt; }
public void setElectedAt(LocalDate electedAt) { this.electedAt = electedAt; }
public LocalDate getTermStart() { return termStart; }
public void setTermStart(LocalDate termStart) { this.termStart = termStart; }
public LocalDate getTermEnd() { return termEnd; }
public void setTermEnd(LocalDate termEnd) { this.termEnd = termEnd; }
public Boolean getIsCurrent() { return isCurrent; }
public void setIsCurrent(Boolean isCurrent) { this.isCurrent = isCurrent; }
public UUID getElectedInAssemblyId() { return electedInAssemblyId; }
public void setElectedInAssemblyId(UUID electedInAssemblyId) { this.electedInAssemblyId = electedInAssemblyId; }
}
@@ -0,0 +1,50 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Board position definition (e.g., "1. Vorsitzender", "Kassenwart", "Präventionsbeauftragter").
* Legal basis: §26 BGB (Vorstand), §30 BGB (Besonderer Vertreter), §23 KCanG (Präventionsbeauftragter).
*/
@Entity
@Table(name = "board_positions", indexes = {
@Index(name = "idx_board_positions_club", columnList = "club_id"),
@Index(name = "idx_board_positions_tenant", columnList = "tenant_id")
})
public class BoardPosition extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 100)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -0,0 +1,139 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.EventType;
import de.cannamanage.domain.enums.RecurrenceRule;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Club event entity — supports RSVP, recurring events, and iCal export.
*/
@Entity
@Table(name = "club_events", indexes = {
@Index(name = "idx_club_events_tenant_start", columnList = "tenant_id, start_at"),
@Index(name = "idx_club_events_type", columnList = "tenant_id, event_type"),
@Index(name = "idx_club_events_club_id", columnList = "club_id")
})
public class ClubEvent extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 50)
private EventType eventType;
@Column(name = "start_at", nullable = false)
private Instant startAt;
@Column(name = "end_at")
private Instant endAt;
@Column(name = "location", length = 300)
private String location;
@Column(name = "max_attendees")
private Integer maxAttendees;
@Column(name = "is_recurring", nullable = false)
private boolean recurring = false;
@Enumerated(EnumType.STRING)
@Column(name = "recurrence_rule", length = 100)
private RecurrenceRule recurrenceRule;
@Column(name = "recurrence_end_date")
private LocalDate recurrenceEndDate;
@Column(name = "reminder_sent", nullable = false)
private boolean reminderSent = false;
@Column(name = "created_by", nullable = false)
private UUID createdBy;
@Column(name = "updated_at")
private Instant updatedAt;
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
private List<EventRsvp> rsvps = new ArrayList<>();
public ClubEvent() {}
public ClubEvent(UUID clubId, String title, String description, EventType eventType,
Instant startAt, Instant endAt, String location, Integer maxAttendees,
UUID createdBy) {
this.clubId = clubId;
this.title = title;
this.description = description;
this.eventType = eventType;
this.startAt = startAt;
this.endAt = endAt;
this.location = location;
this.maxAttendees = maxAttendees;
this.createdBy = createdBy;
this.updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and Setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public EventType getEventType() { return eventType; }
public void setEventType(EventType eventType) { this.eventType = eventType; }
public Instant getStartAt() { return startAt; }
public void setStartAt(Instant startAt) { this.startAt = startAt; }
public Instant getEndAt() { return endAt; }
public void setEndAt(Instant endAt) { this.endAt = endAt; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public Integer getMaxAttendees() { return maxAttendees; }
public void setMaxAttendees(Integer maxAttendees) { this.maxAttendees = maxAttendees; }
public boolean isRecurring() { return recurring; }
public void setRecurring(boolean recurring) { this.recurring = recurring; }
public RecurrenceRule getRecurrenceRule() { return recurrenceRule; }
public void setRecurrenceRule(RecurrenceRule recurrenceRule) { this.recurrenceRule = recurrenceRule; }
public LocalDate getRecurrenceEndDate() { return recurrenceEndDate; }
public void setRecurrenceEndDate(LocalDate recurrenceEndDate) { this.recurrenceEndDate = recurrenceEndDate; }
public boolean isReminderSent() { return reminderSent; }
public void setReminderSent(boolean reminderSent) { this.reminderSent = reminderSent; }
public UUID getCreatedBy() { return createdBy; }
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public List<EventRsvp> getRsvps() { return rsvps; }
public void setRsvps(List<EventRsvp> rsvps) { this.rsvps = rsvps; }
}
@@ -0,0 +1,74 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ComplianceArea;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* Tracks compliance deadlines with optional recurrence.
* Powers the compliance dashboard traffic-light system.
*/
@Entity
@Table(name = "compliance_deadlines")
public class ComplianceDeadline extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "area", nullable = false, length = 50)
private ComplianceArea area;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "description")
private String description;
@Column(name = "due_date", nullable = false)
private LocalDate dueDate;
@Column(name = "is_recurring")
private Boolean isRecurring = false;
@Column(name = "recurrence_rule", length = 50)
private String recurrenceRule;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "completed_by")
private UUID completedBy;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public ComplianceArea getArea() { return area; }
public void setArea(ComplianceArea area) { this.area = area; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDate getDueDate() { return dueDate; }
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
public Boolean getIsRecurring() { return isRecurring; }
public void setIsRecurring(Boolean recurring) { isRecurring = recurring; }
public String getRecurrenceRule() { return recurrenceRule; }
public void setRecurrenceRule(String recurrenceRule) { this.recurrenceRule = recurrenceRule; }
public Instant getCompletedAt() { return completedAt; }
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
public UUID getCompletedBy() { return completedBy; }
public void setCompletedBy(UUID completedBy) { this.completedBy = completedBy; }
}
@@ -0,0 +1,107 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.util.UUID;
/**
* Sprint 10 — Saved CSV column mapping template for a club.
* <p>
* CSV bank exports have no standard layout: column order, delimiter, encoding,
* date format, and decimal separator all vary by bank. Rather than asking the
* admin to re-configure on every upload, mappings are saved per bank
* (e.g. "Sparkasse Export", "DKB Online").
* <p>
* One mapping per club may be flagged as {@link #isDefault} — used to
* pre-populate the upload wizard.
*/
@Entity
@Table(name = "csv_column_mappings")
public class CsvColumnMapping extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
/** User-facing label, e.g. "Sparkasse Export" or "DKB Online". */
@Column(name = "name", nullable = false, length = 100)
private String name;
/** 0-based column index of the booking date. */
@Column(name = "date_column", nullable = false)
private Integer dateColumn;
/** 0-based column index of the amount field (sign convention: bank's own). */
@Column(name = "amount_column", nullable = false)
private Integer amountColumn;
@Column(name = "reference_column")
private Integer referenceColumn;
@Column(name = "counterparty_column")
private Integer counterpartyColumn;
@Column(name = "iban_column")
private Integer ibanColumn;
@Column(name = "delimiter", nullable = false, length = 5)
private String delimiter = ";";
/** Pattern compatible with {@code DateTimeFormatter.ofPattern}, e.g. {@code dd.MM.yyyy}. */
@Column(name = "date_format", nullable = false, length = 20)
private String dateFormat = "dd.MM.yyyy";
/** Single character — typically "," (German) or "." (English). */
@Column(name = "decimal_separator", nullable = false, length = 1)
private String decimalSeparator = ",";
@Column(name = "skip_header_rows", nullable = false)
private Integer skipHeaderRows = 1;
/** Character set name, e.g. {@code ISO-8859-1} (German default), {@code UTF-8}, {@code windows-1252}. */
@Column(name = "encoding", nullable = false, length = 20)
private String encoding = "ISO-8859-1";
@Column(name = "is_default", nullable = false)
private Boolean isDefault = false;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getDateColumn() { return dateColumn; }
public void setDateColumn(Integer dateColumn) { this.dateColumn = dateColumn; }
public Integer getAmountColumn() { return amountColumn; }
public void setAmountColumn(Integer amountColumn) { this.amountColumn = amountColumn; }
public Integer getReferenceColumn() { return referenceColumn; }
public void setReferenceColumn(Integer referenceColumn) { this.referenceColumn = referenceColumn; }
public Integer getCounterpartyColumn() { return counterpartyColumn; }
public void setCounterpartyColumn(Integer counterpartyColumn) { this.counterpartyColumn = counterpartyColumn; }
public Integer getIbanColumn() { return ibanColumn; }
public void setIbanColumn(Integer ibanColumn) { this.ibanColumn = ibanColumn; }
public String getDelimiter() { return delimiter; }
public void setDelimiter(String delimiter) { this.delimiter = delimiter; }
public String getDateFormat() { return dateFormat; }
public void setDateFormat(String dateFormat) { this.dateFormat = dateFormat; }
public String getDecimalSeparator() { return decimalSeparator; }
public void setDecimalSeparator(String decimalSeparator) { this.decimalSeparator = decimalSeparator; }
public Integer getSkipHeaderRows() { return skipHeaderRows; }
public void setSkipHeaderRows(Integer skipHeaderRows) { this.skipHeaderRows = skipHeaderRows; }
public String getEncoding() { return encoding; }
public void setEncoding(String encoding) { this.encoding = encoding; }
public Boolean getIsDefault() { return isDefault; }
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
}
@@ -0,0 +1,51 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.UUID;
/**
* Stores custom mail domain configuration for Enterprise tier clubs.
* Verified via DNS TXT record: cannamanage-verify={verification_token}
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "custom_mail_domains")
public class CustomMailDomain {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(name = "tenant_id", nullable = false, unique = true)
private UUID tenantId;
@Column(name = "from_address", nullable = false)
private String fromAddress;
@Column(name = "domain", nullable = false)
private String domain;
@Column(name = "verification_token", nullable = false, length = 64)
private String verificationToken;
@Column(name = "verified", nullable = false)
private boolean verified = false;
@Column(name = "verified_at")
private Instant verifiedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
public CustomMailDomain(UUID tenantId, String fromAddress, String domain, String verificationToken) {
this.tenantId = tenantId;
this.fromAddress = fromAddress;
this.domain = domain;
this.verificationToken = verificationToken;
}
}
@@ -0,0 +1,74 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.DestructionMethod;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Records cannabis destruction events per KCanG §22.
* Immutable compliance record — never updated after creation.
*/
@Entity
@Table(name = "destruction_records")
public class DestructionRecord extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "batch_id")
private UUID batchId;
@Column(name = "amount_grams", nullable = false, precision = 8, scale = 2)
private BigDecimal amountGrams;
@Enumerated(EnumType.STRING)
@Column(name = "destruction_method", nullable = false, length = 50)
private DestructionMethod destructionMethod;
@Column(name = "description")
private String description;
@Column(name = "destroyed_at", nullable = false)
private Instant destroyedAt;
@Column(name = "witnessed_by")
private UUID witnessedBy;
@Column(name = "witness_name", length = 200)
private String witnessName;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public UUID getBatchId() { return batchId; }
public void setBatchId(UUID batchId) { this.batchId = batchId; }
public BigDecimal getAmountGrams() { return amountGrams; }
public void setAmountGrams(BigDecimal amountGrams) { this.amountGrams = amountGrams; }
public DestructionMethod getDestructionMethod() { return destructionMethod; }
public void setDestructionMethod(DestructionMethod destructionMethod) { this.destructionMethod = destructionMethod; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Instant getDestroyedAt() { return destroyedAt; }
public void setDestroyedAt(Instant destroyedAt) { this.destroyedAt = destroyedAt; }
public UUID getWitnessedBy() { return witnessedBy; }
public void setWitnessedBy(UUID witnessedBy) { this.witnessedBy = witnessedBy; }
public String getWitnessName() { return witnessName; }
public void setWitnessName(String witnessName) { this.witnessName = witnessName; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
}
@@ -0,0 +1,40 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.DevicePlatform;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
/**
* Push notification device token — stores Web Push subscriptions and mobile push tokens.
* A user can have multiple device tokens (multi-device support, max 10 per user).
*/
@Entity
@Table(name = "device_tokens", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "token"})
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DeviceToken extends AbstractTenantEntity {
@Column(name = "user_id", nullable = false)
private UUID userId;
@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private DevicePlatform platform;
@Column(name = "token", nullable = false, columnDefinition = "TEXT")
private String token;
@Column(name = "device_name", length = 100)
private String deviceName;
@Column(name = "last_used_at")
private Instant lastUsedAt;
}
@@ -0,0 +1,96 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Club document entity for the document archive.
* Legal basis: §22 KCanG (documentation requirements), §147 AO (retention).
*/
@Entity
@Table(name = "documents", indexes = {
@Index(name = "idx_documents_club", columnList = "club_id"),
@Index(name = "idx_documents_tenant", columnList = "tenant_id"),
@Index(name = "idx_documents_category", columnList = "club_id, category")
})
public class Document extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Enumerated(EnumType.STRING)
@Column(name = "category", nullable = false, length = 50)
private DocumentCategory category;
@Column(name = "filename", nullable = false, length = 255)
private String filename;
@Column(name = "content_type", nullable = false, length = 100)
private String contentType;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "storage_path", nullable = false, length = 500)
private String storagePath;
@Enumerated(EnumType.STRING)
@Column(name = "access_level", nullable = false, length = 20)
private DocumentAccessLevel accessLevel = DocumentAccessLevel.ALL_MEMBERS;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "uploaded_by", nullable = false)
private UUID uploadedBy;
@Column(name = "updated_at")
private Instant updatedAt;
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public DocumentCategory getCategory() { return category; }
public void setCategory(DocumentCategory category) { this.category = category; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public String getContentType() { return contentType; }
public void setContentType(String contentType) { this.contentType = contentType; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public String getStoragePath() { return storagePath; }
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
public DocumentAccessLevel getAccessLevel() { return accessLevel; }
public void setAccessLevel(DocumentAccessLevel accessLevel) { this.accessLevel = accessLevel; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,62 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.RsvpStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Event RSVP entity — tracks member attendance responses.
* Unique constraint on (event_id, member_id) ensures one RSVP per member per event.
*/
@Entity
@Table(name = "event_rsvps",
uniqueConstraints = @UniqueConstraint(
name = "uq_event_rsvps_event_member",
columnNames = {"event_id", "member_id"}
),
indexes = {
@Index(name = "idx_event_rsvps_event", columnList = "event_id"),
@Index(name = "idx_event_rsvps_member", columnList = "member_id")
}
)
public class EventRsvp extends AbstractTenantEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private ClubEvent event;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private RsvpStatus status;
@Column(name = "responded_at", nullable = false)
private Instant respondedAt;
public EventRsvp() {}
public EventRsvp(ClubEvent event, UUID memberId, RsvpStatus status) {
this.event = event;
this.memberId = memberId;
this.status = status;
this.respondedAt = Instant.now();
}
// Getters and Setters
public ClubEvent getEvent() { return event; }
public void setEvent(ClubEvent event) { this.event = event; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public RsvpStatus getStatus() { return status; }
public void setStatus(RsvpStatus status) { this.status = status; }
public Instant getRespondedAt() { return respondedAt; }
public void setRespondedAt(Instant respondedAt) { this.respondedAt = respondedAt; }
}
@@ -0,0 +1,71 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.FeeInterval;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Fee schedule (Beitragsordnung) — defines a named fee tier for the club.
* Never hard-deleted; set isActive=false to deactivate.
*/
@Entity
@Table(name = "fee_schedules")
public class FeeSchedule extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Enumerated(EnumType.STRING)
@Column(name = "interval", nullable = false, length = 20)
private FeeInterval interval;
@Column(name = "is_default")
private Boolean isDefault = false;
@Column(name = "is_active")
private Boolean isActive = true;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
void onCreateFee() {
this.updatedAt = Instant.now();
}
@PreUpdate
void onUpdateFee() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public FeeInterval getInterval() { return interval; }
public void setInterval(FeeInterval interval) { this.interval = interval; }
public Boolean getIsDefault() { return isDefault; }
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,78 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ForumTargetType;
import de.cannamanage.domain.enums.ReactionType;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Forum reaction entity — one reaction per user per target (topic or reply).
* Toggle behavior: clicking again removes the reaction.
*/
@Entity
@Table(name = "forum_reactions", uniqueConstraints = {
@UniqueConstraint(name = "uq_forum_reactions_target_user",
columnNames = {"target_type", "target_id", "user_id"})
}, indexes = {
@Index(name = "idx_forum_reactions_target", columnList = "target_type, target_id")
})
public class ForumReaction {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(name = "target_type", nullable = false, length = 10)
private ForumTargetType targetType;
@Column(name = "target_id", nullable = false)
private UUID targetId;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Enumerated(EnumType.STRING)
@Column(name = "reaction_type", nullable = false, length = 20)
private ReactionType reactionType;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.createdAt = Instant.now();
}
public ForumReaction() {}
public ForumReaction(ForumTargetType targetType, UUID targetId, UUID userId, ReactionType reactionType) {
this.targetType = targetType;
this.targetId = targetId;
this.userId = userId;
this.reactionType = reactionType;
}
// Getters and setters
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public ForumTargetType getTargetType() { return targetType; }
public void setTargetType(ForumTargetType targetType) { this.targetType = targetType; }
public UUID getTargetId() { return targetId; }
public void setTargetId(UUID targetId) { this.targetId = targetId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public ReactionType getReactionType() { return reactionType; }
public void setReactionType(ReactionType reactionType) { this.reactionType = reactionType; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
@@ -0,0 +1,65 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Forum reply entity — a response to a forum topic.
* Content stored as HTML. Replies can be edited within a 60-minute window.
*/
@Entity
@Table(name = "forum_replies", indexes = {
@Index(name = "idx_forum_replies_topic_id", columnList = "topic_id"),
@Index(name = "idx_forum_replies_tenant_id", columnList = "tenant_id")
})
public class ForumReply extends AbstractTenantEntity {
@Column(name = "topic_id", nullable = false)
private UUID topicId;
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "author_id", nullable = false)
private UUID authorId;
@Column(name = "is_edited", nullable = false)
private boolean edited = false;
@Column(name = "edited_at")
private Instant editedAt;
public ForumReply() {}
public ForumReply(UUID topicId, UUID clubId, String content, UUID authorId) {
this.topicId = topicId;
this.clubId = clubId;
this.content = content;
this.authorId = authorId;
}
// Getters and setters
public UUID getTopicId() { return topicId; }
public void setTopicId(UUID topicId) { this.topicId = topicId; }
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public boolean isEdited() { return edited; }
public void setEdited(boolean edited) { this.edited = edited; }
public Instant getEditedAt() { return editedAt; }
public void setEditedAt(Instant editedAt) { this.editedAt = editedAt; }
}
@@ -0,0 +1,83 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ForumTargetType;
import de.cannamanage.domain.enums.ReportStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Forum report entity — allows members to report inappropriate content.
* Reporter identity is protected: reporterId is NOT exposed in public DTOs (only visible to moderators).
*/
@Entity
@Table(name = "forum_reports", indexes = {
@Index(name = "idx_forum_reports_club_status", columnList = "club_id, status"),
@Index(name = "idx_forum_reports_tenant_id", columnList = "tenant_id")
})
public class ForumReport extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "target_type", nullable = false, length = 10)
private ForumTargetType targetType;
@Column(name = "target_id", nullable = false)
private UUID targetId;
@Column(name = "reporter_id", nullable = false)
private UUID reporterId;
@Column(name = "reason", nullable = false, columnDefinition = "TEXT")
private String reason;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ReportStatus status = ReportStatus.OPEN;
@Column(name = "reviewed_by")
private UUID reviewedBy;
@Column(name = "reviewed_at")
private Instant reviewedAt;
public ForumReport() {}
public ForumReport(UUID clubId, ForumTargetType targetType, UUID targetId, UUID reporterId, String reason) {
this.clubId = clubId;
this.targetType = targetType;
this.targetId = targetId;
this.reporterId = reporterId;
this.reason = reason;
this.status = ReportStatus.OPEN;
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public ForumTargetType getTargetType() { return targetType; }
public void setTargetType(ForumTargetType targetType) { this.targetType = targetType; }
public UUID getTargetId() { return targetId; }
public void setTargetId(UUID targetId) { this.targetId = targetId; }
public UUID getReporterId() { return reporterId; }
public void setReporterId(UUID reporterId) { this.reporterId = reporterId; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public ReportStatus getStatus() { return status; }
public void setStatus(ReportStatus status) { this.status = status; }
public UUID getReviewedBy() { return reviewedBy; }
public void setReviewedBy(UUID reviewedBy) { this.reviewedBy = reviewedBy; }
public Instant getReviewedAt() { return reviewedAt; }
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
}
@@ -0,0 +1,99 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Forum topic entity — club-scoped discussion thread.
* Content is stored as HTML (from Tiptap rich text editor).
* Extends AbstractTenantEntity for automatic tenant isolation.
*/
@Entity
@Table(name = "forum_topics", indexes = {
@Index(name = "idx_forum_topics_club_id", columnList = "club_id"),
@Index(name = "idx_forum_topics_tenant_id", columnList = "tenant_id")
})
public class ForumTopic extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "author_id", nullable = false)
private UUID authorId;
@Column(name = "is_locked", nullable = false)
private boolean locked = false;
@Column(name = "is_pinned", nullable = false)
private boolean pinned = false;
@Column(name = "reply_count", nullable = false)
private int replyCount = 0;
@Column(name = "last_reply_at")
private Instant lastReplyAt;
@Column(name = "updated_at")
private Instant updatedAt;
public ForumTopic() {}
public ForumTopic(UUID clubId, String title, String content, UUID authorId) {
this.clubId = clubId;
this.title = title;
this.content = content;
this.authorId = authorId;
this.updatedAt = Instant.now();
}
@PrePersist
@Override
void onCreate() {
super.onCreate();
if (this.updatedAt == null) {
this.updatedAt = Instant.now();
}
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public boolean isLocked() { return locked; }
public void setLocked(boolean locked) { this.locked = locked; }
public boolean isPinned() { return pinned; }
public void setPinned(boolean pinned) { this.pinned = pinned; }
public int getReplyCount() { return replyCount; }
public void setReplyCount(int replyCount) { this.replyCount = replyCount; }
public Instant getLastReplyAt() { return lastReplyAt; }
public void setLastReplyAt(Instant lastReplyAt) { this.lastReplyAt = lastReplyAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,82 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Metadata for generated reports. The actual file is stored on disk.
* Provides audit trail of all reports generated per tenant.
*/
@Entity
@Table(name = "generated_reports")
public class GeneratedReport extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "report_type", nullable = false, length = 50)
private ReportType reportType;
@Enumerated(EnumType.STRING)
@Column(name = "report_format", nullable = false, length = 10)
private ExportFormat reportFormat;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "file_size")
private Long fileSize;
@Column(name = "storage_path", length = 500)
private String storagePath;
@Column(name = "parameters", columnDefinition = "jsonb")
private String parameters; // JSON string
@Column(name = "generated_by", nullable = false)
private UUID generatedBy;
@Column(name = "generated_at")
private Instant generatedAt;
@PrePersist
void onCreateReport() {
if (this.generatedAt == null) {
this.generatedAt = Instant.now();
}
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public ReportType getReportType() { return reportType; }
public void setReportType(ReportType reportType) { this.reportType = reportType; }
public ExportFormat getReportFormat() { return reportFormat; }
public void setReportFormat(ExportFormat reportFormat) { this.reportFormat = reportFormat; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Long getFileSize() { return fileSize; }
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
public String getStoragePath() { return storagePath; }
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
public String getParameters() { return parameters; }
public void setParameters(String parameters) { this.parameters = parameters; }
public UUID getGeneratedBy() { return generatedBy; }
public void setGeneratedBy(UUID generatedBy) { this.generatedBy = generatedBy; }
public Instant getGeneratedAt() { return generatedAt; }
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
}
@@ -0,0 +1,104 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.InfoBoardCategory;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Info board post entity — club-scoped announcements (Schwarzes Brett).
* Content is stored as HTML (from Tiptap rich text editor).
*/
@Entity
@Table(name = "info_board_posts", indexes = {
@Index(name = "idx_info_board_posts_club_id", columnList = "club_id"),
@Index(name = "idx_info_board_posts_category", columnList = "category"),
@Index(name = "idx_info_board_posts_tenant", columnList = "tenant_id")
})
public class InfoBoardPost extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Enumerated(EnumType.STRING)
@Column(name = "category", nullable = false, length = 50)
private InfoBoardCategory category;
@Column(name = "is_pinned", nullable = false)
private boolean pinned = false;
@Column(name = "is_archived", nullable = false)
private boolean archived = false;
@Column(name = "author_id", nullable = false)
private UUID authorId;
@Column(name = "updated_at")
private Instant updatedAt;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostAttachment> attachments = new ArrayList<>();
public InfoBoardPost() {}
public InfoBoardPost(UUID clubId, String title, String content, InfoBoardCategory category, UUID authorId) {
this.clubId = clubId;
this.title = title;
this.content = content;
this.category = category;
this.authorId = authorId;
this.updatedAt = Instant.now();
}
@PrePersist
@Override
void onCreate() {
super.onCreate();
if (this.updatedAt == null) {
this.updatedAt = Instant.now();
}
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public InfoBoardCategory getCategory() { return category; }
public void setCategory(InfoBoardCategory category) { this.category = category; }
public boolean isPinned() { return pinned; }
public void setPinned(boolean pinned) { this.pinned = pinned; }
public boolean isArchived() { return archived; }
public void setArchived(boolean archived) { this.archived = archived; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public List<PostAttachment> getAttachments() { return attachments; }
public void setAttachments(List<PostAttachment> attachments) { this.attachments = attachments; }
}
@@ -0,0 +1,73 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.TransactionType;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
/**
* Kassenbuch entry — append-only per §147 AO (Aufbewahrungspflicht).
* NO update, NO delete. Corrections are done via compensating entries.
*/
@Entity
@Table(name = "ledger_entries")
public class LedgerEntry extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "transaction_type", nullable = false, length = 10)
private TransactionType transactionType;
@Column(name = "category", nullable = false, length = 50)
private String category;
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Column(name = "description", nullable = false, length = 500)
private String description;
@Column(name = "reference", length = 200)
private String reference;
@Column(name = "payment_id")
private UUID paymentId;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public TransactionType getTransactionType() { return transactionType; }
public void setTransactionType(TransactionType transactionType) { this.transactionType = transactionType; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getReference() { return reference; }
public void setReference(String reference) { this.reference = reference; }
public UUID getPaymentId() { return paymentId; }
public void setPaymentId(UUID paymentId) { this.paymentId = paymentId; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
public LocalDate getTransactionDate() { return transactionDate; }
public void setTransactionDate(LocalDate transactionDate) { this.transactionDate = transactionDate; }
}
@@ -3,6 +3,7 @@ package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.MemberStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
@@ -15,6 +16,9 @@ import java.util.UUID;
)
public class Member extends AbstractTenantEntity {
@Column(name = "user_id")
private UUID userId;
@Column(name = "club_id", nullable = false)
private UUID clubId;
@@ -46,6 +50,20 @@ public class Member extends AbstractTenantEntity {
@Column(name = "prevention_officer", nullable = false)
private boolean preventionOfficer = false;
/**
* Sprint 10 — Member's IBAN, used by the bank statement matching engine.
* Nullable: only populated after the member explicitly grants BANK_DATA consent.
*/
@Column(name = "iban", length = 34)
private String iban;
/** Sprint 10 — Timestamp when BANK_DATA consent was granted for this IBAN. */
@Column(name = "iban_consent_date")
private Instant ibanConsentDate;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
@@ -75,4 +93,10 @@ public class Member extends AbstractTenantEntity {
public boolean isPreventionOfficer() { return preventionOfficer; }
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
public String getIban() { return iban; }
public void setIban(String iban) { this.iban = iban; }
public Instant getIbanConsentDate() { return ibanConsentDate; }
public void setIbanConsentDate(Instant ibanConsentDate) { this.ibanConsentDate = ibanConsentDate; }
}

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