Files
pi_mcps/docs/wiki/pages/CannaManage-03-Architecture.md
T
Patrick Plate cda8946c75 docs(cannamanage): add CannaManage wiki pages and mockup images
- 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
2026-04-06 11:21:35 +02:00

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

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

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