Files
pi_mcps/docs/wiki/pages/CannaManage-03-Architecture.md
T

20 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 · React/Vite (MVP) → Next.js v2 Last updated: 2026-04-06


1. Architecture Overview

graph TD
    AdminBrowser["🖥️ Browser — Admin Portal"]
    MemberBrowser["🖥️ Browser — Member Portal"]

    Frontend["React/Vite Frontend\n(SPA — served by Nginx)"]

    AdminBrowser -->|HTTPS| Frontend
    MemberBrowser -->|HTTPS| Frontend

    Frontend -->|REST/JSON| 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

    Frontend --> Nginx
    Nginx --> Backend

Component Responsibilities

Component Technology Role
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
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

Decision: Schema-Per-Tenant

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

HTTP Request
    └─ Spring Security Filter: extract JWT → resolve tenant_id
         └─ TenantContext.setCurrentTenant(tenantId)   // ThreadLocal
              └─ JPA @Where filter applied on every entity query

Code Pattern — Schema Routing DataSource

// TenantRoutingDataSource.java (pseudocode)
public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();  // returns tenant schema name
    }
}
// TenantInterceptor.java (pseudocode)
@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest req, ...) {
        String tenantId = JwtUtils.extractTenantId(req);
        TenantContext.setCurrentTenant("tenant_" + tenantId);
        return true;
    }
}

Invariants enforced:

  • 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

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, 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.

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

@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 = 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.

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