docs(cannamanage): update wiki pages and sprint plans + brand pipeline doc

This commit is contained in:
Patrick Plate
2026-06-11 09:02:14 +02:00
parent bf721c1379
commit 17d14aae09
8 changed files with 2181 additions and 162 deletions
+547
View File
@@ -0,0 +1,547 @@
# CannaManage — Sprint 1 Implementation Plan
**Sprint:** 1 — Foundation
**Phase:** Phase 1 (Weeks 18 of Phase 0 Foundation)
**Author:** Lumen (architect mode), 2026-04-10
**Status:** Ready for Patrick's approval
---
## Sprint Goal
> **"Get the compliance engine running and fully tested — with zero production code and zero API yet."**
Sprint 1 produces a compilable, testable Maven multi-module project with:
- All core JPA entities modelled
- Flyway V1 baseline migration SQL
- `ComplianceService` implemented with 100% unit test coverage (TC-001 → TC-010)
- A working local dev environment (Docker Compose: PostgreSQL + app)
No UI, no REST API, no Stripe in Sprint 1. The compliance engine is the legal heart of the product — validate it first.
---
## Deliverables
| # | Deliverable | Definition of Done |
|---|------------|-------------------|
| D1 | Maven multi-module project scaffold | `./mvnw clean verify` passes with no test failures |
| D2 | `cannamanage-domain` module | All 8 JPA entities compile; `AbstractTenantEntity` wired |
| D3 | Flyway `V1__initial_schema.sql` | Migration applies cleanly against PostgreSQL 16 |
| D4 | `ComplianceService` | All 5 business methods implemented |
| D5 | Unit test suite TC-001 → TC-010 | JaCoCo reports 100% line + branch coverage on `ComplianceService` |
| D6 | Local dev `docker-compose.yml` | `docker compose up db` starts PostgreSQL; app connects cleanly |
---
## 1. Maven Multi-Module Structure
```
cannamanage/ ← root POM (parent)
├── pom.xml ← parent POM (BOM: Spring Boot 3.x, Java 21)
├── cannamanage-domain/ ← JPA entities, enums, constants
│ └── src/main/java/de/cannamanage/domain/
│ ├── entity/ ← JPA entity classes
│ ├── enums/ ← MemberStatus, BatchStatus, etc.
│ └── constants/
│ └── ComplianceConstants.java
├── cannamanage-service/ ← Business logic, services (TESTED HERE)
│ └── src/
│ ├── main/java/de/cannamanage/service/
│ │ ├── ComplianceService.java
│ │ ├── dto/ ← QuotaStatus, ComplianceCheckResult, etc.
│ │ └── exception/ ← QuotaExceededException, MemberIneligibleException
│ └── test/java/de/cannamanage/service/
│ └── ComplianceServiceTest.java ← TC-001 to TC-010
├── cannamanage-api/ ← Spring Boot app entry point (REST controllers — Sprint 2)
│ └── src/main/java/de/cannamanage/api/
│ └── CannaManageApplication.java
└── docker-compose.yml ← Local dev: PostgreSQL 16
```
### Parent POM key dependencies (BOM managed)
```xml
<!-- Spring Boot 3.3.x parent -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
</parent>
<!-- Modules -->
<modules>
<module>cannamanage-domain</module>
<module>cannamanage-service</module>
<module>cannamanage-api</module>
</modules>
<!-- Key managed versions -->
<!-- Java 21, Hibernate 6.x (via Spring Boot BOM), Flyway 9.x -->
<!-- JJWT 0.12.x (Sprint 2), iText 7 (Sprint 3), Stripe 25.x (Sprint 4) -->
```
---
## 2. `cannamanage-domain` — JPA Entities
### 2.1 `AbstractTenantEntity` (base class for all entities)
```java
// de.cannamanage.domain.entity.AbstractTenantEntity
@MappedSuperclass
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = UUID.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class AbstractTenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
this.tenantId = TenantContext.getCurrentTenant(); // ThreadLocal
this.createdAt = Instant.now();
}
}
```
### 2.2 Entities to implement (Sprint 1)
| Entity | Key fields | Notes |
|--------|-----------|-------|
| `Club` | id, name, licenseNumber, maxMembers, status | Root tenant aggregate |
| `Member` | id, clubId, firstName, lastName, email, dob, membershipNumber, status, isUnder21 | `isUnder21` derived from DOB |
| `Strain` | id, name, thcPercentage, cbdPercentage | Immutable once created |
| `Batch` | id, strainId, quantityGrams, harvestDate, batchCode, status, contaminationFlag | status: AVAILABLE → EXHAUSTED / RECALLED |
| `Distribution` | id, memberId, batchId, quantityGrams, distributedAt, recordedBy, notes | `@Column(updatable=false)` on all fields — immutable |
| `MonthlyQuota` | id, memberId, year, month, totalDistributed, maxAllowed, version | `@Version` for optimistic lock |
| `StockMovement` | id, batchId, movementType, quantityGrams, reason, createdAt | Audit journal |
| `User` | id, memberId, email, passwordHash, role, lastLogin, active, refreshTokenHash | Login identity |
### 2.3 `ComplianceConstants.java`
```java
// de.cannamanage.domain.constants.ComplianceConstants
public final class ComplianceConstants {
// CanG §19(2) — adult limits
public static final BigDecimal ADULT_DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
public static final BigDecimal ADULT_MONTHLY_LIMIT_GRAMS = new BigDecimal("50.0");
// CanG §19(3) — under-21 limits
public static final BigDecimal UNDER21_MONTHLY_LIMIT_GRAMS = new BigDecimal("30.0");
// CanG §19(4) — under-21 THC cap
public static final BigDecimal UNDER21_MAX_THC_PERCENTAGE = new BigDecimal("10.0");
// Minimum membership age
public static final int MINIMUM_MEMBERSHIP_AGE = 18;
// Under-21 threshold
public static final int UNDER21_THRESHOLD_AGE = 21;
private ComplianceConstants() {}
}
```
---
## 3. Flyway `V1__initial_schema.sql`
Location: `cannamanage-api/src/main/resources/db/migration/V1__initial_schema.sql`
```sql
-- Clubs (root of tenant hierarchy)
CREATE TABLE clubs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
address TEXT,
license_number VARCHAR(100) NOT NULL UNIQUE,
max_members INT NOT NULL DEFAULT 500,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Members
CREATE TABLE members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
club_id UUID NOT NULL REFERENCES clubs(id),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
date_of_birth DATE NOT NULL,
membership_date DATE NOT NULL DEFAULT CURRENT_DATE,
membership_number VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
is_under_21 BOOLEAN NOT NULL DEFAULT FALSE,
prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(email, tenant_id),
UNIQUE(membership_number, tenant_id)
);
-- Strains
CREATE TABLE strains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
thc_percentage NUMERIC(5,2) NOT NULL,
cbd_percentage NUMERIC(5,2) NOT NULL DEFAULT 0.00,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Batches
CREATE TABLE batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
strain_id UUID NOT NULL REFERENCES strains(id),
quantity_grams NUMERIC(10,2) NOT NULL,
harvest_date DATE,
batch_code VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'AVAILABLE',
contamination_flag BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(batch_code, tenant_id)
);
-- Distributions (immutable — no UPDATE/DELETE via app)
CREATE TABLE distributions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
batch_id UUID NOT NULL REFERENCES batches(id),
quantity_grams NUMERIC(10,2) NOT NULL,
distributed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
recorded_by UUID NOT NULL REFERENCES members(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Monthly quotas (one row per member per calendar month)
CREATE TABLE monthly_quotas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
year INT NOT NULL,
month INT NOT NULL CHECK (month >= 1 AND month <= 12),
total_distributed NUMERIC(10,2) NOT NULL DEFAULT 0.00,
max_allowed NUMERIC(10,2) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(member_id, year, month)
);
-- Stock movements (audit journal)
CREATE TABLE stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
batch_id UUID NOT NULL REFERENCES batches(id),
movement_type VARCHAR(50) NOT NULL, -- IN, OUT, RECALL, ADJUSTMENT
quantity_grams NUMERIC(10,2) NOT NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users (login identities)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
member_id UUID REFERENCES members(id),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'ROLE_MEMBER',
last_login TIMESTAMPTZ,
active BOOLEAN NOT NULL DEFAULT TRUE,
refresh_token_hash VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(email, tenant_id)
);
-- Indexes for common query patterns
CREATE INDEX idx_members_club_id ON members(club_id);
CREATE INDEX idx_members_tenant_id ON members(tenant_id);
CREATE INDEX idx_distributions_member_id ON distributions(member_id);
CREATE INDEX idx_distributions_tenant_id ON distributions(tenant_id);
CREATE INDEX idx_distributions_distributed_at ON distributions(distributed_at);
CREATE INDEX idx_monthly_quotas_member_month ON monthly_quotas(member_id, year, month);
CREATE INDEX idx_batches_tenant_status ON batches(tenant_id, status);
```
---
## 4. `ComplianceService` — Implementation Spec
Package: `de.cannamanage.service`
### 4.1 Dependencies (injected via constructor)
```java
@Service
@Transactional
public class ComplianceService {
private final MemberRepository memberRepository;
private final DistributionRepository distributionRepository;
private final BatchRepository batchRepository;
private final MonthlyQuotaRepository monthlyQuotaRepository;
private final StrainRepository strainRepository;
// constructor injection...
}
```
### 4.2 Method: `checkDistributionAllowed(UUID memberId, UUID batchId, BigDecimal quantityGrams)`
**Algorithm (sequential checks, fail-fast):**
```
1. Load Member — throw MemberNotFoundException if not found
2. CHECK: member.status == ACTIVE → else throw QuotaExceededException(MEMBER_INACTIVE)
3. Load Batch → CHECK: batch.status == AVAILABLE → else throw BatchUnavailableException
4. Load Strain via batch.strainId
5. IF member.isUnder21 AND strain.thcPercentage > UNDER21_MAX_THC_PERCENTAGE
→ throw QuotaExceededException(HIGH_THC_RESTRICTED_UNDER_21)
6. Calculate todayDistributed = SUM(distributions.quantityGrams WHERE memberId AND date=TODAY)
CHECK: todayDistributed + quantityGrams > ADULT_DAILY_LIMIT_GRAMS
→ throw QuotaExceededException(QUOTA_EXCEEDED_DAILY)
7. Get or create MonthlyQuota for (memberId, currentYear, currentMonth)
SET maxAllowed = isUnder21 ? UNDER21_MONTHLY_LIMIT_GRAMS : ADULT_MONTHLY_LIMIT_GRAMS
CHECK: quota.totalDistributed + quantityGrams > quota.maxAllowed
→ throw QuotaExceededException(QUOTA_EXCEEDED_MONTHLY)
8. Return ComplianceCheckResult(allowed=true, remainingDaily, remainingMonthly)
```
### 4.3 `QuotaExceededException` — error codes
```java
public enum QuotaViolationCode {
MEMBER_INACTIVE,
QUOTA_EXCEEDED_DAILY,
QUOTA_EXCEEDED_MONTHLY,
HIGH_THC_RESTRICTED_UNDER_21,
BATCH_UNAVAILABLE
}
```
### 4.4 DTOs
```java
// ComplianceCheckResult
record ComplianceCheckResult(
boolean allowed,
BigDecimal remainingDaily,
BigDecimal remainingMonthly,
boolean isUnder21
) {}
// QuotaStatus
record QuotaStatus(
BigDecimal totalAllowed,
BigDecimal totalUsed,
BigDecimal remaining,
boolean isUnder21,
int year,
int month
) {}
```
---
## 5. Unit Test Suite (TC-001 → TC-010)
**Class:** `ComplianceServiceTest` in `cannamanage-service`
**Coverage requirement:** 100% line + branch on `ComplianceService`
**Tools:** JUnit 5, Mockito 5, AssertJ
### Test structure
```java
@ExtendWith(MockitoExtension.class)
class ComplianceServiceTest {
@Mock MemberRepository memberRepository;
@Mock DistributionRepository distributionRepository;
@Mock BatchRepository batchRepository;
@Mock MonthlyQuotaRepository monthlyQuotaRepository;
@Mock StrainRepository strainRepository;
@InjectMocks ComplianceService complianceService;
// Test fixtures
private static final UUID ADULT_MEMBER_ID = UUID.randomUUID();
private static final UUID UNDER21_MEMBER_ID = UUID.randomUUID();
private static final UUID BATCH_ID = UUID.randomUUID();
private static final UUID HIGH_THC_STRAIN_ID = UUID.randomUUID();
// TC-001: adult at monthly limit → throws QUOTA_EXCEEDED_MONTHLY
// TC-002: under-21 at monthly limit → throws QUOTA_EXCEEDED_MONTHLY
// TC-003: adult at daily limit → throws QUOTA_EXCEEDED_DAILY
// TC-004: under-21 + high THC strain → throws HIGH_THC_RESTRICTED_UNDER_21
// TC-005: adult at 49g requesting 2g → throws QUOTA_EXCEEDED_MONTHLY
// TC-006: adult at 0g requesting 25g → allowed, remaining=0
// TC-007: adult at 24.9g requesting 0.1g → allowed, remainingDaily=0
// TC-008: adult at 24.9g requesting 0.2g → throws QUOTA_EXCEEDED_DAILY
// TC-009: SUSPENDED member → throws MEMBER_INACTIVE
// TC-010: EXPELLED member → throws MEMBER_INACTIVE
}
```
### Key mock patterns
```java
// TC-001 example mock setup
Member adultMember = new Member();
adultMember.setId(ADULT_MEMBER_ID);
adultMember.setUnder21(false);
adultMember.setStatus(MemberStatus.ACTIVE);
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
MonthlyQuota quota = new MonthlyQuota();
quota.setTotalDistributed(new BigDecimal("50.0"));
quota.setMaxAllowed(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
.thenReturn(Optional.of(quota));
// Assert
assertThatThrownBy(() -> complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
.isInstanceOf(QuotaExceededException.class)
.extracting("code")
.isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
```
---
## 6. Local Dev Docker Compose
```yaml
# docker-compose.yml (root of cannamanage project)
version: '3.9'
services:
db:
image: postgres:16-alpine
container_name: cannamanage-db-local
environment:
POSTGRES_DB: cannamanage
POSTGRES_USER: cannamanage
POSTGRES_PASSWORD: dev_password_change_in_prod
ports:
- "5432:5432"
volumes:
- pgdata_local:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cannamanage"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata_local:
```
```properties
# cannamanage-api/src/main/resources/application-local.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/cannamanage
spring.datasource.username=cannamanage
spring.datasource.password=dev_password_change_in_prod
spring.jpa.hibernate.ddl-auto=validate # Flyway owns schema
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
logging.level.de.cannamanage=DEBUG
```
**Run locally:**
```bash
git clone http://192.168.188.119:30008/pplate/cannamanage.git
cd cannamanage
docker compose up db -d
./mvnw spring-boot:run -pl cannamanage-api -Dspring.profiles.active=local
```
---
## 7. Sprint 1 Gitea Issues (already created: #1#10)
Based on the Sprint 1 board at `http://truenas.local:30008/pplate/cannamanage/wiki/Sprint-1-Board`, these map to:
| Gitea Issue | Sprint 1 Deliverable |
|-------------|---------------------|
| #1 | Maven multi-module project scaffold |
| #2 | `AbstractTenantEntity` + `TenantContext` ThreadLocal |
| #3 | All 8 JPA entities in `cannamanage-domain` |
| #4 | `ComplianceConstants.java` |
| #5 | Flyway `V1__initial_schema.sql` |
| #6 | `ComplianceService` implementation |
| #7 | Unit tests TC-001 → TC-010 (100% coverage) |
| #8 | `docker-compose.yml` local dev |
| #9 | `application-local.properties` |
| #10 | JaCoCo coverage gate in parent POM |
---
## 8. Out of Scope — Sprint 1
These are **explicitly deferred** to Sprint 2+:
- REST API controllers (`AuthController`, `MemberController`, `DistributionController`)
- Spring Security + JWT filter chain
- PrimeFaces JSF frontend
- Stripe billing integration
- iText 7 PDF reports
- Email notifications
- Testcontainers integration tests (TC-018 → TC-022)
- Hetzner deployment / CI pipeline
- `MemberService` (TC-011 → TC-015)
---
## 9. Definition of Done — Sprint 1
- [ ] `./mvnw clean verify` exits 0 on clean checkout
- [ ] `./mvnw test -pl cannamanage-service` reports 10/10 tests passing
- [ ] JaCoCo report shows `ComplianceService` at 100% line + branch coverage
- [ ] `docker compose up db -d` starts PostgreSQL; Flyway V1 migration applies cleanly
- [ ] No `TODO` comments in production code paths
- [ ] All 8 JPA entities have `@Column(nullable = false)` on required fields
- [ ] `ComplianceConstants.java` contains all CanG limits as `public static final BigDecimal`
- [ ] `AbstractTenantEntity.tenantId` is `@Column(updatable = false)`
- [ ] Code pushed to `http://192.168.188.119:30008/pplate/cannamanage` main branch
---
## 10. Recommended Implementation Order
```
Day 1: Root pom.xml + module scaffolds → ./mvnw compile passes
Day 2: AbstractTenantEntity + TenantContext + ComplianceConstants
Day 3: All 8 JPA entities (compile-time only, no DB yet)
Day 4: Flyway V1 SQL + docker-compose.yml → migration applies
Day 5: ComplianceService skeleton (method signatures + DTOs)
Day 6: TC-001 → TC-005 (the exception/blocking cases)
Day 7: TC-006 → TC-010 (boundary + happy path cases)
Day 8: JaCoCo gate; clean up; push to Gitea
```
*Assuming ~23 hours of evening/weekend coding per day as side project.*
---
*Plan created: 2026-04-10 | Sprint start: when Patrick approves | Estimated coding sessions: 8 × 2-3h*