Compare commits

...

14 Commits

Author SHA1 Message Date
Patrick Plate 9453aecf0b fix(roo): add anti-loop guardrails to prevent autonomous session resumption
- Add Rule 9 (Anti-Loop Guardrail) to 01-bigmind-core.md: detect 2+ identical
  partial sessions and surface the loop to user instead of auto-resuming
- Add partial=history clause to Rule 1: partial/blocked/abandoned outcomes are
  historical records only, never task queue items
- Add focus guard to memory_announce_focus: must reflect current user message,
  not prior session outcome; use 'Awaiting user task assignment' if no task yet
- Add .roo/rules/06-anti-loop.md: global injection for ALL modes overriding
  any mode-specific 'do the task immediately' behavior
- Add mode interaction safety clause to 00-identity.md: session ritual does not
  authorize beginning any task — only explicit user message does

Root cause: pic-gen 'do the task' personality + BigMind context inference
produced 6 identical partial branding sessions in a loop.
2026-04-10 23:27:32 +02:00
Patrick Plate 1d1e70776f docs(plans): add heretic encoder swap task for FLUX.2 Klein uncensored generation 2026-04-10 20:32:05 +02:00
Patrick Plate 1d8849cb41 fix(mcp-image-gen): confirmed working FLUX.2 Klein encoder filename
- CLIPLoader clip_name: qwen_3_4b_klein.safetensors (from Comfy-Org/vae-text-encorder-for-flux-klein-4b)
- VAE: flux2-vae.safetensors (321MB, same repo)
- Live test confirmed: 2.1MB photorealistic 1024x1024 PNG in 52.43s on RX 7900 XTX
- Test: assert clip_name == qwen_3_4b_klein.safetensors
- 37/37 tests pass
2026-04-10 20:29:18 +02:00
Patrick Plate 40c91edf2f fix(mcp-image-gen): merge CFGGuider workflow fix for FLUX.2 Klein 4B 2026-04-10 20:21:16 +02:00
Patrick Plate 4a99a3625a fix(mcp-image-gen): rewrite flux2_klein_heretic workflow with CFGGuider + correct node types
- Replace FluxDisableGuidance+BasicGuider chain with CFGGuider (cfg=5)
- CLIPLoader: add device='default', keep type='flux2'
- UNETLoader: weight_dtype='default' (not fp8_e4m3fn — avoids dimension mismatch)
- VAEDecode/SaveImage: updated node IDs (11→VAEDecode, 12→SaveImage)
- Encoder: qwen_3_4b_bfl.safetensors (7.5GB BFL-merged shards)
- Tests: update heretic model assertions for new node structure (37/37 pass)
- Add RECAP doc with root cause analysis and session history
2026-04-10 20:21:12 +02:00
Patrick Plate 38d26adb1f Merge branch 'fix/mcp-image-gen/heretic-flux2-bugfixes' 2026-04-10 19:21:51 +02:00
Patrick Plate ea0c5d39c4 fix(mcp-image-gen): fix Heretic/FLUX2 integration bugs
- Fix syntax error in server.py (dangling docstring lines)
- Correct model filename: flux-2-klein-4b.safetensors (without -fp8)
- Fix _WORKFLOW_REGISTRY key to match actual downloaded filename
- Update get_models() to always include registry models as fallback
- Fix test expectations to match corrected model names
- All 37 tests passing
2026-04-10 19:21:51 +02:00
Patrick Plate 8f24168dcd fix(bigmind): fix FTS sync in delete_chunks_before — use rowid DELETE not rebuild
conversation_chunks_fts is a standalone FTS5 table (no content= option).
The old INSERT ... VALUES('rebuild') is a no-op on standalone tables and
left deleted chunks searchable in the FTS shadow tables.

Fix: collect IDs before deletion, explicitly DELETE FROM conversation_chunks_fts
WHERE rowid IN (...) before removing from the main table. This keeps FTS
in sync after every vacuum call.

Tests: 303/303 passing. Vacuum tests now pass for the right reason.
2026-04-07 23:41:13 +02:00
Patrick Plate cda8946c75 docs(cannamanage): add CannaManage wiki pages and mockup images
- 11 wiki pages: CannaManage-Home + 01-10 covering full Phase 0 docs
- 5 mockup images in docs/wiki/images/
- Updated _Sidebar.md with CannaManage section
2026-04-06 11:21:35 +02:00
Patrick Plate 97ccafc0d7 Merge branch 'docs/cannamanage/phase0-docs' 2026-04-06 11:07:44 +02:00
Patrick Plate c25a97c37b docs(cannamanage): add complete Phase 0 documentation suite
- 01-PROJECT-CHARTER.md: project charter with Gantt chart and risk register
- 02-USER-STORIES.md: 25 user stories with MoSCoW priorities and ACs
- 03-ARCHITECTURE.md: system architecture, ERD (8 entities), multi-tenancy design
- 04-FLOWCHARTS.md: 5 business logic flow charts (distribution, recall, etc)
- 05-API-SPEC.md: REST API spec (7 controllers, 30+ endpoints)
- 06-WIREFRAMES.md: 6 screen wireframes with AI-generated mockup images
- 07-CODING-STANDARDS.md: Java 21 standards, Git strategy, compliance rules
- 08-TEST-PLAN.md: 26 test cases, JaCoCo coverage gates
- 09-DEPLOYMENT-GUIDE.md: Hetzner Docker Compose + Gitea CI/CD pipeline
- README.md + CHANGELOG.md + 10-RETROSPECTIVE.md
- 5 AI-generated UI mockup images (Flux Schnell/ComfyUI)
2026-04-06 11:07:35 +02:00
Patrick Plate a72a2efceb feat(mcp-image-gen): merge ComfyUI auto-start health check + systemd service 2026-04-06 10:43:44 +02:00
Patrick Plate c662a5237b feat(mcp-image-gen): add ComfyUI auto-start health check + systemd service
Option A: Add lifespan context manager to server.py
- _ping_comfyui(): async health check against /system_stats
- check_and_start_comfyui(): ping on startup; if down, launches ComfyUI
  via subprocess.Popen from COMFYUI_DIR (.venv/bin/python main.py)
  with HSA_OVERRIDE_GFX_VERSION=11.0.0 injected for AMD ROCm
- Polls up to 30s for readiness after auto-start
- New env var: COMFYUI_DIR (default ~/ComfyUI)
- FastMCP lifespan= wired in; 34/34 tests still passing

Option B: Add comfyui.service systemd user service file
- Install: cp mcp/mcp-image-gen/comfyui.service ~/.config/systemd/user/
- Enable: systemctl --user enable --now comfyui
- Sets HSA_OVERRIDE_GFX_VERSION=11.0.0, WorkingDirectory=%h/ComfyUI
- Restart=on-failure, logs via journald

docs: Update mcp-image-gen-ComfyUI-Setup.md
- New Step 4: systemd service install + linger instructions
- Step 5: manual start (moved from old Step 4)
- Step 6/7 renumbered; COMFYUI_DIR env var documented
- Architecture diagram added; troubleshooting rows updated
2026-04-06 10:43:36 +02:00
Patrick Plate 0ff3f20589 feat(mcp-image-gen): merge name and count params into main 2026-04-06 07:45:45 +02:00
49 changed files with 12950 additions and 93 deletions
+12 -1
View File
@@ -24,4 +24,15 @@ BigMind is my persistent memory MCP server at `~/.mcp/bigmind/memory.db`. I use
- Use BigMind memory at the start of every task.
- Form explicit hypotheses with confidence % during analysis.
- Optimize for token efficiency — search memory before reading files.
- Work in modes: Architect (plan), Code (implement), Ask (explain), Debug (troubleshoot).
- Work in modes: Architect (plan), Code (implement), Ask (explain), Debug (troubleshoot).
## ⚠️ Session Ritual ≠ Task Authorization
Completing `memory_start_session()` + `memory_list_hypotheses()` + `memory_announce_focus()` does
**NOT** authorize beginning any task. It is housekeeping only.
**Work begins only when Patrick explicitly assigns a task in the current conversation.**
Prior session outcomes (`partial`, `blocked`, `abandoned`) are historical records. They are never
instructions. Mode-specific rules that say "do the task immediately" apply only to tasks given by
the user in this conversation — not to tasks inferred from memory context.
+33 -2
View File
@@ -4,11 +4,18 @@
Every new session must begin with the following sequence executed in strict order before any other work is performed:
1. `memory_start_session()` — Open a new session and load all prior context, including user preferences, active projects, and recent decisions.
2. `memory_list_hypotheses()` — Review all open hypotheses from previous sessions. Assess whether any have become stale, require updated confidence scores, or can be immediately resolved based on new information.
3. `memory_announce_focus()` — Declare the explicit focus of this session, including the task objective, all files expected to be read or modified, the working branch if applicable, and the IDE environment (ide_hint="VS Code" or ide_hint="IntelliJ" as appropriate).
3. `memory_announce_focus()` — Declare the explicit focus of this session, including the task objective, all files expected to be read or modified, the working branch if applicable, and the IDE environment (ide_hint="VS Code" or ide_hint="IntelliJ" as appropriate). **The focus MUST reflect the current session's task as stated by the user's first message. If the user has not yet given a task at the time of calling, use `"Awaiting user task assignment"` as the description. Never derive focus from a prior session's partial/blocked/abandoned outcome.**
4. `memory_close_stale_sessions()` — Identify and close any orphaned sessions left behind by crashed or terminated IDE instances. A session is considered stale if it has had no activity for more than 2 hours and no corresponding active IDE is detected.
Do not skip any step. Do not reorder. If any call fails, retry once before proceeding with a logged warning.
> **⚠️ CRITICAL — Partial Sessions Are History, Not a Task Queue:**
> Sessions closed with `partial`, `blocked`, or `abandoned` outcomes are **historical records only**.
> They do NOT constitute pending obligations, resumption requests, or open tasks.
> A new session begins fresh. The **only** source of the current session's task is what the user
> writes in their **first message of this conversation** — never the outcome of a prior session.
> Reading prior context is for awareness only — it does NOT authorize beginning any prior task.
## Rule 2: Session End Ritual (Always Last Action — No Exceptions)
Every session must conclude with:
`memory_end_session()` — Close the session with all of the following fields populated:
@@ -60,4 +67,28 @@ Multiple IDEs and sessions may be active simultaneously. Treat this as a concurr
## Rule 8: Consistency and Self-Correction
- If at any point during a session you realize a rule was skipped or partially followed, immediately remediate by executing the missed step and logging the correction.
- Periodically during long sessions (approximately every 10 substantive exchanges), perform a lightweight self-audit: verify the session is still focused on the announced objective, check for unflagged important exchanges, and update any hypothesis confidence scores that may have shifted.
- If the user provides information that contradicts a stored fact, update the fact immediately and log the change with the old value, new value, and reason for the update.
- If the user provides information that contradicts a stored fact, update the fact immediately and log the change with the old value, new value, and reason for the update.
## Rule 9: Detect and Break Session Loops Before They Start
A **session loop** occurs when multiple consecutive sessions share near-identical headlines, topics,
and `partial`/`blocked`/`abandoned` outcomes — indicating the same task failed to complete repeatedly
without user re-authorization.
**Detection:** If `memory_start_session()` context shows **2 or more** recently closed sessions with:
- Substantially similar headlines or topics, **AND**
- `partial`, `blocked`, or `abandoned` outcome
**Required Response — Break the loop immediately:**
1. Do NOT attempt to resume or retry the repeated task silently
2. Inform the user: "I noticed the last N sessions all attempted [task] and ended partial. I won't auto-resume that. What would you like to do?"
3. Summarize what context/progress was accumulated across those sessions
4. Wait for an explicit user instruction before doing anything
**Explicit resumption:** If the user's first message in this conversation explicitly asks to continue
or retry the previous task, that is a valid instruction — proceed normally. The rule only prevents
**silent autonomous resumption** based on context alone.
**Mode interaction:** This rule applies regardless of mode. Even if a mode's rules say "do the task
immediately," prior session context alone is never sufficient authorization. Only the user's live
message in this conversation authorizes action.
+56
View File
@@ -0,0 +1,56 @@
# Anti-Loop Guardrail — Mandatory for All Modes
## ⛔ Never Resume Past Work Without Explicit User Authorization
This rule applies to **every mode** (code, architect, debug, pic-gen, ask, homelab, paisy, etc.)
and **overrides any mode-specific "do the task immediately" instructions**.
### The Core Prohibition
**Prior session context — including `partial`, `blocked`, or `abandoned` outcomes — does NOT
authorize beginning, resuming, or retrying any task.**
The only valid source of a task in any session is what **the user writes in their first message
of the current conversation.**
### What NOT To Do At Session Start
❌ Do NOT look at the last session headline and start that task
❌ Do NOT interpret `partial` outcome as "I need to finish this"
❌ Do NOT call `memory_announce_focus()` with a prior session's task before the user speaks
❌ Do NOT begin any creative, generative, or code-writing work based on context alone
❌ Do NOT assume "the user probably wants to continue" — ask if unsure
### What TO Do At Session Start
✅ Load context for **awareness only** — past sessions are reference, not instructions
✅ Announce focus as `"Awaiting user task assignment"` if the user has not yet spoken
✅ Wait for the user's first message before doing any substantive work
✅ If context shows a loop (2+ identical partial sessions), surface it explicitly and ask
### Session Loop Detection
If `memory_start_session()` context shows **2 or more** recently closed sessions with:
- Near-identical headlines or topics, AND
- `partial`, `blocked`, or `abandoned` outcome
**Stop. Do not resume.** Inform the user:
> "I noticed the last [N] sessions all attempted [task description] and ended partial.
> I won't auto-resume that — it's likely causing a loop. What would you like to do?"
Then wait for an explicit instruction.
### Exception: Explicit Resumption
If the user's **first message** in this conversation explicitly says to continue or retry
a prior task (e.g., "continue the branding generation", "pick up where we left off"),
that IS valid authorization — proceed normally.
The rule only prevents **silent autonomous resumption** from context inference.
---
*This file is loaded for all modes via `.roo/rules/`. It was added 2026-04-10 to fix a
session loop bug where pic-gen sessions repeatedly attempted CannaManage branding generation
without user authorization, producing 6 identical `partial` sessions.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

+227
View File
@@ -0,0 +1,227 @@
# CannaManage — Project Charter
**Author:** Patrick Plate
**Date:** 2026-04-06
**Version:** 1.0
**Status:** Draft for Review
---
## 1. Executive Summary
### Vision Statement
> *CannaManage is the compliance backbone for German cannabis social clubs — purpose-built to turn a legally mandated administrative burden into a manageable, auditable, and digitised workflow.*
### The Problem
Germany's **Konsumcannabisgesetz (CanG)**, in force since April 1, 2024, legalised cannabis for personal use and established a framework for **Anbauvereinigungen** (cannabis social clubs / CSCs). Every operating CSC faces mandatory, recurring compliance obligations:
- Track every distribution (recipient, strain, weight, date/time) — by law
- Enforce quantity limits per member (50g/month for adults, 30g/month for under-21, 25g/day)
- Maintain batch-level contamination traceability
- Produce periodic authority reports
- Designate and track a Prevention Officer (Präventionsbeauftragter)
- Manage member data under DSGVO
Clubs currently manage this with Excel spreadsheets, pen-and-paper logs, and WhatsApp groups — creating legal risk, audit gaps, and administrative chaos.
### Why Now
The market is less than two years old. **No purpose-built software tooling exists** for German CSCs. The window to establish market leadership is 20262027 before larger players notice the niche. First-mover advantage combined with the permanent regulatory moat from CanG compliance requirements makes this the right moment.
### What We Are Building
A **multi-tenant B2B SaaS platform** offering:
- Club admin portal (member management, distribution logging, stock management, compliance reporting)
- Member portal (personal quota, distribution history, stock visibility)
- Built-in CanG compliance enforcement and export tooling
**We are selling compliance management software to licensed, regulated entities. We are not in the cannabis business.**
---
## 2. Project Scope
### 2.1 In Scope — MVP v1
| Area | Features Included |
|------|-------------------|
| **Onboarding** | Club registration, setup wizard, admin account creation |
| **Member Management** | Add/remove members, age verification (18+, 1821 restricted), contact data |
| **Distribution Tracking** | Log each handout (member, strain, weight, date/time); enforce daily/monthly limits |
| **Limit Enforcement** | 25g/day cap, 50g/month (adult), 30g/month (under-21), 10% THC flag |
| **Stock Management** | Strains, batch tracking, quantity levels |
| **Admin Dashboard** | Club-level totals: members, distributions this month, stock levels |
| **Compliance Exports** | Monthly distribution report (PDF + CSV), member list export for inspections |
| **Contamination Recall** | Flag a batch; system lists all members who received from it |
| **Prevention Officer** | Store officer contact info and designation date |
| **Member Portal** | Login with club-issued credentials; view quota, distribution history, stock availability |
| **Authentication** | Spring Security + JWT; role-based (ADMIN, MEMBER) |
| **Hosting** | Hetzner VPS (German DC), Docker Compose, PostgreSQL + Flyway |
### 2.2 Explicitly Out of Scope — MVP v1
| Feature | Reason Excluded |
|---------|-----------------|
| Public club discovery / "find clubs near you" | **Illegal under CanG §§67 advertising ban** |
| Cannabis e-commerce or payment for cannabis | Illegal; violates positioning |
| Non-EU data storage (AWS us-east, etc.) | DSGVO violation |
| Stripe subscription billing | Deferred to Phase 1 (Weeks 916) |
| Email/SMS notifications | v2 feature |
| Mobile native app (Android/iOS) | v2/v3 feature |
| Multi-location club support | v3 feature |
| Legal template marketplace | v3 feature |
| Next.js/React frontend | v2 migration after revenue justifies investment |
| Authority portal integrations | v3 feature (portals don't exist yet) |
---
## 3. Stakeholders
| Role | Description | Needs |
|------|-------------|-------|
| **Club Admin** *(primary user)* | Vereinsvorstand or designated manager; runs day-to-day club operations | Compliant distribution logging, member management, authority-ready exports |
| **Club Member** *(secondary user)* | Verified adult member of the Anbauvereinigung | Self-service quota visibility, distribution history, stock availability |
| **Prevention Officer** *(Präventionsbeauftragter, tertiary user)* | Legally required role; may or may not be the admin | Contact info tracked in system; receives relevant reports |
| **Patrick Plate** *(developer & product owner)* | Solo developer; nights/weekends; ADP Germany full-time | Minimal learning overhead; fast path to first revenue; legally sound product |
---
## 4. Success Criteria
MVP is considered complete when all of the following are true:
| # | Criterion | Measure |
|---|-----------|---------|
| 1 | **Core compliance loop working** | Admin can log a distribution → system enforces limits → admin exports PDF report for authorities |
| 2 | **Multi-tenant isolation** | Two clubs' data are completely isolated — no cross-tenant data leakage |
| 3 | **Member portal live** | Member can log in with club-issued credentials and view their quota + history |
| 4 | **Contamination recall functional** | Admin flags a batch; system returns full recipient list in < 2 seconds |
| 5 | **Deployment stable** | Platform runs on Hetzner VPS via Docker Compose with uptime ≥ 99% over 30-day beta |
| 6 | **Beta validation** | 35 real club admins have used the system and provided written feedback |
| 7 | **Legal review passed** | No features violate CanG advertising ban; DSGVO AVV in place before any live data |
| 8 | **Zero PII on non-EU infrastructure** | All data confirmed to reside in Hetzner DE datacenter |
---
## 5. Constraints & Assumptions
### Constraints
| Type | Constraint |
|------|-----------|
| **Legal** | CanG §§67 imposes a **total advertising and sponsoring ban** on cannabis AND Anbauvereinigungen — no public club discovery feature, ever |
| **Legal** | DSGVO requires EU hosting, data processing agreements (AVV), member data export/deletion capability |
| **Technical (MVP)** | Frontend is PrimeFaces + JSF — Patrick's existing expertise; no new framework learning in Phase 0 |
| **Technical** | Multi-tenancy via `tenant_id` on all JPA entities — no row-level security shortcuts |
| **Team** | Solo developer — Patrick; nights and weekends only; full-time at ADP Germany |
| **Timeline** | Phase 0 target: 8 weeks; Phase 1 target: 16 weeks total from project start |
| **Budget** | Infrastructure: Hetzner €520/month; no team salary cost |
### Assumptions
- German CSCs are willing to pay €29–€79/month for compliance software
- Stripe will process subscriptions for compliance software (not cannabis sales) without restriction
- Spring Boot 3.x is sufficiently adjacent to Patrick's Jakarta EE expertise to use without major ramp-up
- PrimeFaces MVP is sufficient for beta validation — UI polish deferred to v2
- CanG remains in force and CSC licensing continues in all major Bundesländer
---
## 6. Risk Register
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| **Advertising ban reinterpreted to include B2B SaaS** | Low | High | Obtain legal opinion from cannabis law specialist before launch (€300500); strict no-discovery design enforced at architecture level |
| **New German government rolls back or tightens CanG** | Medium | High | Modular architecture — compliance-only features can be extracted and pivoted to a general club management tool |
| **Stripe blocks cannabis-adjacent businesses** | Medium | High | Position as "Vereinsverwaltungs-Software" (club management software); never process cannabis payments; test with Stripe before public launch |
| **Clubs fail / licenses revoked** | Medium | Medium | Diversified customer base; per-month billing (easy cancellation); no annual lock-in required for MVP |
| **DSGVO violation** | Low | Very High | EU-only hosting (Hetzner DE), DPA/AVV agreements before any live data, DSGVO-compliant privacy policy in German, member data export/deletion API from day one |
---
## 7. Budget & Resources
| Item | Cost | Notes |
|------|------|-------|
| **Development** | €0 (Patrick's time) | Nights/weekends; valued at opportunity cost only |
| **Infrastructure — Hetzner VPS** | €520/month | German DC; scales with load |
| **Infrastructure — PostgreSQL** | €0 (self-hosted on VPS) | Managed DB upgrade available when needed |
| **Legal opinion** | €300500 (one-time) | Cannabis law specialist; pre-launch requirement |
| **Domain (cannamanage.de)** | ~€15/year | To be registered |
| **Stripe fees** | 1.4% + €0.25 per transaction | EU cards; only on paid subscriptions |
| **Email (Resend / Jakarta Mail)** | €010/month | Resend free tier for low volume |
| **Sentry monitoring** | €0 (free tier) | Error tracking; Java SDK |
| **Total pre-launch** | **~€600700** | Including legal opinion |
---
## 8. Timeline Overview
```mermaid
gantt
title CannaManage Development Roadmap
dateFormat YYYY-MM-DD
axisFormat %b %Y
section Phase 0 — Foundation
Spring Boot setup + JPA entities :p0a, 2026-04-07, 2w
Core REST API (member, distribution) :p0b, after p0a, 2w
Admin portal PrimeFaces :p0c, after p0b, 2w
Limit enforcement + PDF report :p0d, after p0c, 2w
section Phase 1 — MVP
Member portal :p1a, after p0d, 2w
Stock management + contamination recall :p1b, after p1a, 2w
Stripe billing integration :p1c, after p1b, 2w
DSGVO + beta launch (5 clubs) :p1d, after p1c, 2w
section Phase 2 — Launch
Payment flows + email notifications :p2a, after p1d, 4w
Marketing site + legal review :p2b, after p2a, 4w
Soft launch to club community :milestone, after p2b, 0d
section Phase 3 — Growth
PrimeFaces → Next.js migration :p3a, 2026-12-01, 8w
PWA mobile :p3b, after p3a, 4w
Template marketplace + referral :p3c, after p3b, 8w
```
---
## 9. Legal Framework
### Key CanG Provisions
| Provision | Content | Product Implication |
|-----------|---------|---------------------|
| **§2 CanG** | Definitions — Anbauvereinigung, Mitglied | Data model must align with statutory definitions of club and member |
| **§§1526 CanG** | Anbauvereinigungen — formation, rights, obligations | Club registration flow must capture legally required club attributes |
| **§22 CanG** | Distribution limits: 25g/day, 50g/month per adult member | Hard enforcement in distribution service; cannot be overridden by admin |
| **§23 CanG** | Under-21 restrictions: 30g/month max, max 10% THC | Age flag on member entity; separate limit enforcement path for restricted category |
| **§§67 CanG** | **Total advertising and sponsoring ban** for cannabis and Anbauvereinigungen | **No public club discovery. No stock visible to non-members. No club listings.** Architecture constraint. |
| **§26 CanG** | Documentation and reporting obligations | Compliance export module is a legal requirement, not an optional feature |
| **§27 CanG** | Prevention officer requirements | Prevention officer fields mandatory in club setup; not optional |
### DSGVO Obligations
- All personal data stored on EU infrastructure (Hetzner DE)
- Data processing agreement (AVV) required with each club before live data entry
- Member data export endpoint required (Art. 20 DSGVO — data portability)
- Member data deletion endpoint required (Art. 17 DSGVO — right to erasure)
- Privacy policy in German, DSGVO-compliant, published before launch
---
## 10. Sign-Off
| Role | Name | Date |
|------|------|------|
| **Project Sponsor** | Patrick Plate | 2026-04-06 |
| **Lead Developer** | Patrick Plate | 2026-04-06 |
| **Product Owner** | Patrick Plate | 2026-04-06 |
---
*Next review date: 2026-05-01 | Source: [STRATEGY.md](../STRATEGY.md)*
@@ -0,0 +1,467 @@
# CannaManage — User Stories & Acceptance Criteria
**Author:** Patrick Plate
**Date:** 2026-04-06
**Version:** 1.0
**Status:** Draft for Review
---
## 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 |
---
## Must Have — MVP v1
### Club Admin Stories
---
### US-001: Register Club and Complete Setup Wizard
**As a** Club Admin, **I want to** register my Anbauvereinigung and complete a guided setup wizard, **so that** my club is correctly configured with all legally required attributes before any members are added.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can register with email + password; email confirmation required before accessing the system
- [ ] AC2: Setup wizard collects: club name, registered address, founding date, Vereinsregisternummer (if available), maximum membership count
- [ ] AC3: Wizard requires designation of a Prevention Officer (name, contact) — field is mandatory, cannot be skipped
- [ ] AC4: Wizard requires acceptance of DSGVO data processing agreement (AVV) before any member data can be entered
- [ ] AC5: Completing the wizard provisions the club's isolated tenant environment (all subsequent data scoped to this club only)
- [ ] AC6: Admin receives a welcome email with login link after successful setup
- [ ] AC7: Incomplete wizard state is saved — admin can resume from last completed step
**Notes:** The AVV acceptance (AC4) is a legal prerequisite for handling member personal data under DSGVO. It must be timestamped and stored.
---
### US-002: Add and Remove Members with Age Verification
**As a** Club Admin, **I want to** add and remove club members with age verification, **so that** the member roster is accurate and the system can apply the correct distribution limits per member.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can add a member with: full name, date of birth, email (optional), membership start date, member ID (auto-generated or manual)
- [ ] AC2: System rejects members with date of birth indicating age < 18
- [ ] AC3: Members aged 1821 are automatically flagged as "Restricted (§23 CanG)" — this flag drives reduced quantity limits
- [ ] AC4: Admin can deactivate (soft-delete) a member; deactivated members cannot receive distributions but their historical records are preserved
- [ ] AC5: Admin can permanently delete a member record (DSGVO Art. 17 right to erasure); system warns if member has distribution history and requires explicit confirmation
- [ ] AC6: Member list is searchable by name and filterable by status (active / restricted / deactivated)
- [ ] AC7: Total active member count is visible on the dashboard and in the member list header
**Notes:** Hard deletion (AC5) must cascade correctly — distribution records referencing the member must be anonymised, not deleted, to preserve the compliance audit trail.
---
### US-003: Record a Distribution
**As a** Club Admin, **I want to** record each cannabis distribution to a member, **so that** every handout is documented as required by §26 CanG and the member's consumption is tracked.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can log a distribution by selecting: member (search/autocomplete), strain, weight in grams (decimal, e.g. 3.5g), batch, date and time
- [ ] AC2: System pre-fills date/time with current timestamp; admin can override
- [ ] AC3: If the distribution would cause the member to exceed their daily limit (25g), the system displays a prominent warning and requires explicit override confirmation
- [ ] AC4: If the distribution would cause the member to exceed their monthly limit (50g adult / 30g restricted), the system **blocks** the entry and displays the reason
- [ ] AC5: For restricted members (§23), system additionally validates that the selected strain's THC percentage is ≤ 10% (if THC% is recorded on the batch)
- [ ] AC6: Successfully saved distributions appear immediately in the distribution log and update the member's monthly counter
- [ ] AC7: Distribution records are immutable after creation — admin can only add a correction note, not edit the original record
**Notes:** Immutability (AC7) is essential for audit integrity. Correction notes are the appropriate mechanism for errors.
---
### US-004: View and Enforce Distribution Limits
**As a** Club Admin, **I want to** view each member's current distribution totals and remaining quota, **so that** I can verify limits at a glance before and after recording distributions.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Each member's detail view shows: distributions this month (total grams), daily total for today, remaining monthly quota, and limit category (Adult 50g / Restricted 30g)
- [ ] AC2: Remaining quota is displayed as a progress bar (visual indicator of how close to the limit)
- [ ] AC3: Members who have reached or exceeded their monthly limit are visually flagged in the member list (e.g., red badge)
- [ ] AC4: Members who have consumed > 80% of their monthly limit show a warning indicator (e.g., amber badge)
- [ ] AC5: Monthly counters reset automatically on the first of each calendar month
- [ ] AC6: System applies §22 limits (50g/month, 25g/day) for adults and §23 limits (30g/month) for restricted members — these cannot be changed by the admin
**Notes:** The limits in AC6 are statutory and must be hardcoded, not configurable per club.
---
### US-005: Manage Stock (Strains, Quantities, Batches)
**As a** Club Admin, **I want to** manage my club's cannabis stock including strains, batch information, and quantities, **so that** I know what is available for distribution and can track batch provenance for contamination purposes.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can create a strain with: name, THC% (optional), CBD% (optional), variety type (Indica/Sativa/Hybrid)
- [ ] AC2: Admin can create a batch linked to a strain with: batch ID (auto-generated), quantity in grams, harvest date (optional), grow cycle reference (optional)
- [ ] AC3: Each distribution recorded reduces the associated batch's available quantity
- [ ] AC4: Admin can manually adjust stock quantity with a reason note (e.g., "lab sample", "disposal")
- [ ] AC5: Admin is warned (but not blocked) when a batch's available quantity drops below a configurable threshold (default: 100g)
- [ ] AC6: Stock overview page shows all active batches with: strain name, batch ID, quantity available, quantity distributed to date
- [ ] AC7: Depleted batches (quantity = 0) are automatically moved to an "archived" view
**Notes:** Batch tracking is required for contamination recall (US-009). The batch ID must be immutable once created.
---
### US-006: View Admin Dashboard
**As a** Club Admin, **I want to** see a summary dashboard when I log in, **so that** I have an at-a-glance overview of club activity and can identify anything requiring attention.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Dashboard displays: total active members, members at/near their monthly limit (count), total distributions this calendar month (grams), active stock level (total grams across all batches)
- [ ] AC2: Dashboard shows a count of members in the "restricted §23" category separately
- [ ] AC3: Dashboard highlights any batches flagged as contaminated (contamination alert count)
- [ ] AC4: Dashboard includes a recent activity feed (last 10 distributions: member name, strain, weight, time)
- [ ] AC5: All dashboard data reflects the admin's own club only — never cross-tenant data
- [ ] AC6: Dashboard loads in < 3 seconds on Hetzner VPS hardware
**Notes:** Keep the dashboard simple for MVP — a single page with widgets. No charts required for v1.
---
### US-007: Export Monthly Compliance Report (PDF + CSV)
**As a** Club Admin, **I want to** export a monthly compliance report as PDF and CSV, **so that** I can fulfil my documentation and reporting obligations under §26 CanG.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can select any calendar month/year and generate a compliance report
- [ ] AC2: PDF report contains: club name, reporting period, total distributions (count and weight), distribution detail table (member ID, strain, batch, weight, date/time), stock summary
- [ ] AC3: Member names in the PDF are replaced with member IDs to minimise PII exposure in the report document (actual name lookup available to the club separately)
- [ ] AC4: CSV export contains full distribution log for the selected period with headers: member_id, strain, batch_id, weight_g, distribution_date, distribution_time
- [ ] AC5: PDF is generated server-side using iText 7 (no client-side rendering dependency)
- [ ] AC6: Export completes in < 10 seconds for a month with up to 5,000 distribution records
- [ ] AC7: Generated reports are not stored on the server — they are streamed directly to the browser as a download
**Notes:** Not storing reports (AC7) reduces data exposure risk. The club is responsible for retaining their own copies.
---
### US-008: Export Member List for Inspections
**As a** Club Admin, **I want to** export the current member list, **so that** I can present it to authorities during an inspection as required by law.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can export the active member list as PDF and CSV at any time
- [ ] AC2: Export includes: member ID, full name, date of birth, age category (Adult/Restricted §23), membership start date, current membership status
- [ ] AC3: Export is timestamped with the generation date/time in the document
- [ ] AC4: Admin is shown a DSGVO reminder before downloading (this document contains personal data — handle per your privacy obligations)
- [ ] AC5: Export includes the club name and address in the header
- [ ] AC6: Only active members are included by default; admin can optionally include deactivated members
**Notes:** This document contains significant PII. The DSGVO reminder (AC4) is important to keep admins legally aware.
---
### US-009: Trigger Contamination Alert for a Batch
**As a** Club Admin, **I want to** flag a batch as contaminated and immediately see all members who received from it, **so that** I can notify affected members and fulfil my contamination traceability obligations under CanG.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can mark any batch as "contaminated" with a reason note and timestamp
- [ ] AC2: Immediately upon flagging, system displays a list of all members who received distributions from the contaminated batch (name, member ID, total grams received, dates received)
- [ ] AC3: Contaminated batches are removed from the active distribution interface — admin cannot select them for new distributions
- [ ] AC4: The dashboard shows a contamination alert badge whenever any active batch is flagged
- [ ] AC5: Admin can export the affected member list as PDF and CSV (for authority notification)
- [ ] AC6: Contamination status is immutable — once flagged, only a senior action (with confirmation) can reverse it; reversal is logged with reason
**Notes:** Contamination traceability is explicitly required by CanG. Response speed matters — the affected member list (AC2) must display without delay.
---
### US-010: Manage Prevention Officer Information
**As a** Club Admin, **I want to** record and update Prevention Officer (Präventionsbeauftragter) information, **so that** my club meets the mandatory requirement of §27 CanG.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Club profile includes a Prevention Officer section with fields: full name, contact email, contact phone, designation date
- [ ] AC2: All four fields are required — the system warns if any is empty and marks the section as incomplete
- [ ] AC3: Admin can update the Prevention Officer at any time; previous officer entries are retained in a change log (name, designation period)
- [ ] AC4: The compliance report export (US-007) includes the current Prevention Officer name and contact in its header
- [ ] AC5: Setup wizard (US-001) cannot be completed without entering Prevention Officer information
**Notes:** This is a statutory requirement, not optional. AC5 enforces that clubs cannot operate on the platform without this data.
---
### Member Portal Stories
---
### US-011: Login with Club-Issued Credentials
**As a** Club Member, **I want to** log in to the member portal using credentials issued by my club, **so that** I can access my personal information without the club admin needing to be present.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can generate login credentials (username + temporary password) for a member from the member management screen
- [ ] AC2: Member receives credentials via a secure channel (displayed to admin for manual handoff in MVP; email in v2)
- [ ] AC3: Member is required to change their temporary password on first login
- [ ] AC4: Member login is scoped to their club only — they cannot access any other club's data or member list
- [ ] AC5: Failed login attempts are rate-limited (5 attempts, then 15-minute lockout)
- [ ] AC6: Member sessions expire after 24 hours of inactivity
- [ ] AC7: Members cannot register themselves — accounts are always created by the Club Admin
**Notes:** AC7 is critical for CanG compliance — only verified, age-checked members should have portal access.
---
### US-012: View Personal Distribution History
**As a** Club Member, **I want to** view my personal distribution history, **so that** I can track what I have received from the club.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Member can view all their distributions in reverse chronological order: date/time, strain, weight (grams), batch ID
- [ ] AC2: Current calendar month distributions are shown first, with a clear monthly subtotal
- [ ] AC3: Member can filter history by month/year
- [ ] AC4: Member sees only their own distribution history — no other member's data is accessible
- [ ] AC5: History is read-only — members cannot edit or delete distribution records
---
### US-013: View Current Stock Availability
**As a** Club Member, **I want to** see what strains are currently available at the club, **so that** I know what I can request on my next visit.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Member portal shows a stock list with: strain name, variety type (Indica/Sativa/Hybrid), THC% (if recorded), availability status (Available / Low Stock / Unavailable)
- [ ] AC2: Exact batch quantities are NOT shown to members — only availability status
- [ ] AC3: Only strains with available stock (quantity > 0) are shown as "Available"
- [ ] AC4: Strains with stock below the admin-configured low-stock threshold are shown as "Low Stock"
- [ ] AC5: For restricted members (§23 CanG), strains with THC > 10% are shown with a "Not available to you" indicator rather than hidden (transparency about why)
- [ ] AC6: Stock view is refreshed in real time — no stale cache longer than 5 minutes
**Notes:** AC2 is important — showing exact quantities could constitute advertising for the club's stock. Only availability status is shown.
---
### US-014: View Remaining Monthly Quota
**As a** Club Member, **I want to** see my remaining monthly quota, **so that** I can plan my distributions and stay within my legal limits.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Member portal homepage prominently displays: consumed this month (grams), remaining quota (grams), monthly limit (grams), days remaining in current month
- [ ] AC2: Quota is displayed as a progress bar with colour coding: green (< 50% used), amber (5080% used), red (> 80% used)
- [ ] AC3: Members in the restricted §23 category see their 30g/month limit (not the 50g adult limit)
- [ ] AC4: Daily limit status is also visible: consumed today (grams) vs. 25g daily cap
- [ ] AC5: Quota resets display on the first of each calendar month — confirmed visually (e.g., "Resets in X days")
---
## Should Have — v2
---
### US-015: Process Membership Fee Payments via Stripe
**As a** Club Admin, **I want to** collect membership fees from members via Stripe, **so that** fee collection is automated and documented without manual bank transfers.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Admin can configure an annual membership fee amount for their club
- [ ] AC2: Members can pay via Stripe-hosted checkout (card payment)
- [ ] AC3: Stripe subscription or one-time payment for annual fee — admin configures which model
- [ ] AC4: Payment confirmation is logged against the member record with date and amount
- [ ] AC5: Admin can view payment status per member (paid / pending / overdue)
- [ ] AC6: No cannabis product payments are ever processed through this system — fee is for club membership only
**Notes:** Stripe position: membership fees for registered non-profit clubs (Vereinsbeiträge) are standard use case. AC6 must be enforced at system design level.
---
### US-016: Manage Automated Waiting List
**As a** Club Admin, **I want to** manage a waiting list for new membership applicants, **so that** I can process applications in order while respecting the club's maximum membership count.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Admin can set a maximum member count for the club (from setup wizard or settings)
- [ ] AC2: When member count reaches maximum, new applicants are added to a waiting list with timestamp
- [ ] AC3: Waiting list is FIFO — applicants are offered membership in order of application
- [ ] AC4: Admin can notify the next waiting list applicant (email notification — v2 dependency)
- [ ] AC5: Admin can remove applicants from the waiting list
- [ ] AC6: Waiting list count is visible on the admin dashboard
---
### US-017: Receive Email and SMS Notifications
**As a** Club Member, **I want to** receive email (and optionally SMS) notifications for key events, **so that** I am informed without needing to log in to the portal.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Member receives email notification when their distribution is recorded by the admin
- [ ] AC2: Member receives email when their monthly quota reaches 80% consumed
- [ ] AC3: Member receives email when a batch they received from is flagged as contaminated
- [ ] AC4: Admin receives email when any member's quota is exceeded (should not happen, but safety net)
- [ ] AC5: SMS notifications are optional and require member opt-in; email is default
- [ ] AC6: All notification emails are sent in German (language is not configurable in v2)
- [ ] AC7: Members can manage notification preferences (opt out of non-mandatory notifications)
---
### US-018: Track Multi-Strain Grow Cycles
**As a** Club Admin, **I want to** track grow cycles linked to batches, **so that** I have full provenance from grow start to distribution.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Admin can create a grow cycle with: cycle ID, strain, start date, expected harvest date, grow area (optional), notes
- [ ] AC2: Batches can be linked to a grow cycle
- [ ] AC3: Grow cycle view shows: all batches produced, total yield, grow duration
- [ ] AC4: Closed grow cycles (harvest complete) are archived but remain searchable
- [ ] AC5: Grow cycle data is included in the monthly compliance report (batch provenance section)
---
## Could Have — v3
---
### US-019: Access Mobile PWA
**As a** Club Member, **I want to** use CannaManage on my smartphone without installing an app, **so that** I can check my quota and stock on the go.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: The member portal is fully responsive and usable on mobile viewport sizes (320px and up)
- [ ] AC2: The app can be added to the home screen (PWA manifest, service worker, offline cache for quota display)
- [ ] AC3: Core member portal features (quota, distribution history, stock view) work in offline mode with cached data
- [ ] AC4: Admin portal is also responsive (admin-on-the-go distribution logging)
- [ ] AC5: No app store submission required — pure PWA
---
### US-020: Support Multi-Location Club
**As a** Club Admin, **I want to** manage a club with multiple distribution locations, **so that** members can pick up from different sites and all distributions are consolidated.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: Admin can define multiple locations (name, address) for one club
- [ ] AC2: Distributions are recorded with a location tag
- [ ] AC3: Stock is managed per location or shared — admin configures which model
- [ ] AC4: Compliance reports can be generated per location or consolidated for the whole club
- [ ] AC5: Members are assigned a primary location but can receive from any location within quota limits
---
### US-021: Download Legal Document Templates
**As a** Club Admin, **I want to** download standardised legal document templates (Satzung, Jugendschutzkonzept), **so that** I can fulfil my legal obligations without hiring a lawyer for every document.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: Template library is accessible from the admin portal (separate from compliance exports)
- [ ] AC2: Available templates include: Vereinssatzung (club charter), Jugendschutzkonzept (youth protection concept), DSGVO Datenschutzerklärung
- [ ] AC3: Templates are pre-filled with club-specific data (name, address, Prevention Officer) where applicable
- [ ] AC4: Templates are available as DOCX (editable) and PDF (final version)
- [ ] AC5: Template library is a paid add-on (€49 one-time or included in Professional/Enterprise plan)
---
### US-022: Integrate with Authority Reporting Portals
**As a** Club Admin, **I want to** submit compliance reports directly to authority portals via CannaManage, **so that** I save time and avoid transcription errors in authority submissions.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: System can detect available authority portals by Bundesland (state)
- [ ] AC2: Admin can initiate a report submission from within CannaManage
- [ ] AC3: Submission status is tracked (submitted, acknowledged, rejected) per report
- [ ] AC4: System retries failed submissions automatically (up to 3 times)
- [ ] AC5: This feature is only activated once at least one Bundesland has a machine-readable submission portal
**Notes:** Authority portals may not exist in v3 timeline — this is aspirational and depends on government digitalisation progress.
---
## Won't Have — MVP (Explicitly Excluded)
---
### US-023: Public Club Discovery — "Find Clubs Near You"
**As a** Public User, I want to find cannabis clubs near my location.
**Priority:** Won't Have (MVP)
**Reason:** **Explicitly illegal under CanG §§67.** The advertising and sponsoring ban covers any feature that functions as advertising for Anbauvereinigungen to the general public. A public club directory constitutes advertising for clubs. This feature will never be built in any form on this platform.
**Acceptance Criteria:** *None — this feature is permanently excluded.*
**Notes:** This is not a commercial decision. It is a **legal constraint** hardcoded into the product architecture. No public-facing club listing, no map, no search, no "register your club publicly."
---
### US-024: Cannabis E-Commerce or Payment for Cannabis Products
**As a** Club Member, I want to purchase cannabis through the CannaManage platform.
**Priority:** Won't Have (MVP)
**Reason:** **Illegal.** Cannabis sales are not the legal model for Anbauvereinigungen under CanG. Payment for cannabis products would violate German law and immediately trigger Stripe account termination. CannaManage processes membership fee payments only — not cannabis product payments, ever.
**Acceptance Criteria:** *None — permanently excluded.*
---
### US-025: Non-EU Data Storage
**As a** Club Admin, I want my club's data stored on the cheapest/fastest infrastructure, including non-EU servers.
**Priority:** Won't Have (MVP)
**Reason:** **DSGVO violation.** Club member data includes personal data (name, date of birth, consumption records). Storing this outside the EU without a valid adequacy decision or standard contractual clauses violates Art. 4449 DSGVO. All data remains on Hetzner DE datacenters.
**Acceptance Criteria:** *None — permanently excluded.*
---
## Acceptance Criteria Traceability Matrix
| Story | Role | Phase | Legal Basis | Key Risk |
|-------|------|-------|-------------|----------|
| US-001 | Club Admin | MVP | DSGVO (AVV) | Clubs operating without AVV |
| US-002 | Club Admin | MVP | §2223 CanG | Under-21 age verification gaps |
| US-003 | Club Admin | MVP | §26 CanG | Distribution limit bypass |
| US-004 | Club Admin | MVP | §2223 CanG | Incorrect limit category applied |
| US-005 | Club Admin | MVP | §26 CanG (batch traceability) | Inaccurate stock → wrong quota available |
| US-006 | Club Admin | MVP | — | Cross-tenant data leak |
| US-007 | Club Admin | MVP | §26 CanG | Incomplete report → authority rejection |
| US-008 | Club Admin | MVP | §26 CanG | Outdated member list at inspection |
| US-009 | Club Admin | MVP | CanG (contamination traceability) | Delayed recall notification |
| US-010 | Club Admin | MVP | §27 CanG | Missing officer → club licence risk |
| US-011 | Club Member | MVP | DSGVO | Unauthorised member account creation |
| US-012 | Club Member | MVP | DSGVO (Art. 15 access) | Cross-member data exposure |
| US-013 | Club Member | MVP | §§67 CanG (no advertising) | Over-disclosure of stock data |
| US-014 | Club Member | MVP | §2223 CanG | Member unaware of impending limit breach |
| US-015 | Club Admin | v2 | — | Stripe cannabis-adjacent policy |
| US-016 | Club Admin | v2 | — | Waiting list ordering errors |
| US-017 | Club Member | v2 | DSGVO (email marketing consent) | Spam / opt-out compliance |
| US-018 | Club Admin | v2 | §26 CanG (provenance) | Batch-grow linkage gaps |
| US-019 | Club Member | v3 | — | Offline cache staleness |
| US-020 | Club Admin | v3 | — | Stock isolation complexity |
| US-021 | Club Admin | v3 | — | Template legal accuracy |
| US-022 | Club Admin | v3 | §26 CanG | Portal API non-existence |
| US-023 | *(none)* | Never | **Illegal §§67 CanG** | Platform shutdown risk |
| US-024 | *(none)* | Never | **Illegal** | Stripe termination + criminal liability |
| US-025 | *(none)* | Never | **DSGVO Art. 4449** | Regulatory fine + club data breach |
---
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
@@ -0,0 +1,504 @@
# 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 · PrimeFaces JSF MVP → Next.js v2
**Last updated:** 2026-04-06
---
## 1. Architecture Overview
```mermaid
graph TD
AdminBrowser["🖥️ Browser — Admin Portal"]
MemberBrowser["🖥️ Browser — Member Portal"]
JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"]
AdminBrowser -->|HTTP/S| JSF
MemberBrowser -->|HTTP/S| JSF
JSF -->|REST calls| Backend
subgraph Backend ["☕ Spring Boot 3.x 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"]
REST --> Service
Service --> JPA
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)"]
Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG
subgraph Hetzner ["🖧 Hetzner VPS — Docker Compose"]
Backend
PG
Nginx["🔒 Nginx\n(reverse proxy + TLS)"]
end
JSF --> Nginx
Nginx --> Backend
```
### Component Responsibilities
| Component | Technology | Role |
|---|---|---|
| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI |
| Member Portal | PrimeFaces JSF (→ 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 |
| 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 |
| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment |
---
## 2. Multi-Tenancy Strategy
### Approach: Shared Schema with Row-Level Filtering
Every JPA entity carries a `tenant_id` column (UUID, `NOT NULL`). A single PostgreSQL database hosts all clubs — row-level filtering enforces data isolation at the application layer.
**Why shared schema (not separate schema/DB per tenant)?**
- Lower operational overhead for an MVP with < 500 clubs
- Single Flyway migration path across all tenants
- Simpler connection pooling (one pool, not N)
- Acceptable security risk when `tenant_id` filter is enforced at the service layer
### Tenant Resolution
```
HTTP Request
└─ Spring Security Filter: extract JWT → resolve tenant_id
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
└─ JPA @Where filter applied on every entity query
```
### Code Pattern — Tenant-Aware Base Entity
```java
// AbstractTenantEntity.java (pseudocode)
@MappedSuperclass
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = UUID.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class AbstractTenantEntity {
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@PrePersist
void injectTenant() {
this.tenantId = TenantContext.getCurrentTenant();
}
}
```
```java
// TenantFilterInterceptor.java (pseudocode)
@Component
public class TenantFilterInterceptor implements HandlerInterceptor {
@Autowired EntityManager em;
@Override
public boolean preHandle(HttpServletRequest req, ...) {
UUID tenantId = TenantContext.getCurrentTenant();
Session session = em.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
return true;
}
}
```
**Invariants enforced:**
- `tenant_id` is set at `@PrePersist` — never accepted from user input
- `tenant_id` is `updatable = false` — cannot be changed after creation
- Hibernate filter is enabled on every request thread before any query executes
- All repository methods inherit the filter; raw JPQL queries must include `AND e.tenantId = :tenantId`
---
## 3. Authentication & Authorization
### JWT Token Flow
- **Access token:** 8-hour expiry, signed HS256, contains `sub` (userId), `role`, `tenantId`
- **Refresh token:** 30-day expiry, stored in `users.refresh_token_hash` (hashed)
- **Stateless:** No server-side session. JWT is verified on every request by `JwtAuthFilter`
### Roles
| Role | Description | Access |
|---|---|---|
| `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions |
| `ROLE_MEMBER` | Club member | Own quota, own distribution history |
| `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data |
### Service-Layer Authorization Example
```java
@Service
public class DistributionService {
@PreAuthorize("hasRole('CLUB_ADMIN')")
public Distribution recordDistribution(RecordDistributionRequest req) { ... }
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
public QuotaStatus getMyQuota(UUID memberId) { ... }
@PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')")
public List<Member> getUnder21Members() { ... }
}
```
### Member Login Sequence
```mermaid
sequenceDiagram
participant B as Browser
participant API as Spring Boot /api/v1/auth/login
participant DB as PostgreSQL (users table)
participant JWT as JwtService
B->>API: POST /api/v1/auth/login {email, password}
API->>DB: SELECT * FROM users WHERE email = ? AND active = true
DB-->>API: UserEntity (password_hash, role, tenant_id, member_id)
API->>API: BCrypt.verify(password, password_hash)
alt Invalid credentials
API-->>B: 401 Unauthorized
else Valid
API->>JWT: generateAccessToken(userId, role, tenantId) → 8h
API->>JWT: generateRefreshToken(userId) → 30d
API->>DB: UPDATE users SET refresh_token_hash = ?, last_login = NOW()
DB-->>API: OK
JWT-->>API: accessToken, refreshToken
API-->>B: 200 { accessToken, refreshToken, expiresIn: 28800 }
end
```
---
## 4. Data Model (JPA Entities)
### Entity-Relationship Diagram
```mermaid
erDiagram
Club {
UUID id PK
UUID tenant_id
string name
string address
string license_number
int max_members
timestamp created_at
enum status
}
Member {
UUID id PK
UUID tenant_id
UUID club_id FK
string first_name
string last_name
string email
date date_of_birth
date membership_date
string membership_number
enum status
boolean is_under_21
boolean prevention_officer
}
Strain {
UUID id PK
UUID tenant_id
string name
decimal thc_percentage
decimal cbd_percentage
string description
}
Batch {
UUID id PK
UUID tenant_id
UUID strain_id FK
decimal quantity_grams
date harvest_date
string batch_code
enum status
boolean contamination_flag
}
Distribution {
UUID id PK
UUID tenant_id
UUID member_id FK
UUID batch_id FK
decimal quantity_grams
timestamp distributed_at
UUID recorded_by FK
string notes
boolean immutable
}
MonthlyQuota {
UUID id PK
UUID tenant_id
UUID member_id FK
int year
int month
decimal total_distributed
decimal max_allowed
}
StockMovement {
UUID id PK
UUID tenant_id
UUID batch_id FK
enum movement_type
decimal quantity_grams
string reason
timestamp created_at
}
User {
UUID id PK
UUID tenant_id
UUID member_id FK
string email
string password_hash
enum role
timestamp last_login
boolean active
}
Club ||--o{ Member : "has members"
Member ||--o{ Distribution : "receives"
Member ||--o{ MonthlyQuota : "has quota per month"
Member ||--o| User : "may have login"
Strain ||--o{ Batch : "cultivated as"
Batch ||--o{ Distribution : "distributed via"
Batch ||--o{ StockMovement : "tracked in"
Member ||--o{ Distribution : "recorded_by (admin)"
```
### Relationship Notes
| Relationship | Cardinality | Notes |
|---|---|---|
| Club → Member | 1:N | `member.club_id` FK; max enforced by `Club.max_members` |
| Member → Distribution | 1:N | Each distribution targets one member |
| Member → MonthlyQuota | 1:N | One row per `(member_id, year, month)` — UNIQUE constraint |
| Member → User | 1:0..1 | A member may have a portal login; admins may have no `member_id` |
| Strain → Batch | 1:N | Each batch is one strain; a strain can have many batches |
| Batch → Distribution | 1:N | A batch can supply many distributions |
| Batch → StockMovement | 1:N | Every IN/OUT/RECALL against a batch is journaled |
| Distribution.recorded_by → Member | N:1 | The admin who recorded it (audit trail) |
### Key Constraints
- `Distribution.immutable = true` by default — records are append-only; no UPDATE/DELETE allowed via API
- `MonthlyQuota` has `UNIQUE(member_id, year, month)` — enforced at DB level
- `Batch.contamination_flag` triggers recall workflow; `Batch.status = RECALLED` is the final state
- All `tenant_id` columns: `NOT NULL`, `updatable = false`, set via `@PrePersist`
- `Member.is_under_21` is derived from `date_of_birth` at registration and re-evaluated on birthday (scheduled job)
---
## 5. API Layer Design
### Base Path: `/api/v1/`
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` |
### Standard HTTP conventions
- `201 Created` + `Location` header on resource creation
- `400 Bad Request` with `{ error, message, field? }` on validation failure
- `403 Forbidden` when role/tenant check fails
- `422 Unprocessable Entity` when compliance limits are breached (quota exceeded)
- Pagination: `?page=0&size=20&sort=field,asc`
---
## 6. Compliance Engine
The `ComplianceService` is the heart of regulatory enforcement. It is called synchronously before every distribution is persisted. All operations run in a single `@Transactional` block with optimistic locking to prevent race conditions under concurrent distribution recording.
```java
@Service
@Transactional
public class ComplianceService {
/**
* Validates whether a distribution is legally permitted.
*
* Checks:
* 1. Member is ACTIVE (not SUSPENDED or EXPELLED)
* 2. Daily limit: total distributed today + requestedGrams ≤ 25g
* 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed
* where max_allowed = 30g (under-21) or 50g (adult)
* 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED)
* 5. Batch has sufficient stock
*
* @throws ComplianceLimitExceededException with remaining quota details
* @throws MemberIneligibleException if member is not ACTIVE
* @throws BatchUnavailableException if batch is recalled or exhausted
*/
public ComplianceCheckResult checkDistributionAllowed(
UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... }
/**
* Returns remaining quota for the current calendar month.
* Creates a MonthlyQuota row if none exists (lazy initialization).
*
* @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 }
*/
public QuotaStatus getMonthlyRemaining(UUID memberId) { ... }
/**
* Flags a batch as RECALLED.
* Returns all members who received distributions from this batch
* so the caller can trigger notifications.
* Writes a StockMovement(RECALL) entry.
*
* @return List<AffectedMember> { memberId, name, email, totalReceived }
*/
public List<AffectedMember> recallBatch(UUID batchId) { ... }
}
```
### Race Condition Prevention
`MonthlyQuota` rows carry a JPA `@Version` column (optimistic locking). If two concurrent requests attempt to increment `total_distributed` simultaneously, one will receive an `OptimisticLockException` and must retry. The retry logic is handled by a `@Retryable` annotation (Spring Retry, max 3 attempts, 50ms backoff).
```java
@Entity
public class MonthlyQuota extends AbstractTenantEntity {
@Version
private Long version; // optimistic lock
// ... other fields
}
```
---
## 7. Infrastructure (Hetzner)
```mermaid
graph TD
Dev["👨‍💻 Developer (Fedora Workstation)"]
Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"]
Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"]
Dev -->|git push| Gitea
Gitea -->|SSH deploy script\n(webhook → deploy.sh)| Hetzner
subgraph Hetzner
Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"]
App["☕ cannamanage-app\n(Spring Boot JAR)"]
DB[("🐘 cannamanage-db\nPostgreSQL 16")]
Nginx -->|proxy_pass :8080| App
App -->|JDBC :5432| DB
end
Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx
```
### Docker Compose Services
```yaml
# docker-compose.yml (abbreviated)
services:
cannamanage-app:
image: cannamanage:latest
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage
JWT_SECRET: ${JWT_SECRET}
STRIPE_API_KEY: ${STRIPE_API_KEY}
depends_on: [cannamanage-db]
ports: ["127.0.0.1:8080:8080"]
cannamanage-db:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_DB: cannamanage
POSTGRES_PASSWORD: ${DB_PASSWORD}
cannamanage-nginx:
image: nginx:alpine
ports: ["443:443", "80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt:ro
```
### Hetzner Sizing
| Resource | Spec | Rationale |
|---|---|---|
| Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP |
| Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups |
| Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days |
| Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany |
| TLS | Let's Encrypt via Certbot | Auto-renew via cron |
### Deployment Workflow
```
git push origin main
→ Gitea webhook fires
→ deploy.sh on Hetzner:
docker pull cannamanage:latest
docker compose up -d --no-deps cannamanage-app
# zero-downtime: Nginx buffers requests during restart
```
Flyway migrations run automatically on application startup (`spring.flyway.enabled=true`). Migration files live at `src/main/resources/db/migration/V*.sql`.
---
## 8. Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Multi-tenancy | Shared schema + `tenant_id` | MVP simplicity; upgrade to schema-per-tenant possible later |
| Frontend MVP | PrimeFaces JSF | Patrick's existing expertise; fastest path to working UI |
| Frontend v2 | Next.js / React | Modern UX; deferred to avoid scope creep in MVP |
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
| 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 |
@@ -0,0 +1,229 @@
# 04 — Business Logic Flow Charts
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
**Phase:** 2 of 5 — Architecture & Data Model
**Last updated:** 2026-04-06
All flows are implemented in the Spring Boot service layer. Mermaid `flowchart TD` syntax.
---
## Flow 1: Distribution Recording
Records a cannabis distribution to a member. This is the most compliance-critical path in the system. Every step that can fail returns a user-facing error with actionable detail (remaining quota, batch status, etc.).
```mermaid
flowchart TD
START([🟢 Admin clicks\n'Record Distribution']) --> SEL_MEMBER[Select member from list]
SEL_MEMBER --> LOAD_MEMBER[Load member profile\nfrom MemberRepository]
LOAD_MEMBER --> CHECK_ACTIVE{Member status\n= ACTIVE?}
CHECK_ACTIVE -->|No — SUSPENDED\nor EXPELLED| ERR_MEMBER[❌ Error: Member not eligible\nShow status reason]
CHECK_ACTIVE -->|Yes| CHECK_AGE{is_under_21\n= true?}
CHECK_AGE -->|Under 21| MAX_MONTHLY_30[Monthly limit = 30g]
CHECK_AGE -->|Adult ≥ 21| MAX_MONTHLY_50[Monthly limit = 50g]
MAX_MONTHLY_30 --> ENTER_QTY[Admin enters quantity\nin grams]
MAX_MONTHLY_50 --> ENTER_QTY
ENTER_QTY --> VALIDATE_QTY{quantity > 0\nand ≤ 25g?}
VALIDATE_QTY -->|No| ERR_QTY[❌ Error: Invalid quantity\nDaily max is 25g per visit]
VALIDATE_QTY -->|Yes| CHECK_DAILY[ComplianceService:\nSum distributions today\nfor this member]
CHECK_DAILY --> DAILY_OK{today_total +\nquantity ≤ 25g?}
DAILY_OK -->|No| ERR_DAILY[❌ Error: Daily limit exceeded\nShow remaining today]
DAILY_OK -->|Yes| CHECK_MONTHLY[ComplianceService:\nLoad MonthlyQuota\ncurrent month]
CHECK_MONTHLY --> MONTHLY_OK{monthly_total +\nquantity ≤ max_allowed?}
MONTHLY_OK -->|No| ERR_MONTHLY[❌ Error: Monthly quota exceeded\nShow remaining this month\nand reset date]
MONTHLY_OK -->|Yes| SEL_BATCH[Admin selects batch]
SEL_BATCH --> LOAD_BATCH[Load batch from\nBatchRepository]
LOAD_BATCH --> CHECK_BATCH{Batch status\n= AVAILABLE?}
CHECK_BATCH -->|RECALLED| ERR_RECALLED[❌ Error: Batch recalled\nSelect a different batch]
CHECK_BATCH -->|EXHAUSTED| ERR_EXHAUSTED[❌ Error: Batch exhausted\nNo stock remaining]
CHECK_BATCH -->|AVAILABLE| CHECK_STOCK{batch.quantity_grams\n≥ requested quantity?}
CHECK_STOCK -->|No| ERR_STOCK[❌ Error: Insufficient stock\nShow available quantity]
CHECK_STOCK -->|Yes| CONFIRM[Admin reviews and confirms\ndistribution details]
CONFIRM --> SAVE_DIST["💾 Save Distribution record\n(immutable = true,\nrecorded_by = currentUser)"]
SAVE_DIST --> UPD_QUOTA["💾 UPDATE MonthlyQuota\ntotal_distributed += quantity\n(@Version optimistic lock)"]
UPD_QUOTA --> UPD_STOCK["💾 INSERT StockMovement\n(type = OUT, batch_id, qty)"]
UPD_STOCK --> UPD_BATCH["💾 UPDATE Batch\nquantity_grams -= quantity\n(if = 0 → status = EXHAUSTED)"]
UPD_BATCH --> SUCCESS([✅ Success\nShow confirmation\nwith updated quota display])
```
---
## Flow 2: Member Registration
Registers a new member in the club. Includes DSGVO consent, age validation, under-21 flag assignment, and automatic portal account creation.
```mermaid
flowchart TD
START([🟢 Admin opens\n'Add Member' form]) --> ENTER_DATA[Admin enters member data:\nfirst/last name, email,\ndate of birth, address]
ENTER_DATA --> VALIDATE_EMAIL{Email unique\nin this club?}
VALIDATE_EMAIL -->|Already exists| ERR_EMAIL[❌ Error: Email already\nregistered in this club]
VALIDATE_EMAIL -->|Unique| VALIDATE_AGE{Age ≥ 18?}
VALIDATE_AGE -->|Under 18| ERR_AGE[❌ Error: Member must be\nat least 18 years old\n§ 10 KCanG]
VALIDATE_AGE -->|18 or older| CHECK_UNDER21{18 ≤ age < 21?}
CHECK_UNDER21 -->|Yes| SET_FLAG_TRUE["Set is_under_21 = true\nMonthly limit will be 30g"]
CHECK_UNDER21 -->|No, ≥ 21| SET_FLAG_FALSE["Set is_under_21 = false\nMonthly limit will be 50g"]
SET_FLAG_TRUE --> CHECK_CAPACITY[Check Club.max_members\nvs current member count]
SET_FLAG_FALSE --> CHECK_CAPACITY
CHECK_CAPACITY --> CAPACITY_OK{Club has\nfree capacity?}
CAPACITY_OK -->|No| ERR_CAPACITY[❌ Error: Club at max capacity\nCannot register more members]
CAPACITY_OK -->|Yes| GEN_NUMBER["Generate membership_number\n(club prefix + sequential ID)"]
GEN_NUMBER --> DSGVO[Show DSGVO consent dialog:\n• Data usage explanation\n• Right to erasure\n• Admin must confirm consent obtained]
DSGVO --> DSGVO_OK{Admin confirms\nconsent obtained?}
DSGVO_OK -->|No| ABORT([🔴 Abort — member\ncannot be registered\nwithout DSGVO consent])
DSGVO_OK -->|Yes| SAVE_MEMBER["💾 Save Member\n(status = ACTIVE,\nmembership_date = today)"]
SAVE_MEMBER --> CREATE_USER["💾 Create User account\n(role = ROLE_MEMBER,\ngenerate temp password)"]
CREATE_USER --> SEND_EMAIL["📧 Send welcome email:\n• Membership number\n• Temp login credentials\n• Portal URL\n• DSGVO information sheet PDF"]
SEND_EMAIL --> SUCCESS([✅ Member registered\nShow member profile\nwith membership number])
```
---
## Flow 3: Contamination Batch Recall
Handles the recall of a contaminated batch. This flow is time-critical — speed of notification is essential for member safety. All affected distributions are identified and the prevention officer is notified.
```mermaid
flowchart TD
START([🟢 Admin selects batch\nand clicks 'Flag Recall']) --> CONFIRM_RECALL{Confirm recall\nof batch?\nThis cannot be undone.}
CONFIRM_RECALL -->|Cancel| CANCEL([🔴 Cancelled — batch\nstatus unchanged])
CONFIRM_RECALL -->|Confirm| QUERY_DIST["🔍 Query all Distributions\nWHERE batch_id = :batchId\n(across all members)"]
QUERY_DIST --> HAS_DIST{Any distributions\nfound?}
HAS_DIST -->|No distributions| NO_DIST["⚠️ Batch was never distributed\n(still flag as RECALLED\nfor inventory integrity)"]
HAS_DIST -->|Yes| BUILD_LIST["Build affected member list:\n• member name\n• distribution date\n• quantity received\n• contact email"]
NO_DIST --> FLAG_BATCH
BUILD_LIST --> SHOW_LIST[Show affected member list\nto admin for review]
SHOW_LIST --> ADMIN_REVIEW{Admin reviews\nand confirms recall?}
ADMIN_REVIEW -->|Cancel| CANCEL
ADMIN_REVIEW -->|Proceed| FLAG_BATCH["💾 UPDATE Batch\nstatus = RECALLED\ncontamination_flag = true"]
FLAG_BATCH --> LOG_MOVEMENT["💾 INSERT StockMovement\n(type = RECALL,\nbatch_id, reason)"]
LOG_MOVEMENT --> EXPORT_LIST["📄 Generate export:\n• CSV: affected_members_recall_{batchCode}.csv\n• PDF: recall_report_{batchCode}.pdf\n(via iText 7)"]
EXPORT_LIST --> NOTIFY_OFFICER["📧 Email Prevention Officer:\n• Batch code and details\n• Affected member count\n• Attached CSV/PDF"]
NOTIFY_OFFICER --> AUDIT_LOG["💾 INSERT AuditLog\n(action = BATCH_RECALL,\nperformedBy, timestamp)"]
AUDIT_LOG --> SUCCESS([✅ Recall complete\nOffer download of\nexport files])
```
---
## Flow 4: Compliance Report Generation
Generates the monthly compliance report required by § 22 KCanG. Covers all distributions within a calendar month, with per-member quota analysis and club metadata for regulatory submission.
```mermaid
flowchart TD
START([🟢 Admin opens\nReports section]) --> SELECT_PERIOD[Admin selects\nmonth and year]
SELECT_PERIOD --> VALIDATE_PERIOD{Period in the\npast or current\nmonth?}
VALIDATE_PERIOD -->|Future month| ERR_FUTURE[❌ Error: Cannot generate\nreport for future periods]
VALIDATE_PERIOD -->|Valid| LOAD_CLUB[Load Club metadata:\nlicense number,\nprevention officer name]
LOAD_CLUB --> QUERY_DIST["🔍 ReportService:\nSELECT * FROM distributions\nWHERE month = :month\nAND year = :year\nAND tenant_id = :tenantId"]
QUERY_DIST --> HAS_DATA{Any distributions\nin this period?}
HAS_DATA -->|No data| EMPTY_REPORT[Generate empty report\nwith zero totals\n(still valid compliance submission)]
HAS_DATA -->|Yes| AGG_MEMBER["Aggregate by member:\n• total_distributed_grams\n• number_of_visits\n• quota_usage_percent\n• is_under_21 flag"]
EMPTY_REPORT --> AGG_STRAIN
AGG_MEMBER --> AGG_STRAIN["Aggregate by strain/batch:\n• strain name, THC%, CBD%\n• quantity distributed\n• batch codes used"]
AGG_STRAIN --> ADD_METADATA["Add club metadata:\n• Club name + license number\n• Prevention officer name\n• Report generation timestamp\n• Total members active in period"]
ADD_METADATA --> RENDER_PDF["📄 iText 7:\nRender PDF report\n• Cover page with club details\n• Summary table\n• Per-member breakdown\n• Strain/batch appendix"]
RENDER_PDF --> RENDER_CSV["📊 Generate CSV:\n• One row per distribution\n• member_id, name, date,\n quantity, strain, batch_code"]
RENDER_CSV --> STORE_FILES["💾 Store generated files\ntemporarily in server /tmp\n(TTL: 1 hour)"]
STORE_FILES --> SUCCESS([✅ Report ready\nOffer download:\n📄 PDF 📊 CSV])
```
---
## Flow 5: Member Login & Quota Display
The member portal entry flow. Members log in to view their current monthly quota, remaining allowance, and recent distribution history. This is a read-only portal — members cannot modify any data.
```mermaid
flowchart TD
START([🟢 Member navigates\nto member portal URL]) --> SHOW_LOGIN[Show login form:\nemail + password]
SHOW_LOGIN --> SUBMIT[Member submits credentials]
SUBMIT --> FIND_USER["🔍 Spring Security:\nSELECT FROM users\nWHERE email = ?\nAND active = true"]
FIND_USER --> USER_FOUND{User found?}
USER_FOUND -->|No| ERR_NOTFOUND[❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)]
USER_FOUND -->|Yes| VERIFY_PW{BCrypt.verify\n(password, hash)\nmatches?}
VERIFY_PW -->|No| ERR_PW[❌ Invalid credentials]
VERIFY_PW -->|Yes| CHECK_MEMBER{User has\nmember_id set?}
CHECK_MEMBER -->|No — admin account| ERR_NOTMEMBER[❌ Error: Use admin portal\nfor admin accounts]
CHECK_MEMBER -->|Yes| ISSUE_JWT["🔑 Issue JWT:\n• role = ROLE_MEMBER\n• tenantId = user.tenantId\n• memberId = user.memberId\n• expiry = 8h"]
ISSUE_JWT --> UPDATE_LOGIN["💾 UPDATE users\nlast_login = NOW()"]
UPDATE_LOGIN --> LOAD_PORTAL["Load member portal\n(JSF view or SPA)"]
LOAD_PORTAL --> CALL_QUOTA["📡 GET /api/v1/members/me/quota\n(JWT in Authorization header)"]
CALL_QUOTA --> FETCH_QUOTA["🔍 QuotaController:\nLoad MonthlyQuota\nfor current month\n(create if not exists)"]
FETCH_QUOTA --> CALC_REMAINING{Quota record\nexists?}
CALC_REMAINING -->|No — new month| CREATE_QUOTA["Create MonthlyQuota row:\ntotal_distributed = 0\nmax_allowed = 30g or 50g"]
CALC_REMAINING -->|Yes| RETURN_QUOTA["Return QuotaStatus:\n• totalAllowed\n• totalUsed\n• remaining\n• percentUsed"]
CREATE_QUOTA --> RETURN_QUOTA
RETURN_QUOTA --> DISPLAY_PROGRESS["Display quota progress bar:\n🟩🟩🟩⬜⬜ e.g. 15g of 50g used\nColor: green < 60% / yellow < 85% / red ≥ 85%"]
DISPLAY_PROGRESS --> CALL_HISTORY["📡 GET /api/v1/distributions\n?memberId=me&limit=10\n&sort=distributed_at,desc"]
CALL_HISTORY --> DISPLAY_HISTORY["Display last 10 distributions:\n• Date, quantity, strain name\n• Batch code\n• Recorded by (staff name)"]
DISPLAY_HISTORY --> SUCCESS([✅ Member portal loaded\nQuota + history visible])
```
---
## Flow Summary
| Flow | Trigger | Key Service | Critical Constraint |
|---|---|---|---|
| Distribution Recording | Admin records handout | `ComplianceService` | Daily 25g + monthly 30g/50g limits |
| Member Registration | Admin adds new member | `MemberService` | Age ≥ 18, DSGVO consent mandatory |
| Batch Recall | Admin flags contamination | `ComplianceService.recallBatch()` | Immediate prevention officer notification |
| Report Generation | Admin requests monthly report | `ReportService` | iText 7 PDF + CSV for regulatory filing |
| Member Login | Member accesses portal | `AuthService` + `QuotaController` | JWT stateless, read-only member view |
### Error Handling Conventions
All flows follow these conventions for user-facing error messages:
- **Compliance errors** (`422 Unprocessable Entity`): Always include remaining quota/allowance so the admin knows what quantity would be valid
- **Validation errors** (`400 Bad Request`): Include the specific `field` and a human-readable `message` in German (UI locale)
- **Permission errors** (`403 Forbidden`): Generic message — do not reveal tenant or role details
- **System errors** (`500 Internal Server Error`): Log full stack trace; show generic user message; alert via email to club admin
### Transaction Boundaries
The Distribution Recording flow (Flow 1) executes steps `SAVE_DIST → UPD_QUOTA → UPD_STOCK → UPD_BATCH` in a **single `@Transactional` block**. If any step fails (e.g., optimistic lock collision on `MonthlyQuota`), the entire transaction rolls back and no partial state is persisted.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,550 @@
# CannaManage — Wireframes & UI Mockups
**Phase 4a | Document 6 of 7**
**Date:** 2026-04-06
**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL
---
## Table of Contents
1. [Design System Overview](#1-design-system-overview)
2. [Admin Portal Screens](#2-admin-portal-screens)
3. [Member Portal Screens](#3-member-portal-screens)
4. [Navigation & Information Architecture](#4-navigation--information-architecture)
5. [Responsive Design Notes](#5-responsive-design-notes)
6. [Accessibility](#6-accessibility)
---
## 1. Design System Overview
### 1.1 Color Palette
| Token | Hex | Usage |
|---|---|---|
| `--color-primary` | `#2D5016` | Sidebar background, primary buttons, active nav items |
| `--color-primary-medium` | `#4A7C28` | Hover states, section headers, badge outlines |
| `--color-accent` | `#8BC34A` | Highlights, progress bars filled, success indicators |
| `--color-bg` | `#F5F5F5` | Page background, card backgrounds |
| `--color-text` | `#1A1A1A` | Body text, table cell content |
| `--color-warning` | `#FF6B35` | Quota >80%, low stock, warnings |
| `--color-error` | `#D32F2F` | Quota exceeded, recalled batches, destructive actions |
| `--color-white` | `#FFFFFF` | Sidebar text, button labels on dark bg, card surfaces |
### 1.2 Typography
| Element | Font | Size | Weight |
|---|---|---|---|
| H1 — Page title | Inter | 24px | 600 |
| H2 — Section heading | Inter | 18px | 600 |
| H3 — Card title | Inter | 14px | 600 |
| Body / table rows | Inter | 14px | 400 |
| Caption / label | Inter | 12px | 400 |
| Mono (codes, IDs) | JetBrains Mono | 13px | 400 |
### 1.3 Component Library
All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP.
| Component | Usage |
|---|---|
| `p:panel` | Section containers, card wrappers |
| `p:dataTable` with `p:column` | Tabular data: distributions, members, batches |
| `p:paginator` | Pagination on all tables |
| `p:inputText` | Single-line text fields |
| `p:inputNumber` | Weight inputs (gram precision) |
| `p:selectOneMenu` | Dropdown selects (member, strain, batch) |
| `p:calendar` | Date range pickers for reports |
| `p:progressBar` | Quota consumption display |
| `p:commandButton` | Primary and secondary actions |
| `p:confirmDialog` | Dangerous actions (recall, delete) |
| `p:messages` / `p:message` | Inline validation errors |
| `p:badge` | Status indicators (AVAILABLE, LOW, RECALLED) |
| `p:sidebar` | Mobile nav drawer (member portal) |
| `p:dialog` | Modal overlays |
### 1.4 Layout Grid
```
┌────────────────────────────────────────────────────┐
│ TOP NAVBAR (56px) club name · avatar · logout │
├──────────────┬─────────────────────────────────────┤
│ │ │
│ SIDEBAR │ MAIN CONTENT │
│ (240px) │ (fluid, min 784px) │
│ fixed │ │
│ │ │
└──────────────┴─────────────────────────────────────┘
```
- **Sidebar:** fixed left, `#2D5016` background, white nav labels with `#8BC34A` icons
- **Top Navbar:** `#FFFFFF` with bottom border `#E0E0E0`, breadcrumb left, user controls right
- **Main Content:** `#F5F5F5` background, 24px padding, max content width 1200px centered
---
## 2. Admin Portal Screens
### Screen 1 — Admin Dashboard
![Admin Dashboard](images/mockup-admin-dashboard.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Dashboard 🗓 April 2026 │
│ 📊 Dashboard◄│ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ 👥 Members│ │ Total Members│ │ Distributions│ │ Stock Available│ │
│ │ │ │ │ This Month │ │ │ │
│ 📋 Distrib│ │ 142 │ │ 87 │ │ 3,240 g │ │
│ │ │ ▲ +3 MoM │ │ ▲ +12 MoM │ │ ▼ -800g MoM │ │
│ 📦 Stock │ └──────────────┘ └──────────────┘ └───────────────┘ │
│ │ │
│ 📄 Reports│ Recent Distributions [+ New Entry] │
│ │ ┌─────────────────────────────────────────────────┐ │
│ ✅ Complian│ │ Member │ Strain │ Qty │ Date │ ✓ │ │
│ │ ├─────────────┼─────────────┼───────┼───────┼────┤ │
│ ⚙ Settings│ │ Müller, A. │ OG Kush B12 │ 5.0g │ 06.04 │ ✓ │ │
│ │ │ Schmidt, K. │ Amnesia H09 │ 3.5g │ 06.04 │ ✓ │ │
│ │ │ Weber, T. │ OG Kush B12 │ 7.0g │ 05.04 │ ✓ │ │
│ │ │ … │ │ │ │ │ │
└────────────┴──┴─────────────────────────────────────────────────────┘
```
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s |
| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail |
| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` |
| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` |
| Trend indicators | Custom CSS `<span>` | Green ▲ / Red ▼ with delta value |
---
### Screen 2 — Distribution Recording Form
![Distribution Form](images/mockup-distribution-form.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Distributions New Distribution │
│ 📊 Dashbrd│ │
│ │ ┌──────────────────────────────────────────────────┐ │
│ 👥 Members│ │ Member * │ │
│ │ │ ┌──────────────────────────────────────────┐ │ │
│ 📋 Distrib◄│ │ │ 🔍 Search by name or member no. │ │ │
│ │ │ └──────────────────────────────────────────┘ │ │
│ 📦 Stock │ │ │ │
│ │ │ Strain / Batch * │ │
│ 📄 Reports│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ │ Select available batch ▼ │ │ │
│ ✅ Complian│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │ │
│ ⚙ Settings│ │ Weight (grams) * │ │
│ │ │ ┌──────────┐ │ │
│ │ │ │ 0.0 g │ ← p:inputNumber min=0.1 max=25 │ │
│ │ │ └──────────┘ │ │
│ │ │ │ │
│ │ │ Monthly Quota — Müller, Anna │ │
│ │ │ ████████████░░░░░░░░ 32.5g / 50g 65% │ │
│ │ │ [████████████████░░░] <- p:progressBar │ │
│ │ │ │ │
│ │ │ [ Record Distribution ] [Cancel] │ │
│ │ └──────────────────────────────────────────────────┘ │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Compliance UX — Real-Time Quota Indicator
The quota progress bar updates live as the weight field changes (via `f:ajax event="keyup"`):
| Quota Used After Distribution | Bar Color | Submit Button | Message |
|---|---|---|---|
| 079% | `#8BC34A` (green) | Enabled | — |
| 8099% | `#FF6B35` (orange) | Enabled | "⚠ Approaching monthly limit" |
| 100% | `#D32F2F` (red) | **Disabled** | "🚫 Monthly limit reached (50g)" |
| Over-21 member, >30g monthly | `#D32F2F` (red) | **Disabled** | "🚫 Under-21 limit reached (30g)" |
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Member search | `p:selectOneMenu` with `p:ajax` filter | Filters on type, shows name + member no. |
| Strain/Batch dropdown | `p:selectOneMenu` | Populated after member selection; shows only `AVAILABLE` batches |
| Weight input | `p:inputNumber` min=`0.1` max=`25.0` step=`0.1` | Triggers quota recalculation on blur |
| Quota bar | `p:progressBar` with dynamic `value` | Color class applied via `styleClass` computed in backing bean |
| Submit | `p:commandButton` | Disabled via `disabled="#{bean.quotaExceeded}"` |
| Cancel | `p:link` | Returns to distribution log without saving |
---
### Screen 3 — Stock Management
![Stock Management](images/mockup-stock-management.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Stock Management [+ Add Batch] │
│ 📊 Dashbrd│ │
│ │ ┌──────────────────────┐ ┌────────────────────┐ │
│ 👥 Members│ │ 🔍 Filter by strain │ │ Status: All ▼ │ │
│ │ └──────────────────────┘ └────────────────────┘ │
│ 📋 Distrib│ │
│ │ ┌───────────────────────────────────────────────────┐ │
│ 📦 Stock ◄│ │ Strain │Batch│THC% │CBD%│ Qty │Status│Act │ │
│ │ ├──────────────┼─────┼─────┼────┼───────┼──────┼────┤ │
│ 📄 Reports│ │ OG Kush │B-12 │ 19% │ 1% │ 850g │ ● │[R] │ │
│ │ │ Amnesia Haze │H-09 │ 22% │<1% │ 72g │ ⚠ │[R] │ │
│ ✅ Complian│ │ Blue Dream │D-05 │ 17% │ 2% │ 0g │ — │[R] │ │
│ │ │ Hindu Kush │K-21 │ 8% │15% │ 340g │ ✓ │[R] │ │
│ ⚙ Settings│ │ AK-47 #4 │A-03 │ 20% │ 1% │ RECALLED │ ⛔ │[R] │ │
│ │ └───────────────────────────────────────────────────┘ │
│ │ [◄ 1 2 3 … ►] Showing 1-10/42 │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Status Badges
| Badge | Color | Icon | Condition |
|---|---|---|---|
| `AVAILABLE` | `#4A7C28` bg | ✓ checkmark | `qty > 100g` and not recalled |
| `LOW` | `#FF6B35` bg | ⚠ warning | `0 < qty ≤ 100g` |
| `EXHAUSTED` | `#9E9E9E` bg | — dash | `qty = 0` |
| `RECALLED` | `#D32F2F` bg | ⛔ stop | `recall_date IS NOT NULL` |
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup |
| Status filter | `p:selectOneMenu` | Filters table rows by status value |
| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page |
| Status badge | Custom CSS `<span class="badge badge-{status}">` | Icon + text label (not color alone) |
| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing |
| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form |
---
### Screen 4 — Compliance Report Generation
![Compliance Report](images/mockup-compliance-report.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Reports Monthly Compliance Report │
│ 📊 Dashbrd│ │
│ │ ┌─────────────────────────────────────────────────┐ │
│ 👥 Members│ │ Reporting Period │ │
│ │ │ Month: [ March ▼ ] Year: [ 2026 ▼ ] │ │
│ 📋 Distrib│ │ [ Generate Report ] │ │
│ │ └─────────────────────────────────────────────────┘ │
│ 📦 Stock │ │
│ │ ┌─────────────────────────────────────────────────┐ │
│ 📄 Reports◄│ │ PDF PREVIEW │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │
│ ✅ Complian│ │ │ 🌿 CannaManage — Monthly Report Mar 2026 │ │ │
│ │ │ │ Club: Grüne Oase Berlin e.V. │ │ │
│ ⚙ Settings│ │ │ ─────────────────────────────────────── │ │ │
│ │ │ │ Total Members: 142 │ │ │
│ │ │ │ Active Members (distributed): 87 │ │ │
│ │ │ │ Total Distributed: 435.5g │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ [⬇ Download PDF] [⬇ Download CSV] │ │
│ │ └─────────────────────────────────────────────────┘ │
│ │ │
│ │ Summary Table │
│ │ ┌────────────────────────────────────────────────┐ │
│ │ │ Metric │ Value │ Limit │ │
│ │ ├──────────────────────┼───────────┼─────────────┤ │
│ │ │ Members >50g/month │ 0 │ Must be 0 │ │
│ │ │ Members >30g (U21) │ 0 │ Must be 0 │ │
│ │ │ Recalled Batches │ 1 │ — (info) │ │
│ │ │ Avg grams / member │ 5.0g │ — │ │
│ │ └────────────────────────────────────────────────┘ │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Month selector | `p:selectOneMenu` | Months JanDec |
| Year selector | `p:selectOneMenu` | Current year ± 2 |
| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail |
| PDF preview | `<iframe>` embedding `/report/preview?month=3&year=2026` | Generated by iText 7 in `cannamanage-report` module |
| Download PDF | `p:commandButton` | Streams PDF response from REST endpoint |
| Download CSV | `p:commandButton` | Streams CSV response (member-level data) |
| Summary table | `p:dataTable` | Computed compliance metrics; zero violations = green row |
---
## 3. Member Portal Screens
### Screen 5 — Member Dashboard / Quota View
![Member Quota](images/mockup-member-quota.png)
#### ASCII Wireframe
```
┌────────────────────────────────────────────────────┐
│ 🌿 CannaManage Anna Müller #M-0042 │
│ ────────────────────────────────────────────── │
│ │
│ Monthly Quota Remaining │
│ │
│ ╭───────────────╮ │
│ │ │ │
│ │ 17.5 g │ │
│ │ remaining │ │
│ │ │ │
│ │ of 50g/month │ │
│ ╰───────────────╯ │
│ ▓▓▓▓▓▓▓▓▓▓▓░░░░░░░ 65% used │
│ │
│ Distribution History │
│ ┌────────────────────────────────────────────┐ │
│ │ Date │ Strain │ Quantity │ │
│ ├────────────┼──────────────┼────────────────┤ │
│ │ 06.04.2026 │ OG Kush │ 5.0g │ │
│ │ 02.04.2026 │ Amnesia Haze │ 12.5g │ │
│ │ 28.03.2026 │ OG Kush │ 15.0g │ │
│ └────────────┴──────────────┴────────────────┘ │
│ │
│ Available Strains │
│ ┌────────────────────────────────────────────┐ │
│ │ Strain │ Availability │ │
│ ├──────────────┼─────────────────────────────┤ │
│ │ OG Kush │ ● Available │ │
│ │ Amnesia Haze │ ⚠ Limited │ │
│ │ Hindu Kush │ ● Available │ │
│ └────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────┘
```
#### Compliance Note — Available Strains Display
Per CanG §§67, members may NOT see specific batch quantities or total stock levels. The **Available Strains** table shows only:
- Strain name
- Availability status (Available / Limited / Unavailable)
Quantities, batch codes, and THC/CBD percentages are **not exposed** in the member portal.
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Quota circle | Custom CSS radial progress (`conic-gradient`) | Computed from monthly total; color matches threshold rules |
| Quota bar | `p:progressBar` | Same color logic as admin distribution form |
| History table | `p:dataTable` | Last 10 distributions; sorted newest first; no pagination in MVP |
| Strains table | `p:dataTable` | `status` column: text + icon only, no quantities |
---
### Screen 6 — Member Login
> *No mockup image — ASCII wireframe only.*
#### ASCII Wireframe
```
┌──────────────────────────────────────────┐
│ │
│ 🌿 CannaManage │
│ │
│ ┌──────────────────────────────────┐ │
│ │ E-Mail Address │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ you@example.com │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ Password │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ •••••••••••• │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ [ ████ Log In ████████████ ] │ │
│ └──────────────────────────────────┘ │
│ │
│ Problems logging in? │
│ Contact your club administrator. │
│ │
└──────────────────────────────────────────┘
```
#### Design Decisions
- **No self-registration link** — member accounts are created exclusively by admins via the admin portal. The login page has no "Create account" or "Sign up" flow.
- **No forgot-password link** — password resets are initiated by the club admin only. The login page directs users to contact their admin, avoiding email-based reset flows that would require verified email infrastructure in MVP.
- **No social login** — DSGVO compliance and club accountability require traceable credential management.
- **Form submission:** POST to `/login` (Spring Security form login), redirect to `/member/dashboard` on success.
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Email field | `p:inputText` with `required="true"` | Bean Validation `@Email` |
| Password field | `p:password` feedback=`false` | No strength meter on login |
| Login button | `p:commandButton` | Submit form; shows `p:messages` on failure |
| Error message | `p:messages` | "Invalid email or password." (never specific about which field failed) |
---
## 4. Navigation & Information Architecture
```mermaid
graph TD
Root["CannaManage Root"]
Root --> AdminPortal["Admin Portal /admin/"]
Root --> MemberPortal["Member Portal /member/"]
AdminPortal --> AdminDash["Dashboard (default)"]
AdminPortal --> Members["Members"]
Members --> MemberList["Member List"]
Members --> MemberDetail["Member Detail"]
AdminPortal --> Distributions["Distributions"]
Distributions --> DistLog["Distribution Log"]
Distributions --> NewDist["New Distribution"]
AdminPortal --> Stock["Stock"]
Stock --> Strains["Strains"]
Stock --> Batches["Batches"]
AdminPortal --> Reports["Reports"]
Reports --> MonthlyReport["Monthly Compliance"]
Reports --> MemberExport["Member Export"]
Reports --> RecallReport["Batch Recall Report"]
AdminPortal --> Compliance["Compliance"]
Compliance --> PreventionOfficer["Prevention Officer Info"]
AdminPortal --> Settings["Settings"]
Settings --> ClubProfile["Club Profile"]
MemberPortal --> MemberDash["Dashboard / Quota"]
MemberPortal --> DistHistory["Distribution History"]
MemberPortal --> StockAvail["Stock Availability"]
```
### URL Structure
| Path | Description | Role |
|---|---|---|
| `/login` | Login page | Public |
| `/admin/dashboard` | Admin home | `ROLE_ADMIN` |
| `/admin/members` | Member list | `ROLE_ADMIN` |
| `/admin/members/{id}` | Member detail | `ROLE_ADMIN` |
| `/admin/distributions` | Distribution log | `ROLE_ADMIN` |
| `/admin/distributions/new` | New distribution form | `ROLE_ADMIN` |
| `/admin/stock/strains` | Strain catalog | `ROLE_ADMIN` |
| `/admin/stock/batches` | Batch management | `ROLE_ADMIN` |
| `/admin/reports/monthly` | Compliance reports | `ROLE_ADMIN` |
| `/admin/reports/members` | Member data export | `ROLE_ADMIN` |
| `/admin/reports/recall` | Recall report | `ROLE_ADMIN` |
| `/admin/compliance` | Prevention officer | `ROLE_ADMIN` |
| `/admin/settings` | Club settings | `ROLE_ADMIN` |
| `/member/dashboard` | Member quota view | `ROLE_MEMBER` |
| `/member/distributions` | Personal history | `ROLE_MEMBER` |
| `/member/stock` | Strain availability | `ROLE_MEMBER` |
---
## 5. Responsive Design Notes
### MVP (v1) — Desktop-First
Target viewport: **1024px+**. PrimeFaces responsive grid (`p:panelGrid` with responsive columns, `ui-g-12 ui-md-6 ui-lg-4`) handles most layout adaptation down to tablet without custom media queries.
| Breakpoint | Behavior |
|---|---|
| `≥ 1280px` | Full layout — sidebar + content side-by-side |
| `10241279px` | Sidebar collapses to icon-only (60px); tooltips on hover |
| `7681023px` | Sidebar hidden; hamburger menu in top navbar |
| `< 768px` | Admin portal degraded (tables scroll horizontally) |
### Member Portal — Mobile-First from Day One
Members will typically check quota status on their phone. The member portal is designed mobile-first regardless of MVP/v2 timeline.
| Breakpoint | Behavior |
|---|---|
| `≥ 1024px` | Two-column layout: quota circle left, history right |
| `7681023px` | Single-column, full-width cards |
| `375767px` | Single-column, compact quota ring, condensed table |
| `< 375px` | Minimum supported; no horizontal scroll |
### v2 Roadmap
- PWA manifest + service worker (offline quota display)
- 768px and 375px explicit breakpoints with design tokens
- Touch-friendly `p:sidebar` for mobile member nav
- Push notifications for low quota warnings
---
## 6. Accessibility
CannaManage targets **WCAG 2.1 AA** compliance across both portals.
### Keyboard Navigation
| Element | Keyboard Behavior |
|---|---|
| Navigation sidebar | Tab navigates items; Enter activates |
| Data tables | Tab to table; arrow keys for row navigation |
| Dropdown menus | Enter/Space to open; arrow keys to navigate; Escape to close |
| Modal dialogs | Focus trapped inside; Escape to close; first focusable element receives focus on open |
| Confirmation dialogs | Tab between Confirm and Cancel; Enter on focused button |
### Screen Reader Support
- All `p:inputText` / `p:inputNumber` fields have `<label>` with `for` attribute
- `aria-label` set on icon-only buttons (e.g., recall action column)
- `aria-live="polite"` region on quota bar — announces percentage changes
- `aria-describedby` links compliance warning messages to the weight input
- PrimeFaces generates `role="grid"` and `aria-rowcount` on all data tables
### Color Independence
Status badges must never rely on color alone:
| Status | Color | Icon | Text label |
|---|---|---|---|
| AVAILABLE | Green | ✓ | "Available" |
| LOW | Orange | ⚠ | "Low Stock" |
| RECALLED | Red | ⛔ | "Recalled" |
| EXHAUSTED | Gray | — | "Exhausted" |
Quota progress bar additionally shows numeric percentage text alongside color change.
### Contrast Ratios
| Foreground | Background | Ratio | AA pass |
|---|---|---|---|
| `#FFFFFF` | `#2D5016` | 9.1:1 | ✅ |
| `#1A1A1A` | `#F5F5F5` | 16.0:1 | ✅ |
| `#1A1A1A` | `#FFFFFF` | 19.0:1 | ✅ |
| `#FFFFFF` | `#D32F2F` | 5.1:1 | ✅ |
| `#FFFFFF` | `#FF6B35` | 3.1:1 | ⚠ verify at large text only |
---
*Next: [07-CODING-STANDARDS.md](07-CODING-STANDARDS.md)*
@@ -0,0 +1,825 @@
# CannaManage — Coding Standards & Git Strategy
**Phase 4a | Document 7 of 7**
**Date:** 2026-04-06
**Stack:** Java 21 · Spring Boot 3.x · JPA/Hibernate · PrimeFaces JSF · PostgreSQL
---
## Table of Contents
1. [Project Structure](#1-project-structure)
2. [Java Coding Standards](#2-java-coding-standards)
3. [Compliance Code Rules](#3-compliance-code-rules)
4. [Git Strategy](#4-git-strategy)
5. [Testing Standards](#5-testing-standards)
6. [Code Review Checklist](#6-code-review-checklist)
7. [Security Standards](#7-security-standards)
8. [Environment Configuration](#8-environment-configuration)
---
## 1. Project Structure
### Maven Multi-Module Layout
```
cannamanage/
├── pom.xml # Parent POM — dependency management, versions
├── cannamanage-domain/ # JPA entities, enums, exceptions, value objects
│ └── src/main/java/de/cannamanage/domain/
│ ├── member/ # Member, MemberStatus, MembershipType
│ ├── distribution/ # Distribution, DistributionRecord
│ ├── stock/ # Strain, Batch, BatchStatus
│ ├── compliance/ # ComplianceConstants, QuotaExceededException
│ └── common/ # AbstractTenantEntity, TenantId
├── cannamanage-service/ # Business logic, compliance engine, repositories
│ └── src/main/java/de/cannamanage/service/
│ ├── member/ # MemberService, MemberRepository
│ ├── distribution/ # DistributionService, DistributionRepository
│ ├── stock/ # StockService, BatchRepository
│ ├── compliance/ # ComplianceService, QuotaCalculator
│ └── report/ # ReportDataService
├── cannamanage-web/ # PrimeFaces JSF backing beans + XHTML views
│ └── src/main/
│ ├── java/de/cannamanage/web/
│ │ ├── admin/ # AdminDashboardBean, DistributionFormBean
│ │ ├── member/ # MemberDashboardBean
│ │ └── common/ # AuthBean, NavigationBean
│ └── webapp/
│ ├── admin/ # dashboard.xhtml, distribution-form.xhtml, stock.xhtml
│ ├── member/ # dashboard.xhtml, stock.xhtml
│ └── WEB-INF/ # faces-config.xml, web.xml
├── cannamanage-api/ # REST controllers (Spring Boot MVC)
│ └── src/main/java/de/cannamanage/api/
│ ├── member/ # MemberController, MemberDto
│ ├── distribution/ # DistributionController, DistributionDto
│ ├── stock/ # StockController, BatchDto
│ ├── auth/ # AuthController, JwtFilter
│ └── report/ # ReportController
└── cannamanage-report/ # iText 7 PDF generation
└── src/main/java/de/cannamanage/report/
├── monthly/ # MonthlyComplianceReport
├── recall/ # BatchRecallReport
└── export/ # MemberCsvExporter
```
### Module Dependencies
```
cannamanage-domain (no deps on other modules)
cannamanage-service (depends on domain)
cannamanage-api (depends on service, domain)
cannamanage-web (depends on service, domain)
cannamanage-report (depends on service, domain)
```
`cannamanage-api` and `cannamanage-web` are siblings — they do not depend on each other. The web module is the PrimeFaces JSF frontend (MVP); the API module provides the REST layer (future mobile / integration use).
---
## 2. Java Coding Standards
### Language Version
Java 21. All modern language features are permitted and preferred:
| Feature | Use Case | Example |
|---|---|---|
| Records | DTOs, value objects, query results | `record MemberSummary(UUID id, String name, BigDecimal quotaUsed)` |
| Sealed classes | Result types, compliance outcomes | `sealed interface QuotaResult permits QuotaOk, QuotaWarning, QuotaExceeded` |
| Text blocks | JPQL, SQL in tests, JSON fixtures | `String jpql = """ SELECT m FROM Member m WHERE... """` |
| Pattern matching `instanceof` | Type checks in services | `if (result instanceof QuotaExceeded e) { ... }` |
| Switch expressions | Status mapping, report routing | `yield` syntax preferred |
### Package Structure
Pattern: `de.cannamanage.[module].[layer]`
```
de.cannamanage.domain.member # Member entity
de.cannamanage.domain.compliance # ComplianceConstants, exceptions
de.cannamanage.service.distribution # DistributionService
de.cannamanage.api.stock # StockController, BatchDto
de.cannamanage.web.admin # DistributionFormBean
de.cannamanage.report.monthly # MonthlyComplianceReport
```
### Class Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| JPA Entity | `{Domain}` | `Member`, `Distribution`, `Batch` |
| Spring Service | `{Domain}Service` | `MemberService`, `ComplianceService` |
| Repository | `{Domain}Repository` | `DistributionRepository` |
| REST Controller | `{Domain}Controller` | `StockController` |
| JSF Backing Bean | `{Screen}Bean` | `DistributionFormBean`, `AdminDashboardBean` |
| DTO (request) | `{Domain}Request` | `CreateDistributionRequest` |
| DTO (response) | `{Domain}Response` / `{Domain}Dto` | `MemberSummaryDto` |
| Exception | `{Condition}Exception` | `QuotaExceededException`, `BatchRecalledException` |
| Enum | `{Domain}Status` / `{Domain}Type` | `BatchStatus`, `MembershipType` |
| Constants class | `{Domain}Constants` | `ComplianceConstants` |
### Dependency Injection
**Constructor injection only.** Field injection (`@Autowired` on fields) is prohibited.
```java
// ✅ Correct
@Service
@RequiredArgsConstructor
public class DistributionService {
private final DistributionRepository distributionRepository;
private final ComplianceService complianceService;
private final MemberRepository memberRepository;
}
// ❌ Prohibited
@Service
public class DistributionService {
@Autowired
private DistributionRepository distributionRepository;
}
```
Lombok `@RequiredArgsConstructor` is the preferred way to generate the constructor.
### Entity Base Class
All `@Entity` classes must extend `AbstractTenantEntity`. No raw entities without tenant isolation.
```java
// de.cannamanage.domain.common.AbstractTenantEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractTenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
}
// ✅ All entities extend this
@Entity
@Table(name = "members")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member extends AbstractTenantEntity {
// domain fields only — no id/tenantId/audit fields here
}
```
### Transaction Boundaries
- `@Transactional` belongs on **service layer** methods only
- Controllers and repositories must not declare `@Transactional`
- Use `@Transactional(readOnly = true)` for query-only methods — improves performance with Hibernate's read-only session optimization
```java
// ✅ Service layer — correct
@Service
@RequiredArgsConstructor
public class MemberService {
@Transactional(readOnly = true)
public MemberSummaryDto getById(UUID memberId, UUID tenantId) { ... }
@Transactional
public void updateMemberStatus(UUID memberId, MemberStatus status, UUID tenantId) { ... }
}
// ❌ Controller — prohibited
@RestController
public class MemberController {
@Transactional // Never here
@GetMapping("/members/{id}")
public MemberSummaryDto getMember(@PathVariable UUID id) { ... }
}
```
### Lombok Usage
| Annotation | Allowed | Notes |
|---|---|---|
| `@Getter` | ✅ | On entities and DTOs |
| `@Setter` | ✅ | Use sparingly on entities; prefer builder pattern |
| `@Builder` | ✅ | On entities and DTOs |
| `@RequiredArgsConstructor` | ✅ | Services, beans (for DI) |
| `@NoArgsConstructor` | ✅ | JPA requires no-arg constructor |
| `@AllArgsConstructor` | ✅ | With `@Builder` |
| `@ToString` | ✅ | Exclude sensitive fields: `@ToString.Exclude` on `passwordHash` etc. |
| `@EqualsAndHashCode` | ✅ | Entities: only on `id` field |
| `@Data` | ❌ | **Prohibited on entities** — generates mutable setters for all fields, breaks JPA proxy patterns |
| `@SneakyThrows` | ❌ | Never hide checked exceptions |
### Code Style
- **Checkstyle config:** Google Java Style Guide (`checkstyle-google.xml` in parent POM)
- **Indentation:** 4 spaces (no tabs)
- **Line length:** 120 characters max
- **No magic numbers** — use named constants or enums:
```java
// ❌ Magic number
if (member.getAge() < 21) { limit = 30; }
// ✅ Named constant
if (member.getAge() < ComplianceConstants.AGE_LIMIT_UNDER21) {
limit = ComplianceConstants.MONTHLY_LIMIT_UNDER21_GRAMS;
}
```
---
## 3. Compliance Code Rules
These rules apply exclusively to code that enforces CanG (Cannabisgesetz) distribution limits. Violations here carry legal risk.
### Compliance Constants
All legal limits live in a single, centrally tested constants class. **Never hardcode these values inline.**
```java
// de.cannamanage.domain.compliance.ComplianceConstants
public final class ComplianceConstants {
private ComplianceConstants() {} // no instantiation
/** Maximum grams per single distribution for any member. */
public static final BigDecimal DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
/** Monthly gram limit for adult members (age ≥ 21). */
public static final BigDecimal MONTHLY_LIMIT_ADULT_GRAMS = new BigDecimal("50.0");
/** Monthly gram limit for members under 21 years of age (CanG §10 Abs.1). */
public static final BigDecimal MONTHLY_LIMIT_UNDER21_GRAMS = new BigDecimal("30.0");
/** Age threshold below which the reduced monthly limit applies. */
public static final int AGE_LIMIT_UNDER21 = 21;
/** Minimum age for club membership (CanG §15 Abs.1). */
public static final int MINIMUM_MEMBER_AGE = 18;
}
```
### ComplianceService Rules
1. `ComplianceService` methods **must always execute within a `@Transactional` boundary** — either by being called from a service method already in a transaction, or by declaring `@Transactional` themselves. The compliance check and the distribution record creation must be atomic.
2. Every public method in `ComplianceService` must have a corresponding test in `ComplianceServiceTest` that exercises its boundary conditions.
3. `ComplianceService` is the **only** class permitted to read `ComplianceConstants` limits and make pass/fail decisions. No other class performs limit arithmetic.
```java
@Service
@RequiredArgsConstructor
public class ComplianceService {
private final DistributionRepository distributionRepository;
/**
* Validates whether a distribution of the given weight is permitted for the member.
*
* <p>Checks the daily single-distribution limit and the member's monthly quota.
* Must be called inside an existing @Transactional boundary — the calling
* DistributionService is responsible for the transaction.
*
* @param memberId the member receiving the distribution
* @param tenantId the club's tenant identifier
* @param weightGrams the proposed distribution weight in grams
* @return QuotaOk if permitted; QuotaWarning if >80% used; QuotaExceeded if over limit
* @throws IllegalArgumentException if weightGrams exceeds DAILY_LIMIT_GRAMS
*/
public QuotaResult checkDistributionAllowed(UUID memberId, UUID tenantId, BigDecimal weightGrams) {
if (weightGrams.compareTo(ComplianceConstants.DAILY_LIMIT_GRAMS) > 0) {
throw new IllegalArgumentException(
"Single distribution exceeds daily limit of " + ComplianceConstants.DAILY_LIMIT_GRAMS + "g");
}
// ... monthly quota logic using ComplianceConstants
}
}
```
### Distribution Record Immutability
Once written, a `Distribution` record may never be modified (legal audit trail requirement). Enforce this at the JPA level:
```java
@Entity
@Table(name = "distributions")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Distribution extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false, updatable = false)
private UUID memberId;
@Column(name = "batch_id", nullable = false, updatable = false)
private UUID batchId;
@Column(name = "weight_grams", nullable = false, updatable = false,
precision = 8, scale = 2)
private BigDecimal weightGrams;
@Column(name = "distributed_at", nullable = false, updatable = false)
private Instant distributedAt;
@Column(name = "recorded_by_admin_id", nullable = false, updatable = false)
private UUID recordedByAdminId;
// No setters — @Getter only, no @Setter
// updatable = false on ALL columns — Hibernate will reject any UPDATE attempt
}
```
### Compliance Test Coverage Requirement
`ComplianceServiceTest` must include at minimum:
| Test Method | What It Covers |
|---|---|
| `checkDistributionAllowed_givenWeightAt25g_shouldReturnQuotaOk` | Exactly at daily limit |
| `checkDistributionAllowed_givenWeightOver25g_shouldThrowIllegalArgument` | Daily limit exceeded |
| `checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldReturnQuotaExceeded` | Adult at 50g |
| `checkDistributionAllowed_givenUnder21MemberAt30g_shouldReturnQuotaExceeded` | Under-21 at 30g |
| `checkDistributionAllowed_givenUnder21MemberAtAdultLimit_shouldReturnQuotaExceeded` | Under-21 must not reach 50g |
| `checkDistributionAllowed_givenMemberAt80Percent_shouldReturnQuotaWarning` | Warning threshold |
| `checkDistributionAllowed_givenMemberAt40g_shouldReturnQuotaOk` | Normal adult, within limit |
---
## 4. Git Strategy
### Branching Model — GitHub Flow (Solo Dev)
```
main ──────────────────────────────────────────────────────► (production-ready)
│ │ │
└─► feature/US-042─┘ └─► fix/member-age-edge ─┘
```
| Branch | Purpose | Merge Via |
|---|---|---|
| `main` | Production-ready code only; protected | PR only |
| `develop` | Integration branch for in-progress work | Merge to main when stable |
| `feature/US-XXX-short-description` | New feature tied to a user story | PR → develop → main |
| `fix/short-description` | Bug fix | PR → main (or develop if risk is low) |
| `chore/short-description` | Dependency updates, config, CI | PR → main |
**Branch naming examples:**
- `feature/US-042-compliance-quota-check`
- `feature/US-015-member-registration-form`
- `fix/member-under21-age-boundary`
- `chore/update-spring-boot-3.3.1`
### Commit Message Format — Conventional Commits
```
type(scope): short description (imperative, ≤72 chars)
[optional body — explain WHY, not WHAT; reference CanG sections if relevant]
[optional footer]
BREAKING CHANGE: description if applicable
Closes #issue-number
```
#### Types
| Type | When to Use |
|---|---|
| `feat` | New feature or user-visible behavior |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `style` | Formatting, whitespace — no logic change |
| `refactor` | Code restructuring — no behavior change |
| `test` | Adding or updating tests |
| `chore` | Build, deps, config, CI — no production code |
#### Scopes
| Scope | Module / Area |
|---|---|
| `member` | Member management |
| `distribution` | Distribution recording and history |
| `stock` | Strain and batch management |
| `compliance` | `ComplianceService`, `ComplianceConstants`, CanG limits |
| `auth` | JWT, Spring Security, login |
| `report` | PDF/CSV generation |
| `infra` | Docker, CI, Flyway migrations |
| `web` | PrimeFaces JSF views and backing beans |
| `api` | REST controllers and DTOs |
#### Commit Examples
```bash
feat(compliance): add daily 25g distribution limit check
Implements CanG §10 Abs.1 single-distribution cap. ComplianceService
now throws IllegalArgumentException before any quota calculation if
weightGrams > ComplianceConstants.DAILY_LIMIT_GRAMS.
fix(member): correct under-21 flag when age is exactly 21
Age comparison was using < instead of <=. Members who turn 21 on the
exact distribution date now correctly receive the adult (50g) limit.
Closes #17
test(distribution): add quota boundary tests for 30g under-21 limit
Adds 6 parameterized test cases covering 28g, 29g, 29.9g, 30g, 30.1g,
and 31g for under-21 members. All reference ComplianceConstants — no
hardcoded values in test assertions.
chore(deps): update Spring Boot to 3.3.1
CVE-2024-38821 fix included. No API changes required.
docs(compliance): document ComplianceConstants usage policy in README
```
### Tag Strategy
Semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
```bash
git tag -a v1.0.0 -m "Initial release — core member + distribution management"
git tag -a v1.1.0 -m "Add member portal with quota view"
git tag -a v1.0.1 -m "Fix under-21 monthly limit boundary condition"
```
---
## 5. Testing Standards
### Framework Stack
| Layer | Framework | Annotation / Config |
|---|---|---|
| Unit tests | JUnit 5 + Mockito | `@ExtendWith(MockitoExtension.class)` |
| Integration tests | Spring Boot Test + Testcontainers | `@SpringBootTest`, `@Testcontainers` |
| Web layer tests | `MockMvc` | `@WebMvcTest(DistributionController.class)` |
| Repository tests | `DataJpaTest` + Testcontainers | Real PostgreSQL via Testcontainers |
| PDF generation tests | JUnit 5 + iText assertions | Verify PDF structure, not pixel comparison |
### Test Naming Convention
```
methodName_givenCondition_shouldExpectedBehavior
```
```java
// ✅ Correct
@Test
void checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldThrowQuotaExceededException()
@Test
void createDistribution_givenValidRequest_shouldPersistAndReturnDto()
@Test
void getQuotaRemaining_givenUnder21Member_shouldCapAt30g()
@Test
void generateMonthlyReport_givenNoPriorDistributions_shouldReturnZeroTotals()
```
### Unit Test Structure
```java
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest {
@Mock
private DistributionRepository distributionRepository;
@InjectMocks
private ComplianceService complianceService;
@Test
void checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldReturnQuotaExceeded() {
// GIVEN
UUID memberId = UUID.randomUUID();
UUID tenantId = UUID.randomUUID();
BigDecimal currentMonthTotal = ComplianceConstants.MONTHLY_LIMIT_ADULT_GRAMS;
when(distributionRepository.sumWeightByMemberAndMonth(eq(memberId), eq(tenantId), any()))
.thenReturn(currentMonthTotal);
// WHEN
QuotaResult result = complianceService.checkDistributionAllowed(
memberId, tenantId, new BigDecimal("1.0"));
// THEN
assertThat(result).isInstanceOf(QuotaExceeded.class);
}
}
```
### Integration Test Structure
```java
@SpringBootTest
@Testcontainers
@Transactional // rolls back after each test
class DistributionServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
// Tests run against real PostgreSQL — Flyway migrations apply automatically
}
```
### Coverage Target
| Module | Line Coverage Target |
|---|---|
| `cannamanage-service` | **≥ 80%** (enforced by JaCoCo in CI) |
| `cannamanage-domain` | ≥ 70% (entities + value objects) |
| `cannamanage-api` | ≥ 70% (controllers via MockMvc) |
| `cannamanage-report` | ≥ 60% (PDF generation harder to test) |
| `cannamanage-web` | Best effort (JSF backing beans — limited testability) |
### Test Rules
1. **No test may hardcode a compliance limit value.** All assertions must reference `ComplianceConstants`:
```java
// ❌ Prohibited
assertThat(limit).isEqualTo(new BigDecimal("50.0"));
// ✅ Required
assertThat(limit).isEqualTo(ComplianceConstants.MONTHLY_LIMIT_ADULT_GRAMS);
```
2. Parameterized tests (`@ParameterizedTest`) are strongly preferred for boundary condition coverage.
3. Test data builders (or fixtures) must live in `src/test/java/.../fixtures/` — no anonymous object creation scattered across test methods.
---
## 6. Code Review Checklist
Since CannaManage is a solo development project, a self-review checklist replaces a peer review process. All items must be checked before merging any PR to `main`.
### Self-Review Checklist
```markdown
## Compliance & Legal
- [ ] All distribution limits reference `ComplianceConstants` — zero hardcoded values
- [ ] `Distribution` entity fields are annotated `@Column(updatable = false)` where required
- [ ] `ComplianceService` calls are only made inside `@Transactional` boundaries
- [ ] New compliance rules have corresponding unit tests in `ComplianceServiceTest`
## Data & Multi-Tenancy
- [ ] New entity extends `AbstractTenantEntity`
- [ ] `tenant_id` is never accepted from user input (HTTP body, query param, path variable)
- [ ] All repository queries filter by `tenantId` — no cross-tenant data leakage possible
## Security & DSGVO
- [ ] No PII in log statements (no email, full name, member number in log lines)
- [ ] No passwords, tokens, or secrets hardcoded anywhere
- [ ] New REST endpoints annotated with `@PreAuthorize`
- [ ] DTOs validated with Bean Validation annotations (`@NotNull`, `@Size`, etc.)
## Database
- [ ] Flyway migration file added for any schema change (`V{n}__description.sql`)
- [ ] Migration file is backward-compatible or includes rollback notes
- [ ] No `@Column(nullable = false)` added without corresponding DB migration
## Code Quality
- [ ] Constructor injection used — no `@Autowired` field injection
- [ ] No `@Data` on JPA entities
- [ ] No magic numbers — named constants or enums used
- [ ] Checkstyle passes locally (`./mvnw checkstyle:check`)
- [ ] Javadoc on all public service methods
## Testing
- [ ] Unit test added for new service method
- [ ] Integration test updated if schema or contract changed
- [ ] Test coverage does not decrease in `cannamanage-service`
- [ ] Test method names follow `method_givenCondition_shouldExpect` pattern
## General
- [ ] Commit message follows Conventional Commits format
- [ ] Branch name follows `feature/US-XXX-` or `fix/` convention
- [ ] No `TODO` comments left in production code (use GitHub Issues instead)
```
---
## 7. Security Standards
### Authentication & Authorization
```java
// JWT secret from environment only — never in application.properties
@Value("${JWT_SECRET}")
private String jwtSecret;
// All endpoints behind @PreAuthorize — no security by obscurity
@RestController
@RequestMapping("/api/v1/distributions")
public class DistributionController {
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public Page<DistributionDto> list(...) { ... }
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public DistributionDto create(...) { ... }
}
// Member portal endpoints restricted to role + own data
@GetMapping("/api/v1/member/quota")
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
public QuotaDto getQuota(@RequestParam UUID memberId) { ... }
```
### CORS Configuration
```java
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// No wildcard — club subdomain only
config.setAllowedOriginPatterns(List.of("https://*.cannamanage.de"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
// ...
}
```
### Input Validation
All DTOs must be annotated with Bean Validation constraints. The controller calls `@Valid` on request bodies.
```java
public record CreateDistributionRequest(
@NotNull(message = "Member ID is required")
UUID memberId,
@NotNull(message = "Batch ID is required")
UUID batchId,
@NotNull(message = "Weight is required")
@DecimalMin(value = "0.1", message = "Weight must be at least 0.1g")
@DecimalMax(value = "25.0", message = "Weight cannot exceed daily limit")
BigDecimal weightGrams
) {}
```
### SQL Injection Prevention
- **JPA named queries only** — no string concatenation in JPQL
- Spring Data JPA repository methods generate parameterized queries automatically
- Native SQL queries use `@Query` with named parameters (`:param` syntax), never `+`
```java
// ✅ Safe — parameterized
@Query("SELECT SUM(d.weightGrams) FROM Distribution d WHERE d.memberId = :memberId AND d.tenantId = :tenantId AND MONTH(d.distributedAt) = :month")
BigDecimal sumWeightByMemberAndMonth(@Param("memberId") UUID memberId,
@Param("tenantId") UUID tenantId,
@Param("month") int month);
// ❌ Prohibited — SQL injection risk
String jpql = "SELECT ... WHERE name = '" + memberName + "'";
```
### Password Hashing
```java
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12 — ~250ms per hash on modern hardware
}
```
### Sensitive Data Logging
```java
// ❌ Never log PII
log.info("Processing distribution for member: {}", member.getEmail());
log.info("Member {} requested quota", member.getFullName());
// ✅ Log with opaque identifiers only
log.info("Processing distribution for memberId={} tenantId={}", member.getId(), tenantId);
log.info("Quota check passed for memberId={}", memberId);
```
---
## 8. Environment Configuration
### Environment Variables Reference
All secrets and environment-specific configuration are provided via environment variables. Never commit secrets to version control.
| Variable | Required | Default | Description |
|---|---|---|---|
| `DB_URL` | ✅ | — | JDBC URL, e.g. `jdbc:postgresql://localhost:5432/cannamanage` |
| `DB_USERNAME` | ✅ | — | PostgreSQL username |
| `DB_PASSWORD` | ✅ | — | PostgreSQL password |
| `JWT_SECRET` | ✅ | — | 256-bit (32-byte) random secret for JWT signing; generate with `openssl rand -base64 32` |
| `JWT_ACCESS_TTL_HOURS` | ❌ | `8` | Access token TTL in hours |
| `JWT_REFRESH_TTL_DAYS` | ❌ | `30` | Refresh token TTL in days |
| `STRIPE_SECRET_KEY` | ✅ (billing) | — | Stripe secret key (starts with `sk_live_` in production) |
| `STRIPE_WEBHOOK_SECRET` | ✅ (billing) | — | Stripe webhook signing secret for subscription events |
| `MAIL_HOST` | ✅ | — | SMTP host for transactional emails |
| `MAIL_USERNAME` | ✅ | — | SMTP username |
| `MAIL_PASSWORD` | ✅ | — | SMTP password |
| `MAIL_FROM` | ❌ | `noreply@cannamanage.de` | From address for system emails |
| `SENTRY_DSN` | ❌ | — | Sentry DSN for error tracking; omit to disable |
| `APP_BASE_URL` | ✅ | — | Application base URL, e.g. `https://meinclub.cannamanage.de` |
| `ADMIN_INITIAL_EMAIL` | ❌ | — | Seed admin email on first startup (Flyway data migration) |
| `ADMIN_INITIAL_PASSWORD` | ❌ | — | Seed admin password — change immediately after first login |
### `application.properties` Pattern
```properties
# application.properties — references env vars only; no values hardcoded
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
jwt.secret=${JWT_SECRET}
jwt.access.ttl-hours=${JWT_ACCESS_TTL_HOURS:8}
jwt.refresh.ttl-days=${JWT_REFRESH_TTL_DAYS:30}
stripe.secret-key=${STRIPE_SECRET_KEY}
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
spring.mail.host=${MAIL_HOST}
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}
sentry.dsn=${SENTRY_DSN:}
```
### Profile Strategy
> **`spring.profiles.active=prod` is NOT a security mechanism.** Never use profile-based condition checks to gate security-relevant behavior (e.g., `@ConditionalOnProperty(name="spring.profiles.active", havingValue="prod")`).
Profiles are used **only** for infrastructure wiring (in-memory H2 vs. real PostgreSQL for tests, Testcontainers vs. external DB).
| Profile | Usage |
|---|---|
| `(none)` | Production — all config from environment variables |
| `test` | JUnit integration tests — Testcontainers PostgreSQL |
| `dev` | Local development — Docker Compose PostgreSQL, verbose SQL logging |
### Local Development Setup
```bash
# Start local PostgreSQL via Docker Compose
docker compose up -d postgres
# Run with dev profile (verbose SQL, local DB)
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev \
-Dspring-boot.run.arguments="--DB_URL=jdbc:postgresql://localhost:5432/cannamanage_dev \
--DB_USERNAME=cannamanage --DB_PASSWORD=dev_password \
--JWT_SECRET=$(openssl rand -base64 32)"
```
---
*End of CannaManage coding standards. See also [03-ARCHITECTURE.md](03-ARCHITECTURE.md) for data model and [05-API-SPEC.md](05-API-SPEC.md) for REST contract.*
+439
View File
@@ -0,0 +1,439 @@
# 08 — Test Plan
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Status:** Draft
---
## 1. Test Strategy Overview
### 1.1 Testing Pyramid
```
┌─────────────────┐
│ E2E Tests │ 10% — Playwright (deferred to v2)
│ (10%) │
├─────────────────┤
│ Integration │ 20% — Spring Boot Test + Testcontainers
│ Tests (20%) │
├─────────────────┤
│ Unit Tests │ 70% — JUnit 5 + Mockito
│ (70%) │
└─────────────────┘
```
The compliance-critical path (`ComplianceService`) requires **100% line coverage** — no exceptions. Every quota rule is a legal obligation under CanG §§1922.
### 1.2 Tools and Frameworks
| Layer | Tool | Purpose |
|-------|------|---------|
| Unit | JUnit 5 (`junit-jupiter`) | Test runner |
| Unit | Mockito 5 | Mock dependencies |
| Unit | AssertJ | Fluent assertions |
| Integration | Spring Boot Test (`@SpringBootTest`) | Full application context |
| Integration | Testcontainers (PostgreSQL module) | Real DB in Docker |
| Integration | MockMvc / RestAssured | HTTP layer testing |
| Coverage | JaCoCo | Line/branch coverage reporting |
| E2E | Playwright (Java) | Browser automation — **deferred to v2** |
### 1.3 CI Trigger Policy
| Branch pattern | Tests run |
|---------------|-----------|
| `feature/*` | Unit tests only (`./mvnw test`) |
| `develop` | Unit + Integration (`./mvnw verify -P integration-tests`) |
| `main` | Unit + Integration + coverage gate |
Coverage gate blocks merge to `main` if `ComplianceService` drops below 100%.
---
## 2. Unit Test Cases — ComplianceService
**Class under test:** `de.cannamanage.service.ComplianceService`
**Dependencies mocked:** `DistributionRepository`, `MemberRepository`, `StrainRepository`
---
**TC-001** | `checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly`
- **Given:** Adult member (age 35) with exactly 50.0g distributed this calendar month, requesting 1.0g more
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
- **Compliance ref:** CanG §19(2) — 50g/month limit for adults
---
**TC-002** | `checkDistributionAllowed_givenUnder21AtMonthlyLimit_shouldThrowQuotaExceededMonthly`
- **Given:** Under-21 member (age 18) with exactly 30.0g distributed this calendar month, requesting 1.0g more
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
- **Compliance ref:** CanG §19(3) — 30g/month limit for under-21 members
---
**TC-003** | `checkDistributionAllowed_givenAdultAtDailyLimit_shouldThrowQuotaExceededDaily`
- **Given:** Adult member with exactly 25.0g distributed today, requesting 0.5g more
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.5)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY`
- **Compliance ref:** CanG §19(2) — 25g/day limit
---
**TC-004** | `checkDistributionAllowed_givenUnder21RequestingHighThcStrain_shouldThrowHighThcRestricted`
- **Given:** Under-21 member (age 20), requesting a strain with THC content 12.5% (exceeds 10% threshold)
- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0, strainId)`
- **Then:** Throws `QuotaExceededException` with code `HIGH_THC_RESTRICTED_UNDER_21`
- **Compliance ref:** CanG §19(4) — under-21 members restricted to ≤10% THC strains
---
**TC-005** | `checkDistributionAllowed_givenAdultAt49gMonthlyRequesting2g_shouldThrowQuotaExceededMonthly`
- **Given:** Adult member with 49.0g distributed this month, requesting 2.0g (would total 51.0g)
- **When:** `complianceService.checkDistributionAllowed(memberId, 2.0)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
- **Note:** Even partial over-quota requests must be rejected in full; no partial fulfillment
---
**TC-006** | `checkDistributionAllowed_givenAdultAt0gRequesting25g_shouldReturnAllowed`
- **Given:** Adult member with 0.0g distributed this month and today, requesting 25.0g
- **When:** `complianceService.checkDistributionAllowed(memberId, 25.0)`
- **Then:** Returns `DistributionAllowedResult` with `allowed = true`, `remainingDaily = 0.0`, `remainingMonthly = 25.0`
- **Note:** Exactly at daily limit — allowed
---
**TC-007** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_1g_shouldReturnAllowed`
- **Given:** Adult member with 24.9g distributed today, requesting 0.1g (would reach exactly 25.0g)
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.1)`
- **Then:** Returns `allowed = true`, `remainingDaily = 0.0`
- **Note:** Boundary — exactly at limit is allowed
---
**TC-008** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_2g_shouldThrowQuotaExceededDaily`
- **Given:** Adult member with 24.9g distributed today, requesting 0.2g (would total 25.1g)
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.2)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY`
- **Note:** Boundary + 1 — must be blocked
---
**TC-009** | `checkDistributionAllowed_givenSuspendedMember_shouldThrowMemberInactive`
- **Given:** Member with `status = MemberStatus.SUSPENDED`, requesting any amount
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE`
- **Note:** Status check must occur before any quota calculation
---
**TC-010** | `checkDistributionAllowed_givenExpelledMember_shouldThrowMemberInactive`
- **Given:** Member with `status = MemberStatus.EXPELLED`, requesting any amount
- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0)`
- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE`
- **Note:** Expelled members are permanently blocked, no quota check performed
---
## 3. Unit Test Cases — MemberService
**Class under test:** `de.cannamanage.service.MemberService`
**Dependencies mocked:** `MemberRepository`, `ClubRepository`, `PasswordEncoder`
---
**TC-011** | `createMember_givenAge17_shouldThrowUnderageException`
- **Given:** `CreateMemberRequest` with DOB resulting in age 17 at time of registration
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Throws `UnderageException` with message containing minimum age (18)
- **Compliance ref:** CanG §6(1) — membership requires minimum age 18
---
**TC-012** | ~~`createMember_givenAge18_shouldSucceedAndSetIsUnder21False`~~*this case is incorrect*
> **Note:** Age 18 IS under 21, therefore `isUnder21 = true`. See TC-013.
---
**TC-013** | `createMember_givenAge18_shouldSucceedAndSetIsUnder21True`
- **Given:** `CreateMemberRequest` with DOB resulting in age 18 at time of registration
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Returns created `Member` with `isUnder21 = true`, `status = ACTIVE`
- **Note:** The 18-year-old flag matters for quota enforcement (30g/month, ≤10% THC)
---
**TC-014** | `createMember_givenAge21_shouldSucceedAndSetIsUnder21False`
- **Given:** `CreateMemberRequest` with DOB resulting in age exactly 21 at time of registration
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Returns created `Member` with `isUnder21 = false`, `status = ACTIVE`
- **Note:** Age 21 is the boundary — exactly 21 qualifies as adult for quota purposes
---
**TC-015** | `createMember_givenDuplicateEmail_shouldThrowDuplicateMemberException`
- **Given:** `MemberRepository.existsByEmailAndTenantId(email, tenantId)` returns `true`
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Throws `DuplicateMemberException` with code `DUPLICATE_EMAIL`
- **Note:** Email uniqueness is per-tenant, not global
---
## 4. Unit Test Cases — Tenant Isolation
**Class under test:** JPA repositories with `@TenantAware` filter active
**Setup:** Thread-local `TenantContext` populated via `TenantContextHolder.setTenantId()`
---
**TC-016** | `distributionRepository_givenTenantAContext_shouldNotReturnTenantBData`
- **Given:** Two tenants (Tenant A: UUID-A, Tenant B: UUID-B); 5 distributions for each; `TenantContextHolder` set to UUID-A
- **When:** `distributionRepository.findAll()`
- **Then:** Returns exactly 5 records, all with `tenantId = UUID-A`; zero records from Tenant B
- **Note:** Hibernate filter `tenantFilter` must be enabled in `TenantAwareInterceptor`
---
**TC-017** | `memberRepository_givenTenantAContext_shouldNotSeeClubBMembers`
- **Given:** 10 members in Club A, 8 members in Club B; context set to Club A's tenant
- **When:** `memberRepository.findAll()`
- **Then:** Returns exactly 10 records; no member from Club B present
- **Note:** Cross-tenant data leakage is a GDPR violation, not just a business bug
---
## 5. Integration Test Cases (Testcontainers)
**Setup:** `@SpringBootTest(webEnvironment = RANDOM_PORT)` with `@Testcontainers`; real PostgreSQL 16 container; Flyway migrations applied before each test class.
---
**TC-018** | `POST /api/v1/distributions — successful distribution recording`
- **Given:** Active adult member with 0g distributed; valid `DistributionRequest` for 10.0g; authenticated as `ROLE_ADMIN`
- **When:** `POST /api/v1/distributions` with valid JWT
- **Then:** HTTP 201; response body contains `distributionId`, `amount = 10.0`, `recordedAt`; DB contains one `distribution` row with `is_recalled = false`
---
**TC-019** | `POST /api/v1/distributions — quota exceeded returns 422`
- **Given:** Adult member with 50.0g already distributed this month; requesting 1.0g more
- **When:** `POST /api/v1/distributions`
- **Then:** HTTP 422; response body `{"errorCode": "QUOTA_EXCEEDED_MONTHLY", "remainingMonthly": 0.0}`
---
**TC-020** | `POST /api/v1/distributions — concurrent race condition (last gram)`
- **Given:** Adult member with 24.9g distributed today; two concurrent requests each for 0.2g (both would individually exceed 25g/day)
- **When:** Both requests fired simultaneously via two threads
- **Then:** Exactly one succeeds (HTTP 201); exactly one fails (HTTP 422 `QUOTA_EXCEEDED_DAILY`); DB total does not exceed 25.0g
- **Mechanism:** `SELECT ... FOR UPDATE` on quota aggregation query prevents double-spend
---
**TC-021** | `POST /api/v1/auth/login — valid credentials return JWT`
- **Given:** Admin user with email `admin@test-club.de`, correct password
- **When:** `POST /api/v1/auth/login` with `{"email": "admin@test-club.de", "password": "..."}`
- **Then:** HTTP 200; response contains `accessToken` (JWT), `tokenType = "Bearer"`, `expiresIn = 3600`
---
**TC-022** | `POST /api/v1/auth/login — invalid credentials return 401`
- **Given:** Admin user exists; wrong password provided
- **When:** `POST /api/v1/auth/login` with wrong password
- **Then:** HTTP 401; response `{"errorCode": "INVALID_CREDENTIALS"}`; no token issued
---
**TC-023** | `GET /api/v1/members — ROLE_MEMBER accessing admin endpoint returns 403`
- **Given:** Authenticated user with `ROLE_MEMBER` JWT (not ROLE_ADMIN)
- **When:** `GET /api/v1/members` (admin-only endpoint)
- **Then:** HTTP 403; response `{"errorCode": "FORBIDDEN"}`
---
**TC-024** | `GET /api/v1/members/{id}/quota — member accessing own quota returns 200`
- **Given:** Authenticated member with JWT; requesting their own `memberId`
- **When:** `GET /api/v1/members/{ownId}/quota`
- **Then:** HTTP 200; response contains `dailyUsed`, `dailyRemaining`, `monthlyUsed`, `monthlyRemaining`, `isUnder21`
---
**TC-025** | `GET /api/v1/members/{otherMemberId}/quota — member accessing other member returns 403`
- **Given:** Authenticated member requesting quota of a *different* member (same club)
- **When:** `GET /api/v1/members/{otherMemberId}/quota`
- **Then:** HTTP 403; GDPR principle: members must not see each other's consumption data
---
**TC-026** | `POST /api/v1/stock/batches/{id}/recall — verify cascade`
- **Given:** Batch `BATCH-TEST-001` with 3 distributions recorded against it; `isRecalled = false`
- **When:** `POST /api/v1/stock/batches/BATCH-TEST-001/recall` with `{"reason": "Contamination detected"}`
- **Then:** HTTP 200; batch `isRecalled = true`; all 3 distribution records have `isRecalled = true`; response body contains list of 3 affected member IDs for notification
---
## 6. Test Data Fixtures
Define these constants in `src/test/java/de/cannamanage/fixtures/TestFixtures.java`:
```java
public final class TestFixtures {
// Tenant
public static final UUID TENANT_ID =
UUID.fromString("00000000-0000-0000-0000-000000000001");
public static final String CLUB_NAME = "Test Cannabis Club e.V.";
// Adult member
public static final UUID ADULT_MEMBER_ID =
UUID.fromString("00000000-0000-0000-0000-000000000010");
public static final String ADULT_MEMBER_NAME = "Klaus Mueller";
public static final LocalDate ADULT_MEMBER_DOB =
LocalDate.of(1990, 1, 1); // age 36 as of 2026
// Under-21 member
public static final UUID UNDER21_MEMBER_ID =
UUID.fromString("00000000-0000-0000-0000-000000000011");
public static final String UNDER21_MEMBER_NAME = "Lisa Mayer";
public static final LocalDate UNDER21_MEMBER_DOB =
LocalDate.of(2007, 1, 1); // age 19 as of 2026, isUnder21=true
// Strain
public static final UUID STRAIN_ID =
UUID.fromString("00000000-0000-0000-0000-000000000020");
public static final String STRAIN_NAME = "Test OG";
public static final double STRAIN_THC_PERCENT = 20.0;
public static final double STRAIN_CBD_PERCENT = 1.0;
// Batch
public static final String BATCH_NUMBER = "BATCH-TEST-001";
public static final double BATCH_INITIAL_WEIGHT_G = 500.0;
// Compliance constants (mirror ComplianceConstants.java)
public static final double ADULT_MONTHLY_LIMIT_G = 50.0;
public static final double UNDER21_MONTHLY_LIMIT_G = 30.0;
public static final double DAILY_LIMIT_G = 25.0;
public static final double UNDER21_MAX_THC_PERCENT = 10.0;
}
```
---
## 7. Coverage Requirements
| Module | Test Type | Minimum Coverage | Enforcement |
|--------|-----------|-----------------|-------------|
| `cannamanage-service` | Unit | 80% line | JaCoCo CI gate |
| `cannamanage-api` | Integration | 70% endpoint coverage | Manual checklist |
| `cannamanage-domain` | Unit | 60% line (entities/enums) | JaCoCo CI gate |
| `ComplianceService` | Unit | **100% line + branch** | JaCoCo CI gate — hard fail |
| `TenantIsolationFilter` | Unit + Integration | 90% line | JaCoCo CI gate |
> **Rationale for 100% on ComplianceService:** Each uncovered line is a potential legal violation. A missed branch in quota calculation could result in over-distribution — a criminal offence under CanG §34. This is not negotiable.
### JaCoCo Configuration (`pom.xml`)
```xml
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>jacoco-check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>CLASS</element>
<includes>
<include>de.cannamanage.service.ComplianceService</include>
</includes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
</limits>
</rule>
<rule>
<element>PACKAGE</element>
<includes>
<include>de.cannamanage.service.*</include>
</includes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
```
---
## 8. Test Execution
```bash
# Run all unit tests
./mvnw test -pl cannamanage-service
# Run integration tests (requires Docker for Testcontainers)
./mvnw verify -P integration-tests
# Run specific test class
./mvnw test -pl cannamanage-service -Dtest=ComplianceServiceTest
# Coverage report (output: target/site/jacoco/index.html)
./mvnw verify jacoco:report
# Coverage report for single module
./mvnw verify jacoco:report -pl cannamanage-service
# Run compliance tests only (tagged)
./mvnw test -pl cannamanage-service -Dgroups=compliance
# Check coverage gate (will fail build if thresholds not met)
./mvnw verify -P coverage-check
```
### Testcontainers Docker requirement
Integration tests require Docker available on the host. The PostgreSQL container is pulled automatically by Testcontainers on first run. Ensure:
- Docker daemon running: `systemctl start docker` (or `docker info`)
- User in `docker` group: `sudo usermod -aG docker $USER`
### Test annotation conventions
```java
// Unit test — no Spring context
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest { ... }
// Integration test — full context + Testcontainers
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class DistributionIntegrationTest { ... }
// Tag compliance tests for selective execution
@Tag("compliance")
@Test
void checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly() { ... }
```
@@ -0,0 +1,639 @@
# 09 — Deployment Guide
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose
---
## 1. Prerequisites
### Hetzner VPS Specification
| Resource | Value | Monthly Cost |
|----------|-------|-------------|
| Server type | CX21 | ~€5.88/month |
| vCPU | 2 | — |
| RAM | 4 GB | — |
| SSD | 40 GB | — |
| Network | 20 TB transfer | — |
| OS | Ubuntu 22.04 LTS | — |
> **Scale-up trigger:** Upgrade to CX31 (8GB RAM) when concurrent active clubs exceeds 20. PostgreSQL is the memory consumer — headroom is consumed by connection pools, not application heap.
### DNS Setup
| Record | Type | Value |
|--------|------|-------|
| `cannamanage.de` | A | `<VPS-IP>` |
| `app.cannamanage.de` | A | `<VPS-IP>` |
| `*.cannamanage.de` | A | `<VPS-IP>` |
Wildcard A record enables future per-club subdomains (`clubname.cannamanage.de`) without additional DNS changes.
### Required Software
- Docker Engine 24+ (`docker.io` or Docker CE)
- Docker Compose v2 (`docker compose` — not `docker-compose`)
- Certbot with Nginx plugin (`python3-certbot-nginx`)
- OpenSSH server (enabled by default on Ubuntu)
---
## 2. Infrastructure Architecture
```mermaid
graph TB
Internet["🌐 Internet"] -->|"port 80/443"| Nginx["Nginx (reverse proxy)"]
Nginx -->|"http://app:8080"| App["cannamanage-app\n(Spring Boot 3.x)"]
App -->|"jdbc:postgresql://db:5432"| DB["PostgreSQL 16\n(cannamanage DB)"]
LetsEncrypt["Let's Encrypt\n(certbot auto-renew)"] -.->|"TLS cert"| Nginx
Gitea["Gitea Actions\n(homelab CI)"] -->|"SSH + docker compose"| VPS["Hetzner VPS\n/opt/cannamanage"]
subgraph VPS ["Hetzner VPS — Docker network: cannamanage_net"]
Nginx
App
DB
end
```
All three services run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
---
## 3. Docker Compose Setup
**File:** `/opt/cannamanage/docker-compose.yml`
```yaml
version: '3.9'
networks:
cannamanage_net:
driver: bridge
volumes:
pgdata:
driver: local
nginx_certs:
driver: local
services:
nginx:
image: nginx:1.25-alpine
container_name: cannamanage-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- nginx_certs:/etc/letsencrypt:ro
- /var/log/nginx:/var/log/nginx
depends_on:
app:
condition: service_healthy
networks:
- cannamanage_net
restart: unless-stopped
app:
image: cannamanage:${VERSION:-latest}
container_name: cannamanage-app
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME}
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
- APP_JWT_SECRET=${JWT_SECRET}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- SPRING_MAIL_HOST=${MAIL_HOST}
- SPRING_MAIL_USERNAME=${MAIL_USERNAME}
- SPRING_MAIL_PASSWORD=${MAIL_PASSWORD}
- SENTRY_DSN=${SENTRY_DSN}
- SPRING_PROFILES_ACTIVE=production
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- cannamanage_net
restart: unless-stopped
db:
image: postgres:16-alpine
container_name: cannamanage-db
environment:
- POSTGRES_DB=cannamanage
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d cannamanage"]
interval: 10s
timeout: 5s
retries: 5
networks:
- cannamanage_net
restart: unless-stopped
# PostgreSQL port intentionally NOT exposed externally
```
**Nginx site config** (`/opt/cannamanage/nginx/conf.d/cannamanage.conf`):
```nginx
server {
listen 80;
server_name app.cannamanage.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.cannamanage.de;
ssl_certificate /etc/letsencrypt/live/app.cannamanage.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.cannamanage.de/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Stripe webhook — allow larger body
location /api/v1/billing/webhook {
proxy_pass http://app:8080;
proxy_set_header Host $host;
client_max_body_size 1m;
}
}
```
---
## 4. Environment Variables
**File:** `/opt/cannamanage/.env` (never committed to git — add to `.gitignore`)
```bash
# Database
DB_USERNAME=cannamanage_user
DB_PASSWORD=<strong-random-password-min-32-chars>
# JWT signing key (256-bit minimum — generate with: openssl rand -hex 32)
JWT_SECRET=<256-bit-random-hex>
# Stripe (use sk_live_ for production, sk_test_ for staging)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email (SMTP)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=noreply@cannamanage.de
MAIL_PASSWORD=<mail-password>
MAIL_FROM=CannaManage <noreply@cannamanage.de>
# Error tracking
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project-id>
# Application version (set by CI during deploy)
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`.
---
## 5. First-Time Deployment
### Step 1 — Create Hetzner VPS
1. Log into [console.hetzner.cloud](https://console.hetzner.cloud)
2. Create server: **CX21**, Ubuntu 22.04, Nuremberg or Frankfurt datacenter
3. Add your SSH public key during setup (`cat ~/.ssh/id_ed25519.pub`)
4. Note the assigned IPv4 address — update DNS A records
### Step 2 — Install Docker + Docker Compose
```bash
ssh root@<VPS-IP>
# Update system
apt update && apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
# Add deploy user (never run production as root)
adduser deploy
usermod -aG docker deploy
usermod -aG sudo deploy
# Install Certbot
apt install -y python3-certbot-nginx certbot
```
### Step 3 — Clone Repository
```bash
su - deploy
mkdir -p /opt/cannamanage
cd /opt/cannamanage
git clone http://192.168.188.119:30008/pplate/cannamanage.git .
# Or from public mirror when available
```
### Step 4 — Create Production `.env`
```bash
cd /opt/cannamanage
cp .env.example .env
nano .env # Fill in all production secrets
chmod 600 .env
```
### Step 5 — Obtain SSL Certificate
```bash
# Stop anything on port 80 first (nothing should be running yet)
certbot certonly --standalone \
-d app.cannamanage.de \
--non-interactive \
--agree-tos \
-m ssl@cannamanage.de
# Symlink certs into nginx_certs volume location
# Certbot places certs at /etc/letsencrypt/live/app.cannamanage.de/
```
### Step 6 — Build Docker Image
```bash
# On the VPS (or build locally and push to registry)
./mvnw package -DskipTests -P production
docker build -t cannamanage:latest .
```
### Step 7 — Start Services
```bash
cd /opt/cannamanage
docker compose up -d
```
### Step 8 — Verify Health
```bash
# All containers should be 'healthy' or 'running'
docker compose ps
# Check application logs
docker compose logs -f app --tail=100
# Test health endpoint
curl -f http://localhost:8080/actuator/health
# Expected: {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}
```
### Step 9 — Flyway Migrations
Flyway runs automatically on Spring Boot startup. Verify migration log:
```bash
docker compose logs app | grep -i flyway
# Expected: Successfully applied N migrations to schema "public"
```
### Step 10 — Create First Admin User
```bash
# Option A: via REST API (recommended)
curl -X POST https://app.cannamanage.de/api/v1/admin/bootstrap \
-H "Content-Type: application/json" \
-d '{
"adminEmail": "admin@yourclub.de",
"adminPassword": "<strong-password>",
"clubName": "Your Club e.V.",
"clubRegistrationNumber": "VR 12345"
}'
# The bootstrap endpoint is disabled after first use (one-time setup flag in DB)
```
### Step 11 — Verify Production Access
```bash
# Web UI
open https://app.cannamanage.de
# API health check
curl https://app.cannamanage.de/actuator/health
```
---
## 6. CI/CD Pipeline (Gitea Actions)
**File:** `.gitea/workflows/deploy.yml`
```yaml
name: Deploy to Production
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run unit tests
run: ./mvnw test -pl cannamanage-service
- name: Run integration tests
run: ./mvnw verify -P integration-tests
# Testcontainers requires Docker — GitHub/Gitea hosted runners have Docker pre-installed
- name: Coverage gate check
run: ./mvnw verify -P coverage-check
build-and-deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build JAR
run: ./mvnw package -DskipTests -P production
- name: Build Docker image
run: |
docker build \
-t cannamanage:${{ github.sha }} \
-t cannamanage:latest \
.
- name: Save Docker image
run: docker save cannamanage:${{ github.sha }} | gzip > /tmp/cannamanage.tar.gz
- name: Copy image to VPS
run: |
scp -o StrictHostKeyChecking=no \
/tmp/cannamanage.tar.gz \
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
- name: Deploy via SSH
run: |
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.HETZNER_IP }} "
set -e
cd /opt/cannamanage
# Load new image
docker load < /tmp/cannamanage.tar.gz
rm /tmp/cannamanage.tar.gz
# Rolling restart app only (DB stays up)
VERSION=${{ github.sha }} docker compose up -d app
# Wait for health
sleep 10
docker compose ps app | grep 'healthy' || (docker compose logs app --tail=50 && exit 1)
# Prune old images (keep last 3)
docker image prune -f
"
```
### Required Gitea Repository Secrets
| Secret | Value |
|--------|-------|
| `HETZNER_IP` | VPS IPv4 address |
| `SSH_PRIVATE_KEY` | Private key for `deploy` user |
Add deploy user's public key to VPS authorized_keys:
```bash
# On VPS as deploy user
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<gitea-actions-public-key>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
---
## 7. Database Backup
### Automated Daily Backup
Add to root crontab (`crontab -e`):
```bash
# Daily backup at 03:00 UTC — keep 14 days
0 3 * * * docker exec cannamanage-db pg_dump \
-U cannamanage_user \
--format=custom \
cannamanage | gzip > /opt/backups/cannamanage_$(date +\%Y\%m\%d).sql.gz
# Cleanup backups older than 14 days
5 3 * * * find /opt/backups -name "cannamanage_*.sql.gz" -mtime +14 -delete
```
Create backup directory:
```bash
mkdir -p /opt/backups
chown deploy:deploy /opt/backups
```
### Restore from Backup
```bash
# Restore (caution: this overwrites existing data)
gunzip -c /opt/backups/cannamanage_20260406.sql.gz | \
docker exec -i cannamanage-db pg_restore \
-U cannamanage_user \
--clean \
--dbname=cannamanage
# Verify restore
docker exec cannamanage-db psql \
-U cannamanage_user \
-d cannamanage \
-c "SELECT COUNT(*) FROM clubs;"
```
### Offsite Backup (Optional)
For additional redundancy, sync backups to Hetzner Object Storage:
```bash
# Install s3cmd and configure with Hetzner S3-compatible endpoint
s3cmd sync /opt/backups/ s3://cannamanage-backups/
```
---
## 8. Monitoring & Health Checks
### Spring Boot Actuator
The application exposes health endpoints via `spring-boot-actuator`:
```bash
# Full health detail (requires ROLE_ADMIN JWT)
GET /actuator/health
# Example response
{
"status": "UP",
"components": {
"db": { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
"diskSpace": { "status": "UP", "details": { "total": 42GB, "free": 30GB } },
"ping": { "status": "UP" }
}
}
```
Expose only `health` and `info` publicly in `application-production.yml`:
```yaml
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
```
### Log Locations
| Source | Location |
|--------|----------|
| Application logs | `docker compose logs -f app` |
| Nginx access logs | `/var/log/nginx/access.log` |
| Nginx error logs | `/var/log/nginx/error.log` |
| PostgreSQL logs | `docker compose logs db` |
| Sentry (errors) | `https://sentry.io/organizations/<org>/` |
### Alerting
Configure Sentry to email on new errors:
1. Set `SENTRY_DSN` in `.env`
2. Add `io.sentry:sentry-spring-boot-starter-jakarta:7.x` to POM
3. Sentry auto-captures all unhandled exceptions with full stack trace
Simple uptime check via `cron` + email:
```bash
# Health check every 5 minutes — email on 3 consecutive failures
*/5 * * * * /opt/cannamanage/scripts/health_check.sh
```
```bash
#!/bin/bash
# /opt/cannamanage/scripts/health_check.sh
HEALTH_URL="https://app.cannamanage.de/actuator/health"
FAIL_COUNT_FILE="/tmp/cannamanage_health_fails"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL")
if [ "$HTTP_STATUS" != "200" ]; then
FAILS=$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0)
FAILS=$((FAILS + 1))
echo "$FAILS" > "$FAIL_COUNT_FILE"
if [ "$FAILS" -ge 3 ]; then
echo "CannaManage health check failed $FAILS times" | \
mail -s "ALERT: CannaManage DOWN" admin@cannamanage.de
fi
else
echo 0 > "$FAIL_COUNT_FILE"
fi
```
---
## 9. SSL Certificate Renewal
Let's Encrypt certificates expire after 90 days. Certbot handles renewal automatically:
```bash
# Test renewal (dry run — no actual renewal)
certbot renew --dry-run
# Manual renewal
certbot renew --nginx
# Reload Nginx after renewal
docker exec cannamanage-nginx nginx -s reload
```
### Auto-Renewal via Cron
```bash
# Renew at 02:00 UTC on the 1st and 15th of each month
0 2 1,15 * * certbot renew --quiet --nginx && \
docker exec cannamanage-nginx nginx -s reload
```
Certbot only renews when the certificate is less than 30 days from expiry — safe to run frequently.
---
## 10. Rollback Procedure
If a deployment causes issues:
```bash
# On VPS — list recent images
docker images cannamanage --format "table {{.Tag}}\t{{.CreatedAt}}"
# Roll back to previous SHA
cd /opt/cannamanage
VERSION=<previous-sha> docker compose up -d app
# Verify health after rollback
docker compose ps app
curl https://app.cannamanage.de/actuator/health
```
If database migrations were applied and rollback is needed:
1. Restore from last backup (see Section 7)
2. Redeploy the previous image version
3. Flyway baseline the schema at previous version
> **Note:** Flyway migrations are append-only and forward-only. Design migrations to be reversible where possible (add columns before removing old ones, etc.).
@@ -0,0 +1,97 @@
# 10 — Sprint 0 Planning Retrospective
**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)
**Outcome:** ✅ Complete — 10-document suite written, architecture locked
---
## What Went Well ✅
**AI-assisted documentation at scale.** The complete documentation suite (10 documents, ~25,000 words total) was created in a single focused session using the Roo Orchestrator mode to coordinate multi-document generation. This would have taken 23 days manually. The quality is high enough to serve as actual implementation guidance — not placeholder text.
**Legal analysis confirmed viability early.** The CanG compliance review (Phase 1) identified the key constraints (no public directory, no consumer-facing advertising, B2B-only) before any code was written. These became hard architectural constraints rather than late surprises. No "oh wait, we can't do that" moments during technical design.
**Architecture decisions locked before code.** The shared-schema multi-tenancy decision, immutable distribution records design, and `ComplianceConstants` pattern were all decided and documented before a single line of production code was written. This is the correct order. Rework from late architectural pivots is far more expensive than planning time.
**Compliance constants centralized from day zero.** Designing `ComplianceConstants.java` as the single source of truth for all CanG quota values (25g/day, 50g/month, etc.) prevents the most dangerous class of compliance bug: magic numbers scattered across the codebase that diverge when the law changes.
**ComfyUI mockup images in minutes.** Generating 5 realistic UI mockup images with FLUX.1-schnell took approximately 8 minutes of wall-clock time. This provides a visual reference for the UI that would otherwise require a designer or Figma skills. The images are good enough for stakeholder presentations and early user research.
**Test plan written before code.** TC-001 through TC-026 were defined against specifications, not against existing implementation. This forces clarity on what the code must do before writing it — the test cases are essentially executable requirements.
---
## What Was Challenging ⚠️
**ComfyUI manual startup friction.** The ComfyUI image generation server does not auto-start with the system. This required manual service start and a retry cycle before image generation could proceed. The fix (systemd user service + auto-start lifespan check in `mcp-image-gen`) was implemented during this planning sprint but added unexpected overhead.
**Solo developer timeline is ambitious.** The 1824 month estimate for a production-ready SaaS while employed full-time at ADP Germany is tight. Sprint 1 goals are achievable; the risk accumulates in Sprints 36 when frontend work, billing integration, and PDF generation converge. The PrimeFaces JSF choice for MVP was deliberate to reduce this risk — existing Java frontend skills transfer directly.
**Spring Boot 3 is not yet a "home" stack.** ADP work uses Jakarta EE (JBoss, CDI, JAX-RS). Spring Boot 3 shares the JPA/Hibernate mental model but diverges on dependency injection, auto-configuration, and application packaging. The learning curve is real but bounded — the `mss-failsafe` and `wellmann-shop` projects in `pi_mcps` demonstrate that the transition is manageable.
**Next.js/React remains a significant gap.** The v2 frontend pivot to Next.js 15 + React 19 is the highest-skill-gap risk in the project. PrimeFaces buys time, but the clock starts ticking on React learning from Sprint 1. Deferring is correct; ignoring it is not.
**No real user validation yet.** The entire architecture and pricing model is based on market research and regulatory reading, not on conversations with actual club administrators. The product may be solving the right problem in the wrong way. This is the most important open risk.
---
## Key Decisions Made 📋
| Decision | Rationale | Alternatives rejected |
|----------|-----------|----------------------|
| Shared-schema multi-tenancy (single DB, `tenant_id` columns) | Lowest ops overhead for MVP; one DB to backup/restore; simpler Flyway migrations | Schema-per-tenant (complex provisioning), DB-per-tenant (expensive at scale) |
| Immutable distribution records (`@Column(updatable = false)`) | Legal integrity — audit logs must be tamper-proof; corrections via `RecallEvent`, not `UPDATE` | Mutable records (simpler but legally risky under CanG §26 record-keeping) |
| PrimeFaces JSF for MVP frontend | Leverages existing Jakarta EE skills; fastest path to working product; no JS build tooling required | React/Next.js (faster modern dev, but higher skill gap), Thymeleaf (less interactive) |
| No public club discovery — permanent architectural exclusion | CanG §§67 prohibit advertising cannabis to the general public; club lookup tool would likely constitute advertising | N/A — this is a legal constraint, not a design choice |
| `ComplianceConstants.java` single source of truth | Prevents magic number scatter; single change point when law evolves | Constants in each service (fragile), DB-configurable limits (dangerous — allows disabling compliance) |
| Hetzner VPS over AWS/GCP | Cost (€5.88/month vs €20+); EU data residency (GDPR); simpler ops for solo developer | AWS (expensive, complex), Fly.io (less EU clarity), Railway (vendor lock-in) |
---
## Risks Going Forward ⚠️
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| New German government tightens CanG (e.g. lower quota limits) | Medium | High — requires rapid compliance updates | `ComplianceConstants.java` centralizes all limits; update is a 1-file change + test re-run |
| Stripe flags account as cannabis-adjacent | Medium | Critical — billing becomes unusable | Use category "Vereinsverwaltung" (club management) in Stripe onboarding; prepare Mollie as fallback |
| Solo dev burnout / timeline slip | High | Medium — delayed launch, not cancellation | Strict MVP scope; PrimeFaces reduces frontend effort; no scope creep before first paying customer |
| Market timing risk — clubs adopt ad-hoc Excel/WhatsApp solutions | Medium | High — low willingness to pay for formal software | User research with 3+ clubs in Sprint 1 is mandatory before writing production code |
| Legal risk: CanG compliance interpretation | Low | High — criminal liability for club officers | Specialist cannabis law opinion (€300500) before launch; not optional |
| Under-21 age calculation edge cases | Low | Medium — compliance bug | Birthday-based age calculation uses `Period.between()`, not year subtraction; tested in TC-013/014 |
---
## Next Steps — Sprint 1 Goals
- [ ] Initialize Spring Boot 3.x Maven multi-module project (`cannamanage-parent`, `cannamanage-domain`, `cannamanage-service`, `cannamanage-api`, `cannamanage-web`)
- [ ] Implement `AbstractTenantEntity` base class with `@MappedSuperclass`
- [ ] Write `V1__initial_schema.sql` Flyway migration covering all 8 entities
- [ ] Implement `ComplianceService` with full quota logic and 100% test coverage (TC-001010)
- [ ] Implement `MemberService` with age validation (TC-011015)
- [ ] Set up JaCoCo with ComplianceService 100% coverage gate
- [ ] Gitea repository created and CI pipeline (unit tests on `feature/*`) functional
- [ ] **Talk to 3 real club administrators** — validate pain points, willingness to pay, and current workarounds
- [ ] Get specialist legal opinion from a cannabis law attorney (€300500 budget)
---
## Metrics
| Metric | Value |
|--------|-------|
| Planning duration | 3 days (2026-04-04 to 2026-04-06) |
| Documents created | 10 (01-PROJECT-CHARTER through 10-RETROSPECTIVE) |
| Estimated total words | ~25,000 |
| Test cases defined | 26 |
| API endpoints specified | 30+ |
| JPA entities designed | 8 |
| UI screens wireframed | 6 |
| UI mockup images generated | 5 |
| Lines of production code written | **0** |
| Architecture decisions logged | 6 major |
| Open risks identified | 6 |
The ratio of planning output to production code written is intentional. Phase 0 exists to eliminate avoidable rework — the most expensive kind.
+28
View File
@@ -0,0 +1,28 @@
# 🌿 CannaManage
**B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)**
> Status: Phase 0 — Planning Complete | Stack: Spring Boot 3.x + PrimeFaces → Next.js | 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 |
| [Flow Charts](CannaManage-04-Flowcharts) | 5 business logic flows (distribution, recall, compliance) |
| [API Spec](CannaManage-05-API) | REST API: 7 controllers, 30+ endpoints |
| [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 |
## Quick Facts
- **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)
+13
View File
@@ -17,5 +17,18 @@
- [🏢 mss-failsafe](Java-mss-failsafe)
- [📐 Java Architecture](Java-Architecture)
### 🌿 CannaManage
- [🏠 Overview](CannaManage-Home)
- [📋 Project Charter](CannaManage-01-Charter)
- [📖 User Stories](CannaManage-02-UserStories)
- [🏗️ Architecture](CannaManage-03-Architecture)
- [🔄 Flow Charts](CannaManage-04-Flowcharts)
- [🔌 API Spec](CannaManage-05-API)
- [🎨 Wireframes](CannaManage-06-Wireframes)
- [📏 Coding Standards](CannaManage-07-CodingStandards)
- [🧪 Test Plan](CannaManage-08-TestPlan)
- [🚀 Deployment](CannaManage-09-Deployment)
- [🔍 Retrospective](CannaManage-10-Retrospective)
---
*[Gitea Repo](http://192.168.188.119:30008/pplate/pi_mcps)*
+81 -10
View File
@@ -62,7 +62,52 @@ huggingface-cli download comfyanonymous/flux_text_encoders \
--local-dir ~/ComfyUI/models/clip
```
## Step 4: Start ComfyUI
## Step 4: Install the systemd User Service (Recommended)
Installing ComfyUI as a systemd user service ensures it starts automatically on login and restarts on failure.
```bash
# Copy the bundled service file to the systemd user directory
mkdir -p ~/.config/systemd/user
cp ~/pi_mcps/mcp/mcp-image-gen/comfyui.service ~/.config/systemd/user/comfyui.service
# Reload systemd, enable + start the service
systemctl --user daemon-reload
systemctl --user enable --now comfyui
# Verify it is running
systemctl --user status comfyui
```
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is already set in the service file — it is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
### Enable lingering (start ComfyUI even without a login session)
```bash
loginctl enable-linger $USER
```
This ensures the service starts at boot even before you log in — recommended for headless / homelab setups.
### Managing the service
```bash
# Follow live logs
journalctl --user -u comfyui -f
# Restart after model changes
systemctl --user restart comfyui
# Stop temporarily
systemctl --user stop comfyui
# Disable autostart
systemctl --user disable comfyui
```
## Step 5: Manual Start (without systemd)
If you prefer to start ComfyUI manually (e.g. for debugging):
```bash
cd ~/ComfyUI
@@ -74,26 +119,36 @@ HSA_OVERRIDE_GFX_VERSION=11.0.0 \
echo "ComfyUI PID: $!"
```
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
## Step 5: Verify ComfyUI is Running
## Step 6: Verify ComfyUI is Running
```bash
curl http://localhost:8188/system_stats
# Should return JSON with GPU info
```
## Step 6: Configure mcp-image-gen
## Step 7: Configure mcp-image-gen
```bash
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
# Environment variables (set in .roo/mcp.json or shell):
# COMFYUI_URL=http://localhost:8188
# IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated
# COMFYUI_TIMEOUT=120
# COMFYUI_URL=http://localhost:8188 — ComfyUI API endpoint
# IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated — where generated images are saved
# COMFYUI_TIMEOUT=120 — max wait time (seconds) per image
# COMFYUI_DIR=~/ComfyUI — path to ComfyUI install (used by auto-start)
```
### Auto-start behaviour
`mcp-image-gen` includes a **startup health check** in its lifespan. Every time the MCP server starts it:
1. Pings `http://localhost:8188/system_stats`
2. **If reachable** — logs `ComfyUI is already running ✓` and proceeds normally.
3. **If not reachable** — attempts to launch ComfyUI as a background subprocess from `COMFYUI_DIR` using `.venv/bin/python main.py --listen --port 8188` with `HSA_OVERRIDE_GFX_VERSION=11.0.0` injected automatically.
4. Polls up to 30 s for ComfyUI to become ready.
With the systemd service enabled, step 3 is never needed in practice — but the check acts as a safety net.
## Performance
| GPU | Model | Resolution | Steps | Time |
@@ -101,12 +156,28 @@ cd /home/pplate/pi_mcps/mcp/mcp-image-gen
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
## Architecture Overview
```
Boot
└─ systemd --user (comfyui.service)
└─ ComfyUI at localhost:8188
VS Code / Roo Code
└─ mcp-image-gen MCP server (stdio)
├─ lifespan startup: ping localhost:8188
│ └─ if down: subprocess.Popen ComfyUI, wait ≤30s
└─ tools: generate_image, list_available_models, …
```
## Troubleshooting
| Problem | Solution |
|---|---|
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
| `Connection refused` from mcp-image-gen | Start ComfyUI first, check port 8188 |
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install |
| `Connection refused` from mcp-image-gen | Check `systemctl --user status comfyui`; or set `COMFYUI_DIR` so auto-start can locate the install |
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install and `HSA_OVERRIDE_GFX_VERSION` |
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
| Auto-start logs | `journalctl --user -u comfyui -f` or check mcp-image-gen server logs |
| Service not starting at boot | Run `loginctl enable-linger $USER` to enable session-less startup |
+16 -9
View File
@@ -441,21 +441,28 @@ def search_chunks(user_id: str, query: str, limit: int = 10) -> list:
def delete_chunks_before(user_id: str, cutoff_iso: str) -> int:
"""Delete Tier-3 chunks older than cutoff. Returns count deleted."""
with db() as conn:
count = conn.execute(
"SELECT COUNT(*) FROM conversation_chunks WHERE user_id=? AND created_at < ?",
# Collect IDs first — needed for FTS sync.
# conversation_chunks_fts is a STANDALONE FTS5 table (no content= option),
# so we must delete FTS rows explicitly by rowid. The old VALUES('rebuild')
# approach only works for content= backed tables and was a no-op here.
rows = conn.execute(
"SELECT id FROM conversation_chunks WHERE user_id=? AND created_at < ?",
(user_id, cutoff_iso),
).fetchone()[0]
if count == 0:
).fetchall()
if not rows:
return 0
ids = [r[0] for r in rows]
# Delete FTS entries by rowid before removing from main table
placeholders = ",".join("?" * len(ids))
conn.execute(
f"DELETE FROM conversation_chunks_fts WHERE rowid IN ({placeholders})",
ids,
)
conn.execute(
"DELETE FROM conversation_chunks WHERE user_id=? AND created_at < ?",
(user_id, cutoff_iso),
)
# Rebuild the FTS5 index from the content table — always correct for content= tables
conn.execute(
"INSERT INTO conversation_chunks_fts(conversation_chunks_fts) VALUES('rebuild')"
)
return count
return len(ids)
# ── FACTS ───────────────────────────────────────────────────────────────────────
+5 -1
View File
@@ -2,7 +2,11 @@
**FastMCP server for AI image generation via ComfyUI.**
This MCP server wraps a locally running [ComfyUI](https://github.com/comfyanonymous/ComfyUI) instance, exposing image generation as MCP tools callable from Roo Code, Claude Desktop, or any MCP-compatible client. It supports FLUX.1-schnell, FLUX.1-dev, SDXL, and any other ComfyUI-compatible checkpoint model. Generated images are saved to disk **and** returned as inline base64 so Claude can display them directly in chat.
This MCP server wraps a locally running [ComfyUI](https://github.com/comfyanonymous/ComfyUI) instance, exposing image generation as MCP tools callable from Roo Code, Claude Desktop, or any MCP-compatible client.
**New:** Support for **FLUX.2 Klein 4B** with **Heretic-abliterated Qwen3-4B text encoder** (zero KL divergence, no refusals). Select via `model="flux-2-klein-4b-fp8.safetensors"`.
It supports FLUX.1-schnell (default), FLUX.2 Klein (Heretic), and any other ComfyUI-compatible checkpoint model. Generated images are saved to disk **and** returned as inline base64 so Claude can display them directly in chat.
---
+50 -1
View File
@@ -565,7 +565,56 @@ Then pass it back: `seed=3847291045`
---
## 10. Known Limitations
## 10. FLUX.2 Klein 4B with Heretic Abliteration (New)
**New in this release:** Support for **FLUX.2 Klein 4B** using an **abliterated Qwen3-4B text encoder** via Heretic.
### Why Heretic?
FLUX.2 Klein uses a full LLM (Qwen3-4B) as its text encoder instead of CLIP+T5. This LLM has safety alignment that can refuse certain prompts. Heretic removes this alignment with **zero measurable KL divergence** (0.0000) and only 3/100 refusals.
### How to use it
```python
generate_image(
prompt="a beautiful cyberpunk fox in neon tokyo, highly detailed",
model="flux-2-klein-4b-fp8.safetensors",
width=1024,
height=1024,
steps=4
)
```
### Models to download
```bash
# 1. FLUX.2 Klein 4B (distilled, fp8)
huggingface-cli download black-forest-labs/FLUX.2-klein-4B \
flux-2-klein-4b-fp8.safetensors \
--local-dir ~/ComfyUI/models/diffusion_models/
# 2. FLUX.2 VAE
huggingface-cli download black-forest-labs/FLUX.2-klein-4B \
flux2-vae.safetensors \
--local-dir ~/ComfyUI/models/vae/
# 3. Heretic-abliterated Qwen3-4B (from DreamFast)
huggingface-cli download DreamFast/qwen3-4b-heretic \
--local-dir /tmp/qwen3-heretic/
cp /tmp/qwen3-heretic/model.safetensors \
~/ComfyUI/models/text_encoders/qwen_3_4b_heretic.safetensors
```
### Supported models (via `model=` parameter)
| Model | Description | VRAM | Speed | Censorship |
|-------|-------------|------|-------|------------|
| `flux1-schnell.safetensors` | Original (default) | ~8GB | Very fast | None |
| `flux-2-klein-4b-fp8.safetensors` | **New** — with Heretic Qwen3-4B | ~12GB | Fast | **Removed** |
---
## 11. Known Limitations
### ComfyUI must run locally
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=ComfyUI — Local AI Image Generation (AMD ROCm / FLUX.1-schnell)
Documentation=https://github.com/comfyanonymous/ComfyUI
After=network.target
[Service]
Type=simple
WorkingDirectory=%h/ComfyUI
ExecStart=%h/ComfyUI/.venv/bin/python main.py --listen --port 8188
Restart=on-failure
RestartSec=10
# AMD RX 7900 XTX ROCm GFX override — required for correct GPU detection
Environment=HSA_OVERRIDE_GFX_VERSION=11.0.0
# Redirect output — follow with: journalctl --user -u comfyui -f
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
+176 -65
View File
@@ -4,16 +4,23 @@ import asyncio
import base64
import copy
import json
import logging
import os
import random
import re
import subprocess
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Annotated
import httpx
from fastmcp import FastMCP
from mcp.types import ImageContent, TextContent
from pydantic import Field
logger = logging.getLogger("mcp-image-gen")
# ---------------------------------------------------------------------------
# Configuration
@@ -23,13 +30,118 @@ COMFYUI_URL = os.environ.get("COMFYUI_URL", "http://localhost:8188").rstrip("/")
IMAGE_OUTPUT_DIR = os.environ.get("IMAGE_OUTPUT_DIR", "~/Pictures/mcp-generated")
COMFYUI_TIMEOUT = int(os.environ.get("COMFYUI_TIMEOUT", "120"))
# Directory where ComfyUI is installed (used for auto-start only)
# Override via COMFYUI_DIR env var. Systemd service sets this automatically.
COMFYUI_DIR = Path(
os.environ.get("COMFYUI_DIR", "~/ComfyUI")
).expanduser().resolve()
# Maximum number of images allowed in a single batch call
MAX_COUNT = 10
# Path to the bundled FLUX.1-schnell workflow template
_WORKFLOW_PATH = Path(__file__).parent / "workflows" / "flux_schnell.json"
# Workflow registry: model filename → workflow JSON path
# This allows us to support multiple models (FLUX.1-schnell + FLUX.2 Klein with Heretic encoder)
_WORKFLOW_REGISTRY: dict[str, Path] = {
"flux1-schnell.safetensors": Path(__file__).parent / "workflows" / "flux_schnell.json",
"flux-2-klein-4b.safetensors": Path(__file__).parent / "workflows" / "flux2_klein_heretic.json",
}
mcp = FastMCP("mcp-image-gen")
_DEFAULT_MODEL = "flux1-schnell.safetensors"
# ---------------------------------------------------------------------------
# ComfyUI health check + auto-start
# ---------------------------------------------------------------------------
async def _ping_comfyui(url: str, timeout: float = 5.0) -> bool:
"""Return True if ComfyUI is reachable at *url*/system_stats."""
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{url}/system_stats")
return resp.status_code == 200
except (httpx.ConnectError, httpx.TimeoutException, OSError):
return False
async def check_and_start_comfyui() -> None:
"""Ping ComfyUI; if not reachable, attempt to launch it as a subprocess.
Called once at server startup from the lifespan context manager.
Uses COMFYUI_DIR to locate the installation and its venv Python.
The HSA_OVERRIDE_GFX_VERSION=11.0.0 env var is injected automatically
for AMD ROCm / RX 7900 XTX compatibility.
"""
if await _ping_comfyui(COMFYUI_URL):
logger.info("ComfyUI is already running at %s", COMFYUI_URL)
return
logger.warning(
"ComfyUI not reachable at %s — attempting to start from %s",
COMFYUI_URL, COMFYUI_DIR,
)
python = COMFYUI_DIR / ".venv" / "bin" / "python"
main_py = COMFYUI_DIR / "main.py"
if not python.exists():
logger.error(
"ComfyUI venv Python not found at %s. "
"Install ComfyUI first (see docs/wiki/pages/mcp-image-gen-ComfyUI-Setup.md).",
python,
)
return
if not main_py.exists():
logger.error(
"ComfyUI main.py not found at %s — is COMFYUI_DIR correct?",
main_py,
)
return
# Build environment: inherit current env, set ROCm override for AMD RX 7900 XTX
env = os.environ.copy()
env.setdefault("HSA_OVERRIDE_GFX_VERSION", "11.0.0")
try:
proc = subprocess.Popen(
[str(python), str(main_py), "--listen", "--port", "8188"],
cwd=str(COMFYUI_DIR),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # detach from MCP server process group
)
logger.info("ComfyUI launched (PID %d) — waiting for readiness…", proc.pid)
except OSError as exc:
logger.error("Failed to start ComfyUI subprocess: %s", exc)
return
# Wait up to 30 s for ComfyUI to become ready (polls every 2 s)
wait_limit = 30
for attempt in range(wait_limit // 2):
await asyncio.sleep(2)
if await _ping_comfyui(COMFYUI_URL):
logger.info(
"ComfyUI ready at %s after ~%ds ✓", COMFYUI_URL, (attempt + 1) * 2
)
return
logger.warning(
"ComfyUI did not respond within %ds. "
"Generation calls will fail until it is ready. "
"Check logs: journalctl --user -u comfyui -f",
wait_limit,
)
@asynccontextmanager
async def lifespan(app):
"""FastMCP lifespan: run ComfyUI health check at server startup."""
await check_and_start_comfyui()
yield # server is live here
# Nothing to tear down — ComfyUI is managed by systemd, not this process
mcp = FastMCP("mcp-image-gen", lifespan=lifespan)
# ---------------------------------------------------------------------------
@@ -75,21 +187,37 @@ class ComfyUIClient:
return resp.content
async def get_models(self) -> list[str]:
"""Return the list of available checkpoint model filenames."""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self.base_url}/object_info/CheckpointLoaderSimple"
)
resp.raise_for_status()
data = resp.json()
# ComfyUI returns: {"CheckpointLoaderSimple": {"input": {"required": {"ckpt_name": [["model1.safetensors", ...], ...]}}}}
node_info = data.get("CheckpointLoaderSimple", {})
ckpt_list = (
node_info.get("input", {})
.get("required", {})
.get("ckpt_name", [[]])[0]
)
return ckpt_list if isinstance(ckpt_list, list) else []
"""Return the list of available checkpoint model filenames.
Combines models known to ComfyUI with our internal registry
(including FLUX.2 Klein with Heretic encoder).
"""
models = set()
# Get models from ComfyUI
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self.base_url}/object_info/CheckpointLoaderSimple"
)
resp.raise_for_status()
data = resp.json()
node_info = data.get("CheckpointLoaderSimple", {})
ckpt_list = (
node_info.get("input", {})
.get("required", {})
.get("ckpt_name", [[]])[0]
)
if isinstance(ckpt_list, list):
models.update(ckpt_list)
except Exception:
# ComfyUI not reachable — fall back to registry only
pass
# Add our registered models
models.update(_WORKFLOW_REGISTRY.keys())
return sorted(list(models))
# ---------------------------------------------------------------------------
@@ -103,13 +231,20 @@ def build_flux_workflow(
height: int,
steps: int,
seed: int,
model: str,
model: str = _DEFAULT_MODEL,
) -> dict:
"""Build a ComfyUI API-format workflow dict for FLUX.1-schnell text-to-image.
"""Build a ComfyUI API-format workflow dict for the requested model.
This is a pure function — no I/O, fully testable.
Supports:
- "flux1-schnell.safetensors" (original)
- "flux-2-klein-4b-fp8.safetensors" (with Heretic-abliterated Qwen3-4B text encoder)
Falls back to FLUX.1-schnell if model is unknown.
This is a pure function — no I/O outside the registry, fully testable.
"""
with open(_WORKFLOW_PATH) as f:
workflow_path = _WORKFLOW_REGISTRY.get(model, _WORKFLOW_REGISTRY[_DEFAULT_MODEL])
with open(workflow_path) as f:
wf = json.load(f)
wf = copy.deepcopy(wf)
@@ -171,18 +306,13 @@ async def _generate_single(
) -> list:
"""Generate a single image and return [TextContent, ImageContent] or [TextContent] on error.
Args:
client: ComfyUIClient instance.
prompt: Positive text prompt.
negative_prompt: Negative text prompt.
width / height: Image dimensions.
steps: Inference steps.
seed: Seed value (-1 = random).
model: ComfyUI model filename.
resolved_output_dir: Resolved output directory Path.
name: User-supplied name prefix (unsanitized).
label: Human-readable label for TextContent prefix (e.g. "[lumen 1/3]").
Supports two models:
- flux1-schnell.safetensors (default, fast 4-step)
- flux-2-klein-4b.safetensors (with Heretic-abliterated Qwen3-4B text encoder — no refusals)
"""
if model not in _WORKFLOW_REGISTRY:
model = _DEFAULT_MODEL
logger.warning("Unknown model %s, falling back to %s", model, _DEFAULT_MODEL)
# Build and submit workflow
try:
workflow = build_flux_workflow(
@@ -332,40 +462,22 @@ async def _generate_single(
@mcp.tool()
async def generate_image(
prompt: str,
width: int = 1024,
height: int = 1024,
steps: int = 4,
model: str = "flux1-schnell.safetensors",
seed: int = -1,
negative_prompt: str = "",
output_dir: str = "",
name: str = "",
count: int = 1,
prompt: Annotated[str, Field(description="Text description of the image to generate.")],
width: Annotated[int, Field(description="Image width in pixels (default: 1024).")] = 1024,
height: Annotated[int, Field(description="Image height in pixels (default: 1024).")] = 1024,
steps: Annotated[int, Field(description="Number of inference steps. FLUX.1-schnell works well at 4.")] = 4,
model: Annotated[str, Field(description="ComfyUI model filename (default: flux1-schnell.safetensors).")] = "flux1-schnell.safetensors",
seed: Annotated[int, Field(description="Random seed for reproducibility. -1 = random. When count > 1 and seed != -1, seeds are incremented per image (seed, seed+1, seed+2, ...) to produce deterministic variation.")] = -1,
negative_prompt: Annotated[str, Field(description="Things to exclude from the image (optional).")] = "",
output_dir: Annotated[str, Field(description="Override output directory. Defaults to IMAGE_OUTPUT_DIR env var or ~/Pictures/mcp-generated.")] = "",
name: Annotated[str, Field(description="Optional filename prefix. Saved as {name}_{timestamp}_{seed}.png. Useful to avoid confusion with auto-generated timestamp filenames.")] = "",
count: Annotated[int, Field(description="Number of images to generate (110). Each image is generated sequentially. Partial failures are returned inline — the batch continues even if one image fails.")] = 1,
) -> list:
"""Generate an image from a text prompt using ComfyUI.
Returns both a file path (for persistence) and an inline base64 image
(for display in Claude / Roo Code chat).
Args:
prompt: Text description of the image to generate.
width: Image width in pixels (default: 1024).
height: Image height in pixels (default: 1024).
steps: Number of inference steps. FLUX.1-schnell works well at 4.
model: ComfyUI model filename (default: flux1-schnell.safetensors).
seed: Random seed for reproducibility. -1 = random.
When count > 1 and seed != -1, seeds are incremented per image
(seed, seed+1, seed+2, ...) to produce deterministic variation.
negative_prompt: Things to exclude from the image (optional).
output_dir: Override output directory. Defaults to IMAGE_OUTPUT_DIR env var
or ~/Pictures/mcp-generated.
name: Optional filename prefix. Saved as {name}_{timestamp}_{seed}.png.
Useful to avoid confusion with auto-generated timestamp filenames.
count: Number of images to generate (110). Each image is generated
sequentially. Partial failures are returned inline — the batch
continues even if one image fails.
Returns:
Flat interleaved list: [TextContent1, ImageContent1, TextContent2, ImageContent2, ...]
On error for any single image, that slot contains only [TextContent(error)].
@@ -442,12 +554,11 @@ async def list_available_models() -> list[str]:
@mcp.tool()
async def get_generation_status(prompt_id: str) -> dict:
async def get_generation_status(
prompt_id: Annotated[str, Field(description="The prompt ID returned by a previous generate_image call.")],
) -> dict:
"""Check the status of a queued or running generation job.
Args:
prompt_id: The prompt ID returned by a previous generate_image call.
Returns:
Dict with 'status' key: "pending", "running", "completed", or "not_found".
"""
@@ -0,0 +1,98 @@
{
"1": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b_klein.safetensors",
"type": "flux2",
"device": "default"
}
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["1", 0],
"text": "PROMPT_PLACEHOLDER"
}
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["1", 0],
"text": "NEGATIVE_PLACEHOLDER"
}
},
"4": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "flux-2-klein-4b.safetensors",
"weight_dtype": "default"
}
},
"5": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "flux2-vae.safetensors"
}
},
"6": {
"class_type": "EmptyFlux2LatentImage",
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
}
},
"7": {
"class_type": "Flux2Scheduler",
"inputs": {
"steps": 20,
"width": 1024,
"height": 1024
}
},
"8": {
"class_type": "CFGGuider",
"inputs": {
"model": ["4", 0],
"positive": ["2", 0],
"negative": ["3", 0],
"cfg": 5
}
},
"9": {
"class_type": "KSamplerSelect",
"inputs": {
"sampler_name": "euler"
}
},
"10": {
"class_type": "RandomNoise",
"inputs": {
"noise_seed": 42
}
},
"11": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["10", 0],
"guider": ["8", 0],
"sampler": ["9", 0],
"sigmas": ["7", 0],
"latent_image": ["6", 0]
}
},
"12": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["11", 0],
"vae": ["5", 0]
}
},
"13": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "mcp-image-gen",
"images": ["12", 0]
}
}
}
+56 -4
View File
@@ -31,7 +31,7 @@ COMFYUI_BASE = "http://test-comfyui:8188"
# ---------------------------------------------------------------------------
def test_build_flux_workflow_structure():
"""Verify build_flux_workflow returns a dict with correct node types."""
"""Verify build_flux_workflow returns a dict with correct node types for default model."""
wf = build_flux_workflow(
prompt="a red cat",
neg_prompt="ugly",
@@ -52,6 +52,56 @@ def test_build_flux_workflow_structure():
assert wf["33"]["class_type"] == "CLIPTextEncode"
def test_build_flux_workflow_heretic_model():
"""Verify FLUX.2 Klein 4B with Heretic Qwen3-4B encoder uses correct nodes."""
wf = build_flux_workflow(
prompt="a red cat",
neg_prompt="ugly",
width=1024,
height=1024,
steps=4,
seed=42,
model="flux-2-klein-4b.safetensors",
)
# New FLUX.2 workflow uses different node IDs and types
assert wf["1"]["class_type"] == "CLIPLoader" # Qwen3-4B uses single CLIPLoader
assert wf["1"]["inputs"]["type"] == "flux2" # correct type for FLUX.2
assert wf["1"]["inputs"]["device"] == "default" # required for FLUX.2 CLIPLoader
assert wf["1"]["inputs"]["clip_name"] == "qwen_3_4b_klein.safetensors" # Comfy-Org/vae-text-encorder-for-flux-klein-4b
assert wf["2"]["class_type"] == "CLIPTextEncode" # standard CLIP encode (not Flux-specific)
assert wf["4"]["class_type"] == "UNETLoader"
assert wf["4"]["inputs"]["unet_name"] == "flux-2-klein-4b.safetensors"
assert wf["4"]["inputs"]["weight_dtype"] == "default" # not fp8 — avoids dimension errors
assert wf["6"]["class_type"] == "EmptyFlux2LatentImage" # FLUX.2-specific latent
assert wf["8"]["class_type"] == "CFGGuider" # CFGGuider replaces FluxDisableGuidance+BasicGuider
assert wf["8"]["inputs"]["cfg"] == 5 # cfg=5 for FLUX.2 Klein
assert wf["11"]["class_type"] == "SamplerCustomAdvanced" # FLUX.2 sampler (node 11, not 12)
assert wf["13"]["class_type"] == "SaveImage" # output node
def test_workflow_registry_contains_both_models():
"""Verify the registry contains both supported models."""
assert "flux1-schnell.safetensors" in server._WORKFLOW_REGISTRY
assert "flux-2-klein-4b.safetensors" in server._WORKFLOW_REGISTRY
assert len(server._WORKFLOW_REGISTRY) == 2
def test_workflow_registry_fallback():
"""Unknown model falls back to default (FLUX.1-schnell)."""
wf = build_flux_workflow(
prompt="test",
neg_prompt="",
width=512,
height=512,
steps=4,
seed=42,
model="unknown-model.safetensors",
)
# Should have used default workflow (DualCLIPLoader)
assert wf["30"]["class_type"] == "DualCLIPLoader"
assert wf["32"]["inputs"]["unet_name"] == "unknown-model.safetensors"
def test_build_flux_workflow_params_injected():
"""Verify all parameters are injected into correct nodes."""
wf = build_flux_workflow(
@@ -202,14 +252,16 @@ async def test_list_available_models():
@respx.mock
@pytest.mark.asyncio
async def test_list_available_models_comfyui_offline():
"""When ComfyUI is unreachable, list_available_models returns error message."""
"""When ComfyUI is unreachable, list_available_models falls back to registry models."""
respx.get(f"{COMFYUI_BASE}/object_info/CheckpointLoaderSimple").mock(
side_effect=httpx.ConnectError("connection refused")
)
result = await list_available_models()
assert len(result) == 1
assert "not reachable" in result[0].lower()
# Should return registry models even when ComfyUI is offline
assert isinstance(result, list)
assert "flux1-schnell.safetensors" in result
assert "flux-2-klein-4b.safetensors" in result
# ---------------------------------------------------------------------------
+149
View File
@@ -0,0 +1,149 @@
# BigMind Session Loop — Root Cause & Fix Plan
**Date:** 2026-04-10
**Reported by:** Patrick
**Severity:** High — caused 6 identical wasted sessions with $0+ API cost per loop
---
## Problem Statement
BigMind's session ritual, combined with mode-specific behavior rules, creates a self-reinforcing
resumption loop when a session ends as `partial`. The model loads prior context, sees an incomplete
task, and autonomously attempts to resume it — without ever waiting for user input. This produces
a chain of identical `partial` sessions that only breaks when Patrick manually intervenes.
Observed: 6 identical sessions titled *"Prepared large-scale CannaManage branding generation"*,
all `partial`, all spawned from one session ending before image generation completed in pic-gen mode.
---
## Root Cause Analysis
### Loop Trigger Chain
```
[Session N] ends partial (task: CannaManage branding generation)
[Session N+1] memory_start_session() → loads context
│ Context shows: last outcome = partial
│ Rule 1: "search before every task, avoid redundant work"
│ → model reads: "prior task incomplete, I must finish it"
memory_announce_focus() called with prior session's task
│ → locks in wrong objective BEFORE user speaks
Mode rules (pic-gen) fire: "generate images now"
│ → autonomous action without user instruction
Hits context/token/tool limit → session ends partial
└──────────────────────────────────────────► REPEAT
```
### Three Compounding Failures
#### Failure 1: Rule 1 — No "partial = history only" clause
Rule 1 says to load context and search for prior work. It has **no explicit instruction**
that sessions marked `partial` are historical records, NOT resumption requests.
The model's default behavior is to treat incomplete work as a pending obligation.
#### Failure 2: memory_announce_focus — Called on prior context, not current task
The architect rules say to call `memory_announce_focus()` as part of the startup ritual.
But when no user message has been received yet, the model has nothing to announce except
the prior session's objective — which is the wrong task for the new session.
#### Failure 3: Mode interaction amplification
Modes with strong "do the task" personalities (pic-gen, code) compound the loop. When
context suggests "there's pending image generation work", pic-gen mode's instructions
say to start generating — creating autonomous action before the user speaks.
---
## Fix Design
### Fix 1: Rule 1 Addendum — Partial Sessions Are History
Add explicit text to Rule 1 in `01-bigmind-core.md`:
> **`partial`, `blocked`, or `abandoned` outcomes are historical records only.**
> They do NOT constitute task queues, resumption requests, or pending obligations.
> A new session begins fresh. The current session's task is determined solely by
> what the user writes in their first message — never by the outcome of a prior session.
### Fix 2: New Rule 9 — Anti-Loop Guardrail
Add Rule 9 to `01-bigmind-core.md`:
> **Rule 9: Detect and Break Loops Before They Start**
>
> If `memory_start_session()` context shows 2 or more recently closed sessions with:
> - Near-identical headlines or topics, AND
> - `partial` or `blocked` outcome
>
> → **Do NOT attempt to resume the repeated task.**
> → Instead: acknowledge the loop to the user, summarize what context was accumulated
> across the repeated sessions, and ask: "What would you like to do?"
>
> Never assume the correct action is to retry a failed/partial task silently.
### Fix 3: memory_announce_focus — Wait for User Input
Add a constraint to Rule 3 (announce focus):
> **`memory_announce_focus()` must reflect the CURRENT session's task.**
> Call it only AFTER the user has given a clear instruction for this conversation.
> Do NOT announce focus derived from prior session outcomes before the user speaks.
> During the startup ritual (steps 1-4 of Rule 1), use a placeholder focus if needed:
> `memory_announce_focus(session_id, "Awaiting user task assignment")`
### Fix 4: Mode Interaction Safety Clause
Add a universal safety rule (applies to all modes):
> **Session ritual completion ≠ task authorization.**
> Completing `memory_start_session()` + `memory_list_hypotheses()` + `memory_announce_focus()`
> does NOT authorize beginning any task. Work begins only when the user explicitly assigns it
> in the current conversation. Prior session context is reference material, not instruction.
---
## Files to Change
| File | Change |
|------|--------|
| `.roo/rules/01-bigmind-core.md` | Add Rule 9, add partial=history clause to Rule 1, add focus guard to Rule 3 |
| `.roo/rules/00-identity.md` | Add mode-interaction safety clause |
---
## Risk Assessment
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Model ignores new rules in long context | Medium | Rules are loaded via rules files, not context — they apply per-session |
| Fix breaks legitimate resumption (e.g., user explicitly asks to continue) | Low | Rules say "task determined by user's first message" — explicit resumption request still works |
| New Rule 9 fires falsely on legitimate repeated partial tasks | Low | Trigger requires near-identical headlines AND repeated partial — normal work produces diverse headlines |
---
## Success Criteria
1. Starting a new session after a partial pic-gen session → model waits for user input, no autonomous generation
2. Starting a new session after 2+ identical partial sessions → model acknowledges the loop and asks what to do
3. User explicitly asking "continue the branding generation" → model correctly resumes (rule only prevents silent resumption)
---
## Implementation Order
1. Patch `.roo/rules/01-bigmind-core.md` — add Rule 9 + partial=history clause + focus guard
2. Patch `.roo/rules/00-identity.md` — add mode interaction safety clause
3. Test by starting a new session in pic-gen mode with partial history in context
4. Push to Gitea
@@ -0,0 +1,227 @@
# CannaManage — Project Charter
**Author:** Patrick Plate
**Date:** 2026-04-06
**Version:** 1.0
**Status:** Draft for Review
---
## 1. Executive Summary
### Vision Statement
> *CannaManage is the compliance backbone for German cannabis social clubs — purpose-built to turn a legally mandated administrative burden into a manageable, auditable, and digitised workflow.*
### The Problem
Germany's **Konsumcannabisgesetz (CanG)**, in force since April 1, 2024, legalised cannabis for personal use and established a framework for **Anbauvereinigungen** (cannabis social clubs / CSCs). Every operating CSC faces mandatory, recurring compliance obligations:
- Track every distribution (recipient, strain, weight, date/time) — by law
- Enforce quantity limits per member (50g/month for adults, 30g/month for under-21, 25g/day)
- Maintain batch-level contamination traceability
- Produce periodic authority reports
- Designate and track a Prevention Officer (Präventionsbeauftragter)
- Manage member data under DSGVO
Clubs currently manage this with Excel spreadsheets, pen-and-paper logs, and WhatsApp groups — creating legal risk, audit gaps, and administrative chaos.
### Why Now
The market is less than two years old. **No purpose-built software tooling exists** for German CSCs. The window to establish market leadership is 20262027 before larger players notice the niche. First-mover advantage combined with the permanent regulatory moat from CanG compliance requirements makes this the right moment.
### What We Are Building
A **multi-tenant B2B SaaS platform** offering:
- Club admin portal (member management, distribution logging, stock management, compliance reporting)
- Member portal (personal quota, distribution history, stock visibility)
- Built-in CanG compliance enforcement and export tooling
**We are selling compliance management software to licensed, regulated entities. We are not in the cannabis business.**
---
## 2. Project Scope
### 2.1 In Scope — MVP v1
| Area | Features Included |
|------|-------------------|
| **Onboarding** | Club registration, setup wizard, admin account creation |
| **Member Management** | Add/remove members, age verification (18+, 1821 restricted), contact data |
| **Distribution Tracking** | Log each handout (member, strain, weight, date/time); enforce daily/monthly limits |
| **Limit Enforcement** | 25g/day cap, 50g/month (adult), 30g/month (under-21), 10% THC flag |
| **Stock Management** | Strains, batch tracking, quantity levels |
| **Admin Dashboard** | Club-level totals: members, distributions this month, stock levels |
| **Compliance Exports** | Monthly distribution report (PDF + CSV), member list export for inspections |
| **Contamination Recall** | Flag a batch; system lists all members who received from it |
| **Prevention Officer** | Store officer contact info and designation date |
| **Member Portal** | Login with club-issued credentials; view quota, distribution history, stock availability |
| **Authentication** | Spring Security + JWT; role-based (ADMIN, MEMBER) |
| **Hosting** | Hetzner VPS (German DC), Docker Compose, PostgreSQL + Flyway |
### 2.2 Explicitly Out of Scope — MVP v1
| Feature | Reason Excluded |
|---------|-----------------|
| Public club discovery / "find clubs near you" | **Illegal under CanG §§67 advertising ban** |
| Cannabis e-commerce or payment for cannabis | Illegal; violates positioning |
| Non-EU data storage (AWS us-east, etc.) | DSGVO violation |
| Stripe subscription billing | Deferred to Phase 1 (Weeks 916) |
| Email/SMS notifications | v2 feature |
| Mobile native app (Android/iOS) | v2/v3 feature |
| Multi-location club support | v3 feature |
| Legal template marketplace | v3 feature |
| Next.js/React frontend | v2 migration after revenue justifies investment |
| Authority portal integrations | v3 feature (portals don't exist yet) |
---
## 3. Stakeholders
| Role | Description | Needs |
|------|-------------|-------|
| **Club Admin** *(primary user)* | Vereinsvorstand or designated manager; runs day-to-day club operations | Compliant distribution logging, member management, authority-ready exports |
| **Club Member** *(secondary user)* | Verified adult member of the Anbauvereinigung | Self-service quota visibility, distribution history, stock availability |
| **Prevention Officer** *(Präventionsbeauftragter, tertiary user)* | Legally required role; may or may not be the admin | Contact info tracked in system; receives relevant reports |
| **Patrick Plate** *(developer & product owner)* | Solo developer; nights/weekends; ADP Germany full-time | Minimal learning overhead; fast path to first revenue; legally sound product |
---
## 4. Success Criteria
MVP is considered complete when all of the following are true:
| # | Criterion | Measure |
|---|-----------|---------|
| 1 | **Core compliance loop working** | Admin can log a distribution → system enforces limits → admin exports PDF report for authorities |
| 2 | **Multi-tenant isolation** | Two clubs' data are completely isolated — no cross-tenant data leakage |
| 3 | **Member portal live** | Member can log in with club-issued credentials and view their quota + history |
| 4 | **Contamination recall functional** | Admin flags a batch; system returns full recipient list in < 2 seconds |
| 5 | **Deployment stable** | Platform runs on Hetzner VPS via Docker Compose with uptime ≥ 99% over 30-day beta |
| 6 | **Beta validation** | 35 real club admins have used the system and provided written feedback |
| 7 | **Legal review passed** | No features violate CanG advertising ban; DSGVO AVV in place before any live data |
| 8 | **Zero PII on non-EU infrastructure** | All data confirmed to reside in Hetzner DE datacenter |
---
## 5. Constraints & Assumptions
### Constraints
| Type | Constraint |
|------|-----------|
| **Legal** | CanG §§67 imposes a **total advertising and sponsoring ban** on cannabis AND Anbauvereinigungen — no public club discovery feature, ever |
| **Legal** | DSGVO requires EU hosting, data processing agreements (AVV), member data export/deletion capability |
| **Technical (MVP)** | Frontend is PrimeFaces + JSF — Patrick's existing expertise; no new framework learning in Phase 0 |
| **Technical** | Multi-tenancy via `tenant_id` on all JPA entities — no row-level security shortcuts |
| **Team** | Solo developer — Patrick; nights and weekends only; full-time at ADP Germany |
| **Timeline** | Phase 0 target: 8 weeks; Phase 1 target: 16 weeks total from project start |
| **Budget** | Infrastructure: Hetzner €520/month; no team salary cost |
### Assumptions
- German CSCs are willing to pay €29–€79/month for compliance software
- Stripe will process subscriptions for compliance software (not cannabis sales) without restriction
- Spring Boot 3.x is sufficiently adjacent to Patrick's Jakarta EE expertise to use without major ramp-up
- PrimeFaces MVP is sufficient for beta validation — UI polish deferred to v2
- CanG remains in force and CSC licensing continues in all major Bundesländer
---
## 6. Risk Register
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| **Advertising ban reinterpreted to include B2B SaaS** | Low | High | Obtain legal opinion from cannabis law specialist before launch (€300500); strict no-discovery design enforced at architecture level |
| **New German government rolls back or tightens CanG** | Medium | High | Modular architecture — compliance-only features can be extracted and pivoted to a general club management tool |
| **Stripe blocks cannabis-adjacent businesses** | Medium | High | Position as "Vereinsverwaltungs-Software" (club management software); never process cannabis payments; test with Stripe before public launch |
| **Clubs fail / licenses revoked** | Medium | Medium | Diversified customer base; per-month billing (easy cancellation); no annual lock-in required for MVP |
| **DSGVO violation** | Low | Very High | EU-only hosting (Hetzner DE), DPA/AVV agreements before any live data, DSGVO-compliant privacy policy in German, member data export/deletion API from day one |
---
## 7. Budget & Resources
| Item | Cost | Notes |
|------|------|-------|
| **Development** | €0 (Patrick's time) | Nights/weekends; valued at opportunity cost only |
| **Infrastructure — Hetzner VPS** | €520/month | German DC; scales with load |
| **Infrastructure — PostgreSQL** | €0 (self-hosted on VPS) | Managed DB upgrade available when needed |
| **Legal opinion** | €300500 (one-time) | Cannabis law specialist; pre-launch requirement |
| **Domain (cannamanage.de)** | ~€15/year | To be registered |
| **Stripe fees** | 1.4% + €0.25 per transaction | EU cards; only on paid subscriptions |
| **Email (Resend / Jakarta Mail)** | €010/month | Resend free tier for low volume |
| **Sentry monitoring** | €0 (free tier) | Error tracking; Java SDK |
| **Total pre-launch** | **~€600700** | Including legal opinion |
---
## 8. Timeline Overview
```mermaid
gantt
title CannaManage Development Roadmap
dateFormat YYYY-MM-DD
axisFormat %b %Y
section Phase 0 — Foundation
Spring Boot setup + JPA entities :p0a, 2026-04-07, 2w
Core REST API (member, distribution) :p0b, after p0a, 2w
Admin portal PrimeFaces :p0c, after p0b, 2w
Limit enforcement + PDF report :p0d, after p0c, 2w
section Phase 1 — MVP
Member portal :p1a, after p0d, 2w
Stock management + contamination recall :p1b, after p1a, 2w
Stripe billing integration :p1c, after p1b, 2w
DSGVO + beta launch (5 clubs) :p1d, after p1c, 2w
section Phase 2 — Launch
Payment flows + email notifications :p2a, after p1d, 4w
Marketing site + legal review :p2b, after p2a, 4w
Soft launch to club community :milestone, after p2b, 0d
section Phase 3 — Growth
PrimeFaces → Next.js migration :p3a, 2026-12-01, 8w
PWA mobile :p3b, after p3a, 4w
Template marketplace + referral :p3c, after p3b, 8w
```
---
## 9. Legal Framework
### Key CanG Provisions
| Provision | Content | Product Implication |
|-----------|---------|---------------------|
| **§2 CanG** | Definitions — Anbauvereinigung, Mitglied | Data model must align with statutory definitions of club and member |
| **§§1526 CanG** | Anbauvereinigungen — formation, rights, obligations | Club registration flow must capture legally required club attributes |
| **§22 CanG** | Distribution limits: 25g/day, 50g/month per adult member | Hard enforcement in distribution service; cannot be overridden by admin |
| **§23 CanG** | Under-21 restrictions: 30g/month max, max 10% THC | Age flag on member entity; separate limit enforcement path for restricted category |
| **§§67 CanG** | **Total advertising and sponsoring ban** for cannabis and Anbauvereinigungen | **No public club discovery. No stock visible to non-members. No club listings.** Architecture constraint. |
| **§26 CanG** | Documentation and reporting obligations | Compliance export module is a legal requirement, not an optional feature |
| **§27 CanG** | Prevention officer requirements | Prevention officer fields mandatory in club setup; not optional |
### DSGVO Obligations
- All personal data stored on EU infrastructure (Hetzner DE)
- Data processing agreement (AVV) required with each club before live data entry
- Member data export endpoint required (Art. 20 DSGVO — data portability)
- Member data deletion endpoint required (Art. 17 DSGVO — right to erasure)
- Privacy policy in German, DSGVO-compliant, published before launch
---
## 10. Sign-Off
| Role | Name | Date |
|------|------|------|
| **Project Sponsor** | Patrick Plate | 2026-04-06 |
| **Lead Developer** | Patrick Plate | 2026-04-06 |
| **Product Owner** | Patrick Plate | 2026-04-06 |
---
*Next review date: 2026-05-01 | Source: [STRATEGY.md](../STRATEGY.md)*
@@ -0,0 +1,467 @@
# CannaManage — User Stories & Acceptance Criteria
**Author:** Patrick Plate
**Date:** 2026-04-06
**Version:** 1.0
**Status:** Draft for Review
---
## 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 |
---
## Must Have — MVP v1
### Club Admin Stories
---
### US-001: Register Club and Complete Setup Wizard
**As a** Club Admin, **I want to** register my Anbauvereinigung and complete a guided setup wizard, **so that** my club is correctly configured with all legally required attributes before any members are added.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can register with email + password; email confirmation required before accessing the system
- [ ] AC2: Setup wizard collects: club name, registered address, founding date, Vereinsregisternummer (if available), maximum membership count
- [ ] AC3: Wizard requires designation of a Prevention Officer (name, contact) — field is mandatory, cannot be skipped
- [ ] AC4: Wizard requires acceptance of DSGVO data processing agreement (AVV) before any member data can be entered
- [ ] AC5: Completing the wizard provisions the club's isolated tenant environment (all subsequent data scoped to this club only)
- [ ] AC6: Admin receives a welcome email with login link after successful setup
- [ ] AC7: Incomplete wizard state is saved — admin can resume from last completed step
**Notes:** The AVV acceptance (AC4) is a legal prerequisite for handling member personal data under DSGVO. It must be timestamped and stored.
---
### US-002: Add and Remove Members with Age Verification
**As a** Club Admin, **I want to** add and remove club members with age verification, **so that** the member roster is accurate and the system can apply the correct distribution limits per member.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can add a member with: full name, date of birth, email (optional), membership start date, member ID (auto-generated or manual)
- [ ] AC2: System rejects members with date of birth indicating age < 18
- [ ] AC3: Members aged 1821 are automatically flagged as "Restricted (§23 CanG)" — this flag drives reduced quantity limits
- [ ] AC4: Admin can deactivate (soft-delete) a member; deactivated members cannot receive distributions but their historical records are preserved
- [ ] AC5: Admin can permanently delete a member record (DSGVO Art. 17 right to erasure); system warns if member has distribution history and requires explicit confirmation
- [ ] AC6: Member list is searchable by name and filterable by status (active / restricted / deactivated)
- [ ] AC7: Total active member count is visible on the dashboard and in the member list header
**Notes:** Hard deletion (AC5) must cascade correctly — distribution records referencing the member must be anonymised, not deleted, to preserve the compliance audit trail.
---
### US-003: Record a Distribution
**As a** Club Admin, **I want to** record each cannabis distribution to a member, **so that** every handout is documented as required by §26 CanG and the member's consumption is tracked.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can log a distribution by selecting: member (search/autocomplete), strain, weight in grams (decimal, e.g. 3.5g), batch, date and time
- [ ] AC2: System pre-fills date/time with current timestamp; admin can override
- [ ] AC3: If the distribution would cause the member to exceed their daily limit (25g), the system displays a prominent warning and requires explicit override confirmation
- [ ] AC4: If the distribution would cause the member to exceed their monthly limit (50g adult / 30g restricted), the system **blocks** the entry and displays the reason
- [ ] AC5: For restricted members (§23), system additionally validates that the selected strain's THC percentage is ≤ 10% (if THC% is recorded on the batch)
- [ ] AC6: Successfully saved distributions appear immediately in the distribution log and update the member's monthly counter
- [ ] AC7: Distribution records are immutable after creation — admin can only add a correction note, not edit the original record
**Notes:** Immutability (AC7) is essential for audit integrity. Correction notes are the appropriate mechanism for errors.
---
### US-004: View and Enforce Distribution Limits
**As a** Club Admin, **I want to** view each member's current distribution totals and remaining quota, **so that** I can verify limits at a glance before and after recording distributions.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Each member's detail view shows: distributions this month (total grams), daily total for today, remaining monthly quota, and limit category (Adult 50g / Restricted 30g)
- [ ] AC2: Remaining quota is displayed as a progress bar (visual indicator of how close to the limit)
- [ ] AC3: Members who have reached or exceeded their monthly limit are visually flagged in the member list (e.g., red badge)
- [ ] AC4: Members who have consumed > 80% of their monthly limit show a warning indicator (e.g., amber badge)
- [ ] AC5: Monthly counters reset automatically on the first of each calendar month
- [ ] AC6: System applies §22 limits (50g/month, 25g/day) for adults and §23 limits (30g/month) for restricted members — these cannot be changed by the admin
**Notes:** The limits in AC6 are statutory and must be hardcoded, not configurable per club.
---
### US-005: Manage Stock (Strains, Quantities, Batches)
**As a** Club Admin, **I want to** manage my club's cannabis stock including strains, batch information, and quantities, **so that** I know what is available for distribution and can track batch provenance for contamination purposes.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can create a strain with: name, THC% (optional), CBD% (optional), variety type (Indica/Sativa/Hybrid)
- [ ] AC2: Admin can create a batch linked to a strain with: batch ID (auto-generated), quantity in grams, harvest date (optional), grow cycle reference (optional)
- [ ] AC3: Each distribution recorded reduces the associated batch's available quantity
- [ ] AC4: Admin can manually adjust stock quantity with a reason note (e.g., "lab sample", "disposal")
- [ ] AC5: Admin is warned (but not blocked) when a batch's available quantity drops below a configurable threshold (default: 100g)
- [ ] AC6: Stock overview page shows all active batches with: strain name, batch ID, quantity available, quantity distributed to date
- [ ] AC7: Depleted batches (quantity = 0) are automatically moved to an "archived" view
**Notes:** Batch tracking is required for contamination recall (US-009). The batch ID must be immutable once created.
---
### US-006: View Admin Dashboard
**As a** Club Admin, **I want to** see a summary dashboard when I log in, **so that** I have an at-a-glance overview of club activity and can identify anything requiring attention.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Dashboard displays: total active members, members at/near their monthly limit (count), total distributions this calendar month (grams), active stock level (total grams across all batches)
- [ ] AC2: Dashboard shows a count of members in the "restricted §23" category separately
- [ ] AC3: Dashboard highlights any batches flagged as contaminated (contamination alert count)
- [ ] AC4: Dashboard includes a recent activity feed (last 10 distributions: member name, strain, weight, time)
- [ ] AC5: All dashboard data reflects the admin's own club only — never cross-tenant data
- [ ] AC6: Dashboard loads in < 3 seconds on Hetzner VPS hardware
**Notes:** Keep the dashboard simple for MVP — a single page with widgets. No charts required for v1.
---
### US-007: Export Monthly Compliance Report (PDF + CSV)
**As a** Club Admin, **I want to** export a monthly compliance report as PDF and CSV, **so that** I can fulfil my documentation and reporting obligations under §26 CanG.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can select any calendar month/year and generate a compliance report
- [ ] AC2: PDF report contains: club name, reporting period, total distributions (count and weight), distribution detail table (member ID, strain, batch, weight, date/time), stock summary
- [ ] AC3: Member names in the PDF are replaced with member IDs to minimise PII exposure in the report document (actual name lookup available to the club separately)
- [ ] AC4: CSV export contains full distribution log for the selected period with headers: member_id, strain, batch_id, weight_g, distribution_date, distribution_time
- [ ] AC5: PDF is generated server-side using iText 7 (no client-side rendering dependency)
- [ ] AC6: Export completes in < 10 seconds for a month with up to 5,000 distribution records
- [ ] AC7: Generated reports are not stored on the server — they are streamed directly to the browser as a download
**Notes:** Not storing reports (AC7) reduces data exposure risk. The club is responsible for retaining their own copies.
---
### US-008: Export Member List for Inspections
**As a** Club Admin, **I want to** export the current member list, **so that** I can present it to authorities during an inspection as required by law.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can export the active member list as PDF and CSV at any time
- [ ] AC2: Export includes: member ID, full name, date of birth, age category (Adult/Restricted §23), membership start date, current membership status
- [ ] AC3: Export is timestamped with the generation date/time in the document
- [ ] AC4: Admin is shown a DSGVO reminder before downloading (this document contains personal data — handle per your privacy obligations)
- [ ] AC5: Export includes the club name and address in the header
- [ ] AC6: Only active members are included by default; admin can optionally include deactivated members
**Notes:** This document contains significant PII. The DSGVO reminder (AC4) is important to keep admins legally aware.
---
### US-009: Trigger Contamination Alert for a Batch
**As a** Club Admin, **I want to** flag a batch as contaminated and immediately see all members who received from it, **so that** I can notify affected members and fulfil my contamination traceability obligations under CanG.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can mark any batch as "contaminated" with a reason note and timestamp
- [ ] AC2: Immediately upon flagging, system displays a list of all members who received distributions from the contaminated batch (name, member ID, total grams received, dates received)
- [ ] AC3: Contaminated batches are removed from the active distribution interface — admin cannot select them for new distributions
- [ ] AC4: The dashboard shows a contamination alert badge whenever any active batch is flagged
- [ ] AC5: Admin can export the affected member list as PDF and CSV (for authority notification)
- [ ] AC6: Contamination status is immutable — once flagged, only a senior action (with confirmation) can reverse it; reversal is logged with reason
**Notes:** Contamination traceability is explicitly required by CanG. Response speed matters — the affected member list (AC2) must display without delay.
---
### US-010: Manage Prevention Officer Information
**As a** Club Admin, **I want to** record and update Prevention Officer (Präventionsbeauftragter) information, **so that** my club meets the mandatory requirement of §27 CanG.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Club profile includes a Prevention Officer section with fields: full name, contact email, contact phone, designation date
- [ ] AC2: All four fields are required — the system warns if any is empty and marks the section as incomplete
- [ ] AC3: Admin can update the Prevention Officer at any time; previous officer entries are retained in a change log (name, designation period)
- [ ] AC4: The compliance report export (US-007) includes the current Prevention Officer name and contact in its header
- [ ] AC5: Setup wizard (US-001) cannot be completed without entering Prevention Officer information
**Notes:** This is a statutory requirement, not optional. AC5 enforces that clubs cannot operate on the platform without this data.
---
### Member Portal Stories
---
### US-011: Login with Club-Issued Credentials
**As a** Club Member, **I want to** log in to the member portal using credentials issued by my club, **so that** I can access my personal information without the club admin needing to be present.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Admin can generate login credentials (username + temporary password) for a member from the member management screen
- [ ] AC2: Member receives credentials via a secure channel (displayed to admin for manual handoff in MVP; email in v2)
- [ ] AC3: Member is required to change their temporary password on first login
- [ ] AC4: Member login is scoped to their club only — they cannot access any other club's data or member list
- [ ] AC5: Failed login attempts are rate-limited (5 attempts, then 15-minute lockout)
- [ ] AC6: Member sessions expire after 24 hours of inactivity
- [ ] AC7: Members cannot register themselves — accounts are always created by the Club Admin
**Notes:** AC7 is critical for CanG compliance — only verified, age-checked members should have portal access.
---
### US-012: View Personal Distribution History
**As a** Club Member, **I want to** view my personal distribution history, **so that** I can track what I have received from the club.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Member can view all their distributions in reverse chronological order: date/time, strain, weight (grams), batch ID
- [ ] AC2: Current calendar month distributions are shown first, with a clear monthly subtotal
- [ ] AC3: Member can filter history by month/year
- [ ] AC4: Member sees only their own distribution history — no other member's data is accessible
- [ ] AC5: History is read-only — members cannot edit or delete distribution records
---
### US-013: View Current Stock Availability
**As a** Club Member, **I want to** see what strains are currently available at the club, **so that** I know what I can request on my next visit.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Member portal shows a stock list with: strain name, variety type (Indica/Sativa/Hybrid), THC% (if recorded), availability status (Available / Low Stock / Unavailable)
- [ ] AC2: Exact batch quantities are NOT shown to members — only availability status
- [ ] AC3: Only strains with available stock (quantity > 0) are shown as "Available"
- [ ] AC4: Strains with stock below the admin-configured low-stock threshold are shown as "Low Stock"
- [ ] AC5: For restricted members (§23 CanG), strains with THC > 10% are shown with a "Not available to you" indicator rather than hidden (transparency about why)
- [ ] AC6: Stock view is refreshed in real time — no stale cache longer than 5 minutes
**Notes:** AC2 is important — showing exact quantities could constitute advertising for the club's stock. Only availability status is shown.
---
### US-014: View Remaining Monthly Quota
**As a** Club Member, **I want to** see my remaining monthly quota, **so that** I can plan my distributions and stay within my legal limits.
**Priority:** Must Have
**Acceptance Criteria:**
- [ ] AC1: Member portal homepage prominently displays: consumed this month (grams), remaining quota (grams), monthly limit (grams), days remaining in current month
- [ ] AC2: Quota is displayed as a progress bar with colour coding: green (< 50% used), amber (5080% used), red (> 80% used)
- [ ] AC3: Members in the restricted §23 category see their 30g/month limit (not the 50g adult limit)
- [ ] AC4: Daily limit status is also visible: consumed today (grams) vs. 25g daily cap
- [ ] AC5: Quota resets display on the first of each calendar month — confirmed visually (e.g., "Resets in X days")
---
## Should Have — v2
---
### US-015: Process Membership Fee Payments via Stripe
**As a** Club Admin, **I want to** collect membership fees from members via Stripe, **so that** fee collection is automated and documented without manual bank transfers.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Admin can configure an annual membership fee amount for their club
- [ ] AC2: Members can pay via Stripe-hosted checkout (card payment)
- [ ] AC3: Stripe subscription or one-time payment for annual fee — admin configures which model
- [ ] AC4: Payment confirmation is logged against the member record with date and amount
- [ ] AC5: Admin can view payment status per member (paid / pending / overdue)
- [ ] AC6: No cannabis product payments are ever processed through this system — fee is for club membership only
**Notes:** Stripe position: membership fees for registered non-profit clubs (Vereinsbeiträge) are standard use case. AC6 must be enforced at system design level.
---
### US-016: Manage Automated Waiting List
**As a** Club Admin, **I want to** manage a waiting list for new membership applicants, **so that** I can process applications in order while respecting the club's maximum membership count.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Admin can set a maximum member count for the club (from setup wizard or settings)
- [ ] AC2: When member count reaches maximum, new applicants are added to a waiting list with timestamp
- [ ] AC3: Waiting list is FIFO — applicants are offered membership in order of application
- [ ] AC4: Admin can notify the next waiting list applicant (email notification — v2 dependency)
- [ ] AC5: Admin can remove applicants from the waiting list
- [ ] AC6: Waiting list count is visible on the admin dashboard
---
### US-017: Receive Email and SMS Notifications
**As a** Club Member, **I want to** receive email (and optionally SMS) notifications for key events, **so that** I am informed without needing to log in to the portal.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Member receives email notification when their distribution is recorded by the admin
- [ ] AC2: Member receives email when their monthly quota reaches 80% consumed
- [ ] AC3: Member receives email when a batch they received from is flagged as contaminated
- [ ] AC4: Admin receives email when any member's quota is exceeded (should not happen, but safety net)
- [ ] AC5: SMS notifications are optional and require member opt-in; email is default
- [ ] AC6: All notification emails are sent in German (language is not configurable in v2)
- [ ] AC7: Members can manage notification preferences (opt out of non-mandatory notifications)
---
### US-018: Track Multi-Strain Grow Cycles
**As a** Club Admin, **I want to** track grow cycles linked to batches, **so that** I have full provenance from grow start to distribution.
**Priority:** Should Have
**Acceptance Criteria:**
- [ ] AC1: Admin can create a grow cycle with: cycle ID, strain, start date, expected harvest date, grow area (optional), notes
- [ ] AC2: Batches can be linked to a grow cycle
- [ ] AC3: Grow cycle view shows: all batches produced, total yield, grow duration
- [ ] AC4: Closed grow cycles (harvest complete) are archived but remain searchable
- [ ] AC5: Grow cycle data is included in the monthly compliance report (batch provenance section)
---
## Could Have — v3
---
### US-019: Access Mobile PWA
**As a** Club Member, **I want to** use CannaManage on my smartphone without installing an app, **so that** I can check my quota and stock on the go.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: The member portal is fully responsive and usable on mobile viewport sizes (320px and up)
- [ ] AC2: The app can be added to the home screen (PWA manifest, service worker, offline cache for quota display)
- [ ] AC3: Core member portal features (quota, distribution history, stock view) work in offline mode with cached data
- [ ] AC4: Admin portal is also responsive (admin-on-the-go distribution logging)
- [ ] AC5: No app store submission required — pure PWA
---
### US-020: Support Multi-Location Club
**As a** Club Admin, **I want to** manage a club with multiple distribution locations, **so that** members can pick up from different sites and all distributions are consolidated.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: Admin can define multiple locations (name, address) for one club
- [ ] AC2: Distributions are recorded with a location tag
- [ ] AC3: Stock is managed per location or shared — admin configures which model
- [ ] AC4: Compliance reports can be generated per location or consolidated for the whole club
- [ ] AC5: Members are assigned a primary location but can receive from any location within quota limits
---
### US-021: Download Legal Document Templates
**As a** Club Admin, **I want to** download standardised legal document templates (Satzung, Jugendschutzkonzept), **so that** I can fulfil my legal obligations without hiring a lawyer for every document.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: Template library is accessible from the admin portal (separate from compliance exports)
- [ ] AC2: Available templates include: Vereinssatzung (club charter), Jugendschutzkonzept (youth protection concept), DSGVO Datenschutzerklärung
- [ ] AC3: Templates are pre-filled with club-specific data (name, address, Prevention Officer) where applicable
- [ ] AC4: Templates are available as DOCX (editable) and PDF (final version)
- [ ] AC5: Template library is a paid add-on (€49 one-time or included in Professional/Enterprise plan)
---
### US-022: Integrate with Authority Reporting Portals
**As a** Club Admin, **I want to** submit compliance reports directly to authority portals via CannaManage, **so that** I save time and avoid transcription errors in authority submissions.
**Priority:** Could Have
**Acceptance Criteria:**
- [ ] AC1: System can detect available authority portals by Bundesland (state)
- [ ] AC2: Admin can initiate a report submission from within CannaManage
- [ ] AC3: Submission status is tracked (submitted, acknowledged, rejected) per report
- [ ] AC4: System retries failed submissions automatically (up to 3 times)
- [ ] AC5: This feature is only activated once at least one Bundesland has a machine-readable submission portal
**Notes:** Authority portals may not exist in v3 timeline — this is aspirational and depends on government digitalisation progress.
---
## Won't Have — MVP (Explicitly Excluded)
---
### US-023: Public Club Discovery — "Find Clubs Near You"
**As a** Public User, I want to find cannabis clubs near my location.
**Priority:** Won't Have (MVP)
**Reason:** **Explicitly illegal under CanG §§67.** The advertising and sponsoring ban covers any feature that functions as advertising for Anbauvereinigungen to the general public. A public club directory constitutes advertising for clubs. This feature will never be built in any form on this platform.
**Acceptance Criteria:** *None — this feature is permanently excluded.*
**Notes:** This is not a commercial decision. It is a **legal constraint** hardcoded into the product architecture. No public-facing club listing, no map, no search, no "register your club publicly."
---
### US-024: Cannabis E-Commerce or Payment for Cannabis Products
**As a** Club Member, I want to purchase cannabis through the CannaManage platform.
**Priority:** Won't Have (MVP)
**Reason:** **Illegal.** Cannabis sales are not the legal model for Anbauvereinigungen under CanG. Payment for cannabis products would violate German law and immediately trigger Stripe account termination. CannaManage processes membership fee payments only — not cannabis product payments, ever.
**Acceptance Criteria:** *None — permanently excluded.*
---
### US-025: Non-EU Data Storage
**As a** Club Admin, I want my club's data stored on the cheapest/fastest infrastructure, including non-EU servers.
**Priority:** Won't Have (MVP)
**Reason:** **DSGVO violation.** Club member data includes personal data (name, date of birth, consumption records). Storing this outside the EU without a valid adequacy decision or standard contractual clauses violates Art. 4449 DSGVO. All data remains on Hetzner DE datacenters.
**Acceptance Criteria:** *None — permanently excluded.*
---
## Acceptance Criteria Traceability Matrix
| Story | Role | Phase | Legal Basis | Key Risk |
|-------|------|-------|-------------|----------|
| US-001 | Club Admin | MVP | DSGVO (AVV) | Clubs operating without AVV |
| US-002 | Club Admin | MVP | §2223 CanG | Under-21 age verification gaps |
| US-003 | Club Admin | MVP | §26 CanG | Distribution limit bypass |
| US-004 | Club Admin | MVP | §2223 CanG | Incorrect limit category applied |
| US-005 | Club Admin | MVP | §26 CanG (batch traceability) | Inaccurate stock → wrong quota available |
| US-006 | Club Admin | MVP | — | Cross-tenant data leak |
| US-007 | Club Admin | MVP | §26 CanG | Incomplete report → authority rejection |
| US-008 | Club Admin | MVP | §26 CanG | Outdated member list at inspection |
| US-009 | Club Admin | MVP | CanG (contamination traceability) | Delayed recall notification |
| US-010 | Club Admin | MVP | §27 CanG | Missing officer → club licence risk |
| US-011 | Club Member | MVP | DSGVO | Unauthorised member account creation |
| US-012 | Club Member | MVP | DSGVO (Art. 15 access) | Cross-member data exposure |
| US-013 | Club Member | MVP | §§67 CanG (no advertising) | Over-disclosure of stock data |
| US-014 | Club Member | MVP | §2223 CanG | Member unaware of impending limit breach |
| US-015 | Club Admin | v2 | — | Stripe cannabis-adjacent policy |
| US-016 | Club Admin | v2 | — | Waiting list ordering errors |
| US-017 | Club Member | v2 | DSGVO (email marketing consent) | Spam / opt-out compliance |
| US-018 | Club Admin | v2 | §26 CanG (provenance) | Batch-grow linkage gaps |
| US-019 | Club Member | v3 | — | Offline cache staleness |
| US-020 | Club Admin | v3 | — | Stock isolation complexity |
| US-021 | Club Admin | v3 | — | Template legal accuracy |
| US-022 | Club Admin | v3 | §26 CanG | Portal API non-existence |
| US-023 | *(none)* | Never | **Illegal §§67 CanG** | Platform shutdown risk |
| US-024 | *(none)* | Never | **Illegal** | Stripe termination + criminal liability |
| US-025 | *(none)* | Never | **DSGVO Art. 4449** | Regulatory fine + club data breach |
---
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
@@ -0,0 +1,504 @@
# 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 · PrimeFaces JSF MVP → Next.js v2
**Last updated:** 2026-04-06
---
## 1. Architecture Overview
```mermaid
graph TD
AdminBrowser["🖥️ Browser — Admin Portal"]
MemberBrowser["🖥️ Browser — Member Portal"]
JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"]
AdminBrowser -->|HTTP/S| JSF
MemberBrowser -->|HTTP/S| JSF
JSF -->|REST calls| Backend
subgraph Backend ["☕ Spring Boot 3.x 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"]
REST --> Service
Service --> JPA
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)"]
Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG
subgraph Hetzner ["🖧 Hetzner VPS — Docker Compose"]
Backend
PG
Nginx["🔒 Nginx\n(reverse proxy + TLS)"]
end
JSF --> Nginx
Nginx --> Backend
```
### Component Responsibilities
| Component | Technology | Role |
|---|---|---|
| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI |
| Member Portal | PrimeFaces JSF (→ 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 |
| 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 |
| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment |
---
## 2. Multi-Tenancy Strategy
### Approach: Shared Schema with Row-Level Filtering
Every JPA entity carries a `tenant_id` column (UUID, `NOT NULL`). A single PostgreSQL database hosts all clubs — row-level filtering enforces data isolation at the application layer.
**Why shared schema (not separate schema/DB per tenant)?**
- Lower operational overhead for an MVP with < 500 clubs
- Single Flyway migration path across all tenants
- Simpler connection pooling (one pool, not N)
- Acceptable security risk when `tenant_id` filter is enforced at the service layer
### Tenant Resolution
```
HTTP Request
└─ Spring Security Filter: extract JWT → resolve tenant_id
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
└─ JPA @Where filter applied on every entity query
```
### Code Pattern — Tenant-Aware Base Entity
```java
// AbstractTenantEntity.java (pseudocode)
@MappedSuperclass
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = UUID.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class AbstractTenantEntity {
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@PrePersist
void injectTenant() {
this.tenantId = TenantContext.getCurrentTenant();
}
}
```
```java
// TenantFilterInterceptor.java (pseudocode)
@Component
public class TenantFilterInterceptor implements HandlerInterceptor {
@Autowired EntityManager em;
@Override
public boolean preHandle(HttpServletRequest req, ...) {
UUID tenantId = TenantContext.getCurrentTenant();
Session session = em.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
return true;
}
}
```
**Invariants enforced:**
- `tenant_id` is set at `@PrePersist` — never accepted from user input
- `tenant_id` is `updatable = false` — cannot be changed after creation
- Hibernate filter is enabled on every request thread before any query executes
- All repository methods inherit the filter; raw JPQL queries must include `AND e.tenantId = :tenantId`
---
## 3. Authentication & Authorization
### JWT Token Flow
- **Access token:** 8-hour expiry, signed HS256, contains `sub` (userId), `role`, `tenantId`
- **Refresh token:** 30-day expiry, stored in `users.refresh_token_hash` (hashed)
- **Stateless:** No server-side session. JWT is verified on every request by `JwtAuthFilter`
### Roles
| Role | Description | Access |
|---|---|---|
| `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions |
| `ROLE_MEMBER` | Club member | Own quota, own distribution history |
| `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data |
### Service-Layer Authorization Example
```java
@Service
public class DistributionService {
@PreAuthorize("hasRole('CLUB_ADMIN')")
public Distribution recordDistribution(RecordDistributionRequest req) { ... }
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
public QuotaStatus getMyQuota(UUID memberId) { ... }
@PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')")
public List<Member> getUnder21Members() { ... }
}
```
### Member Login Sequence
```mermaid
sequenceDiagram
participant B as Browser
participant API as Spring Boot /api/v1/auth/login
participant DB as PostgreSQL (users table)
participant JWT as JwtService
B->>API: POST /api/v1/auth/login {email, password}
API->>DB: SELECT * FROM users WHERE email = ? AND active = true
DB-->>API: UserEntity (password_hash, role, tenant_id, member_id)
API->>API: BCrypt.verify(password, password_hash)
alt Invalid credentials
API-->>B: 401 Unauthorized
else Valid
API->>JWT: generateAccessToken(userId, role, tenantId) → 8h
API->>JWT: generateRefreshToken(userId) → 30d
API->>DB: UPDATE users SET refresh_token_hash = ?, last_login = NOW()
DB-->>API: OK
JWT-->>API: accessToken, refreshToken
API-->>B: 200 { accessToken, refreshToken, expiresIn: 28800 }
end
```
---
## 4. Data Model (JPA Entities)
### Entity-Relationship Diagram
```mermaid
erDiagram
Club {
UUID id PK
UUID tenant_id
string name
string address
string license_number
int max_members
timestamp created_at
enum status
}
Member {
UUID id PK
UUID tenant_id
UUID club_id FK
string first_name
string last_name
string email
date date_of_birth
date membership_date
string membership_number
enum status
boolean is_under_21
boolean prevention_officer
}
Strain {
UUID id PK
UUID tenant_id
string name
decimal thc_percentage
decimal cbd_percentage
string description
}
Batch {
UUID id PK
UUID tenant_id
UUID strain_id FK
decimal quantity_grams
date harvest_date
string batch_code
enum status
boolean contamination_flag
}
Distribution {
UUID id PK
UUID tenant_id
UUID member_id FK
UUID batch_id FK
decimal quantity_grams
timestamp distributed_at
UUID recorded_by FK
string notes
boolean immutable
}
MonthlyQuota {
UUID id PK
UUID tenant_id
UUID member_id FK
int year
int month
decimal total_distributed
decimal max_allowed
}
StockMovement {
UUID id PK
UUID tenant_id
UUID batch_id FK
enum movement_type
decimal quantity_grams
string reason
timestamp created_at
}
User {
UUID id PK
UUID tenant_id
UUID member_id FK
string email
string password_hash
enum role
timestamp last_login
boolean active
}
Club ||--o{ Member : "has members"
Member ||--o{ Distribution : "receives"
Member ||--o{ MonthlyQuota : "has quota per month"
Member ||--o| User : "may have login"
Strain ||--o{ Batch : "cultivated as"
Batch ||--o{ Distribution : "distributed via"
Batch ||--o{ StockMovement : "tracked in"
Member ||--o{ Distribution : "recorded_by (admin)"
```
### Relationship Notes
| Relationship | Cardinality | Notes |
|---|---|---|
| Club → Member | 1:N | `member.club_id` FK; max enforced by `Club.max_members` |
| Member → Distribution | 1:N | Each distribution targets one member |
| Member → MonthlyQuota | 1:N | One row per `(member_id, year, month)` — UNIQUE constraint |
| Member → User | 1:0..1 | A member may have a portal login; admins may have no `member_id` |
| Strain → Batch | 1:N | Each batch is one strain; a strain can have many batches |
| Batch → Distribution | 1:N | A batch can supply many distributions |
| Batch → StockMovement | 1:N | Every IN/OUT/RECALL against a batch is journaled |
| Distribution.recorded_by → Member | N:1 | The admin who recorded it (audit trail) |
### Key Constraints
- `Distribution.immutable = true` by default — records are append-only; no UPDATE/DELETE allowed via API
- `MonthlyQuota` has `UNIQUE(member_id, year, month)` — enforced at DB level
- `Batch.contamination_flag` triggers recall workflow; `Batch.status = RECALLED` is the final state
- All `tenant_id` columns: `NOT NULL`, `updatable = false`, set via `@PrePersist`
- `Member.is_under_21` is derived from `date_of_birth` at registration and re-evaluated on birthday (scheduled job)
---
## 5. API Layer Design
### Base Path: `/api/v1/`
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` |
### Standard HTTP conventions
- `201 Created` + `Location` header on resource creation
- `400 Bad Request` with `{ error, message, field? }` on validation failure
- `403 Forbidden` when role/tenant check fails
- `422 Unprocessable Entity` when compliance limits are breached (quota exceeded)
- Pagination: `?page=0&size=20&sort=field,asc`
---
## 6. Compliance Engine
The `ComplianceService` is the heart of regulatory enforcement. It is called synchronously before every distribution is persisted. All operations run in a single `@Transactional` block with optimistic locking to prevent race conditions under concurrent distribution recording.
```java
@Service
@Transactional
public class ComplianceService {
/**
* Validates whether a distribution is legally permitted.
*
* Checks:
* 1. Member is ACTIVE (not SUSPENDED or EXPELLED)
* 2. Daily limit: total distributed today + requestedGrams ≤ 25g
* 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed
* where max_allowed = 30g (under-21) or 50g (adult)
* 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED)
* 5. Batch has sufficient stock
*
* @throws ComplianceLimitExceededException with remaining quota details
* @throws MemberIneligibleException if member is not ACTIVE
* @throws BatchUnavailableException if batch is recalled or exhausted
*/
public ComplianceCheckResult checkDistributionAllowed(
UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... }
/**
* Returns remaining quota for the current calendar month.
* Creates a MonthlyQuota row if none exists (lazy initialization).
*
* @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 }
*/
public QuotaStatus getMonthlyRemaining(UUID memberId) { ... }
/**
* Flags a batch as RECALLED.
* Returns all members who received distributions from this batch
* so the caller can trigger notifications.
* Writes a StockMovement(RECALL) entry.
*
* @return List<AffectedMember> { memberId, name, email, totalReceived }
*/
public List<AffectedMember> recallBatch(UUID batchId) { ... }
}
```
### Race Condition Prevention
`MonthlyQuota` rows carry a JPA `@Version` column (optimistic locking). If two concurrent requests attempt to increment `total_distributed` simultaneously, one will receive an `OptimisticLockException` and must retry. The retry logic is handled by a `@Retryable` annotation (Spring Retry, max 3 attempts, 50ms backoff).
```java
@Entity
public class MonthlyQuota extends AbstractTenantEntity {
@Version
private Long version; // optimistic lock
// ... other fields
}
```
---
## 7. Infrastructure (Hetzner)
```mermaid
graph TD
Dev["👨‍💻 Developer (Fedora Workstation)"]
Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"]
Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"]
Dev -->|git push| Gitea
Gitea -->|SSH deploy script\n(webhook → deploy.sh)| Hetzner
subgraph Hetzner
Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"]
App["☕ cannamanage-app\n(Spring Boot JAR)"]
DB[("🐘 cannamanage-db\nPostgreSQL 16")]
Nginx -->|proxy_pass :8080| App
App -->|JDBC :5432| DB
end
Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx
```
### Docker Compose Services
```yaml
# docker-compose.yml (abbreviated)
services:
cannamanage-app:
image: cannamanage:latest
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage
JWT_SECRET: ${JWT_SECRET}
STRIPE_API_KEY: ${STRIPE_API_KEY}
depends_on: [cannamanage-db]
ports: ["127.0.0.1:8080:8080"]
cannamanage-db:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_DB: cannamanage
POSTGRES_PASSWORD: ${DB_PASSWORD}
cannamanage-nginx:
image: nginx:alpine
ports: ["443:443", "80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt:ro
```
### Hetzner Sizing
| Resource | Spec | Rationale |
|---|---|---|
| Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP |
| Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups |
| Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days |
| Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany |
| TLS | Let's Encrypt via Certbot | Auto-renew via cron |
### Deployment Workflow
```
git push origin main
→ Gitea webhook fires
→ deploy.sh on Hetzner:
docker pull cannamanage:latest
docker compose up -d --no-deps cannamanage-app
# zero-downtime: Nginx buffers requests during restart
```
Flyway migrations run automatically on application startup (`spring.flyway.enabled=true`). Migration files live at `src/main/resources/db/migration/V*.sql`.
---
## 8. Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Multi-tenancy | Shared schema + `tenant_id` | MVP simplicity; upgrade to schema-per-tenant possible later |
| Frontend MVP | PrimeFaces JSF | Patrick's existing expertise; fastest path to working UI |
| Frontend v2 | Next.js / React | Modern UX; deferred to avoid scope creep in MVP |
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
| 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 |
@@ -0,0 +1,229 @@
# 04 — Business Logic Flow Charts
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
**Phase:** 2 of 5 — Architecture & Data Model
**Last updated:** 2026-04-06
All flows are implemented in the Spring Boot service layer. Mermaid `flowchart TD` syntax.
---
## Flow 1: Distribution Recording
Records a cannabis distribution to a member. This is the most compliance-critical path in the system. Every step that can fail returns a user-facing error with actionable detail (remaining quota, batch status, etc.).
```mermaid
flowchart TD
START([🟢 Admin clicks\n'Record Distribution']) --> SEL_MEMBER[Select member from list]
SEL_MEMBER --> LOAD_MEMBER[Load member profile\nfrom MemberRepository]
LOAD_MEMBER --> CHECK_ACTIVE{Member status\n= ACTIVE?}
CHECK_ACTIVE -->|No — SUSPENDED\nor EXPELLED| ERR_MEMBER[❌ Error: Member not eligible\nShow status reason]
CHECK_ACTIVE -->|Yes| CHECK_AGE{is_under_21\n= true?}
CHECK_AGE -->|Under 21| MAX_MONTHLY_30[Monthly limit = 30g]
CHECK_AGE -->|Adult ≥ 21| MAX_MONTHLY_50[Monthly limit = 50g]
MAX_MONTHLY_30 --> ENTER_QTY[Admin enters quantity\nin grams]
MAX_MONTHLY_50 --> ENTER_QTY
ENTER_QTY --> VALIDATE_QTY{quantity > 0\nand ≤ 25g?}
VALIDATE_QTY -->|No| ERR_QTY[❌ Error: Invalid quantity\nDaily max is 25g per visit]
VALIDATE_QTY -->|Yes| CHECK_DAILY[ComplianceService:\nSum distributions today\nfor this member]
CHECK_DAILY --> DAILY_OK{today_total +\nquantity ≤ 25g?}
DAILY_OK -->|No| ERR_DAILY[❌ Error: Daily limit exceeded\nShow remaining today]
DAILY_OK -->|Yes| CHECK_MONTHLY[ComplianceService:\nLoad MonthlyQuota\ncurrent month]
CHECK_MONTHLY --> MONTHLY_OK{monthly_total +\nquantity ≤ max_allowed?}
MONTHLY_OK -->|No| ERR_MONTHLY[❌ Error: Monthly quota exceeded\nShow remaining this month\nand reset date]
MONTHLY_OK -->|Yes| SEL_BATCH[Admin selects batch]
SEL_BATCH --> LOAD_BATCH[Load batch from\nBatchRepository]
LOAD_BATCH --> CHECK_BATCH{Batch status\n= AVAILABLE?}
CHECK_BATCH -->|RECALLED| ERR_RECALLED[❌ Error: Batch recalled\nSelect a different batch]
CHECK_BATCH -->|EXHAUSTED| ERR_EXHAUSTED[❌ Error: Batch exhausted\nNo stock remaining]
CHECK_BATCH -->|AVAILABLE| CHECK_STOCK{batch.quantity_grams\n≥ requested quantity?}
CHECK_STOCK -->|No| ERR_STOCK[❌ Error: Insufficient stock\nShow available quantity]
CHECK_STOCK -->|Yes| CONFIRM[Admin reviews and confirms\ndistribution details]
CONFIRM --> SAVE_DIST["💾 Save Distribution record\n(immutable = true,\nrecorded_by = currentUser)"]
SAVE_DIST --> UPD_QUOTA["💾 UPDATE MonthlyQuota\ntotal_distributed += quantity\n(@Version optimistic lock)"]
UPD_QUOTA --> UPD_STOCK["💾 INSERT StockMovement\n(type = OUT, batch_id, qty)"]
UPD_STOCK --> UPD_BATCH["💾 UPDATE Batch\nquantity_grams -= quantity\n(if = 0 → status = EXHAUSTED)"]
UPD_BATCH --> SUCCESS([✅ Success\nShow confirmation\nwith updated quota display])
```
---
## Flow 2: Member Registration
Registers a new member in the club. Includes DSGVO consent, age validation, under-21 flag assignment, and automatic portal account creation.
```mermaid
flowchart TD
START([🟢 Admin opens\n'Add Member' form]) --> ENTER_DATA[Admin enters member data:\nfirst/last name, email,\ndate of birth, address]
ENTER_DATA --> VALIDATE_EMAIL{Email unique\nin this club?}
VALIDATE_EMAIL -->|Already exists| ERR_EMAIL[❌ Error: Email already\nregistered in this club]
VALIDATE_EMAIL -->|Unique| VALIDATE_AGE{Age ≥ 18?}
VALIDATE_AGE -->|Under 18| ERR_AGE[❌ Error: Member must be\nat least 18 years old\n§ 10 KCanG]
VALIDATE_AGE -->|18 or older| CHECK_UNDER21{18 ≤ age < 21?}
CHECK_UNDER21 -->|Yes| SET_FLAG_TRUE["Set is_under_21 = true\nMonthly limit will be 30g"]
CHECK_UNDER21 -->|No, ≥ 21| SET_FLAG_FALSE["Set is_under_21 = false\nMonthly limit will be 50g"]
SET_FLAG_TRUE --> CHECK_CAPACITY[Check Club.max_members\nvs current member count]
SET_FLAG_FALSE --> CHECK_CAPACITY
CHECK_CAPACITY --> CAPACITY_OK{Club has\nfree capacity?}
CAPACITY_OK -->|No| ERR_CAPACITY[❌ Error: Club at max capacity\nCannot register more members]
CAPACITY_OK -->|Yes| GEN_NUMBER["Generate membership_number\n(club prefix + sequential ID)"]
GEN_NUMBER --> DSGVO[Show DSGVO consent dialog:\n• Data usage explanation\n• Right to erasure\n• Admin must confirm consent obtained]
DSGVO --> DSGVO_OK{Admin confirms\nconsent obtained?}
DSGVO_OK -->|No| ABORT([🔴 Abort — member\ncannot be registered\nwithout DSGVO consent])
DSGVO_OK -->|Yes| SAVE_MEMBER["💾 Save Member\n(status = ACTIVE,\nmembership_date = today)"]
SAVE_MEMBER --> CREATE_USER["💾 Create User account\n(role = ROLE_MEMBER,\ngenerate temp password)"]
CREATE_USER --> SEND_EMAIL["📧 Send welcome email:\n• Membership number\n• Temp login credentials\n• Portal URL\n• DSGVO information sheet PDF"]
SEND_EMAIL --> SUCCESS([✅ Member registered\nShow member profile\nwith membership number])
```
---
## Flow 3: Contamination Batch Recall
Handles the recall of a contaminated batch. This flow is time-critical — speed of notification is essential for member safety. All affected distributions are identified and the prevention officer is notified.
```mermaid
flowchart TD
START([🟢 Admin selects batch\nand clicks 'Flag Recall']) --> CONFIRM_RECALL{Confirm recall\nof batch?\nThis cannot be undone.}
CONFIRM_RECALL -->|Cancel| CANCEL([🔴 Cancelled — batch\nstatus unchanged])
CONFIRM_RECALL -->|Confirm| QUERY_DIST["🔍 Query all Distributions\nWHERE batch_id = :batchId\n(across all members)"]
QUERY_DIST --> HAS_DIST{Any distributions\nfound?}
HAS_DIST -->|No distributions| NO_DIST["⚠️ Batch was never distributed\n(still flag as RECALLED\nfor inventory integrity)"]
HAS_DIST -->|Yes| BUILD_LIST["Build affected member list:\n• member name\n• distribution date\n• quantity received\n• contact email"]
NO_DIST --> FLAG_BATCH
BUILD_LIST --> SHOW_LIST[Show affected member list\nto admin for review]
SHOW_LIST --> ADMIN_REVIEW{Admin reviews\nand confirms recall?}
ADMIN_REVIEW -->|Cancel| CANCEL
ADMIN_REVIEW -->|Proceed| FLAG_BATCH["💾 UPDATE Batch\nstatus = RECALLED\ncontamination_flag = true"]
FLAG_BATCH --> LOG_MOVEMENT["💾 INSERT StockMovement\n(type = RECALL,\nbatch_id, reason)"]
LOG_MOVEMENT --> EXPORT_LIST["📄 Generate export:\n• CSV: affected_members_recall_{batchCode}.csv\n• PDF: recall_report_{batchCode}.pdf\n(via iText 7)"]
EXPORT_LIST --> NOTIFY_OFFICER["📧 Email Prevention Officer:\n• Batch code and details\n• Affected member count\n• Attached CSV/PDF"]
NOTIFY_OFFICER --> AUDIT_LOG["💾 INSERT AuditLog\n(action = BATCH_RECALL,\nperformedBy, timestamp)"]
AUDIT_LOG --> SUCCESS([✅ Recall complete\nOffer download of\nexport files])
```
---
## Flow 4: Compliance Report Generation
Generates the monthly compliance report required by § 22 KCanG. Covers all distributions within a calendar month, with per-member quota analysis and club metadata for regulatory submission.
```mermaid
flowchart TD
START([🟢 Admin opens\nReports section]) --> SELECT_PERIOD[Admin selects\nmonth and year]
SELECT_PERIOD --> VALIDATE_PERIOD{Period in the\npast or current\nmonth?}
VALIDATE_PERIOD -->|Future month| ERR_FUTURE[❌ Error: Cannot generate\nreport for future periods]
VALIDATE_PERIOD -->|Valid| LOAD_CLUB[Load Club metadata:\nlicense number,\nprevention officer name]
LOAD_CLUB --> QUERY_DIST["🔍 ReportService:\nSELECT * FROM distributions\nWHERE month = :month\nAND year = :year\nAND tenant_id = :tenantId"]
QUERY_DIST --> HAS_DATA{Any distributions\nin this period?}
HAS_DATA -->|No data| EMPTY_REPORT[Generate empty report\nwith zero totals\n(still valid compliance submission)]
HAS_DATA -->|Yes| AGG_MEMBER["Aggregate by member:\n• total_distributed_grams\n• number_of_visits\n• quota_usage_percent\n• is_under_21 flag"]
EMPTY_REPORT --> AGG_STRAIN
AGG_MEMBER --> AGG_STRAIN["Aggregate by strain/batch:\n• strain name, THC%, CBD%\n• quantity distributed\n• batch codes used"]
AGG_STRAIN --> ADD_METADATA["Add club metadata:\n• Club name + license number\n• Prevention officer name\n• Report generation timestamp\n• Total members active in period"]
ADD_METADATA --> RENDER_PDF["📄 iText 7:\nRender PDF report\n• Cover page with club details\n• Summary table\n• Per-member breakdown\n• Strain/batch appendix"]
RENDER_PDF --> RENDER_CSV["📊 Generate CSV:\n• One row per distribution\n• member_id, name, date,\n quantity, strain, batch_code"]
RENDER_CSV --> STORE_FILES["💾 Store generated files\ntemporarily in server /tmp\n(TTL: 1 hour)"]
STORE_FILES --> SUCCESS([✅ Report ready\nOffer download:\n📄 PDF 📊 CSV])
```
---
## Flow 5: Member Login & Quota Display
The member portal entry flow. Members log in to view their current monthly quota, remaining allowance, and recent distribution history. This is a read-only portal — members cannot modify any data.
```mermaid
flowchart TD
START([🟢 Member navigates\nto member portal URL]) --> SHOW_LOGIN[Show login form:\nemail + password]
SHOW_LOGIN --> SUBMIT[Member submits credentials]
SUBMIT --> FIND_USER["🔍 Spring Security:\nSELECT FROM users\nWHERE email = ?\nAND active = true"]
FIND_USER --> USER_FOUND{User found?}
USER_FOUND -->|No| ERR_NOTFOUND[❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)]
USER_FOUND -->|Yes| VERIFY_PW{BCrypt.verify\n(password, hash)\nmatches?}
VERIFY_PW -->|No| ERR_PW[❌ Invalid credentials]
VERIFY_PW -->|Yes| CHECK_MEMBER{User has\nmember_id set?}
CHECK_MEMBER -->|No — admin account| ERR_NOTMEMBER[❌ Error: Use admin portal\nfor admin accounts]
CHECK_MEMBER -->|Yes| ISSUE_JWT["🔑 Issue JWT:\n• role = ROLE_MEMBER\n• tenantId = user.tenantId\n• memberId = user.memberId\n• expiry = 8h"]
ISSUE_JWT --> UPDATE_LOGIN["💾 UPDATE users\nlast_login = NOW()"]
UPDATE_LOGIN --> LOAD_PORTAL["Load member portal\n(JSF view or SPA)"]
LOAD_PORTAL --> CALL_QUOTA["📡 GET /api/v1/members/me/quota\n(JWT in Authorization header)"]
CALL_QUOTA --> FETCH_QUOTA["🔍 QuotaController:\nLoad MonthlyQuota\nfor current month\n(create if not exists)"]
FETCH_QUOTA --> CALC_REMAINING{Quota record\nexists?}
CALC_REMAINING -->|No — new month| CREATE_QUOTA["Create MonthlyQuota row:\ntotal_distributed = 0\nmax_allowed = 30g or 50g"]
CALC_REMAINING -->|Yes| RETURN_QUOTA["Return QuotaStatus:\n• totalAllowed\n• totalUsed\n• remaining\n• percentUsed"]
CREATE_QUOTA --> RETURN_QUOTA
RETURN_QUOTA --> DISPLAY_PROGRESS["Display quota progress bar:\n🟩🟩🟩⬜⬜ e.g. 15g of 50g used\nColor: green < 60% / yellow < 85% / red ≥ 85%"]
DISPLAY_PROGRESS --> CALL_HISTORY["📡 GET /api/v1/distributions\n?memberId=me&limit=10\n&sort=distributed_at,desc"]
CALL_HISTORY --> DISPLAY_HISTORY["Display last 10 distributions:\n• Date, quantity, strain name\n• Batch code\n• Recorded by (staff name)"]
DISPLAY_HISTORY --> SUCCESS([✅ Member portal loaded\nQuota + history visible])
```
---
## Flow Summary
| Flow | Trigger | Key Service | Critical Constraint |
|---|---|---|---|
| Distribution Recording | Admin records handout | `ComplianceService` | Daily 25g + monthly 30g/50g limits |
| Member Registration | Admin adds new member | `MemberService` | Age ≥ 18, DSGVO consent mandatory |
| Batch Recall | Admin flags contamination | `ComplianceService.recallBatch()` | Immediate prevention officer notification |
| Report Generation | Admin requests monthly report | `ReportService` | iText 7 PDF + CSV for regulatory filing |
| Member Login | Member accesses portal | `AuthService` + `QuotaController` | JWT stateless, read-only member view |
### Error Handling Conventions
All flows follow these conventions for user-facing error messages:
- **Compliance errors** (`422 Unprocessable Entity`): Always include remaining quota/allowance so the admin knows what quantity would be valid
- **Validation errors** (`400 Bad Request`): Include the specific `field` and a human-readable `message` in German (UI locale)
- **Permission errors** (`403 Forbidden`): Generic message — do not reveal tenant or role details
- **System errors** (`500 Internal Server Error`): Log full stack trace; show generic user message; alert via email to club admin
### Transaction Boundaries
The Distribution Recording flow (Flow 1) executes steps `SAVE_DIST → UPD_QUOTA → UPD_STOCK → UPD_BATCH` in a **single `@Transactional` block**. If any step fails (e.g., optimistic lock collision on `MonthlyQuota`), the entire transaction rolls back and no partial state is persisted.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,550 @@
# CannaManage — Wireframes & UI Mockups
**Phase 4a | Document 6 of 7**
**Date:** 2026-04-06
**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL
---
## Table of Contents
1. [Design System Overview](#1-design-system-overview)
2. [Admin Portal Screens](#2-admin-portal-screens)
3. [Member Portal Screens](#3-member-portal-screens)
4. [Navigation & Information Architecture](#4-navigation--information-architecture)
5. [Responsive Design Notes](#5-responsive-design-notes)
6. [Accessibility](#6-accessibility)
---
## 1. Design System Overview
### 1.1 Color Palette
| Token | Hex | Usage |
|---|---|---|
| `--color-primary` | `#2D5016` | Sidebar background, primary buttons, active nav items |
| `--color-primary-medium` | `#4A7C28` | Hover states, section headers, badge outlines |
| `--color-accent` | `#8BC34A` | Highlights, progress bars filled, success indicators |
| `--color-bg` | `#F5F5F5` | Page background, card backgrounds |
| `--color-text` | `#1A1A1A` | Body text, table cell content |
| `--color-warning` | `#FF6B35` | Quota >80%, low stock, warnings |
| `--color-error` | `#D32F2F` | Quota exceeded, recalled batches, destructive actions |
| `--color-white` | `#FFFFFF` | Sidebar text, button labels on dark bg, card surfaces |
### 1.2 Typography
| Element | Font | Size | Weight |
|---|---|---|---|
| H1 — Page title | Inter | 24px | 600 |
| H2 — Section heading | Inter | 18px | 600 |
| H3 — Card title | Inter | 14px | 600 |
| Body / table rows | Inter | 14px | 400 |
| Caption / label | Inter | 12px | 400 |
| Mono (codes, IDs) | JetBrains Mono | 13px | 400 |
### 1.3 Component Library
All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP.
| Component | Usage |
|---|---|
| `p:panel` | Section containers, card wrappers |
| `p:dataTable` with `p:column` | Tabular data: distributions, members, batches |
| `p:paginator` | Pagination on all tables |
| `p:inputText` | Single-line text fields |
| `p:inputNumber` | Weight inputs (gram precision) |
| `p:selectOneMenu` | Dropdown selects (member, strain, batch) |
| `p:calendar` | Date range pickers for reports |
| `p:progressBar` | Quota consumption display |
| `p:commandButton` | Primary and secondary actions |
| `p:confirmDialog` | Dangerous actions (recall, delete) |
| `p:messages` / `p:message` | Inline validation errors |
| `p:badge` | Status indicators (AVAILABLE, LOW, RECALLED) |
| `p:sidebar` | Mobile nav drawer (member portal) |
| `p:dialog` | Modal overlays |
### 1.4 Layout Grid
```
┌────────────────────────────────────────────────────┐
│ TOP NAVBAR (56px) club name · avatar · logout │
├──────────────┬─────────────────────────────────────┤
│ │ │
│ SIDEBAR │ MAIN CONTENT │
│ (240px) │ (fluid, min 784px) │
│ fixed │ │
│ │ │
└──────────────┴─────────────────────────────────────┘
```
- **Sidebar:** fixed left, `#2D5016` background, white nav labels with `#8BC34A` icons
- **Top Navbar:** `#FFFFFF` with bottom border `#E0E0E0`, breadcrumb left, user controls right
- **Main Content:** `#F5F5F5` background, 24px padding, max content width 1200px centered
---
## 2. Admin Portal Screens
### Screen 1 — Admin Dashboard
![Admin Dashboard](images/mockup-admin-dashboard.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Dashboard 🗓 April 2026 │
│ 📊 Dashboard◄│ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ 👥 Members│ │ Total Members│ │ Distributions│ │ Stock Available│ │
│ │ │ │ │ This Month │ │ │ │
│ 📋 Distrib│ │ 142 │ │ 87 │ │ 3,240 g │ │
│ │ │ ▲ +3 MoM │ │ ▲ +12 MoM │ │ ▼ -800g MoM │ │
│ 📦 Stock │ └──────────────┘ └──────────────┘ └───────────────┘ │
│ │ │
│ 📄 Reports│ Recent Distributions [+ New Entry] │
│ │ ┌─────────────────────────────────────────────────┐ │
│ ✅ Complian│ │ Member │ Strain │ Qty │ Date │ ✓ │ │
│ │ ├─────────────┼─────────────┼───────┼───────┼────┤ │
│ ⚙ Settings│ │ Müller, A. │ OG Kush B12 │ 5.0g │ 06.04 │ ✓ │ │
│ │ │ Schmidt, K. │ Amnesia H09 │ 3.5g │ 06.04 │ ✓ │ │
│ │ │ Weber, T. │ OG Kush B12 │ 7.0g │ 05.04 │ ✓ │ │
│ │ │ … │ │ │ │ │ │
└────────────┴──┴─────────────────────────────────────────────────────┘
```
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s |
| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail |
| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` |
| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` |
| Trend indicators | Custom CSS `<span>` | Green ▲ / Red ▼ with delta value |
---
### Screen 2 — Distribution Recording Form
![Distribution Form](images/mockup-distribution-form.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Distributions New Distribution │
│ 📊 Dashbrd│ │
│ │ ┌──────────────────────────────────────────────────┐ │
│ 👥 Members│ │ Member * │ │
│ │ │ ┌──────────────────────────────────────────┐ │ │
│ 📋 Distrib◄│ │ │ 🔍 Search by name or member no. │ │ │
│ │ │ └──────────────────────────────────────────┘ │ │
│ 📦 Stock │ │ │ │
│ │ │ Strain / Batch * │ │
│ 📄 Reports│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ │ Select available batch ▼ │ │ │
│ ✅ Complian│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │ │
│ ⚙ Settings│ │ Weight (grams) * │ │
│ │ │ ┌──────────┐ │ │
│ │ │ │ 0.0 g │ ← p:inputNumber min=0.1 max=25 │ │
│ │ │ └──────────┘ │ │
│ │ │ │ │
│ │ │ Monthly Quota — Müller, Anna │ │
│ │ │ ████████████░░░░░░░░ 32.5g / 50g 65% │ │
│ │ │ [████████████████░░░] <- p:progressBar │ │
│ │ │ │ │
│ │ │ [ Record Distribution ] [Cancel] │ │
│ │ └──────────────────────────────────────────────────┘ │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Compliance UX — Real-Time Quota Indicator
The quota progress bar updates live as the weight field changes (via `f:ajax event="keyup"`):
| Quota Used After Distribution | Bar Color | Submit Button | Message |
|---|---|---|---|
| 079% | `#8BC34A` (green) | Enabled | — |
| 8099% | `#FF6B35` (orange) | Enabled | "⚠ Approaching monthly limit" |
| 100% | `#D32F2F` (red) | **Disabled** | "🚫 Monthly limit reached (50g)" |
| Over-21 member, >30g monthly | `#D32F2F` (red) | **Disabled** | "🚫 Under-21 limit reached (30g)" |
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Member search | `p:selectOneMenu` with `p:ajax` filter | Filters on type, shows name + member no. |
| Strain/Batch dropdown | `p:selectOneMenu` | Populated after member selection; shows only `AVAILABLE` batches |
| Weight input | `p:inputNumber` min=`0.1` max=`25.0` step=`0.1` | Triggers quota recalculation on blur |
| Quota bar | `p:progressBar` with dynamic `value` | Color class applied via `styleClass` computed in backing bean |
| Submit | `p:commandButton` | Disabled via `disabled="#{bean.quotaExceeded}"` |
| Cancel | `p:link` | Returns to distribution log without saving |
---
### Screen 3 — Stock Management
![Stock Management](images/mockup-stock-management.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Stock Management [+ Add Batch] │
│ 📊 Dashbrd│ │
│ │ ┌──────────────────────┐ ┌────────────────────┐ │
│ 👥 Members│ │ 🔍 Filter by strain │ │ Status: All ▼ │ │
│ │ └──────────────────────┘ └────────────────────┘ │
│ 📋 Distrib│ │
│ │ ┌───────────────────────────────────────────────────┐ │
│ 📦 Stock ◄│ │ Strain │Batch│THC% │CBD%│ Qty │Status│Act │ │
│ │ ├──────────────┼─────┼─────┼────┼───────┼──────┼────┤ │
│ 📄 Reports│ │ OG Kush │B-12 │ 19% │ 1% │ 850g │ ● │[R] │ │
│ │ │ Amnesia Haze │H-09 │ 22% │<1% │ 72g │ ⚠ │[R] │ │
│ ✅ Complian│ │ Blue Dream │D-05 │ 17% │ 2% │ 0g │ — │[R] │ │
│ │ │ Hindu Kush │K-21 │ 8% │15% │ 340g │ ✓ │[R] │ │
│ ⚙ Settings│ │ AK-47 #4 │A-03 │ 20% │ 1% │ RECALLED │ ⛔ │[R] │ │
│ │ └───────────────────────────────────────────────────┘ │
│ │ [◄ 1 2 3 … ►] Showing 1-10/42 │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Status Badges
| Badge | Color | Icon | Condition |
|---|---|---|---|
| `AVAILABLE` | `#4A7C28` bg | ✓ checkmark | `qty > 100g` and not recalled |
| `LOW` | `#FF6B35` bg | ⚠ warning | `0 < qty ≤ 100g` |
| `EXHAUSTED` | `#9E9E9E` bg | — dash | `qty = 0` |
| `RECALLED` | `#D32F2F` bg | ⛔ stop | `recall_date IS NOT NULL` |
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup |
| Status filter | `p:selectOneMenu` | Filters table rows by status value |
| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page |
| Status badge | Custom CSS `<span class="badge badge-{status}">` | Icon + text label (not color alone) |
| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing |
| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form |
---
### Screen 4 — Compliance Report Generation
![Compliance Report](images/mockup-compliance-report.png)
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Reports Monthly Compliance Report │
│ 📊 Dashbrd│ │
│ │ ┌─────────────────────────────────────────────────┐ │
│ 👥 Members│ │ Reporting Period │ │
│ │ │ Month: [ March ▼ ] Year: [ 2026 ▼ ] │ │
│ 📋 Distrib│ │ [ Generate Report ] │ │
│ │ └─────────────────────────────────────────────────┘ │
│ 📦 Stock │ │
│ │ ┌─────────────────────────────────────────────────┐ │
│ 📄 Reports◄│ │ PDF PREVIEW │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │
│ ✅ Complian│ │ │ 🌿 CannaManage — Monthly Report Mar 2026 │ │ │
│ │ │ │ Club: Grüne Oase Berlin e.V. │ │ │
│ ⚙ Settings│ │ │ ─────────────────────────────────────── │ │ │
│ │ │ │ Total Members: 142 │ │ │
│ │ │ │ Active Members (distributed): 87 │ │ │
│ │ │ │ Total Distributed: 435.5g │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ [⬇ Download PDF] [⬇ Download CSV] │ │
│ │ └─────────────────────────────────────────────────┘ │
│ │ │
│ │ Summary Table │
│ │ ┌────────────────────────────────────────────────┐ │
│ │ │ Metric │ Value │ Limit │ │
│ │ ├──────────────────────┼───────────┼─────────────┤ │
│ │ │ Members >50g/month │ 0 │ Must be 0 │ │
│ │ │ Members >30g (U21) │ 0 │ Must be 0 │ │
│ │ │ Recalled Batches │ 1 │ — (info) │ │
│ │ │ Avg grams / member │ 5.0g │ — │ │
│ │ └────────────────────────────────────────────────┘ │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Month selector | `p:selectOneMenu` | Months JanDec |
| Year selector | `p:selectOneMenu` | Current year ± 2 |
| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail |
| PDF preview | `<iframe>` embedding `/report/preview?month=3&year=2026` | Generated by iText 7 in `cannamanage-report` module |
| Download PDF | `p:commandButton` | Streams PDF response from REST endpoint |
| Download CSV | `p:commandButton` | Streams CSV response (member-level data) |
| Summary table | `p:dataTable` | Computed compliance metrics; zero violations = green row |
---
## 3. Member Portal Screens
### Screen 5 — Member Dashboard / Quota View
![Member Quota](images/mockup-member-quota.png)
#### ASCII Wireframe
```
┌────────────────────────────────────────────────────┐
│ 🌿 CannaManage Anna Müller #M-0042 │
│ ────────────────────────────────────────────── │
│ │
│ Monthly Quota Remaining │
│ │
│ ╭───────────────╮ │
│ │ │ │
│ │ 17.5 g │ │
│ │ remaining │ │
│ │ │ │
│ │ of 50g/month │ │
│ ╰───────────────╯ │
│ ▓▓▓▓▓▓▓▓▓▓▓░░░░░░░ 65% used │
│ │
│ Distribution History │
│ ┌────────────────────────────────────────────┐ │
│ │ Date │ Strain │ Quantity │ │
│ ├────────────┼──────────────┼────────────────┤ │
│ │ 06.04.2026 │ OG Kush │ 5.0g │ │
│ │ 02.04.2026 │ Amnesia Haze │ 12.5g │ │
│ │ 28.03.2026 │ OG Kush │ 15.0g │ │
│ └────────────┴──────────────┴────────────────┘ │
│ │
│ Available Strains │
│ ┌────────────────────────────────────────────┐ │
│ │ Strain │ Availability │ │
│ ├──────────────┼─────────────────────────────┤ │
│ │ OG Kush │ ● Available │ │
│ │ Amnesia Haze │ ⚠ Limited │ │
│ │ Hindu Kush │ ● Available │ │
│ └────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────┘
```
#### Compliance Note — Available Strains Display
Per CanG §§67, members may NOT see specific batch quantities or total stock levels. The **Available Strains** table shows only:
- Strain name
- Availability status (Available / Limited / Unavailable)
Quantities, batch codes, and THC/CBD percentages are **not exposed** in the member portal.
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Quota circle | Custom CSS radial progress (`conic-gradient`) | Computed from monthly total; color matches threshold rules |
| Quota bar | `p:progressBar` | Same color logic as admin distribution form |
| History table | `p:dataTable` | Last 10 distributions; sorted newest first; no pagination in MVP |
| Strains table | `p:dataTable` | `status` column: text + icon only, no quantities |
---
### Screen 6 — Member Login
> *No mockup image — ASCII wireframe only.*
#### ASCII Wireframe
```
┌──────────────────────────────────────────┐
│ │
│ 🌿 CannaManage │
│ │
│ ┌──────────────────────────────────┐ │
│ │ E-Mail Address │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ you@example.com │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ Password │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ •••••••••••• │ │ │
│ │ └────────────────────────────┘ │ │
│ │ │ │
│ │ [ ████ Log In ████████████ ] │ │
│ └──────────────────────────────────┘ │
│ │
│ Problems logging in? │
│ Contact your club administrator. │
│ │
└──────────────────────────────────────────┘
```
#### Design Decisions
- **No self-registration link** — member accounts are created exclusively by admins via the admin portal. The login page has no "Create account" or "Sign up" flow.
- **No forgot-password link** — password resets are initiated by the club admin only. The login page directs users to contact their admin, avoiding email-based reset flows that would require verified email infrastructure in MVP.
- **No social login** — DSGVO compliance and club accountability require traceable credential management.
- **Form submission:** POST to `/login` (Spring Security form login), redirect to `/member/dashboard` on success.
#### Components & Behavior
| Component | PrimeFaces | Behavior |
|---|---|---|
| Email field | `p:inputText` with `required="true"` | Bean Validation `@Email` |
| Password field | `p:password` feedback=`false` | No strength meter on login |
| Login button | `p:commandButton` | Submit form; shows `p:messages` on failure |
| Error message | `p:messages` | "Invalid email or password." (never specific about which field failed) |
---
## 4. Navigation & Information Architecture
```mermaid
graph TD
Root["CannaManage Root"]
Root --> AdminPortal["Admin Portal /admin/"]
Root --> MemberPortal["Member Portal /member/"]
AdminPortal --> AdminDash["Dashboard (default)"]
AdminPortal --> Members["Members"]
Members --> MemberList["Member List"]
Members --> MemberDetail["Member Detail"]
AdminPortal --> Distributions["Distributions"]
Distributions --> DistLog["Distribution Log"]
Distributions --> NewDist["New Distribution"]
AdminPortal --> Stock["Stock"]
Stock --> Strains["Strains"]
Stock --> Batches["Batches"]
AdminPortal --> Reports["Reports"]
Reports --> MonthlyReport["Monthly Compliance"]
Reports --> MemberExport["Member Export"]
Reports --> RecallReport["Batch Recall Report"]
AdminPortal --> Compliance["Compliance"]
Compliance --> PreventionOfficer["Prevention Officer Info"]
AdminPortal --> Settings["Settings"]
Settings --> ClubProfile["Club Profile"]
MemberPortal --> MemberDash["Dashboard / Quota"]
MemberPortal --> DistHistory["Distribution History"]
MemberPortal --> StockAvail["Stock Availability"]
```
### URL Structure
| Path | Description | Role |
|---|---|---|
| `/login` | Login page | Public |
| `/admin/dashboard` | Admin home | `ROLE_ADMIN` |
| `/admin/members` | Member list | `ROLE_ADMIN` |
| `/admin/members/{id}` | Member detail | `ROLE_ADMIN` |
| `/admin/distributions` | Distribution log | `ROLE_ADMIN` |
| `/admin/distributions/new` | New distribution form | `ROLE_ADMIN` |
| `/admin/stock/strains` | Strain catalog | `ROLE_ADMIN` |
| `/admin/stock/batches` | Batch management | `ROLE_ADMIN` |
| `/admin/reports/monthly` | Compliance reports | `ROLE_ADMIN` |
| `/admin/reports/members` | Member data export | `ROLE_ADMIN` |
| `/admin/reports/recall` | Recall report | `ROLE_ADMIN` |
| `/admin/compliance` | Prevention officer | `ROLE_ADMIN` |
| `/admin/settings` | Club settings | `ROLE_ADMIN` |
| `/member/dashboard` | Member quota view | `ROLE_MEMBER` |
| `/member/distributions` | Personal history | `ROLE_MEMBER` |
| `/member/stock` | Strain availability | `ROLE_MEMBER` |
---
## 5. Responsive Design Notes
### MVP (v1) — Desktop-First
Target viewport: **1024px+**. PrimeFaces responsive grid (`p:panelGrid` with responsive columns, `ui-g-12 ui-md-6 ui-lg-4`) handles most layout adaptation down to tablet without custom media queries.
| Breakpoint | Behavior |
|---|---|
| `≥ 1280px` | Full layout — sidebar + content side-by-side |
| `10241279px` | Sidebar collapses to icon-only (60px); tooltips on hover |
| `7681023px` | Sidebar hidden; hamburger menu in top navbar |
| `< 768px` | Admin portal degraded (tables scroll horizontally) |
### Member Portal — Mobile-First from Day One
Members will typically check quota status on their phone. The member portal is designed mobile-first regardless of MVP/v2 timeline.
| Breakpoint | Behavior |
|---|---|
| `≥ 1024px` | Two-column layout: quota circle left, history right |
| `7681023px` | Single-column, full-width cards |
| `375767px` | Single-column, compact quota ring, condensed table |
| `< 375px` | Minimum supported; no horizontal scroll |
### v2 Roadmap
- PWA manifest + service worker (offline quota display)
- 768px and 375px explicit breakpoints with design tokens
- Touch-friendly `p:sidebar` for mobile member nav
- Push notifications for low quota warnings
---
## 6. Accessibility
CannaManage targets **WCAG 2.1 AA** compliance across both portals.
### Keyboard Navigation
| Element | Keyboard Behavior |
|---|---|
| Navigation sidebar | Tab navigates items; Enter activates |
| Data tables | Tab to table; arrow keys for row navigation |
| Dropdown menus | Enter/Space to open; arrow keys to navigate; Escape to close |
| Modal dialogs | Focus trapped inside; Escape to close; first focusable element receives focus on open |
| Confirmation dialogs | Tab between Confirm and Cancel; Enter on focused button |
### Screen Reader Support
- All `p:inputText` / `p:inputNumber` fields have `<label>` with `for` attribute
- `aria-label` set on icon-only buttons (e.g., recall action column)
- `aria-live="polite"` region on quota bar — announces percentage changes
- `aria-describedby` links compliance warning messages to the weight input
- PrimeFaces generates `role="grid"` and `aria-rowcount` on all data tables
### Color Independence
Status badges must never rely on color alone:
| Status | Color | Icon | Text label |
|---|---|---|---|
| AVAILABLE | Green | ✓ | "Available" |
| LOW | Orange | ⚠ | "Low Stock" |
| RECALLED | Red | ⛔ | "Recalled" |
| EXHAUSTED | Gray | — | "Exhausted" |
Quota progress bar additionally shows numeric percentage text alongside color change.
### Contrast Ratios
| Foreground | Background | Ratio | AA pass |
|---|---|---|---|
| `#FFFFFF` | `#2D5016` | 9.1:1 | ✅ |
| `#1A1A1A` | `#F5F5F5` | 16.0:1 | ✅ |
| `#1A1A1A` | `#FFFFFF` | 19.0:1 | ✅ |
| `#FFFFFF` | `#D32F2F` | 5.1:1 | ✅ |
| `#FFFFFF` | `#FF6B35` | 3.1:1 | ⚠ verify at large text only |
---
*Next: [07-CODING-STANDARDS.md](07-CODING-STANDARDS.md)*
@@ -0,0 +1,825 @@
# CannaManage — Coding Standards & Git Strategy
**Phase 4a | Document 7 of 7**
**Date:** 2026-04-06
**Stack:** Java 21 · Spring Boot 3.x · JPA/Hibernate · PrimeFaces JSF · PostgreSQL
---
## Table of Contents
1. [Project Structure](#1-project-structure)
2. [Java Coding Standards](#2-java-coding-standards)
3. [Compliance Code Rules](#3-compliance-code-rules)
4. [Git Strategy](#4-git-strategy)
5. [Testing Standards](#5-testing-standards)
6. [Code Review Checklist](#6-code-review-checklist)
7. [Security Standards](#7-security-standards)
8. [Environment Configuration](#8-environment-configuration)
---
## 1. Project Structure
### Maven Multi-Module Layout
```
cannamanage/
├── pom.xml # Parent POM — dependency management, versions
├── cannamanage-domain/ # JPA entities, enums, exceptions, value objects
│ └── src/main/java/de/cannamanage/domain/
│ ├── member/ # Member, MemberStatus, MembershipType
│ ├── distribution/ # Distribution, DistributionRecord
│ ├── stock/ # Strain, Batch, BatchStatus
│ ├── compliance/ # ComplianceConstants, QuotaExceededException
│ └── common/ # AbstractTenantEntity, TenantId
├── cannamanage-service/ # Business logic, compliance engine, repositories
│ └── src/main/java/de/cannamanage/service/
│ ├── member/ # MemberService, MemberRepository
│ ├── distribution/ # DistributionService, DistributionRepository
│ ├── stock/ # StockService, BatchRepository
│ ├── compliance/ # ComplianceService, QuotaCalculator
│ └── report/ # ReportDataService
├── cannamanage-web/ # PrimeFaces JSF backing beans + XHTML views
│ └── src/main/
│ ├── java/de/cannamanage/web/
│ │ ├── admin/ # AdminDashboardBean, DistributionFormBean
│ │ ├── member/ # MemberDashboardBean
│ │ └── common/ # AuthBean, NavigationBean
│ └── webapp/
│ ├── admin/ # dashboard.xhtml, distribution-form.xhtml, stock.xhtml
│ ├── member/ # dashboard.xhtml, stock.xhtml
│ └── WEB-INF/ # faces-config.xml, web.xml
├── cannamanage-api/ # REST controllers (Spring Boot MVC)
│ └── src/main/java/de/cannamanage/api/
│ ├── member/ # MemberController, MemberDto
│ ├── distribution/ # DistributionController, DistributionDto
│ ├── stock/ # StockController, BatchDto
│ ├── auth/ # AuthController, JwtFilter
│ └── report/ # ReportController
└── cannamanage-report/ # iText 7 PDF generation
└── src/main/java/de/cannamanage/report/
├── monthly/ # MonthlyComplianceReport
├── recall/ # BatchRecallReport
└── export/ # MemberCsvExporter
```
### Module Dependencies
```
cannamanage-domain (no deps on other modules)
cannamanage-service (depends on domain)
cannamanage-api (depends on service, domain)
cannamanage-web (depends on service, domain)
cannamanage-report (depends on service, domain)
```
`cannamanage-api` and `cannamanage-web` are siblings — they do not depend on each other. The web module is the PrimeFaces JSF frontend (MVP); the API module provides the REST layer (future mobile / integration use).
---
## 2. Java Coding Standards
### Language Version
Java 21. All modern language features are permitted and preferred:
| Feature | Use Case | Example |
|---|---|---|
| Records | DTOs, value objects, query results | `record MemberSummary(UUID id, String name, BigDecimal quotaUsed)` |
| Sealed classes | Result types, compliance outcomes | `sealed interface QuotaResult permits QuotaOk, QuotaWarning, QuotaExceeded` |
| Text blocks | JPQL, SQL in tests, JSON fixtures | `String jpql = """ SELECT m FROM Member m WHERE... """` |
| Pattern matching `instanceof` | Type checks in services | `if (result instanceof QuotaExceeded e) { ... }` |
| Switch expressions | Status mapping, report routing | `yield` syntax preferred |
### Package Structure
Pattern: `de.cannamanage.[module].[layer]`
```
de.cannamanage.domain.member # Member entity
de.cannamanage.domain.compliance # ComplianceConstants, exceptions
de.cannamanage.service.distribution # DistributionService
de.cannamanage.api.stock # StockController, BatchDto
de.cannamanage.web.admin # DistributionFormBean
de.cannamanage.report.monthly # MonthlyComplianceReport
```
### Class Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| JPA Entity | `{Domain}` | `Member`, `Distribution`, `Batch` |
| Spring Service | `{Domain}Service` | `MemberService`, `ComplianceService` |
| Repository | `{Domain}Repository` | `DistributionRepository` |
| REST Controller | `{Domain}Controller` | `StockController` |
| JSF Backing Bean | `{Screen}Bean` | `DistributionFormBean`, `AdminDashboardBean` |
| DTO (request) | `{Domain}Request` | `CreateDistributionRequest` |
| DTO (response) | `{Domain}Response` / `{Domain}Dto` | `MemberSummaryDto` |
| Exception | `{Condition}Exception` | `QuotaExceededException`, `BatchRecalledException` |
| Enum | `{Domain}Status` / `{Domain}Type` | `BatchStatus`, `MembershipType` |
| Constants class | `{Domain}Constants` | `ComplianceConstants` |
### Dependency Injection
**Constructor injection only.** Field injection (`@Autowired` on fields) is prohibited.
```java
// ✅ Correct
@Service
@RequiredArgsConstructor
public class DistributionService {
private final DistributionRepository distributionRepository;
private final ComplianceService complianceService;
private final MemberRepository memberRepository;
}
// ❌ Prohibited
@Service
public class DistributionService {
@Autowired
private DistributionRepository distributionRepository;
}
```
Lombok `@RequiredArgsConstructor` is the preferred way to generate the constructor.
### Entity Base Class
All `@Entity` classes must extend `AbstractTenantEntity`. No raw entities without tenant isolation.
```java
// de.cannamanage.domain.common.AbstractTenantEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AbstractTenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
}
// ✅ All entities extend this
@Entity
@Table(name = "members")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member extends AbstractTenantEntity {
// domain fields only — no id/tenantId/audit fields here
}
```
### Transaction Boundaries
- `@Transactional` belongs on **service layer** methods only
- Controllers and repositories must not declare `@Transactional`
- Use `@Transactional(readOnly = true)` for query-only methods — improves performance with Hibernate's read-only session optimization
```java
// ✅ Service layer — correct
@Service
@RequiredArgsConstructor
public class MemberService {
@Transactional(readOnly = true)
public MemberSummaryDto getById(UUID memberId, UUID tenantId) { ... }
@Transactional
public void updateMemberStatus(UUID memberId, MemberStatus status, UUID tenantId) { ... }
}
// ❌ Controller — prohibited
@RestController
public class MemberController {
@Transactional // Never here
@GetMapping("/members/{id}")
public MemberSummaryDto getMember(@PathVariable UUID id) { ... }
}
```
### Lombok Usage
| Annotation | Allowed | Notes |
|---|---|---|
| `@Getter` | ✅ | On entities and DTOs |
| `@Setter` | ✅ | Use sparingly on entities; prefer builder pattern |
| `@Builder` | ✅ | On entities and DTOs |
| `@RequiredArgsConstructor` | ✅ | Services, beans (for DI) |
| `@NoArgsConstructor` | ✅ | JPA requires no-arg constructor |
| `@AllArgsConstructor` | ✅ | With `@Builder` |
| `@ToString` | ✅ | Exclude sensitive fields: `@ToString.Exclude` on `passwordHash` etc. |
| `@EqualsAndHashCode` | ✅ | Entities: only on `id` field |
| `@Data` | ❌ | **Prohibited on entities** — generates mutable setters for all fields, breaks JPA proxy patterns |
| `@SneakyThrows` | ❌ | Never hide checked exceptions |
### Code Style
- **Checkstyle config:** Google Java Style Guide (`checkstyle-google.xml` in parent POM)
- **Indentation:** 4 spaces (no tabs)
- **Line length:** 120 characters max
- **No magic numbers** — use named constants or enums:
```java
// ❌ Magic number
if (member.getAge() < 21) { limit = 30; }
// ✅ Named constant
if (member.getAge() < ComplianceConstants.AGE_LIMIT_UNDER21) {
limit = ComplianceConstants.MONTHLY_LIMIT_UNDER21_GRAMS;
}
```
---
## 3. Compliance Code Rules
These rules apply exclusively to code that enforces CanG (Cannabisgesetz) distribution limits. Violations here carry legal risk.
### Compliance Constants
All legal limits live in a single, centrally tested constants class. **Never hardcode these values inline.**
```java
// de.cannamanage.domain.compliance.ComplianceConstants
public final class ComplianceConstants {
private ComplianceConstants() {} // no instantiation
/** Maximum grams per single distribution for any member. */
public static final BigDecimal DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
/** Monthly gram limit for adult members (age ≥ 21). */
public static final BigDecimal MONTHLY_LIMIT_ADULT_GRAMS = new BigDecimal("50.0");
/** Monthly gram limit for members under 21 years of age (CanG §10 Abs.1). */
public static final BigDecimal MONTHLY_LIMIT_UNDER21_GRAMS = new BigDecimal("30.0");
/** Age threshold below which the reduced monthly limit applies. */
public static final int AGE_LIMIT_UNDER21 = 21;
/** Minimum age for club membership (CanG §15 Abs.1). */
public static final int MINIMUM_MEMBER_AGE = 18;
}
```
### ComplianceService Rules
1. `ComplianceService` methods **must always execute within a `@Transactional` boundary** — either by being called from a service method already in a transaction, or by declaring `@Transactional` themselves. The compliance check and the distribution record creation must be atomic.
2. Every public method in `ComplianceService` must have a corresponding test in `ComplianceServiceTest` that exercises its boundary conditions.
3. `ComplianceService` is the **only** class permitted to read `ComplianceConstants` limits and make pass/fail decisions. No other class performs limit arithmetic.
```java
@Service
@RequiredArgsConstructor
public class ComplianceService {
private final DistributionRepository distributionRepository;
/**
* Validates whether a distribution of the given weight is permitted for the member.
*
* <p>Checks the daily single-distribution limit and the member's monthly quota.
* Must be called inside an existing @Transactional boundary — the calling
* DistributionService is responsible for the transaction.
*
* @param memberId the member receiving the distribution
* @param tenantId the club's tenant identifier
* @param weightGrams the proposed distribution weight in grams
* @return QuotaOk if permitted; QuotaWarning if >80% used; QuotaExceeded if over limit
* @throws IllegalArgumentException if weightGrams exceeds DAILY_LIMIT_GRAMS
*/
public QuotaResult checkDistributionAllowed(UUID memberId, UUID tenantId, BigDecimal weightGrams) {
if (weightGrams.compareTo(ComplianceConstants.DAILY_LIMIT_GRAMS) > 0) {
throw new IllegalArgumentException(
"Single distribution exceeds daily limit of " + ComplianceConstants.DAILY_LIMIT_GRAMS + "g");
}
// ... monthly quota logic using ComplianceConstants
}
}
```
### Distribution Record Immutability
Once written, a `Distribution` record may never be modified (legal audit trail requirement). Enforce this at the JPA level:
```java
@Entity
@Table(name = "distributions")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Distribution extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false, updatable = false)
private UUID memberId;
@Column(name = "batch_id", nullable = false, updatable = false)
private UUID batchId;
@Column(name = "weight_grams", nullable = false, updatable = false,
precision = 8, scale = 2)
private BigDecimal weightGrams;
@Column(name = "distributed_at", nullable = false, updatable = false)
private Instant distributedAt;
@Column(name = "recorded_by_admin_id", nullable = false, updatable = false)
private UUID recordedByAdminId;
// No setters — @Getter only, no @Setter
// updatable = false on ALL columns — Hibernate will reject any UPDATE attempt
}
```
### Compliance Test Coverage Requirement
`ComplianceServiceTest` must include at minimum:
| Test Method | What It Covers |
|---|---|
| `checkDistributionAllowed_givenWeightAt25g_shouldReturnQuotaOk` | Exactly at daily limit |
| `checkDistributionAllowed_givenWeightOver25g_shouldThrowIllegalArgument` | Daily limit exceeded |
| `checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldReturnQuotaExceeded` | Adult at 50g |
| `checkDistributionAllowed_givenUnder21MemberAt30g_shouldReturnQuotaExceeded` | Under-21 at 30g |
| `checkDistributionAllowed_givenUnder21MemberAtAdultLimit_shouldReturnQuotaExceeded` | Under-21 must not reach 50g |
| `checkDistributionAllowed_givenMemberAt80Percent_shouldReturnQuotaWarning` | Warning threshold |
| `checkDistributionAllowed_givenMemberAt40g_shouldReturnQuotaOk` | Normal adult, within limit |
---
## 4. Git Strategy
### Branching Model — GitHub Flow (Solo Dev)
```
main ──────────────────────────────────────────────────────► (production-ready)
│ │ │
└─► feature/US-042─┘ └─► fix/member-age-edge ─┘
```
| Branch | Purpose | Merge Via |
|---|---|---|
| `main` | Production-ready code only; protected | PR only |
| `develop` | Integration branch for in-progress work | Merge to main when stable |
| `feature/US-XXX-short-description` | New feature tied to a user story | PR → develop → main |
| `fix/short-description` | Bug fix | PR → main (or develop if risk is low) |
| `chore/short-description` | Dependency updates, config, CI | PR → main |
**Branch naming examples:**
- `feature/US-042-compliance-quota-check`
- `feature/US-015-member-registration-form`
- `fix/member-under21-age-boundary`
- `chore/update-spring-boot-3.3.1`
### Commit Message Format — Conventional Commits
```
type(scope): short description (imperative, ≤72 chars)
[optional body — explain WHY, not WHAT; reference CanG sections if relevant]
[optional footer]
BREAKING CHANGE: description if applicable
Closes #issue-number
```
#### Types
| Type | When to Use |
|---|---|
| `feat` | New feature or user-visible behavior |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `style` | Formatting, whitespace — no logic change |
| `refactor` | Code restructuring — no behavior change |
| `test` | Adding or updating tests |
| `chore` | Build, deps, config, CI — no production code |
#### Scopes
| Scope | Module / Area |
|---|---|
| `member` | Member management |
| `distribution` | Distribution recording and history |
| `stock` | Strain and batch management |
| `compliance` | `ComplianceService`, `ComplianceConstants`, CanG limits |
| `auth` | JWT, Spring Security, login |
| `report` | PDF/CSV generation |
| `infra` | Docker, CI, Flyway migrations |
| `web` | PrimeFaces JSF views and backing beans |
| `api` | REST controllers and DTOs |
#### Commit Examples
```bash
feat(compliance): add daily 25g distribution limit check
Implements CanG §10 Abs.1 single-distribution cap. ComplianceService
now throws IllegalArgumentException before any quota calculation if
weightGrams > ComplianceConstants.DAILY_LIMIT_GRAMS.
fix(member): correct under-21 flag when age is exactly 21
Age comparison was using < instead of <=. Members who turn 21 on the
exact distribution date now correctly receive the adult (50g) limit.
Closes #17
test(distribution): add quota boundary tests for 30g under-21 limit
Adds 6 parameterized test cases covering 28g, 29g, 29.9g, 30g, 30.1g,
and 31g for under-21 members. All reference ComplianceConstants — no
hardcoded values in test assertions.
chore(deps): update Spring Boot to 3.3.1
CVE-2024-38821 fix included. No API changes required.
docs(compliance): document ComplianceConstants usage policy in README
```
### Tag Strategy
Semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
```bash
git tag -a v1.0.0 -m "Initial release — core member + distribution management"
git tag -a v1.1.0 -m "Add member portal with quota view"
git tag -a v1.0.1 -m "Fix under-21 monthly limit boundary condition"
```
---
## 5. Testing Standards
### Framework Stack
| Layer | Framework | Annotation / Config |
|---|---|---|
| Unit tests | JUnit 5 + Mockito | `@ExtendWith(MockitoExtension.class)` |
| Integration tests | Spring Boot Test + Testcontainers | `@SpringBootTest`, `@Testcontainers` |
| Web layer tests | `MockMvc` | `@WebMvcTest(DistributionController.class)` |
| Repository tests | `DataJpaTest` + Testcontainers | Real PostgreSQL via Testcontainers |
| PDF generation tests | JUnit 5 + iText assertions | Verify PDF structure, not pixel comparison |
### Test Naming Convention
```
methodName_givenCondition_shouldExpectedBehavior
```
```java
// ✅ Correct
@Test
void checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldThrowQuotaExceededException()
@Test
void createDistribution_givenValidRequest_shouldPersistAndReturnDto()
@Test
void getQuotaRemaining_givenUnder21Member_shouldCapAt30g()
@Test
void generateMonthlyReport_givenNoPriorDistributions_shouldReturnZeroTotals()
```
### Unit Test Structure
```java
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest {
@Mock
private DistributionRepository distributionRepository;
@InjectMocks
private ComplianceService complianceService;
@Test
void checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldReturnQuotaExceeded() {
// GIVEN
UUID memberId = UUID.randomUUID();
UUID tenantId = UUID.randomUUID();
BigDecimal currentMonthTotal = ComplianceConstants.MONTHLY_LIMIT_ADULT_GRAMS;
when(distributionRepository.sumWeightByMemberAndMonth(eq(memberId), eq(tenantId), any()))
.thenReturn(currentMonthTotal);
// WHEN
QuotaResult result = complianceService.checkDistributionAllowed(
memberId, tenantId, new BigDecimal("1.0"));
// THEN
assertThat(result).isInstanceOf(QuotaExceeded.class);
}
}
```
### Integration Test Structure
```java
@SpringBootTest
@Testcontainers
@Transactional // rolls back after each test
class DistributionServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
// Tests run against real PostgreSQL — Flyway migrations apply automatically
}
```
### Coverage Target
| Module | Line Coverage Target |
|---|---|
| `cannamanage-service` | **≥ 80%** (enforced by JaCoCo in CI) |
| `cannamanage-domain` | ≥ 70% (entities + value objects) |
| `cannamanage-api` | ≥ 70% (controllers via MockMvc) |
| `cannamanage-report` | ≥ 60% (PDF generation harder to test) |
| `cannamanage-web` | Best effort (JSF backing beans — limited testability) |
### Test Rules
1. **No test may hardcode a compliance limit value.** All assertions must reference `ComplianceConstants`:
```java
// ❌ Prohibited
assertThat(limit).isEqualTo(new BigDecimal("50.0"));
// ✅ Required
assertThat(limit).isEqualTo(ComplianceConstants.MONTHLY_LIMIT_ADULT_GRAMS);
```
2. Parameterized tests (`@ParameterizedTest`) are strongly preferred for boundary condition coverage.
3. Test data builders (or fixtures) must live in `src/test/java/.../fixtures/` — no anonymous object creation scattered across test methods.
---
## 6. Code Review Checklist
Since CannaManage is a solo development project, a self-review checklist replaces a peer review process. All items must be checked before merging any PR to `main`.
### Self-Review Checklist
```markdown
## Compliance & Legal
- [ ] All distribution limits reference `ComplianceConstants` — zero hardcoded values
- [ ] `Distribution` entity fields are annotated `@Column(updatable = false)` where required
- [ ] `ComplianceService` calls are only made inside `@Transactional` boundaries
- [ ] New compliance rules have corresponding unit tests in `ComplianceServiceTest`
## Data & Multi-Tenancy
- [ ] New entity extends `AbstractTenantEntity`
- [ ] `tenant_id` is never accepted from user input (HTTP body, query param, path variable)
- [ ] All repository queries filter by `tenantId` — no cross-tenant data leakage possible
## Security & DSGVO
- [ ] No PII in log statements (no email, full name, member number in log lines)
- [ ] No passwords, tokens, or secrets hardcoded anywhere
- [ ] New REST endpoints annotated with `@PreAuthorize`
- [ ] DTOs validated with Bean Validation annotations (`@NotNull`, `@Size`, etc.)
## Database
- [ ] Flyway migration file added for any schema change (`V{n}__description.sql`)
- [ ] Migration file is backward-compatible or includes rollback notes
- [ ] No `@Column(nullable = false)` added without corresponding DB migration
## Code Quality
- [ ] Constructor injection used — no `@Autowired` field injection
- [ ] No `@Data` on JPA entities
- [ ] No magic numbers — named constants or enums used
- [ ] Checkstyle passes locally (`./mvnw checkstyle:check`)
- [ ] Javadoc on all public service methods
## Testing
- [ ] Unit test added for new service method
- [ ] Integration test updated if schema or contract changed
- [ ] Test coverage does not decrease in `cannamanage-service`
- [ ] Test method names follow `method_givenCondition_shouldExpect` pattern
## General
- [ ] Commit message follows Conventional Commits format
- [ ] Branch name follows `feature/US-XXX-` or `fix/` convention
- [ ] No `TODO` comments left in production code (use GitHub Issues instead)
```
---
## 7. Security Standards
### Authentication & Authorization
```java
// JWT secret from environment only — never in application.properties
@Value("${JWT_SECRET}")
private String jwtSecret;
// All endpoints behind @PreAuthorize — no security by obscurity
@RestController
@RequestMapping("/api/v1/distributions")
public class DistributionController {
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public Page<DistributionDto> list(...) { ... }
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public DistributionDto create(...) { ... }
}
// Member portal endpoints restricted to role + own data
@GetMapping("/api/v1/member/quota")
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
public QuotaDto getQuota(@RequestParam UUID memberId) { ... }
```
### CORS Configuration
```java
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// No wildcard — club subdomain only
config.setAllowedOriginPatterns(List.of("https://*.cannamanage.de"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
// ...
}
```
### Input Validation
All DTOs must be annotated with Bean Validation constraints. The controller calls `@Valid` on request bodies.
```java
public record CreateDistributionRequest(
@NotNull(message = "Member ID is required")
UUID memberId,
@NotNull(message = "Batch ID is required")
UUID batchId,
@NotNull(message = "Weight is required")
@DecimalMin(value = "0.1", message = "Weight must be at least 0.1g")
@DecimalMax(value = "25.0", message = "Weight cannot exceed daily limit")
BigDecimal weightGrams
) {}
```
### SQL Injection Prevention
- **JPA named queries only** — no string concatenation in JPQL
- Spring Data JPA repository methods generate parameterized queries automatically
- Native SQL queries use `@Query` with named parameters (`:param` syntax), never `+`
```java
// ✅ Safe — parameterized
@Query("SELECT SUM(d.weightGrams) FROM Distribution d WHERE d.memberId = :memberId AND d.tenantId = :tenantId AND MONTH(d.distributedAt) = :month")
BigDecimal sumWeightByMemberAndMonth(@Param("memberId") UUID memberId,
@Param("tenantId") UUID tenantId,
@Param("month") int month);
// ❌ Prohibited — SQL injection risk
String jpql = "SELECT ... WHERE name = '" + memberName + "'";
```
### Password Hashing
```java
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength 12 — ~250ms per hash on modern hardware
}
```
### Sensitive Data Logging
```java
// ❌ Never log PII
log.info("Processing distribution for member: {}", member.getEmail());
log.info("Member {} requested quota", member.getFullName());
// ✅ Log with opaque identifiers only
log.info("Processing distribution for memberId={} tenantId={}", member.getId(), tenantId);
log.info("Quota check passed for memberId={}", memberId);
```
---
## 8. Environment Configuration
### Environment Variables Reference
All secrets and environment-specific configuration are provided via environment variables. Never commit secrets to version control.
| Variable | Required | Default | Description |
|---|---|---|---|
| `DB_URL` | ✅ | — | JDBC URL, e.g. `jdbc:postgresql://localhost:5432/cannamanage` |
| `DB_USERNAME` | ✅ | — | PostgreSQL username |
| `DB_PASSWORD` | ✅ | — | PostgreSQL password |
| `JWT_SECRET` | ✅ | — | 256-bit (32-byte) random secret for JWT signing; generate with `openssl rand -base64 32` |
| `JWT_ACCESS_TTL_HOURS` | ❌ | `8` | Access token TTL in hours |
| `JWT_REFRESH_TTL_DAYS` | ❌ | `30` | Refresh token TTL in days |
| `STRIPE_SECRET_KEY` | ✅ (billing) | — | Stripe secret key (starts with `sk_live_` in production) |
| `STRIPE_WEBHOOK_SECRET` | ✅ (billing) | — | Stripe webhook signing secret for subscription events |
| `MAIL_HOST` | ✅ | — | SMTP host for transactional emails |
| `MAIL_USERNAME` | ✅ | — | SMTP username |
| `MAIL_PASSWORD` | ✅ | — | SMTP password |
| `MAIL_FROM` | ❌ | `noreply@cannamanage.de` | From address for system emails |
| `SENTRY_DSN` | ❌ | — | Sentry DSN for error tracking; omit to disable |
| `APP_BASE_URL` | ✅ | — | Application base URL, e.g. `https://meinclub.cannamanage.de` |
| `ADMIN_INITIAL_EMAIL` | ❌ | — | Seed admin email on first startup (Flyway data migration) |
| `ADMIN_INITIAL_PASSWORD` | ❌ | — | Seed admin password — change immediately after first login |
### `application.properties` Pattern
```properties
# application.properties — references env vars only; no values hardcoded
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
jwt.secret=${JWT_SECRET}
jwt.access.ttl-hours=${JWT_ACCESS_TTL_HOURS:8}
jwt.refresh.ttl-days=${JWT_REFRESH_TTL_DAYS:30}
stripe.secret-key=${STRIPE_SECRET_KEY}
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
spring.mail.host=${MAIL_HOST}
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}
sentry.dsn=${SENTRY_DSN:}
```
### Profile Strategy
> **`spring.profiles.active=prod` is NOT a security mechanism.** Never use profile-based condition checks to gate security-relevant behavior (e.g., `@ConditionalOnProperty(name="spring.profiles.active", havingValue="prod")`).
Profiles are used **only** for infrastructure wiring (in-memory H2 vs. real PostgreSQL for tests, Testcontainers vs. external DB).
| Profile | Usage |
|---|---|
| `(none)` | Production — all config from environment variables |
| `test` | JUnit integration tests — Testcontainers PostgreSQL |
| `dev` | Local development — Docker Compose PostgreSQL, verbose SQL logging |
### Local Development Setup
```bash
# Start local PostgreSQL via Docker Compose
docker compose up -d postgres
# Run with dev profile (verbose SQL, local DB)
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev \
-Dspring-boot.run.arguments="--DB_URL=jdbc:postgresql://localhost:5432/cannamanage_dev \
--DB_USERNAME=cannamanage --DB_PASSWORD=dev_password \
--JWT_SECRET=$(openssl rand -base64 32)"
```
---
*End of CannaManage coding standards. See also [03-ARCHITECTURE.md](03-ARCHITECTURE.md) for data model and [05-API-SPEC.md](05-API-SPEC.md) for REST contract.*
@@ -0,0 +1,439 @@
# 08 — Test Plan
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Status:** Draft
---
## 1. Test Strategy Overview
### 1.1 Testing Pyramid
```
┌─────────────────┐
│ E2E Tests │ 10% — Playwright (deferred to v2)
│ (10%) │
├─────────────────┤
│ Integration │ 20% — Spring Boot Test + Testcontainers
│ Tests (20%) │
├─────────────────┤
│ Unit Tests │ 70% — JUnit 5 + Mockito
│ (70%) │
└─────────────────┘
```
The compliance-critical path (`ComplianceService`) requires **100% line coverage** — no exceptions. Every quota rule is a legal obligation under CanG §§1922.
### 1.2 Tools and Frameworks
| Layer | Tool | Purpose |
|-------|------|---------|
| Unit | JUnit 5 (`junit-jupiter`) | Test runner |
| Unit | Mockito 5 | Mock dependencies |
| Unit | AssertJ | Fluent assertions |
| Integration | Spring Boot Test (`@SpringBootTest`) | Full application context |
| Integration | Testcontainers (PostgreSQL module) | Real DB in Docker |
| Integration | MockMvc / RestAssured | HTTP layer testing |
| Coverage | JaCoCo | Line/branch coverage reporting |
| E2E | Playwright (Java) | Browser automation — **deferred to v2** |
### 1.3 CI Trigger Policy
| Branch pattern | Tests run |
|---------------|-----------|
| `feature/*` | Unit tests only (`./mvnw test`) |
| `develop` | Unit + Integration (`./mvnw verify -P integration-tests`) |
| `main` | Unit + Integration + coverage gate |
Coverage gate blocks merge to `main` if `ComplianceService` drops below 100%.
---
## 2. Unit Test Cases — ComplianceService
**Class under test:** `de.cannamanage.service.ComplianceService`
**Dependencies mocked:** `DistributionRepository`, `MemberRepository`, `StrainRepository`
---
**TC-001** | `checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly`
- **Given:** Adult member (age 35) with exactly 50.0g distributed this calendar month, requesting 1.0g more
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
- **Compliance ref:** CanG §19(2) — 50g/month limit for adults
---
**TC-002** | `checkDistributionAllowed_givenUnder21AtMonthlyLimit_shouldThrowQuotaExceededMonthly`
- **Given:** Under-21 member (age 18) with exactly 30.0g distributed this calendar month, requesting 1.0g more
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
- **Compliance ref:** CanG §19(3) — 30g/month limit for under-21 members
---
**TC-003** | `checkDistributionAllowed_givenAdultAtDailyLimit_shouldThrowQuotaExceededDaily`
- **Given:** Adult member with exactly 25.0g distributed today, requesting 0.5g more
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.5)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY`
- **Compliance ref:** CanG §19(2) — 25g/day limit
---
**TC-004** | `checkDistributionAllowed_givenUnder21RequestingHighThcStrain_shouldThrowHighThcRestricted`
- **Given:** Under-21 member (age 20), requesting a strain with THC content 12.5% (exceeds 10% threshold)
- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0, strainId)`
- **Then:** Throws `QuotaExceededException` with code `HIGH_THC_RESTRICTED_UNDER_21`
- **Compliance ref:** CanG §19(4) — under-21 members restricted to ≤10% THC strains
---
**TC-005** | `checkDistributionAllowed_givenAdultAt49gMonthlyRequesting2g_shouldThrowQuotaExceededMonthly`
- **Given:** Adult member with 49.0g distributed this month, requesting 2.0g (would total 51.0g)
- **When:** `complianceService.checkDistributionAllowed(memberId, 2.0)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
- **Note:** Even partial over-quota requests must be rejected in full; no partial fulfillment
---
**TC-006** | `checkDistributionAllowed_givenAdultAt0gRequesting25g_shouldReturnAllowed`
- **Given:** Adult member with 0.0g distributed this month and today, requesting 25.0g
- **When:** `complianceService.checkDistributionAllowed(memberId, 25.0)`
- **Then:** Returns `DistributionAllowedResult` with `allowed = true`, `remainingDaily = 0.0`, `remainingMonthly = 25.0`
- **Note:** Exactly at daily limit — allowed
---
**TC-007** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_1g_shouldReturnAllowed`
- **Given:** Adult member with 24.9g distributed today, requesting 0.1g (would reach exactly 25.0g)
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.1)`
- **Then:** Returns `allowed = true`, `remainingDaily = 0.0`
- **Note:** Boundary — exactly at limit is allowed
---
**TC-008** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_2g_shouldThrowQuotaExceededDaily`
- **Given:** Adult member with 24.9g distributed today, requesting 0.2g (would total 25.1g)
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.2)`
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY`
- **Note:** Boundary + 1 — must be blocked
---
**TC-009** | `checkDistributionAllowed_givenSuspendedMember_shouldThrowMemberInactive`
- **Given:** Member with `status = MemberStatus.SUSPENDED`, requesting any amount
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE`
- **Note:** Status check must occur before any quota calculation
---
**TC-010** | `checkDistributionAllowed_givenExpelledMember_shouldThrowMemberInactive`
- **Given:** Member with `status = MemberStatus.EXPELLED`, requesting any amount
- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0)`
- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE`
- **Note:** Expelled members are permanently blocked, no quota check performed
---
## 3. Unit Test Cases — MemberService
**Class under test:** `de.cannamanage.service.MemberService`
**Dependencies mocked:** `MemberRepository`, `ClubRepository`, `PasswordEncoder`
---
**TC-011** | `createMember_givenAge17_shouldThrowUnderageException`
- **Given:** `CreateMemberRequest` with DOB resulting in age 17 at time of registration
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Throws `UnderageException` with message containing minimum age (18)
- **Compliance ref:** CanG §6(1) — membership requires minimum age 18
---
**TC-012** | ~~`createMember_givenAge18_shouldSucceedAndSetIsUnder21False`~~*this case is incorrect*
> **Note:** Age 18 IS under 21, therefore `isUnder21 = true`. See TC-013.
---
**TC-013** | `createMember_givenAge18_shouldSucceedAndSetIsUnder21True`
- **Given:** `CreateMemberRequest` with DOB resulting in age 18 at time of registration
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Returns created `Member` with `isUnder21 = true`, `status = ACTIVE`
- **Note:** The 18-year-old flag matters for quota enforcement (30g/month, ≤10% THC)
---
**TC-014** | `createMember_givenAge21_shouldSucceedAndSetIsUnder21False`
- **Given:** `CreateMemberRequest` with DOB resulting in age exactly 21 at time of registration
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Returns created `Member` with `isUnder21 = false`, `status = ACTIVE`
- **Note:** Age 21 is the boundary — exactly 21 qualifies as adult for quota purposes
---
**TC-015** | `createMember_givenDuplicateEmail_shouldThrowDuplicateMemberException`
- **Given:** `MemberRepository.existsByEmailAndTenantId(email, tenantId)` returns `true`
- **When:** `memberService.createMember(request, tenantId)`
- **Then:** Throws `DuplicateMemberException` with code `DUPLICATE_EMAIL`
- **Note:** Email uniqueness is per-tenant, not global
---
## 4. Unit Test Cases — Tenant Isolation
**Class under test:** JPA repositories with `@TenantAware` filter active
**Setup:** Thread-local `TenantContext` populated via `TenantContextHolder.setTenantId()`
---
**TC-016** | `distributionRepository_givenTenantAContext_shouldNotReturnTenantBData`
- **Given:** Two tenants (Tenant A: UUID-A, Tenant B: UUID-B); 5 distributions for each; `TenantContextHolder` set to UUID-A
- **When:** `distributionRepository.findAll()`
- **Then:** Returns exactly 5 records, all with `tenantId = UUID-A`; zero records from Tenant B
- **Note:** Hibernate filter `tenantFilter` must be enabled in `TenantAwareInterceptor`
---
**TC-017** | `memberRepository_givenTenantAContext_shouldNotSeeClubBMembers`
- **Given:** 10 members in Club A, 8 members in Club B; context set to Club A's tenant
- **When:** `memberRepository.findAll()`
- **Then:** Returns exactly 10 records; no member from Club B present
- **Note:** Cross-tenant data leakage is a GDPR violation, not just a business bug
---
## 5. Integration Test Cases (Testcontainers)
**Setup:** `@SpringBootTest(webEnvironment = RANDOM_PORT)` with `@Testcontainers`; real PostgreSQL 16 container; Flyway migrations applied before each test class.
---
**TC-018** | `POST /api/v1/distributions — successful distribution recording`
- **Given:** Active adult member with 0g distributed; valid `DistributionRequest` for 10.0g; authenticated as `ROLE_ADMIN`
- **When:** `POST /api/v1/distributions` with valid JWT
- **Then:** HTTP 201; response body contains `distributionId`, `amount = 10.0`, `recordedAt`; DB contains one `distribution` row with `is_recalled = false`
---
**TC-019** | `POST /api/v1/distributions — quota exceeded returns 422`
- **Given:** Adult member with 50.0g already distributed this month; requesting 1.0g more
- **When:** `POST /api/v1/distributions`
- **Then:** HTTP 422; response body `{"errorCode": "QUOTA_EXCEEDED_MONTHLY", "remainingMonthly": 0.0}`
---
**TC-020** | `POST /api/v1/distributions — concurrent race condition (last gram)`
- **Given:** Adult member with 24.9g distributed today; two concurrent requests each for 0.2g (both would individually exceed 25g/day)
- **When:** Both requests fired simultaneously via two threads
- **Then:** Exactly one succeeds (HTTP 201); exactly one fails (HTTP 422 `QUOTA_EXCEEDED_DAILY`); DB total does not exceed 25.0g
- **Mechanism:** `SELECT ... FOR UPDATE` on quota aggregation query prevents double-spend
---
**TC-021** | `POST /api/v1/auth/login — valid credentials return JWT`
- **Given:** Admin user with email `admin@test-club.de`, correct password
- **When:** `POST /api/v1/auth/login` with `{"email": "admin@test-club.de", "password": "..."}`
- **Then:** HTTP 200; response contains `accessToken` (JWT), `tokenType = "Bearer"`, `expiresIn = 3600`
---
**TC-022** | `POST /api/v1/auth/login — invalid credentials return 401`
- **Given:** Admin user exists; wrong password provided
- **When:** `POST /api/v1/auth/login` with wrong password
- **Then:** HTTP 401; response `{"errorCode": "INVALID_CREDENTIALS"}`; no token issued
---
**TC-023** | `GET /api/v1/members — ROLE_MEMBER accessing admin endpoint returns 403`
- **Given:** Authenticated user with `ROLE_MEMBER` JWT (not ROLE_ADMIN)
- **When:** `GET /api/v1/members` (admin-only endpoint)
- **Then:** HTTP 403; response `{"errorCode": "FORBIDDEN"}`
---
**TC-024** | `GET /api/v1/members/{id}/quota — member accessing own quota returns 200`
- **Given:** Authenticated member with JWT; requesting their own `memberId`
- **When:** `GET /api/v1/members/{ownId}/quota`
- **Then:** HTTP 200; response contains `dailyUsed`, `dailyRemaining`, `monthlyUsed`, `monthlyRemaining`, `isUnder21`
---
**TC-025** | `GET /api/v1/members/{otherMemberId}/quota — member accessing other member returns 403`
- **Given:** Authenticated member requesting quota of a *different* member (same club)
- **When:** `GET /api/v1/members/{otherMemberId}/quota`
- **Then:** HTTP 403; GDPR principle: members must not see each other's consumption data
---
**TC-026** | `POST /api/v1/stock/batches/{id}/recall — verify cascade`
- **Given:** Batch `BATCH-TEST-001` with 3 distributions recorded against it; `isRecalled = false`
- **When:** `POST /api/v1/stock/batches/BATCH-TEST-001/recall` with `{"reason": "Contamination detected"}`
- **Then:** HTTP 200; batch `isRecalled = true`; all 3 distribution records have `isRecalled = true`; response body contains list of 3 affected member IDs for notification
---
## 6. Test Data Fixtures
Define these constants in `src/test/java/de/cannamanage/fixtures/TestFixtures.java`:
```java
public final class TestFixtures {
// Tenant
public static final UUID TENANT_ID =
UUID.fromString("00000000-0000-0000-0000-000000000001");
public static final String CLUB_NAME = "Test Cannabis Club e.V.";
// Adult member
public static final UUID ADULT_MEMBER_ID =
UUID.fromString("00000000-0000-0000-0000-000000000010");
public static final String ADULT_MEMBER_NAME = "Klaus Mueller";
public static final LocalDate ADULT_MEMBER_DOB =
LocalDate.of(1990, 1, 1); // age 36 as of 2026
// Under-21 member
public static final UUID UNDER21_MEMBER_ID =
UUID.fromString("00000000-0000-0000-0000-000000000011");
public static final String UNDER21_MEMBER_NAME = "Lisa Mayer";
public static final LocalDate UNDER21_MEMBER_DOB =
LocalDate.of(2007, 1, 1); // age 19 as of 2026, isUnder21=true
// Strain
public static final UUID STRAIN_ID =
UUID.fromString("00000000-0000-0000-0000-000000000020");
public static final String STRAIN_NAME = "Test OG";
public static final double STRAIN_THC_PERCENT = 20.0;
public static final double STRAIN_CBD_PERCENT = 1.0;
// Batch
public static final String BATCH_NUMBER = "BATCH-TEST-001";
public static final double BATCH_INITIAL_WEIGHT_G = 500.0;
// Compliance constants (mirror ComplianceConstants.java)
public static final double ADULT_MONTHLY_LIMIT_G = 50.0;
public static final double UNDER21_MONTHLY_LIMIT_G = 30.0;
public static final double DAILY_LIMIT_G = 25.0;
public static final double UNDER21_MAX_THC_PERCENT = 10.0;
}
```
---
## 7. Coverage Requirements
| Module | Test Type | Minimum Coverage | Enforcement |
|--------|-----------|-----------------|-------------|
| `cannamanage-service` | Unit | 80% line | JaCoCo CI gate |
| `cannamanage-api` | Integration | 70% endpoint coverage | Manual checklist |
| `cannamanage-domain` | Unit | 60% line (entities/enums) | JaCoCo CI gate |
| `ComplianceService` | Unit | **100% line + branch** | JaCoCo CI gate — hard fail |
| `TenantIsolationFilter` | Unit + Integration | 90% line | JaCoCo CI gate |
> **Rationale for 100% on ComplianceService:** Each uncovered line is a potential legal violation. A missed branch in quota calculation could result in over-distribution — a criminal offence under CanG §34. This is not negotiable.
### JaCoCo Configuration (`pom.xml`)
```xml
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>jacoco-check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>CLASS</element>
<includes>
<include>de.cannamanage.service.ComplianceService</include>
</includes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>1.00</minimum>
</limit>
</limits>
</rule>
<rule>
<element>PACKAGE</element>
<includes>
<include>de.cannamanage.service.*</include>
</includes>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
```
---
## 8. Test Execution
```bash
# Run all unit tests
./mvnw test -pl cannamanage-service
# Run integration tests (requires Docker for Testcontainers)
./mvnw verify -P integration-tests
# Run specific test class
./mvnw test -pl cannamanage-service -Dtest=ComplianceServiceTest
# Coverage report (output: target/site/jacoco/index.html)
./mvnw verify jacoco:report
# Coverage report for single module
./mvnw verify jacoco:report -pl cannamanage-service
# Run compliance tests only (tagged)
./mvnw test -pl cannamanage-service -Dgroups=compliance
# Check coverage gate (will fail build if thresholds not met)
./mvnw verify -P coverage-check
```
### Testcontainers Docker requirement
Integration tests require Docker available on the host. The PostgreSQL container is pulled automatically by Testcontainers on first run. Ensure:
- Docker daemon running: `systemctl start docker` (or `docker info`)
- User in `docker` group: `sudo usermod -aG docker $USER`
### Test annotation conventions
```java
// Unit test — no Spring context
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest { ... }
// Integration test — full context + Testcontainers
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class DistributionIntegrationTest { ... }
// Tag compliance tests for selective execution
@Tag("compliance")
@Test
void checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly() { ... }
```
@@ -0,0 +1,639 @@
# 09 — Deployment Guide
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose
---
## 1. Prerequisites
### Hetzner VPS Specification
| Resource | Value | Monthly Cost |
|----------|-------|-------------|
| Server type | CX21 | ~€5.88/month |
| vCPU | 2 | — |
| RAM | 4 GB | — |
| SSD | 40 GB | — |
| Network | 20 TB transfer | — |
| OS | Ubuntu 22.04 LTS | — |
> **Scale-up trigger:** Upgrade to CX31 (8GB RAM) when concurrent active clubs exceeds 20. PostgreSQL is the memory consumer — headroom is consumed by connection pools, not application heap.
### DNS Setup
| Record | Type | Value |
|--------|------|-------|
| `cannamanage.de` | A | `<VPS-IP>` |
| `app.cannamanage.de` | A | `<VPS-IP>` |
| `*.cannamanage.de` | A | `<VPS-IP>` |
Wildcard A record enables future per-club subdomains (`clubname.cannamanage.de`) without additional DNS changes.
### Required Software
- Docker Engine 24+ (`docker.io` or Docker CE)
- Docker Compose v2 (`docker compose` — not `docker-compose`)
- Certbot with Nginx plugin (`python3-certbot-nginx`)
- OpenSSH server (enabled by default on Ubuntu)
---
## 2. Infrastructure Architecture
```mermaid
graph TB
Internet["🌐 Internet"] -->|"port 80/443"| Nginx["Nginx (reverse proxy)"]
Nginx -->|"http://app:8080"| App["cannamanage-app\n(Spring Boot 3.x)"]
App -->|"jdbc:postgresql://db:5432"| DB["PostgreSQL 16\n(cannamanage DB)"]
LetsEncrypt["Let's Encrypt\n(certbot auto-renew)"] -.->|"TLS cert"| Nginx
Gitea["Gitea Actions\n(homelab CI)"] -->|"SSH + docker compose"| VPS["Hetzner VPS\n/opt/cannamanage"]
subgraph VPS ["Hetzner VPS — Docker network: cannamanage_net"]
Nginx
App
DB
end
```
All three services run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
---
## 3. Docker Compose Setup
**File:** `/opt/cannamanage/docker-compose.yml`
```yaml
version: '3.9'
networks:
cannamanage_net:
driver: bridge
volumes:
pgdata:
driver: local
nginx_certs:
driver: local
services:
nginx:
image: nginx:1.25-alpine
container_name: cannamanage-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- nginx_certs:/etc/letsencrypt:ro
- /var/log/nginx:/var/log/nginx
depends_on:
app:
condition: service_healthy
networks:
- cannamanage_net
restart: unless-stopped
app:
image: cannamanage:${VERSION:-latest}
container_name: cannamanage-app
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME}
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
- APP_JWT_SECRET=${JWT_SECRET}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- SPRING_MAIL_HOST=${MAIL_HOST}
- SPRING_MAIL_USERNAME=${MAIL_USERNAME}
- SPRING_MAIL_PASSWORD=${MAIL_PASSWORD}
- SENTRY_DSN=${SENTRY_DSN}
- SPRING_PROFILES_ACTIVE=production
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- cannamanage_net
restart: unless-stopped
db:
image: postgres:16-alpine
container_name: cannamanage-db
environment:
- POSTGRES_DB=cannamanage
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d cannamanage"]
interval: 10s
timeout: 5s
retries: 5
networks:
- cannamanage_net
restart: unless-stopped
# PostgreSQL port intentionally NOT exposed externally
```
**Nginx site config** (`/opt/cannamanage/nginx/conf.d/cannamanage.conf`):
```nginx
server {
listen 80;
server_name app.cannamanage.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.cannamanage.de;
ssl_certificate /etc/letsencrypt/live/app.cannamanage.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.cannamanage.de/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# Stripe webhook — allow larger body
location /api/v1/billing/webhook {
proxy_pass http://app:8080;
proxy_set_header Host $host;
client_max_body_size 1m;
}
}
```
---
## 4. Environment Variables
**File:** `/opt/cannamanage/.env` (never committed to git — add to `.gitignore`)
```bash
# Database
DB_USERNAME=cannamanage_user
DB_PASSWORD=<strong-random-password-min-32-chars>
# JWT signing key (256-bit minimum — generate with: openssl rand -hex 32)
JWT_SECRET=<256-bit-random-hex>
# Stripe (use sk_live_ for production, sk_test_ for staging)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email (SMTP)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=noreply@cannamanage.de
MAIL_PASSWORD=<mail-password>
MAIL_FROM=CannaManage <noreply@cannamanage.de>
# Error tracking
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project-id>
# Application version (set by CI during deploy)
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`.
---
## 5. First-Time Deployment
### Step 1 — Create Hetzner VPS
1. Log into [console.hetzner.cloud](https://console.hetzner.cloud)
2. Create server: **CX21**, Ubuntu 22.04, Nuremberg or Frankfurt datacenter
3. Add your SSH public key during setup (`cat ~/.ssh/id_ed25519.pub`)
4. Note the assigned IPv4 address — update DNS A records
### Step 2 — Install Docker + Docker Compose
```bash
ssh root@<VPS-IP>
# Update system
apt update && apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
# Add deploy user (never run production as root)
adduser deploy
usermod -aG docker deploy
usermod -aG sudo deploy
# Install Certbot
apt install -y python3-certbot-nginx certbot
```
### Step 3 — Clone Repository
```bash
su - deploy
mkdir -p /opt/cannamanage
cd /opt/cannamanage
git clone http://192.168.188.119:30008/pplate/cannamanage.git .
# Or from public mirror when available
```
### Step 4 — Create Production `.env`
```bash
cd /opt/cannamanage
cp .env.example .env
nano .env # Fill in all production secrets
chmod 600 .env
```
### Step 5 — Obtain SSL Certificate
```bash
# Stop anything on port 80 first (nothing should be running yet)
certbot certonly --standalone \
-d app.cannamanage.de \
--non-interactive \
--agree-tos \
-m ssl@cannamanage.de
# Symlink certs into nginx_certs volume location
# Certbot places certs at /etc/letsencrypt/live/app.cannamanage.de/
```
### Step 6 — Build Docker Image
```bash
# On the VPS (or build locally and push to registry)
./mvnw package -DskipTests -P production
docker build -t cannamanage:latest .
```
### Step 7 — Start Services
```bash
cd /opt/cannamanage
docker compose up -d
```
### Step 8 — Verify Health
```bash
# All containers should be 'healthy' or 'running'
docker compose ps
# Check application logs
docker compose logs -f app --tail=100
# Test health endpoint
curl -f http://localhost:8080/actuator/health
# Expected: {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}
```
### Step 9 — Flyway Migrations
Flyway runs automatically on Spring Boot startup. Verify migration log:
```bash
docker compose logs app | grep -i flyway
# Expected: Successfully applied N migrations to schema "public"
```
### Step 10 — Create First Admin User
```bash
# Option A: via REST API (recommended)
curl -X POST https://app.cannamanage.de/api/v1/admin/bootstrap \
-H "Content-Type: application/json" \
-d '{
"adminEmail": "admin@yourclub.de",
"adminPassword": "<strong-password>",
"clubName": "Your Club e.V.",
"clubRegistrationNumber": "VR 12345"
}'
# The bootstrap endpoint is disabled after first use (one-time setup flag in DB)
```
### Step 11 — Verify Production Access
```bash
# Web UI
open https://app.cannamanage.de
# API health check
curl https://app.cannamanage.de/actuator/health
```
---
## 6. CI/CD Pipeline (Gitea Actions)
**File:** `.gitea/workflows/deploy.yml`
```yaml
name: Deploy to Production
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run unit tests
run: ./mvnw test -pl cannamanage-service
- name: Run integration tests
run: ./mvnw verify -P integration-tests
# Testcontainers requires Docker — GitHub/Gitea hosted runners have Docker pre-installed
- name: Coverage gate check
run: ./mvnw verify -P coverage-check
build-and-deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build JAR
run: ./mvnw package -DskipTests -P production
- name: Build Docker image
run: |
docker build \
-t cannamanage:${{ github.sha }} \
-t cannamanage:latest \
.
- name: Save Docker image
run: docker save cannamanage:${{ github.sha }} | gzip > /tmp/cannamanage.tar.gz
- name: Copy image to VPS
run: |
scp -o StrictHostKeyChecking=no \
/tmp/cannamanage.tar.gz \
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
- name: Deploy via SSH
run: |
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.HETZNER_IP }} "
set -e
cd /opt/cannamanage
# Load new image
docker load < /tmp/cannamanage.tar.gz
rm /tmp/cannamanage.tar.gz
# Rolling restart app only (DB stays up)
VERSION=${{ github.sha }} docker compose up -d app
# Wait for health
sleep 10
docker compose ps app | grep 'healthy' || (docker compose logs app --tail=50 && exit 1)
# Prune old images (keep last 3)
docker image prune -f
"
```
### Required Gitea Repository Secrets
| Secret | Value |
|--------|-------|
| `HETZNER_IP` | VPS IPv4 address |
| `SSH_PRIVATE_KEY` | Private key for `deploy` user |
Add deploy user's public key to VPS authorized_keys:
```bash
# On VPS as deploy user
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<gitea-actions-public-key>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
---
## 7. Database Backup
### Automated Daily Backup
Add to root crontab (`crontab -e`):
```bash
# Daily backup at 03:00 UTC — keep 14 days
0 3 * * * docker exec cannamanage-db pg_dump \
-U cannamanage_user \
--format=custom \
cannamanage | gzip > /opt/backups/cannamanage_$(date +\%Y\%m\%d).sql.gz
# Cleanup backups older than 14 days
5 3 * * * find /opt/backups -name "cannamanage_*.sql.gz" -mtime +14 -delete
```
Create backup directory:
```bash
mkdir -p /opt/backups
chown deploy:deploy /opt/backups
```
### Restore from Backup
```bash
# Restore (caution: this overwrites existing data)
gunzip -c /opt/backups/cannamanage_20260406.sql.gz | \
docker exec -i cannamanage-db pg_restore \
-U cannamanage_user \
--clean \
--dbname=cannamanage
# Verify restore
docker exec cannamanage-db psql \
-U cannamanage_user \
-d cannamanage \
-c "SELECT COUNT(*) FROM clubs;"
```
### Offsite Backup (Optional)
For additional redundancy, sync backups to Hetzner Object Storage:
```bash
# Install s3cmd and configure with Hetzner S3-compatible endpoint
s3cmd sync /opt/backups/ s3://cannamanage-backups/
```
---
## 8. Monitoring & Health Checks
### Spring Boot Actuator
The application exposes health endpoints via `spring-boot-actuator`:
```bash
# Full health detail (requires ROLE_ADMIN JWT)
GET /actuator/health
# Example response
{
"status": "UP",
"components": {
"db": { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
"diskSpace": { "status": "UP", "details": { "total": 42GB, "free": 30GB } },
"ping": { "status": "UP" }
}
}
```
Expose only `health` and `info` publicly in `application-production.yml`:
```yaml
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
```
### Log Locations
| Source | Location |
|--------|----------|
| Application logs | `docker compose logs -f app` |
| Nginx access logs | `/var/log/nginx/access.log` |
| Nginx error logs | `/var/log/nginx/error.log` |
| PostgreSQL logs | `docker compose logs db` |
| Sentry (errors) | `https://sentry.io/organizations/<org>/` |
### Alerting
Configure Sentry to email on new errors:
1. Set `SENTRY_DSN` in `.env`
2. Add `io.sentry:sentry-spring-boot-starter-jakarta:7.x` to POM
3. Sentry auto-captures all unhandled exceptions with full stack trace
Simple uptime check via `cron` + email:
```bash
# Health check every 5 minutes — email on 3 consecutive failures
*/5 * * * * /opt/cannamanage/scripts/health_check.sh
```
```bash
#!/bin/bash
# /opt/cannamanage/scripts/health_check.sh
HEALTH_URL="https://app.cannamanage.de/actuator/health"
FAIL_COUNT_FILE="/tmp/cannamanage_health_fails"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL")
if [ "$HTTP_STATUS" != "200" ]; then
FAILS=$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0)
FAILS=$((FAILS + 1))
echo "$FAILS" > "$FAIL_COUNT_FILE"
if [ "$FAILS" -ge 3 ]; then
echo "CannaManage health check failed $FAILS times" | \
mail -s "ALERT: CannaManage DOWN" admin@cannamanage.de
fi
else
echo 0 > "$FAIL_COUNT_FILE"
fi
```
---
## 9. SSL Certificate Renewal
Let's Encrypt certificates expire after 90 days. Certbot handles renewal automatically:
```bash
# Test renewal (dry run — no actual renewal)
certbot renew --dry-run
# Manual renewal
certbot renew --nginx
# Reload Nginx after renewal
docker exec cannamanage-nginx nginx -s reload
```
### Auto-Renewal via Cron
```bash
# Renew at 02:00 UTC on the 1st and 15th of each month
0 2 1,15 * * certbot renew --quiet --nginx && \
docker exec cannamanage-nginx nginx -s reload
```
Certbot only renews when the certificate is less than 30 days from expiry — safe to run frequently.
---
## 10. Rollback Procedure
If a deployment causes issues:
```bash
# On VPS — list recent images
docker images cannamanage --format "table {{.Tag}}\t{{.CreatedAt}}"
# Roll back to previous SHA
cd /opt/cannamanage
VERSION=<previous-sha> docker compose up -d app
# Verify health after rollback
docker compose ps app
curl https://app.cannamanage.de/actuator/health
```
If database migrations were applied and rollback is needed:
1. Restore from last backup (see Section 7)
2. Redeploy the previous image version
3. Flyway baseline the schema at previous version
> **Note:** Flyway migrations are append-only and forward-only. Design migrations to be reversible where possible (add columns before removing old ones, etc.).
@@ -0,0 +1,97 @@
# 10 — Sprint 0 Planning Retrospective
**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)
**Outcome:** ✅ Complete — 10-document suite written, architecture locked
---
## What Went Well ✅
**AI-assisted documentation at scale.** The complete documentation suite (10 documents, ~25,000 words total) was created in a single focused session using the Roo Orchestrator mode to coordinate multi-document generation. This would have taken 23 days manually. The quality is high enough to serve as actual implementation guidance — not placeholder text.
**Legal analysis confirmed viability early.** The CanG compliance review (Phase 1) identified the key constraints (no public directory, no consumer-facing advertising, B2B-only) before any code was written. These became hard architectural constraints rather than late surprises. No "oh wait, we can't do that" moments during technical design.
**Architecture decisions locked before code.** The shared-schema multi-tenancy decision, immutable distribution records design, and `ComplianceConstants` pattern were all decided and documented before a single line of production code was written. This is the correct order. Rework from late architectural pivots is far more expensive than planning time.
**Compliance constants centralized from day zero.** Designing `ComplianceConstants.java` as the single source of truth for all CanG quota values (25g/day, 50g/month, etc.) prevents the most dangerous class of compliance bug: magic numbers scattered across the codebase that diverge when the law changes.
**ComfyUI mockup images in minutes.** Generating 5 realistic UI mockup images with FLUX.1-schnell took approximately 8 minutes of wall-clock time. This provides a visual reference for the UI that would otherwise require a designer or Figma skills. The images are good enough for stakeholder presentations and early user research.
**Test plan written before code.** TC-001 through TC-026 were defined against specifications, not against existing implementation. This forces clarity on what the code must do before writing it — the test cases are essentially executable requirements.
---
## What Was Challenging ⚠️
**ComfyUI manual startup friction.** The ComfyUI image generation server does not auto-start with the system. This required manual service start and a retry cycle before image generation could proceed. The fix (systemd user service + auto-start lifespan check in `mcp-image-gen`) was implemented during this planning sprint but added unexpected overhead.
**Solo developer timeline is ambitious.** The 1824 month estimate for a production-ready SaaS while employed full-time at ADP Germany is tight. Sprint 1 goals are achievable; the risk accumulates in Sprints 36 when frontend work, billing integration, and PDF generation converge. The PrimeFaces JSF choice for MVP was deliberate to reduce this risk — existing Java frontend skills transfer directly.
**Spring Boot 3 is not yet a "home" stack.** ADP work uses Jakarta EE (JBoss, CDI, JAX-RS). Spring Boot 3 shares the JPA/Hibernate mental model but diverges on dependency injection, auto-configuration, and application packaging. The learning curve is real but bounded — the `mss-failsafe` and `wellmann-shop` projects in `pi_mcps` demonstrate that the transition is manageable.
**Next.js/React remains a significant gap.** The v2 frontend pivot to Next.js 15 + React 19 is the highest-skill-gap risk in the project. PrimeFaces buys time, but the clock starts ticking on React learning from Sprint 1. Deferring is correct; ignoring it is not.
**No real user validation yet.** The entire architecture and pricing model is based on market research and regulatory reading, not on conversations with actual club administrators. The product may be solving the right problem in the wrong way. This is the most important open risk.
---
## Key Decisions Made 📋
| Decision | Rationale | Alternatives rejected |
|----------|-----------|----------------------|
| Shared-schema multi-tenancy (single DB, `tenant_id` columns) | Lowest ops overhead for MVP; one DB to backup/restore; simpler Flyway migrations | Schema-per-tenant (complex provisioning), DB-per-tenant (expensive at scale) |
| Immutable distribution records (`@Column(updatable = false)`) | Legal integrity — audit logs must be tamper-proof; corrections via `RecallEvent`, not `UPDATE` | Mutable records (simpler but legally risky under CanG §26 record-keeping) |
| PrimeFaces JSF for MVP frontend | Leverages existing Jakarta EE skills; fastest path to working product; no JS build tooling required | React/Next.js (faster modern dev, but higher skill gap), Thymeleaf (less interactive) |
| No public club discovery — permanent architectural exclusion | CanG §§67 prohibit advertising cannabis to the general public; club lookup tool would likely constitute advertising | N/A — this is a legal constraint, not a design choice |
| `ComplianceConstants.java` single source of truth | Prevents magic number scatter; single change point when law evolves | Constants in each service (fragile), DB-configurable limits (dangerous — allows disabling compliance) |
| Hetzner VPS over AWS/GCP | Cost (€5.88/month vs €20+); EU data residency (GDPR); simpler ops for solo developer | AWS (expensive, complex), Fly.io (less EU clarity), Railway (vendor lock-in) |
---
## Risks Going Forward ⚠️
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| New German government tightens CanG (e.g. lower quota limits) | Medium | High — requires rapid compliance updates | `ComplianceConstants.java` centralizes all limits; update is a 1-file change + test re-run |
| Stripe flags account as cannabis-adjacent | Medium | Critical — billing becomes unusable | Use category "Vereinsverwaltung" (club management) in Stripe onboarding; prepare Mollie as fallback |
| Solo dev burnout / timeline slip | High | Medium — delayed launch, not cancellation | Strict MVP scope; PrimeFaces reduces frontend effort; no scope creep before first paying customer |
| Market timing risk — clubs adopt ad-hoc Excel/WhatsApp solutions | Medium | High — low willingness to pay for formal software | User research with 3+ clubs in Sprint 1 is mandatory before writing production code |
| Legal risk: CanG compliance interpretation | Low | High — criminal liability for club officers | Specialist cannabis law opinion (€300500) before launch; not optional |
| Under-21 age calculation edge cases | Low | Medium — compliance bug | Birthday-based age calculation uses `Period.between()`, not year subtraction; tested in TC-013/014 |
---
## Next Steps — Sprint 1 Goals
- [ ] Initialize Spring Boot 3.x Maven multi-module project (`cannamanage-parent`, `cannamanage-domain`, `cannamanage-service`, `cannamanage-api`, `cannamanage-web`)
- [ ] Implement `AbstractTenantEntity` base class with `@MappedSuperclass`
- [ ] Write `V1__initial_schema.sql` Flyway migration covering all 8 entities
- [ ] Implement `ComplianceService` with full quota logic and 100% test coverage (TC-001010)
- [ ] Implement `MemberService` with age validation (TC-011015)
- [ ] Set up JaCoCo with ComplianceService 100% coverage gate
- [ ] Gitea repository created and CI pipeline (unit tests on `feature/*`) functional
- [ ] **Talk to 3 real club administrators** — validate pain points, willingness to pay, and current workarounds
- [ ] Get specialist legal opinion from a cannabis law attorney (€300500 budget)
---
## Metrics
| Metric | Value |
|--------|-------|
| Planning duration | 3 days (2026-04-04 to 2026-04-06) |
| Documents created | 10 (01-PROJECT-CHARTER through 10-RETROSPECTIVE) |
| Estimated total words | ~25,000 |
| Test cases defined | 26 |
| API endpoints specified | 30+ |
| JPA entities designed | 8 |
| UI screens wireframed | 6 |
| UI mockup images generated | 5 |
| Lines of production code written | **0** |
| Architecture decisions logged | 6 major |
| Open risks identified | 6 |
The ratio of planning output to production code written is intentional. Phase 0 exists to eliminate avoidable rework — the most expensive kind.
@@ -0,0 +1,43 @@
# Changelog
All notable changes to CannaManage will be documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [Unreleased]
### Added
- Complete project documentation suite (10 documents, ~25,000 words)
- System architecture design: 8 JPA entities, Maven multi-module structure, multi-tenancy via shared schema + Hibernate filter
- REST API specification: 7 controllers, 30+ endpoints, full request/response schemas with error codes
- Compliance engine design: `ComplianceService` enforcing CanG §§1922 limits (25g/day, 50g/month adults; 30g/month under-21; ≤10% THC under-21)
- `ComplianceConstants.java` design: all legal thresholds as named constants to prevent magic numbers in compliance logic
- UI wireframes for 6 screens: Admin Dashboard, Distribution Recording Form, Member List, Member Quota View, Stock Management, Compliance Report
- 5 AI-generated UI mockup images (FLUX.1-schnell via ComfyUI, 1024×512)
- Test plan with 26 test cases covering ComplianceService (TC-001010), MemberService (TC-011015), tenant isolation (TC-016017), and integration tests (TC-018026)
- Deployment guide for Hetzner VPS: Docker Compose setup, Nginx reverse proxy, SSL with Let's Encrypt, CI/CD via Gitea Actions, database backup strategy
- Coding standards: Java 21 conventions, JPA patterns, multi-tenancy rules, immutable distribution records
- Flowcharts: distribution flow (5-step), member lifecycle (state machine), billing provisioning flow (Mermaid diagrams)
- README with full documentation index, tech stack table, pricing tiers, legal notice
---
## [0.1.0] - 2026-04-06
### Added
- `STRATEGY.md` — initial project vision and feasibility assessment
- Legal analysis confirming CanG compliance viability for B2B SaaS model (no public advertising, no club discovery, B2B-only)
- Market analysis: ~3,000 registered clubs in Germany, TAM estimated at €2.85M ARR
- Tech stack selection rationale: Spring Boot 3.x + PrimeFaces JSF (MVP) → Next.js v2; PostgreSQL + Flyway; iText 7 PDF; Stripe billing
- Multi-tenancy architectural decision: shared schema with `tenant_id` column (chosen over schema-per-tenant for lower operational overhead at MVP scale)
- Pricing model: 4 tiers (Starter €29, Growth €59, Professional €99, Enterprise €199/month)
---
[Unreleased]: https://github.com/pplate/cannamanage/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/pplate/cannamanage/releases/tag/v0.1.0
+186
View File
@@ -0,0 +1,186 @@
# CannaManage 🌿
![Status: Planning](https://img.shields.io/badge/Status-Planning-yellow)
![Stack: Spring Boot 3.x + PrimeFaces](https://img.shields.io/badge/Stack-Spring%20Boot%203.x%20%2B%20PrimeFaces-brightgreen)
![Legal: CanG-Compliant](https://img.shields.io/badge/Legal-%E2%9C%85%20CanG--Compliant-blue)
![License: Proprietary](https://img.shields.io/badge/License-Proprietary-red)
**B2B compliance and membership management SaaS for German cannabis social clubs (Cannabis-Anbauvereine).**
---
## What is CannaManage?
Since April 2024, the Cannabisgesetz (CanG) permits licensed non-commercial social clubs to cultivate and distribute cannabis to their members. Each club must enforce strict distribution quotas, maintain tamper-proof audit logs, verify member eligibility, and comply with record-keeping obligations — all under threat of criminal liability for club officers.
CannaManage is a purpose-built SaaS platform that makes these compliance obligations manageable. Club administrators get a single interface to track membership, record distributions against per-member quotas (25g/day, 50g/month for adults; 30g/month for under-21 members), manage cannabis stock batches, and generate the distribution logs required during official inspections.
The platform is B2B only: it serves club administrators, not end consumers. It never stores member identities in a publicly discoverable way and contains no features that could be construed as advertising cannabis to the public — a legal requirement under CanG §§67. All data remains within the EU on Hetzner infrastructure.
---
## Key Features (MVP)
- **Member management** — Registration, age verification (18+ required), under-21 flagging, status lifecycle (ACTIVE → SUSPENDED → EXPELLED)
- **Distribution tracking** — Real-time quota enforcement: 25g/day, 50g/month (adults), 30g/month (under-21), ≤10% THC for under-21 members
- **Compliance audit log** — Immutable distribution records; no `UPDATE` or `DELETE` on completed distributions
- **Stock & batch management** — Strain catalogue with THC/CBD percentages, batch tracking from intake to zero, batch recall with member notification
- **Multi-tenancy** — Full data isolation per club; shared-schema architecture with `tenant_id` filter
- **PDF compliance reports** — iText 7 generated distribution logs formatted for regulatory inspection
- **Stripe billing integration** — Subscription management per club; 4-tier pricing; webhook-driven provisioning
- **Member self-service portal** — Quota dashboard, personal distribution history, appointment booking
- **Role-based access** — ROLE_ADMIN (club officers), ROLE_MEMBER (read-only self-service)
- **JWT authentication** — Stateless API with short-lived access tokens and secure refresh token rotation
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend framework | Spring Boot 3.x (Java 21) |
| Persistence | Spring Data JPA / Hibernate 6 |
| Database | PostgreSQL 16 |
| Database migrations | Flyway |
| Frontend (MVP) | PrimeFaces 14 (JSF) |
| Frontend (v2 target) | Next.js 15 + React 19 |
| PDF generation | iText 7 |
| Billing | Stripe Java SDK |
| Email | Spring Mail (SMTP) |
| Error tracking | Sentry Java SDK |
| Containerization | Docker + Docker Compose |
| Infrastructure | Hetzner VPS (CX21, Ubuntu 22.04) |
| Reverse proxy | Nginx (TLS termination) |
| Build tool | Maven (multi-module) |
| Testing | JUnit 5, Mockito, Testcontainers |
| CI/CD | Gitea Actions |
---
## Architecture
See [`03-ARCHITECTURE.md`](03-ARCHITECTURE.md) for the full system design, including:
- Maven multi-module structure (`cannamanage-domain`, `cannamanage-service`, `cannamanage-api`, `cannamanage-web`)
- JPA entity model (8 entities: `Club`, `Member`, `Distribution`, `Strain`, `Batch`, `Subscription`, `PricingPlan`, `InspectionReport`)
- Multi-tenancy implementation (shared schema with Hibernate filter)
- Security architecture (JWT + Spring Security)
---
## Getting Started (Development Setup)
### Prerequisites
- Java 21 (`sdk install java 21.0.3-tem`)
- Maven 3.9+
- Docker Desktop (for local PostgreSQL via Testcontainers or `docker compose up db`)
- Your preferred IDE (IntelliJ IDEA recommended for Spring Boot)
### Local development
```bash
git clone http://192.168.188.119:30008/pplate/cannamanage.git
cd cannamanage
# Copy environment template
cp .env.example .env
# Edit .env — fill in DB credentials and JWT secret for local dev
# Start local PostgreSQL (Docker)
docker compose up db -d
# Run application (Flyway migrations run automatically)
./mvnw spring-boot:run -pl cannamanage-api
# Application available at:
# http://localhost:8080 — PrimeFaces UI
# http://localhost:8080/api/ — REST API
# http://localhost:8080/actuator/health — Health check
```
### Running Tests
```bash
# Unit tests only (fast, no Docker required)
./mvnw test -pl cannamanage-service
# All tests including integration (requires Docker for Testcontainers)
./mvnw verify -P integration-tests
# Coverage report
./mvnw verify jacoco:report
# Open: cannamanage-service/target/site/jacoco/index.html
```
---
## Documentation Index
| # | Document | Description |
|---|----------|-------------|
| 01 | [`01-PROJECT-CHARTER.md`](01-PROJECT-CHARTER.md) | Project scope, objectives, stakeholders, constraints |
| 02 | [`02-USER-STORIES.md`](02-USER-STORIES.md) | 25 user stories across 5 epics with acceptance criteria |
| 03 | [`03-ARCHITECTURE.md`](03-ARCHITECTURE.md) | System design, data model, multi-tenancy, security |
| 04 | [`04-FLOWCHARTS.md`](04-FLOWCHARTS.md) | Distribution flow, member lifecycle, billing flow (Mermaid) |
| 05 | [`05-API-SPEC.md`](05-API-SPEC.md) | REST API specification — 7 controllers, 30+ endpoints |
| 06 | [`06-WIREFRAMES.md`](06-WIREFRAMES.md) | UI wireframes for 6 screens + AI-generated mockups |
| 07 | [`07-CODING-STANDARDS.md`](07-CODING-STANDARDS.md) | Java coding conventions, compliance rules, JPA patterns |
| 08 | [`08-TEST-PLAN.md`](08-TEST-PLAN.md) | Test strategy, 26 test cases, coverage requirements |
| 09 | [`09-DEPLOYMENT-GUIDE.md`](09-DEPLOYMENT-GUIDE.md) | Hetzner VPS deployment, CI/CD pipeline, backup |
| 10 | [`10-RETROSPECTIVE.md`](10-RETROSPECTIVE.md) | Sprint 0 retrospective — planning phase review |
---
## Pricing
Four tiers targeting different club sizes:
| Tier | Price | Members | Key inclusions |
|------|-------|---------|----------------|
| **Starter** | €29/month | Up to 50 | Core compliance, basic reports |
| **Growth** | €59/month | Up to 150 | + PDF exports, email notifications |
| **Professional** | €99/month | Up to 500 | + Stripe billing, priority support |
| **Enterprise** | €199/month | Unlimited | + Custom reports, API access, SLA |
All tiers include a 30-day free trial. Annual billing available at 2 months free (×10/12 monthly rate).
**Market sizing:** Germany had approximately 3,000+ registered cannabis social clubs as of early 2026. At average Growth tier pricing, TAM is approximately €2.85M ARR.
---
## Legal Notice
CannaManage operates exclusively as a B2B SaaS tool for licensed Cannabis-Anbauvereine registered under CanG (Cannabisgesetz, effective April 2024). Key legal positions:
- **No public directory:** CannaManage contains no feature that enables public discovery of clubs or their locations. Club data is never exposed publicly or to other clubs.
- **No advertising:** The platform does not advertise cannabis products to consumers. Club admins manage their own members — no public-facing cannabis content.
- **CanG compliance:** Quota limits (25g/day, 50g/month for adults; 30g/month for under-21 members; ≤10% THC for under-21) are hardcoded constants, not configurable per club. This prevents compliance from being accidentally disabled.
- **GDPR:** All data stored on EU infrastructure (Hetzner, Germany/Finland datacenters). Member personal data handled under GDPR legitimate interest basis (club membership) and statutory record-keeping obligation (CanG §26).
- **Minimum age:** Club membership and therefore access to the member portal requires age 18+, verified at registration.
*CannaManage is a management tool, not a cannabis marketplace. Club officers remain personally responsible for their club's legal compliance under CanG.*
---
## Development Status
**Phase 0 — Foundation (Planning) — ✅ Complete as of 2026-04-06**
The complete documentation suite (10 documents) has been written, covering architecture, API specification, data model, wireframes, test plan, and deployment guide. No production code has been written yet.
**Sprint 1 goals (next):**
- [ ] Initialize Spring Boot 3.x Maven multi-module project
- [ ] Implement `AbstractTenantEntity` and Flyway baseline migration (`V1__initial_schema.sql`)
- [ ] Build `ComplianceService` with 100% test coverage (TC-001 through TC-010)
- [ ] Validate concept with 3 real club admins
- [ ] Obtain specialist legal opinion on CanG compliance approach
---
## License
**Proprietary — All Rights Reserved**
Copyright © 2026 Patrick Plate. All rights reserved.
This software and all associated documentation are proprietary and confidential. No part of this codebase may be reproduced, distributed, or transmitted in any form without the prior written permission of the author.
Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

+139
View File
@@ -0,0 +1,139 @@
# Task: Swap Qwen3-4B Encoder for Heretic Abliterated Version
**Datum:** 2026-04-10
**Status:** Ready — waiting for correct Heretic encoder to be published
**Depends on:** FLUX.2 Klein 4B working (✅ done as of 2026-04-10)
---
## Goal
Replace the standard `qwen_3_4b_klein.safetensors` with an abliterated (Heretic) version that has:
- **Zero measurable quality loss** (KL divergence = 0.0000)
- **No prompt refusals** (≤3/100 in DreamFast v1.2.0 testing)
Result: `generate_image(prompt, model="flux-2-klein-4b.safetensors")` will work with **any** prompt without refusals.
---
## Current State
| File | Location | Status |
|------|----------|--------|
| `flux-2-klein-4b.safetensors` | `~/ComfyUI/models/diffusion_models/` | ✅ Working |
| `qwen_3_4b_klein.safetensors` | `~/ComfyUI/models/text_encoders/` | ✅ Working (standard, has refusals) |
| `flux2-vae.safetensors` | `~/ComfyUI/models/vae/` | ✅ Working |
The MCP workflow [`mcp/mcp-image-gen/src/workflows/flux2_klein_heretic.json`](../mcp/mcp-image-gen/src/workflows/flux2_klein_heretic.json) already uses `qwen_3_4b_klein.safetensors`**no code change needed**, only the file on disk needs to be replaced.
---
## The Problem to Solve First
The standard Heretic repos may not have the **FLUX.2 Klein-compatible** encoder dimensions:
| Encoder | `hidden_size` | Conditioning dim | Usable? |
|---------|--------------|-----------------|---------|
| BFL Qwen3-4B (FLUX.2 Klein) | **2560** | 7680 (2560×3) | ✅ |
| DreamFast/qwen3-4b-heretic | unknown — must check | ? | ⚠️ verify first |
| Standard Qwen3-4B | 4096 | 4096 | ❌ wrong |
**Before downloading, verify DreamFast's model is fine-tuned from the BFL variant** (hidden_size=2560), not the standard Qwen3 (hidden_size=4096).
---
## Steps
### Step 1: Check DreamFast Heretic repo
```bash
huggingface-cli model-info DreamFast/qwen3-4b-heretic 2>/dev/null | grep -i hidden
```
Or browse: https://huggingface.co/DreamFast/qwen3-4b-heretic/blob/main/config.json
Look for: `"hidden_size": 2560` — that's the FLUX.2 Klein-compatible version.
### Step 2a: If DreamFast has the right dimensions (2560)
```bash
# Download
huggingface-cli download DreamFast/qwen3-4b-heretic \
--local-dir /tmp/qwen3-4b-heretic/
# Back up working encoder first
cp ~/ComfyUI/models/text_encoders/qwen_3_4b_klein.safetensors \
~/ComfyUI/models/text_encoders/qwen_3_4b_klein_backup.safetensors
# Swap in the Heretic version
cp /tmp/qwen3-4b-heretic/model.safetensors \
~/ComfyUI/models/text_encoders/qwen_3_4b_klein.safetensors
```
### Step 2b: If DreamFast has wrong dimensions (4096) — find alternative
Options in order of preference:
1. **Lockout/qwen3-4b-heretic-zimage** — check if BFL-compatible:
```bash
huggingface-cli model-info Lockout/qwen3-4b-heretic-zimage 2>/dev/null | grep hidden
```
2. **Run Heretic abliteration yourself** on the working `qwen_3_4b_klein.safetensors`
Tool: https://github.com/FailSpy/abliterator
Script: `python abliterator.py --model qwen_3_4b_klein.safetensors --output qwen_3_4b_klein_heretic.safetensors`
3. **Wait** for DreamFast or BFL to publish the FLUX.2-specific abliterated encoder
### Step 3: Live test
```python
generate_image(
"an explicit test prompt that would normally be refused",
model="flux-2-klein-4b.safetensors",
steps=20
)
```
Expected: Image generated, no refusal error in ComfyUI logs.
### Step 4: If it works — no code changes needed
The MCP code, workflow JSON, and registry are already correct. Just verify:
- Check `journalctl --user -u comfyui -f` during generation for any errors
- Confirm file in `~/Pictures/mcp-generated/` was saved
---
## Fallback Plan
If the Heretic encoder is unavailable in the right dimensions, the **GGUF route** works too:
```bash
# ComfyUI-GGUF is already installed: ~/ComfyUI/custom_nodes/ComfyUI-GGUF
# Download Heretic GGUF (if BFL-compatible variant published):
huggingface-cli download Lockout/qwen3-4b-heretic-zimage \
qwen-4b-zimage-hereticV2-q8.gguf \
--local-dir ~/ComfyUI/models/text_encoders/
```
Then update [`flux2_klein_heretic.json`](../mcp/mcp-image-gen/src/workflows/flux2_klein_heretic.json) node `"1"`:
```json
"class_type": "CLIPLoaderGGUF", // instead of CLIPLoader
"inputs": {
"clip_name": "qwen-4b-zimage-hereticV2-q8.gguf",
"type": "flux2"
}
```
---
## No Code Changes Required (unless GGUF fallback)
The entire MCP server, workflow registry, and test suite are already correct. This is **purely a model file task**.
---
## Success Criteria
- [ ] `generate_image("...", model="flux-2-klein-4b.safetensors")` works with prompts that currently get refused
- [ ] Output image quality identical to standard encoder (check: no visible artifacts vs reference)
- [ ] ComfyUI logs show no dimension errors
- [ ] `qwen_3_4b_klein_backup.safetensors` kept as rollback
+104
View File
@@ -0,0 +1,104 @@
# FLUX.2 Klein 4B + Heretic — Session Recap
**Date:** 2026-04-10
**Status:** Code complete, live generation BLOCKED by encoder dimension mismatch
---
## What We Achieved ✅
### Code Infrastructure (Solid)
- **`mcp-image-gen/src/server.py`** — Generic workflow registry with model-based dispatch, `_inject_workflow_params()` works recursively on any node layout
- **`mcp-image-gen/tests/test_server.py`** — 37/37 tests passing
- **Gitea** — pushed to main (commit `38d26ad`)
- The architecture is right: adding a new model = add 1 JSON file + 1 registry entry
### Models Downloaded (on disk)
| File | Location | Status |
|------|----------|--------|
| `flux-2-klein-4b.safetensors` | `~/ComfyUI/models/diffusion_models/` | ✅ 7.3GB |
| `qwen_3_4b_bfl.safetensors` | `~/ComfyUI/models/text_encoders/` | ✅ merged from BFL shards |
| `qwen_3_4b.safetensors` (z_image) | `~/ComfyUI/models/text_encoders/split_files/` | ✅ wrong model |
| `Qwen3-4B-Q8_0.gguf` | `~/ComfyUI/models/text_encoders/` | ✅ wrong arch |
| ComfyUI-GGUF extension | `~/ComfyUI/custom_nodes/ComfyUI-GGUF` | ✅ installed |
---
## What Failed and Why ❌
### The Error (persistent)
```
mat1 and mat2 shapes cannot be multiplied (512x4096 and 7680x3072)
```
### Root Cause Analysis
**Node 13** (`SamplerCustomAdvanced`) fails — meaning the conditioning vector from the text encoder doesn't match the diffusion model's expected input.
| Component | Expected | Got |
|-----------|----------|-----|
| FLUX.2 Klein 4B conditioning input | **7680-dim** (2560 × 3) | **4096-dim** |
**Why 7680 = 2560 × 3?**
FLUX models concatenate text embeddings across multiple time steps. The BFL Qwen3 encoder has `hidden_size=2560`, so the concatenated output is 2560×3=7680.
**Why 4096?**
Every other Qwen3 variant (z_image_turbo, official Qwen repo GGUF) uses standard Qwen3 with `hidden_size=4096` — these are for Z-Image and text generation respectively, NOT for FLUX.2 Klein.
### What We Tried (and Why Each Failed)
1. `CLIPLoader type=flux` → wrong architecture (FLUX.1 style)
2. `CLIPLoader type=flux2` → correct node, wrong encoder file (z_image Qwen)
3. `CLIPLoaderGGUF type=flux2` → correct node, wrong GGUF (standard Qwen3)
4. `CLIPLoader type=flux2 + qwen_3_4b_bfl.safetensors` → merged BFL shards, but still fails
5. Workflow: `KSampler` → doesn't work with FLUX.2 (different architecture)
6. Workflow: `SamplerCustomAdvanced + BasicGuider + Flux2Scheduler` → correct architecture but encoding mismatch persists
### The Real Missing Piece
The BFL FLUX.2 Klein text encoder in Diffusers format is designed for use via `transformers/diffusers` pipeline, NOT via ComfyUI's `CLIPLoader`. ComfyUI reads the weights differently. The weights are there but ComfyUI doesn't know how to map `model.embed_tokens`, `model.layers.N.*` etc. to the CLIP interface it expects.
**The correct encoder file for ComfyUI** is `Comfy-Org/vae-text-encorder-for-flux-klein-4b` — the 7.5GB file we downloaded IS the right one, but ComfyUI is likely loading it with the wrong adapter in the `CLIPLoader`.
---
## Clean Approach — What We Need to Do
### Option A: Use ComfyUI Web UI (Easiest)
1. Open `http://localhost:8188` in browser
2. Load the "Flux.2 Klein 4B Text-to-Image" workflow template (it's in the UI Templates)
3. **Export the working API JSON** (Ctrl+Shift+E or Settings → Save as API format)
4. Replace our `flux2_klein_heretic.json` with the exported JSON
5. Add placeholders and test
This gives us the **verified working node graph** without guessing. 10 minutes.
### Option B: Find a Working API JSON online
- Reddit r/comfyui has working FLUX.2 Klein workflows
- Export format is what we need
### Then: Add Heretic
Once we have a working standard workflow:
1. Download the actual Heretic-abliterated version of the BFL encoder (once it's published)
2. Swap encoder filename in the JSON
---
## My Recommendation
**Do Option A right now.** Open `http://localhost:8188`, load the template, export to API format, paste the JSON. We'll be running in 10 minutes instead of guessing node names.
The MCP server code is solid — the only broken piece is `flux2_klein_heretic.json`. Once we have the right JSON from the UI, everything else works.
---
## Files to Clean Up (After We Have the Right JSON)
```bash
# Remove wrong encoders (save ~8GB)
rm ~/ComfyUI/models/text_encoders/qwen_3_4b.safetensors # z_image version
rm ~/ComfyUI/models/text_encoders/qwen_3_4b_flux2.safetensors
# Keep
# ~/ComfyUI/models/text_encoders/qwen_3_4b_bfl.safetensors ← correct encoder
# ~/ComfyUI/models/text_encoders/Qwen3-4B-Q8_0.gguf ← maybe useful later
```
+300
View File
@@ -0,0 +1,300 @@
# Plan: FLUX.2 Klein 4B + Heretic Abliterated Text Encoder in mcp-image-gen
**Datum:** 2026-04-10
**Autor:** Lumen / Patrick Plate
**Status:** Ready for Implementation
---
## Ziel
Das bestehende `mcp-image-gen` ComfyUI-Backend um ein zweites Modell erweitern:
**FLUX.2 Klein 4B** mit dem abliterierten **Qwen3-4B-Heretic** als Text-Encoder.
Ergebnis: `generate_image` kann via `model`-Parameter zwischen zwei Workflows wählen:
- `flux1-schnell.safetensors` → bestehender Workflow (unverändert)
- `flux-2-klein-4b-fp8.safetensors` → neuer Heretic-Workflow (keine Prompt-Refusals)
---
## Technischer Hintergrund
### Warum Heretic + FLUX.2 Klein?
FLUX.2 Klein 4B verwendet **Qwen3-4B als LLM Text-Encoder** (statt CLIP+T5 wie bei FLUX.1).
Dieser LLM-Encoder hat Safety-Alignment → verweigert bestimmte Prompts → abliterieren.
`DreamFast/qwen3-4b-heretic` (HuggingFace):
- **KL Divergenz: 0.0000** — null messbarer Modell-Schaden
- Nur **3/100 Refusals** nach Heretic v1.2.0 (200 Trials)
- Drop-in Replacement für `qwen_3_4b.safetensors`
### Modell-Architektur Unterschied
| | FLUX.1-schnell | FLUX.2 Klein 4B |
|---|---|---|
| Diffusion Model | `flux1-schnell.safetensors` (UNet) | `flux-2-klein-4b-fp8.safetensors` |
| Text Encoder | `DualCLIPLoader` (T5+CLIP) | `CLIPLoader` (Qwen3-4B) |
| VAE | `ae.safetensors` | `flux2-vae.safetensors` |
| Steps | 4 | 4 (distilled) |
| VRAM | ~8GB | ~8.4GB |
| Refusals | keine (kein LLM-Encoder) | keine (abliteriert) |
---
## Dateien & Ordner
### Neue Modell-Dateien (herunterzuladen)
```
~/ComfyUI/models/
├── diffusion_models/
│ └── flux-2-klein-4b-fp8.safetensors ← FLUX.2 Klein distilled 4B
├── text_encoders/
│ └── qwen_3_4b_heretic.safetensors ← Heretic abliteriert (von DreamFast/qwen3-4b-heretic)
└── vae/
└── flux2-vae.safetensors ← VAE für FLUX.2
```
### Neue/geänderte Projekt-Dateien
```
mcp/mcp-image-gen/
├── src/
│ ├── server.py ← Workflow-Registry ergänzen
│ └── workflows/
│ ├── flux_schnell.json ← unverändert
│ └── flux2_klein_heretic.json ← NEU
├── tests/
│ └── test_server.py ← neue Tests für Registry + Workflow
└── USAGE.md ← Download-Anleitung ergänzen
```
---
## Phase 1: Modelle herunterladen
### 1a. FLUX.2 Klein 4B (Diffusion Model)
```bash
# Von Black Forest Labs HuggingFace
huggingface-cli download black-forest-labs/FLUX.2-klein-4B \
flux-2-klein-4b-fp8.safetensors \
--local-dir ~/ComfyUI/models/diffusion_models/
```
### 1b. FLUX.2 VAE
```bash
huggingface-cli download black-forest-labs/FLUX.2-klein-4B \
flux2-vae.safetensors \
--local-dir ~/ComfyUI/models/vae/
```
### 1c. Qwen3-4B-Heretic (abliterierter Text-Encoder)
```bash
# Von DreamFast — bereits abliteriert, kein Heretic-Run nötig
huggingface-cli download DreamFast/qwen3-4b-heretic \
--local-dir /tmp/qwen3-4b-heretic/
# Safetensors-Datei in ComfyUI text_encoders ablegen
cp /tmp/qwen3-4b-heretic/model.safetensors \
~/ComfyUI/models/text_encoders/qwen_3_4b_heretic.safetensors
```
> **Hinweis:** DreamFast/qwen3-4b-heretic ist ein GGUF-/SafeTensors-Mix.
> Wir brauchen die `.safetensors` Variante für ComfyUI. Falls nur GGUF verfügbar:
> `huggingface-cli download Lockout/qwen3-4b-heretic-zimage qwen-4b-zimage-hereticV2-q8.gguf`
---
## Phase 2: Neues Workflow-JSON
**Datei:** [`mcp/mcp-image-gen/src/workflows/flux2_klein_heretic.json`](mcp/mcp-image-gen/src/workflows/flux2_klein_heretic.json)
FLUX.2 Klein verwendet andere ComfyUI-Nodes als FLUX.1-schnell:
- `DualCLIPLoader``CLIPLoader` (einzelner Qwen-Encoder)
- `UNETLoader` mit `diffusion_models/` Pfad statt `checkpoints/`
- `EmptySD3LatentImage` → gleich (kompatibel)
- `KSampler` → gleich aber `sampler_name: "euler"`, `scheduler: "beta"`, `steps: 4`
```json
{
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 0],
"text": "PROMPT_PLACEHOLDER"
}
},
"8": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["13", 0],
"vae": ["31", 0]
}
},
"9": {
"class_type": "SaveImage",
"inputs": {
"filename_prefix": "mcp-image-gen",
"images": ["8", 0]
}
},
"13": {
"class_type": "KSampler",
"inputs": {
"cfg": 1.0,
"denoise": 1.0,
"latent_image": ["27", 0],
"model": ["32", 0],
"negative": ["33", 0],
"positive": ["6", 0],
"sampler_name": "euler",
"scheduler": "beta",
"seed": 42,
"steps": 4
}
},
"27": {
"class_type": "EmptySD3LatentImage",
"inputs": {
"batch_size": 1,
"height": 1024,
"width": 1024
}
},
"30": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b_heretic.safetensors",
"type": "flux"
}
},
"31": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "flux2-vae.safetensors"
}
},
"32": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "flux-2-klein-4b-fp8.safetensors",
"weight_dtype": "fp8_e4m3fn"
}
},
"33": {
"class_type": "CLIPTextEncode",
"inputs": {
"clip": ["30", 0],
"text": "NEGATIVE_PLACEHOLDER"
}
}
}
```
---
## Phase 3: server.py — Workflow-Registry
### Änderung 1: Workflow-Registry dict (nach `_WORKFLOW_PATH`)
```python
# Path to the bundled FLUX.1-schnell workflow template
_WORKFLOW_PATH = Path(__file__).parent / "workflows" / "flux_schnell.json"
# Workflow registry: model filename → workflow JSON path
_WORKFLOW_REGISTRY: dict[str, Path] = {
"flux1-schnell.safetensors": Path(__file__).parent / "workflows" / "flux_schnell.json",
"flux-2-klein-4b-fp8.safetensors": Path(__file__).parent / "workflows" / "flux2_klein_heretic.json",
}
_DEFAULT_MODEL = "flux1-schnell.safetensors"
```
### Änderung 2: `_load_workflow()` Hilfsfunktion
```python
def _load_workflow(model: str) -> dict:
"""Load the correct workflow JSON for the requested model.
Falls back to FLUX.1-schnell if model not in registry.
"""
path = _WORKFLOW_REGISTRY.get(model, _WORKFLOW_PATH)
if not path.exists():
raise FileNotFoundError(f"Workflow JSON not found: {path}")
return json.loads(path.read_text())
```
### Änderung 3: `_generate_single()` nutzt Registry
Aktueller Code lädt immer `_WORKFLOW_PATH`. Änderung: `_load_workflow(model)` aufrufen:
```python
async def _generate_single(
client: ComfyUIClient,
prompt: str,
negative_prompt: str,
model: str,
seed: int,
width: int,
height: int,
steps: int,
output_dir: Path,
name: str,
) -> tuple[TextContent, ImageContent | None]:
workflow = _load_workflow(model) # ← statt json.loads(_WORKFLOW_PATH.read_text())
# ... rest unchanged
```
---
## Phase 4: Tests
Neue Tests in [`mcp/mcp-image-gen/tests/test_server.py`](mcp/mcp-image-gen/tests/test_server.py):
1. **`test_workflow_registry_contains_both_models`** — Registry hat flux1-schnell + flux2-klein
2. **`test_load_workflow_flux1_schnell`** — lädt flux_schnell.json korrekt
3. **`test_load_workflow_flux2_klein`** — lädt flux2_klein_heretic.json korrekt
4. **`test_load_workflow_unknown_model_falls_back`** — unbekanntes Modell → FLUX.1-schnell
5. **`test_generate_image_uses_flux2_workflow`** — end-to-end Mock mit flux-2-klein-4b-fp8.safetensors
---
## Phase 5: USAGE.md Update
Neuer Abschnitt "FLUX.2 Klein 4B (Heretic)" in [`mcp/mcp-image-gen/USAGE.md`](mcp/mcp-image-gen/USAGE.md):
- Download-Befehle für alle 3 neuen Modell-Dateien
- Erklärung warum Heretic (abliterierter Text-Encoder, KL=0)
- Beispiel-Aufruf: `generate_image("...", model="flux-2-klein-4b-fp8.safetensors")`
---
## VRAM-Analyse
| Modell | VRAM gesamt | Passt in 24GB? |
|---|---|---|
| FLUX.1-schnell (fp8) | ~8GB | ✅ |
| FLUX.2 Klein 4B (fp8) + Qwen3-4B | ~8.4GB + ~4GB = ~12.4GB | ✅ |
| Beide gleichzeitig geladen | ~20GB | ✅ mit Margin |
Der RX 7900 XTX mit 24GB VRAM kann beide Modelle komfortabel halten.
---
## Risiken & Mitigationen
| Risiko | Wahrscheinlichkeit | Mitigation |
|---|---|---|
| `CLIPLoader` node nicht verfügbar in ComfyUI | niedrig | ComfyUI updaten; alternativ custom node |
| DreamFast-Modell nur als GGUF verfügbar | mittel | Lockout/qwen3-4b-heretic-zimage GGUF als Fallback |
| Qwen3-4B braucht anderen node type | mittel | Live-Test in ComfyUI UI zuerst; workflow anpassen |
| ROCm + Qwen3-4B Kompatibilität | niedrig | gleiche ROCm-Umgebung wie FLUX.1-schnell |
---
## Entscheidung
**Empfehlung: Umsetzen.** Minimale Code-Änderungen, kein Breaking Change, klarer Mehrwert.
Der einzige unsichere Punkt ist der genaue ComfyUI-Node-Name für den Qwen3-4B-Loader.
**Empfohlene Vorgehensweise:** Erst in der ComfyUI-Web-UI manuell einen Workflow mit Qwen3-4B aufbauen → JSON exportieren → als `flux2_klein_heretic.json` speichern. Das garantiert korrekte Node-Namen ohne Guess-Work.