# 03 — System Architecture **Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen) **Phase:** 2 of 5 — Architecture & Data Model **Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · PrimeFaces JSF MVP → Next.js v2 **Last updated:** 2026-04-06 --- ## 1. Architecture Overview ```mermaid graph TD AdminBrowser["🖥️ Browser — Admin Portal"] MemberBrowser["🖥️ Browser — Member Portal"] JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"] AdminBrowser -->|HTTP/S| JSF MemberBrowser -->|HTTP/S| JSF JSF -->|REST calls| Backend subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"] REST["REST API Layer\n/api/v1/"] Service["Service Layer\n(ComplianceService, ReportService…)"] JPA["JPA / Hibernate\nRepositories"] Security["Spring Security + JWT\nTenant Interceptor"] REST --> Service Service --> JPA Security --> REST end JPA -->|JDBC| PG[("🐘 PostgreSQL 16\nmulti-tenant via tenant_id")] Backend -->|Stripe Java SDK| Stripe["💳 Stripe\n(payment processing)"] Backend -->|Jakarta Mail| Mail["📧 Jakarta Mail\n(email notifications)"] Backend -->|iText 7| PDF["📄 iText 7\n(PDF report generation)"] Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG subgraph Hetzner ["🖧 Hetzner VPS — Docker Compose"] Backend PG Nginx["🔒 Nginx\n(reverse proxy + TLS)"] end JSF --> Nginx Nginx --> Backend ``` ### Component Responsibilities | Component | Technology | Role | |---|---|---| | Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI | | Member Portal | PrimeFaces JSF (→ Next.js v2) | Member quota & history UI | | REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints | | Auth | Spring Security 6 + JJWT | Stateless JWT authentication | | ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering | | Database | PostgreSQL 16 | Primary data store (multi-tenant) | | Migrations | Flyway | Versioned schema management | | Payments | Stripe Java SDK | Club subscription billing | | Email | Jakarta Mail / Spring Mail | Welcome emails, recall alerts | | PDF | iText 7 | Compliance report generation | | Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment | --- ## 2. Multi-Tenancy Strategy ### Approach: Shared Schema with Row-Level Filtering Every JPA entity carries a `tenant_id` column (UUID, `NOT NULL`). A single PostgreSQL database hosts all clubs — row-level filtering enforces data isolation at the application layer. **Why shared schema (not separate schema/DB per tenant)?** - Lower operational overhead for an MVP with < 500 clubs - Single Flyway migration path across all tenants - Simpler connection pooling (one pool, not N) - Acceptable security risk when `tenant_id` filter is enforced at the service layer ### Tenant Resolution ``` HTTP Request └─ Spring Security Filter: extract JWT → resolve tenant_id └─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal └─ JPA @Where filter applied on every entity query ``` ### Code Pattern — Tenant-Aware Base Entity ```java // AbstractTenantEntity.java (pseudocode) @MappedSuperclass @FilterDef( name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = UUID.class) ) @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public abstract class AbstractTenantEntity { @Column(name = "tenant_id", nullable = false, updatable = false) private UUID tenantId; @PrePersist void injectTenant() { this.tenantId = TenantContext.getCurrentTenant(); } } ``` ```java // TenantFilterInterceptor.java (pseudocode) @Component public class TenantFilterInterceptor implements HandlerInterceptor { @Autowired EntityManager em; @Override public boolean preHandle(HttpServletRequest req, ...) { UUID tenantId = TenantContext.getCurrentTenant(); Session session = em.unwrap(Session.class); session.enableFilter("tenantFilter") .setParameter("tenantId", tenantId); return true; } } ``` **Invariants enforced:** - `tenant_id` is set at `@PrePersist` — never accepted from user input - `tenant_id` is `updatable = false` — cannot be changed after creation - Hibernate filter is enabled on every request thread before any query executes - All repository methods inherit the filter; raw JPQL queries must include `AND e.tenantId = :tenantId` --- ## 3. Authentication & Authorization ### JWT Token Flow - **Access token:** 8-hour expiry, signed HS256, contains `sub` (userId), `role`, `tenantId` - **Refresh token:** 30-day expiry, stored in `users.refresh_token_hash` (hashed) - **Stateless:** No server-side session. JWT is verified on every request by `JwtAuthFilter` ### Roles | Role | Description | Access | |---|---|---| | `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions | | `ROLE_MEMBER` | Club member | Own quota, own distribution history | | `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data | ### Service-Layer Authorization Example ```java @Service public class DistributionService { @PreAuthorize("hasRole('CLUB_ADMIN')") public Distribution recordDistribution(RecordDistributionRequest req) { ... } @PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId") public QuotaStatus getMyQuota(UUID memberId) { ... } @PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')") public List getUnder21Members() { ... } } ``` ### Member Login Sequence ```mermaid sequenceDiagram participant B as Browser participant API as Spring Boot /api/v1/auth/login participant DB as PostgreSQL (users table) participant JWT as JwtService B->>API: POST /api/v1/auth/login {email, password} API->>DB: SELECT * FROM users WHERE email = ? AND active = true DB-->>API: UserEntity (password_hash, role, tenant_id, member_id) API->>API: BCrypt.verify(password, password_hash) alt Invalid credentials API-->>B: 401 Unauthorized else Valid API->>JWT: generateAccessToken(userId, role, tenantId) → 8h API->>JWT: generateRefreshToken(userId) → 30d API->>DB: UPDATE users SET refresh_token_hash = ?, last_login = NOW() DB-->>API: OK JWT-->>API: accessToken, refreshToken API-->>B: 200 { accessToken, refreshToken, expiresIn: 28800 } end ``` --- ## 4. Data Model (JPA Entities) ### Entity-Relationship Diagram ```mermaid erDiagram Club { UUID id PK UUID tenant_id string name string address string license_number int max_members timestamp created_at enum status } Member { UUID id PK UUID tenant_id UUID club_id FK string first_name string last_name string email date date_of_birth date membership_date string membership_number enum status boolean is_under_21 boolean prevention_officer } Strain { UUID id PK UUID tenant_id string name decimal thc_percentage decimal cbd_percentage string description } Batch { UUID id PK UUID tenant_id UUID strain_id FK decimal quantity_grams date harvest_date string batch_code enum status boolean contamination_flag } Distribution { UUID id PK UUID tenant_id UUID member_id FK UUID batch_id FK decimal quantity_grams timestamp distributed_at UUID recorded_by FK string notes boolean immutable } MonthlyQuota { UUID id PK UUID tenant_id UUID member_id FK int year int month decimal total_distributed decimal max_allowed } StockMovement { UUID id PK UUID tenant_id UUID batch_id FK enum movement_type decimal quantity_grams string reason timestamp created_at } User { UUID id PK UUID tenant_id UUID member_id FK string email string password_hash enum role timestamp last_login boolean active } Club ||--o{ Member : "has members" Member ||--o{ Distribution : "receives" Member ||--o{ MonthlyQuota : "has quota per month" Member ||--o| User : "may have login" Strain ||--o{ Batch : "cultivated as" Batch ||--o{ Distribution : "distributed via" Batch ||--o{ StockMovement : "tracked in" Member ||--o{ Distribution : "recorded_by (admin)" ``` ### Relationship Notes | Relationship | Cardinality | Notes | |---|---|---| | Club → Member | 1:N | `member.club_id` FK; max enforced by `Club.max_members` | | Member → Distribution | 1:N | Each distribution targets one member | | Member → MonthlyQuota | 1:N | One row per `(member_id, year, month)` — UNIQUE constraint | | Member → User | 1:0..1 | A member may have a portal login; admins may have no `member_id` | | Strain → Batch | 1:N | Each batch is one strain; a strain can have many batches | | Batch → Distribution | 1:N | A batch can supply many distributions | | Batch → StockMovement | 1:N | Every IN/OUT/RECALL against a batch is journaled | | Distribution.recorded_by → Member | N:1 | The admin who recorded it (audit trail) | ### Key Constraints - `Distribution.immutable = true` by default — records are append-only; no UPDATE/DELETE allowed via API - `MonthlyQuota` has `UNIQUE(member_id, year, month)` — enforced at DB level - `Batch.contamination_flag` triggers recall workflow; `Batch.status = RECALLED` is the final state - All `tenant_id` columns: `NOT NULL`, `updatable = false`, set via `@PrePersist` - `Member.is_under_21` is derived from `date_of_birth` at registration and re-evaluated on birthday (scheduled job) --- ## 5. API Layer Design ### Base Path: `/api/v1/` All endpoints are RESTful. JSON request/response bodies. JWT Bearer token required on all except `/auth/**`. | Controller | Base Path | Key Endpoints | |---|---|---| | `AuthController` | `/api/v1/auth` | `POST /login`, `POST /refresh`, `POST /logout` | | `ClubController` | `/api/v1/clubs` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}` | | `MemberController` | `/api/v1/members` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status`, `GET /me/quota` | | `DistributionController` | `/api/v1/distributions` | `POST /`, `GET /?memberId=&month=&year=` | | `StockController` | `/api/v1/stock` | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` | | `ReportController` | `/api/v1/reports` | `GET /monthly?month=&year=` (PDF + CSV), `GET /members/{id}/history` | | `QuotaController` | `/api/v1/quota` | `GET /members/{id}/current`, `GET /members/{id}/history` | ### Standard HTTP conventions - `201 Created` + `Location` header on resource creation - `400 Bad Request` with `{ error, message, field? }` on validation failure - `403 Forbidden` when role/tenant check fails - `422 Unprocessable Entity` when compliance limits are breached (quota exceeded) - Pagination: `?page=0&size=20&sort=field,asc` --- ## 6. Compliance Engine The `ComplianceService` is the heart of regulatory enforcement. It is called synchronously before every distribution is persisted. All operations run in a single `@Transactional` block with optimistic locking to prevent race conditions under concurrent distribution recording. ```java @Service @Transactional public class ComplianceService { /** * Validates whether a distribution is legally permitted. * * Checks: * 1. Member is ACTIVE (not SUSPENDED or EXPELLED) * 2. Daily limit: total distributed today + requestedGrams ≤ 25g * 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed * where max_allowed = 30g (under-21) or 50g (adult) * 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED) * 5. Batch has sufficient stock * * @throws ComplianceLimitExceededException with remaining quota details * @throws MemberIneligibleException if member is not ACTIVE * @throws BatchUnavailableException if batch is recalled or exhausted */ public ComplianceCheckResult checkDistributionAllowed( UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... } /** * Returns remaining quota for the current calendar month. * Creates a MonthlyQuota row if none exists (lazy initialization). * * @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 } */ public QuotaStatus getMonthlyRemaining(UUID memberId) { ... } /** * Flags a batch as RECALLED. * Returns all members who received distributions from this batch * so the caller can trigger notifications. * Writes a StockMovement(RECALL) entry. * * @return List { memberId, name, email, totalReceived } */ public List recallBatch(UUID batchId) { ... } } ``` ### Race Condition Prevention `MonthlyQuota` rows carry a JPA `@Version` column (optimistic locking). If two concurrent requests attempt to increment `total_distributed` simultaneously, one will receive an `OptimisticLockException` and must retry. The retry logic is handled by a `@Retryable` annotation (Spring Retry, max 3 attempts, 50ms backoff). ```java @Entity public class MonthlyQuota extends AbstractTenantEntity { @Version private Long version; // optimistic lock // ... other fields } ``` --- ## 7. Infrastructure (Hetzner) ```mermaid graph TD Dev["👨‍💻 Developer (Fedora Workstation)"] Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"] Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"] Dev -->|git push| Gitea Gitea -->|SSH deploy script\n(webhook → deploy.sh)| Hetzner subgraph Hetzner Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"] App["☕ cannamanage-app\n(Spring Boot JAR)"] DB[("🐘 cannamanage-db\nPostgreSQL 16")] Nginx -->|proxy_pass :8080| App App -->|JDBC :5432| DB end Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx ``` ### Docker Compose Services ```yaml # docker-compose.yml (abbreviated) services: cannamanage-app: image: cannamanage:latest environment: SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage JWT_SECRET: ${JWT_SECRET} STRIPE_API_KEY: ${STRIPE_API_KEY} depends_on: [cannamanage-db] ports: ["127.0.0.1:8080:8080"] cannamanage-db: image: postgres:16-alpine volumes: [pgdata:/var/lib/postgresql/data] environment: POSTGRES_DB: cannamanage POSTGRES_PASSWORD: ${DB_PASSWORD} cannamanage-nginx: image: nginx:alpine ports: ["443:443", "80:80"] volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - /etc/letsencrypt:/etc/letsencrypt:ro ``` ### Hetzner Sizing | Resource | Spec | Rationale | |---|---|---| | Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP | | Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups | | Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days | | Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany | | TLS | Let's Encrypt via Certbot | Auto-renew via cron | ### Deployment Workflow ``` git push origin main → Gitea webhook fires → deploy.sh on Hetzner: docker pull cannamanage:latest docker compose up -d --no-deps cannamanage-app # zero-downtime: Nginx buffers requests during restart ``` Flyway migrations run automatically on application startup (`spring.flyway.enabled=true`). Migration files live at `src/main/resources/db/migration/V*.sql`. --- ## 8. Key Design Decisions | Decision | Choice | Rationale | |---|---|---| | Multi-tenancy | Shared schema + `tenant_id` | MVP simplicity; upgrade to schema-per-tenant possible later | | Frontend MVP | PrimeFaces JSF | Patrick's existing expertise; fastest path to working UI | | Frontend v2 | Next.js / React | Modern UX; deferred to avoid scope creep in MVP | | Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready | | PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts | | Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates | | Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance | | Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |