docs(cannamanage): architecture revision — schema-per-tenant, React/Vite, staff roles, Mermaid fixes, grow calendar, TrueNAS CI
@@ -464,4 +464,41 @@
|
||||
|
||||
---
|
||||
|
||||
## Could Have — v2 (Additions)
|
||||
|
||||
### US-026: Staff Member Management
|
||||
|
||||
**As a** Club Admin, **I want to** create staff accounts with configurable permissions, **so that** my team members can do their work without having access to data they don't need (DSGVO principle of least privilege).
|
||||
|
||||
**Priority:** Must Have (upgraded from Could Have — see note)
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can create staff accounts with email + temporary password
|
||||
- [ ] AC2: Admin assigns permissions per staff account from a defined permission set (`RECORD_DISTRIBUTION`, `VIEW_MEMBER_LIST`, `VIEW_MEMBER_QUOTA`, `ADD_MEMBER`, `VIEW_STOCK`, `RECORD_STOCK_IN`, `VIEW_COMPLIANCE_REPORT`, `MANAGE_GROW_CALENDAR`)
|
||||
- [ ] AC3: Pre-created role templates available: **Ausgabe** (distribution desk), **Lager** (stock/cultivation), **Vorstand** (board member)
|
||||
- [ ] AC4: Staff accounts cannot access billing, club settings, or staff management
|
||||
- [ ] AC5: All distributions recorded by staff include `recorded_by = staffUserId` in audit trail
|
||||
- [ ] AC6: Admin can deactivate a staff account; historical data is retained for audit purposes
|
||||
- [ ] AC7: Staff member sees only the navigation sections permitted by their granted permissions
|
||||
|
||||
> **Note:** Promoted to core / Must Have. Staff management is not a v2 feature — clubs have multiple people involved from day one. DSGVO requires that each person only accesses data relevant to their function. Designing this post-MVP would require schema, API, and permission model rework.
|
||||
|
||||
---
|
||||
|
||||
### US-027: Grow Calendar
|
||||
|
||||
**As a** Club Admin or authorised staff member, **I want to** maintain a cultivation calendar for each grow cycle, **so that** the club has a central record of what was planted, when to expect harvest, and the grow diary with notes and photos.
|
||||
|
||||
**Priority:** Could Have (v2)
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin/staff can create a grow entry with: strain name, planted date, expected harvest date, grow medium, notes
|
||||
- [ ] AC2: Grow entries are linked to a batch — when the harvest is registered as a batch, the grow entry is marked as completed
|
||||
- [ ] AC3: A grow diary allows adding timestamped notes and optional photos per grow entry
|
||||
- [ ] AC4: Grow calendar view shows a visual timeline of active grow cycles (Gantt-style or calendar grid)
|
||||
- [ ] AC5: Admin can set who has access to the grow calendar via staff permission `MANAGE_GROW_CALENDAR`
|
||||
- [ ] AC6: Photos are stored per-tenant and never exposed to members or other tenants
|
||||
|
||||
**Notes:** The grow calendar bridges cultivation management and compliance — it provides provenance traceability from seed/clone to distributed batch. This directly supports §26 CanG batch traceability requirements for the origin of cultivated product. Photo attachments are a nice-to-have within this story; the core diary functionality is the v2 deliverable.
|
||||
|
||||
---
|
||||
|
||||
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
|
||||
|
||||
+93
-47
@@ -2,7 +2,7 @@
|
||||
|
||||
**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
|
||||
**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2
|
||||
**Last updated:** 2026-04-06
|
||||
|
||||
---
|
||||
@@ -14,12 +14,12 @@ graph TD
|
||||
AdminBrowser["🖥️ Browser — Admin Portal"]
|
||||
MemberBrowser["🖥️ Browser — Member Portal"]
|
||||
|
||||
JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"]
|
||||
Frontend["React/Vite Frontend\n(SPA — served by Nginx)"]
|
||||
|
||||
AdminBrowser -->|HTTP/S| JSF
|
||||
MemberBrowser -->|HTTP/S| JSF
|
||||
AdminBrowser -->|HTTPS| Frontend
|
||||
MemberBrowser -->|HTTPS| Frontend
|
||||
|
||||
JSF -->|REST calls| Backend
|
||||
Frontend -->|REST/JSON| Backend
|
||||
|
||||
subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"]
|
||||
REST["REST API Layer\n/api/v1/"]
|
||||
@@ -45,7 +45,7 @@ graph TD
|
||||
Nginx["🔒 Nginx\n(reverse proxy + TLS)"]
|
||||
end
|
||||
|
||||
JSF --> Nginx
|
||||
Frontend --> Nginx
|
||||
Nginx --> Backend
|
||||
```
|
||||
|
||||
@@ -53,8 +53,8 @@ graph TD
|
||||
|
||||
| Component | Technology | Role |
|
||||
|---|---|---|
|
||||
| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI |
|
||||
| Member Portal | PrimeFaces JSF (→ Next.js v2) | Member quota & history UI |
|
||||
| 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 |
|
||||
@@ -69,15 +69,47 @@ graph TD
|
||||
|
||||
## 2. Multi-Tenancy Strategy
|
||||
|
||||
### Approach: Shared Schema with Row-Level Filtering
|
||||
### Decision: Schema-Per-Tenant
|
||||
|
||||
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.
|
||||
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 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
|
||||
**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
|
||||
|
||||
@@ -88,51 +120,38 @@ HTTP Request
|
||||
└─ JPA @Where filter applied on every entity query
|
||||
```
|
||||
|
||||
### Code Pattern — Tenant-Aware Base Entity
|
||||
### Code Pattern — Schema Routing DataSource
|
||||
|
||||
```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 {
|
||||
// TenantRoutingDataSource.java (pseudocode)
|
||||
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
@Column(name = "tenant_id", nullable = false, updatable = false)
|
||||
private UUID tenantId;
|
||||
|
||||
@PrePersist
|
||||
void injectTenant() {
|
||||
this.tenantId = TenantContext.getCurrentTenant();
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
return TenantContext.getCurrentTenant(); // returns tenant schema name
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// TenantFilterInterceptor.java (pseudocode)
|
||||
// TenantInterceptor.java (pseudocode)
|
||||
@Component
|
||||
public class TenantFilterInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Autowired EntityManager em;
|
||||
public class TenantInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req, ...) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Session session = em.unwrap(Session.class);
|
||||
session.enableFilter("tenantFilter")
|
||||
.setParameter("tenantId", tenantId);
|
||||
String tenantId = JwtUtils.extractTenantId(req);
|
||||
TenantContext.setCurrentTenant("tenant_" + 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`
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -148,10 +167,36 @@ public class TenantFilterInterceptor implements HandlerInterceptor {
|
||||
|
||||
| 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_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
|
||||
@@ -494,11 +539,12 @@ Flyway migrations run automatically on application startup (`spring.flyway.enabl
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -143,7 +143,7 @@ flowchart TD
|
||||
|
||||
QUERY_DIST --> HAS_DATA{Any distributions\nin this period?}
|
||||
|
||||
HAS_DATA -->|No data| EMPTY_REPORT[Generate empty report\nwith zero totals\n(still valid compliance submission)]
|
||||
HAS_DATA -->|No data| EMPTY_REPORT["Generate empty report\nwith zero totals\n(still valid compliance submission)"]
|
||||
HAS_DATA -->|Yes| AGG_MEMBER["Aggregate by member:\n• total_distributed_grams\n• number_of_visits\n• quota_usage_percent\n• is_under_21 flag"]
|
||||
|
||||
EMPTY_REPORT --> AGG_STRAIN
|
||||
@@ -174,7 +174,7 @@ flowchart TD
|
||||
SUBMIT --> FIND_USER["🔍 Spring Security:\nSELECT FROM users\nWHERE email = ?\nAND active = true"]
|
||||
|
||||
FIND_USER --> USER_FOUND{User found?}
|
||||
USER_FOUND -->|No| ERR_NOTFOUND[❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)]
|
||||
USER_FOUND -->|No| ERR_NOTFOUND["❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)"]
|
||||
USER_FOUND -->|Yes| VERIFY_PW{BCrypt.verify\n(password, hash)\nmatches?}
|
||||
|
||||
VERIFY_PW -->|No| ERR_PW[❌ Invalid credentials]
|
||||
|
||||
+145
-75
@@ -2,7 +2,7 @@
|
||||
|
||||
**Phase 4a | Document 6 of 7**
|
||||
**Date:** 2026-04-06
|
||||
**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL
|
||||
**Stack:** Spring Boot 3.x · React/Vite SPA · PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
@@ -45,24 +45,26 @@
|
||||
|
||||
### 1.3 Component Library
|
||||
|
||||
All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP.
|
||||
The frontend is a **React/Vite SPA** with no PrimeFaces or JSF dependency. Component primitives come from [shadcn/ui](https://ui.shadcn.com/) (Radix UI + Tailwind CSS). This gives full control over styling, accessibility, and mobile responsiveness without JSF's lifecycle overhead.
|
||||
|
||||
| Component | Usage |
|
||||
|---|---|
|
||||
| `p:panel` | Section containers, card wrappers |
|
||||
| `p:dataTable` with `p:column` | Tabular data: distributions, members, batches |
|
||||
| `p:paginator` | Pagination on all tables |
|
||||
| `p:inputText` | Single-line text fields |
|
||||
| `p:inputNumber` | Weight inputs (gram precision) |
|
||||
| `p:selectOneMenu` | Dropdown selects (member, strain, batch) |
|
||||
| `p:calendar` | Date range pickers for reports |
|
||||
| `p:progressBar` | Quota consumption display |
|
||||
| `p:commandButton` | Primary and secondary actions |
|
||||
| `p:confirmDialog` | Dangerous actions (recall, delete) |
|
||||
| `p:messages` / `p:message` | Inline validation errors |
|
||||
| `p:badge` | Status indicators (AVAILABLE, LOW, RECALLED) |
|
||||
| `p:sidebar` | Mobile nav drawer (member portal) |
|
||||
| `p:dialog` | Modal overlays |
|
||||
> **Why not PrimeFaces?** JSF/PrimeFaces is a server-side component model ill-suited to the modern REST API backend we're building. It tightly couples UI lifecycle to the backend, makes mobile responsiveness painful, and creates a hiring bottleneck. React is the right tool here. PrimeFaces is a fine choice for internal enterprise apps — not for a commercial SaaS.
|
||||
|
||||
| Component | Library | Usage |
|
||||
|---|---|---|
|
||||
| `Card` / `Panel` | shadcn/ui | Section containers |
|
||||
| `DataTable` | TanStack Table v8 | Distributions, members, batches — virtualized |
|
||||
| `Pagination` | shadcn/ui Pagination | All tables |
|
||||
| `Input` | shadcn/ui Input | Single-line text fields |
|
||||
| `NumberInput` | react-number-format | Weight inputs (gram precision, min/max) |
|
||||
| `Select` | shadcn/ui Select | Dropdown selects (member, strain, batch) |
|
||||
| `DatePicker` | shadcn/ui Calendar | Date range pickers for reports |
|
||||
| `Progress` | shadcn/ui Progress | Quota consumption bar |
|
||||
| `Button` | shadcn/ui Button | Primary and secondary actions |
|
||||
| `AlertDialog` | shadcn/ui AlertDialog | Dangerous actions (recall) |
|
||||
| `Toast` | sonner | Success/error notifications |
|
||||
| `Badge` | shadcn/ui Badge | Status indicators (AVAILABLE, LOW, RECALLED) |
|
||||
| `Sheet` | shadcn/ui Sheet | Mobile nav drawer |
|
||||
| `Dialog` | shadcn/ui Dialog | Modal overlays |
|
||||
|
||||
### 1.4 Layout Grid
|
||||
|
||||
@@ -118,13 +120,13 @@ All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/A
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s |
|
||||
| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail |
|
||||
| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` |
|
||||
| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` |
|
||||
| Trend indicators | Custom CSS `<span>` | Green ▲ / Red ▼ with delta value |
|
||||
| KPI Cards | shadcn/ui Card | Auto-refreshed via `useQuery` (react-query, 60s stale) |
|
||||
| Recent Distributions table | TanStack Table (5 rows) | Row click → navigate to distribution detail |
|
||||
| Member column link | React Router `<Link>` | Navigate to `/admin/members/{id}` |
|
||||
| `+ New Entry` button | shadcn/ui Button variant="default" | Navigate to `/admin/distributions/new` |
|
||||
| Trend indicators | Tailwind `text-green-600` / `text-red-600` | ▲/▼ with delta value |
|
||||
|
||||
---
|
||||
|
||||
@@ -178,14 +180,14 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Member search | `p:selectOneMenu` with `p:ajax` filter | Filters on type, shows name + member no. |
|
||||
| Strain/Batch dropdown | `p:selectOneMenu` | Populated after member selection; shows only `AVAILABLE` batches |
|
||||
| Weight input | `p:inputNumber` min=`0.1` max=`25.0` step=`0.1` | Triggers quota recalculation on blur |
|
||||
| Quota bar | `p:progressBar` with dynamic `value` | Color class applied via `styleClass` computed in backing bean |
|
||||
| Submit | `p:commandButton` | Disabled via `disabled="#{bean.quotaExceeded}"` |
|
||||
| Cancel | `p:link` | Returns to distribution log without saving |
|
||||
| Member search | shadcn/ui Combobox | `useQuery` debounced search; shows name + member no. |
|
||||
| Strain/Batch dropdown | shadcn/ui Select | Populated after member selection; filters `AVAILABLE` batches |
|
||||
| Weight input | react-number-format | min=0.1 max=25.0 step=0.1; triggers quota recalculation via `onChange` |
|
||||
| Quota bar | shadcn/ui Progress | Color class via `cn()` utility computed in component state |
|
||||
| Submit | shadcn/ui Button | `disabled={quotaExceeded}` from react state |
|
||||
| Cancel | React Router `<Link>` | Returns to distribution log without saving |
|
||||
|
||||
---
|
||||
|
||||
@@ -229,15 +231,15 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup |
|
||||
| Status filter | `p:selectOneMenu` | Filters table rows by status value |
|
||||
| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page |
|
||||
| Status badge | Custom CSS `<span class="badge badge-{status}">` | Icon + text label (not color alone) |
|
||||
| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing |
|
||||
| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
|
||||
| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form |
|
||||
| Strain filter | shadcn/ui Input | Filters TanStack table client-side via `columnFilters` state |
|
||||
| Status filter | shadcn/ui Select | Filters table rows by status value |
|
||||
| Batch table | TanStack Table | Server-side pagination via `manualPagination`, 10 rows/page |
|
||||
| Status badge | shadcn/ui Badge variant mapped | Icon + text label (not color alone) |
|
||||
| Recall button | shadcn/ui Button variant="destructive" | Opens shadcn/ui AlertDialog before executing |
|
||||
| Confirm dialog | shadcn/ui AlertDialog | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
|
||||
| Add Batch | shadcn/ui Button | Opens shadcn/ui Dialog with batch entry form |
|
||||
|
||||
---
|
||||
|
||||
@@ -287,15 +289,15 @@ The quota progress bar updates live as the weight field changes (via `f:ajax eve
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Month selector | `p:selectOneMenu` | Months Jan–Dec |
|
||||
| Year selector | `p:selectOneMenu` | Current year ± 2 |
|
||||
| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail |
|
||||
| PDF preview | `<iframe>` embedding `/report/preview?month=3&year=2026` | Generated by iText 7 in `cannamanage-report` module |
|
||||
| Download PDF | `p:commandButton` | Streams PDF response from REST endpoint |
|
||||
| Download CSV | `p:commandButton` | Streams CSV response (member-level data) |
|
||||
| Summary table | `p:dataTable` | Computed compliance metrics; zero violations = green row |
|
||||
| Month selector | shadcn/ui Select | Months Jan–Dec |
|
||||
| Year selector | shadcn/ui Select | Current year ± 2 |
|
||||
| Generate button | shadcn/ui Button | Calls report API; shows loading spinner; renders PDF thumbnail |
|
||||
| PDF preview | `<iframe>` embedding `/api/v1/reports/preview?month=3&year=2026` | Generated by iText 7 backend |
|
||||
| Download PDF | shadcn/ui Button | `window.open(reportUrl)` — streams PDF from REST endpoint |
|
||||
| Download CSV | shadcn/ui Button | `window.open(csvUrl)` — streams CSV from REST endpoint |
|
||||
| Summary table | TanStack Table | Compliance metrics; zero violations row has `text-green-600` |
|
||||
|
||||
---
|
||||
|
||||
@@ -354,12 +356,12 @@ Quantities, batch codes, and THC/CBD percentages are **not exposed** in the memb
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Quota circle | Custom CSS radial progress (`conic-gradient`) | Computed from monthly total; color matches threshold rules |
|
||||
| Quota bar | `p:progressBar` | Same color logic as admin distribution form |
|
||||
| History table | `p:dataTable` | Last 10 distributions; sorted newest first; no pagination in MVP |
|
||||
| Strains table | `p:dataTable` | `status` column: text + icon only, no quantities |
|
||||
| Quota bar | shadcn/ui Progress | Same color logic as admin distribution form |
|
||||
| History table | TanStack Table | Last 10 distributions; sorted newest first; no pagination in MVP |
|
||||
| Strains table | TanStack Table | `status` column: text + icon only, no quantities |
|
||||
|
||||
---
|
||||
|
||||
@@ -367,6 +369,64 @@ Quantities, batch codes, and THC/CBD percentages are **not exposed** in the memb
|
||||
|
||||
> *No mockup image — ASCII wireframe only.*
|
||||
|
||||
---
|
||||
|
||||
### Screen 7 — Staff Management (Admin)
|
||||
|
||||
> *Core feature — not deferred to v2.*
|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
|
||||
├────────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ Settings › Staff Members [+ Add Staff] │
|
||||
│ 📊 Dashbrd│ │
|
||||
│ │ ┌──────────────────────────────────────────────────┐ │
|
||||
│ 👥 Members│ │ Name │ Role Template │ Permissions │ Act│ │
|
||||
│ │ ├─────────────────┼───────────────┼─────────────┼────┤ │
|
||||
│ 📋 Distrib│ │ Lisa Schmidt │ Ausgabe │ 3 of 8 │[✎][⛔]│
|
||||
│ │ │ Tom Weber │ Lager │ 4 of 8 │[✎][⛔]│
|
||||
│ 📦 Stock │ │ Sandra Müller │ Vorstand │ 7 of 8 │[✎][⛔]│
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
│ 📄 Reports│ │
|
||||
│ │ ┌─── Add / Edit Staff ──────────────────────────────┐ │
|
||||
│ ✅ Complian│ │ Name: _______________ Email: _______________ │ │
|
||||
│ │ │ │ │
|
||||
│ 👤 Staff │ │ Role Template: [ Ausgabe ▼ ] (pre-fills below) │ │
|
||||
│ │ │ │ │
|
||||
│ ⚙ Settings│ │ Permissions: │ │
|
||||
│ │ │ ☑ Record Distribution ☑ View Member List │ │
|
||||
│ │ │ ☑ View Member Quota ☐ Add Member │ │
|
||||
│ │ │ ☐ View Stock ☐ Record Stock In │ │
|
||||
│ │ │ ☐ View Compliance Report ☐ Manage Grow Calendar │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ [ Save Staff Member ] [ Cancel ] │ │
|
||||
│ │ └────────────────────────────────────────────────────┘ │
|
||||
└────────────┴────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Design Decisions
|
||||
|
||||
- **Admin sees everything.** The staff management screen is only accessible with `ROLE_CLUB_ADMIN`. Staff accounts cannot modify their own permissions.
|
||||
- **DSGVO principle of least privilege.** Each staff member only sees the data their role requires. A distribution desk worker (`Ausgabe`) does not see cultivation calendar or full stock levels — only what they need to hand out product.
|
||||
- **Pre-created role templates** reduce admin setup time. Templates are editable — they just pre-fill the permission checkboxes.
|
||||
- **Staff ≠ reduced admin.** Staff accounts do not have access to billing, club settings, or staff management. Even a "Vorstand" staff member cannot create other staff accounts.
|
||||
- **Audit trail.** All distributions recorded by staff include `recorded_by = staffUserId` so it's clear who did what.
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Staff table | TanStack Table | Shows name, role template, permission count, actions |
|
||||
| Role template dropdown | shadcn/ui Select | Pre-populates permission checkboxes on selection |
|
||||
| Permission checkboxes | shadcn/ui Checkbox | Individual overrides after template selection |
|
||||
| Save | shadcn/ui Button | POST/PUT `/api/v1/staff` with `{ permissions: [...] }` |
|
||||
| Deactivate | shadcn/ui Button variant="destructive" | Soft-deletes staff account; data retained for audit |
|
||||
|
||||
---
|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
@@ -403,12 +463,12 @@ Quantities, batch codes, and THC/CBD percentages are **not exposed** in the memb
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
| Component | Library | Behavior |
|
||||
|---|---|---|
|
||||
| Email field | `p:inputText` with `required="true"` | Bean Validation `@Email` |
|
||||
| Password field | `p:password` feedback=`false` | No strength meter on login |
|
||||
| Login button | `p:commandButton` | Submit form; shows `p:messages` on failure |
|
||||
| Error message | `p:messages` | "Invalid email or password." (never specific about which field failed) |
|
||||
| Email field | shadcn/ui Input type="email" | HTML5 validation + react-hook-form `@Email` |
|
||||
| Password field | shadcn/ui Input type="password" | No strength meter on login |
|
||||
| Login button | shadcn/ui Button | Submit via react-hook-form; shows error toast on failure |
|
||||
| Error message | sonner toast | "Invalid email or password." (never specific about which field failed) |
|
||||
|
||||
---
|
||||
|
||||
@@ -419,6 +479,7 @@ graph TD
|
||||
Root["CannaManage Root"]
|
||||
Root --> AdminPortal["Admin Portal /admin/"]
|
||||
Root --> MemberPortal["Member Portal /member/"]
|
||||
Root --> StaffPortal["Staff Portal /staff/"]
|
||||
|
||||
AdminPortal --> AdminDash["Dashboard (default)"]
|
||||
AdminPortal --> Members["Members"]
|
||||
@@ -436,9 +497,14 @@ graph TD
|
||||
Reports --> RecallReport["Batch Recall Report"]
|
||||
AdminPortal --> Compliance["Compliance"]
|
||||
Compliance --> PreventionOfficer["Prevention Officer Info"]
|
||||
AdminPortal --> StaffMgmt["Staff Members"]
|
||||
StaffMgmt --> StaffList["Staff List"]
|
||||
StaffMgmt --> StaffNew["Add/Edit Staff"]
|
||||
AdminPortal --> Settings["Settings"]
|
||||
Settings --> ClubProfile["Club Profile"]
|
||||
|
||||
StaffPortal --> StaffDash["Staff Dashboard\n(permissions-filtered)"]
|
||||
|
||||
MemberPortal --> MemberDash["Dashboard / Quota"]
|
||||
MemberPortal --> DistHistory["Distribution History"]
|
||||
MemberPortal --> StockAvail["Stock Availability"]
|
||||
@@ -460,7 +526,11 @@ graph TD
|
||||
| `/admin/reports/members` | Member data export | `ROLE_ADMIN` |
|
||||
| `/admin/reports/recall` | Recall report | `ROLE_ADMIN` |
|
||||
| `/admin/compliance` | Prevention officer | `ROLE_ADMIN` |
|
||||
| `/admin/staff` | Staff list | `ROLE_ADMIN` |
|
||||
| `/admin/staff/new` | Create staff account | `ROLE_ADMIN` |
|
||||
| `/admin/staff/{id}` | Edit staff permissions | `ROLE_ADMIN` |
|
||||
| `/admin/settings` | Club settings | `ROLE_ADMIN` |
|
||||
| `/staff/dashboard` | Staff home (permissions-filtered) | `ROLE_STAFF` |
|
||||
| `/member/dashboard` | Member quota view | `ROLE_MEMBER` |
|
||||
| `/member/distributions` | Personal history | `ROLE_MEMBER` |
|
||||
| `/member/stock` | Strain availability | `ROLE_MEMBER` |
|
||||
@@ -469,34 +539,34 @@ graph TD
|
||||
|
||||
## 5. Responsive Design Notes
|
||||
|
||||
### MVP (v1) — Desktop-First
|
||||
### MVP (v1) — Tailwind Breakpoints
|
||||
|
||||
Target viewport: **1024px+**. PrimeFaces responsive grid (`p:panelGrid` with responsive columns, `ui-g-12 ui-md-6 ui-lg-4`) handles most layout adaptation down to tablet without custom media queries.
|
||||
The React/Vite SPA uses **Tailwind CSS** breakpoints throughout. The switch from PrimeFaces means we no longer depend on JSF's `ui-g-*` responsive grid — Tailwind's `sm:` / `md:` / `lg:` utilities apply cleanly to every component.
|
||||
|
||||
| Breakpoint | Behavior |
|
||||
|---|---|
|
||||
| `≥ 1280px` | Full layout — sidebar + content side-by-side |
|
||||
| `1024–1279px` | Sidebar collapses to icon-only (60px); tooltips on hover |
|
||||
| `768–1023px` | Sidebar hidden; hamburger menu in top navbar |
|
||||
| `< 768px` | Admin portal degraded (tables scroll horizontally) |
|
||||
| Breakpoint | Tailwind prefix | Admin Portal | Member Portal |
|
||||
|---|---|---|---|
|
||||
| `≥ 1280px` | `xl:` | Full layout — sidebar + content | Two-column: quota left, history right |
|
||||
| `1024–1279px` | `lg:` | Sidebar collapses to icons (60px) | Two-column (narrower) |
|
||||
| `768–1023px` | `md:` | Sidebar hidden; hamburger sheet | Single-column, full-width cards |
|
||||
| `< 768px` | `sm:` / base | Admin: horizontal table scroll | Member: compact quota ring, condensed table |
|
||||
|
||||
### Member Portal — Mobile-First from Day One
|
||||
|
||||
Members will typically check quota status on their phone. The member portal is designed mobile-first regardless of MVP/v2 timeline.
|
||||
Members will typically check quota status on their phone. The member portal uses `flex-col` mobile-first layout with `md:flex-row` for wider viewports — no breakpoint-specific class sprawl.
|
||||
|
||||
| Breakpoint | Behavior |
|
||||
|---|---|
|
||||
| `≥ 1024px` | Two-column layout: quota circle left, history right |
|
||||
| `768–1023px` | Single-column, full-width cards |
|
||||
| `375–767px` | Single-column, compact quota ring, condensed table |
|
||||
| `< 375px` | Minimum supported; no horizontal scroll |
|
||||
### Responsive Conventions (React/Tailwind)
|
||||
|
||||
- No inline styles — use Tailwind utilities exclusively
|
||||
- `cn()` utility (clsx + tailwind-merge) for conditional class composition
|
||||
- Tables on mobile: horizontal scroll wrapper `overflow-x-auto` on `<div>` wrapping `<table>`
|
||||
- All modals and sheets use `shadcn/ui Dialog` / `Sheet` — these are already mobile-friendly (viewport-aware positioning)
|
||||
- Touch targets: all interactive elements `min-h-[44px]` and `min-w-[44px]` per WCAG 2.5.5
|
||||
|
||||
### v2 Roadmap
|
||||
|
||||
- PWA manifest + service worker (offline quota display)
|
||||
- 768px and 375px explicit breakpoints with design tokens
|
||||
- Touch-friendly `p:sidebar` for mobile member nav
|
||||
- Push notifications for low quota warnings
|
||||
- Per-club subdomain routing (`clubname.cannamanage.de`)
|
||||
|
||||
---
|
||||
|
||||
@@ -516,11 +586,11 @@ CannaManage targets **WCAG 2.1 AA** compliance across both portals.
|
||||
|
||||
### Screen Reader Support
|
||||
|
||||
- All `p:inputText` / `p:inputNumber` fields have `<label>` with `for` attribute
|
||||
- All `Input` / `NumberInput` fields have `<label>` with `htmlFor` (React) — Radix UI enforces this automatically for shadcn/ui form fields
|
||||
- `aria-label` set on icon-only buttons (e.g., recall action column)
|
||||
- `aria-live="polite"` region on quota bar — announces percentage changes
|
||||
- `aria-describedby` links compliance warning messages to the weight input
|
||||
- PrimeFaces generates `role="grid"` and `aria-rowcount` on all data tables
|
||||
- TanStack Table exposes `role="grid"` and `aria-rowcount` via `getTableProps()`
|
||||
|
||||
### Color Independence
|
||||
|
||||
|
||||
+71
-34
@@ -3,7 +3,7 @@
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
|
||||
**Version:** 0.1.0-PLAN
|
||||
**Date:** 2026-04-06
|
||||
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose
|
||||
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose (Release) | TrueNAS.local — Docker (Build/CI)
|
||||
|
||||
---
|
||||
|
||||
@@ -45,20 +45,42 @@ Wildcard A record enables future per-club subdomains (`clubname.cannamanage.de`)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Internet["🌐 Internet"] -->|"port 80/443"| Nginx["Nginx (reverse proxy)"]
|
||||
Nginx -->|"http://app:8080"| App["cannamanage-app\n(Spring Boot 3.x)"]
|
||||
App -->|"jdbc:postgresql://db:5432"| DB["PostgreSQL 16\n(cannamanage DB)"]
|
||||
LetsEncrypt["Let's Encrypt\n(certbot auto-renew)"] -.->|"TLS cert"| Nginx
|
||||
Gitea["Gitea Actions\n(homelab CI)"] -->|"SSH + docker compose"| VPS["Hetzner VPS\n/opt/cannamanage"]
|
||||
Dev["👨💻 Dev Workstation\n(Fedora, 192.168.188.x)"]
|
||||
Gitea["🏠 Gitea\n(truenas.local:30008)"]
|
||||
TrueNAS["🖧 TrueNAS.local Docker\n(192.168.188.119)\nBuild + Staging"]
|
||||
Hetzner["☁️ Hetzner VPS CX21\nProduction Release"]
|
||||
|
||||
subgraph VPS ["Hetzner VPS — Docker network: cannamanage_net"]
|
||||
Nginx
|
||||
App
|
||||
DB
|
||||
Dev -->|"git push"| Gitea
|
||||
Gitea -->|"Gitea Actions runner\n(on TrueNAS.local)"| TrueNAS
|
||||
TrueNAS -->|"mvn package + docker build"| TrueNAS
|
||||
TrueNAS -->|"docker save | scp\n(on merge to main)"| Hetzner
|
||||
|
||||
subgraph TrueNAS ["TrueNAS.local — CI/CD Build Environment"]
|
||||
GiteaRunner["Gitea Actions Runner"]
|
||||
BuildCache["Maven .m2 cache\n(persistent volume)"]
|
||||
StagingDB["PostgreSQL staging\n(ephemeral)"]
|
||||
end
|
||||
|
||||
subgraph Hetzner ["Hetzner VPS — Production Release Environment"]
|
||||
Nginx["Nginx (reverse proxy + TLS)"]
|
||||
App["cannamanage-app\n(Spring Boot 3.x)"]
|
||||
DB["PostgreSQL 16\n(persistent pgdata volume)"]
|
||||
Nginx -->|"proxy_pass :8080"| App
|
||||
App -->|"JDBC :5432"| DB
|
||||
end
|
||||
|
||||
Internet["🌍 Internet HTTPS"] -->|"port 443"| Nginx
|
||||
```
|
||||
|
||||
All three services run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
|
||||
### Environment Roles
|
||||
|
||||
| Environment | Host | Purpose |
|
||||
|---|---|---|
|
||||
| **Development** | Dev workstation (Fedora) | Local feature development, unit tests |
|
||||
| **Build / CI** | TrueNAS.local Docker | Gitea Actions runner; Maven build; integration tests (Testcontainers); Docker image build |
|
||||
| **Production / Release** | Hetzner VPS CX21 | Live clubs, real data; Hetzner = our release environment |
|
||||
|
||||
All three services on Hetzner run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
|
||||
|
||||
---
|
||||
|
||||
@@ -351,12 +373,14 @@ curl https://app.cannamanage.de/actuator/health
|
||||
|
||||
---
|
||||
|
||||
## 6. CI/CD Pipeline (Gitea Actions)
|
||||
## 6. CI/CD Pipeline (Gitea Actions on TrueNAS.local)
|
||||
|
||||
The Gitea Actions runner runs **on TrueNAS.local** — this is our homelab build machine. It has Docker, a persistent Maven `.m2` cache volume, and direct SSH access to the Hetzner VPS. Builds happen locally; only the final artifact (Docker image tarball) is shipped to Hetzner.
|
||||
|
||||
**File:** `.gitea/workflows/deploy.yml`
|
||||
|
||||
```yaml
|
||||
name: Deploy to Production
|
||||
name: Build and Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -365,7 +389,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted # <-- TrueNAS.local Gitea runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -381,25 +405,18 @@ jobs:
|
||||
|
||||
- name: Run integration tests
|
||||
run: ./mvnw verify -P integration-tests
|
||||
# Testcontainers requires Docker — GitHub/Gitea hosted runners have Docker pre-installed
|
||||
# Testcontainers starts PostgreSQL via Docker on the TrueNAS runner
|
||||
|
||||
- name: Coverage gate check
|
||||
run: ./mvnw verify -P coverage-check
|
||||
|
||||
build-and-deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted # <-- TrueNAS.local Gitea runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build JAR
|
||||
- name: Build JAR (production profile)
|
||||
run: ./mvnw package -DskipTests -P production
|
||||
|
||||
- name: Build Docker image
|
||||
@@ -412,13 +429,13 @@ jobs:
|
||||
- name: Save Docker image
|
||||
run: docker save cannamanage:${{ github.sha }} | gzip > /tmp/cannamanage.tar.gz
|
||||
|
||||
- name: Copy image to VPS
|
||||
- name: Copy image to Hetzner VPS
|
||||
run: |
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
/tmp/cannamanage.tar.gz \
|
||||
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
|
||||
|
||||
- name: Deploy via SSH
|
||||
- name: Deploy via SSH to Hetzner (Production Release)
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.HETZNER_IP }} "
|
||||
set -e
|
||||
@@ -435,23 +452,43 @@ jobs:
|
||||
sleep 10
|
||||
docker compose ps app | grep 'healthy' || (docker compose logs app --tail=50 && exit 1)
|
||||
|
||||
# Prune old images (keep last 3)
|
||||
# Prune old images (keep last 3 SHAs)
|
||||
docker image prune -f
|
||||
"
|
||||
|
||||
- name: Cleanup local build artifact
|
||||
run: rm -f /tmp/cannamanage.tar.gz
|
||||
```
|
||||
|
||||
### Gitea Actions Runner on TrueNAS.local
|
||||
|
||||
The self-hosted runner is a Docker container on TrueNAS.local:
|
||||
|
||||
```bash
|
||||
# On TrueNAS.local — install Gitea Actions runner
|
||||
docker run -d \
|
||||
--name gitea-runner-cannamanage \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner/cannamanage:/data \
|
||||
-v /opt/gitea-runner/.m2:/root/.m2 \ # Maven cache persisted across builds
|
||||
-e GITEA_INSTANCE_URL=http://192.168.188.119:30008 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token-from-gitea-settings> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
### Required Gitea Repository Secrets
|
||||
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `HETZNER_IP` | VPS IPv4 address |
|
||||
| `SSH_PRIVATE_KEY` | Private key for `deploy` user |
|
||||
| Secret | Where set | Value |
|
||||
|--------|-----------|-------|
|
||||
| `HETZNER_IP` | Gitea repo secrets | Hetzner VPS IPv4 address |
|
||||
| `SSH_PRIVATE_KEY` | Gitea repo secrets | Private key for `deploy` user on Hetzner |
|
||||
|
||||
Add deploy user's public key to VPS authorized_keys:
|
||||
```bash
|
||||
# On VPS as deploy user
|
||||
# On Hetzner VPS — add TrueNAS runner's public key
|
||||
# (generate keypair on TrueNAS.local: ssh-keygen -t ed25519 -f ~/.ssh/gitea_runner_deploy)
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "<gitea-actions-public-key>" >> ~/.ssh/authorized_keys
|
||||
echo "<truenas-runner-public-key>" >> ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user