55d8434f35
- 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)
845 lines
35 KiB
Markdown
845 lines
35 KiB
Markdown
# 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)
|