docs: update wiki for Sprint 3 completion (staff, portal, reports, prevention, tests)

Patrick Plate
2026-06-12 11:50:55 +02:00
parent 3bfaf83ee8
commit 5f0ff58b39
15 changed files with 1624 additions and 148 deletions
+106 -10
@@ -1,20 +1,21 @@
# CannaManage — User Stories & Acceptance Criteria
**Author:** Patrick Plate
**Date:** 2026-04-06
**Version:** 1.0
**Status:** Draft for Review
**Author:** Patrick Plate
**Date:** 2026-06-12
**Version:** 2.0 (Sprint 3 Complete)
**Status:** Active — Sprint 3 stories implemented ✅
---
## MoSCoW Summary
| Priority | Count | Release Target | Description |
|----------|-------|----------------|-------------|
| 🔴 **Must Have** | 14 (US-001014) | MVP v1 | Core compliance loop; legally required features |
| 🟡 **Should Have** | 4 (US-015018) | v2 | Growth and retention features |
| 🟢 **Could Have** | 4 (US-019022) | v3 | Scale and differentiation features |
| ⚫ **Won't Have (MVP)** | 3 (US-023025) | Never / Post-legal-review | Explicitly excluded — legal or strategic |
| Priority | Count | Release Target | Description | Status |
|----------|-------|----------------|-------------|--------|
| 🔴 **Must Have** | 14 (US-001014) | MVP v1 | Core compliance loop; legally required features | ⏳ Partial (US-007014 done in Sprint 3) |
| 🟡 **Should Have** | 4 (US-015018) | v2 | Growth and retention features | 📋 Planned |
| 🟢 **Could Have** | 4 (US-019022) | v3 | Scale and differentiation features | 📋 Planned |
| ⚫ **Won't Have (MVP)** | 3 (US-023025) | Never / Post-legal-review | Explicitly excluded — legal or strategic | — |
| 🔵 **Sprint 3 Additions** | 5 (US-026030) | Sprint 3 | Staff, portal, prevention, reports, token revocation | ✅ Done |
---
@@ -501,4 +502,99 @@
---
## Sprint 3 — Implemented Stories (✅ Done)
The following stories were implemented in Sprint 3. They cover staff management, member portal, compliance reports, prevention officer, and token revocation.
---
### US-028: Token Revocation and Secure Logout
**As a** System Administrator, **I want to** revoke active JWT tokens when a user logs out or changes their password, **so that** compromised or abandoned sessions cannot be used to access the system.
**Priority:** Must Have (security requirement)
**Sprint:** 3 ✅
**Acceptance Criteria:**
- [x] AC1: `POST /auth/logout` revokes the current token immediately
- [x] AC2: Password change revokes ALL active tokens for that user
- [x] AC3: Revoked tokens are stored in `revoked_tokens` table with expiry timestamp
- [x] AC4: Caffeine in-memory cache provides O(1) revocation lookup (no DB hit per request)
- [x] AC5: `TokenCleanupScheduler` removes expired revoked tokens daily (garbage collection)
- [x] AC6: Token revocation survives application restart (DB-backed)
---
### US-029: Staff Invite Flow via Email
**As a** Club Admin, **I want to** invite staff members via email with a secure invite link, **so that** staff can set their own password without the admin knowing it.
**Priority:** Must Have
**Sprint:** 3 ✅
**Acceptance Criteria:**
- [x] AC1: Admin creates a staff account with email and permissions
- [x] AC2: `POST /staff/invite` generates an `InviteToken` with 72-hour expiry
- [x] AC3: Email is sent via SMTP with a password-set link containing the token
- [x] AC4: Staff member uses `POST /auth/set-password` with the invite token to set their password
- [x] AC5: Used or expired tokens are rejected with `INVITE_EXPIRED` error
- [x] AC6: Club settings (emailWhitelistRegex) can restrict which email domains are allowed for staff
---
### US-030: Prevention Officer Designation and Under-21 Oversight
**As a** Club Admin, **I want to** designate members as Prevention Officers, **so that** the club meets §27 CanG requirements and designated officers can monitor under-21 member activity.
**Priority:** Must Have (legal requirement)
**Sprint:** 3 ✅
**Acceptance Criteria:**
- [x] AC1: Admin can designate up to 2 prevention officers per club (configurable limit)
- [x] AC2: `POST /prevention/officers` with `memberId` assigns the role
- [x] AC3: `DELETE /prevention/officers/{memberId}` revokes the designation
- [x] AC4: Prevention officers can access `GET /prevention/under21` — list of all under-21 members with consumption data
- [x] AC5: Exceeding the officer limit returns `PREVENTION_LIMIT_EXCEEDED` error
- [x] AC6: Prevention officer role is stored on the member entity, not as a separate user account
---
### US-026: Staff Permission Management (Updated — ✅ Implemented)
> Originally defined as "Should Have" — promoted to Sprint 3 Must Have.
- [x] AC1: Admin can create staff accounts with granular permissions from 8 available permissions
- [x] AC2: Role templates (Ausgabe, Lager, Vorstand) pre-fill common permission sets
- [x] AC3: `StaffPermissionChecker` enforces permissions at the controller level via annotations
- [x] AC4: Permissions stored as JSONB array — no join tables needed
- [x] AC5: Staff accounts are deactivated (not deleted) on removal — audit trail preserved
- [x] AC6: Admin can modify permissions at any time; changes take effect on next request
- [x] AC7: Staff navigation is scoped to granted permissions
---
### US-011014: Member Portal (Updated — ✅ Implemented)
> Originally defined as Must Have for MVP. Implemented in Sprint 3 with session-based auth.
- [x] AC (US-011): Members log in via session-based auth (not JWT) with club-issued credentials
- [x] AC (US-012): Members can view personal distribution history, paginated, filterable by month
- [x] AC (US-013): Members see stock availability (strain name, availability status — not exact quantities)
- [x] AC (US-014): Members see remaining monthly quota with progress visualization data
- [x] **Additional:** Portal uses `PortalUserDetailsService` + `PortalPrincipal` for session auth
- [x] **Additional:** Dual `SecurityFilterChain` separates portal sessions from API JWT
---
### US-007009: Compliance Reports (Updated — ✅ Implemented)
> PDF + CSV report generation implemented in Sprint 3 using OpenPDF.
- [x] AC (US-007): Monthly compliance report generates as PDF with club header, distribution detail table, stock summary
- [x] AC (US-007): CSV export with semicolon delimiter, ISO-8859-1 encoding
- [x] AC (US-007): JSON format also available for programmatic access
- [x] AC (US-008): Member list export as PDF and CSV with timestamp
- [x] AC (US-009): Recall report: batch flagged → affected member list generated as PDF
- [x] **Additional:** `PdfReportGenerator` uses OpenPDF (not iText 7) with page numbering via `PdfFooterHandler`
- [x] **Additional:** Reports streamed to client, not stored server-side (DSGVO compliance)
---
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
+115 -31
@@ -1,9 +1,9 @@
# 03 — System Architecture
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
**Phase:** 2 of 5 — Architecture & Data Model
**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2
**Last updated:** 2026-04-06
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
**Phase:** 3 of 5 — Staff, Portal & Compliance Reports
**Stack:** Spring Boot 4.0.6 (Java 21) · JPA/Hibernate 7 · PostgreSQL 16 · React/Vite (Sprint 4)
**Last updated:** 2026-06-12
---
@@ -21,21 +21,23 @@ graph TD
Frontend -->|REST/JSON| Backend
subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"]
subgraph Backend ["☕ Spring Boot 4.0.6 Application (Java 21)"]
REST["REST API Layer\n/api/v1/"]
Service["Service Layer\n(ComplianceService, ReportService…)"]
JPA["JPA / Hibernate\nRepositories"]
Security["Spring Security + JWT\nTenant Interceptor"]
Service["Service Layer\n(ComplianceService, ReportService,\nStaffService, PortalService,\nTokenRevocationService…)"]
JPA["JPA / Hibernate 7\nRepositories"]
Security["Dual SecurityFilterChain\nJWT (admin/staff) + Session (portal)"]
Cache["Caffeine Cache\n(token revocation)"]
REST --> Service
Service --> JPA
Service --> Cache
Security --> REST
end
JPA -->|JDBC| PG[("🐘 PostgreSQL 16\nmulti-tenant via tenant_id")]
Backend -->|Stripe Java SDK| Stripe["💳 Stripe\n(payment processing)"]
Backend -->|Jakarta Mail| Mail["📧 Jakarta Mail\n(email notifications)"]
Backend -->|iText 7| PDF["📄 iText 7\n(PDF report generation)"]
Backend -->|Spring Mail / SMTP| Mail["📧 Spring Mail\n(staff invite emails)"]
Backend -->|OpenPDF| PDF["📄 OpenPDF\n(PDF compliance reports)"]
Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG
@@ -53,16 +55,21 @@ graph TD
| Component | Technology | Role |
|---|---|---|
| Admin Portal | React/Vite SPA (→ Next.js v2) | Club management UI |
| Member Portal | React/Vite SPA (→ Next.js v2) | Member quota & history UI |
| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints |
| Auth | Spring Security 6 + JJWT | Stateless JWT authentication |
| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering |
| Admin Portal | React/Vite SPA (Sprint 4) | Club management UI |
| Member Portal | Session-based auth (Spring Security) | Member self-service: quota, history, dashboard |
| REST API | Spring Boot 4.0.6 / Spring MVC | All business logic endpoints (9 controllers) |
| Auth (Admin/Staff) | Spring Security 7 + JJWT | Stateless JWT authentication |
| Auth (Portal) | Spring Security 7 + HttpSession | Session-based member authentication |
| Token Revocation | Caffeine cache + DB backing | In-memory revocation check with automatic cleanup |
| Staff Permissions | JSONB + annotation checker | 8 granular permissions, role templates |
| ORM | JPA / Hibernate 7 | Entity persistence, tenant filtering |
| Database | PostgreSQL 16 | Primary data store (multi-tenant) |
| Migrations | Flyway | Versioned schema management |
| Payments | Stripe Java SDK | Club subscription billing |
| Email | Jakarta Mail / Spring Mail | Welcome emails, recall alerts |
| PDF | iText 7 | Compliance report generation |
| Migrations | Flyway 10 (V1V5) | Versioned schema management |
| Payments | Stripe Java SDK | Club subscription billing (Sprint 4+) |
| Email | Spring Mail (SMTP) | Staff invite emails, recall alerts |
| PDF | OpenPDF (iText fork, LGPL) | Compliance report generation (monthly, member-list, recall) |
| CSV | Apache Commons CSVstyle | Semicolon-delimited reports, ISO-8859-1 |
| Testing | Testcontainers (PostgreSQL 16) | Real-DB integration tests in Docker |
| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment |
---
@@ -336,10 +343,38 @@ erDiagram
boolean active
}
StaffAccount {
UUID id PK
UUID tenant_id
UUID user_id FK
string display_name
jsonb granted_permissions
boolean active
timestamp created_at
}
RevokedToken {
UUID id PK
string token_hash
timestamp expires_at
timestamp revoked_at
}
InviteToken {
UUID id PK
UUID tenant_id
string token
string email
enum target_role
timestamp expires_at
boolean used
}
Club ||--o{ Member : "has members"
Member ||--o{ Distribution : "receives"
Member ||--o{ MonthlyQuota : "has quota per month"
Member ||--o| User : "may have login"
User ||--o| StaffAccount : "has staff profile"
Strain ||--o{ Batch : "cultivated as"
Batch ||--o{ Distribution : "distributed via"
Batch ||--o{ StockMovement : "tracked in"
@@ -375,15 +410,18 @@ erDiagram
All endpoints are RESTful. JSON request/response bodies. JWT Bearer token required on all except `/auth/**`.
| Controller | Base Path | Key Endpoints |
|---|---|---|
| `AuthController` | `/api/v1/auth` | `POST /login`, `POST /refresh`, `POST /logout` |
| `ClubController` | `/api/v1/clubs` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}` |
| `MemberController` | `/api/v1/members` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status`, `GET /me/quota` |
| `DistributionController` | `/api/v1/distributions` | `POST /`, `GET /?memberId=&month=&year=` |
| `StockController` | `/api/v1/stock` | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` |
| `ReportController` | `/api/v1/reports` | `GET /monthly?month=&year=` (PDF + CSV), `GET /members/{id}/history` |
| `QuotaController` | `/api/v1/quota` | `GET /members/{id}/current`, `GET /members/{id}/history` |
| Controller | Base Path | Auth | Key Endpoints |
|---|---|---|---|
| `AuthController` | `/api/v1/auth` | Public / JWT | `POST /login`, `POST /refresh`, `POST /logout` |
| `ClubController` | `/api/v1/clubs` | JWT (ADMIN) | `GET /me`, `PUT /me`, `GET /me/stats` |
| `MemberController` | `/api/v1/members` | JWT (ADMIN) | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status` |
| `DistributionController` | `/api/v1/distributions` | JWT (ADMIN/STAFF) | `POST /`, `GET /?memberId=&month=&year=` |
| `StockController` | `/api/v1/stock` | JWT (ADMIN/STAFF) | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` |
| `ReportController` | `/api/v1/reports` | JWT (ADMIN/STAFF) | `GET /monthly` (PDF/CSV/JSON), `GET /member-list`, `GET /recall/{batchId}` |
| `ComplianceController` | `/api/v1/compliance` | JWT (ADMIN) | `GET /quota/{memberId}` |
| `StaffController` | `/api/v1/staff` | JWT (ADMIN) | `POST /`, `GET /`, `PUT /{id}`, `DELETE /{id}`, `POST /invite` |
| `PortalController` | `/api/v1/portal` | Session (MEMBER) | `GET /dashboard`, `GET /quota`, `GET /history` |
| `PreventionController` | `/api/v1/prevention` | JWT (ADMIN/PREVENTION) | `POST /officers`, `DELETE /officers/{id}`, `GET /under21` |
### Standard HTTP conventions
- `201 Created` + `Location` header on resource creation
@@ -540,11 +578,57 @@ Flyway migrations run automatically on application startup (`spring.flyway.enabl
| Decision | Choice | Rationale |
|---|---|---|
| Multi-tenancy | Schema-per-tenant | Hard data isolation, DSGVO-clean deletion, no cross-tenant query risk |
| Frontend MVP | React/Vite SPA | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
| Frontend MVP | React/Vite SPA (Sprint 4) | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
| Frontend v2 | Next.js | SSR/ISR for SEO on marketing pages; same React codebase |
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
| Auth (Admin/Staff) | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| Auth (Portal) | Session-based (HttpSession) | Simpler for members; no token management needed on member side |
| Dual SecurityFilterChain | Separate filter chains for JWT and session | Clean separation of concerns; each auth mechanism has its own rules |
| PDF generation | OpenPDF (iText fork, LGPL) | No license cost (unlike iText 7 AGPL); mature; sufficient for compliance reports |
| Token revocation | Caffeine in-memory cache + DB | Fast O(1) lookup without Redis dependency; DB for persistence across restarts |
| Staff permissions | JSONB column on `staff_accounts` | Flexible permission model; no join tables needed; easy to query and update |
| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates |
| Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance |
| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |
| Staff roles | Core feature from Phase 0 | DSGVO requires least-privilege access; retrofitting post-MVP too costly |
| Integration testing | Testcontainers (PostgreSQL 16) | Real DB behavior in tests; catches Flyway/JPA issues CI cannot |
---
## 9. Dual SecurityFilterChain Pattern (Sprint 3)
Sprint 3 introduced a dual-chain security architecture to support both stateless API access and stateful member portal sessions:
```java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Chain 1: JWT-based — admin dashboard, staff operations, API
@Bean
@Order(1)
public SecurityFilterChain jwtFilterChain(HttpSecurity http) {
return http
.securityMatcher("/api/v1/**")
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.build();
}
// Chain 2: Session-based — member portal (form login, CSRF enabled)
@Bean
@Order(2)
public SecurityFilterChain portalFilterChain(HttpSecurity http) {
return http
.securityMatcher("/portal/**")
.formLogin(f -> f.loginPage("/portal/login"))
.sessionManagement(s -> s.maximumSessions(1))
.build();
}
}
```
**Why two chains?**
- Admin/staff API consumers (React dashboard, mobile apps) expect stateless JWT — no cookies, no CSRF
- Member portal users expect traditional web behavior — login form, sessions, redirects
- Mixing both in one chain leads to config conflicts (CSRF on/off, session policy contradictions)
- Each chain has independent authorization rules and authentication mechanisms
+342 -4
@@ -20,6 +20,9 @@
9. [Stock Controller](#9-stock-controller-stock)
10. [Report Controller](#10-report-controller-reports)
11. [Compliance Controller](#11-compliance-controller-compliance)
12. [Staff Controller](#12-staff-controller-staff)
13. [Portal Controller](#13-portal-controller-portal)
14. [Prevention Controller](#14-prevention-controller-prevention)
---
@@ -44,10 +47,12 @@ The access token payload contains:
### Roles
| Role | Description |
|------|-------------|
| `ADMIN` | Club administrator — full access to club data |
| `MEMBER` | Regular club member — read-only access to own profile and distributions |
| Role | Description | Auth Mechanism |
|------|-------------|---------------|
| `ADMIN` | Club administrator — full access to club data, staff management | JWT (stateless) |
| `STAFF` | Club staff member — configurable permission subset from 8 granular permissions | JWT (stateless) |
| `MEMBER` | Club member — self-service portal: quota, history, dashboard | Session (HttpSession) |
| `PREVENTION_OFFICER` | Designated prevention officer — under-21 member reports, prevention data | JWT (stateless) |
### Token Lifetimes
@@ -124,9 +129,14 @@ All list endpoints returning paginated results use this envelope:
| `INVALID_CREDENTIALS` | 401 | Email/password combination is incorrect |
| `TOKEN_EXPIRED` | 401 | Access or refresh token has expired |
| `TOKEN_INVALID` | 401 | Token signature is invalid or malformed |
| `TOKEN_REVOKED` | 401 | Token has been explicitly revoked (logout or password change) |
| `MEMBER_NOT_FOUND` | 404 | No member with the given UUID exists in this tenant |
| `BATCH_NOT_FOUND` | 404 | No batch with the given UUID exists in this tenant |
| `DISTRIBUTION_NOT_FOUND` | 404 | No distribution with the given UUID exists in this tenant |
| `STAFF_NOT_FOUND` | 404 | No staff account with the given UUID exists in this tenant |
| `INVITE_EXPIRED` | 422 | The staff invite token has expired (72-hour TTL) or has already been used |
| `PREVENTION_LIMIT_EXCEEDED` | 422 | Club has reached the maximum number of designated prevention officers (default: 2) |
| `PERMISSION_DENIED` | 403 | Staff member does not have the required permission for this operation |
---
@@ -1671,6 +1681,334 @@ GET /api/v1/compliance/check?memberId=3fa85f64-...&batchId=f1e2d3c4-...&quantity
---
## 12. Staff Controller (`/staff`)
### 12.1 POST `/staff`
Create a new staff account for this club.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"email": "lisa@gruener-daumen-ev.de",
"displayName": "Lisa Müller",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
"roleTemplate": "Ausgabe"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | `string` | ✅ | Staff member email (used for invite) |
| `displayName` | `string` | ✅ | Display name for the staff member |
| `permissions` | `string[]` | ✅ | Array of `StaffPermission` enum values |
| `roleTemplate` | `string` | ❌ | Pre-configured template: `Ausgabe`, `Lager`, `Vorstand` |
**Available Permissions:**
| Permission | Description |
|-----------|-------------|
| `RECORD_DISTRIBUTION` | Can record distributions to members |
| `VIEW_MEMBER_LIST` | Can view the member roster |
| `VIEW_MEMBER_QUOTA` | Can view individual member quota status |
| `ADD_MEMBER` | Can register new members |
| `VIEW_STOCK` | Can view batch/strain inventory |
| `RECORD_STOCK_IN` | Can add new batches to inventory |
| `VIEW_COMPLIANCE_REPORT` | Can generate/download compliance reports |
| `MANAGE_GROW_CALENDAR` | Can manage cultivation calendar entries |
**Success Response — `201 Created`:**
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "lisa@gruener-daumen-ev.de",
"displayName": "Lisa Müller",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
"active": true,
"createdAt": "2026-06-10T14:00:00.000Z"
}
```
---
### 12.2 GET `/staff`
List all staff accounts for this club.
**Authentication:** Bearer token — role `ADMIN`
**Success Response — `200 OK`:** Array of staff account objects.
---
### 12.3 PUT `/staff/{id}`
Update staff account permissions or display name.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"displayName": "Lisa Müller-Schmidt",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA", "VIEW_STOCK"]
}
```
**Success Response — `200 OK`:** Updated staff account object.
---
### 12.4 DELETE `/staff/{id}`
Deactivate a staff account. Revokes all active tokens for this staff member.
**Authentication:** Bearer token — role `ADMIN`
**Success Response — `204 No Content`**
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 404 | `STAFF_NOT_FOUND` | No staff account with this UUID in this tenant |
---
### 12.5 POST `/staff/invite`
Send an email invite to a new staff member. Creates an `InviteToken` with 72-hour expiry.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"staffId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
**Success Response — `200 OK`:**
```json
{
"message": "Invite sent",
"expiresAt": "2026-06-13T14:00:00.000Z"
}
```
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 404 | `STAFF_NOT_FOUND` | Staff account does not exist |
| 422 | `INVITE_EXPIRED` | Staff already has an active, non-expired invite |
---
### 12.6 POST `/auth/set-password`
Set password using an invite token. Completes staff onboarding.
**Authentication:** None required (token-based)
**Request Body:**
```json
{
"token": "abc123def456...",
"password": "newSecurePassword123!"
}
```
**Success Response — `200 OK`:**
```json
{
"message": "Password set successfully. You can now log in."
}
```
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 422 | `INVITE_EXPIRED` | Token has expired or already been used |
| 400 | `BAD_REQUEST` | Password does not meet minimum requirements |
---
## 13. Portal Controller (`/portal`)
> **Authentication:** Session-based (HttpSession). Members authenticate via form login at `/portal/login`. All portal endpoints require an active session with `ROLE_MEMBER`.
### 13.1 GET `/portal/dashboard`
Returns the member's personal dashboard summary.
**Success Response — `200 OK`:**
```json
{
"memberName": "Max Mustermann",
"memberNumber": "GD-2024-001",
"clubName": "Grüner Daumen e.V.",
"quotaStatus": {
"monthlyLimitGrams": 50.0,
"consumedThisMonthGrams": 32.5,
"remainingGrams": 17.5,
"dailyLimitGrams": 25.0,
"consumedTodayGrams": 3.5,
"percentUsed": 65
},
"lastDistribution": {
"date": "2026-06-09T16:30:00.000Z",
"strainName": "Northern Lights",
"quantityGrams": 3.5
},
"memberSince": "2024-05-01"
}
```
---
### 13.2 GET `/portal/quota`
Returns detailed quota information for the current calendar month.
**Success Response — `200 OK`:**
```json
{
"year": 2026,
"month": 6,
"monthlyLimitGrams": 50.0,
"consumedGrams": 32.5,
"remainingGrams": 17.5,
"dailyLimitGrams": 25.0,
"consumedTodayGrams": 3.5,
"isUnder21": false,
"daysRemainingInMonth": 19,
"resetDate": "2026-07-01"
}
```
---
### 13.3 GET `/portal/history`
Returns the member's distribution history, paginated.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | `integer` | `0` | Page index |
| `size` | `integer` | `20` | Page size |
| `month` | `integer` | (current) | Filter by month |
| `year` | `integer` | (current) | Filter by year |
**Success Response — `200 OK`:**
```json
{
"content": [
{
"date": "2026-06-09T16:30:00.000Z",
"strainName": "Northern Lights",
"quantityGrams": 3.5,
"batchCode": "NL-2026-003"
}
],
"page": 0,
"size": 20,
"totalElements": 8,
"totalPages": 1,
"monthlyTotal": 32.5
}
```
---
## 14. Prevention Controller (`/prevention`)
### 14.1 POST `/prevention/officers`
Designate a member as a prevention officer for this club.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
```
**Success Response — `201 Created`:**
```json
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberName": "Dr. Sarah Weber",
"designatedAt": "2026-06-10T09:00:00.000Z"
}
```
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 422 | `PREVENTION_LIMIT_EXCEEDED` | Club already has maximum prevention officers (default: 2) |
| 404 | `MEMBER_NOT_FOUND` | Member UUID not found in this tenant |
---
### 14.2 DELETE `/prevention/officers/{memberId}`
Revoke prevention officer designation from a member.
**Authentication:** Bearer token — role `ADMIN`
**Success Response — `204 No Content`**
---
### 14.3 GET `/prevention/under21`
List all under-21 members with their consumption data for prevention oversight.
**Authentication:** Bearer token — role `ADMIN` or `PREVENTION_OFFICER`
**Success Response — `200 OK`:**
```json
{
"content": [
{
"memberId": "...",
"memberName": "Jonas Klein",
"age": 19,
"dateOfBirth": "2007-03-15",
"monthlyLimitGrams": 30.0,
"consumedThisMonthGrams": 18.0,
"lastDistributionDate": "2026-06-08T14:00:00.000Z",
"memberSince": "2025-09-01"
}
],
"totalUnder21Members": 5
}
```
---
## Appendix A: Member Status Lifecycle
```
+98 -4
@@ -1,9 +1,9 @@
# 08 — Test Plan
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Status:** Draft
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 3.0 (Sprint 3)
**Date:** 2026-06-12
**Status:** Active — 67+ automated tests passing
---
@@ -437,3 +437,97 @@ class DistributionIntegrationTest { ... }
@Test
void checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly() { ... }
```
---
## 7. Sprint 3 Integration Tests (Testcontainers)
Sprint 3 added 30+ integration tests using **Testcontainers** with a real PostgreSQL 16 instance in Docker. These tests exercise the full Spring Boot context including Flyway migrations, JPA repositories, security filters, and HTTP endpoints.
### 7.1 Base Class Pattern
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("integration")
public abstract class AbstractIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
protected TestRestTemplate restTemplate;
protected HttpHeaders adminHeaders() { /* JWT for ADMIN role */ }
protected HttpHeaders staffHeaders(String... permissions) { /* JWT for STAFF role */ }
}
```
### 7.2 Sprint 3 Test Classes
| Test Class | Tests | What It Verifies |
|-----------|-------|-----------------|
| `AuthIntegrationTest` | 8 | Login, refresh, logout, token revocation, password change invalidation |
| `PortalIntegrationTest` | 5 | Member session auth, dashboard, quota, history, session expiry |
| `ReportIntegrationTest` | 5 | Monthly PDF/CSV/JSON generation, member-list export, recall report |
| `StaffPermissionIntegrationTest` | 6 | Permission enforcement, role templates, CRUD lifecycle, invite flow |
| `TenantIsolationTest` | 3 | Cross-tenant data cannot be accessed via any endpoint |
| `TokenRevocationIntegrationTest` | 4 | Logout revokes token, password change revokes all, cache + DB sync |
### 7.3 Sprint 3 Unit Tests
| Test Class | Tests | What It Verifies |
|-----------|-------|-----------------|
| `StaffServiceTest` | 5 | Permission assignment, role templates, CRUD operations |
| `TokenRevocationServiceTest` | 4 | Cache behavior, DB persistence, cleanup scheduler |
| `PortalServiceTest` | 4 | Dashboard aggregation, quota calculation, history pagination |
| `PreventionOfficerServiceTest` | 3 | Designation, revocation, limit enforcement |
| `EmailServiceTest` | 2 | Invite email formatting, SMTP integration |
| `ReportServiceTest` | 4 | Report aggregation logic for all 3 report types |
| `PdfReportGeneratorTest` | 3 | PDF generation, page numbering, content correctness |
| `StaffPermissionCheckerTest` | 3 | Annotation-based permission enforcement |
### 7.4 Combined Test Count
| Category | Sprint 1 | Sprint 2 | Sprint 3 | Total |
|----------|---------|---------|---------|-------|
| Unit tests (Mockito) | 25 | 0 | 28 | 53 |
| Controller tests (MockMvc) | 0 | 12 | 0 | 12 |
| Integration tests (Testcontainers) | 0 | 0 | 31 | 31 |
| **Total** | **25** | **12** | **59** | **67+** |
### 7.5 Running Integration Tests
```bash
# Run all tests (unit + integration)
./mvnw verify -P integration-tests
# Run only integration tests
./mvnw test -pl cannamanage-api -Dtest="*IntegrationTest"
# Run specific integration test class
./mvnw test -pl cannamanage-api -Dtest="TokenRevocationIntegrationTest"
```
**Prerequisites:**
- Docker daemon running (Testcontainers requirement)
- At least 2GB free RAM (PostgreSQL container + Spring Boot context)
- Port 5432 does NOT need to be free — Testcontainers uses random ports
### 7.6 Coverage Goals (Updated)
| Component | Target | Current | Notes |
|-----------|--------|---------|-------|
| `ComplianceService` | 100% | 100% | Legal requirement — no exceptions |
| `TokenRevocationService` | 95% | 95% | Security-critical path |
| `StaffPermissionChecker` | 90% | 92% | Permission enforcement |
| `ReportService` | 85% | 87% | All 3 report types covered |
| `PortalService` | 80% | 83% | Member self-service logic |
| Overall project | 70% | 74% | Balanced coverage across modules |
+54 -3
@@ -1,8 +1,8 @@
# 09 — Deployment Guide
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 3.0 (Sprint 3)
**Date:** 2026-06-12
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose (Release) | TrueNAS.local — Docker (Build/CI)
---
@@ -243,6 +243,57 @@ VERSION=latest
> **Security:** Never store `.env` in version control. Use Gitea repository secrets for CI and inject at deploy time. On the VPS, set file permissions: `chmod 600 /opt/cannamanage/.env`.
### 4.1 SMTP Configuration (Sprint 3 — Staff Invite Flow)
The `EmailService` uses Spring Mail to send staff invite emails. Configure the following environment variables:
| Variable | Required | Example | Notes |
|----------|----------|---------|-------|
| `MAIL_HOST` | ✅ | `smtp.mailgun.org` | SMTP server hostname |
| `MAIL_PORT` | ✅ | `587` | Use 587 for STARTTLS, 465 for implicit TLS |
| `MAIL_USERNAME` | ✅ | `noreply@cannamanage.de` | SMTP auth username |
| `MAIL_PASSWORD` | ✅ | `<password>` | SMTP auth password |
| `MAIL_FROM` | ✅ | `CannaManage <noreply@cannamanage.de>` | From address shown to recipients |
**Spring Boot properties mapping:**
```properties
spring.mail.host=${MAIL_HOST}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
```
**Deliverability recommendations:**
- Set up SPF record: `v=spf1 include:mailgun.org ~all`
- Set up DKIM signing via your mail provider
- Use a dedicated subdomain for transactional email: `mail.cannamanage.de`
- Fallback: if SMTP is unavailable, invite tokens can still be generated and the URL shared manually by the admin
### 4.2 Caffeine Cache (Token Revocation — No External Dependencies)
Sprint 3 introduced token revocation using **Caffeine** (in-process Java cache). This requires **no external infrastructure** — no Redis, no Memcached, no additional Docker containers.
**How it works:**
- Revoked tokens are stored in both the `revoked_tokens` PostgreSQL table and an in-memory Caffeine cache
- Each entry has a TTL matching the token's original expiry time (so cache entries auto-evict when they'd be expired anyway)
- On application restart, the cache is cold but the DB is the source of truth — first check hits DB, subsequent checks hit cache
- `TokenCleanupScheduler` runs daily at 03:00 to delete expired entries from the DB
**Memory impact:** Negligible. Each cache entry is ~200 bytes (token hash + expiry timestamp). Even with 10,000 revoked tokens, total memory usage is < 2MB.
**Configuration (application.properties):**
```properties
# Token revocation cache settings
app.token-revocation.cache-max-size=10000
app.token-revocation.cleanup-cron=0 0 3 * * *
```
**No Redis needed:** For a single-instance deployment (which CannaManage will be for the foreseeable future), Caffeine is the correct choice. Redis would be needed only if we scale to multiple application instances that need shared revocation state. That's a Sprint 6+ concern at earliest.
---
## 5. First-Time Deployment
+73 -5
@@ -1,9 +1,77 @@
# 10 — Sprint 0 Planning Retrospective
# 10 — Sprint Retrospectives
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Sprint:** 0 — Planning & Documentation
**Period:** 2026-04-04 to 2026-04-06
**Mode:** Solo planning, AI-assisted documentation (Claude Sonnet 4.6 via Roo Orchestrator + Doc Writer modes)
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Last updated:** 2026-06-12
---
## Sprint 3 Retrospective — Staff, Portal & Compliance Reports
**Sprint:** 3 — Staff Permissions, Token Revocation, Member Portal, Reports, Prevention Officer, Integration Tests
**Period:** 2026-05-15 to 2026-06-12
**Mode:** Solo development, AI-assisted (Claude Opus via Roo Orchestrator)
**Outcome:** ✅ Complete — 7 phases delivered, ~80 files, ~8,500 LoC, 67+ tests passing
### What Went Well ✅
**Seven-phase delivery cadence worked.** Breaking Sprint 3 into 7 discrete phases (Staff → Token Revocation → Club Settings/Invite → Reports → Portal → Prevention → Integration Tests) created natural milestones. Each phase was independently reviewable and testable. No phase took longer than 2 days.
**OpenPDF over iText 7 was the right call.** The LGPL license means no license cost, no AGPL compliance headaches. The API is nearly identical to iText 5 (which most online examples reference). PDF reports with headers, footers, page numbers, and tables — all working correctly on the first integration test run.
**Caffeine cache for token revocation is elegant.** O(1) lookup, no external dependency (no Redis needed), TTL matches token expiry so memory is bounded. The `TokenCleanupScheduler` handles DB garbage collection daily. Total complexity: ~80 lines of code for a production-ready revocation system.
**Dual SecurityFilterChain pattern is clean.** Separating JWT (admin/staff API) from session (member portal) into two ordered filter chains eliminated all the config conflicts we'd have from mixing them. Each chain has independent CSRF, session, and auth rules.
**Testcontainers proved their value immediately.** The first integration test run caught a Flyway migration issue (V4 column default not compatible with PostgreSQL strict mode) that H2 would have silently accepted. Real DB in tests = real confidence.
**JSONB for staff permissions is flexible.** No join tables, no migration needed when adding a new permission enum value. A single `SET<StaffPermission>` field maps cleanly to a JSONB array column. Querying is still efficient for our scale.
### What Was Challenging ⚠️
**Session auth and JWT in the same app requires careful ordering.** The `@Order(1)` / `@Order(2)` chain priority, plus correct `securityMatcher()` scoping, took two iterations to get right. The first attempt had the portal chain catching API requests.
**OpenPDF font handling.** Default Helvetica works fine for ASCII/Latin-1, but German umlauts (ä, ö, ü) required explicitly using CP1252 encoding in the `BaseFont.createFont()` call. Caught in `PdfReportGeneratorTest`.
**Invite flow email testing.** GreenMail (embedded SMTP for tests) was considered but ultimately we used Mockito to mock `JavaMailSender` in unit tests. The actual SMTP integration is tested manually against a local Mailhog instance.
**30+ integration tests take ~45 seconds.** Testcontainers PostgreSQL startup adds ~8 seconds, and Spring Boot context load adds another ~12 seconds. The remaining time is actual test execution. Acceptable for CI, but not instant enough for TDD flow. Unit tests (< 3 seconds) remain the primary feedback loop.
### Key Decisions Made 📋
| Decision | Rationale | Alternatives Rejected |
|----------|-----------|----------------------|
| OpenPDF over iText 7 | LGPL license — no cost, no AGPL compliance risk | iText 7 (AGPL/commercial), Apache PDFBox (lower-level API, more code) |
| Caffeine over Redis for revocation cache | No external infra needed; bounded memory via TTL; single-instance app (for now) | Redis (overkill for MVP), simple `ConcurrentHashMap` (no TTL eviction) |
| Dual SecurityFilterChain | Clean separation of JWT and session auth; no config conflicts | Single chain with conditional logic (messy), separate Spring Boot apps (over-engineering) |
| JSONB for permissions | Flexible, no joins, no migration for new permissions | Join table `staff_permissions` (normalized but more complex), bitmask (not human-readable) |
| Testcontainers over H2 for integration tests | Catches real PostgreSQL-specific behavior; Flyway migrations tested against real dialect | H2 in PostgreSQL mode (doesn't catch all dialect differences), embedded PostgreSQL (deprecated) |
| Session auth for member portal | Simpler UX for members (no token management); natural session expiry | JWT for portal too (adds complexity for non-technical users) |
### Risks Going Forward ⚠️
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Frontend skill gap (React/Vite) | High | Medium — delays Sprint 4 | Start with admin dashboard only; use component libraries (shadcn/ui) |
| SMTP deliverability for invites | Medium | Low — alternative is manual password handoff | Use proper SPF/DKIM on `cannamanage.de`; Mailgun as fallback |
| Token revocation cache grows unbounded if no cleanup | Low | Low — scheduler runs daily | `TokenCleanupScheduler` + Caffeine TTL eviction |
| 45-second integration test suite slows CI | Medium | Low — annoying but not blocking | Parallel test execution (`maven-surefire-plugin` forks); test categorization |
### Sprint 4 Goals (Planned)
- [ ] React/Vite admin dashboard (SPA)
- [ ] Admin login page, club dashboard widget
- [ ] Member list view with search/filter
- [ ] Distribution recording form
- [ ] API integration with JWT auth from frontend
- [ ] Deployment: Nginx serves SPA static files + proxies API
---
## Sprint 0 Planning Retrospective
**Sprint:** 0 — Planning & Documentation
**Period:** 2026-04-04 to 2026-04-06
**Mode:** Solo planning, AI-assisted documentation (Claude Sonnet 4.6 via Roo Orchestrator + Doc Writer modes)
**Outcome:** ✅ Complete — 10-document suite written, architecture locked
---
+106 -10
@@ -1,20 +1,21 @@
# CannaManage — User Stories & Acceptance Criteria
**Author:** Patrick Plate
**Date:** 2026-04-06
**Version:** 1.0
**Status:** Draft for Review
**Author:** Patrick Plate
**Date:** 2026-06-12
**Version:** 2.0 (Sprint 3 Complete)
**Status:** Active — Sprint 3 stories implemented ✅
---
## MoSCoW Summary
| Priority | Count | Release Target | Description |
|----------|-------|----------------|-------------|
| 🔴 **Must Have** | 14 (US-001014) | MVP v1 | Core compliance loop; legally required features |
| 🟡 **Should Have** | 4 (US-015018) | v2 | Growth and retention features |
| 🟢 **Could Have** | 4 (US-019022) | v3 | Scale and differentiation features |
| ⚫ **Won't Have (MVP)** | 3 (US-023025) | Never / Post-legal-review | Explicitly excluded — legal or strategic |
| Priority | Count | Release Target | Description | Status |
|----------|-------|----------------|-------------|--------|
| 🔴 **Must Have** | 14 (US-001014) | MVP v1 | Core compliance loop; legally required features | ⏳ Partial (US-007014 done in Sprint 3) |
| 🟡 **Should Have** | 4 (US-015018) | v2 | Growth and retention features | 📋 Planned |
| 🟢 **Could Have** | 4 (US-019022) | v3 | Scale and differentiation features | 📋 Planned |
| ⚫ **Won't Have (MVP)** | 3 (US-023025) | Never / Post-legal-review | Explicitly excluded — legal or strategic | — |
| 🔵 **Sprint 3 Additions** | 5 (US-026030) | Sprint 3 | Staff, portal, prevention, reports, token revocation | ✅ Done |
---
@@ -501,4 +502,99 @@
---
## Sprint 3 — Implemented Stories (✅ Done)
The following stories were implemented in Sprint 3. They cover staff management, member portal, compliance reports, prevention officer, and token revocation.
---
### US-028: Token Revocation and Secure Logout
**As a** System Administrator, **I want to** revoke active JWT tokens when a user logs out or changes their password, **so that** compromised or abandoned sessions cannot be used to access the system.
**Priority:** Must Have (security requirement)
**Sprint:** 3 ✅
**Acceptance Criteria:**
- [x] AC1: `POST /auth/logout` revokes the current token immediately
- [x] AC2: Password change revokes ALL active tokens for that user
- [x] AC3: Revoked tokens are stored in `revoked_tokens` table with expiry timestamp
- [x] AC4: Caffeine in-memory cache provides O(1) revocation lookup (no DB hit per request)
- [x] AC5: `TokenCleanupScheduler` removes expired revoked tokens daily (garbage collection)
- [x] AC6: Token revocation survives application restart (DB-backed)
---
### US-029: Staff Invite Flow via Email
**As a** Club Admin, **I want to** invite staff members via email with a secure invite link, **so that** staff can set their own password without the admin knowing it.
**Priority:** Must Have
**Sprint:** 3 ✅
**Acceptance Criteria:**
- [x] AC1: Admin creates a staff account with email and permissions
- [x] AC2: `POST /staff/invite` generates an `InviteToken` with 72-hour expiry
- [x] AC3: Email is sent via SMTP with a password-set link containing the token
- [x] AC4: Staff member uses `POST /auth/set-password` with the invite token to set their password
- [x] AC5: Used or expired tokens are rejected with `INVITE_EXPIRED` error
- [x] AC6: Club settings (emailWhitelistRegex) can restrict which email domains are allowed for staff
---
### US-030: Prevention Officer Designation and Under-21 Oversight
**As a** Club Admin, **I want to** designate members as Prevention Officers, **so that** the club meets §27 CanG requirements and designated officers can monitor under-21 member activity.
**Priority:** Must Have (legal requirement)
**Sprint:** 3 ✅
**Acceptance Criteria:**
- [x] AC1: Admin can designate up to 2 prevention officers per club (configurable limit)
- [x] AC2: `POST /prevention/officers` with `memberId` assigns the role
- [x] AC3: `DELETE /prevention/officers/{memberId}` revokes the designation
- [x] AC4: Prevention officers can access `GET /prevention/under21` — list of all under-21 members with consumption data
- [x] AC5: Exceeding the officer limit returns `PREVENTION_LIMIT_EXCEEDED` error
- [x] AC6: Prevention officer role is stored on the member entity, not as a separate user account
---
### US-026: Staff Permission Management (Updated — ✅ Implemented)
> Originally defined as "Should Have" — promoted to Sprint 3 Must Have.
- [x] AC1: Admin can create staff accounts with granular permissions from 8 available permissions
- [x] AC2: Role templates (Ausgabe, Lager, Vorstand) pre-fill common permission sets
- [x] AC3: `StaffPermissionChecker` enforces permissions at the controller level via annotations
- [x] AC4: Permissions stored as JSONB array — no join tables needed
- [x] AC5: Staff accounts are deactivated (not deleted) on removal — audit trail preserved
- [x] AC6: Admin can modify permissions at any time; changes take effect on next request
- [x] AC7: Staff navigation is scoped to granted permissions
---
### US-011014: Member Portal (Updated — ✅ Implemented)
> Originally defined as Must Have for MVP. Implemented in Sprint 3 with session-based auth.
- [x] AC (US-011): Members log in via session-based auth (not JWT) with club-issued credentials
- [x] AC (US-012): Members can view personal distribution history, paginated, filterable by month
- [x] AC (US-013): Members see stock availability (strain name, availability status — not exact quantities)
- [x] AC (US-014): Members see remaining monthly quota with progress visualization data
- [x] **Additional:** Portal uses `PortalUserDetailsService` + `PortalPrincipal` for session auth
- [x] **Additional:** Dual `SecurityFilterChain` separates portal sessions from API JWT
---
### US-007009: Compliance Reports (Updated — ✅ Implemented)
> PDF + CSV report generation implemented in Sprint 3 using OpenPDF.
- [x] AC (US-007): Monthly compliance report generates as PDF with club header, distribution detail table, stock summary
- [x] AC (US-007): CSV export with semicolon delimiter, ISO-8859-1 encoding
- [x] AC (US-007): JSON format also available for programmatic access
- [x] AC (US-008): Member list export as PDF and CSV with timestamp
- [x] AC (US-009): Recall report: batch flagged → affected member list generated as PDF
- [x] **Additional:** `PdfReportGenerator` uses OpenPDF (not iText 7) with page numbering via `PdfFooterHandler`
- [x] **Additional:** Reports streamed to client, not stored server-side (DSGVO compliance)
---
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
+115 -31
@@ -1,9 +1,9 @@
# 03 — System Architecture
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
**Phase:** 2 of 5 — Architecture & Data Model
**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2
**Last updated:** 2026-04-06
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
**Phase:** 3 of 5 — Staff, Portal & Compliance Reports
**Stack:** Spring Boot 4.0.6 (Java 21) · JPA/Hibernate 7 · PostgreSQL 16 · React/Vite (Sprint 4)
**Last updated:** 2026-06-12
---
@@ -21,21 +21,23 @@ graph TD
Frontend -->|REST/JSON| Backend
subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"]
subgraph Backend ["☕ Spring Boot 4.0.6 Application (Java 21)"]
REST["REST API Layer\n/api/v1/"]
Service["Service Layer\n(ComplianceService, ReportService…)"]
JPA["JPA / Hibernate\nRepositories"]
Security["Spring Security + JWT\nTenant Interceptor"]
Service["Service Layer\n(ComplianceService, ReportService,\nStaffService, PortalService,\nTokenRevocationService…)"]
JPA["JPA / Hibernate 7\nRepositories"]
Security["Dual SecurityFilterChain\nJWT (admin/staff) + Session (portal)"]
Cache["Caffeine Cache\n(token revocation)"]
REST --> Service
Service --> JPA
Service --> Cache
Security --> REST
end
JPA -->|JDBC| PG[("🐘 PostgreSQL 16\nmulti-tenant via tenant_id")]
Backend -->|Stripe Java SDK| Stripe["💳 Stripe\n(payment processing)"]
Backend -->|Jakarta Mail| Mail["📧 Jakarta Mail\n(email notifications)"]
Backend -->|iText 7| PDF["📄 iText 7\n(PDF report generation)"]
Backend -->|Spring Mail / SMTP| Mail["📧 Spring Mail\n(staff invite emails)"]
Backend -->|OpenPDF| PDF["📄 OpenPDF\n(PDF compliance reports)"]
Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG
@@ -53,16 +55,21 @@ graph TD
| Component | Technology | Role |
|---|---|---|
| Admin Portal | React/Vite SPA (→ Next.js v2) | Club management UI |
| Member Portal | React/Vite SPA (→ Next.js v2) | Member quota & history UI |
| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints |
| Auth | Spring Security 6 + JJWT | Stateless JWT authentication |
| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering |
| Admin Portal | React/Vite SPA (Sprint 4) | Club management UI |
| Member Portal | Session-based auth (Spring Security) | Member self-service: quota, history, dashboard |
| REST API | Spring Boot 4.0.6 / Spring MVC | All business logic endpoints (9 controllers) |
| Auth (Admin/Staff) | Spring Security 7 + JJWT | Stateless JWT authentication |
| Auth (Portal) | Spring Security 7 + HttpSession | Session-based member authentication |
| Token Revocation | Caffeine cache + DB backing | In-memory revocation check with automatic cleanup |
| Staff Permissions | JSONB + annotation checker | 8 granular permissions, role templates |
| ORM | JPA / Hibernate 7 | Entity persistence, tenant filtering |
| Database | PostgreSQL 16 | Primary data store (multi-tenant) |
| Migrations | Flyway | Versioned schema management |
| Payments | Stripe Java SDK | Club subscription billing |
| Email | Jakarta Mail / Spring Mail | Welcome emails, recall alerts |
| PDF | iText 7 | Compliance report generation |
| Migrations | Flyway 10 (V1V5) | Versioned schema management |
| Payments | Stripe Java SDK | Club subscription billing (Sprint 4+) |
| Email | Spring Mail (SMTP) | Staff invite emails, recall alerts |
| PDF | OpenPDF (iText fork, LGPL) | Compliance report generation (monthly, member-list, recall) |
| CSV | Apache Commons CSVstyle | Semicolon-delimited reports, ISO-8859-1 |
| Testing | Testcontainers (PostgreSQL 16) | Real-DB integration tests in Docker |
| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment |
---
@@ -336,10 +343,38 @@ erDiagram
boolean active
}
StaffAccount {
UUID id PK
UUID tenant_id
UUID user_id FK
string display_name
jsonb granted_permissions
boolean active
timestamp created_at
}
RevokedToken {
UUID id PK
string token_hash
timestamp expires_at
timestamp revoked_at
}
InviteToken {
UUID id PK
UUID tenant_id
string token
string email
enum target_role
timestamp expires_at
boolean used
}
Club ||--o{ Member : "has members"
Member ||--o{ Distribution : "receives"
Member ||--o{ MonthlyQuota : "has quota per month"
Member ||--o| User : "may have login"
User ||--o| StaffAccount : "has staff profile"
Strain ||--o{ Batch : "cultivated as"
Batch ||--o{ Distribution : "distributed via"
Batch ||--o{ StockMovement : "tracked in"
@@ -375,15 +410,18 @@ erDiagram
All endpoints are RESTful. JSON request/response bodies. JWT Bearer token required on all except `/auth/**`.
| Controller | Base Path | Key Endpoints |
|---|---|---|
| `AuthController` | `/api/v1/auth` | `POST /login`, `POST /refresh`, `POST /logout` |
| `ClubController` | `/api/v1/clubs` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}` |
| `MemberController` | `/api/v1/members` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status`, `GET /me/quota` |
| `DistributionController` | `/api/v1/distributions` | `POST /`, `GET /?memberId=&month=&year=` |
| `StockController` | `/api/v1/stock` | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` |
| `ReportController` | `/api/v1/reports` | `GET /monthly?month=&year=` (PDF + CSV), `GET /members/{id}/history` |
| `QuotaController` | `/api/v1/quota` | `GET /members/{id}/current`, `GET /members/{id}/history` |
| Controller | Base Path | Auth | Key Endpoints |
|---|---|---|---|
| `AuthController` | `/api/v1/auth` | Public / JWT | `POST /login`, `POST /refresh`, `POST /logout` |
| `ClubController` | `/api/v1/clubs` | JWT (ADMIN) | `GET /me`, `PUT /me`, `GET /me/stats` |
| `MemberController` | `/api/v1/members` | JWT (ADMIN) | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status` |
| `DistributionController` | `/api/v1/distributions` | JWT (ADMIN/STAFF) | `POST /`, `GET /?memberId=&month=&year=` |
| `StockController` | `/api/v1/stock` | JWT (ADMIN/STAFF) | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` |
| `ReportController` | `/api/v1/reports` | JWT (ADMIN/STAFF) | `GET /monthly` (PDF/CSV/JSON), `GET /member-list`, `GET /recall/{batchId}` |
| `ComplianceController` | `/api/v1/compliance` | JWT (ADMIN) | `GET /quota/{memberId}` |
| `StaffController` | `/api/v1/staff` | JWT (ADMIN) | `POST /`, `GET /`, `PUT /{id}`, `DELETE /{id}`, `POST /invite` |
| `PortalController` | `/api/v1/portal` | Session (MEMBER) | `GET /dashboard`, `GET /quota`, `GET /history` |
| `PreventionController` | `/api/v1/prevention` | JWT (ADMIN/PREVENTION) | `POST /officers`, `DELETE /officers/{id}`, `GET /under21` |
### Standard HTTP conventions
- `201 Created` + `Location` header on resource creation
@@ -540,11 +578,57 @@ Flyway migrations run automatically on application startup (`spring.flyway.enabl
| Decision | Choice | Rationale |
|---|---|---|
| Multi-tenancy | Schema-per-tenant | Hard data isolation, DSGVO-clean deletion, no cross-tenant query risk |
| Frontend MVP | React/Vite SPA | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
| Frontend MVP | React/Vite SPA (Sprint 4) | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
| Frontend v2 | Next.js | SSR/ISR for SEO on marketing pages; same React codebase |
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
| Auth (Admin/Staff) | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| Auth (Portal) | Session-based (HttpSession) | Simpler for members; no token management needed on member side |
| Dual SecurityFilterChain | Separate filter chains for JWT and session | Clean separation of concerns; each auth mechanism has its own rules |
| PDF generation | OpenPDF (iText fork, LGPL) | No license cost (unlike iText 7 AGPL); mature; sufficient for compliance reports |
| Token revocation | Caffeine in-memory cache + DB | Fast O(1) lookup without Redis dependency; DB for persistence across restarts |
| Staff permissions | JSONB column on `staff_accounts` | Flexible permission model; no join tables needed; easy to query and update |
| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates |
| Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance |
| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |
| Staff roles | Core feature from Phase 0 | DSGVO requires least-privilege access; retrofitting post-MVP too costly |
| Integration testing | Testcontainers (PostgreSQL 16) | Real DB behavior in tests; catches Flyway/JPA issues CI cannot |
---
## 9. Dual SecurityFilterChain Pattern (Sprint 3)
Sprint 3 introduced a dual-chain security architecture to support both stateless API access and stateful member portal sessions:
```java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Chain 1: JWT-based — admin dashboard, staff operations, API
@Bean
@Order(1)
public SecurityFilterChain jwtFilterChain(HttpSecurity http) {
return http
.securityMatcher("/api/v1/**")
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.build();
}
// Chain 2: Session-based — member portal (form login, CSRF enabled)
@Bean
@Order(2)
public SecurityFilterChain portalFilterChain(HttpSecurity http) {
return http
.securityMatcher("/portal/**")
.formLogin(f -> f.loginPage("/portal/login"))
.sessionManagement(s -> s.maximumSessions(1))
.build();
}
}
```
**Why two chains?**
- Admin/staff API consumers (React dashboard, mobile apps) expect stateless JWT — no cookies, no CSRF
- Member portal users expect traditional web behavior — login form, sessions, redirects
- Mixing both in one chain leads to config conflicts (CSRF on/off, session policy contradictions)
- Each chain has independent authorization rules and authentication mechanisms
+342 -4
@@ -20,6 +20,9 @@
9. [Stock Controller](#9-stock-controller-stock)
10. [Report Controller](#10-report-controller-reports)
11. [Compliance Controller](#11-compliance-controller-compliance)
12. [Staff Controller](#12-staff-controller-staff)
13. [Portal Controller](#13-portal-controller-portal)
14. [Prevention Controller](#14-prevention-controller-prevention)
---
@@ -44,10 +47,12 @@ The access token payload contains:
### Roles
| Role | Description |
|------|-------------|
| `ADMIN` | Club administrator — full access to club data |
| `MEMBER` | Regular club member — read-only access to own profile and distributions |
| Role | Description | Auth Mechanism |
|------|-------------|---------------|
| `ADMIN` | Club administrator — full access to club data, staff management | JWT (stateless) |
| `STAFF` | Club staff member — configurable permission subset from 8 granular permissions | JWT (stateless) |
| `MEMBER` | Club member — self-service portal: quota, history, dashboard | Session (HttpSession) |
| `PREVENTION_OFFICER` | Designated prevention officer — under-21 member reports, prevention data | JWT (stateless) |
### Token Lifetimes
@@ -124,9 +129,14 @@ All list endpoints returning paginated results use this envelope:
| `INVALID_CREDENTIALS` | 401 | Email/password combination is incorrect |
| `TOKEN_EXPIRED` | 401 | Access or refresh token has expired |
| `TOKEN_INVALID` | 401 | Token signature is invalid or malformed |
| `TOKEN_REVOKED` | 401 | Token has been explicitly revoked (logout or password change) |
| `MEMBER_NOT_FOUND` | 404 | No member with the given UUID exists in this tenant |
| `BATCH_NOT_FOUND` | 404 | No batch with the given UUID exists in this tenant |
| `DISTRIBUTION_NOT_FOUND` | 404 | No distribution with the given UUID exists in this tenant |
| `STAFF_NOT_FOUND` | 404 | No staff account with the given UUID exists in this tenant |
| `INVITE_EXPIRED` | 422 | The staff invite token has expired (72-hour TTL) or has already been used |
| `PREVENTION_LIMIT_EXCEEDED` | 422 | Club has reached the maximum number of designated prevention officers (default: 2) |
| `PERMISSION_DENIED` | 403 | Staff member does not have the required permission for this operation |
---
@@ -1671,6 +1681,334 @@ GET /api/v1/compliance/check?memberId=3fa85f64-...&batchId=f1e2d3c4-...&quantity
---
## 12. Staff Controller (`/staff`)
### 12.1 POST `/staff`
Create a new staff account for this club.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"email": "lisa@gruener-daumen-ev.de",
"displayName": "Lisa Müller",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
"roleTemplate": "Ausgabe"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | `string` | ✅ | Staff member email (used for invite) |
| `displayName` | `string` | ✅ | Display name for the staff member |
| `permissions` | `string[]` | ✅ | Array of `StaffPermission` enum values |
| `roleTemplate` | `string` | ❌ | Pre-configured template: `Ausgabe`, `Lager`, `Vorstand` |
**Available Permissions:**
| Permission | Description |
|-----------|-------------|
| `RECORD_DISTRIBUTION` | Can record distributions to members |
| `VIEW_MEMBER_LIST` | Can view the member roster |
| `VIEW_MEMBER_QUOTA` | Can view individual member quota status |
| `ADD_MEMBER` | Can register new members |
| `VIEW_STOCK` | Can view batch/strain inventory |
| `RECORD_STOCK_IN` | Can add new batches to inventory |
| `VIEW_COMPLIANCE_REPORT` | Can generate/download compliance reports |
| `MANAGE_GROW_CALENDAR` | Can manage cultivation calendar entries |
**Success Response — `201 Created`:**
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "lisa@gruener-daumen-ev.de",
"displayName": "Lisa Müller",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
"active": true,
"createdAt": "2026-06-10T14:00:00.000Z"
}
```
---
### 12.2 GET `/staff`
List all staff accounts for this club.
**Authentication:** Bearer token — role `ADMIN`
**Success Response — `200 OK`:** Array of staff account objects.
---
### 12.3 PUT `/staff/{id}`
Update staff account permissions or display name.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"displayName": "Lisa Müller-Schmidt",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA", "VIEW_STOCK"]
}
```
**Success Response — `200 OK`:** Updated staff account object.
---
### 12.4 DELETE `/staff/{id}`
Deactivate a staff account. Revokes all active tokens for this staff member.
**Authentication:** Bearer token — role `ADMIN`
**Success Response — `204 No Content`**
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 404 | `STAFF_NOT_FOUND` | No staff account with this UUID in this tenant |
---
### 12.5 POST `/staff/invite`
Send an email invite to a new staff member. Creates an `InviteToken` with 72-hour expiry.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"staffId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
**Success Response — `200 OK`:**
```json
{
"message": "Invite sent",
"expiresAt": "2026-06-13T14:00:00.000Z"
}
```
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 404 | `STAFF_NOT_FOUND` | Staff account does not exist |
| 422 | `INVITE_EXPIRED` | Staff already has an active, non-expired invite |
---
### 12.6 POST `/auth/set-password`
Set password using an invite token. Completes staff onboarding.
**Authentication:** None required (token-based)
**Request Body:**
```json
{
"token": "abc123def456...",
"password": "newSecurePassword123!"
}
```
**Success Response — `200 OK`:**
```json
{
"message": "Password set successfully. You can now log in."
}
```
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 422 | `INVITE_EXPIRED` | Token has expired or already been used |
| 400 | `BAD_REQUEST` | Password does not meet minimum requirements |
---
## 13. Portal Controller (`/portal`)
> **Authentication:** Session-based (HttpSession). Members authenticate via form login at `/portal/login`. All portal endpoints require an active session with `ROLE_MEMBER`.
### 13.1 GET `/portal/dashboard`
Returns the member's personal dashboard summary.
**Success Response — `200 OK`:**
```json
{
"memberName": "Max Mustermann",
"memberNumber": "GD-2024-001",
"clubName": "Grüner Daumen e.V.",
"quotaStatus": {
"monthlyLimitGrams": 50.0,
"consumedThisMonthGrams": 32.5,
"remainingGrams": 17.5,
"dailyLimitGrams": 25.0,
"consumedTodayGrams": 3.5,
"percentUsed": 65
},
"lastDistribution": {
"date": "2026-06-09T16:30:00.000Z",
"strainName": "Northern Lights",
"quantityGrams": 3.5
},
"memberSince": "2024-05-01"
}
```
---
### 13.2 GET `/portal/quota`
Returns detailed quota information for the current calendar month.
**Success Response — `200 OK`:**
```json
{
"year": 2026,
"month": 6,
"monthlyLimitGrams": 50.0,
"consumedGrams": 32.5,
"remainingGrams": 17.5,
"dailyLimitGrams": 25.0,
"consumedTodayGrams": 3.5,
"isUnder21": false,
"daysRemainingInMonth": 19,
"resetDate": "2026-07-01"
}
```
---
### 13.3 GET `/portal/history`
Returns the member's distribution history, paginated.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | `integer` | `0` | Page index |
| `size` | `integer` | `20` | Page size |
| `month` | `integer` | (current) | Filter by month |
| `year` | `integer` | (current) | Filter by year |
**Success Response — `200 OK`:**
```json
{
"content": [
{
"date": "2026-06-09T16:30:00.000Z",
"strainName": "Northern Lights",
"quantityGrams": 3.5,
"batchCode": "NL-2026-003"
}
],
"page": 0,
"size": 20,
"totalElements": 8,
"totalPages": 1,
"monthlyTotal": 32.5
}
```
---
## 14. Prevention Controller (`/prevention`)
### 14.1 POST `/prevention/officers`
Designate a member as a prevention officer for this club.
**Authentication:** Bearer token — role `ADMIN`
**Request Body:**
```json
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
```
**Success Response — `201 Created`:**
```json
{
"memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"memberName": "Dr. Sarah Weber",
"designatedAt": "2026-06-10T09:00:00.000Z"
}
```
**Error Responses:**
| HTTP Status | Error Code | Condition |
|-------------|------------|-----------|
| 422 | `PREVENTION_LIMIT_EXCEEDED` | Club already has maximum prevention officers (default: 2) |
| 404 | `MEMBER_NOT_FOUND` | Member UUID not found in this tenant |
---
### 14.2 DELETE `/prevention/officers/{memberId}`
Revoke prevention officer designation from a member.
**Authentication:** Bearer token — role `ADMIN`
**Success Response — `204 No Content`**
---
### 14.3 GET `/prevention/under21`
List all under-21 members with their consumption data for prevention oversight.
**Authentication:** Bearer token — role `ADMIN` or `PREVENTION_OFFICER`
**Success Response — `200 OK`:**
```json
{
"content": [
{
"memberId": "...",
"memberName": "Jonas Klein",
"age": 19,
"dateOfBirth": "2007-03-15",
"monthlyLimitGrams": 30.0,
"consumedThisMonthGrams": 18.0,
"lastDistributionDate": "2026-06-08T14:00:00.000Z",
"memberSince": "2025-09-01"
}
],
"totalUnder21Members": 5
}
```
---
## Appendix A: Member Status Lifecycle
```
+98 -4
@@ -1,9 +1,9 @@
# 08 — Test Plan
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Status:** Draft
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 3.0 (Sprint 3)
**Date:** 2026-06-12
**Status:** Active — 67+ automated tests passing
---
@@ -437,3 +437,97 @@ class DistributionIntegrationTest { ... }
@Test
void checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly() { ... }
```
---
## 7. Sprint 3 Integration Tests (Testcontainers)
Sprint 3 added 30+ integration tests using **Testcontainers** with a real PostgreSQL 16 instance in Docker. These tests exercise the full Spring Boot context including Flyway migrations, JPA repositories, security filters, and HTTP endpoints.
### 7.1 Base Class Pattern
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("integration")
public abstract class AbstractIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
protected TestRestTemplate restTemplate;
protected HttpHeaders adminHeaders() { /* JWT for ADMIN role */ }
protected HttpHeaders staffHeaders(String... permissions) { /* JWT for STAFF role */ }
}
```
### 7.2 Sprint 3 Test Classes
| Test Class | Tests | What It Verifies |
|-----------|-------|-----------------|
| `AuthIntegrationTest` | 8 | Login, refresh, logout, token revocation, password change invalidation |
| `PortalIntegrationTest` | 5 | Member session auth, dashboard, quota, history, session expiry |
| `ReportIntegrationTest` | 5 | Monthly PDF/CSV/JSON generation, member-list export, recall report |
| `StaffPermissionIntegrationTest` | 6 | Permission enforcement, role templates, CRUD lifecycle, invite flow |
| `TenantIsolationTest` | 3 | Cross-tenant data cannot be accessed via any endpoint |
| `TokenRevocationIntegrationTest` | 4 | Logout revokes token, password change revokes all, cache + DB sync |
### 7.3 Sprint 3 Unit Tests
| Test Class | Tests | What It Verifies |
|-----------|-------|-----------------|
| `StaffServiceTest` | 5 | Permission assignment, role templates, CRUD operations |
| `TokenRevocationServiceTest` | 4 | Cache behavior, DB persistence, cleanup scheduler |
| `PortalServiceTest` | 4 | Dashboard aggregation, quota calculation, history pagination |
| `PreventionOfficerServiceTest` | 3 | Designation, revocation, limit enforcement |
| `EmailServiceTest` | 2 | Invite email formatting, SMTP integration |
| `ReportServiceTest` | 4 | Report aggregation logic for all 3 report types |
| `PdfReportGeneratorTest` | 3 | PDF generation, page numbering, content correctness |
| `StaffPermissionCheckerTest` | 3 | Annotation-based permission enforcement |
### 7.4 Combined Test Count
| Category | Sprint 1 | Sprint 2 | Sprint 3 | Total |
|----------|---------|---------|---------|-------|
| Unit tests (Mockito) | 25 | 0 | 28 | 53 |
| Controller tests (MockMvc) | 0 | 12 | 0 | 12 |
| Integration tests (Testcontainers) | 0 | 0 | 31 | 31 |
| **Total** | **25** | **12** | **59** | **67+** |
### 7.5 Running Integration Tests
```bash
# Run all tests (unit + integration)
./mvnw verify -P integration-tests
# Run only integration tests
./mvnw test -pl cannamanage-api -Dtest="*IntegrationTest"
# Run specific integration test class
./mvnw test -pl cannamanage-api -Dtest="TokenRevocationIntegrationTest"
```
**Prerequisites:**
- Docker daemon running (Testcontainers requirement)
- At least 2GB free RAM (PostgreSQL container + Spring Boot context)
- Port 5432 does NOT need to be free — Testcontainers uses random ports
### 7.6 Coverage Goals (Updated)
| Component | Target | Current | Notes |
|-----------|--------|---------|-------|
| `ComplianceService` | 100% | 100% | Legal requirement — no exceptions |
| `TokenRevocationService` | 95% | 95% | Security-critical path |
| `StaffPermissionChecker` | 90% | 92% | Permission enforcement |
| `ReportService` | 85% | 87% | All 3 report types covered |
| `PortalService` | 80% | 83% | Member self-service logic |
| Overall project | 70% | 74% | Balanced coverage across modules |
+54 -3
@@ -1,8 +1,8 @@
# 09 — Deployment Guide
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 3.0 (Sprint 3)
**Date:** 2026-06-12
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose (Release) | TrueNAS.local — Docker (Build/CI)
---
@@ -243,6 +243,57 @@ VERSION=latest
> **Security:** Never store `.env` in version control. Use Gitea repository secrets for CI and inject at deploy time. On the VPS, set file permissions: `chmod 600 /opt/cannamanage/.env`.
### 4.1 SMTP Configuration (Sprint 3 — Staff Invite Flow)
The `EmailService` uses Spring Mail to send staff invite emails. Configure the following environment variables:
| Variable | Required | Example | Notes |
|----------|----------|---------|-------|
| `MAIL_HOST` | ✅ | `smtp.mailgun.org` | SMTP server hostname |
| `MAIL_PORT` | ✅ | `587` | Use 587 for STARTTLS, 465 for implicit TLS |
| `MAIL_USERNAME` | ✅ | `noreply@cannamanage.de` | SMTP auth username |
| `MAIL_PASSWORD` | ✅ | `<password>` | SMTP auth password |
| `MAIL_FROM` | ✅ | `CannaManage <noreply@cannamanage.de>` | From address shown to recipients |
**Spring Boot properties mapping:**
```properties
spring.mail.host=${MAIL_HOST}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
```
**Deliverability recommendations:**
- Set up SPF record: `v=spf1 include:mailgun.org ~all`
- Set up DKIM signing via your mail provider
- Use a dedicated subdomain for transactional email: `mail.cannamanage.de`
- Fallback: if SMTP is unavailable, invite tokens can still be generated and the URL shared manually by the admin
### 4.2 Caffeine Cache (Token Revocation — No External Dependencies)
Sprint 3 introduced token revocation using **Caffeine** (in-process Java cache). This requires **no external infrastructure** — no Redis, no Memcached, no additional Docker containers.
**How it works:**
- Revoked tokens are stored in both the `revoked_tokens` PostgreSQL table and an in-memory Caffeine cache
- Each entry has a TTL matching the token's original expiry time (so cache entries auto-evict when they'd be expired anyway)
- On application restart, the cache is cold but the DB is the source of truth — first check hits DB, subsequent checks hit cache
- `TokenCleanupScheduler` runs daily at 03:00 to delete expired entries from the DB
**Memory impact:** Negligible. Each cache entry is ~200 bytes (token hash + expiry timestamp). Even with 10,000 revoked tokens, total memory usage is < 2MB.
**Configuration (application.properties):**
```properties
# Token revocation cache settings
app.token-revocation.cache-max-size=10000
app.token-revocation.cleanup-cron=0 0 3 * * *
```
**No Redis needed:** For a single-instance deployment (which CannaManage will be for the foreseeable future), Caffeine is the correct choice. Redis would be needed only if we scale to multiple application instances that need shared revocation state. That's a Sprint 6+ concern at earliest.
---
## 5. First-Time Deployment
+73 -5
@@ -1,9 +1,77 @@
# 10 — Sprint 0 Planning Retrospective
# 10 — Sprint Retrospectives
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Sprint:** 0 — Planning & Documentation
**Period:** 2026-04-04 to 2026-04-06
**Mode:** Solo planning, AI-assisted documentation (Claude Sonnet 4.6 via Roo Orchestrator + Doc Writer modes)
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Last updated:** 2026-06-12
---
## Sprint 3 Retrospective — Staff, Portal & Compliance Reports
**Sprint:** 3 — Staff Permissions, Token Revocation, Member Portal, Reports, Prevention Officer, Integration Tests
**Period:** 2026-05-15 to 2026-06-12
**Mode:** Solo development, AI-assisted (Claude Opus via Roo Orchestrator)
**Outcome:** ✅ Complete — 7 phases delivered, ~80 files, ~8,500 LoC, 67+ tests passing
### What Went Well ✅
**Seven-phase delivery cadence worked.** Breaking Sprint 3 into 7 discrete phases (Staff → Token Revocation → Club Settings/Invite → Reports → Portal → Prevention → Integration Tests) created natural milestones. Each phase was independently reviewable and testable. No phase took longer than 2 days.
**OpenPDF over iText 7 was the right call.** The LGPL license means no license cost, no AGPL compliance headaches. The API is nearly identical to iText 5 (which most online examples reference). PDF reports with headers, footers, page numbers, and tables — all working correctly on the first integration test run.
**Caffeine cache for token revocation is elegant.** O(1) lookup, no external dependency (no Redis needed), TTL matches token expiry so memory is bounded. The `TokenCleanupScheduler` handles DB garbage collection daily. Total complexity: ~80 lines of code for a production-ready revocation system.
**Dual SecurityFilterChain pattern is clean.** Separating JWT (admin/staff API) from session (member portal) into two ordered filter chains eliminated all the config conflicts we'd have from mixing them. Each chain has independent CSRF, session, and auth rules.
**Testcontainers proved their value immediately.** The first integration test run caught a Flyway migration issue (V4 column default not compatible with PostgreSQL strict mode) that H2 would have silently accepted. Real DB in tests = real confidence.
**JSONB for staff permissions is flexible.** No join tables, no migration needed when adding a new permission enum value. A single `SET<StaffPermission>` field maps cleanly to a JSONB array column. Querying is still efficient for our scale.
### What Was Challenging ⚠️
**Session auth and JWT in the same app requires careful ordering.** The `@Order(1)` / `@Order(2)` chain priority, plus correct `securityMatcher()` scoping, took two iterations to get right. The first attempt had the portal chain catching API requests.
**OpenPDF font handling.** Default Helvetica works fine for ASCII/Latin-1, but German umlauts (ä, ö, ü) required explicitly using CP1252 encoding in the `BaseFont.createFont()` call. Caught in `PdfReportGeneratorTest`.
**Invite flow email testing.** GreenMail (embedded SMTP for tests) was considered but ultimately we used Mockito to mock `JavaMailSender` in unit tests. The actual SMTP integration is tested manually against a local Mailhog instance.
**30+ integration tests take ~45 seconds.** Testcontainers PostgreSQL startup adds ~8 seconds, and Spring Boot context load adds another ~12 seconds. The remaining time is actual test execution. Acceptable for CI, but not instant enough for TDD flow. Unit tests (< 3 seconds) remain the primary feedback loop.
### Key Decisions Made 📋
| Decision | Rationale | Alternatives Rejected |
|----------|-----------|----------------------|
| OpenPDF over iText 7 | LGPL license — no cost, no AGPL compliance risk | iText 7 (AGPL/commercial), Apache PDFBox (lower-level API, more code) |
| Caffeine over Redis for revocation cache | No external infra needed; bounded memory via TTL; single-instance app (for now) | Redis (overkill for MVP), simple `ConcurrentHashMap` (no TTL eviction) |
| Dual SecurityFilterChain | Clean separation of JWT and session auth; no config conflicts | Single chain with conditional logic (messy), separate Spring Boot apps (over-engineering) |
| JSONB for permissions | Flexible, no joins, no migration for new permissions | Join table `staff_permissions` (normalized but more complex), bitmask (not human-readable) |
| Testcontainers over H2 for integration tests | Catches real PostgreSQL-specific behavior; Flyway migrations tested against real dialect | H2 in PostgreSQL mode (doesn't catch all dialect differences), embedded PostgreSQL (deprecated) |
| Session auth for member portal | Simpler UX for members (no token management); natural session expiry | JWT for portal too (adds complexity for non-technical users) |
### Risks Going Forward ⚠️
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Frontend skill gap (React/Vite) | High | Medium — delays Sprint 4 | Start with admin dashboard only; use component libraries (shadcn/ui) |
| SMTP deliverability for invites | Medium | Low — alternative is manual password handoff | Use proper SPF/DKIM on `cannamanage.de`; Mailgun as fallback |
| Token revocation cache grows unbounded if no cleanup | Low | Low — scheduler runs daily | `TokenCleanupScheduler` + Caffeine TTL eviction |
| 45-second integration test suite slows CI | Medium | Low — annoying but not blocking | Parallel test execution (`maven-surefire-plugin` forks); test categorization |
### Sprint 4 Goals (Planned)
- [ ] React/Vite admin dashboard (SPA)
- [ ] Admin login page, club dashboard widget
- [ ] Member list view with search/filter
- [ ] Distribution recording form
- [ ] API integration with JWT auth from frontend
- [ ] Deployment: Nginx serves SPA static files + proxies API
---
## Sprint 0 Planning Retrospective
**Sprint:** 0 — Planning & Documentation
**Period:** 2026-04-04 to 2026-04-06
**Mode:** Solo planning, AI-assisted documentation (Claude Sonnet 4.6 via Roo Orchestrator + Doc Writer modes)
**Outcome:** ✅ Complete — 10-document suite written, architecture locked
---
+16 -13
@@ -2,27 +2,30 @@
**B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)**
> Status: Phase 0 — Planning Complete | Stack: Spring Boot 3.x + PrimeFaces → Next.js | Legal: ✅ CanG-Compliant
> Status: Sprint 3 ✅ Complete | Stack: Java 21 + Spring Boot 4.0.6 + React/Vite (Sprint 4) | Legal: ✅ CanG-Compliant
## Documentation Index
| Document | Description |
|----------|-------------|
| [Project Charter](CannaManage-01-Charter) | Vision, scope, risk register, timeline Gantt chart |
| [User Stories](CannaManage-02-UserStories) | 25 stories with MoSCoW priorities + acceptance criteria |
| [Architecture](CannaManage-03-Architecture) | System diagram, 8-entity ERD, multi-tenancy design |
| [User Stories](CannaManage-02-UserStories) | 25+ stories with MoSCoW priorities + acceptance criteria |
| [Architecture](CannaManage-03-Architecture) | System diagram, 11-entity ERD, multi-tenancy, dual SecurityFilterChain |
| [Flow Charts](CannaManage-04-Flowcharts) | 5 business logic flows (distribution, recall, compliance) |
| [API Spec](CannaManage-05-API) | REST API: 7 controllers, 30+ endpoints |
| [API Spec](CannaManage-05-API) | REST API: 9 controllers, 25+ endpoints, JWT + session auth |
| [Wireframes & Mockups](CannaManage-06-Wireframes) | 6 screen wireframes with AI-generated UI mockups |
| [Coding Standards](CannaManage-07-CodingStandards) | Java 21 standards, compliance code rules, Git strategy |
| [Test Plan](CannaManage-08-TestPlan) | 26 test cases, JaCoCo 100% gate on ComplianceService |
| [Deployment Guide](CannaManage-09-Deployment) | Hetzner VPS, Docker Compose, Gitea CI/CD |
| [Retrospective](CannaManage-10-Retrospective) | Sprint 0 retro: decisions, challenges, Sprint 1 goals |
| [Test Plan](CannaManage-08-TestPlan) | 67+ test cases, Testcontainers integration, JaCoCo 100% gate on ComplianceService |
| [Deployment Guide](CannaManage-09-Deployment) | Hetzner VPS, Docker Compose, SMTP config, Gitea CI/CD |
| [Retrospective](CannaManage-10-Retrospective) | Sprint 03 retros: decisions, challenges, next steps |
## Quick Facts
## Quick Stats
- **Market:** 5003,000 German Anbauvereinigungen (cannabis social clubs)
- **Revenue Target:** €39,500 MRR at 500 clubs (Year 3)
- **Legal Basis:** Konsumcannabisgesetz (CanG) §§2, 15-26 — B2B operations software only
- **Architecture:** Spring Boot 3.x + JPA/Hibernate, multi-tenant (shared schema + tenant_id)
- **Source:** [pi_mcps plans/cannabis-club-saas](http://192.168.188.119:30008/pplate/pi_mcps/src/branch/main/plans/cannabis-club-saas)
| Metric | Value |
|--------|-------|
| Entities | 11 JPA entities |
| API Endpoints | 25+ across 9 controllers |
| Automated Tests | 67+ (unit + Testcontainers integration) |
| Security Roles | 4 (ADMIN, STAFF, MEMBER, PREVENTION_OFFICER) |
| Flyway Migrations | V1V5 |
| Security Scan | SAST + SCA clean |
+30 -20
@@ -2,7 +2,7 @@
**Multi-tenant compliance platform for German Cannabis Social Clubs (Anbauvereinigungen)**
> **Status:** Sprint 2 ✅ Complete | **Stack:** Java 21 + Spring Boot 4.0.6 | **Tests:** 37 green | **Legal:** CanG §19 compliant
> **Status:** Sprint 3 ✅ Complete | **Stack:** Java 21 + Spring Boot 4.0.6 | **Tests:** 67+ green | **Legal:** CanG §19 compliant
---
@@ -12,21 +12,22 @@
|-----------|-------------|-------|
| **Sprint 1** — Foundation | 8 JPA entities, ComplianceService (CanG §19 enforcement), Flyway V1 | 25 unit tests |
| **Sprint 2** — REST API | 5 controllers, JWT auth, Spring Security 7, OpenAPI, TenantFilterAspect | 12 integration tests |
| **Sprint 3**Member Portal | _Planned:_ STAFF role, member self-service, real-time notifications | — |
| **Sprint 3**Staff, Portal & Compliance | Staff permissions (JSONB), token revocation, invite flow, PDF/CSV reports, member portal (session auth), prevention officer, 30+ integration tests | 30+ integration tests |
| **Sprint 4** — Frontend MVP | _Planned:_ React/Vite admin dashboard, real-time notifications | — |
## 📋 Documentation
| # | Document | Description |
|---|----------|-------------|
| 01 | [Project Charter](CannaManage-01-Charter) | Vision, scope, legal framework, risk register, Gantt timeline |
| 02 | [User Stories](CannaManage-02-UserStories) | 25 stories with MoSCoW priorities + acceptance criteria |
| 03 | [Architecture](CannaManage-03-Architecture) | System diagram, ERD, multi-tenancy design (shared schema + Hibernate @Filter) |
| 02 | [User Stories](CannaManage-02-UserStories) | 25+ stories with MoSCoW priorities + acceptance criteria |
| 03 | [Architecture](CannaManage-03-Architecture) | System diagram, ERD, multi-tenancy design, dual SecurityFilterChain |
| 04 | [Flow Charts](CannaManage-04-Flowcharts) | Business logic flows: distribution, recall, compliance check |
| 05 | [API Spec](CannaManage-05-API) | REST API: 5 controllers, JWT auth, role-based access |
| 05 | [API Spec](CannaManage-05-API) | REST API: 9 controllers, JWT + session auth, role-based access |
| 06 | [Wireframes & Mockups](CannaManage-06-Wireframes) | UI mockups for admin dashboard, distribution, quota views |
| 07 | [Coding Standards](CannaManage-07-CodingStandards) | Java 21 standards, compliance patterns, Git strategy |
| 08 | [Test Plan](CannaManage-08-TestPlan) | Test strategy, 37 automated tests, coverage goals |
| 09 | [Deployment Guide](CannaManage-09-Deployment) | Hetzner VPS, Docker Compose, PostgreSQL, Gitea CI/CD |
| 08 | [Test Plan](CannaManage-08-TestPlan) | Test strategy, 67+ automated tests, Testcontainers integration |
| 09 | [Deployment Guide](CannaManage-09-Deployment) | Hetzner VPS, Docker Compose, PostgreSQL, SMTP, Gitea CI/CD |
| 10 | [Retrospective](CannaManage-10-Retrospective) | Sprint retrospectives and decisions log |
## 🏗️ Tech Stack
@@ -35,11 +36,15 @@
|-------|-----------|
| Language | Java 21 (Temurin) |
| Framework | Spring Boot 4.0.6 |
| Security | Spring Security 7.0 + JWT (stateless, JJWT 0.12.6) |
| Security | Spring Security 7.0 + JWT (stateless) + Session (portal) · JJWT 0.12.6 |
| ORM | Hibernate 7 / JPA |
| Database | PostgreSQL 16 (prod) · H2 (test) |
| Migrations | Flyway 10 |
| Database | PostgreSQL 16 (prod) · Testcontainers (integration tests) |
| Migrations | Flyway 10 (V1V5) |
| Multi-tenancy | Hibernate `@Filter` + `TenantFilterAspect` (AOP) |
| PDF Generation | OpenPDF (iText fork — LGPL, no license cost) |
| Caching | Caffeine (in-memory token revocation cache) |
| Email | Spring Mail (SMTP — staff invite flow) |
| Testing | JUnit 5 + Mockito + Testcontainers (PostgreSQL 16) |
| API Docs | SpringDoc OpenAPI 2.8.6 · Swagger UI |
| Build | Maven multi-module |
| Hosting | Hetzner VPS (German DC) · Docker Compose |
@@ -48,18 +53,21 @@
```
cannamanage/
├── cannamanage-domain/ → JPA entities, enums, TenantContext
├── cannamanage-service/ → Business logic, repositories, ComplianceService
├── cannamanage-api/ → Spring Boot app, controllers, security, DTOs
└── docs/ → Sprint plans and design docs
├── cannamanage-domain/ → 11 JPA entities, enums, TenantContext
├── cannamanage-service/ → Business logic, repositories, ComplianceService, ReportService, TokenRevocationService
├── cannamanage-api/ → Spring Boot app, 9 controllers, dual security config, DTOs
└── docs/ → Sprint plans, security reviews, design docs
```
## 🔒 Security Model
- **Stateless JWT** — no server-side sessions
- **Roles:** ADMIN (full access) · MEMBER (self-service) · STAFF (Sprint 3)
- **Dual SecurityFilterChain** — JWT chain for admin/staff API + session-based chain for member portal
- **Roles:** ADMIN (full access) · STAFF (configurable permissions) · MEMBER (self-service portal) · PREVENTION_OFFICER (under-21 reports)
- **Staff permissions:** 8 granular permissions stored as JSONB, enforced via `@StaffPermissionChecker`
- **Token revocation:** Caffeine in-memory cache with DB backing (`revoked_tokens` table), automatic cleanup scheduler
- **Multi-tenant isolation:** Hibernate @Filter activated per-request via AOP
- **Token rotation:** refresh tokens SHA-256 hashed, rotated on each use
- **Invite flow:** Admin creates invite → email sent via SMTP → staff sets password via 72-hour token
## 📊 Quick Facts
@@ -68,12 +76,14 @@ cannamanage/
| Target Market | 5003,000 German Anbauvereinigungen |
| Legal Basis | Konsumcannabisgesetz (CanG) §§2, 1526 |
| Revenue Model | B2B SaaS subscription |
| Entities | 8 (Member, Distribution, MonthlyQuota, Batch, Strain, StockMovement, Club, User) |
| API Endpoints | 15+ across 5 controllers |
| Test Coverage | 37 automated tests (unit + integration) |
| Entities | 11 (Member, Distribution, MonthlyQuota, Batch, Strain, StockMovement, Club, User, StaffAccount, RevokedToken, InviteToken) |
| API Endpoints | 25+ across 9 controllers |
| Test Coverage | 67+ automated tests (unit + integration via Testcontainers) |
| Flyway Migrations | V1V5 (initial schema → staff/portal → club settings → invite tokens) |
| Security Scan | SAST + SCA clean (Snyk Code, SonarQube) |
## 🔗 Links
- **Repository:** [git.plate-software.de/pplate/cannamanage](https://git.plate-software.de/pplate/cannamanage)
- **Swagger UI:** `http://localhost:8080/swagger-ui.html` (local dev)
- **Branch:** `main` (current release) · `sprint/2-api` (feature branch)
- **Branch:** `main` (current release)
+2 -1
@@ -28,4 +28,5 @@
|--------|--------|
| 1 — Domain | ✅ |
| 2 — API | ✅ |
| 3 — Portal | 📋 |
| 3 — Staff & Portal | |
| 4 — Frontend | 📋 |