cda8946c75
- 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
505 lines
17 KiB
Markdown
505 lines
17 KiB
Markdown
# 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<Member> getUnder21Members() { ... }
|
|
}
|
|
```
|
|
|
|
### Member Login Sequence
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant B as Browser
|
|
participant API as Spring Boot /api/v1/auth/login
|
|
participant DB as PostgreSQL (users table)
|
|
participant JWT as JwtService
|
|
|
|
B->>API: POST /api/v1/auth/login {email, password}
|
|
API->>DB: SELECT * FROM users WHERE email = ? AND active = true
|
|
DB-->>API: UserEntity (password_hash, role, tenant_id, member_id)
|
|
API->>API: BCrypt.verify(password, password_hash)
|
|
alt Invalid credentials
|
|
API-->>B: 401 Unauthorized
|
|
else Valid
|
|
API->>JWT: generateAccessToken(userId, role, tenantId) → 8h
|
|
API->>JWT: generateRefreshToken(userId) → 30d
|
|
API->>DB: UPDATE users SET refresh_token_hash = ?, last_login = NOW()
|
|
DB-->>API: OK
|
|
JWT-->>API: accessToken, refreshToken
|
|
API-->>B: 200 { accessToken, refreshToken, expiresIn: 28800 }
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Data Model (JPA Entities)
|
|
|
|
### Entity-Relationship Diagram
|
|
|
|
```mermaid
|
|
erDiagram
|
|
Club {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
string name
|
|
string address
|
|
string license_number
|
|
int max_members
|
|
timestamp created_at
|
|
enum status
|
|
}
|
|
|
|
Member {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
UUID club_id FK
|
|
string first_name
|
|
string last_name
|
|
string email
|
|
date date_of_birth
|
|
date membership_date
|
|
string membership_number
|
|
enum status
|
|
boolean is_under_21
|
|
boolean prevention_officer
|
|
}
|
|
|
|
Strain {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
string name
|
|
decimal thc_percentage
|
|
decimal cbd_percentage
|
|
string description
|
|
}
|
|
|
|
Batch {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
UUID strain_id FK
|
|
decimal quantity_grams
|
|
date harvest_date
|
|
string batch_code
|
|
enum status
|
|
boolean contamination_flag
|
|
}
|
|
|
|
Distribution {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
UUID member_id FK
|
|
UUID batch_id FK
|
|
decimal quantity_grams
|
|
timestamp distributed_at
|
|
UUID recorded_by FK
|
|
string notes
|
|
boolean immutable
|
|
}
|
|
|
|
MonthlyQuota {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
UUID member_id FK
|
|
int year
|
|
int month
|
|
decimal total_distributed
|
|
decimal max_allowed
|
|
}
|
|
|
|
StockMovement {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
UUID batch_id FK
|
|
enum movement_type
|
|
decimal quantity_grams
|
|
string reason
|
|
timestamp created_at
|
|
}
|
|
|
|
User {
|
|
UUID id PK
|
|
UUID tenant_id
|
|
UUID member_id FK
|
|
string email
|
|
string password_hash
|
|
enum role
|
|
timestamp last_login
|
|
boolean active
|
|
}
|
|
|
|
Club ||--o{ Member : "has members"
|
|
Member ||--o{ Distribution : "receives"
|
|
Member ||--o{ MonthlyQuota : "has quota per month"
|
|
Member ||--o| User : "may have login"
|
|
Strain ||--o{ Batch : "cultivated as"
|
|
Batch ||--o{ Distribution : "distributed via"
|
|
Batch ||--o{ StockMovement : "tracked in"
|
|
Member ||--o{ Distribution : "recorded_by (admin)"
|
|
```
|
|
|
|
### Relationship Notes
|
|
|
|
| Relationship | Cardinality | Notes |
|
|
|---|---|---|
|
|
| Club → Member | 1:N | `member.club_id` FK; max enforced by `Club.max_members` |
|
|
| Member → Distribution | 1:N | Each distribution targets one member |
|
|
| Member → MonthlyQuota | 1:N | One row per `(member_id, year, month)` — UNIQUE constraint |
|
|
| Member → User | 1:0..1 | A member may have a portal login; admins may have no `member_id` |
|
|
| Strain → Batch | 1:N | Each batch is one strain; a strain can have many batches |
|
|
| Batch → Distribution | 1:N | A batch can supply many distributions |
|
|
| Batch → StockMovement | 1:N | Every IN/OUT/RECALL against a batch is journaled |
|
|
| Distribution.recorded_by → Member | N:1 | The admin who recorded it (audit trail) |
|
|
|
|
### Key Constraints
|
|
|
|
- `Distribution.immutable = true` by default — records are append-only; no UPDATE/DELETE allowed via API
|
|
- `MonthlyQuota` has `UNIQUE(member_id, year, month)` — enforced at DB level
|
|
- `Batch.contamination_flag` triggers recall workflow; `Batch.status = RECALLED` is the final state
|
|
- All `tenant_id` columns: `NOT NULL`, `updatable = false`, set via `@PrePersist`
|
|
- `Member.is_under_21` is derived from `date_of_birth` at registration and re-evaluated on birthday (scheduled job)
|
|
|
|
---
|
|
|
|
## 5. API Layer Design
|
|
|
|
### Base Path: `/api/v1/`
|
|
|
|
All endpoints are RESTful. JSON request/response bodies. JWT Bearer token required on all except `/auth/**`.
|
|
|
|
| Controller | Base Path | Key Endpoints |
|
|
|---|---|---|
|
|
| `AuthController` | `/api/v1/auth` | `POST /login`, `POST /refresh`, `POST /logout` |
|
|
| `ClubController` | `/api/v1/clubs` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}` |
|
|
| `MemberController` | `/api/v1/members` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status`, `GET /me/quota` |
|
|
| `DistributionController` | `/api/v1/distributions` | `POST /`, `GET /?memberId=&month=&year=` |
|
|
| `StockController` | `/api/v1/stock` | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` |
|
|
| `ReportController` | `/api/v1/reports` | `GET /monthly?month=&year=` (PDF + CSV), `GET /members/{id}/history` |
|
|
| `QuotaController` | `/api/v1/quota` | `GET /members/{id}/current`, `GET /members/{id}/history` |
|
|
|
|
### Standard HTTP conventions
|
|
- `201 Created` + `Location` header on resource creation
|
|
- `400 Bad Request` with `{ error, message, field? }` on validation failure
|
|
- `403 Forbidden` when role/tenant check fails
|
|
- `422 Unprocessable Entity` when compliance limits are breached (quota exceeded)
|
|
- Pagination: `?page=0&size=20&sort=field,asc`
|
|
|
|
---
|
|
|
|
## 6. Compliance Engine
|
|
|
|
The `ComplianceService` is the heart of regulatory enforcement. It is called synchronously before every distribution is persisted. All operations run in a single `@Transactional` block with optimistic locking to prevent race conditions under concurrent distribution recording.
|
|
|
|
```java
|
|
@Service
|
|
@Transactional
|
|
public class ComplianceService {
|
|
|
|
/**
|
|
* Validates whether a distribution is legally permitted.
|
|
*
|
|
* Checks:
|
|
* 1. Member is ACTIVE (not SUSPENDED or EXPELLED)
|
|
* 2. Daily limit: total distributed today + requestedGrams ≤ 25g
|
|
* 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed
|
|
* where max_allowed = 30g (under-21) or 50g (adult)
|
|
* 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED)
|
|
* 5. Batch has sufficient stock
|
|
*
|
|
* @throws ComplianceLimitExceededException with remaining quota details
|
|
* @throws MemberIneligibleException if member is not ACTIVE
|
|
* @throws BatchUnavailableException if batch is recalled or exhausted
|
|
*/
|
|
public ComplianceCheckResult checkDistributionAllowed(
|
|
UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... }
|
|
|
|
/**
|
|
* Returns remaining quota for the current calendar month.
|
|
* Creates a MonthlyQuota row if none exists (lazy initialization).
|
|
*
|
|
* @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 }
|
|
*/
|
|
public QuotaStatus getMonthlyRemaining(UUID memberId) { ... }
|
|
|
|
/**
|
|
* Flags a batch as RECALLED.
|
|
* Returns all members who received distributions from this batch
|
|
* so the caller can trigger notifications.
|
|
* Writes a StockMovement(RECALL) entry.
|
|
*
|
|
* @return List<AffectedMember> { memberId, name, email, totalReceived }
|
|
*/
|
|
public List<AffectedMember> recallBatch(UUID batchId) { ... }
|
|
}
|
|
```
|
|
|
|
### Race Condition Prevention
|
|
|
|
`MonthlyQuota` rows carry a JPA `@Version` column (optimistic locking). If two concurrent requests attempt to increment `total_distributed` simultaneously, one will receive an `OptimisticLockException` and must retry. The retry logic is handled by a `@Retryable` annotation (Spring Retry, max 3 attempts, 50ms backoff).
|
|
|
|
```java
|
|
@Entity
|
|
public class MonthlyQuota extends AbstractTenantEntity {
|
|
|
|
@Version
|
|
private Long version; // optimistic lock
|
|
|
|
// ... other fields
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Infrastructure (Hetzner)
|
|
|
|
```mermaid
|
|
graph TD
|
|
Dev["👨💻 Developer (Fedora Workstation)"]
|
|
Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"]
|
|
Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"]
|
|
|
|
Dev -->|git push| Gitea
|
|
Gitea -->|SSH deploy script\n(webhook → deploy.sh)| Hetzner
|
|
|
|
subgraph Hetzner
|
|
Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"]
|
|
App["☕ cannamanage-app\n(Spring Boot JAR)"]
|
|
DB[("🐘 cannamanage-db\nPostgreSQL 16")]
|
|
|
|
Nginx -->|proxy_pass :8080| App
|
|
App -->|JDBC :5432| DB
|
|
end
|
|
|
|
Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx
|
|
```
|
|
|
|
### Docker Compose Services
|
|
|
|
```yaml
|
|
# docker-compose.yml (abbreviated)
|
|
services:
|
|
cannamanage-app:
|
|
image: cannamanage:latest
|
|
environment:
|
|
SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage
|
|
JWT_SECRET: ${JWT_SECRET}
|
|
STRIPE_API_KEY: ${STRIPE_API_KEY}
|
|
depends_on: [cannamanage-db]
|
|
ports: ["127.0.0.1:8080:8080"]
|
|
|
|
cannamanage-db:
|
|
image: postgres:16-alpine
|
|
volumes: [pgdata:/var/lib/postgresql/data]
|
|
environment:
|
|
POSTGRES_DB: cannamanage
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
|
|
cannamanage-nginx:
|
|
image: nginx:alpine
|
|
ports: ["443:443", "80:80"]
|
|
volumes:
|
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
|
```
|
|
|
|
### Hetzner Sizing
|
|
|
|
| Resource | Spec | Rationale |
|
|
|---|---|---|
|
|
| Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP |
|
|
| Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups |
|
|
| Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days |
|
|
| Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany |
|
|
| TLS | Let's Encrypt via Certbot | Auto-renew via cron |
|
|
|
|
### Deployment Workflow
|
|
|
|
```
|
|
git push origin main
|
|
→ Gitea webhook fires
|
|
→ deploy.sh on Hetzner:
|
|
docker pull cannamanage:latest
|
|
docker compose up -d --no-deps cannamanage-app
|
|
# zero-downtime: Nginx buffers requests during restart
|
|
```
|
|
|
|
Flyway migrations run automatically on application startup (`spring.flyway.enabled=true`). Migration files live at `src/main/resources/db/migration/V*.sql`.
|
|
|
|
---
|
|
|
|
## 8. Key Design Decisions
|
|
|
|
| Decision | Choice | Rationale |
|
|
|---|---|---|
|
|
| Multi-tenancy | 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 |
|