docs(cannamanage): update wiki pages and sprint plans + brand pipeline doc
This commit is contained in:
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user