wiki: add 03 Architecture
+550
@@ -0,0 +1,550 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```java
|
||||
// TenantRoutingDataSource.java (pseudocode)
|
||||
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
return TenantContext.getCurrentTenant(); // returns tenant schema name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 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.
|
||||
|
||||
```java
|
||||
// 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
|
||||
|
||||
```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 | 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 |
|
||||
Reference in New Issue
Block a user