Files

548 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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*