From c25a97c37bc6f89079e366abfa83437385baec35 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Mon, 6 Apr 2026 11:07:35 +0200 Subject: [PATCH] docs(cannamanage): add complete Phase 0 documentation suite - 01-PROJECT-CHARTER.md: project charter with Gantt chart and risk register - 02-USER-STORIES.md: 25 user stories with MoSCoW priorities and ACs - 03-ARCHITECTURE.md: system architecture, ERD (8 entities), multi-tenancy design - 04-FLOWCHARTS.md: 5 business logic flow charts (distribution, recall, etc) - 05-API-SPEC.md: REST API spec (7 controllers, 30+ endpoints) - 06-WIREFRAMES.md: 6 screen wireframes with AI-generated mockup images - 07-CODING-STANDARDS.md: Java 21 standards, Git strategy, compliance rules - 08-TEST-PLAN.md: 26 test cases, JaCoCo coverage gates - 09-DEPLOYMENT-GUIDE.md: Hetzner Docker Compose + Gitea CI/CD pipeline - README.md + CHANGELOG.md + 10-RETROSPECTIVE.md - 5 AI-generated UI mockup images (Flux Schnell/ComfyUI) --- .../docs/01-PROJECT-CHARTER.md | 227 +++ .../docs/02-USER-STORIES.md | 467 +++++ .../docs/03-ARCHITECTURE.md | 504 +++++ .../cannabis-club-saas/docs/04-FLOWCHARTS.md | 229 +++ plans/cannabis-club-saas/docs/05-API-SPEC.md | 1715 +++++++++++++++++ .../cannabis-club-saas/docs/06-WIREFRAMES.md | 550 ++++++ .../docs/07-CODING-STANDARDS.md | 825 ++++++++ plans/cannabis-club-saas/docs/08-TEST-PLAN.md | 439 +++++ .../docs/09-DEPLOYMENT-GUIDE.md | 639 ++++++ .../docs/10-RETROSPECTIVE.md | 97 + plans/cannabis-club-saas/docs/CHANGELOG.md | 43 + plans/cannabis-club-saas/docs/README.md | 186 ++ .../docs/images/mockup-admin-dashboard.png | Bin 0 -> 633942 bytes .../docs/images/mockup-compliance-report.png | Bin 0 -> 524732 bytes .../docs/images/mockup-distribution-form.png | Bin 0 -> 296694 bytes .../docs/images/mockup-member-quota.png | Bin 0 -> 446093 bytes .../docs/images/mockup-stock-management.png | Bin 0 -> 633773 bytes 17 files changed, 5921 insertions(+) create mode 100644 plans/cannabis-club-saas/docs/01-PROJECT-CHARTER.md create mode 100644 plans/cannabis-club-saas/docs/02-USER-STORIES.md create mode 100644 plans/cannabis-club-saas/docs/03-ARCHITECTURE.md create mode 100644 plans/cannabis-club-saas/docs/04-FLOWCHARTS.md create mode 100644 plans/cannabis-club-saas/docs/05-API-SPEC.md create mode 100644 plans/cannabis-club-saas/docs/06-WIREFRAMES.md create mode 100644 plans/cannabis-club-saas/docs/07-CODING-STANDARDS.md create mode 100644 plans/cannabis-club-saas/docs/08-TEST-PLAN.md create mode 100644 plans/cannabis-club-saas/docs/09-DEPLOYMENT-GUIDE.md create mode 100644 plans/cannabis-club-saas/docs/10-RETROSPECTIVE.md create mode 100644 plans/cannabis-club-saas/docs/CHANGELOG.md create mode 100644 plans/cannabis-club-saas/docs/README.md create mode 100644 plans/cannabis-club-saas/docs/images/mockup-admin-dashboard.png create mode 100644 plans/cannabis-club-saas/docs/images/mockup-compliance-report.png create mode 100644 plans/cannabis-club-saas/docs/images/mockup-distribution-form.png create mode 100644 plans/cannabis-club-saas/docs/images/mockup-member-quota.png create mode 100644 plans/cannabis-club-saas/docs/images/mockup-stock-management.png diff --git a/plans/cannabis-club-saas/docs/01-PROJECT-CHARTER.md b/plans/cannabis-club-saas/docs/01-PROJECT-CHARTER.md new file mode 100644 index 0000000..cdb8b5f --- /dev/null +++ b/plans/cannabis-club-saas/docs/01-PROJECT-CHARTER.md @@ -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)* diff --git a/plans/cannabis-club-saas/docs/02-USER-STORIES.md b/plans/cannabis-club-saas/docs/02-USER-STORIES.md new file mode 100644 index 0000000..103e553 --- /dev/null +++ b/plans/cannabis-club-saas/docs/02-USER-STORIES.md @@ -0,0 +1,467 @@ +# CannaManage — User Stories & Acceptance Criteria + +**Author:** Patrick Plate +**Date:** 2026-04-06 +**Version:** 1.0 +**Status:** Draft for Review + +--- + +## MoSCoW Summary + +| Priority | Count | Release Target | Description | +|----------|-------|----------------|-------------| +| 🔴 **Must Have** | 14 (US-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 | + +--- + +*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)* diff --git a/plans/cannabis-club-saas/docs/03-ARCHITECTURE.md b/plans/cannabis-club-saas/docs/03-ARCHITECTURE.md new file mode 100644 index 0000000..2cabfda --- /dev/null +++ b/plans/cannabis-club-saas/docs/03-ARCHITECTURE.md @@ -0,0 +1,504 @@ +# 03 — System Architecture + +**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen) +**Phase:** 2 of 5 — Architecture & Data Model +**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · PrimeFaces JSF MVP → Next.js v2 +**Last updated:** 2026-04-06 + +--- + +## 1. Architecture Overview + +```mermaid +graph TD + AdminBrowser["🖥️ Browser — Admin Portal"] + MemberBrowser["🖥️ Browser — Member Portal"] + + JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"] + + AdminBrowser -->|HTTP/S| JSF + MemberBrowser -->|HTTP/S| JSF + + JSF -->|REST calls| Backend + + subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"] + REST["REST API Layer\n/api/v1/"] + Service["Service Layer\n(ComplianceService, ReportService…)"] + JPA["JPA / Hibernate\nRepositories"] + Security["Spring Security + JWT\nTenant Interceptor"] + + REST --> Service + Service --> JPA + Security --> REST + end + + JPA -->|JDBC| PG[("🐘 PostgreSQL 16\nmulti-tenant via tenant_id")] + Backend -->|Stripe Java SDK| Stripe["💳 Stripe\n(payment processing)"] + Backend -->|Jakarta Mail| Mail["📧 Jakarta Mail\n(email notifications)"] + Backend -->|iText 7| PDF["📄 iText 7\n(PDF report generation)"] + + Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG + + subgraph Hetzner ["🖧 Hetzner VPS — Docker Compose"] + Backend + PG + Nginx["🔒 Nginx\n(reverse proxy + TLS)"] + end + + JSF --> Nginx + Nginx --> Backend +``` + +### Component Responsibilities + +| Component | Technology | Role | +|---|---|---| +| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI | +| Member Portal | PrimeFaces JSF (→ Next.js v2) | Member quota & history UI | +| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints | +| Auth | Spring Security 6 + JJWT | Stateless JWT authentication | +| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering | +| Database | PostgreSQL 16 | Primary data store (multi-tenant) | +| Migrations | Flyway | Versioned schema management | +| Payments | Stripe Java SDK | Club subscription billing | +| Email | Jakarta Mail / Spring Mail | Welcome emails, recall alerts | +| PDF | iText 7 | Compliance report generation | +| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment | + +--- + +## 2. Multi-Tenancy Strategy + +### Approach: Shared Schema with Row-Level Filtering + +Every JPA entity carries a `tenant_id` column (UUID, `NOT NULL`). A single PostgreSQL database hosts all clubs — row-level filtering enforces data isolation at the application layer. + +**Why shared schema (not separate schema/DB per tenant)?** +- Lower operational overhead for an MVP with < 500 clubs +- Single Flyway migration path across all tenants +- Simpler connection pooling (one pool, not N) +- Acceptable security risk when `tenant_id` filter is enforced at the service layer + +### Tenant Resolution + +``` +HTTP Request + └─ Spring Security Filter: extract JWT → resolve tenant_id + └─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal + └─ JPA @Where filter applied on every entity query +``` + +### Code Pattern — Tenant-Aware Base Entity + +```java +// AbstractTenantEntity.java (pseudocode) +@MappedSuperclass +@FilterDef( + name = "tenantFilter", + parameters = @ParamDef(name = "tenantId", type = UUID.class) +) +@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") +public abstract class AbstractTenantEntity { + + @Column(name = "tenant_id", nullable = false, updatable = false) + private UUID tenantId; + + @PrePersist + void injectTenant() { + this.tenantId = TenantContext.getCurrentTenant(); + } +} +``` + +```java +// TenantFilterInterceptor.java (pseudocode) +@Component +public class TenantFilterInterceptor implements HandlerInterceptor { + + @Autowired EntityManager em; + + @Override + public boolean preHandle(HttpServletRequest req, ...) { + UUID tenantId = TenantContext.getCurrentTenant(); + Session session = em.unwrap(Session.class); + session.enableFilter("tenantFilter") + .setParameter("tenantId", tenantId); + return true; + } +} +``` + +**Invariants enforced:** +- `tenant_id` is set at `@PrePersist` — never accepted from user input +- `tenant_id` is `updatable = false` — cannot be changed after creation +- Hibernate filter is enabled on every request thread before any query executes +- All repository methods inherit the filter; raw JPQL queries must include `AND e.tenantId = :tenantId` + +--- + +## 3. Authentication & Authorization + +### JWT Token Flow + +- **Access token:** 8-hour expiry, signed HS256, contains `sub` (userId), `role`, `tenantId` +- **Refresh token:** 30-day expiry, stored in `users.refresh_token_hash` (hashed) +- **Stateless:** No server-side session. JWT is verified on every request by `JwtAuthFilter` + +### Roles + +| Role | Description | Access | +|---|---|---| +| `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions | +| `ROLE_MEMBER` | Club member | Own quota, own distribution history | +| `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data | + +### Service-Layer Authorization Example + +```java +@Service +public class DistributionService { + + @PreAuthorize("hasRole('CLUB_ADMIN')") + public Distribution recordDistribution(RecordDistributionRequest req) { ... } + + @PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId") + public QuotaStatus getMyQuota(UUID memberId) { ... } + + @PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')") + public List 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 { memberId, name, email, totalReceived } + */ + public List recallBatch(UUID batchId) { ... } +} +``` + +### Race Condition Prevention + +`MonthlyQuota` rows carry a JPA `@Version` column (optimistic locking). If two concurrent requests attempt to increment `total_distributed` simultaneously, one will receive an `OptimisticLockException` and must retry. The retry logic is handled by a `@Retryable` annotation (Spring Retry, max 3 attempts, 50ms backoff). + +```java +@Entity +public class MonthlyQuota extends AbstractTenantEntity { + + @Version + private Long version; // optimistic lock + + // ... other fields +} +``` + +--- + +## 7. Infrastructure (Hetzner) + +```mermaid +graph TD + Dev["👨‍💻 Developer (Fedora Workstation)"] + Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"] + Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"] + + Dev -->|git push| Gitea + Gitea -->|SSH deploy script\n(webhook → deploy.sh)| Hetzner + + subgraph Hetzner + Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"] + App["☕ cannamanage-app\n(Spring Boot JAR)"] + DB[("🐘 cannamanage-db\nPostgreSQL 16")] + + Nginx -->|proxy_pass :8080| App + App -->|JDBC :5432| DB + end + + Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx +``` + +### Docker Compose Services + +```yaml +# docker-compose.yml (abbreviated) +services: + cannamanage-app: + image: cannamanage:latest + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage + JWT_SECRET: ${JWT_SECRET} + STRIPE_API_KEY: ${STRIPE_API_KEY} + depends_on: [cannamanage-db] + ports: ["127.0.0.1:8080:8080"] + + cannamanage-db: + image: postgres:16-alpine + volumes: [pgdata:/var/lib/postgresql/data] + environment: + POSTGRES_DB: cannamanage + POSTGRES_PASSWORD: ${DB_PASSWORD} + + cannamanage-nginx: + image: nginx:alpine + ports: ["443:443", "80:80"] + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - /etc/letsencrypt:/etc/letsencrypt:ro +``` + +### Hetzner Sizing + +| Resource | Spec | Rationale | +|---|---|---| +| Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP | +| Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups | +| Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days | +| Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany | +| TLS | Let's Encrypt via Certbot | Auto-renew via cron | + +### Deployment Workflow + +``` +git push origin main + → Gitea webhook fires + → deploy.sh on Hetzner: + docker pull cannamanage:latest + docker compose up -d --no-deps cannamanage-app + # zero-downtime: Nginx buffers requests during restart +``` + +Flyway migrations run automatically on application startup (`spring.flyway.enabled=true`). Migration files live at `src/main/resources/db/migration/V*.sql`. + +--- + +## 8. Key Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Multi-tenancy | Shared schema + `tenant_id` | MVP simplicity; upgrade to schema-per-tenant possible later | +| Frontend MVP | PrimeFaces JSF | Patrick's existing expertise; fastest path to working UI | +| Frontend v2 | Next.js / React | Modern UX; deferred to avoid scope creep in MVP | +| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready | +| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts | +| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates | +| Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance | +| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC | diff --git a/plans/cannabis-club-saas/docs/04-FLOWCHARTS.md b/plans/cannabis-club-saas/docs/04-FLOWCHARTS.md new file mode 100644 index 0000000..1e7dfa8 --- /dev/null +++ b/plans/cannabis-club-saas/docs/04-FLOWCHARTS.md @@ -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. diff --git a/plans/cannabis-club-saas/docs/05-API-SPEC.md b/plans/cannabis-club-saas/docs/05-API-SPEC.md new file mode 100644 index 0000000..7939e01 --- /dev/null +++ b/plans/cannabis-club-saas/docs/05-API-SPEC.md @@ -0,0 +1,1715 @@ +# CannaManage REST API Specification v1.0 + +**Base URL:** `https://{club-domain}/api/v1` +**Content-Type:** `application/json` +**Authentication:** Bearer JWT token via `Authorization: Bearer ` header +**Versioning:** URL-based — current version `/api/v1/`, next version `/api/v2/` + +--- + +## Table of Contents + +1. [Authentication & Conventions](#1-authentication--conventions) +2. [Error Format](#2-error-format) +3. [Pagination Envelope](#3-pagination-envelope) +4. [Custom Error Codes](#4-custom-error-codes) +5. [Auth Controller](#5-auth-controller-auth) +6. [Club Controller](#6-club-controller-clubs) +7. [Member Controller](#7-member-controller-members) +8. [Distribution Controller](#8-distribution-controller-distributions) +9. [Stock Controller](#9-stock-controller-stock) +10. [Report Controller](#10-report-controller-reports) +11. [Compliance Controller](#11-compliance-controller-compliance) + +--- + +## 1. Authentication & Conventions + +### JWT Claims + +The access token payload contains: + +```json +{ + "sub": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenant_id": "8e1a2b3c-4d5e-6f70-8091-a2b3c4d5e6f7", + "role": "ADMIN", + "email": "admin@gruener-daumen-ev.de", + "iat": 1712345678, + "exp": 1712349278 +} +``` + +**`tenant_id` is ALWAYS resolved from the JWT.** It is never accepted as a request parameter or path variable. Any attempt to access data belonging to a different tenant returns `403 TENANT_VIOLATION`. + +### Roles + +| Role | Description | +|------|-------------| +| `ADMIN` | Club administrator — full access to club data | +| `MEMBER` | Regular club member — read-only access to own profile and distributions | + +### Token Lifetimes + +| Token | Lifetime | +|-------|----------| +| Access token | 1 hour | +| Refresh token | 30 days | + +--- + +## 2. Error Format + +All error responses use `application/problem+json` format: + +```json +{ + "status": 400, + "error": "BAD_REQUEST", + "code": "QUOTA_EXCEEDED_MONTHLY", + "message": "Monthly distribution quota of 50g exceeded for member.", + "timestamp": "2026-04-06T10:15:30.000Z", + "path": "/api/v1/distributions" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `status` | `integer` | HTTP status code | +| `error` | `string` | HTTP status reason phrase | +| `code` | `string` | Machine-readable application error code (see §4) | +| `message` | `string` | Human-readable description | +| `timestamp` | `string` | ISO 8601 UTC timestamp | +| `path` | `string` | Request path that caused the error | + +--- + +## 3. Pagination Envelope + +All list endpoints returning paginated results use this envelope: + +```json +{ + "content": [ "...array of items..." ], + "page": 0, + "size": 20, + "totalElements": 150, + "totalPages": 8 +} +``` + +**Standard query parameters for paginated endpoints:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `integer` | `0` | Zero-based page index | +| `size` | `integer` | `20` | Page size (max: 100) | +| `sort` | `string` | varies | Field name + direction, e.g. `createdAt,desc` | + +--- + +## 4. Custom Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `QUOTA_EXCEEDED_DAILY` | 422 | Member has reached the 25 g/day distribution limit (KCanG §19 Abs. 5) | +| `QUOTA_EXCEEDED_MONTHLY` | 422 | Member has reached the monthly limit: 50 g (≥21 yrs) or 30 g (<21 yrs) | +| `BATCH_RECALLED` | 422 | The requested batch has an active contamination/recall flag | +| `MEMBER_INACTIVE` | 422 | Member status is `SUSPENDED` or `EXPELLED` — distributions blocked | +| `MEMBER_UNDERAGE` | 422 | Member date of birth indicates they are under 18 years old | +| `DISTRIBUTION_IMMUTABLE` | 422 | Attempt to modify or delete an existing distribution record (audit trail protected) | +| `TENANT_VIOLATION` | 403 | Requested resource belongs to a different tenant than the JWT claims | +| `DSGVO_CONSENT_MISSING` | 422 | Member has no DSGVO (GDPR) data processing consent on record | +| `BATCH_INSUFFICIENT_STOCK` | 422 | Batch does not have sufficient remaining quantity for the requested distribution | +| `INVALID_CREDENTIALS` | 401 | Email/password combination is incorrect | +| `TOKEN_EXPIRED` | 401 | Access or refresh token has expired | +| `TOKEN_INVALID` | 401 | Token signature is invalid or malformed | +| `MEMBER_NOT_FOUND` | 404 | No member with the given UUID exists in this tenant | +| `BATCH_NOT_FOUND` | 404 | No batch with the given UUID exists in this tenant | +| `DISTRIBUTION_NOT_FOUND` | 404 | No distribution with the given UUID exists in this tenant | + +--- + +## 5. Auth Controller (`/auth`) + +### 5.1 POST `/auth/login` + +Authenticate with email and password. Returns a short-lived access token and a long-lived refresh token. + +**Authentication:** None required + +**Request Body:** + +```json +{ + "email": "admin@gruener-daumen-ev.de", + "password": "supersecret123" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | `string` | ✅ | Club administrator email address | +| `password` | `string` | ✅ | Plaintext password (transmitted over TLS) | + +**Success Response — `200 OK`:** + +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...", + "tokenType": "Bearer", + "expiresIn": 3600, + "member": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "email": "admin@gruener-daumen-ev.de", + "role": "ADMIN", + "clubId": "8e1a2b3c-4d5e-6f70-8091-a2b3c4d5e6f7", + "clubName": "Grüner Daumen e.V." + } +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `INVALID_CREDENTIALS` | Email not found or password does not match | +| 400 | `BAD_REQUEST` | Missing required fields | +| 429 | `TOO_MANY_REQUESTS` | Rate limit exceeded (5 failed attempts per 15 minutes) | + +--- + +### 5.2 POST `/auth/refresh` + +Exchange a valid refresh token for a new access token. + +**Authentication:** Refresh token in request body (not a Bearer token) + +**Request Body:** + +```json +{ + "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..." +} +``` + +**Success Response — `200 OK`:** + +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "tokenType": "Bearer", + "expiresIn": 3600 +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_EXPIRED` | Refresh token has passed its 30-day lifetime | +| 401 | `TOKEN_INVALID` | Refresh token signature is invalid or has been revoked | +| 400 | `BAD_REQUEST` | `refreshToken` field is missing | + +--- + +### 5.3 POST `/auth/logout` + +Invalidate the current refresh token. Access tokens remain valid until natural expiry (TTL-based, no server-side revocation). + +**Authentication:** Bearer access token required + +**Request Body:** None + +**Success Response — `204 No Content`** + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token is invalid or expired | + +--- + +## 6. Club Controller (`/clubs`) + +### 6.1 GET `/clubs/me` + +Retrieve the authenticated admin's club details. Tenant is resolved from JWT. + +**Authentication:** Bearer token — role `ADMIN` + +**Success Response — `200 OK`:** + +```json +{ + "id": "8e1a2b3c-4d5e-6f70-8091-a2b3c4d5e6f7", + "name": "Grüner Daumen e.V.", + "registrationNumber": "VR 12345", + "address": { + "street": "Hanfstraße 42", + "city": "Berlin", + "postalCode": "10115", + "state": "Berlin" + }, + "contactEmail": "info@gruener-daumen-ev.de", + "contactPhone": "+49 30 12345678", + "foundedDate": "2024-04-01", + "maxMembers": 500, + "currentMemberCount": 87, + "status": "ACTIVE", + "createdAt": "2024-04-01T09:00:00.000Z", + "updatedAt": "2026-03-15T14:22:00.000Z" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 6.2 PUT `/clubs/me` + +Update the authenticated admin's club details. + +**Authentication:** Bearer token — role `ADMIN` + +**Request Body:** + +```json +{ + "name": "Grüner Daumen e.V.", + "registrationNumber": "VR 12345", + "address": { + "street": "Hanfstraße 42", + "city": "Berlin", + "postalCode": "10115", + "state": "Berlin" + }, + "contactEmail": "info@gruener-daumen-ev.de", + "contactPhone": "+49 30 12345678", + "maxMembers": 500 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | ✅ | Official club name as registered | +| `registrationNumber` | `string` | ✅ | Vereinsregister number | +| `address` | `object` | ✅ | Club registered address | +| `contactEmail` | `string` | ✅ | Public contact email | +| `contactPhone` | `string` | ❌ | Public contact phone | +| `maxMembers` | `integer` | ❌ | Maximum membership capacity (default: 500, KCanG limit) | + +**Success Response — `200 OK`:** Full club object (same as GET `/clubs/me`) + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Validation failure (invalid email format, missing required fields) | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 6.3 GET `/clubs/me/stats` + +Retrieve dashboard statistics for the authenticated club. + +**Authentication:** Bearer token — role `ADMIN` + +**Success Response — `200 OK`:** + +```json +{ + "memberCount": { + "total": 87, + "active": 82, + "pending": 3, + "suspended": 1, + "expelled": 1 + }, + "distributionsThisMonth": { + "count": 214, + "totalGrams": 3280.5, + "uniqueMembers": 74 + }, + "stock": { + "totalGrams": 12500.0, + "activeBatches": 4, + "strainCount": 6 + }, + "complianceAlerts": { + "membersAtDailyLimit": 2, + "membersAtMonthlyLimit": 5, + "recalledBatchesActive": 0 + }, + "generatedAt": "2026-04-06T10:00:00.000Z" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +## 7. Member Controller (`/members`) + +### 7.1 GET `/members` + +List all members of the authenticated club, paginated and optionally filtered by status. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `integer` | `0` | Page index (zero-based) | +| `size` | `integer` | `20` | Page size | +| `sort` | `string` | `lastName,asc` | Sort field and direction | +| `status` | `string` | (all) | Filter by status: `ACTIVE`, `PENDING`, `SUSPENDED`, `EXPELLED` | +| `search` | `string` | (none) | Full-text search against first name, last name, or member number | + +**Success Response — `200 OK`:** + +```json +{ + "content": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2024-001", + "firstName": "Max", + "lastName": "Mustermann", + "email": "max@example.de", + "status": "ACTIVE", + "dateOfBirth": "1990-05-15", + "joinDate": "2024-05-01", + "monthlyQuotaGrams": 50, + "createdAt": "2024-05-01T10:00:00.000Z" + } + ], + "page": 0, + "size": 20, + "totalElements": 87, + "totalPages": 5 +} +``` + +> **Note:** `dateOfBirth` is only included for ADMIN role. Member role responses omit it. + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 7.2 POST `/members` + +Create a new club member. Generates a unique member number automatically. + +**Authentication:** Bearer token — role `ADMIN` + +**Request Body:** + +```json +{ + "firstName": "Max", + "lastName": "Mustermann", + "email": "max@example.de", + "dateOfBirth": "1990-05-15", + "address": { + "street": "Musterstraße 1", + "city": "Berlin", + "postalCode": "10115", + "state": "Berlin" + }, + "phone": "+49 176 12345678", + "dsgvoConsentDate": "2026-04-06", + "joinDate": "2026-04-06", + "notes": "Referred by member GD-2024-015" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `firstName` | `string` | ✅ | Legal first name | +| `lastName` | `string` | ✅ | Legal last name | +| `email` | `string` | ✅ | Contact email (must be unique within tenant) | +| `dateOfBirth` | `string` | ✅ | ISO 8601 date — used for quota calculation (≥21: 50 g/month, <21: 30 g/month) | +| `address` | `object` | ✅ | Member's registered home address | +| `phone` | `string` | ❌ | Contact phone number | +| `dsgvoConsentDate` | `string` | ✅ | Date member signed DSGVO consent form | +| `joinDate` | `string` | ✅ | Official membership start date | +| `notes` | `string` | ❌ | Internal admin notes (not visible to member) | + +**Success Response — `201 Created`:** + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "firstName": "Max", + "lastName": "Mustermann", + "email": "max@example.de", + "status": "ACTIVE", + "dateOfBirth": "1990-05-15", + "monthlyQuotaGrams": 50, + "joinDate": "2026-04-06", + "dsgvoConsentDate": "2026-04-06", + "createdAt": "2026-04-06T10:30:00.000Z" +} +``` + +> `monthlyQuotaGrams` is computed server-side based on age at time of creation: 50 g for members ≥21 years old, 30 g for members aged 18–20. + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Missing required fields or validation failure | +| 409 | `CONFLICT` | Email already registered for another member in this tenant | +| 422 | `MEMBER_UNDERAGE` | Computed age from `dateOfBirth` is under 18 | +| 422 | `DSGVO_CONSENT_MISSING` | `dsgvoConsentDate` is null or missing | +| 422 | `QUOTA_EXCEEDED_DAILY` | Club has reached maximum member capacity (500 members per KCanG §15) | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 7.3 GET `/members/{id}` + +Retrieve full details for a single member. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Member's unique identifier | + +**Success Response — `200 OK`:** + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "firstName": "Max", + "lastName": "Mustermann", + "email": "max@example.de", + "phone": "+49 176 12345678", + "dateOfBirth": "1990-05-15", + "address": { + "street": "Musterstraße 1", + "city": "Berlin", + "postalCode": "10115", + "state": "Berlin" + }, + "status": "ACTIVE", + "monthlyQuotaGrams": 50, + "joinDate": "2026-04-06", + "dsgvoConsentDate": "2026-04-06", + "notes": "Referred by member GD-2024-015", + "createdAt": "2026-04-06T10:30:00.000Z", + "updatedAt": "2026-04-06T10:30:00.000Z" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `MEMBER_NOT_FOUND` | No member with given UUID exists in this tenant | +| 403 | `TENANT_VIOLATION` | Member UUID exists but belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 7.4 PUT `/members/{id}` + +Update an existing member's details. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Member's unique identifier | + +**Request Body:** Same structure as POST `/members` (all fields optional except those required for compliance) + +**Success Response — `200 OK`:** Full updated member object + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Validation failure | +| 404 | `MEMBER_NOT_FOUND` | No member with given UUID exists in this tenant | +| 403 | `TENANT_VIOLATION` | Member UUID belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 7.5 DELETE `/members/{id}` + +Soft-delete / expel a member. Sets `status` to `EXPELLED` and records an expulsion timestamp. **No data is physically deleted** — all historical records (distributions, etc.) are retained for compliance auditing. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Member's unique identifier | + +**Request Body:** + +```json +{ + "reason": "Voluntary membership resignation", + "effectiveDate": "2026-04-06" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | `string` | ✅ | Reason for expulsion/resignation (stored in audit log) | +| `effectiveDate` | `string` | ✅ | ISO 8601 date when expulsion takes effect | + +**Success Response — `200 OK`:** + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "status": "EXPELLED", + "expelledAt": "2026-04-06T11:00:00.000Z", + "expulsionReason": "Voluntary membership resignation" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Missing `reason` or `effectiveDate` | +| 404 | `MEMBER_NOT_FOUND` | No member with given UUID in this tenant | +| 409 | `CONFLICT` | Member already has `EXPELLED` status | +| 403 | `TENANT_VIOLATION` | Member UUID belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 7.6 GET `/members/me` + +Retrieve the authenticated member's own profile. + +**Authentication:** Bearer token — role `MEMBER` + +**Success Response — `200 OK`:** + +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "firstName": "Max", + "lastName": "Mustermann", + "email": "max@example.de", + "status": "ACTIVE", + "monthlyQuotaGrams": 50, + "joinDate": "2026-04-06" +} +``` + +> `dateOfBirth`, `address`, `notes`, and internal fields are omitted from member self-view. + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 422 | `MEMBER_INACTIVE` | Member account is suspended or expelled | + +--- + +### 7.7 GET `/members/{id}/quota` + +Get the current month's quota usage for a member. + +**Authentication:** Bearer token — role `ADMIN`, or `MEMBER` accessing their own ID + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Member's unique identifier | + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `month` | `string` | current month | ISO 8601 year-month, e.g. `2026-04` | + +**Success Response — `200 OK`:** + +```json +{ + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "month": "2026-04", + "monthlyLimitGrams": 50, + "distributedThisMonthGrams": 22.5, + "remainingMonthlyGrams": 27.5, + "dailyLimitGrams": 25, + "distributedTodayGrams": 5.0, + "remainingTodayGrams": 20.0, + "distributionCount": 3, + "quotaExceeded": false, + "nearLimit": false +} +``` + +> `nearLimit` is `true` when remaining grams ≤ 10 g (monthly) or ≤ 5 g (daily). + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 403 | `FORBIDDEN` | MEMBER role accessing another member's quota | +| 404 | `MEMBER_NOT_FOUND` | No member with given UUID in this tenant | +| 403 | `TENANT_VIOLATION` | Member UUID belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | + +--- + +### 7.8 GET `/members/{id}/distributions` + +Get distribution history for a member, paginated. + +**Authentication:** Bearer token — role `ADMIN`, or `MEMBER` accessing their own ID + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Member's unique identifier | + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `integer` | `0` | Page index | +| `size` | `integer` | `20` | Page size | +| `sort` | `string` | `distributedAt,desc` | Sort field and direction | +| `from` | `string` | — | ISO 8601 date filter start, e.g. `2026-01-01` | +| `to` | `string` | — | ISO 8601 date filter end | + +**Success Response — `200 OK`:** + +```json +{ + "content": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "distributedAt": "2026-04-06T09:30:00.000Z", + "strainName": "Blue Dream", + "batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "quantityGrams": 5.0, + "notes": null + } + ], + "page": 0, + "size": 20, + "totalElements": 42, + "totalPages": 3 +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 403 | `FORBIDDEN` | MEMBER role accessing another member's distributions | +| 404 | `MEMBER_NOT_FOUND` | No member with given UUID in this tenant | +| 403 | `TENANT_VIOLATION` | Member UUID belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | + +--- + +## 8. Distribution Controller (`/distributions`) + +### 8.1 GET `/distributions` + +List all distributions for the authenticated club, filterable by date range and member. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `integer` | `0` | Page index | +| `size` | `integer` | `20` | Page size | +| `sort` | `string` | `distributedAt,desc` | Sort field and direction | +| `from` | `string` | — | ISO 8601 date filter start | +| `to` | `string` | — | ISO 8601 date filter end | +| `memberId` | `UUID` | — | Filter by specific member | +| `batchId` | `UUID` | — | Filter by specific batch | + +**Success Response — `200 OK`:** + +```json +{ + "content": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "distributedAt": "2026-04-06T09:30:00.000Z", + "member": { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "firstName": "Max", + "lastName": "Mustermann" + }, + "strain": { + "id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "name": "Blue Dream", + "thcPercent": 18.5, + "cbdPercent": 0.3 + }, + "batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "batchCode": "BATCH-2026-003", + "quantityGrams": 5.0, + "handedOutBy": "admin@gruener-daumen-ev.de", + "notes": null, + "correctionNotes": [] + } + ], + "page": 0, + "size": 20, + "totalElements": 214, + "totalPages": 11 +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 8.2 POST `/distributions` + +Record a new distribution. All compliance rules are checked before the record is created. **This operation is atomic** — either all checks pass and the record is written, or the entire request fails. + +**Authentication:** Bearer token — role `ADMIN` + +**Request Body:** + +```json +{ + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "quantityGrams": 5.0, + "notes": "Member requested half portion" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `memberId` | `UUID` | ✅ | Receiving member's UUID | +| `batchId` | `UUID` | ✅ | Stock batch UUID to draw from | +| `quantityGrams` | `number` | ✅ | Amount in grams (max 2 decimal places) | +| `notes` | `string` | ❌ | Optional admin note for this distribution | + +**Success Response — `201 Created`:** + +```json +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "distributedAt": "2026-04-06T09:30:00.000Z", + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "batchCode": "BATCH-2026-003", + "strainName": "Blue Dream", + "quantityGrams": 5.0, + "remainingMonthlyQuotaGrams": 22.5, + "remainingDailyQuotaGrams": 15.0, + "handedOutBy": "admin@gruener-daumen-ev.de" +} +``` + +**Compliance Checks (in order of precedence):** + +1. Member exists and belongs to this tenant +2. Member status is `ACTIVE` +3. Member has DSGVO consent on record +4. Batch exists, belongs to this tenant, and is not recalled +5. Batch has sufficient remaining stock +6. `quantityGrams` ≤ remaining daily quota (25 g/day - already distributed today) +7. `quantityGrams` ≤ remaining monthly quota (50 g or 30 g - already distributed this month) + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 422 | `MEMBER_INACTIVE` | Member status is `SUSPENDED` or `EXPELLED` | +| 422 | `DSGVO_CONSENT_MISSING` | Member has no DSGVO consent date | +| 422 | `BATCH_RECALLED` | Batch has an active contamination recall | +| 422 | `BATCH_INSUFFICIENT_STOCK` | Batch has less remaining stock than `quantityGrams` | +| 422 | `QUOTA_EXCEEDED_DAILY` | Distribution would exceed 25 g/day limit | +| 422 | `QUOTA_EXCEEDED_MONTHLY` | Distribution would exceed monthly quota | +| 404 | `MEMBER_NOT_FOUND` | Member UUID not found in this tenant | +| 404 | `BATCH_NOT_FOUND` | Batch UUID not found in this tenant | +| 403 | `TENANT_VIOLATION` | Member or batch belongs to different tenant | +| 400 | `BAD_REQUEST` | `quantityGrams` ≤ 0 or > 25 | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +> **KCanG Compliance Note:** Single distributions of more than 25 g are rejected even if monthly quota remains. The legal daily limit is 25 g per person per calendar day (KCanG §19 Abs. 5). + +--- + +### 8.3 GET `/distributions/{id}` + +Retrieve a single distribution record by ID. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Distribution's unique identifier | + +**Success Response — `200 OK`:** Full distribution object (same structure as list items in §8.1) + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `DISTRIBUTION_NOT_FOUND` | No distribution with given UUID in this tenant | +| 403 | `TENANT_VIOLATION` | Distribution belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 8.4 POST `/distributions/{id}/notes` + +Add a correction note to an existing distribution. Distribution records themselves are **immutable** — amount, member, batch, and timestamp cannot be changed. Correction notes provide an auditable annotation layer for errors or clarifications. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Distribution's unique identifier | + +**Request Body:** + +```json +{ + "note": "Entry error — scale was miscalibrated. Actual weight was approximately 4.8g. Scale recalibrated and verified on 2026-04-06.", + "correctedBy": "admin@gruener-daumen-ev.de" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `note` | `string` | ✅ | Correction note text (max 2000 characters) | +| `correctedBy` | `string` | ✅ | Email of admin entering the correction | + +**Success Response — `201 Created`:** + +```json +{ + "noteId": "b2c3d4e5-f6a7-8901-bcde-f23456789012", + "distributionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "note": "Entry error — scale was miscalibrated. Actual weight was approximately 4.8g.", + "correctedBy": "admin@gruener-daumen-ev.de", + "createdAt": "2026-04-06T11:15:00.000Z" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `DISTRIBUTION_NOT_FOUND` | No distribution with given UUID in this tenant | +| 403 | `TENANT_VIOLATION` | Distribution belongs to a different tenant | +| 400 | `BAD_REQUEST` | Missing `note` field or note exceeds 2000 chars | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +> **Audit Trail Note:** Correction notes do NOT change the original distribution amount. For compliance reporting, the original amount is always used. Correction notes appear in distribution detail views and audit exports but do not affect quota calculations. + +--- + +## 9. Stock Controller (`/stock`) + +### 9.1 GET `/stock/strains` + +List all cannabis strains registered in the club's catalogue. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `integer` | `0` | Page index | +| `size` | `integer` | `50` | Page size | +| `active` | `boolean` | — | Filter by active strains only | + +**Success Response — `200 OK`:** + +```json +{ + "content": [ + { + "id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "name": "Blue Dream", + "variety": "HYBRID", + "thcPercent": 18.5, + "cbdPercent": 0.3, + "description": "Classic hybrid, earthy and sweet aroma", + "active": true, + "createdAt": "2025-01-15T10:00:00.000Z" + } + ], + "page": 0, + "size": 50, + "totalElements": 6, + "totalPages": 1 +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.2 POST `/stock/strains` + +Register a new cannabis strain in the club's catalogue. + +**Authentication:** Bearer token — role `ADMIN` + +**Request Body:** + +```json +{ + "name": "OG Kush", + "variety": "INDICA", + "thcPercent": 22.0, + "cbdPercent": 0.1, + "description": "Classic indica, piney and citrus notes" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | ✅ | Strain name | +| `variety` | `string` | ✅ | `SATIVA`, `INDICA`, or `HYBRID` | +| `thcPercent` | `number` | ✅ | THC content percentage (0–100) | +| `cbdPercent` | `number` | ✅ | CBD content percentage (0–100) | +| `description` | `string` | ❌ | Optional descriptive notes | + +**Success Response — `201 Created`:** Full strain object + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 409 | `CONFLICT` | Strain with same name already exists in this tenant | +| 400 | `BAD_REQUEST` | Invalid `variety` value or missing required fields | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.3 GET `/stock/batches` + +List all stock batches for the authenticated club. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | `integer` | `0` | Page index | +| `size` | `integer` | `20` | Page size | +| `status` | `string` | — | Filter: `AVAILABLE`, `DEPLETED`, `RECALLED` | +| `strainId` | `UUID` | — | Filter by strain | + +**Success Response — `200 OK`:** + +```json +{ + "content": [ + { + "id": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "batchCode": "BATCH-2026-003", + "strain": { + "id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "name": "Blue Dream" + }, + "initialQuantityGrams": 2000.0, + "remainingQuantityGrams": 850.5, + "status": "AVAILABLE", + "harvestDate": "2026-02-15", + "labTestDate": "2026-03-01", + "labTestReference": "LAB-2026-1234", + "thcPercent": 19.2, + "addedAt": "2026-03-10T08:00:00.000Z" + } + ], + "page": 0, + "size": 20, + "totalElements": 4, + "totalPages": 1 +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.4 POST `/stock/batches` + +Add a new stock batch for a registered strain. + +**Authentication:** Bearer token — role `ADMIN` + +**Request Body:** + +```json +{ + "strainId": "c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "initialQuantityGrams": 2000.0, + "harvestDate": "2026-02-15", + "labTestDate": "2026-03-01", + "labTestReference": "LAB-2026-1234", + "thcPercent": 19.2, + "cbdPercent": 0.4, + "notes": "Batch from indoor cultivation cycle 3" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `strainId` | `UUID` | ✅ | Strain this batch belongs to | +| `initialQuantityGrams` | `number` | ✅ | Starting weight in grams | +| `harvestDate` | `string` | ✅ | ISO 8601 date of harvest | +| `labTestDate` | `string` | ✅ | ISO 8601 date of laboratory analysis | +| `labTestReference` | `string` | ✅ | Lab report reference number (audit requirement) | +| `thcPercent` | `number` | ✅ | Actual THC % from lab test | +| `cbdPercent` | `number` | ✅ | Actual CBD % from lab test | +| `notes` | `string` | ❌ | Internal notes | + +**Success Response — `201 Created`:** Full batch object (same as list item above) + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Missing required fields or invalid quantities | +| 404 | — | `strainId` not found in this tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.5 GET `/stock/batches/{id}` + +Retrieve full details for a specific batch including distribution history summary. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Batch unique identifier | + +**Success Response — `200 OK`:** + +```json +{ + "id": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "batchCode": "BATCH-2026-003", + "strain": { + "id": "c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "name": "Blue Dream", + "variety": "HYBRID" + }, + "initialQuantityGrams": 2000.0, + "remainingQuantityGrams": 850.5, + "distributedQuantityGrams": 1149.5, + "distributionCount": 48, + "status": "AVAILABLE", + "harvestDate": "2026-02-15", + "labTestDate": "2026-03-01", + "labTestReference": "LAB-2026-1234", + "thcPercent": 19.2, + "cbdPercent": 0.4, + "notes": "Batch from indoor cultivation cycle 3", + "recallInfo": null, + "addedAt": "2026-03-10T08:00:00.000Z", + "updatedAt": "2026-04-06T09:30:00.000Z" +} +``` + +> `recallInfo` is `null` for non-recalled batches. See §9.6 for recall structure. + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `BATCH_NOT_FOUND` | No batch with given UUID in this tenant | +| 403 | `TENANT_VIOLATION` | Batch belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.6 POST `/stock/batches/{id}/recall` + +Flag a batch as recalled due to contamination or safety concerns. This immediately prevents any new distributions from this batch. Members who received product from this batch can be identified via `GET /reports/recall/{batchId}`. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | `UUID` | Batch unique identifier | + +**Request Body:** + +```json +{ + "reason": "Pesticide residue detected above legal threshold in repeat lab test", + "detectedAt": "2026-04-06", + "severity": "HIGH", + "labReference": "LAB-2026-9876" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `reason` | `string` | ✅ | Description of the contamination/recall reason | +| `detectedAt` | `string` | ✅ | ISO 8601 date contamination was detected | +| `severity` | `string` | ✅ | `LOW`, `MEDIUM`, or `HIGH` | +| `labReference` | `string` | ❌ | Lab report reference for contamination finding | + +**Success Response — `200 OK`:** + +```json +{ + "id": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "batchCode": "BATCH-2026-003", + "status": "RECALLED", + "recallInfo": { + "reason": "Pesticide residue detected above legal threshold", + "detectedAt": "2026-04-06", + "severity": "HIGH", + "labReference": "LAB-2026-9876", + "recalledAt": "2026-04-06T11:30:00.000Z", + "recalledBy": "admin@gruener-daumen-ev.de", + "affectedMemberCount": 23 + } +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `BATCH_NOT_FOUND` | No batch with given UUID in this tenant | +| 409 | `CONFLICT` | Batch is already in `RECALLED` status | +| 403 | `TENANT_VIOLATION` | Batch belongs to a different tenant | +| 400 | `BAD_REQUEST` | Missing required fields or invalid `severity` value | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.7 GET `/stock/summary` (ADMIN view) + +Retrieve a complete stock summary showing totals by strain. + +**Authentication:** Bearer token — role `ADMIN` + +**Success Response — `200 OK`:** + +```json +{ + "totalAvailableGrams": 12500.0, + "activeBatches": 4, + "strains": [ + { + "strainId": "c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "strainName": "Blue Dream", + "availableGrams": 850.5, + "batchCount": 1 + }, + { + "strainId": "d5e6f7a8-b9c0-1234-defa-5678901bcdef", + "strainName": "OG Kush", + "availableGrams": 11649.5, + "batchCount": 3 + } + ], + "recalledBatches": 0, + "generatedAt": "2026-04-06T10:00:00.000Z" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 9.8 GET `/stock/summary` (MEMBER view) + +Retrieve stock availability for members — shows strain availability status only. **No quantities are exposed** to members. + +**Authentication:** Bearer token — role `MEMBER` + +**Success Response — `200 OK`:** + +```json +{ + "strains": [ + { + "strainName": "Blue Dream", + "variety": "HYBRID", + "available": true + }, + { + "strainName": "OG Kush", + "variety": "INDICA", + "available": true + } + ], + "generatedAt": "2026-04-06T10:00:00.000Z" +} +``` + +> MEMBER view is served by the same endpoint path — the response schema differs based on the JWT role claim. No grams, no batch codes, no THC percentages beyond what's in the strain catalogue. + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | + +--- + +## 10. Report Controller (`/reports`) + +All report endpoints are ADMIN-only. Reports are tenant-scoped — all data is filtered to the requesting club's `tenant_id`. + +### 10.1 GET `/reports/monthly` + +Generate the monthly compliance report. Supports JSON (default), PDF, and CSV output. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `month` | `string` | current month | ISO 8601 year-month, e.g. `2026-03` | +| `format` | `string` | `json` | Output format: `json`, `pdf`, `csv` | + +**Success Response — `200 OK` (format=json):** + +```json +{ + "reportId": "RPT-2026-04-GD", + "clubName": "Grüner Daumen e.V.", + "month": "2026-03", + "generatedAt": "2026-04-06T10:00:00.000Z", + "summary": { + "totalDistributions": 214, + "totalGramsDistributed": 3280.5, + "uniqueMembers": 74, + "activeMembers": 82, + "newMembers": 3, + "expelledMembers": 1 + }, + "complianceSummary": { + "quotaViolationsDetected": 0, + "recallsTriggered": 0, + "dsgvoNonCompliantMembers": 0 + }, + "distributions": [ + { + "date": "2026-03-01", + "count": 9, + "totalGrams": 132.5 + } + ] +} +``` + +**Success Response — format=pdf:** + +- `Content-Type: application/pdf` +- `Content-Disposition: attachment; filename="cannamanage-report-2026-03.pdf"` +- Binary PDF body + +**Success Response — format=csv:** + +- `Content-Type: text/csv; charset=UTF-8` +- `Content-Disposition: attachment; filename="cannamanage-report-2026-03.csv"` +- UTF-8 CSV with BOM for Excel compatibility + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Invalid `month` format or `format` value | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 10.2 GET `/reports/members` + +Export the full member list — intended for presentation to authorities during official inspections. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `format` | `string` | `json` | Output format: `json`, `pdf`, `csv` | +| `status` | `string` | `ACTIVE` | Filter by status | +| `asOf` | `string` | today | ISO 8601 date — membership as of this date | + +**Success Response — `200 OK` (format=json):** + +```json +{ + "clubName": "Grüner Daumen e.V.", + "asOf": "2026-04-06", + "totalCount": 82, + "members": [ + { + "memberNumber": "GD-2024-001", + "firstName": "Max", + "lastName": "Mustermann", + "dateOfBirth": "1990-05-15", + "joinDate": "2024-05-01", + "status": "ACTIVE", + "dsgvoConsentDate": "2024-05-01" + } + ] +} +``` + +> This report includes date of birth and DSGVO consent date as required by KCanG inspection protocols. + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Invalid `format` or `status` value | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 10.3 GET `/reports/recall/{batchId}` + +Generate a recall impact report for a specific batch — identifies all members who received product from the recalled batch. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `batchId` | `UUID` | Recalled batch unique identifier | + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `format` | `string` | `json` | Output format: `json`, `pdf`, `csv` | + +**Success Response — `200 OK` (format=json):** + +```json +{ + "batchCode": "BATCH-2026-003", + "strainName": "Blue Dream", + "recallReason": "Pesticide residue detected above legal threshold", + "recalledAt": "2026-04-06T11:30:00.000Z", + "severity": "HIGH", + "affectedMembers": [ + { + "memberNumber": "GD-2024-001", + "firstName": "Max", + "lastName": "Mustermann", + "email": "max@example.de", + "phone": "+49 176 12345678", + "totalReceivedGrams": 15.0, + "lastDistributionDate": "2026-04-05", + "distributionCount": 3 + } + ], + "totalAffectedMembers": 23, + "totalAffectedGrams": 345.5, + "generatedAt": "2026-04-06T11:35:00.000Z" +} +``` + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `BATCH_NOT_FOUND` | No batch with given UUID in this tenant | +| 400 | `BAD_REQUEST` | Invalid `format` value | +| 403 | `TENANT_VIOLATION` | Batch belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +## 11. Compliance Controller (`/compliance`) + +### 11.1 GET `/compliance/quota/{memberId}` + +Check the current compliance status for a specific member's quota. Intended for real-time verification before handing out product at the counter. + +**Authentication:** Bearer token — role `ADMIN` + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `memberId` | `UUID` | Member's unique identifier | + +**Success Response — `200 OK`:** + +```json +{ + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-088", + "memberStatus": "ACTIVE", + "dsgvoConsentPresent": true, + "quota": { + "month": "2026-04", + "monthlyLimitGrams": 50, + "distributedThisMonthGrams": 22.5, + "remainingMonthlyGrams": 27.5, + "dailyLimitGrams": 25, + "distributedTodayGrams": 0.0, + "remainingTodayGrams": 25.0 + }, + "canReceive": true, + "blockingReasons": [] +} +``` + +**Blocked member example:** + +```json +{ + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "memberNumber": "GD-2026-089", + "memberStatus": "ACTIVE", + "dsgvoConsentPresent": true, + "quota": { + "month": "2026-04", + "monthlyLimitGrams": 30, + "distributedThisMonthGrams": 30.0, + "remainingMonthlyGrams": 0.0, + "dailyLimitGrams": 25, + "distributedTodayGrams": 0.0, + "remainingTodayGrams": 25.0 + }, + "canReceive": false, + "blockingReasons": ["QUOTA_EXCEEDED_MONTHLY"] +} +``` + +> `blockingReasons` is an array — multiple blocks can apply simultaneously (e.g., both `MEMBER_INACTIVE` and `DSGVO_CONSENT_MISSING`). + +**Error Responses:** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 404 | `MEMBER_NOT_FOUND` | No member with given UUID in this tenant | +| 403 | `TENANT_VIOLATION` | Member belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +### 11.2 GET `/compliance/check` + +Dry-run compliance pre-check for a proposed distribution. **Nothing is recorded** — this is a read-only validation endpoint used to preview whether a specific distribution would pass all rules. + +**Authentication:** Bearer token — role `ADMIN` + +**Query Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `memberId` | `UUID` | ✅ | Member who would receive | +| `batchId` | `UUID` | ✅ | Batch to distribute from | +| `quantityGrams` | `number` | ✅ | Proposed quantity in grams | + +**Example request:** + +``` +GET /api/v1/compliance/check?memberId=3fa85f64-...&batchId=f1e2d3c4-...&quantityGrams=10 +``` + +**Success Response — `200 OK` (all checks pass):** + +```json +{ + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "quantityGrams": 10.0, + "allowed": true, + "checks": { + "memberActive": true, + "dsgvoConsentPresent": true, + "batchAvailable": true, + "batchNotRecalled": true, + "batchSufficientStock": true, + "dailyQuotaOk": true, + "monthlyQuotaOk": true + }, + "quotaAfter": { + "remainingMonthlyGrams": 17.5, + "remainingTodayGrams": 15.0 + } +} +``` + +**Success Response — `200 OK` (checks fail — still HTTP 200, not an error):** + +```json +{ + "memberId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "batchId": "f1e2d3c4-b5a6-9780-bcde-fa0987654321", + "quantityGrams": 30.0, + "allowed": false, + "checks": { + "memberActive": true, + "dsgvoConsentPresent": true, + "batchAvailable": true, + "batchNotRecalled": true, + "batchSufficientStock": true, + "dailyQuotaOk": false, + "monthlyQuotaOk": true + }, + "violations": ["QUOTA_EXCEEDED_DAILY"], + "quotaAfter": null +} +``` + +> This endpoint always returns `200 OK`. The `allowed` field indicates pass/fail. Use `violations` array to display which rules would be broken. + +**Error Responses (true errors only):** + +| HTTP Status | Error Code | Condition | +|-------------|------------|-----------| +| 400 | `BAD_REQUEST` | Missing required query parameters or `quantityGrams` ≤ 0 | +| 404 | `MEMBER_NOT_FOUND` | Member UUID not found in this tenant | +| 404 | `BATCH_NOT_FOUND` | Batch UUID not found in this tenant | +| 403 | `TENANT_VIOLATION` | Member or batch belongs to a different tenant | +| 401 | `TOKEN_INVALID` | Bearer token missing or invalid | +| 403 | `FORBIDDEN` | Authenticated user does not have ADMIN role | + +--- + +## Appendix A: Member Status Lifecycle + +``` +PENDING → ACTIVE → SUSPENDED → ACTIVE (reinstatement possible) +ACTIVE → EXPELLED (permanent, no reinstatement via API) +PENDING → EXPELLED (rejected application) +``` + +| Status | Distributions allowed | Login allowed | +|--------|----------------------|---------------| +| `PENDING` | No | No | +| `ACTIVE` | Yes | Yes | +| `SUSPENDED` | No | Yes (read-only) | +| `EXPELLED` | No | No | + +--- + +## Appendix B: KCanG Compliance Reference + +Key limits implemented in the distribution compliance engine (as of KCanG 2024): + +| Rule | Limit | Applied To | +|------|-------|------------| +| Daily distribution | 25 g/day | All adult members | +| Monthly distribution (≥21 years) | 50 g/month | Members aged 21+ | +| Monthly distribution (18–20 years) | 30 g/month | Members aged 18–20 | +| Minimum age | 18 years | All members | +| Maximum club members | 500 | Per Anbauvereinigung | +| Simultaneous memberships | 1 club | Per person (enforced externally) | + +Age is calculated at the time of each distribution request, not at membership creation time. A member who turns 21 during the month automatically becomes eligible for the 50 g limit on their birthday. + +--- + +## Appendix C: Tenant Isolation Guarantee + +Every database query includes an implicit `WHERE tenant_id = ?` clause derived from the JWT. The following guarantees hold: + +1. A request authenticated with tenant A's JWT **cannot** read, modify, or delete data belonging to tenant B — even if a valid UUID belonging to tenant B is provided. +2. Any attempt to access cross-tenant resources returns `403 TENANT_VIOLATION`. +3. Cross-tenant violations are logged server-side to the security audit log. +4. Tenant ID is **never** accepted from the request (body, headers, or query parameters) — it is always derived from the validated JWT signature. diff --git a/plans/cannabis-club-saas/docs/06-WIREFRAMES.md b/plans/cannabis-club-saas/docs/06-WIREFRAMES.md new file mode 100644 index 0000000..53c804c --- /dev/null +++ b/plans/cannabis-club-saas/docs/06-WIREFRAMES.md @@ -0,0 +1,550 @@ +# CannaManage — Wireframes & UI Mockups + +**Phase 4a | Document 6 of 7** +**Date:** 2026-04-06 +**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL + +--- + +## Table of Contents + +1. [Design System Overview](#1-design-system-overview) +2. [Admin Portal Screens](#2-admin-portal-screens) +3. [Member Portal Screens](#3-member-portal-screens) +4. [Navigation & Information Architecture](#4-navigation--information-architecture) +5. [Responsive Design Notes](#5-responsive-design-notes) +6. [Accessibility](#6-accessibility) + +--- + +## 1. Design System Overview + +### 1.1 Color Palette + +| Token | Hex | Usage | +|---|---|---| +| `--color-primary` | `#2D5016` | Sidebar background, primary buttons, active nav items | +| `--color-primary-medium` | `#4A7C28` | Hover states, section headers, badge outlines | +| `--color-accent` | `#8BC34A` | Highlights, progress bars filled, success indicators | +| `--color-bg` | `#F5F5F5` | Page background, card backgrounds | +| `--color-text` | `#1A1A1A` | Body text, table cell content | +| `--color-warning` | `#FF6B35` | Quota >80%, low stock, warnings | +| `--color-error` | `#D32F2F` | Quota exceeded, recalled batches, destructive actions | +| `--color-white` | `#FFFFFF` | Sidebar text, button labels on dark bg, card surfaces | + +### 1.2 Typography + +| Element | Font | Size | Weight | +|---|---|---|---| +| H1 — Page title | Inter | 24px | 600 | +| H2 — Section heading | Inter | 18px | 600 | +| H3 — Card title | Inter | 14px | 600 | +| Body / table rows | Inter | 14px | 400 | +| Caption / label | Inter | 12px | 400 | +| Mono (codes, IDs) | JetBrains Mono | 13px | 400 | + +### 1.3 Component Library + +All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP. + +| Component | Usage | +|---|---| +| `p:panel` | Section containers, card wrappers | +| `p:dataTable` with `p:column` | Tabular data: distributions, members, batches | +| `p:paginator` | Pagination on all tables | +| `p:inputText` | Single-line text fields | +| `p:inputNumber` | Weight inputs (gram precision) | +| `p:selectOneMenu` | Dropdown selects (member, strain, batch) | +| `p:calendar` | Date range pickers for reports | +| `p:progressBar` | Quota consumption display | +| `p:commandButton` | Primary and secondary actions | +| `p:confirmDialog` | Dangerous actions (recall, delete) | +| `p:messages` / `p:message` | Inline validation errors | +| `p:badge` | Status indicators (AVAILABLE, LOW, RECALLED) | +| `p:sidebar` | Mobile nav drawer (member portal) | +| `p:dialog` | Modal overlays | + +### 1.4 Layout Grid + +``` +┌────────────────────────────────────────────────────┐ +│ TOP NAVBAR (56px) club name · avatar · logout │ +├──────────────┬─────────────────────────────────────┤ +│ │ │ +│ SIDEBAR │ MAIN CONTENT │ +│ (240px) │ (fluid, min 784px) │ +│ fixed │ │ +│ │ │ +└──────────────┴─────────────────────────────────────┘ +``` + +- **Sidebar:** fixed left, `#2D5016` background, white nav labels with `#8BC34A` icons +- **Top Navbar:** `#FFFFFF` with bottom border `#E0E0E0`, breadcrumb left, user controls right +- **Main Content:** `#F5F5F5` background, 24px padding, max content width 1200px centered + +--- + +## 2. Admin Portal Screens + +### Screen 1 — Admin Dashboard + +![Admin Dashboard](images/mockup-admin-dashboard.png) + +#### ASCII Wireframe + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │ +├────────────┬────────────────────────────────────────────────────────┤ +│ │ Dashboard 🗓 April 2026 │ +│ 📊 Dashboard◄│ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ 👥 Members│ │ Total Members│ │ Distributions│ │ Stock Available│ │ +│ │ │ │ │ This Month │ │ │ │ +│ 📋 Distrib│ │ 142 │ │ 87 │ │ 3,240 g │ │ +│ │ │ ▲ +3 MoM │ │ ▲ +12 MoM │ │ ▼ -800g MoM │ │ +│ 📦 Stock │ └──────────────┘ └──────────────┘ └───────────────┘ │ +│ │ │ +│ 📄 Reports│ Recent Distributions [+ New Entry] │ +│ │ ┌─────────────────────────────────────────────────┐ │ +│ ✅ Complian│ │ Member │ Strain │ Qty │ Date │ ✓ │ │ +│ │ ├─────────────┼─────────────┼───────┼───────┼────┤ │ +│ ⚙ Settings│ │ Müller, A. │ OG Kush B12 │ 5.0g │ 06.04 │ ✓ │ │ +│ │ │ Schmidt, K. │ Amnesia H09 │ 3.5g │ 06.04 │ ✓ │ │ +│ │ │ Weber, T. │ OG Kush B12 │ 7.0g │ 05.04 │ ✓ │ │ +│ │ │ … │ │ │ │ │ │ +└────────────┴──┴─────────────────────────────────────────────────────┘ +``` + +#### Components & Behavior + +| Component | PrimeFaces | Behavior | +|---|---|---| +| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s | +| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail | +| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` | +| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` | +| Trend indicators | Custom CSS `` | Green ▲ / Red ▼ with delta value | + +--- + +### Screen 2 — Distribution Recording Form + +![Distribution Form](images/mockup-distribution-form.png) + +#### ASCII Wireframe + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │ +├────────────┬────────────────────────────────────────────────────────┤ +│ │ Distributions › New Distribution │ +│ 📊 Dashbrd│ │ +│ │ ┌──────────────────────────────────────────────────┐ │ +│ 👥 Members│ │ Member * │ │ +│ │ │ ┌──────────────────────────────────────────┐ │ │ +│ 📋 Distrib◄│ │ │ 🔍 Search by name or member no. │ │ │ +│ │ │ └──────────────────────────────────────────┘ │ │ +│ 📦 Stock │ │ │ │ +│ │ │ Strain / Batch * │ │ +│ 📄 Reports│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ │ Select available batch ▼ │ │ │ +│ ✅ Complian│ │ └──────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ ⚙ Settings│ │ Weight (grams) * │ │ +│ │ │ ┌──────────┐ │ │ +│ │ │ │ 0.0 g │ ← p:inputNumber min=0.1 max=25 │ │ +│ │ │ └──────────┘ │ │ +│ │ │ │ │ +│ │ │ Monthly Quota — Müller, Anna │ │ +│ │ │ ████████████░░░░░░░░ 32.5g / 50g 65% │ │ +│ │ │ [████████████████░░░] <- p:progressBar │ │ +│ │ │ │ │ +│ │ │ [ Record Distribution ] [Cancel] │ │ +│ │ └──────────────────────────────────────────────────┘ │ +└────────────┴────────────────────────────────────────────────────────┘ +``` + +#### Compliance UX — Real-Time Quota Indicator + +The quota progress bar updates live as the weight field changes (via `f:ajax event="keyup"`): + +| Quota Used After Distribution | Bar Color | Submit Button | Message | +|---|---|---|---| +| 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 | PrimeFaces | Behavior | +|---|---|---| +| Member search | `p:selectOneMenu` with `p:ajax` filter | Filters on type, shows name + member no. | +| Strain/Batch dropdown | `p:selectOneMenu` | Populated after member selection; shows only `AVAILABLE` batches | +| Weight input | `p:inputNumber` min=`0.1` max=`25.0` step=`0.1` | Triggers quota recalculation on blur | +| Quota bar | `p:progressBar` with dynamic `value` | Color class applied via `styleClass` computed in backing bean | +| Submit | `p:commandButton` | Disabled via `disabled="#{bean.quotaExceeded}"` | +| Cancel | `p:link` | Returns to distribution log without saving | + +--- + +### Screen 3 — Stock Management + +![Stock Management](images/mockup-stock-management.png) + +#### ASCII Wireframe + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │ +├────────────┬────────────────────────────────────────────────────────┤ +│ │ Stock Management [+ Add Batch] │ +│ 📊 Dashbrd│ │ +│ │ ┌──────────────────────┐ ┌────────────────────┐ │ +│ 👥 Members│ │ 🔍 Filter by strain │ │ Status: All ▼ │ │ +│ │ └──────────────────────┘ └────────────────────┘ │ +│ 📋 Distrib│ │ +│ │ ┌───────────────────────────────────────────────────┐ │ +│ 📦 Stock ◄│ │ Strain │Batch│THC% │CBD%│ Qty │Status│Act │ │ +│ │ ├──────────────┼─────┼─────┼────┼───────┼──────┼────┤ │ +│ 📄 Reports│ │ OG Kush │B-12 │ 19% │ 1% │ 850g │ ● │[R] │ │ +│ │ │ Amnesia Haze │H-09 │ 22% │<1% │ 72g │ ⚠ │[R] │ │ +│ ✅ Complian│ │ Blue Dream │D-05 │ 17% │ 2% │ 0g │ — │[R] │ │ +│ │ │ Hindu Kush │K-21 │ 8% │15% │ 340g │ ✓ │[R] │ │ +│ ⚙ Settings│ │ AK-47 #4 │A-03 │ 20% │ 1% │ RECALLED │ ⛔ │[R] │ │ +│ │ └───────────────────────────────────────────────────┘ │ +│ │ [◄ 1 2 3 … ►] Showing 1-10/42 │ +└────────────┴────────────────────────────────────────────────────────┘ +``` + +#### Status Badges + +| Badge | Color | Icon | Condition | +|---|---|---|---| +| `AVAILABLE` | `#4A7C28` bg | ✓ checkmark | `qty > 100g` and not recalled | +| `LOW` | `#FF6B35` bg | ⚠ warning | `0 < qty ≤ 100g` | +| `EXHAUSTED` | `#9E9E9E` bg | — dash | `qty = 0` | +| `RECALLED` | `#D32F2F` bg | ⛔ stop | `recall_date IS NOT NULL` | + +#### Components & Behavior + +| Component | PrimeFaces | Behavior | +|---|---|---| +| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup | +| Status filter | `p:selectOneMenu` | Filters table rows by status value | +| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page | +| Status badge | Custom CSS `` | Icon + text label (not color alone) | +| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing | +| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." | +| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form | + +--- + +### Screen 4 — Compliance Report Generation + +![Compliance Report](images/mockup-compliance-report.png) + +#### ASCII Wireframe + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │ +├────────────┬────────────────────────────────────────────────────────┤ +│ │ Reports › Monthly Compliance Report │ +│ 📊 Dashbrd│ │ +│ │ ┌─────────────────────────────────────────────────┐ │ +│ 👥 Members│ │ Reporting Period │ │ +│ │ │ Month: [ March ▼ ] Year: [ 2026 ▼ ] │ │ +│ 📋 Distrib│ │ [ Generate Report ] │ │ +│ │ └─────────────────────────────────────────────────┘ │ +│ 📦 Stock │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ +│ 📄 Reports◄│ │ PDF PREVIEW │ │ +│ │ │ ┌─────────────────────────────────────────┐ │ │ +│ ✅ Complian│ │ │ 🌿 CannaManage — Monthly Report Mar 2026 │ │ │ +│ │ │ │ Club: Grüne Oase Berlin e.V. │ │ │ +│ ⚙ Settings│ │ │ ─────────────────────────────────────── │ │ │ +│ │ │ │ Total Members: 142 │ │ │ +│ │ │ │ Active Members (distributed): 87 │ │ │ +│ │ │ │ Total Distributed: 435.5g │ │ │ +│ │ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ │ [⬇ Download PDF] [⬇ Download CSV] │ │ +│ │ └─────────────────────────────────────────────────┘ │ +│ │ │ +│ │ Summary Table │ +│ │ ┌────────────────────────────────────────────────┐ │ +│ │ │ Metric │ Value │ Limit │ │ +│ │ ├──────────────────────┼───────────┼─────────────┤ │ +│ │ │ Members >50g/month │ 0 │ Must be 0 │ │ +│ │ │ Members >30g (U21) │ 0 │ Must be 0 │ │ +│ │ │ Recalled Batches │ 1 │ — (info) │ │ +│ │ │ Avg grams / member │ 5.0g │ — │ │ +│ │ └────────────────────────────────────────────────┘ │ +└────────────┴────────────────────────────────────────────────────────┘ +``` + +#### Components & Behavior + +| Component | PrimeFaces | Behavior | +|---|---|---| +| Month selector | `p:selectOneMenu` | Months Jan–Dec | +| Year selector | `p:selectOneMenu` | Current year ± 2 | +| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail | +| PDF preview | `