docs: sync all CannaManage wiki pages from pi_mcps
@@ -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 2026–2027 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+, 18–21 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 §§6–7 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 9–16) |
|
||||
| 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** | 3–5 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 §§6–7 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 €5–20/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 (€300–500); 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** | €5–20/month | German DC; scales with load |
|
||||
| **Infrastructure — PostgreSQL** | €0 (self-hosted on VPS) | Managed DB upgrade available when needed |
|
||||
| **Legal opinion** | €300–500 (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)** | €0–10/month | Resend free tier for low volume |
|
||||
| **Sentry monitoring** | €0 (free tier) | Error tracking; Java SDK |
|
||||
| **Total pre-launch** | **~€600–700** | 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 |
|
||||
| **§§15–26 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 |
|
||||
| **§§6–7 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,504 @@
|
||||
# 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-001–014) | MVP v1 | Core compliance loop; legally required features |
|
||||
| 🟡 **Should Have** | 4 (US-015–018) | v2 | Growth and retention features |
|
||||
| 🟢 **Could Have** | 4 (US-019–022) | v3 | Scale and differentiation features |
|
||||
| ⚫ **Won't Have (MVP)** | 3 (US-023–025) | 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 18–21 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 (50–80% 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 §§6–7.** 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. 44–49 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 | §22–23 CanG | Under-21 age verification gaps |
|
||||
| US-003 | Club Admin | MVP | §26 CanG | Distribution limit bypass |
|
||||
| US-004 | Club Admin | MVP | §22–23 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 | §§6–7 CanG (no advertising) | Over-disclosure of stock data |
|
||||
| US-014 | Club Member | MVP | §22–23 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 §§6–7 CanG** | Platform shutdown risk |
|
||||
| US-024 | *(none)* | Never | **Illegal** | Stripe termination + criminal liability |
|
||||
| US-025 | *(none)* | Never | **DSGVO Art. 44–49** | Regulatory fine + club data breach |
|
||||
|
||||
---
|
||||
|
||||
## Could Have — v2 (Additions)
|
||||
|
||||
### US-026: Staff Member Management
|
||||
|
||||
**As a** Club Admin, **I want to** create staff accounts with configurable permissions, **so that** my team members can do their work without having access to data they don't need (DSGVO principle of least privilege).
|
||||
|
||||
**Priority:** Must Have (upgraded from Could Have — see note)
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can create staff accounts with email + temporary password
|
||||
- [ ] AC2: Admin assigns permissions per staff account from a defined permission set (`RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA`, `ADD_MEMBER`, `VIEW_STOCK`, `RECORD_STOCK_IN`, `VIEW_COMPLIANCE_REPORT`, `MANAGE_GROW_CALENDAR`)
|
||||
- [ ] AC3: Pre-created role templates available: **Ausgabe** (distribution desk), **Lager** (stock/cultivation), **Vorstand** (board member)
|
||||
- [ ] AC4: Staff accounts cannot access billing, club settings, or staff management
|
||||
- [ ] AC5: All distributions recorded by staff include `recorded_by = staffUserId` in audit trail
|
||||
- [ ] AC6: Admin can deactivate a staff account; historical data is retained for audit purposes
|
||||
- [ ] AC7: Staff member sees only the navigation sections permitted by their granted permissions
|
||||
|
||||
> **Note:** Promoted to core / Must Have. Staff management is not a v2 feature — clubs have multiple people involved from day one. DSGVO requires that each person only accesses data relevant to their function. Designing this post-MVP would require schema, API, and permission model rework.
|
||||
|
||||
---
|
||||
|
||||
### US-027: Grow Calendar
|
||||
|
||||
**As a** Club Admin or authorised staff member, **I want to** maintain a cultivation calendar for each grow cycle, **so that** the club has a central record of what was planted, when to expect harvest, and the grow diary with notes and photos.
|
||||
|
||||
**Priority:** Could Have (v2)
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin/staff can create a grow entry with: strain name, planted date, expected harvest date, grow medium, notes
|
||||
- [ ] AC2: Grow entries are linked to a batch — when the harvest is registered as a batch, the grow entry is marked as completed
|
||||
- [ ] AC3: A grow diary allows adding timestamped notes and optional photos per grow entry
|
||||
- [ ] AC4: Grow calendar view shows a visual timeline of active grow cycles (Gantt-style or calendar grid)
|
||||
- [ ] AC5: Admin can set who has access to the grow calendar via staff permission `MANAGE_GROW_CALENDAR`
|
||||
- [ ] AC6: Photos are stored per-tenant and never exposed to members or other tenants
|
||||
|
||||
**Notes:** The grow calendar bridges cultivation management and compliance — it provides provenance traceability from seed/clone to distributed batch. This directly supports §26 CanG batch traceability requirements for the origin of cultivated product. Photo attachments are a nice-to-have within this story; the core diary functionality is the v2 deliverable.
|
||||
|
||||
---
|
||||
|
||||
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
|
||||
@@ -0,0 +1,550 @@
|
||||
# 03 — System Architecture
|
||||
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
|
||||
**Phase:** 2 of 5 — Architecture & Data Model
|
||||
**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2
|
||||
**Last updated:** 2026-04-06
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
AdminBrowser["🖥️ Browser — Admin Portal"]
|
||||
MemberBrowser["🖥️ Browser — Member Portal"]
|
||||
|
||||
Frontend["React/Vite Frontend\n(SPA — served by Nginx)"]
|
||||
|
||||
AdminBrowser -->|HTTPS| Frontend
|
||||
MemberBrowser -->|HTTPS| Frontend
|
||||
|
||||
Frontend -->|REST/JSON| 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
|
||||
|
||||
Frontend --> Nginx
|
||||
Nginx --> Backend
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Technology | Role |
|
||||
|---|---|---|
|
||||
| Admin Portal | React/Vite SPA (→ Next.js v2) | Club management UI |
|
||||
| Member Portal | React/Vite SPA (→ Next.js v2) | Member quota & history UI |
|
||||
| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints |
|
||||
| Auth | Spring Security 6 + JJWT | Stateless JWT authentication |
|
||||
| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering |
|
||||
| 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
|
||||
|
||||
### Decision: Schema-Per-Tenant
|
||||
|
||||
Each club gets its own PostgreSQL schema (e.g. `tenant_abc123`). A platform-level `public` schema holds only the `tenants` registry. Flyway runs per-schema migrations on onboarding.
|
||||
|
||||
**Why schema-per-tenant, not shared schema?**
|
||||
|
||||
A shared-schema approach (single table with `tenant_id` on every row) is operationally convenient in the short term but creates serious problems at scale:
|
||||
|
||||
| Concern | Shared Schema | Schema-Per-Tenant |
|
||||
|---|---|---|
|
||||
| Data isolation | Application-layer only — one missing filter = data leak | Enforced at DB level — schemas are hard boundaries |
|
||||
| DSGVO compliance | Harder to prove isolation; one backup contains all clubs' data | Per-tenant pg_dump; each club's data is cleanly separable |
|
||||
| Deletion / right to erasure | Must `DELETE WHERE tenant_id = ?` across every table | `DROP SCHEMA tenant_abc123 CASCADE` — clean and auditable |
|
||||
| Migrations | One migration path for all | Per-schema migration via Flyway `schemas` config; adds ~100ms per onboard |
|
||||
| Query performance | Cross-tenant index bloat on large shared tables | Smaller per-tenant tables; no cross-tenant contention |
|
||||
| Future per-club DB isolation | Requires full re-architecture | Trivial: move schema to dedicated DB server |
|
||||
| Operational overhead | Lower — one connection pool | Slightly higher — one pool per tenant (managed by HikariCP with pool-per-schema) |
|
||||
|
||||
**Conclusion:** The shared-schema "MVP convenience" argument only holds for throwaway prototypes. For a compliance SaaS handling personal health-adjacent data (cannabis consumption records), schema-per-tenant is the correct design from Day 1. The migration complexity is manageable; the data isolation benefit is permanent.
|
||||
|
||||
### Tenant Provisioning
|
||||
|
||||
When a new club onboards:
|
||||
|
||||
```
|
||||
POST /api/v1/admin/bootstrap
|
||||
→ TenantProvisioningService.provisionTenant(tenantId)
|
||||
→ CREATE SCHEMA tenant_{tenantId}
|
||||
→ Flyway.migrate(schema=tenant_{tenantId}) // applies all V*.sql
|
||||
→ INSERT INTO public.tenants (id, schema_name, onboarded_at, status)
|
||||
```
|
||||
|
||||
### Tenant Resolution
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
└─ Spring Security Filter: extract JWT → resolve tenant_id
|
||||
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
|
||||
└─ DataSource routes to schema: SET search_path = tenant_{tenantId}
|
||||
└─ All queries execute in tenant's private schema
|
||||
```
|
||||
|
||||
### 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 — Schema Routing DataSource
|
||||
|
||||
```java
|
||||
// TenantRoutingDataSource.java (pseudocode)
|
||||
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
return TenantContext.getCurrentTenant(); // returns tenant schema name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// TenantInterceptor.java (pseudocode)
|
||||
@Component
|
||||
public class TenantInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req, ...) {
|
||||
String tenantId = JwtUtils.extractTenantId(req);
|
||||
TenantContext.setCurrentTenant("tenant_" + tenantId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Invariants enforced:**
|
||||
- Every incoming request resolves its schema before any query runs
|
||||
- No entity has a `tenant_id` column — schema isolation replaces row-level filtering
|
||||
- Raw JDBC queries must be avoided; all access goes through JPA repositories with schema routing
|
||||
- The `public` schema contains only the tenants registry and platform-level config
|
||||
|
||||
---
|
||||
|
||||
## 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, staff management |
|
||||
| `ROLE_STAFF` | Club staff member | Configurable subset of admin permissions — defined per staff account by the admin |
|
||||
| `ROLE_MEMBER` | Club member | Own quota, own distribution history (read-only) |
|
||||
| `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data |
|
||||
|
||||
> **Staff is a core feature, not an add-on.** Real clubs have multiple staff members (front desk, cultivation responsible, prevention officer designate) with different operational responsibilities. DSGVO requires that each staff member can only access data they need for their specific role. The `ROLE_STAFF` with configurable permission grants from the admin is designed from Phase 0 — retrofitting it later would require schema and API changes.
|
||||
|
||||
### Staff Permission Model
|
||||
|
||||
Admins configure staff permissions at account creation. Permissions are stored as a `JSONB` column `granted_permissions` on the `staff_accounts` table within the tenant schema.
|
||||
|
||||
```java
|
||||
// Configurable staff permissions (granted by admin per staff account)
|
||||
public enum StaffPermission {
|
||||
RECORD_DISTRIBUTION, // can record distributions
|
||||
VIEW_MEMBER_LIST, // can view member roster
|
||||
VIEW_MEMBER_QUOTA, // can view individual member quota
|
||||
ADD_MEMBER, // can register new members
|
||||
VIEW_STOCK, // can view batch/strain inventory
|
||||
RECORD_STOCK_IN, // can add new batches
|
||||
VIEW_COMPLIANCE_REPORT, // can generate/download reports
|
||||
MANAGE_GROW_CALENDAR // can manage cultivation calendar entries
|
||||
}
|
||||
```
|
||||
|
||||
Pre-created role templates (configurable by admin):
|
||||
- **Ausgabe** (Distribution desk): `RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA`
|
||||
- **Lager** (Stock/cultivation): `VIEW_STOCK`, `RECORD_STOCK_IN`, `MANAGE_GROW_CALENDAR`
|
||||
- **Vorstand** (Board member): all permissions except staff management
|
||||
|
||||
### 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 | Schema-per-tenant | Hard data isolation, DSGVO-clean deletion, no cross-tenant query risk |
|
||||
| Frontend MVP | React/Vite SPA | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
|
||||
| Frontend v2 | Next.js | SSR/ISR for SEO on marketing pages; same React codebase |
|
||||
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
|
||||
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
|
||||
| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates |
|
||||
| Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance |
|
||||
| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |
|
||||
| Staff roles | Core feature from Phase 0 | DSGVO requires least-privilege access; retrofitting post-MVP too costly |
|
||||
@@ -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.
|
||||
+1715
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,620 @@
|
||||
# CannaManage — Wireframes & UI Mockups
|
||||
|
||||
**Phase 4a | Document 6 of 7**
|
||||
**Date:** 2026-04-06
|
||||
**Stack:** Spring Boot 3.x · React/Vite SPA · 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
|
||||
|
||||
The frontend is a **React/Vite SPA** with no PrimeFaces or JSF dependency. Component primitives come from [shadcn/ui](https://ui.shadcn.com/) (Radix UI + Tailwind CSS). This gives full control over styling, accessibility, and mobile responsiveness without JSF's lifecycle overhead.
|
||||
|
||||
> **Why not PrimeFaces?** JSF/PrimeFaces is a server-side component model ill-suited to the modern REST API backend we're building. It tightly couples UI lifecycle to the backend, makes mobile responsiveness painful, and creates a hiring bottleneck. React is the right tool here. PrimeFaces is a fine choice for internal enterprise apps — not for a commercial SaaS.
|
||||
|
||||
| Component | Library | Usage |
|
||||
|---|---|---|
|
||||
| `Card` / `Panel` | shadcn/ui | Section containers |
|
||||
| `DataTable` | TanStack Table v8 | Distributions, members, batches — virtualized |
|
||||
| `Pagination` | shadcn/ui Pagination | All tables |
|
||||
| `Input` | shadcn/ui Input | Single-line text fields |
|
||||
| `NumberInput` | react-number-format | Weight inputs (gram precision, min/max) |
|
||||
| `Select` | shadcn/ui Select | Dropdown selects (member, strain, batch) |
|
||||
| `DatePicker` | shadcn/ui Calendar | Date range pickers for reports |
|
||||
| `Progress` | shadcn/ui Progress | Quota consumption bar |
|
||||
| `Button` | shadcn/ui Button | Primary and secondary actions |
|
||||
| `AlertDialog` | shadcn/ui AlertDialog | Dangerous actions (recall) |
|
||||
| `Toast` | sonner | Success/error notifications |
|
||||
| `Badge` | shadcn/ui Badge | Status indicators (AVAILABLE, LOW, RECALLED) |
|
||||
| `Sheet` | shadcn/ui Sheet | Mobile nav drawer |
|
||||
| `Dialog` | shadcn/ui 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
|
||||
|
||||

|
||||
|
||||
#### 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 | Library | Behavior |
|
||||
|---|---|---|
|
||||
| KPI Cards | shadcn/ui Card | Auto-refreshed via `useQuery` (react-query, 60s stale) |
|
||||
| Recent Distributions table | TanStack Table (5 rows) | Row click → navigate to distribution detail |
|
||||
| Member column link | React Router `<Link>` | Navigate to `/admin/members/{id}` |
|
||||
| `+ New Entry` button | shadcn/ui Button variant="default" | Navigate to `/admin/distributions/new` |
|
||||
| Trend indicators | Tailwind `text-green-600` / `text-red-600` | ▲/▼ with delta value |
|
||||
|
||||
---
|
||||
|
||||
### Screen 2 — Distribution Recording Form
|
||||
|
||||

|
||||
|
||||
#### 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 |
|
||||
|---|---|---|---|
|
||||
| 0–79% | `#8BC34A` (green) | Enabled | — |
|
||||
| 80–99% | `#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 | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Member search | shadcn/ui Combobox | `useQuery` debounced search; shows name + member no. |
|
||||
| Strain/Batch dropdown | shadcn/ui Select | Populated after member selection; filters `AVAILABLE` batches |
|
||||
| Weight input | react-number-format | min=0.1 max=25.0 step=0.1; triggers quota recalculation via `onChange` |
|
||||
| Quota bar | shadcn/ui Progress | Color class via `cn()` utility computed in component state |
|
||||
| Submit | shadcn/ui Button | `disabled={quotaExceeded}` from react state |
|
||||
| Cancel | React Router `<Link>` | Returns to distribution log without saving |
|
||||
|
||||
---
|
||||
|
||||
### Screen 3 — Stock Management
|
||||
|
||||

|
||||
|
||||
#### 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 | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Strain filter | shadcn/ui Input | Filters TanStack table client-side via `columnFilters` state |
|
||||
| Status filter | shadcn/ui Select | Filters table rows by status value |
|
||||
| Batch table | TanStack Table | Server-side pagination via `manualPagination`, 10 rows/page |
|
||||
| Status badge | shadcn/ui Badge variant mapped | Icon + text label (not color alone) |
|
||||
| Recall button | shadcn/ui Button variant="destructive" | Opens shadcn/ui AlertDialog before executing |
|
||||
| Confirm dialog | shadcn/ui AlertDialog | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
|
||||
| Add Batch | shadcn/ui Button | Opens shadcn/ui Dialog with batch entry form |
|
||||
|
||||
---
|
||||
|
||||
### Screen 4 — Compliance Report Generation
|
||||
|
||||

|
||||
|
||||
#### 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 | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Month selector | shadcn/ui Select | Months Jan–Dec |
|
||||
| Year selector | shadcn/ui Select | Current year ± 2 |
|
||||
| Generate button | shadcn/ui Button | Calls report API; shows loading spinner; renders PDF thumbnail |
|
||||
| PDF preview | `<iframe>` embedding `/api/v1/reports/preview?month=3&year=2026` | Generated by iText 7 backend |
|
||||
| Download PDF | shadcn/ui Button | `window.open(reportUrl)` — streams PDF from REST endpoint |
|
||||
| Download CSV | shadcn/ui Button | `window.open(csvUrl)` — streams CSV from REST endpoint |
|
||||
| Summary table | TanStack Table | Compliance metrics; zero violations row has `text-green-600` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Member Portal Screens
|
||||
|
||||
### Screen 5 — Member Dashboard / Quota View
|
||||
|
||||

|
||||
|
||||
#### 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 §§6–7, 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 | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Quota circle | Custom CSS radial progress (`conic-gradient`) | Computed from monthly total; color matches threshold rules |
|
||||
| Quota bar | shadcn/ui Progress | Same color logic as admin distribution form |
|
||||
| History table | TanStack Table | Last 10 distributions; sorted newest first; no pagination in MVP |
|
||||
| Strains table | TanStack Table | `status` column: text + icon only, no quantities |
|
||||
|
||||
---
|
||||
|
||||
### Screen 6 — Member Login
|
||||
|
||||
> *No mockup image — ASCII wireframe only.*
|
||||
|
||||
---
|
||||
|
||||
### Screen 7 — Staff Management (Admin)
|
||||
|
||||
> *Core feature — not deferred to v2.*
|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
|
||||
├────────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ Settings › Staff Members [+ Add Staff] │
|
||||
│ 📊 Dashbrd│ │
|
||||
│ │ ┌──────────────────────────────────────────────────┐ │
|
||||
│ 👥 Members│ │ Name │ Role Template │ Permissions │ Act│ │
|
||||
│ │ ├─────────────────┼───────────────┼─────────────┼────┤ │
|
||||
│ 📋 Distrib│ │ Lisa Schmidt │ Ausgabe │ 3 of 8 │[✎][⛔]│
|
||||
│ │ │ Tom Weber │ Lager │ 4 of 8 │[✎][⛔]│
|
||||
│ 📦 Stock │ │ Sandra Müller │ Vorstand │ 7 of 8 │[✎][⛔]│
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
│ 📄 Reports│ │
|
||||
│ │ ┌─── Add / Edit Staff ──────────────────────────────┐ │
|
||||
│ ✅ Complian│ │ Name: _______________ Email: _______________ │ │
|
||||
│ │ │ │ │
|
||||
│ 👤 Staff │ │ Role Template: [ Ausgabe ▼ ] (pre-fills below) │ │
|
||||
│ │ │ │ │
|
||||
│ ⚙ Settings│ │ Permissions: │ │
|
||||
│ │ │ ☑ Record Distribution ☑ View Member List │ │
|
||||
│ │ │ ☑ View Member Quota ☐ Add Member │ │
|
||||
│ │ │ ☐ View Stock ☐ Record Stock In │ │
|
||||
│ │ │ ☐ View Compliance Report ☐ Manage Grow Calendar │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ [ Save Staff Member ] [ Cancel ] │ │
|
||||
│ │ └────────────────────────────────────────────────────┘ │
|
||||
└────────────┴────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Design Decisions
|
||||
|
||||
- **Admin sees everything.** The staff management screen is only accessible with `ROLE_CLUB_ADMIN`. Staff accounts cannot modify their own permissions.
|
||||
- **DSGVO principle of least privilege.** Each staff member only sees the data their role requires. A distribution desk worker (`Ausgabe`) does not see cultivation calendar or full stock levels — only what they need to hand out product.
|
||||
- **Pre-created role templates** reduce admin setup time. Templates are editable — they just pre-fill the permission checkboxes.
|
||||
- **Staff ≠ reduced admin.** Staff accounts do not have access to billing, club settings, or staff management. Even a "Vorstand" staff member cannot create other staff accounts.
|
||||
- **Audit trail.** All distributions recorded by staff include `recorded_by = staffUserId` so it's clear who did what.
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Staff table | TanStack Table | Shows name, role template, permission count, actions |
|
||||
| Role template dropdown | shadcn/ui Select | Pre-populates permission checkboxes on selection |
|
||||
| Permission checkboxes | shadcn/ui Checkbox | Individual overrides after template selection |
|
||||
| Save | shadcn/ui Button | POST/PUT `/api/v1/staff` with `{ permissions: [...] }` |
|
||||
| Deactivate | shadcn/ui Button variant="destructive" | Soft-deletes staff account; data retained for audit |
|
||||
|
||||
---
|
||||
|
||||
#### 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 | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Email field | shadcn/ui Input type="email" | HTML5 validation + react-hook-form `@Email` |
|
||||
| Password field | shadcn/ui Input type="password" | No strength meter on login |
|
||||
| Login button | shadcn/ui Button | Submit via react-hook-form; shows error toast on failure |
|
||||
| Error message | sonner toast | "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/"]
|
||||
Root --> StaffPortal["Staff Portal /staff/"]
|
||||
|
||||
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 --> StaffMgmt["Staff Members"]
|
||||
StaffMgmt --> StaffList["Staff List"]
|
||||
StaffMgmt --> StaffNew["Add/Edit Staff"]
|
||||
AdminPortal --> Settings["Settings"]
|
||||
Settings --> ClubProfile["Club Profile"]
|
||||
|
||||
StaffPortal --> StaffDash["Staff Dashboard\n(permissions-filtered)"]
|
||||
|
||||
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/staff` | Staff list | `ROLE_ADMIN` |
|
||||
| `/admin/staff/new` | Create staff account | `ROLE_ADMIN` |
|
||||
| `/admin/staff/{id}` | Edit staff permissions | `ROLE_ADMIN` |
|
||||
| `/admin/settings` | Club settings | `ROLE_ADMIN` |
|
||||
| `/staff/dashboard` | Staff home (permissions-filtered) | `ROLE_STAFF` |
|
||||
| `/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) — Tailwind Breakpoints
|
||||
|
||||
The React/Vite SPA uses **Tailwind CSS** breakpoints throughout. The switch from PrimeFaces means we no longer depend on JSF's `ui-g-*` responsive grid — Tailwind's `sm:` / `md:` / `lg:` utilities apply cleanly to every component.
|
||||
|
||||
| Breakpoint | Tailwind prefix | Admin Portal | Member Portal |
|
||||
|---|---|---|---|
|
||||
| `≥ 1280px` | `xl:` | Full layout — sidebar + content | Two-column: quota left, history right |
|
||||
| `1024–1279px` | `lg:` | Sidebar collapses to icons (60px) | Two-column (narrower) |
|
||||
| `768–1023px` | `md:` | Sidebar hidden; hamburger sheet | Single-column, full-width cards |
|
||||
| `< 768px` | `sm:` / base | Admin: horizontal table scroll | Member: compact quota ring, condensed table |
|
||||
|
||||
### Member Portal — Mobile-First from Day One
|
||||
|
||||
Members will typically check quota status on their phone. The member portal uses `flex-col` mobile-first layout with `md:flex-row` for wider viewports — no breakpoint-specific class sprawl.
|
||||
|
||||
### Responsive Conventions (React/Tailwind)
|
||||
|
||||
- No inline styles — use Tailwind utilities exclusively
|
||||
- `cn()` utility (clsx + tailwind-merge) for conditional class composition
|
||||
- Tables on mobile: horizontal scroll wrapper `overflow-x-auto` on `<div>` wrapping `<table>`
|
||||
- All modals and sheets use `shadcn/ui Dialog` / `Sheet` — these are already mobile-friendly (viewport-aware positioning)
|
||||
- Touch targets: all interactive elements `min-h-[44px]` and `min-w-[44px]` per WCAG 2.5.5
|
||||
|
||||
### v2 Roadmap
|
||||
|
||||
- PWA manifest + service worker (offline quota display)
|
||||
- Push notifications for low quota warnings
|
||||
- Per-club subdomain routing (`clubname.cannamanage.de`)
|
||||
|
||||
---
|
||||
|
||||
## 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 `Input` / `NumberInput` fields have `<label>` with `htmlFor` (React) — Radix UI enforces this automatically for shadcn/ui form fields
|
||||
- `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
|
||||
- TanStack Table exposes `role="grid"` and `aria-rowcount` via `getTableProps()`
|
||||
|
||||
### 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 §§19–22.
|
||||
|
||||
### 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,676 @@
|
||||
# 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 (Release) | TrueNAS.local — Docker (Build/CI)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Dev["👨💻 Dev Workstation\n(Fedora, 192.168.188.x)"]
|
||||
Gitea["🏠 Gitea\n(truenas.local:30008)"]
|
||||
TrueNAS["🖧 TrueNAS.local Docker\n(192.168.188.119)\nBuild + Staging"]
|
||||
Hetzner["☁️ Hetzner VPS CX21\nProduction Release"]
|
||||
|
||||
Dev -->|"git push"| Gitea
|
||||
Gitea -->|"Gitea Actions runner\n(on TrueNAS.local)"| TrueNAS
|
||||
TrueNAS -->|"mvn package + docker build"| TrueNAS
|
||||
TrueNAS -->|"docker save | scp\n(on merge to main)"| Hetzner
|
||||
|
||||
subgraph TrueNAS ["TrueNAS.local — CI/CD Build Environment"]
|
||||
GiteaRunner["Gitea Actions Runner"]
|
||||
BuildCache["Maven .m2 cache\n(persistent volume)"]
|
||||
StagingDB["PostgreSQL staging\n(ephemeral)"]
|
||||
end
|
||||
|
||||
subgraph Hetzner ["Hetzner VPS — Production Release Environment"]
|
||||
Nginx["Nginx (reverse proxy + TLS)"]
|
||||
App["cannamanage-app\n(Spring Boot 3.x)"]
|
||||
DB["PostgreSQL 16\n(persistent pgdata volume)"]
|
||||
Nginx -->|"proxy_pass :8080"| App
|
||||
App -->|"JDBC :5432"| DB
|
||||
end
|
||||
|
||||
Internet["🌍 Internet HTTPS"] -->|"port 443"| Nginx
|
||||
```
|
||||
|
||||
### Environment Roles
|
||||
|
||||
| Environment | Host | Purpose |
|
||||
|---|---|---|
|
||||
| **Development** | Dev workstation (Fedora) | Local feature development, unit tests |
|
||||
| **Build / CI** | TrueNAS.local Docker | Gitea Actions runner; Maven build; integration tests (Testcontainers); Docker image build |
|
||||
| **Production / Release** | Hetzner VPS CX21 | Live clubs, real data; Hetzner = our release environment |
|
||||
|
||||
All three services on Hetzner 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 on TrueNAS.local)
|
||||
|
||||
The Gitea Actions runner runs **on TrueNAS.local** — this is our homelab build machine. It has Docker, a persistent Maven `.m2` cache volume, and direct SSH access to the Hetzner VPS. Builds happen locally; only the final artifact (Docker image tarball) is shipped to Hetzner.
|
||||
|
||||
**File:** `.gitea/workflows/deploy.yml`
|
||||
|
||||
```yaml
|
||||
name: Build and Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted # <-- TrueNAS.local Gitea runner
|
||||
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 starts PostgreSQL via Docker on the TrueNAS runner
|
||||
|
||||
- name: Coverage gate check
|
||||
run: ./mvnw verify -P coverage-check
|
||||
|
||||
build-and-deploy:
|
||||
needs: test
|
||||
runs-on: self-hosted # <-- TrueNAS.local Gitea runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build JAR (production profile)
|
||||
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 Hetzner VPS
|
||||
run: |
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
/tmp/cannamanage.tar.gz \
|
||||
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
|
||||
|
||||
- name: Deploy via SSH to Hetzner (Production Release)
|
||||
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 SHAs)
|
||||
docker image prune -f
|
||||
"
|
||||
|
||||
- name: Cleanup local build artifact
|
||||
run: rm -f /tmp/cannamanage.tar.gz
|
||||
```
|
||||
|
||||
### Gitea Actions Runner on TrueNAS.local
|
||||
|
||||
The self-hosted runner is a Docker container on TrueNAS.local:
|
||||
|
||||
```bash
|
||||
# On TrueNAS.local — install Gitea Actions runner
|
||||
docker run -d \
|
||||
--name gitea-runner-cannamanage \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner/cannamanage:/data \
|
||||
-v /opt/gitea-runner/.m2:/root/.m2 \ # Maven cache persisted across builds
|
||||
-e GITEA_INSTANCE_URL=http://192.168.188.119:30008 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token-from-gitea-settings> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
### Required Gitea Repository Secrets
|
||||
|
||||
| Secret | Where set | Value |
|
||||
|--------|-----------|-------|
|
||||
| `HETZNER_IP` | Gitea repo secrets | Hetzner VPS IPv4 address |
|
||||
| `SSH_PRIVATE_KEY` | Gitea repo secrets | Private key for `deploy` user on Hetzner |
|
||||
|
||||
```bash
|
||||
# On Hetzner VPS — add TrueNAS runner's public key
|
||||
# (generate keypair on TrueNAS.local: ssh-keygen -t ed25519 -f ~/.ssh/gitea_runner_deploy)
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "<truenas-runner-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 2–3 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 18–24 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 3–6 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 §§6–7 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 (€300–500) 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-001–010)
|
||||
- [ ] Implement `MemberService` with age validation (TC-011–015)
|
||||
- [ ] 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 (€300–500 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,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:** 500–3,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)
|
||||
Reference in New Issue
Block a user