- 11 wiki pages: CannaManage-Home + 01-10 covering full Phase 0 docs - 5 mockup images in docs/wiki/images/ - Updated _Sidebar.md with CannaManage section
17 KiB
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
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 |
| Jakarta Mail / Spring Mail | Welcome emails, recall alerts | |
| 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_idfilter 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
// 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();
}
}
// 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_idis set at@PrePersist— never accepted from user inputtenant_idisupdatable = 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
@Service
public class DistributionService {
@PreAuthorize("hasRole('CLUB_ADMIN')")
public Distribution recordDistribution(RecordDistributionRequest req) { ... }
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
public QuotaStatus getMyQuota(UUID memberId) { ... }
@PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')")
public List<Member> getUnder21Members() { ... }
}
Member Login Sequence
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
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 = trueby default — records are append-only; no UPDATE/DELETE allowed via APIMonthlyQuotahasUNIQUE(member_id, year, month)— enforced at DB levelBatch.contamination_flagtriggers recall workflow;Batch.status = RECALLEDis the final state- All
tenant_idcolumns:NOT NULL,updatable = false, set via@PrePersist Member.is_under_21is derived fromdate_of_birthat 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+Locationheader on resource creation400 Bad Requestwith{ error, message, field? }on validation failure403 Forbiddenwhen role/tenant check fails422 Unprocessable Entitywhen 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.
@Service
@Transactional
public class ComplianceService {
/**
* Validates whether a distribution is legally permitted.
*
* Checks:
* 1. Member is ACTIVE (not SUSPENDED or EXPELLED)
* 2. Daily limit: total distributed today + requestedGrams ≤ 25g
* 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed
* where max_allowed = 30g (under-21) or 50g (adult)
* 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED)
* 5. Batch has sufficient stock
*
* @throws ComplianceLimitExceededException with remaining quota details
* @throws MemberIneligibleException if member is not ACTIVE
* @throws BatchUnavailableException if batch is recalled or exhausted
*/
public ComplianceCheckResult checkDistributionAllowed(
UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... }
/**
* Returns remaining quota for the current calendar month.
* Creates a MonthlyQuota row if none exists (lazy initialization).
*
* @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 }
*/
public QuotaStatus getMonthlyRemaining(UUID memberId) { ... }
/**
* Flags a batch as RECALLED.
* Returns all members who received distributions from this batch
* so the caller can trigger notifications.
* Writes a StockMovement(RECALL) entry.
*
* @return List<AffectedMember> { memberId, name, email, totalReceived }
*/
public List<AffectedMember> recallBatch(UUID batchId) { ... }
}
Race Condition Prevention
MonthlyQuota rows carry a JPA @Version column (optimistic locking). If two concurrent requests attempt to increment total_distributed simultaneously, one will receive an OptimisticLockException and must retry. The retry logic is handled by a @Retryable annotation (Spring Retry, max 3 attempts, 50ms backoff).
@Entity
public class MonthlyQuota extends AbstractTenantEntity {
@Version
private Long version; // optimistic lock
// ... other fields
}
7. Infrastructure (Hetzner)
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
# 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 |