docs(cannamanage): update wiki pages and sprint plans + brand pipeline doc

This commit is contained in:
Patrick Plate
2026-06-11 09:02:14 +02:00
parent bf721c1379
commit 17d14aae09
8 changed files with 2181 additions and 162 deletions
@@ -464,4 +464,41 @@
---
## Could Have — v2 (Additions)
### US-026: Staff Member Management
**As a** Club Admin, **I want to** create staff accounts with configurable permissions, **so that** my team members can do their work without having access to data they don't need (DSGVO principle of least privilege).
**Priority:** Must Have (upgraded from Could Have — see note)
**Acceptance Criteria:**
- [ ] AC1: Admin can create staff accounts with email + temporary password
- [ ] AC2: Admin assigns permissions per staff account from a defined permission set (`RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA`, `ADD_MEMBER`, `VIEW_STOCK`, `RECORD_STOCK_IN`, `VIEW_COMPLIANCE_REPORT`, `MANAGE_GROW_CALENDAR`)
- [ ] AC3: Pre-created role templates available: **Ausgabe** (distribution desk), **Lager** (stock/cultivation), **Vorstand** (board member)
- [ ] AC4: Staff accounts cannot access billing, club settings, or staff management
- [ ] AC5: All distributions recorded by staff include `recorded_by = staffUserId` in audit trail
- [ ] AC6: Admin can deactivate a staff account; historical data is retained for audit purposes
- [ ] AC7: Staff member sees only the navigation sections permitted by their granted permissions
> **Note:** Promoted to core / Must Have. Staff management is not a v2 feature — clubs have multiple people involved from day one. DSGVO requires that each person only accesses data relevant to their function. Designing this post-MVP would require schema, API, and permission model rework.
---
### US-027: Grow Calendar
**As a** Club Admin or authorised staff member, **I want to** maintain a cultivation calendar for each grow cycle, **so that** the club has a central record of what was planted, when to expect harvest, and the grow diary with notes and photos.
**Priority:** Could Have (v2)
**Acceptance Criteria:**
- [ ] AC1: Admin/staff can create a grow entry with: strain name, planted date, expected harvest date, grow medium, notes
- [ ] AC2: Grow entries are linked to a batch — when the harvest is registered as a batch, the grow entry is marked as completed
- [ ] AC3: A grow diary allows adding timestamped notes and optional photos per grow entry
- [ ] AC4: Grow calendar view shows a visual timeline of active grow cycles (Gantt-style or calendar grid)
- [ ] AC5: Admin can set who has access to the grow calendar via staff permission `MANAGE_GROW_CALENDAR`
- [ ] AC6: Photos are stored per-tenant and never exposed to members or other tenants
**Notes:** The grow calendar bridges cultivation management and compliance — it provides provenance traceability from seed/clone to distributed batch. This directly supports §26 CanG batch traceability requirements for the origin of cultivated product. Photo attachments are a nice-to-have within this story; the core diary functionality is the v2 deliverable.
---
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
+93 -47
View File
@@ -2,7 +2,7 @@
**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
**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2
**Last updated:** 2026-04-06
---
@@ -14,12 +14,12 @@ graph TD
AdminBrowser["🖥️ Browser — Admin Portal"]
MemberBrowser["🖥️ Browser — Member Portal"]
JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"]
Frontend["React/Vite Frontend\n(SPA — served by Nginx)"]
AdminBrowser -->|HTTP/S| JSF
MemberBrowser -->|HTTP/S| JSF
AdminBrowser -->|HTTPS| Frontend
MemberBrowser -->|HTTPS| Frontend
JSF -->|REST calls| Backend
Frontend -->|REST/JSON| Backend
subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"]
REST["REST API Layer\n/api/v1/"]
@@ -45,7 +45,7 @@ graph TD
Nginx["🔒 Nginx\n(reverse proxy + TLS)"]
end
JSF --> Nginx
Frontend --> Nginx
Nginx --> Backend
```
@@ -53,8 +53,8 @@ graph TD
| Component | Technology | Role |
|---|---|---|
| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI |
| Member Portal | PrimeFaces JSF (→ Next.js v2) | Member quota & history UI |
| Admin Portal | React/Vite SPA (→ Next.js v2) | Club management UI |
| Member Portal | React/Vite SPA (→ Next.js v2) | Member quota & history UI |
| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints |
| Auth | Spring Security 6 + JJWT | Stateless JWT authentication |
| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering |
@@ -69,15 +69,47 @@ graph TD
## 2. Multi-Tenancy Strategy
### Approach: Shared Schema with Row-Level Filtering
### Decision: Schema-Per-Tenant
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.
Each club gets its own PostgreSQL schema (e.g. `tenant_abc123`). A platform-level `public` schema holds only the `tenants` registry. Flyway runs per-schema migrations on onboarding.
**Why 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
**Why schema-per-tenant, not shared schema?**
A shared-schema approach (single table with `tenant_id` on every row) is operationally convenient in the short term but creates serious problems at scale:
| Concern | Shared Schema | Schema-Per-Tenant |
|---|---|---|
| Data isolation | Application-layer only — one missing filter = data leak | Enforced at DB level — schemas are hard boundaries |
| DSGVO compliance | Harder to prove isolation; one backup contains all clubs' data | Per-tenant pg_dump; each club's data is cleanly separable |
| Deletion / right to erasure | Must `DELETE WHERE tenant_id = ?` across every table | `DROP SCHEMA tenant_abc123 CASCADE` — clean and auditable |
| Migrations | One migration path for all | Per-schema migration via Flyway `schemas` config; adds ~100ms per onboard |
| Query performance | Cross-tenant index bloat on large shared tables | Smaller per-tenant tables; no cross-tenant contention |
| Future per-club DB isolation | Requires full re-architecture | Trivial: move schema to dedicated DB server |
| Operational overhead | Lower — one connection pool | Slightly higher — one pool per tenant (managed by HikariCP with pool-per-schema) |
**Conclusion:** The shared-schema "MVP convenience" argument only holds for throwaway prototypes. For a compliance SaaS handling personal health-adjacent data (cannabis consumption records), schema-per-tenant is the correct design from Day 1. The migration complexity is manageable; the data isolation benefit is permanent.
### Tenant Provisioning
When a new club onboards:
```
POST /api/v1/admin/bootstrap
→ TenantProvisioningService.provisionTenant(tenantId)
→ CREATE SCHEMA tenant_{tenantId}
→ Flyway.migrate(schema=tenant_{tenantId}) // applies all V*.sql
→ INSERT INTO public.tenants (id, schema_name, onboarded_at, status)
```
### Tenant Resolution
```
HTTP Request
└─ Spring Security Filter: extract JWT → resolve tenant_id
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
└─ DataSource routes to schema: SET search_path = tenant_{tenantId}
└─ All queries execute in tenant's private schema
```
### Tenant Resolution
@@ -88,51 +120,38 @@ HTTP Request
└─ JPA @Where filter applied on every entity query
```
### Code Pattern — Tenant-Aware Base Entity
### Code Pattern — Schema Routing DataSource
```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 {
// TenantRoutingDataSource.java (pseudocode)
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@PrePersist
void injectTenant() {
this.tenantId = TenantContext.getCurrentTenant();
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenant(); // returns tenant schema name
}
}
```
```java
// TenantFilterInterceptor.java (pseudocode)
// TenantInterceptor.java (pseudocode)
@Component
public class TenantFilterInterceptor implements HandlerInterceptor {
@Autowired EntityManager em;
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, ...) {
UUID tenantId = TenantContext.getCurrentTenant();
Session session = em.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
String tenantId = JwtUtils.extractTenantId(req);
TenantContext.setCurrentTenant("tenant_" + 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`
- Every incoming request resolves its schema before any query runs
- No entity has a `tenant_id` column — schema isolation replaces row-level filtering
- Raw JDBC queries must be avoided; all access goes through JPA repositories with schema routing
- The `public` schema contains only the tenants registry and platform-level config
---
@@ -148,10 +167,36 @@ public class TenantFilterInterceptor implements HandlerInterceptor {
| 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_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions, staff management |
| `ROLE_STAFF` | Club staff member | Configurable subset of admin permissions — defined per staff account by the admin |
| `ROLE_MEMBER` | Club member | Own quota, own distribution history (read-only) |
| `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data |
> **Staff is a core feature, not an add-on.** Real clubs have multiple staff members (front desk, cultivation responsible, prevention officer designate) with different operational responsibilities. DSGVO requires that each staff member can only access data they need for their specific role. The `ROLE_STAFF` with configurable permission grants from the admin is designed from Phase 0 — retrofitting it later would require schema and API changes.
### Staff Permission Model
Admins configure staff permissions at account creation. Permissions are stored as a `JSONB` column `granted_permissions` on the `staff_accounts` table within the tenant schema.
```java
// Configurable staff permissions (granted by admin per staff account)
public enum StaffPermission {
RECORD_DISTRIBUTION, // can record distributions
VIEW_MEMBER_LIST, // can view member roster
VIEW_MEMBER_QUOTA, // can view individual member quota
ADD_MEMBER, // can register new members
VIEW_STOCK, // can view batch/strain inventory
RECORD_STOCK_IN, // can add new batches
VIEW_COMPLIANCE_REPORT, // can generate/download reports
MANAGE_GROW_CALENDAR // can manage cultivation calendar entries
}
```
Pre-created role templates (configurable by admin):
- **Ausgabe** (Distribution desk): `RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA`
- **Lager** (Stock/cultivation): `VIEW_STOCK`, `RECORD_STOCK_IN`, `MANAGE_GROW_CALENDAR`
- **Vorstand** (Board member): all permissions except staff management
### Service-Layer Authorization Example
```java
@@ -494,11 +539,12 @@ Flyway migrations run automatically on application startup (`spring.flyway.enabl
| 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 |
| Multi-tenancy | Schema-per-tenant | Hard data isolation, DSGVO-clean deletion, no cross-tenant query risk |
| Frontend MVP | React/Vite SPA | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
| Frontend v2 | Next.js | SSR/ISR for SEO on marketing pages; same React codebase |
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates |
| Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance |
| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |
| Staff roles | Core feature from Phase 0 | DSGVO requires least-privilege access; retrofitting post-MVP too costly |
+2 -2
View File
@@ -143,7 +143,7 @@ flowchart TD
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 -->|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
@@ -174,7 +174,7 @@ flowchart TD
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 -->|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]
+145 -75
View File
@@ -2,7 +2,7 @@
**Phase 4a | Document 6 of 7**
**Date:** 2026-04-06
**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL
**Stack:** Spring Boot 3.x · React/Vite SPA · PostgreSQL
---
@@ -45,24 +45,26 @@
### 1.3 Component Library
All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP.
The frontend is a **React/Vite SPA** with no PrimeFaces or JSF dependency. Component primitives come from [shadcn/ui](https://ui.shadcn.com/) (Radix UI + Tailwind CSS). This gives full control over styling, accessibility, and mobile responsiveness without JSF's lifecycle overhead.
| 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 |
> **Why not PrimeFaces?** JSF/PrimeFaces is a server-side component model ill-suited to the modern REST API backend we're building. It tightly couples UI lifecycle to the backend, makes mobile responsiveness painful, and creates a hiring bottleneck. React is the right tool here. PrimeFaces is a fine choice for internal enterprise apps — not for a commercial SaaS.
| Component | Library | Usage |
|---|---|---|
| `Card` / `Panel` | shadcn/ui | Section containers |
| `DataTable` | TanStack Table v8 | Distributions, members, batches — virtualized |
| `Pagination` | shadcn/ui Pagination | All tables |
| `Input` | shadcn/ui Input | Single-line text fields |
| `NumberInput` | react-number-format | Weight inputs (gram precision, min/max) |
| `Select` | shadcn/ui Select | Dropdown selects (member, strain, batch) |
| `DatePicker` | shadcn/ui Calendar | Date range pickers for reports |
| `Progress` | shadcn/ui Progress | Quota consumption bar |
| `Button` | shadcn/ui Button | Primary and secondary actions |
| `AlertDialog` | shadcn/ui AlertDialog | Dangerous actions (recall) |
| `Toast` | sonner | Success/error notifications |
| `Badge` | shadcn/ui Badge | Status indicators (AVAILABLE, LOW, RECALLED) |
| `Sheet` | shadcn/ui Sheet | Mobile nav drawer |
| `Dialog` | shadcn/ui Dialog | Modal overlays |
### 1.4 Layout Grid
@@ -118,13 +120,13 @@ All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/A
#### Components & Behavior
| Component | PrimeFaces | Behavior |
| Component | Library | Behavior |
|---|---|---|
| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s |
| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail |
| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` |
| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` |
| Trend indicators | Custom CSS `<span>` | Green ▲ / Red ▼ with delta value |
| KPI Cards | shadcn/ui Card | Auto-refreshed via `useQuery` (react-query, 60s stale) |
| Recent Distributions table | TanStack Table (5 rows) | Row click → navigate to distribution detail |
| Member column link | React Router `<Link>` | Navigate to `/admin/members/{id}` |
| `+ New Entry` button | shadcn/ui Button variant="default" | Navigate to `/admin/distributions/new` |
| Trend indicators | Tailwind `text-green-600` / `text-red-600` | ▲/▼ with delta value |
---
@@ -178,14 +180,14 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve
#### Components & Behavior
| Component | PrimeFaces | Behavior |
| Component | Library | 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 |
| Member search | shadcn/ui Combobox | `useQuery` debounced search; shows name + member no. |
| Strain/Batch dropdown | shadcn/ui Select | Populated after member selection; filters `AVAILABLE` batches |
| Weight input | react-number-format | min=0.1 max=25.0 step=0.1; triggers quota recalculation via `onChange` |
| Quota bar | shadcn/ui Progress | Color class via `cn()` utility computed in component state |
| Submit | shadcn/ui Button | `disabled={quotaExceeded}` from react state |
| Cancel | React Router `<Link>` | Returns to distribution log without saving |
---
@@ -229,15 +231,15 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve
#### Components & Behavior
| Component | PrimeFaces | Behavior |
| Component | Library | Behavior |
|---|---|---|
| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup |
| Status filter | `p:selectOneMenu` | Filters table rows by status value |
| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page |
| Status badge | Custom CSS `<span class="badge badge-{status}">` | Icon + text label (not color alone) |
| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing |
| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form |
| Strain filter | shadcn/ui Input | Filters TanStack table client-side via `columnFilters` state |
| Status filter | shadcn/ui Select | Filters table rows by status value |
| Batch table | TanStack Table | Server-side pagination via `manualPagination`, 10 rows/page |
| Status badge | shadcn/ui Badge variant mapped | Icon + text label (not color alone) |
| Recall button | shadcn/ui Button variant="destructive" | Opens shadcn/ui AlertDialog before executing |
| Confirm dialog | shadcn/ui AlertDialog | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
| Add Batch | shadcn/ui Button | Opens shadcn/ui Dialog with batch entry form |
---
@@ -287,15 +289,15 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve
#### Components & Behavior
| Component | PrimeFaces | Behavior |
| Component | Library | Behavior |
|---|---|---|
| Month selector | `p:selectOneMenu` | Months JanDec |
| Year selector | `p:selectOneMenu` | Current year ± 2 |
| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail |
| PDF preview | `<iframe>` embedding `/report/preview?month=3&year=2026` | Generated by iText 7 in `cannamanage-report` module |
| Download PDF | `p:commandButton` | Streams PDF response from REST endpoint |
| Download CSV | `p:commandButton` | Streams CSV response (member-level data) |
| Summary table | `p:dataTable` | Computed compliance metrics; zero violations = green row |
| Month selector | shadcn/ui Select | Months JanDec |
| Year selector | shadcn/ui Select | Current year ± 2 |
| Generate button | shadcn/ui Button | Calls report API; shows loading spinner; renders PDF thumbnail |
| PDF preview | `<iframe>` embedding `/api/v1/reports/preview?month=3&year=2026` | Generated by iText 7 backend |
| Download PDF | shadcn/ui Button | `window.open(reportUrl)` — streams PDF from REST endpoint |
| Download CSV | shadcn/ui Button | `window.open(csvUrl)` — streams CSV from REST endpoint |
| Summary table | TanStack Table | Compliance metrics; zero violations row has `text-green-600` |
---
@@ -354,12 +356,12 @@ Quantities, batch codes, and THC/CBD percentages are **not exposed** in the memb
#### Components & Behavior
| Component | PrimeFaces | Behavior |
| Component | Library | Behavior |
|---|---|---|
| Quota circle | Custom CSS radial progress (`conic-gradient`) | Computed from monthly total; color matches threshold rules |
| Quota bar | `p:progressBar` | Same color logic as admin distribution form |
| History table | `p:dataTable` | Last 10 distributions; sorted newest first; no pagination in MVP |
| Strains table | `p:dataTable` | `status` column: text + icon only, no quantities |
| Quota bar | shadcn/ui Progress | Same color logic as admin distribution form |
| History table | TanStack Table | Last 10 distributions; sorted newest first; no pagination in MVP |
| Strains table | TanStack Table | `status` column: text + icon only, no quantities |
---
@@ -367,6 +369,64 @@ Quantities, batch codes, and THC/CBD percentages are **not exposed** in the memb
> *No mockup image — ASCII wireframe only.*
---
### Screen 7 — Staff Management (Admin)
> *Core feature — not deferred to v2.*
#### ASCII Wireframe
```
┌─────────────────────────────────────────────────────────────────────┐
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
├────────────┬────────────────────────────────────────────────────────┤
│ │ Settings Staff Members [+ Add Staff] │
│ 📊 Dashbrd│ │
│ │ ┌──────────────────────────────────────────────────┐ │
│ 👥 Members│ │ Name │ Role Template │ Permissions │ Act│ │
│ │ ├─────────────────┼───────────────┼─────────────┼────┤ │
│ 📋 Distrib│ │ Lisa Schmidt │ Ausgabe │ 3 of 8 │[✎][⛔]│
│ │ │ Tom Weber │ Lager │ 4 of 8 │[✎][⛔]│
│ 📦 Stock │ │ Sandra Müller │ Vorstand │ 7 of 8 │[✎][⛔]│
│ │ └──────────────────────────────────────────────────┘ │
│ 📄 Reports│ │
│ │ ┌─── Add / Edit Staff ──────────────────────────────┐ │
│ ✅ Complian│ │ Name: _______________ Email: _______________ │ │
│ │ │ │ │
│ 👤 Staff │ │ Role Template: [ Ausgabe ▼ ] (pre-fills below) │ │
│ │ │ │ │
│ ⚙ Settings│ │ Permissions: │ │
│ │ │ ☑ Record Distribution ☑ View Member List │ │
│ │ │ ☑ View Member Quota ☐ Add Member │ │
│ │ │ ☐ View Stock ☐ Record Stock In │ │
│ │ │ ☐ View Compliance Report ☐ Manage Grow Calendar │ │
│ │ │ │ │
│ │ │ [ Save Staff Member ] [ Cancel ] │ │
│ │ └────────────────────────────────────────────────────┘ │
└────────────┴────────────────────────────────────────────────────────┘
```
#### Design Decisions
- **Admin sees everything.** The staff management screen is only accessible with `ROLE_CLUB_ADMIN`. Staff accounts cannot modify their own permissions.
- **DSGVO principle of least privilege.** Each staff member only sees the data their role requires. A distribution desk worker (`Ausgabe`) does not see cultivation calendar or full stock levels — only what they need to hand out product.
- **Pre-created role templates** reduce admin setup time. Templates are editable — they just pre-fill the permission checkboxes.
- **Staff ≠ reduced admin.** Staff accounts do not have access to billing, club settings, or staff management. Even a "Vorstand" staff member cannot create other staff accounts.
- **Audit trail.** All distributions recorded by staff include `recorded_by = staffUserId` so it's clear who did what.
#### Components & Behavior
| Component | Library | Behavior |
|---|---|---|
| Staff table | TanStack Table | Shows name, role template, permission count, actions |
| Role template dropdown | shadcn/ui Select | Pre-populates permission checkboxes on selection |
| Permission checkboxes | shadcn/ui Checkbox | Individual overrides after template selection |
| Save | shadcn/ui Button | POST/PUT `/api/v1/staff` with `{ permissions: [...] }` |
| Deactivate | shadcn/ui Button variant="destructive" | Soft-deletes staff account; data retained for audit |
---
#### ASCII Wireframe
```
@@ -403,12 +463,12 @@ Quantities, batch codes, and THC/CBD percentages are **not exposed** in the memb
#### Components & Behavior
| Component | PrimeFaces | Behavior |
| Component | Library | Behavior |
|---|---|---|
| Email field | `p:inputText` with `required="true"` | Bean Validation `@Email` |
| Password field | `p:password` feedback=`false` | No strength meter on login |
| Login button | `p:commandButton` | Submit form; shows `p:messages` on failure |
| Error message | `p:messages` | "Invalid email or password." (never specific about which field failed) |
| Email field | shadcn/ui Input type="email" | HTML5 validation + react-hook-form `@Email` |
| Password field | shadcn/ui Input type="password" | No strength meter on login |
| Login button | shadcn/ui Button | Submit via react-hook-form; shows error toast on failure |
| Error message | sonner toast | "Invalid email or password." (never specific about which field failed) |
---
@@ -419,6 +479,7 @@ graph TD
Root["CannaManage Root"]
Root --> AdminPortal["Admin Portal /admin/"]
Root --> MemberPortal["Member Portal /member/"]
Root --> StaffPortal["Staff Portal /staff/"]
AdminPortal --> AdminDash["Dashboard (default)"]
AdminPortal --> Members["Members"]
@@ -436,9 +497,14 @@ graph TD
Reports --> RecallReport["Batch Recall Report"]
AdminPortal --> Compliance["Compliance"]
Compliance --> PreventionOfficer["Prevention Officer Info"]
AdminPortal --> StaffMgmt["Staff Members"]
StaffMgmt --> StaffList["Staff List"]
StaffMgmt --> StaffNew["Add/Edit Staff"]
AdminPortal --> Settings["Settings"]
Settings --> ClubProfile["Club Profile"]
StaffPortal --> StaffDash["Staff Dashboard\n(permissions-filtered)"]
MemberPortal --> MemberDash["Dashboard / Quota"]
MemberPortal --> DistHistory["Distribution History"]
MemberPortal --> StockAvail["Stock Availability"]
@@ -460,7 +526,11 @@ graph TD
| `/admin/reports/members` | Member data export | `ROLE_ADMIN` |
| `/admin/reports/recall` | Recall report | `ROLE_ADMIN` |
| `/admin/compliance` | Prevention officer | `ROLE_ADMIN` |
| `/admin/staff` | Staff list | `ROLE_ADMIN` |
| `/admin/staff/new` | Create staff account | `ROLE_ADMIN` |
| `/admin/staff/{id}` | Edit staff permissions | `ROLE_ADMIN` |
| `/admin/settings` | Club settings | `ROLE_ADMIN` |
| `/staff/dashboard` | Staff home (permissions-filtered) | `ROLE_STAFF` |
| `/member/dashboard` | Member quota view | `ROLE_MEMBER` |
| `/member/distributions` | Personal history | `ROLE_MEMBER` |
| `/member/stock` | Strain availability | `ROLE_MEMBER` |
@@ -469,34 +539,34 @@ graph TD
## 5. Responsive Design Notes
### MVP (v1) — Desktop-First
### MVP (v1) — Tailwind Breakpoints
Target viewport: **1024px+**. PrimeFaces responsive grid (`p:panelGrid` with responsive columns, `ui-g-12 ui-md-6 ui-lg-4`) handles most layout adaptation down to tablet without custom media queries.
The React/Vite SPA uses **Tailwind CSS** breakpoints throughout. The switch from PrimeFaces means we no longer depend on JSF's `ui-g-*` responsive grid — Tailwind's `sm:` / `md:` / `lg:` utilities apply cleanly to every component.
| Breakpoint | Behavior |
|---|---|
| `≥ 1280px` | Full layout — sidebar + content side-by-side |
| `10241279px` | Sidebar collapses to icon-only (60px); tooltips on hover |
| `7681023px` | Sidebar hidden; hamburger menu in top navbar |
| `< 768px` | Admin portal degraded (tables scroll horizontally) |
| Breakpoint | Tailwind prefix | Admin Portal | Member Portal |
|---|---|---|---|
| `≥ 1280px` | `xl:` | Full layout — sidebar + content | Two-column: quota left, history right |
| `10241279px` | `lg:` | Sidebar collapses to icons (60px) | Two-column (narrower) |
| `7681023px` | `md:` | Sidebar hidden; hamburger sheet | Single-column, full-width cards |
| `< 768px` | `sm:` / base | Admin: horizontal table scroll | Member: compact quota ring, condensed table |
### Member Portal — Mobile-First from Day One
Members will typically check quota status on their phone. The member portal is designed mobile-first regardless of MVP/v2 timeline.
Members will typically check quota status on their phone. The member portal uses `flex-col` mobile-first layout with `md:flex-row` for wider viewports — no breakpoint-specific class sprawl.
| Breakpoint | Behavior |
|---|---|
| `≥ 1024px` | Two-column layout: quota circle left, history right |
| `7681023px` | Single-column, full-width cards |
| `375767px` | Single-column, compact quota ring, condensed table |
| `< 375px` | Minimum supported; no horizontal scroll |
### Responsive Conventions (React/Tailwind)
- No inline styles — use Tailwind utilities exclusively
- `cn()` utility (clsx + tailwind-merge) for conditional class composition
- Tables on mobile: horizontal scroll wrapper `overflow-x-auto` on `<div>` wrapping `<table>`
- All modals and sheets use `shadcn/ui Dialog` / `Sheet` — these are already mobile-friendly (viewport-aware positioning)
- Touch targets: all interactive elements `min-h-[44px]` and `min-w-[44px]` per WCAG 2.5.5
### v2 Roadmap
- PWA manifest + service worker (offline quota display)
- 768px and 375px explicit breakpoints with design tokens
- Touch-friendly `p:sidebar` for mobile member nav
- Push notifications for low quota warnings
- Per-club subdomain routing (`clubname.cannamanage.de`)
---
@@ -516,11 +586,11 @@ CannaManage targets **WCAG 2.1 AA** compliance across both portals.
### Screen Reader Support
- All `p:inputText` / `p:inputNumber` fields have `<label>` with `for` attribute
- All `Input` / `NumberInput` fields have `<label>` with `htmlFor` (React) — Radix UI enforces this automatically for shadcn/ui form fields
- `aria-label` set on icon-only buttons (e.g., recall action column)
- `aria-live="polite"` region on quota bar — announces percentage changes
- `aria-describedby` links compliance warning messages to the weight input
- PrimeFaces generates `role="grid"` and `aria-rowcount` on all data tables
- TanStack Table exposes `role="grid"` and `aria-rowcount` via `getTableProps()`
### Color Independence
+71 -34
View File
@@ -3,7 +3,7 @@
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
**Version:** 0.1.0-PLAN
**Date:** 2026-04-06
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose (Release) | TrueNAS.local — Docker (Build/CI)
---
@@ -45,20 +45,42 @@ Wildcard A record enables future per-club subdomains (`clubname.cannamanage.de`)
```mermaid
graph TB
Internet["🌐 Internet"] -->|"port 80/443"| Nginx["Nginx (reverse proxy)"]
Nginx -->|"http://app:8080"| App["cannamanage-app\n(Spring Boot 3.x)"]
App -->|"jdbc:postgresql://db:5432"| DB["PostgreSQL 16\n(cannamanage DB)"]
LetsEncrypt["Let's Encrypt\n(certbot auto-renew)"] -.->|"TLS cert"| Nginx
Gitea["Gitea Actions\n(homelab CI)"] -->|"SSH + docker compose"| VPS["Hetzner VPS\n/opt/cannamanage"]
Dev["👨‍💻 Dev Workstation\n(Fedora, 192.168.188.x)"]
Gitea["🏠 Gitea\n(truenas.local:30008)"]
TrueNAS["🖧 TrueNAS.local Docker\n(192.168.188.119)\nBuild + Staging"]
Hetzner["☁️ Hetzner VPS CX21\nProduction Release"]
subgraph VPS ["Hetzner VPS — Docker network: cannamanage_net"]
Nginx
App
DB
Dev -->|"git push"| Gitea
Gitea -->|"Gitea Actions runner\n(on TrueNAS.local)"| TrueNAS
TrueNAS -->|"mvn package + docker build"| TrueNAS
TrueNAS -->|"docker save | scp\n(on merge to main)"| Hetzner
subgraph TrueNAS ["TrueNAS.local — CI/CD Build Environment"]
GiteaRunner["Gitea Actions Runner"]
BuildCache["Maven .m2 cache\n(persistent volume)"]
StagingDB["PostgreSQL staging\n(ephemeral)"]
end
subgraph Hetzner ["Hetzner VPS — Production Release Environment"]
Nginx["Nginx (reverse proxy + TLS)"]
App["cannamanage-app\n(Spring Boot 3.x)"]
DB["PostgreSQL 16\n(persistent pgdata volume)"]
Nginx -->|"proxy_pass :8080"| App
App -->|"JDBC :5432"| DB
end
Internet["🌍 Internet HTTPS"] -->|"port 443"| Nginx
```
All three services run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
### Environment Roles
| Environment | Host | Purpose |
|---|---|---|
| **Development** | Dev workstation (Fedora) | Local feature development, unit tests |
| **Build / CI** | TrueNAS.local Docker | Gitea Actions runner; Maven build; integration tests (Testcontainers); Docker image build |
| **Production / Release** | Hetzner VPS CX21 | Live clubs, real data; Hetzner = our release environment |
All three services on Hetzner run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
---
@@ -351,12 +373,14 @@ curl https://app.cannamanage.de/actuator/health
---
## 6. CI/CD Pipeline (Gitea Actions)
## 6. CI/CD Pipeline (Gitea Actions on TrueNAS.local)
The Gitea Actions runner runs **on TrueNAS.local** — this is our homelab build machine. It has Docker, a persistent Maven `.m2` cache volume, and direct SSH access to the Hetzner VPS. Builds happen locally; only the final artifact (Docker image tarball) is shipped to Hetzner.
**File:** `.gitea/workflows/deploy.yml`
```yaml
name: Deploy to Production
name: Build and Deploy to Production
on:
push:
@@ -365,7 +389,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: self-hosted # <-- TrueNAS.local Gitea runner
steps:
- uses: actions/checkout@v4
@@ -381,25 +405,18 @@ jobs:
- name: Run integration tests
run: ./mvnw verify -P integration-tests
# Testcontainers requires Docker — GitHub/Gitea hosted runners have Docker pre-installed
# Testcontainers starts PostgreSQL via Docker on the TrueNAS runner
- name: Coverage gate check
run: ./mvnw verify -P coverage-check
build-and-deploy:
needs: test
runs-on: ubuntu-latest
runs-on: self-hosted # <-- TrueNAS.local Gitea runner
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build JAR
- name: Build JAR (production profile)
run: ./mvnw package -DskipTests -P production
- name: Build Docker image
@@ -412,13 +429,13 @@ jobs:
- name: Save Docker image
run: docker save cannamanage:${{ github.sha }} | gzip > /tmp/cannamanage.tar.gz
- name: Copy image to VPS
- name: Copy image to Hetzner VPS
run: |
scp -o StrictHostKeyChecking=no \
/tmp/cannamanage.tar.gz \
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
- name: Deploy via SSH
- name: Deploy via SSH to Hetzner (Production Release)
run: |
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.HETZNER_IP }} "
set -e
@@ -435,23 +452,43 @@ jobs:
sleep 10
docker compose ps app | grep 'healthy' || (docker compose logs app --tail=50 && exit 1)
# Prune old images (keep last 3)
# Prune old images (keep last 3 SHAs)
docker image prune -f
"
- name: Cleanup local build artifact
run: rm -f /tmp/cannamanage.tar.gz
```
### Gitea Actions Runner on TrueNAS.local
The self-hosted runner is a Docker container on TrueNAS.local:
```bash
# On TrueNAS.local — install Gitea Actions runner
docker run -d \
--name gitea-runner-cannamanage \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/gitea-runner/cannamanage:/data \
-v /opt/gitea-runner/.m2:/root/.m2 \ # Maven cache persisted across builds
-e GITEA_INSTANCE_URL=http://192.168.188.119:30008 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token-from-gitea-settings> \
gitea/act_runner:latest
```
### Required Gitea Repository Secrets
| Secret | Value |
|--------|-------|
| `HETZNER_IP` | VPS IPv4 address |
| `SSH_PRIVATE_KEY` | Private key for `deploy` user |
| Secret | Where set | Value |
|--------|-----------|-------|
| `HETZNER_IP` | Gitea repo secrets | Hetzner VPS IPv4 address |
| `SSH_PRIVATE_KEY` | Gitea repo secrets | Private key for `deploy` user on Hetzner |
Add deploy user's public key to VPS authorized_keys:
```bash
# On VPS as deploy user
# On Hetzner VPS — add TrueNAS runner's public key
# (generate keypair on TrueNAS.local: ssh-keygen -t ed25519 -f ~/.ssh/gitea_runner_deploy)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<gitea-actions-public-key>" >> ~/.ssh/authorized_keys
echo "<truenas-runner-public-key>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
+547
View File
@@ -0,0 +1,547 @@
# CannaManage — Sprint 1 Implementation Plan
**Sprint:** 1 — Foundation
**Phase:** Phase 1 (Weeks 18 of Phase 0 Foundation)
**Author:** Lumen (architect mode), 2026-04-10
**Status:** Ready for Patrick's approval
---
## Sprint Goal
> **"Get the compliance engine running and fully tested — with zero production code and zero API yet."**
Sprint 1 produces a compilable, testable Maven multi-module project with:
- All core JPA entities modelled
- Flyway V1 baseline migration SQL
- `ComplianceService` implemented with 100% unit test coverage (TC-001 → TC-010)
- A working local dev environment (Docker Compose: PostgreSQL + app)
No UI, no REST API, no Stripe in Sprint 1. The compliance engine is the legal heart of the product — validate it first.
---
## Deliverables
| # | Deliverable | Definition of Done |
|---|------------|-------------------|
| D1 | Maven multi-module project scaffold | `./mvnw clean verify` passes with no test failures |
| D2 | `cannamanage-domain` module | All 8 JPA entities compile; `AbstractTenantEntity` wired |
| D3 | Flyway `V1__initial_schema.sql` | Migration applies cleanly against PostgreSQL 16 |
| D4 | `ComplianceService` | All 5 business methods implemented |
| D5 | Unit test suite TC-001 → TC-010 | JaCoCo reports 100% line + branch coverage on `ComplianceService` |
| D6 | Local dev `docker-compose.yml` | `docker compose up db` starts PostgreSQL; app connects cleanly |
---
## 1. Maven Multi-Module Structure
```
cannamanage/ ← root POM (parent)
├── pom.xml ← parent POM (BOM: Spring Boot 3.x, Java 21)
├── cannamanage-domain/ ← JPA entities, enums, constants
│ └── src/main/java/de/cannamanage/domain/
│ ├── entity/ ← JPA entity classes
│ ├── enums/ ← MemberStatus, BatchStatus, etc.
│ └── constants/
│ └── ComplianceConstants.java
├── cannamanage-service/ ← Business logic, services (TESTED HERE)
│ └── src/
│ ├── main/java/de/cannamanage/service/
│ │ ├── ComplianceService.java
│ │ ├── dto/ ← QuotaStatus, ComplianceCheckResult, etc.
│ │ └── exception/ ← QuotaExceededException, MemberIneligibleException
│ └── test/java/de/cannamanage/service/
│ └── ComplianceServiceTest.java ← TC-001 to TC-010
├── cannamanage-api/ ← Spring Boot app entry point (REST controllers — Sprint 2)
│ └── src/main/java/de/cannamanage/api/
│ └── CannaManageApplication.java
└── docker-compose.yml ← Local dev: PostgreSQL 16
```
### Parent POM key dependencies (BOM managed)
```xml
<!-- Spring Boot 3.3.x parent -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
</parent>
<!-- Modules -->
<modules>
<module>cannamanage-domain</module>
<module>cannamanage-service</module>
<module>cannamanage-api</module>
</modules>
<!-- Key managed versions -->
<!-- Java 21, Hibernate 6.x (via Spring Boot BOM), Flyway 9.x -->
<!-- JJWT 0.12.x (Sprint 2), iText 7 (Sprint 3), Stripe 25.x (Sprint 4) -->
```
---
## 2. `cannamanage-domain` — JPA Entities
### 2.1 `AbstractTenantEntity` (base class for all entities)
```java
// de.cannamanage.domain.entity.AbstractTenantEntity
@MappedSuperclass
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = UUID.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class AbstractTenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.tenantId = TenantContext.getCurrentTenant(); // ThreadLocal
this.createdAt = Instant.now();
}
}
```
### 2.2 Entities to implement (Sprint 1)
| Entity | Key fields | Notes |
|--------|-----------|-------|
| `Club` | id, name, licenseNumber, maxMembers, status | Root tenant aggregate |
| `Member` | id, clubId, firstName, lastName, email, dob, membershipNumber, status, isUnder21 | `isUnder21` derived from DOB |
| `Strain` | id, name, thcPercentage, cbdPercentage | Immutable once created |
| `Batch` | id, strainId, quantityGrams, harvestDate, batchCode, status, contaminationFlag | status: AVAILABLE → EXHAUSTED / RECALLED |
| `Distribution` | id, memberId, batchId, quantityGrams, distributedAt, recordedBy, notes | `@Column(updatable=false)` on all fields — immutable |
| `MonthlyQuota` | id, memberId, year, month, totalDistributed, maxAllowed, version | `@Version` for optimistic lock |
| `StockMovement` | id, batchId, movementType, quantityGrams, reason, createdAt | Audit journal |
| `User` | id, memberId, email, passwordHash, role, lastLogin, active, refreshTokenHash | Login identity |
### 2.3 `ComplianceConstants.java`
```java
// de.cannamanage.domain.constants.ComplianceConstants
public final class ComplianceConstants {
// CanG §19(2) — adult limits
public static final BigDecimal ADULT_DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
public static final BigDecimal ADULT_MONTHLY_LIMIT_GRAMS = new BigDecimal("50.0");
// CanG §19(3) — under-21 limits
public static final BigDecimal UNDER21_MONTHLY_LIMIT_GRAMS = new BigDecimal("30.0");
// CanG §19(4) — under-21 THC cap
public static final BigDecimal UNDER21_MAX_THC_PERCENTAGE = new BigDecimal("10.0");
// Minimum membership age
public static final int MINIMUM_MEMBERSHIP_AGE = 18;
// Under-21 threshold
public static final int UNDER21_THRESHOLD_AGE = 21;
private ComplianceConstants() {}
}
```
---
## 3. Flyway `V1__initial_schema.sql`
Location: `cannamanage-api/src/main/resources/db/migration/V1__initial_schema.sql`
```sql
-- Clubs (root of tenant hierarchy)
CREATE TABLE clubs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
address TEXT,
license_number VARCHAR(100) NOT NULL UNIQUE,
max_members INT NOT NULL DEFAULT 500,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Members
CREATE TABLE members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
date_of_birth DATE NOT NULL,
membership_date DATE NOT NULL DEFAULT CURRENT_DATE,
membership_number VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
is_under_21 BOOLEAN NOT NULL DEFAULT FALSE,
prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(email, tenant_id),
UNIQUE(membership_number, tenant_id)
);
-- Strains
CREATE TABLE strains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
thc_percentage NUMERIC(5,2) NOT NULL,
cbd_percentage NUMERIC(5,2) NOT NULL DEFAULT 0.00,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Batches
CREATE TABLE batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
strain_id UUID NOT NULL REFERENCES strains(id),
quantity_grams NUMERIC(10,2) NOT NULL,
harvest_date DATE,
batch_code VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'AVAILABLE',
contamination_flag BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(batch_code, tenant_id)
);
-- Distributions (immutable — no UPDATE/DELETE via app)
CREATE TABLE distributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
batch_id UUID NOT NULL REFERENCES batches(id),
quantity_grams NUMERIC(10,2) NOT NULL,
distributed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
recorded_by UUID NOT NULL REFERENCES members(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Monthly quotas (one row per member per calendar month)
CREATE TABLE monthly_quotas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
year INT NOT NULL,
month INT NOT NULL CHECK (month >= 1 AND month <= 12),
total_distributed NUMERIC(10,2) NOT NULL DEFAULT 0.00,
max_allowed NUMERIC(10,2) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(member_id, year, month)
);
-- Stock movements (audit journal)
CREATE TABLE stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
batch_id UUID NOT NULL REFERENCES batches(id),
movement_type VARCHAR(50) NOT NULL, -- IN, OUT, RECALL, ADJUSTMENT
quantity_grams NUMERIC(10,2) NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users (login identities)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID REFERENCES members(id),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'ROLE_MEMBER',
last_login TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT TRUE,
refresh_token_hash VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(email, tenant_id)
);
-- Indexes for common query patterns
CREATE INDEX idx_members_club_id ON members(club_id);
CREATE INDEX idx_members_tenant_id ON members(tenant_id);
CREATE INDEX idx_distributions_member_id ON distributions(member_id);
CREATE INDEX idx_distributions_tenant_id ON distributions(tenant_id);
CREATE INDEX idx_distributions_distributed_at ON distributions(distributed_at);
CREATE INDEX idx_monthly_quotas_member_month ON monthly_quotas(member_id, year, month);
CREATE INDEX idx_batches_tenant_status ON batches(tenant_id, status);
```
---
## 4. `ComplianceService` — Implementation Spec
Package: `de.cannamanage.service`
### 4.1 Dependencies (injected via constructor)
```java
@Service
@Transactional
public class ComplianceService {
private final MemberRepository memberRepository;
private final DistributionRepository distributionRepository;
private final BatchRepository batchRepository;
private final MonthlyQuotaRepository monthlyQuotaRepository;
private final StrainRepository strainRepository;
// constructor injection...
}
```
### 4.2 Method: `checkDistributionAllowed(UUID memberId, UUID batchId, BigDecimal quantityGrams)`
**Algorithm (sequential checks, fail-fast):**
```
1. Load Member — throw MemberNotFoundException if not found
2. CHECK: member.status == ACTIVE → else throw QuotaExceededException(MEMBER_INACTIVE)
3. Load Batch → CHECK: batch.status == AVAILABLE → else throw BatchUnavailableException
4. Load Strain via batch.strainId
5. IF member.isUnder21 AND strain.thcPercentage > UNDER21_MAX_THC_PERCENTAGE
→ throw QuotaExceededException(HIGH_THC_RESTRICTED_UNDER_21)
6. Calculate todayDistributed = SUM(distributions.quantityGrams WHERE memberId AND date=TODAY)
CHECK: todayDistributed + quantityGrams > ADULT_DAILY_LIMIT_GRAMS
→ throw QuotaExceededException(QUOTA_EXCEEDED_DAILY)
7. Get or create MonthlyQuota for (memberId, currentYear, currentMonth)
SET maxAllowed = isUnder21 ? UNDER21_MONTHLY_LIMIT_GRAMS : ADULT_MONTHLY_LIMIT_GRAMS
CHECK: quota.totalDistributed + quantityGrams > quota.maxAllowed
→ throw QuotaExceededException(QUOTA_EXCEEDED_MONTHLY)
8. Return ComplianceCheckResult(allowed=true, remainingDaily, remainingMonthly)
```
### 4.3 `QuotaExceededException` — error codes
```java
public enum QuotaViolationCode {
MEMBER_INACTIVE,
QUOTA_EXCEEDED_DAILY,
QUOTA_EXCEEDED_MONTHLY,
HIGH_THC_RESTRICTED_UNDER_21,
BATCH_UNAVAILABLE
}
```
### 4.4 DTOs
```java
// ComplianceCheckResult
record ComplianceCheckResult(
boolean allowed,
BigDecimal remainingDaily,
BigDecimal remainingMonthly,
boolean isUnder21
) {}
// QuotaStatus
record QuotaStatus(
BigDecimal totalAllowed,
BigDecimal totalUsed,
BigDecimal remaining,
boolean isUnder21,
int year,
int month
) {}
```
---
## 5. Unit Test Suite (TC-001 → TC-010)
**Class:** `ComplianceServiceTest` in `cannamanage-service`
**Coverage requirement:** 100% line + branch on `ComplianceService`
**Tools:** JUnit 5, Mockito 5, AssertJ
### Test structure
```java
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest {
@Mock MemberRepository memberRepository;
@Mock DistributionRepository distributionRepository;
@Mock BatchRepository batchRepository;
@Mock MonthlyQuotaRepository monthlyQuotaRepository;
@Mock StrainRepository strainRepository;
@InjectMocks ComplianceService complianceService;
// Test fixtures
private static final UUID ADULT_MEMBER_ID = UUID.randomUUID();
private static final UUID UNDER21_MEMBER_ID = UUID.randomUUID();
private static final UUID BATCH_ID = UUID.randomUUID();
private static final UUID HIGH_THC_STRAIN_ID = UUID.randomUUID();
// TC-001: adult at monthly limit → throws QUOTA_EXCEEDED_MONTHLY
// TC-002: under-21 at monthly limit → throws QUOTA_EXCEEDED_MONTHLY
// TC-003: adult at daily limit → throws QUOTA_EXCEEDED_DAILY
// TC-004: under-21 + high THC strain → throws HIGH_THC_RESTRICTED_UNDER_21
// TC-005: adult at 49g requesting 2g → throws QUOTA_EXCEEDED_MONTHLY
// TC-006: adult at 0g requesting 25g → allowed, remaining=0
// TC-007: adult at 24.9g requesting 0.1g → allowed, remainingDaily=0
// TC-008: adult at 24.9g requesting 0.2g → throws QUOTA_EXCEEDED_DAILY
// TC-009: SUSPENDED member → throws MEMBER_INACTIVE
// TC-010: EXPELLED member → throws MEMBER_INACTIVE
}
```
### Key mock patterns
```java
// TC-001 example mock setup
Member adultMember = new Member();
adultMember.setId(ADULT_MEMBER_ID);
adultMember.setUnder21(false);
adultMember.setStatus(MemberStatus.ACTIVE);
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
MonthlyQuota quota = new MonthlyQuota();
quota.setTotalDistributed(new BigDecimal("50.0"));
quota.setMaxAllowed(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
// Assert
assertThatThrownBy(() -> complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code")
.isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
```
---
## 6. Local Dev Docker Compose
```yaml
# docker-compose.yml (root of cannamanage project)
version: '3.9'
services:
db:
image: postgres:16-alpine
container_name: cannamanage-db-local
environment:
POSTGRES_DB: cannamanage
POSTGRES_USER: cannamanage
POSTGRES_PASSWORD: dev_password_change_in_prod
ports:
- "5432:5432"
volumes:
- pgdata_local:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cannamanage"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata_local:
```
```properties
# cannamanage-api/src/main/resources/application-local.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/cannamanage
spring.datasource.username=cannamanage
spring.datasource.password=dev_password_change_in_prod
spring.jpa.hibernate.ddl-auto=validate # Flyway owns schema
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
logging.level.de.cannamanage=DEBUG
```
**Run locally:**
```bash
git clone http://192.168.188.119:30008/pplate/cannamanage.git
cd cannamanage
docker compose up db -d
./mvnw spring-boot:run -pl cannamanage-api -Dspring.profiles.active=local
```
---
## 7. Sprint 1 Gitea Issues (already created: #1#10)
Based on the Sprint 1 board at `http://truenas.local:30008/pplate/cannamanage/wiki/Sprint-1-Board`, these map to:
| Gitea Issue | Sprint 1 Deliverable |
|-------------|---------------------|
| #1 | Maven multi-module project scaffold |
| #2 | `AbstractTenantEntity` + `TenantContext` ThreadLocal |
| #3 | All 8 JPA entities in `cannamanage-domain` |
| #4 | `ComplianceConstants.java` |
| #5 | Flyway `V1__initial_schema.sql` |
| #6 | `ComplianceService` implementation |
| #7 | Unit tests TC-001 → TC-010 (100% coverage) |
| #8 | `docker-compose.yml` local dev |
| #9 | `application-local.properties` |
| #10 | JaCoCo coverage gate in parent POM |
---
## 8. Out of Scope — Sprint 1
These are **explicitly deferred** to Sprint 2+:
- REST API controllers (`AuthController`, `MemberController`, `DistributionController`)
- Spring Security + JWT filter chain
- PrimeFaces JSF frontend
- Stripe billing integration
- iText 7 PDF reports
- Email notifications
- Testcontainers integration tests (TC-018 → TC-022)
- Hetzner deployment / CI pipeline
- `MemberService` (TC-011 → TC-015)
---
## 9. Definition of Done — Sprint 1
- [ ] `./mvnw clean verify` exits 0 on clean checkout
- [ ] `./mvnw test -pl cannamanage-service` reports 10/10 tests passing
- [ ] JaCoCo report shows `ComplianceService` at 100% line + branch coverage
- [ ] `docker compose up db -d` starts PostgreSQL; Flyway V1 migration applies cleanly
- [ ] No `TODO` comments in production code paths
- [ ] All 8 JPA entities have `@Column(nullable = false)` on required fields
- [ ] `ComplianceConstants.java` contains all CanG limits as `public static final BigDecimal`
- [ ] `AbstractTenantEntity.tenantId` is `@Column(updatable = false)`
- [ ] Code pushed to `http://192.168.188.119:30008/pplate/cannamanage` main branch
---
## 10. Recommended Implementation Order
```
Day 1: Root pom.xml + module scaffolds → ./mvnw compile passes
Day 2: AbstractTenantEntity + TenantContext + ComplianceConstants
Day 3: All 8 JPA entities (compile-time only, no DB yet)
Day 4: Flyway V1 SQL + docker-compose.yml → migration applies
Day 5: ComplianceService skeleton (method signatures + DTOs)
Day 6: TC-001 → TC-005 (the exception/blocking cases)
Day 7: TC-006 → TC-010 (boundary + happy path cases)
Day 8: JaCoCo gate; clean up; push to Gitea
```
*Assuming ~23 hours of evening/weekend coding per day as side project.*
---
*Plan created: 2026-04-10 | Sprint start: when Patrick approves | Estimated coding sessions: 8 × 2-3h*
+23 -4
View File
@@ -12,17 +12,35 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
### Added
- Complete project documentation suite (10 documents, ~25,000 words)
- System architecture design: 8 JPA entities, Maven multi-module structure, multi-tenancy via shared schema + Hibernate filter
- System architecture design: 8 JPA entities, Maven multi-module structure
- REST API specification: 7 controllers, 30+ endpoints, full request/response schemas with error codes
- Compliance engine design: `ComplianceService` enforcing CanG §§1922 limits (25g/day, 50g/month adults; 30g/month under-21; ≤10% THC under-21)
- `ComplianceConstants.java` design: all legal thresholds as named constants to prevent magic numbers in compliance logic
- UI wireframes for 6 screens: Admin Dashboard, Distribution Recording Form, Member List, Member Quota View, Stock Management, Compliance Report
- 5 AI-generated UI mockup images (FLUX.1-schnell via ComfyUI, 1024×512)
- Test plan with 26 test cases covering ComplianceService (TC-001010), MemberService (TC-011015), tenant isolation (TC-016017), and integration tests (TC-018026)
- Deployment guide for Hetzner VPS: Docker Compose setup, Nginx reverse proxy, SSL with Let's Encrypt, CI/CD via Gitea Actions, database backup strategy
- Coding standards: Java 21 conventions, JPA patterns, multi-tenancy rules, immutable distribution records
- Flowcharts: distribution flow (5-step), member lifecycle (state machine), billing provisioning flow (Mermaid diagrams)
- README with full documentation index, tech stack table, pricing tiers, legal notice
- **[2026-04-06]** Staff member management: `ROLE_STAFF` with configurable per-account permission grants (US-026); admin controls which data staff can access (DSGVO least-privilege). 8 defined permissions, 3 pre-created role templates (Ausgabe, Lager, Vorstand). Core feature from Phase 0.
- **[2026-04-06]** Grow Calendar: US-027 added as Could Have (v2) — cultivation diary per grow cycle, linked to batch harvest, optional photo attachments, admin-controlled access via `MANAGE_GROW_CALENDAR` permission
- **[2026-04-06]** Staff wireframe (Screen 7) added to `06-Wireframes.md` with full ASCII wireframe, component table (TanStack Table, shadcn/ui Checkbox, Select), and DSGVO design rationale
- **[2026-04-06]** Staff routes added to Navigation IA: `/admin/staff`, `/admin/staff/new`, `/admin/staff/{id}`, `/staff/dashboard`
- **[2026-04-06]** TrueNAS.local Gitea Actions self-hosted runner documented in `09-Deployment.md` as the CI/CD build environment; Hetzner = production release target
### Changed
- **[2026-04-06]** `03-Architecture.md`**Multi-tenancy model changed from shared-schema to schema-per-tenant.** Decision rationale: hard DB-level isolation (not application-layer), clean DSGVO deletion (`DROP SCHEMA`), no cross-tenant index bloat, easier future isolation. `tenant_id` columns on every entity removed; schema routing via `TenantRoutingDataSource` replaces Hibernate `@Filter`.
- **[2026-04-06]** `03-Architecture.md`**Frontend changed from PrimeFaces/JSF to React/Vite SPA.** Rationale: JSF server-side lifecycle is a poor fit for a REST API backend; PrimeFaces creates a hiring bottleneck; React is mobile-friendly from day 1. Component library: shadcn/ui (Radix UI + Tailwind CSS) + TanStack Table v8.
- **[2026-04-06]** `03-Architecture.md``ROLE_STAFF` added with configurable `StaffPermission` enum; pre-created templates documented. Staff noted as core feature, not add-on.
- **[2026-04-06]** `06-Wireframes.md` — All component tables updated from PrimeFaces (`p:dataTable`, `p:commandButton`) to React/Tailwind equivalents (TanStack Table, shadcn/ui). Responsive Design section rewritten for Tailwind breakpoints.
- **[2026-04-06]** `09-Deployment.md` — CI/CD section rewritten: `runs-on: ubuntu-latest``runs-on: self-hosted` (TrueNAS.local). Gitea Actions runner setup instructions added. Infrastructure diagram updated to show Dev → Gitea → TrueNAS build → Hetzner release flow.
- **[2026-04-06]** `0.1.0` CHANGELOG entry corrected: removed "shared schema" as final architecture decision (superseded by schema-per-tenant); removed PrimeFaces as frontend (superseded by React/Vite)
### Fixed
- **[2026-04-06]** `04-Flowcharts.md` — Mermaid parse error in Flow 4 (line 146): `[Generate empty report\nwith zero totals\n(still valid compliance submission)]` — parenthesis after newline was parsed as stadium-shape node start. Fixed by wrapping node text in double quotes.
- **[2026-04-06]** `04-Flowcharts.md` — Mermaid parse error in Flow 5 (line 177): `[❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)]` — same root cause, same fix.
---
@@ -33,9 +51,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- `STRATEGY.md` — initial project vision and feasibility assessment
- Legal analysis confirming CanG compliance viability for B2B SaaS model (no public advertising, no club discovery, B2B-only)
- Market analysis: ~3,000 registered clubs in Germany, TAM estimated at €2.85M ARR
- Tech stack selection rationale: Spring Boot 3.x + PrimeFaces JSF (MVP) → Next.js v2; PostgreSQL + Flyway; iText 7 PDF; Stripe billing
- Multi-tenancy architectural decision: shared schema with `tenant_id` column (chosen over schema-per-tenant for lower operational overhead at MVP scale)
- Tech stack selection rationale: Spring Boot 3.x + React/Vite SPA (MVP) → Next.js v2; PostgreSQL + Flyway; iText 7 PDF; Stripe billing
- Multi-tenancy architectural decision: schema-per-tenant (each club gets isolated PostgreSQL schema; platform registry in `public` schema)
- Pricing model: 4 tiers (Starter €29, Growth €59, Professional €99, Enterprise €199/month)
- Deployment guide for Hetzner VPS (production release): Docker Compose, Nginx + Let's Encrypt, Gitea Actions CI/CD via TrueNAS.local self-hosted runner, daily PostgreSQL backup strategy
---
File diff suppressed because it is too large Load Diff