# 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 |
