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

```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<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:**

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