Files
cannamanage/docs/sprint-3/cannamanage-sprint3-plan.md
Patrick Plate 55d8434f35 feat(sprint-3): Phase 1 — staff permissions + token revocation
- StaffPermission enum (8 granular permissions)
- StaffAccount JPA entity with permissions collection
- RevokedToken entity for JWT blacklisting
- Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens)
- StaffAccountRepository + RevokedTokenRepository
- TokenRevocationService with Caffeine cache (60s TTL, 10k max)
- StaffPermissionChecker SpEL bean (@staffPermissions.has)
- PreventionOfficerChecker SpEL bean (@preventionOfficer.check)
- JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions
- JwtAuthFilter: token blacklist check via TokenRevocationService
- SecurityConfig: STAFF role added to endpoint matchers
- Controllers updated with @PreAuthorize for fine-grained access
- TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens)
- Caffeine dependency added to cannamanage-service
- Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
2026-06-11 16:45:21 +02:00

35 KiB

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 blacklistrevoked_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, configurablemax_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 settingsallowed_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 modelStaffPermission enum, staff_accounts table, StaffPermissionChecker SpEL bean P0 1.5 days
2 Token revocationrevoked_tokens table, TokenRevocationService, Caffeine cache, JwtAuthFilter integration P0 0.5 days
3 Club settings controllerGET/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

// 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:

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:

@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:

@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<DistributionResponse> recordDistribution(...)

Staff permissions embedded in JWT:

{
  "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:

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:

@Service
public class TokenRevocationService {
    
    private final Cache<String, Boolean> 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:

// In JwtAuthFilter.doFilterInternal():
String jti = jwtService.extractClaim(token, "jti");
if (tokenRevocationService.isRevoked(jti)) {
    response.sendError(401, "Token revoked");
    return;
}

Cleanup scheduled task:

@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:

@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:

// Club.java
@Column(name = "max_prevention_officers")
private Integer maxPreventionOfficers = 2;  // default: 2

Enforcement on assignment:

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:

@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
public List<MemberResponse> 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:

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):

# 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:

// Club.java
@Column(name = "allowed_email_pattern")
private String allowedEmailPattern;  // regex, NULL = unrestricted

Validation in StaffService:

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:

@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):

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 ClubControllerGET/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:

-- 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)

@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)