# CannaManage — Sprint 3 Implementation Plan **Date:** 2026-06-11 **Author:** Patrick Plate / Lumen (Planner) **Status:** ✅ APPROVED v2 — GO received **Base Branch:** `sprint/2-api` **Sprint Branch:** `sprint/3-staff-portal` **Sprint Goal:** Staff permission model + Token revocation + Member portal + Club/Report controllers + Prevention Officer + Invite flow --- ## 0. Decisions (Confirmed by Patrick) | # | Decision | Detail | |---|----------|--------| | D1 | JWT invalidation | **Token blacklist** — `revoked_tokens` DB table + Caffeine cache (60s TTL). On permission change, all user's tokens revoked. | | D2 | Portal rendering | **JSON API** — no Thymeleaf. React SPA consumes `/portal/**` with session cookies. | | D3 | Prevention officer | **Multiple, configurable** — `max_prevention_officers` on Club entity (default 2). Enforced on assignment. | | D4 | PDF branding | **Minimal branding** — club name header, generated-at timestamp footer, page numbers. Inspection-ready. | | D5 | Integration test DB | **Testcontainers PostgreSQL** — full fidelity for JSONB columns. | | D6 | Staff creation | **Invite flow** — admin creates account, email invite sent, staff sets own password. Requires Spring Mail. | | D7 | Email domain whitelist | **Regex pattern on Club settings** — `allowed_email_pattern` column, validated on invite. NULL = unrestricted. | --- ## 1. Sprint 2 Recap (Context) | Delivered | Status | |-----------|--------| | JWT auth (login + refresh with token rotation) | ✅ | | SecurityConfig with ADMIN + MEMBER roles | ✅ | | TenantFilterAspect (Hibernate @Filter activation) | ✅ | | MemberController (CRUD) | ✅ | | DistributionController (compliance-gated) | ✅ | | StockController (batches) | ✅ | | ComplianceController (wraps service) | ✅ | | OpenAPI/Swagger | ✅ | | Flyway V2 migration | ✅ | | 25 unit tests passing | ✅ | **Deferred from Sprint 2:** STAFF role, Member portal, Club settings, Report generation, Prevention Officer, Integration tests. --- ## 2. Sprint 3 Scope ### ✅ IN Scope | # | Feature | Priority | Effort | |---|---------|----------|--------| | 1 | **Staff permission model** — `StaffPermission` enum, `staff_accounts` table, `StaffPermissionChecker` SpEL bean | P0 | 1.5 days | | 2 | **Token revocation** — `revoked_tokens` table, `TokenRevocationService`, Caffeine cache, `JwtAuthFilter` integration | P0 | 0.5 days | | 3 | **Club settings controller** — `GET/PUT /clubs/me`, `GET /clubs/me/stats`, email domain whitelist, prevention officer limit | P0 | 0.5 days | | 4 | **Staff management + invite flow** — CRUD + email invite + set-password endpoint + domain validation | P1 | 1.5 days | | 5 | **Report controller** — monthly PDF/CSV/JSON, member list, recall report. Minimal branding (OpenPDF). | P1 | 1.5 days | | 6 | **Member portal (session-based auth)** — second `SecurityFilterChain`, form login, `/portal/**` JSON endpoints | P1 | 1.5 days | | 7 | **Prevention officer capability** — configurable limit, assignment endpoint, under-21 access gate | P2 | 0.5 days | | 8 | **Integration tests** — Testcontainers PostgreSQL: auth flow, tenant isolation, staff perms, portal, reports | P2 | 1 day | **Total estimated effort:** ~9 days (single worker, sequential) ### ❌ OUT of Scope (Sprint 4+) - Stripe payment integration - React frontend SPA (admin + portal) - Schema-per-tenant migration - Grow calendar / cultivation tracking - DSGVO consent management UI - PDF template customization per club (logo upload) - Password reset flow (separate from invite) --- ## 3. Architecture Decisions ### 3.1 Staff Permission Model ```java // New enum — cannamanage-domain public enum StaffPermission { RECORD_DISTRIBUTION, // can record distributions VIEW_MEMBER_LIST, // can view member roster VIEW_MEMBER_QUOTA, // can view individual member quota ADD_MEMBER, // can register new members VIEW_STOCK, // can view batch/strain inventory RECORD_STOCK_IN, // can add new batches VIEW_COMPLIANCE_REPORT, // can generate/download reports MANAGE_GROW_CALENDAR // future — cultivation calendar } ``` **Database design:** ```sql CREATE TABLE staff_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, user_id UUID NOT NULL REFERENCES users(id), display_name VARCHAR(255) NOT NULL, granted_permissions JSONB NOT NULL DEFAULT '[]'::jsonb, template_name VARCHAR(100), -- 'ausgabe', 'lager', 'vorstand', or NULL active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT uq_staff_tenant_user UNIQUE(tenant_id, user_id) ); ``` **Authorization — custom SpEL bean:** ```java @Component("staffPermissions") public class StaffPermissionChecker { public boolean has(MethodSecurityExpressionOperations root, StaffPermission required) { Authentication auth = root.getAuthentication(); if (auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) return true; if (auth.getAuthorities().stream().noneMatch(a -> a.getAuthority().equals("ROLE_STAFF"))) return false; StaffAccount staff = staffAccountRepository.findByUserId(getUserId(auth)); return staff != null && staff.getGrantedPermissions().contains(required); } } ``` **Usage:** ```java @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)") public ResponseEntity recordDistribution(...) ``` **Staff permissions embedded in JWT:** ```json { "sub": "user-uuid", "tenant_id": "tenant-uuid", "role": "STAFF", "permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"], "jti": "unique-token-id", "iat": 1712345678, "exp": 1712349278 } ``` The `jti` claim enables token revocation. Permissions in the JWT allow stateless checks (with blacklist validation as fallback for revoked tokens). --- ### 3.2 Token Revocation (Decision D1) **No Redis** — lightweight DB-based approach with in-memory caching: ```sql CREATE TABLE revoked_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), jti VARCHAR(255) NOT NULL UNIQUE, user_id UUID NOT NULL, expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP NOT NULL DEFAULT NOW(), reason VARCHAR(100) -- 'permission_change', 'logout', 'admin_action' ); CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti); CREATE INDEX idx_revoked_tokens_expires ON revoked_tokens(expires_at); ``` **Components:** ```java @Service public class TokenRevocationService { private final Cache blacklistCache = Caffeine.newBuilder() .expireAfterWrite(60, TimeUnit.SECONDS) .maximumSize(10_000) .build(); public boolean isRevoked(String jti) { return blacklistCache.get(jti, key -> revokedTokenRepository.existsByJti(key)); } public void revokeAllForUser(UUID userId) { // Revoke all active tokens for this user // Called when permissions change or admin deactivates staff } } ``` **JwtAuthFilter integration:** ```java // In JwtAuthFilter.doFilterInternal(): String jti = jwtService.extractClaim(token, "jti"); if (tokenRevocationService.isRevoked(jti)) { response.sendError(401, "Token revoked"); return; } ``` **Cleanup scheduled task:** ```java @Scheduled(cron = "0 0 3 * * *") // daily at 3 AM public void cleanupExpiredTokens() { revokedTokenRepository.deleteByExpiresAtBefore(Instant.now()); } ``` --- ### 3.3 Member Portal Auth (Decision D2) Dual `SecurityFilterChain` — session-based for portal, JWT for API: ```java @Bean @Order(2) public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/portal/**") .csrf(Customizer.withDefaults()) // CSRF enabled .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1)) .formLogin(form -> form .loginPage("/portal/login") .loginProcessingUrl("/portal/login") .defaultSuccessUrl("/portal/dashboard", true) .failureUrl("/portal/login?error")) .logout(logout -> logout .logoutUrl("/portal/logout") .logoutSuccessUrl("/portal/login?logout")) .authorizeHttpRequests(auth -> auth .requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll() .requestMatchers("/portal/**").hasRole("MEMBER")); return http.build(); } ``` **Portal JSON endpoints:** | Endpoint | Method | Description | |----------|--------|-------------| | `/portal/login` | POST | Form login (session cookie returned) | | `/portal/dashboard` | GET | Member dashboard (quota + recent distributions) | | `/portal/me` | GET | Own profile data | | `/portal/quota` | GET | Current month quota status | | `/portal/distributions` | GET | Own distribution history (paginated) | **Security invariants:** - Members ONLY access their own data (enforced by `memberId` from session principal) - All portal endpoints are GET (read-only) — no write operations - Session timeout: 30 minutes - `SameSite=Strict` cookie - CSRF token provided via `CookieCsrfTokenRepository.withHttpOnlyFalse()` --- ### 3.4 Prevention Officer (Decision D3) **Configurable limit on Club entity:** ```java // Club.java @Column(name = "max_prevention_officers") private Integer maxPreventionOfficers = 2; // default: 2 ``` **Enforcement on assignment:** ```java public void assignPreventionOfficer(UUID userId) { long currentCount = userRepository.countByTenantIdAndPreventionOfficerTrue(tenantId); int limit = club.getMaxPreventionOfficers(); if (currentCount >= limit) { throw new PreventionOfficerLimitExceededException(limit); } user.setPreventionOfficer(true); userRepository.save(user); } ``` **Access control for under-21 data:** ```java @PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)") public List getUnder21Members() { ... } ``` --- ### 3.5 Staff Invite Flow (Decision D6) **Sequence:** ``` 1. Admin: POST /api/v1/staff { email, displayName, permissions, templateName? } 2. System: validates email against club's allowed_email_pattern (D7) 3. System: creates User (role=STAFF, active=false, no password) 4. System: creates StaffAccount (permissions JSONB) 5. System: creates InviteToken (72h expiry) 6. System: sends email with link: https://{domain}/auth/set-password?token={token} 7. Staff member: POST /api/v1/auth/set-password { token, password } 8. System: validates token, sets password_hash, sets active=true 9. Staff can now login via POST /api/v1/auth/login ``` **Database:** ```sql CREATE TABLE invite_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), token VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_invite_tokens_token ON invite_tokens(token); ``` **Spring Mail config (dev vs prod):** ```yaml # application.yml spring: mail: host: ${SMTP_HOST:localhost} port: ${SMTP_PORT:1025} # Mailpit for dev username: ${SMTP_USER:} password: ${SMTP_PASSWORD:} properties: mail.smtp.auth: ${SMTP_AUTH:false} mail.smtp.starttls.enable: ${SMTP_TLS:false} ``` --- ### 3.6 Email Domain Whitelist (Decision D7) **Club setting:** ```java // Club.java @Column(name = "allowed_email_pattern") private String allowedEmailPattern; // regex, NULL = unrestricted ``` **Validation in StaffService:** ```java private void validateEmailDomain(String email, Club club) { if (club.getAllowedEmailPattern() == null) return; // unrestricted if (!email.matches(club.getAllowedEmailPattern())) { throw new EmailDomainNotAllowedException(email, club.getAllowedEmailPattern()); } } ``` **Example patterns:** - `^.*@gruener-daumen-ev\.de$` — club email only - `^.*@(verein\.de|gmail\.com|gmx\.de)$` — approved domains - `NULL` — any email accepted (default) --- ### 3.7 Report Generation (Decision D4) **OpenPDF (LGPL fork of iText 5) with minimal branding:** ```java @Service public class PdfReportGenerator { public byte[] renderMonthlyReport(MonthlyReport data, Club club) { Document document = new Document(PageSize.A4); // Header: club name + report title document.add(new Paragraph(club.getName()) .setFont(PdfFontFactory.createFont(StandardFonts.HELVETICA_BOLD)) .setFontSize(16)); document.add(new Paragraph("Monatsbericht — " + data.getMonth()) .setFontSize(12)); // Content: data tables... // Footer: generated timestamp + page numbers (via event handler) document.close(); return baos.toByteArray(); } } ``` **PDF footer pattern (OpenPDF PdfPageEventHelper):** ```java public class FooterHandler implements IEventHandler { @Override public void handleEvent(Event event) { PdfDocumentEvent docEvent = (PdfDocumentEvent) event; // Add: "Erstellt am: {timestamp}" + "Seite {n} von {total}" } } ``` --- ## 4. Implementation Phases ### Phase 1: Staff Permission Foundation + Token Revocation (Day 1-2) | Step | Description | Files | |------|-------------|-------| | 1.1 | Create `StaffPermission` enum | `cannamanage-domain/.../enums/StaffPermission.java` | | 1.2 | Create `StaffAccount` JPA entity (JSONB `granted_permissions`) | `cannamanage-domain/.../entity/StaffAccount.java` | | 1.3 | Flyway V3 migration — all new tables + columns (see §6) | `db/migration/V3__sprint3_staff_portal.sql` | | 1.4 | Create `StaffAccountRepository` | `cannamanage-service/.../repository/StaffAccountRepository.java` | | 1.5 | Create `RevokedTokenRepository` | `cannamanage-service/.../repository/RevokedTokenRepository.java` | | 1.6 | Create `TokenRevocationService` + Caffeine cache | `cannamanage-service/.../service/TokenRevocationService.java` | | 1.7 | Create `StaffPermissionChecker` (SpEL bean) | `cannamanage-api/.../security/StaffPermissionChecker.java` | | 1.8 | Create `PreventionOfficerChecker` (SpEL bean) | `cannamanage-api/.../security/PreventionOfficerChecker.java` | | 1.9 | Update `JwtService` — add `jti` + `permissions` claims for STAFF tokens | `cannamanage-api/.../security/JwtService.java` | | 1.10 | Update `JwtAuthFilter` — check token blacklist via `TokenRevocationService` | `cannamanage-api/.../security/JwtAuthFilter.java` | | 1.11 | Update `SecurityConfig` — add STAFF role to relevant endpoint matchers | `cannamanage-api/.../security/SecurityConfig.java` | | 1.12 | Add Caffeine dependency to POM | `cannamanage-service/pom.xml` | | 1.13 | Update existing controllers with `@PreAuthorize` for staff access | All 5 controllers | | 1.14 | Add token cleanup scheduled task | `cannamanage-service/.../service/TokenCleanupScheduler.java` | | 1.15 | Unit tests for permission evaluation + token revocation | `StaffPermissionCheckerTest.java`, `TokenRevocationServiceTest.java` | --- ### Phase 2: Club Settings Controller (Day 2, half-day) | Step | Description | Files | |------|-------------|-------| | 2.1 | Create `ClubResponse` DTO (includes `maxPreventionOfficers`, `allowedEmailPattern`) | `cannamanage-api/.../dto/club/ClubResponse.java` | | 2.2 | Create `UpdateClubRequest` DTO | `cannamanage-api/.../dto/club/UpdateClubRequest.java` | | 2.3 | Create `ClubStatsResponse` DTO | `cannamanage-api/.../dto/club/ClubStatsResponse.java` | | 2.4 | Create `ClubService` | `cannamanage-service/.../service/ClubService.java` | | 2.5 | Create `ClubController` — `GET/PUT /clubs/me`, `GET /clubs/me/stats` | `cannamanage-api/.../controller/ClubController.java` | | 2.6 | `ClubRepository` (if not exists) | `cannamanage-service/.../repository/ClubRepository.java` | | 2.7 | Regex validation for `allowedEmailPattern` (reject invalid regex) | In `ClubService` | | 2.8 | Unit tests | `ClubControllerTest.java`, `ClubServiceTest.java` | --- ### Phase 3: Staff Management + Invite Flow (Day 3-4) | Step | Description | Files | |------|-------------|-------| | 3.1 | Add Spring Mail dependency | `cannamanage-api/pom.xml` | | 3.2 | Create `InviteToken` JPA entity | `cannamanage-domain/.../entity/InviteToken.java` | | 3.3 | Create `InviteTokenRepository` | `cannamanage-service/.../repository/InviteTokenRepository.java` | | 3.4 | Create `EmailService` — sends invite email | `cannamanage-service/.../service/EmailService.java` | | 3.5 | Create `StaffService` — CRUD + invite + domain validation + template application | `cannamanage-service/.../service/StaffService.java` | | 3.6 | Create DTOs: `CreateStaffRequest`, `UpdateStaffRequest`, `StaffResponse` | `cannamanage-api/.../dto/staff/` | | 3.7 | Create `SetPasswordRequest` DTO | `cannamanage-api/.../dto/auth/SetPasswordRequest.java` | | 3.8 | Create `StaffController` — admin-only CRUD | `cannamanage-api/.../controller/StaffController.java` | | 3.9 | Add `POST /auth/set-password` endpoint to `AuthController` | `cannamanage-api/.../controller/AuthController.java` | | 3.10 | Define role templates (Ausgabe, Lager, Vorstand) | `cannamanage-service/.../service/StaffTemplates.java` | | 3.11 | Update `AuthService.login()` — reject `active=false` users | `cannamanage-service/.../service/AuthService.java` | | 3.12 | On permission change: call `tokenRevocationService.revokeAllForUser(userId)` | In `StaffService` | | 3.13 | Email template (plain text for MVP) | `src/main/resources/templates/invite-email.txt` | | 3.14 | Spring Mail config in `application.yml` | `application.yml` | | 3.15 | Unit tests | `StaffServiceTest.java`, `StaffControllerTest.java`, `EmailServiceTest.java` | **Staff controller endpoints:** | Endpoint | Method | Access | Description | |----------|--------|--------|-------------| | `/api/v1/staff` | GET | ADMIN | List all staff accounts | | `/api/v1/staff` | POST | ADMIN | Create staff + send invite email | | `/api/v1/staff/{id}` | GET | ADMIN | Get staff details | | `/api/v1/staff/{id}` | PUT | ADMIN | Update permissions (revokes tokens) | | `/api/v1/staff/{id}` | DELETE | ADMIN | Deactivate staff (revokes tokens) | | `/api/v1/staff/templates` | GET | ADMIN | List permission templates | | `/api/v1/auth/set-password` | POST | Public | Set password from invite token | --- ### Phase 4: Report Controller + PDF Generation (Day 4-5) | Step | Description | Files | |------|-------------|-------| | 4.1 | Add OpenPDF + Commons CSV dependencies to POM | `cannamanage-api/pom.xml` | | 4.2 | Create report data models | `cannamanage-service/.../model/report/MonthlyReport.java`, `MemberListReport.java`, `RecallReport.java` | | 4.3 | Create `ReportService` — data aggregation queries | `cannamanage-service/.../service/ReportService.java` | | 4.4 | Create `PdfReportGenerator` — OpenPDF with minimal branding | `cannamanage-service/.../service/PdfReportGenerator.java` | | 4.5 | Create `FooterHandler` — OpenPDF PdfPageEventHelper for footer | `cannamanage-service/.../service/PdfFooterHandler.java` | | 4.6 | Create `CsvReportGenerator` — Apache Commons CSV (UTF-8 BOM) | `cannamanage-service/.../service/CsvReportGenerator.java` | | 4.7 | Create `ReportController` with `format` query param content negotiation | `cannamanage-api/.../controller/ReportController.java` | | 4.8 | Report DTOs | `cannamanage-api/.../dto/report/` | | 4.9 | Unit tests | `ReportServiceTest.java`, `PdfReportGeneratorTest.java` | **Report endpoints:** | Endpoint | Formats | Description | |----------|---------|-------------| | `GET /reports/monthly?month=2026-03&format=json\|pdf\|csv` | JSON/PDF/CSV | Monthly compliance report | | `GET /reports/members?format=json\|pdf\|csv&status=ACTIVE` | JSON/PDF/CSV | Member list for authorities | | `GET /reports/recall/{batchId}?format=json\|pdf` | JSON/PDF | Recall impact report | --- ### Phase 5: Member Portal (Day 5-6) | Step | Description | Files | |------|-------------|-------| | 5.1 | Add portal `SecurityFilterChain` (`@Order(2)`) | `SecurityConfig.java` | | 5.2 | Create `PortalUserDetailsService` — loads Member user from DB | `cannamanage-api/.../security/PortalUserDetailsService.java` | | 5.3 | Create `PortalController` — JSON endpoints behind session auth | `cannamanage-api/.../controller/PortalController.java` | | 5.4 | Portal DTOs — `PortalDashboard`, `PortalQuota`, `PortalDistributionHistory` | `cannamanage-api/.../dto/portal/` | | 5.5 | `PortalService` — member-scoped queries (own data only) | `cannamanage-service/.../service/PortalService.java` | | 5.6 | Session configuration — timeout, cookie settings | `application.yml` | | 5.7 | Unit tests | `PortalControllerTest.java`, `PortalServiceTest.java` | --- ### Phase 6: Prevention Officer (Day 6, half-day) | Step | Description | Files | |------|-------------|-------| | 6.1 | Add `preventionOfficer` field to `User` entity | `User.java` | | 6.2 | Add `maxPreventionOfficers` field to `Club` entity | `Club.java` | | 6.3 | `PreventionOfficerChecker` already created in Phase 1.8 | — | | 6.4 | Add endpoint: `GET /members/under-21` to MemberController | `MemberController.java` | | 6.5 | Add endpoint: `GET /members/{id}/prevention-data` | `MemberController.java` | | 6.6 | Add endpoint: `PUT /staff/{id}/prevention-officer` (assign/revoke flag) | `StaffController.java` | | 6.7 | Limit enforcement in StaffService | `StaffService.java` | | 6.8 | Unit tests | `PreventionOfficerTest.java` | --- ### Phase 7: Integration Tests (Day 7) | Step | Description | Files | |------|-------------|-------| | 7.1 | Fix Boot 4 `@EntityScan` issue from Sprint 2 | Investigate + fix | | 7.2 | Base test class with Testcontainers setup | `AbstractIntegrationTest.java` | | 7.3 | Auth flow integration test (login → JWT → access → refresh → revoke) | `AuthIntegrationTest.java` | | 7.4 | Tenant isolation test (2 tenants, ensure no data leak) | `TenantIsolationTest.java` | | 7.5 | Staff permission integration test (invite → set password → login → permission check) | `StaffPermissionIntegrationTest.java` | | 7.6 | Portal session test (login → session → own data → deny other's data) | `PortalIntegrationTest.java` | | 7.7 | Report generation test (monthly report with test data) | `ReportIntegrationTest.java` | | 7.8 | Token revocation integration test (change perms → old token rejected) | `TokenRevocationIntegrationTest.java` | --- ## 5. Execution Strategy (Single Worker — Work Lumen) All phases executed sequentially by Work Lumen. No parallelization — keeps full context continuity, avoids merge conflicts, and simplifies review. | Day | Phase | Description | |-----|-------|-------------| | 1-2 | Phase 1 | Staff permission foundation + token revocation | | 2 | Phase 2 | Club controller + settings | | 3-4 | Phase 3 | Staff CRUD + invite flow + email | | 4-5 | Phase 4 | Report controller + OpenPDF/CSV | | 5-6 | Phase 5 | Member portal (session-based auth) | | 6 | Phase 6 | Prevention officer | | 7 | Phase 7 | Integration tests (Testcontainers) | **Branch strategy:** Single branch `sprint/3-staff-portal` off `sprint/2-api`. Atomic commits per phase for clean git history. --- ## 6. Flyway Migration V3 Single migration covering all Sprint 3 schema changes: ```sql -- V3__sprint3_staff_portal.sql -- 1. Staff accounts with configurable permissions CREATE TABLE staff_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, user_id UUID NOT NULL REFERENCES users(id), display_name VARCHAR(255) NOT NULL, granted_permissions JSONB NOT NULL DEFAULT '[]'::jsonb, template_name VARCHAR(100), active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT uq_staff_tenant_user UNIQUE(tenant_id, user_id) ); CREATE INDEX idx_staff_accounts_tenant ON staff_accounts(tenant_id); CREATE INDEX idx_staff_accounts_user ON staff_accounts(user_id); -- 2. Token revocation blacklist CREATE TABLE revoked_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), jti VARCHAR(255) NOT NULL UNIQUE, user_id UUID NOT NULL, expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP NOT NULL DEFAULT NOW(), reason VARCHAR(100) ); CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti); CREATE INDEX idx_revoked_tokens_expires ON revoked_tokens(expires_at); -- 3. Invite tokens for staff onboarding CREATE TABLE invite_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), token VARCHAR(255) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_invite_tokens_token ON invite_tokens(token); -- 4. User extensions ALTER TABLE users ADD COLUMN prevention_officer BOOLEAN NOT NULL DEFAULT false; -- 5. Club extensions ALTER TABLE clubs ADD COLUMN IF NOT EXISTS registration_number VARCHAR(100); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_street VARCHAR(255); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_city VARCHAR(100); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_postal_code VARCHAR(10); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_state VARCHAR(100); ALTER TABLE clubs ADD COLUMN IF NOT EXISTS founded_date DATE; ALTER TABLE clubs ADD COLUMN IF NOT EXISTS max_prevention_officers INTEGER NOT NULL DEFAULT 2; ALTER TABLE clubs ADD COLUMN IF NOT EXISTS allowed_email_pattern VARCHAR(500); ``` --- ## 7. Updated SecurityConfig Structure (Target State) ```java @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { // Order 1: API — stateless JWT + token blacklist check @Bean @Order(1) public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) { // /api/** — JWT filter (with jti blacklist), no CSRF, stateless // ADMIN: all, STAFF: per-permission via @PreAuthorize, MEMBER: self-service } // Order 2: Portal — session-based for members @Bean @Order(2) public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) { // /portal/** — form login, CSRF, session, MEMBER only, 30min timeout } // Order 3: Public — Swagger, health, set-password @Bean @Order(3) public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) { // /swagger-ui/**, /v3/api-docs/**, /actuator/health — permitAll } } ``` --- ## 8. Test Plan Summary | ID | Description | Type | Phase | |----|-------------|------|-------| | T-01 | Staff permission JSONB serialization/deserialization | Unit | P1 | | T-02 | StaffPermissionChecker grants/denies correctly | Unit | P1 | | T-03 | ADMIN bypasses all permission checks | Unit | P1 | | T-04 | STAFF without RECORD_DISTRIBUTION gets 403 | Unit | P1 | | T-05 | Token revocation: revoked jti returns 401 | Unit | P1 | | T-06 | Caffeine cache expires and re-checks DB | Unit | P1 | | T-07 | Club GET/PUT /me returns/updates correct data | Unit | P2 | | T-08 | Club stats aggregation queries | Unit | P2 | | T-09 | Email domain whitelist rejects invalid email | Unit | P2 | | T-10 | Invalid regex in allowedEmailPattern returns 400 | Unit | P2 | | T-11 | Staff invite flow: create → email → set-password → login | Unit | P3 | | T-12 | Expired invite token returns 400 | Unit | P3 | | T-13 | Permission change revokes all user tokens | Unit | P3 | | T-14 | Role template application (Ausgabe grants correct perms) | Unit | P3 | | T-15 | Monthly report data aggregation | Unit | P4 | | T-16 | PDF generation produces valid output with branding | Unit | P4 | | T-17 | CSV export with UTF-8 BOM + correct columns | Unit | P4 | | T-18 | Recall report identifies all affected members | Unit | P4 | | T-19 | Portal session login + own-data-only access | Integration | P5 | | T-20 | Portal CSRF protection (POST without token → 403) | Integration | P5 | | T-21 | Prevention officer limit enforcement | Unit | P6 | | T-22 | Non-prevention-officer gets 403 on under-21 endpoint | Unit | P6 | | T-23 | Full auth flow: login → refresh → revoke → reject | Integration | P7 | | T-24 | Tenant isolation: tenant A cannot see tenant B data | Integration | P7 | | T-25 | Staff permission E2E: invite → activate → login → permission check | Integration | P7 | | T-26 | Token revocation E2E: change perms → old JWT rejected | Integration | P7 | --- ## 9. Dependencies & Libraries | Library | Version | Purpose | New? | |---------|---------|---------|------| | OpenPDF (librepdf) | 2.0.4 | PDF report generation (LGPL — SaaS-safe) | ✅ New | | Apache Commons CSV | 1.11+ | CSV export | ✅ New | | Caffeine | 3.1+ | In-memory token blacklist cache | ✅ New | | Spring Boot Starter Mail | (Boot managed) | Email invite sending | ✅ New | | Testcontainers PostgreSQL | 1.19+ | Integration tests | Already in POM | | Spring Security Test | (Boot managed) | SecurityMockMvc | Already in POM | --- ## 10. Risk Assessment | Risk | Probability | Impact | Mitigation | |------|-------------|--------|------------| | ~~iText 7 license~~ — RESOLVED: using OpenPDF (LGPL) | — | — | No longer a risk. OpenPDF is LGPL 2.1, fully SaaS-compatible. | | Boot 4 `@EntityScan` blocks integration tests | Medium | Medium | Investigate `@AutoConfiguration` changes. Fallback: explicit `EntityManagerFactory` bean. | | JSONB + Hibernate 6 serialization issues | Low | Medium | Hibernate 6 supports JSONB via `@JdbcTypeCode(SqlTypes.JSON)`. Test early in Phase 1. | | SMTP delivery issues in prod | Low | Medium | Use transactional email service (Mailgun/Brevo free tier). Dev uses Mailpit (local). | | Caffeine cache staleness (60s window) | Low | Low | Acceptable: worst case a revoked token works for 60 more seconds. Not a real security hole for a club app. | | Portal CSRF + SPA interaction | Low | Low | `CookieCsrfTokenRepository.withHttpOnlyFalse()` → React reads `XSRF-TOKEN` cookie, sends as header. | --- ## 11. Definition of Done Sprint 3 is **DONE** when: - [ ] All 7 phases implemented and passing - [ ] ≥ 26 tests (matching test plan) - [ ] Flyway V3 migration applies cleanly on fresh PostgreSQL - [ ] Staff invite flow works end-to-end (create → email → set password → login → permission check) - [ ] Token revocation works (change perms → old JWT rejected within 60s) - [ ] Portal login + session auth works independently of JWT - [ ] Reports generate valid PDF (with club name + footer) and CSV output - [ ] Prevention officer flag + configurable limit works - [ ] Email domain whitelist validates on staff invite - [ ] Integration tests pass with Testcontainers PostgreSQL - [ ] Branch `sprint/3-staff-portal` green and pushed --- ## Appendix A: File Tree (New Files in Sprint 3) ``` cannamanage-domain/src/main/java/de/cannamanage/domain/ ├── enums/ │ └── StaffPermission.java ← NEW ├── entity/ │ ├── StaffAccount.java ← NEW │ └── InviteToken.java ← NEW cannamanage-service/src/main/java/de/cannamanage/service/ ├── repository/ │ ├── StaffAccountRepository.java ← NEW │ ├── RevokedTokenRepository.java ← NEW │ ├── InviteTokenRepository.java ← NEW │ └── ClubRepository.java ← NEW (if not exists) ├── service/ │ ├── StaffService.java ← NEW │ ├── StaffTemplates.java ← NEW │ ├── ClubService.java ← NEW │ ├── TokenRevocationService.java ← NEW │ ├── TokenCleanupScheduler.java ← NEW │ ├── EmailService.java ← NEW │ ├── PortalService.java ← NEW │ ├── ReportService.java ← NEW │ ├── PdfReportGenerator.java ← NEW │ ├── PdfFooterHandler.java ← NEW │ └── CsvReportGenerator.java ← NEW ├── model/report/ │ ├── MonthlyReport.java ← NEW │ ├── MemberListReport.java ← NEW │ └── RecallReport.java ← NEW cannamanage-api/src/main/java/de/cannamanage/api/ ├── security/ │ ├── StaffPermissionChecker.java ← NEW │ ├── PreventionOfficerChecker.java ← NEW │ └── PortalUserDetailsService.java ← NEW ├── controller/ │ ├── ClubController.java ← NEW │ ├── StaffController.java ← NEW │ ├── ReportController.java ← NEW │ └── PortalController.java ← NEW ├── dto/ │ ├── auth/ │ │ └── SetPasswordRequest.java ← NEW │ ├── club/ │ │ ├── ClubResponse.java ← NEW │ │ ├── UpdateClubRequest.java ← NEW │ │ └── ClubStatsResponse.java ← NEW │ ├── staff/ │ │ ├── CreateStaffRequest.java ← NEW │ │ ├── UpdateStaffRequest.java ← NEW │ │ └── StaffResponse.java ← NEW │ ├── portal/ │ │ ├── PortalDashboard.java ← NEW │ │ ├── PortalQuota.java ← NEW │ │ └── PortalDistributionHistory.java ← NEW │ └── report/ │ ├── MonthlyReportResponse.java ← NEW │ ├── MemberListResponse.java ← NEW │ └── RecallReportResponse.java ← NEW cannamanage-api/src/main/resources/ ├── db/migration/ │ └── V3__sprint3_staff_portal.sql ← NEW ├── templates/ │ └── invite-email.txt ← NEW └── application.yml ← MODIFIED (mail config, session config) cannamanage-api/src/test/java/de/cannamanage/api/ ├── security/ │ ├── StaffPermissionCheckerTest.java ← NEW │ └── TokenRevocationServiceTest.java ← NEW ├── controller/ │ ├── ClubControllerTest.java ← NEW │ ├── StaffControllerTest.java ← NEW │ ├── ReportControllerTest.java ← NEW │ └── PortalControllerTest.java ← NEW ├── service/ │ ├── StaffServiceTest.java ← NEW │ ├── EmailServiceTest.java ← NEW │ ├── ReportServiceTest.java ← NEW │ └── PdfReportGeneratorTest.java ← NEW ├── PreventionOfficerTest.java ← NEW └── integration/ ├── AbstractIntegrationTest.java ← NEW ├── AuthIntegrationTest.java ← NEW ├── TenantIsolationTest.java ← NEW ├── StaffPermissionIntegrationTest.java ← NEW ├── PortalIntegrationTest.java ← NEW ├── ReportIntegrationTest.java ← NEW └── TokenRevocationIntegrationTest.java ← NEW ``` **Total new files:** ~50 **Modified files:** ~8 (existing controllers, SecurityConfig, JwtService, JwtAuthFilter, User entity, Club entity, application.yml, POMs)