Files
cannamanage/docs/sprint-2/cannamanage-sprint2-plan.md
T
Patrick Plate 2ede872d11 feat: Sprint 2 REST API layer — full implementation
- Fix critical Hibernate @Filter activation bug (TenantFilterAspect)
- Rename UserRole.ROLE_MANAGER → ROLE_STAFF (future-proofing)
- SecurityConfig: ADMIN + MEMBER roles only for Sprint 2
- AuthController: POST /auth/login + POST /auth/refresh with JWT
- AuthService: login, refresh token rotation, hashed refresh storage
- MemberController: CRUD (GET/POST/PUT /members)
- DistributionController: list + record distributions (CanG §26)
- StockController: batch management (GET/POST /stock/batches)
- ComplianceController: quota check (GET /compliance/quota/{id})
- OpenAPI/Swagger config with bearer-jwt security scheme
- GlobalExceptionHandler: full RFC 9457 problem+json coverage
- UserRepository: findByEmail, findByEmailAndTenantId
- Flyway V2: role rename migration + login indexes
- Testcontainers + test profile infrastructure (integration tests deferred)
- Parent POM: Testcontainers BOM, entity scan via properties

Controllers use validated DTOs (Jakarta Bean Validation records).
Compliance checks run before distribution recording.
Tenant filter AOP aspect ensures multi-tenant data isolation.
2026-06-11 12:05:52 +02:00

382 lines
21 KiB
Markdown

# CannaManage — Sprint 2 Architecture Review & Implementation Plan
**Date:** 2026-06-11
**Author:** Patrick Plate / Lumen (Planner)
**Status:** Draft v1 — awaiting GO
**Branch:** `sprint/2-api`
**Sprint Goal:** REST API layer + Spring Security + OpenAPI + Tenant Filter integration
---
## 1. Current State Assessment
### 1.1 Sprint 1 Deliverables (Complete ✅)
| Component | Status | Notes |
|-----------|--------|-------|
| 8 JPA entities | ✅ | `Club`, `Member`, `Batch`, `Distribution`, `MonthlyQuota`, `StockMovement`, `Strain`, `User` |
| `AbstractTenantEntity` | ✅ | Hibernate `@Filter` + `@PrePersist` sets `tenantId` from `TenantContext` |
| `TenantContext` | ✅ | ThreadLocal UUID holder |
| `ComplianceService` | ✅ | Full CanG §19 enforcement: daily/monthly/batch/THC checks |
| 5 repositories | ✅ | `Member`, `Distribution`, `Batch`, `MonthlyQuota`, `Strain` |
| 25 tests, 100% JaCoCo | ✅ | |
### 1.2 Sprint 2 Draft Code (Exists — Needs Revision)
| File | Status | Issues Identified |
|------|--------|-------------------|
| `JwtService.java` | ⚠️ Functional but needs decisions | Token expiry: 1h access + 30d refresh (matches API spec) |
| `JwtAuthFilter.java` | ⚠️ Good pattern | Sets TenantContext correctly, cleans up in `finally` |
| `SecurityConfig.java` | ⚠️ Needs role alignment | Uses `STAFF` role not defined in `UserRole` enum |
| `GlobalExceptionHandler.java` | ✅ Solid | RFC 9457 ProblemDetail pattern, covers key exceptions |
| `application.properties` | ⚠️ | Dev secret hardcoded (acceptable for dev), `flyway.enabled=false` |
---
## 2. Architecture Conflicts Identified
### 🔴 CONFLICT 1: Multi-Tenancy Model Mismatch
**Architecture Doc says:** Schema-per-tenant (`CREATE SCHEMA tenant_{id}`, `SET search_path`)
**Sprint 1 implemented:** Shared-schema with Hibernate `@Filter` on `tenant_id` column
**Analysis:**
The architecture doc (Section 2) explicitly argues for schema-per-tenant with a `TenantRoutingDataSource`. However, Sprint 1 actually implemented a **shared-schema approach** using `@FilterDef`/`@Filter` on `AbstractTenantEntity`. Every entity has a `tenant_id` column and `@PrePersist` sets it from `TenantContext`.
**Impact:** This is not a bug — it's a pragmatic MVP decision. Schema-per-tenant is operationally complex for a solo dev. The shared-schema + Hibernate filter approach works for MVP scale (<50 clubs). The code is internally consistent.
**Decision needed:**
- **Option A:** Keep shared-schema + `@Filter` for MVP (recommended — less ops complexity)
- **Option B:** Refactor to schema-per-tenant as documented (correct long-term, but 2-3 days of plumbing)
**Recommendation:** Option A. The Hibernate `@Filter` approach works and the data model already has `tenant_id` everywhere. We can migrate to schema-per-tenant in v2 when onboarding > 50 clubs. The critical invariant — tenant isolation — is preserved either way.
---
### 🟡 CONFLICT 2: Role Model Divergence
**Architecture Doc roles:** `ROLE_CLUB_ADMIN`, `ROLE_STAFF`, `ROLE_MEMBER`, `ROLE_PREVENTION_OFFICER`
**API Spec roles:** `ADMIN`, `MEMBER` (only two roles mentioned in endpoints)
**Sprint 1 UserRole enum:** `ROLE_ADMIN`, `ROLE_MANAGER`, `ROLE_MEMBER`, `ROLE_PREVENTION_OFFICER`
**SecurityConfig draft:** References `ADMIN`, `STAFF`, `MEMBER`
**Problems:**
1. `ROLE_MANAGER` exists in the enum but is not in the architecture doc (which has `ROLE_STAFF`)
2. `SecurityConfig` references `STAFF` but the enum has `MANAGER`
3. The API spec only distinguishes `ADMIN` and `MEMBER` — no STAFF endpoints at all
4. Architecture doc defines a `StaffPermission` enum with fine-grained permissions — not implemented
**Decision needed:**
- **Option A:** Sprint 2 MVP uses only ADMIN + MEMBER (matches API spec, simplest)
- **Option B:** Implement ADMIN + STAFF + MEMBER now (matches architecture doc intent)
- **Option C:** Full permission model with `StaffPermission` JSONB column
**Recommendation:** Option A for Sprint 2. The API spec only defines ADMIN and MEMBER roles with clear permission boundaries. STAFF with configurable permissions (Option C) is a Sprint 3/4 feature — it requires a `staff_accounts` table, permission grants, and a custom `PermissionEvaluator`. The `ROLE_PREVENTION_OFFICER` is effectively a member with extra read access to under-21 data — can be a flag on the User entity rather than a separate role.
---
### 🟡 CONFLICT 3: Token Expiry Discrepancy
**Architecture Doc:** 8-hour access token
**API Spec:** 1-hour access token (`expiresIn: 3600`)
**Current Code:** 1-hour (matches API spec)
**Recommendation:** Keep 1-hour. Shorter tokens are more secure for a compliance SaaS handling personal data. The refresh token (30 days) handles UX. The architecture doc value was aspirational; the API spec value is the contract.
---
### 🟢 NON-ISSUE: Spring Boot 4.0.6 + Spring Security 7
The current code is already on Boot 4.0.6. Key observations:
1. **`SecurityFilterChain` pattern** — already used correctly (no deprecated `WebSecurityConfigurerAdapter`)
2. **`SessionCreationPolicy.STATELESS`** — correct for JWT API
3. **JJWT 0.12.6** — latest stable, works with Boot 4
4. **Spring Security 7 modularization**`spring-security-access` module may be needed if using `@PreAuthorize`. Boot starter pulls it in transitively.
5. **`springdoc-openapi-starter-webmvc-ui`** — compatible with Boot 4.x (version needs checking in POM)
---
## 3. Sprint 2 Scope
### ✅ IN Scope
| # | Feature | User Stories | Priority |
|---|---------|-------------|----------|
| 1 | **Auth Controller** — login, refresh, logout | US-011 | Must |
| 2 | **Member Controller** — CRUD + quota endpoint | US-002, US-014 | Must |
| 3 | **Distribution Controller** — create + list | US-003, US-004 | Must |
| 4 | **Stock Controller** — strains CRUD, batches CRUD, recall | US-005, US-009 | Must |
| 5 | **Compliance Controller** — quota check, dry-run check | US-004 | Must |
| 6 | **Report Controller** — monthly JSON (PDF deferred) | US-007 | Must (JSON only) |
| 7 | **Club Controller** — /clubs/me, /clubs/me/stats | US-006 | Must |
| 8 | **OpenAPI/Swagger UI** — full API documentation | — | Must |
| 9 | **Hibernate Filter activation** — enable `tenantFilter` on every request | — | Must |
| 10 | **Integration tests** — MockMvc + H2 for all controllers | — | Must |
### ❌ OUT of Scope (Sprint 3+)
| Feature | Reason |
|---------|--------|
| STAFF role + StaffPermission model | Requires separate table + custom PermissionEvaluator |
| PDF/CSV report generation | Requires iText 7 dependency + report templates |
| Member portal (session-based auth) | Different security chain, Sprint 3 |
| Email notifications | Requires Jakarta Mail setup |
| Stripe integration | v2 feature (US-015) |
| Rate limiting on login | Nice-to-have, can add via Spring Cloud Gateway or `bucket4j` later |
| CORS configuration | Needs frontend domain — add when React app exists |
| Schema-per-tenant migration | v2 operational maturity |
---
## 4. Architecture Decisions
### AD-01: Shared-Schema Multi-Tenancy (Keep Current)
**Context:** Architecture doc prescribes schema-per-tenant, Sprint 1 uses Hibernate `@Filter`.
**Decision:** Keep `@Filter` approach for MVP.
**Rationale:** Reduces ops complexity for solo dev. Hibernate filters are reliable when properly activated. The data model is already correct — migration to schema-per-tenant later only requires infrastructure changes, not entity redesign.
**Risk:** If the filter is not enabled on a session, queries return cross-tenant data. Mitigation: the `TenantFilterAspect` (new component, see Step 4 below) will guarantee activation.
### AD-02: Two-Role Model for Sprint 2
**Context:** Architecture doc defines 4 roles + configurable staff permissions.
**Decision:** Sprint 2 uses `ADMIN` and `MEMBER` only.
**Rationale:** API spec only documents these two. SecurityConfig URL rules are sufficient. `@PreAuthorize` annotations on controller methods provide method-level granularity.
**Consequence:** Rename `UserRole.ROLE_MANAGER` → remove it. Keep `ROLE_PREVENTION_OFFICER` as a marker for later but don't enforce it in Sprint 2 security rules.
### AD-03: JwtService as Single Source of Auth Truth
**Context:** No UserDetailsService loading from DB on every request.
**Decision:** JWT claims are sufficient for authorization. No DB hit per request.
**Rationale:** Stateless JWT. The token contains `sub` (userId), `tenant_id`, and `role`. These are all SecurityConfig needs. If we need to check `user.active` in real-time, we'll add a refresh-token-revocation mechanism (invalidate refresh token → user is forced to re-login within 1 hour).
**Tradeoff:** A deactivated user remains authenticated until their access token expires (max 1 hour). Acceptable for MVP.
### AD-04: ProblemDetail (RFC 9457) for All Errors
**Context:** API spec defines a custom error format with `status`, `error`, `code`, `message`, `timestamp`, `path`.
**Decision:** Use Spring's native `ProblemDetail` + custom properties.
**Rationale:** Already implemented in `GlobalExceptionHandler`. Native Spring Boot support since 3.x. The custom error codes (QUOTA_EXCEEDED_MONTHLY, etc.) map to `ProblemDetail.setProperty("code", ...)`.
### AD-05: No Custom UserDetailsService
**Context:** Spring Security typically expects a `UserDetailsService` bean.
**Decision:** AuthController handles login directly via repository lookup + BCrypt verification. No Spring Security `UserDetailsService`.
**Rationale:** For JWT-only auth, the authentication manager is only needed at login time. The `JwtAuthFilter` bypasses Spring's standard auth flow entirely — it sets the SecurityContext directly from JWT claims. This is cleaner for stateless APIs.
---
## 5. Implementation Steps (Ordered)
### Phase 1: Foundation Fixes (Day 1)
| Step | What | Files |
|------|------|-------|
| 1.1 | Fix `UserRole` enum — rename MANAGER→STAFF, keep PREVENTION_OFFICER as future placeholder | `cannamanage-domain/.../enums/UserRole.java` |
| 1.2 | Add `UserRepository` to service module | `cannamanage-service/.../repository/UserRepository.java` |
| 1.3 | Create `TenantFilterAspect` — AOP aspect that enables Hibernate filter on `EntityManager` before every `@Transactional` service call | `cannamanage-service/.../config/TenantFilterAspect.java` |
| 1.4 | Add `updatedAt` field to `AbstractTenantEntity` (currently missing vs. architecture doc) | `cannamanage-domain/.../entity/AbstractTenantEntity.java` |
| 1.5 | Create H2 dev profile with `spring.flyway.enabled=true` + initial migration | `cannamanage-api/src/main/resources/application-dev.properties`, `db/migration/V1__init.sql` |
### Phase 2: Auth Controller (Day 1-2)
| Step | What | Files |
|------|------|-------|
| 2.1 | Create `AuthController` with `POST /login`, `POST /refresh`, `POST /logout` | `cannamanage-api/.../controller/AuthController.java` |
| 2.2 | Create `LoginRequest`, `LoginResponse`, `RefreshRequest`, `RefreshResponse` DTOs | `cannamanage-api/.../dto/auth/` |
| 2.3 | Create `AuthService` — handles login logic, password verification, refresh token rotation | `cannamanage-service/.../AuthService.java` |
| 2.4 | Add domain exception handlers for `QuotaExceededException`, `MemberNotFoundException`, `BatchNotFoundException` to `GlobalExceptionHandler` | `cannamanage-api/.../exception/GlobalExceptionHandler.java` |
### Phase 3: Member Controller (Day 2-3)
| Step | What | Files |
|------|------|-------|
| 3.1 | Create `MemberController` — GET list (paginated), POST create, GET by id, PUT update, DELETE soft-delete | `cannamanage-api/.../controller/MemberController.java` |
| 3.2 | Create `MemberService` — business logic bridge between controller and repositories | `cannamanage-service/.../MemberService.java` |
| 3.3 | Create DTOs: `CreateMemberRequest`, `UpdateMemberRequest`, `MemberResponse`, `MemberSummaryResponse` | `cannamanage-api/.../dto/member/` |
| 3.4 | Add `GET /members/{id}/quota` endpoint (delegates to ComplianceService) | `MemberController.java` |
| 3.5 | Add `GET /members/me` for MEMBER-role self-view | `MemberController.java` |
### Phase 4: Distribution Controller (Day 3-4)
| Step | What | Files |
|------|------|-------|
| 4.1 | Create `DistributionController` — POST create, GET list (paginated + filtered), GET by id, POST /{id}/notes | `cannamanage-api/.../controller/DistributionController.java` |
| 4.2 | Create `DistributionService` — orchestrates ComplianceService + persists Distribution + updates batch stock | `cannamanage-service/.../DistributionService.java` |
| 4.3 | Create DTOs: `CreateDistributionRequest`, `DistributionResponse`, `AddCorrectionNoteRequest` | `cannamanage-api/.../dto/distribution/` |
| 4.4 | Implement batch stock decrement on distribution creation (atomic with compliance check) | `DistributionService.java` |
### Phase 5: Stock Controller (Day 4-5)
| Step | What | Files |
|------|------|-------|
| 5.1 | Create `StockController` — strains CRUD + batches CRUD + recall | `cannamanage-api/.../controller/StockController.java` |
| 5.2 | Create `StockService` — batch management, recall workflow | `cannamanage-service/.../StockService.java` |
| 5.3 | Create DTOs: `CreateStrainRequest`, `StrainResponse`, `CreateBatchRequest`, `BatchResponse`, `RecallBatchRequest` | `cannamanage-api/.../dto/stock/` |
| 5.4 | Implement recall — flags batch, returns affected members | `StockService.java` |
### Phase 6: Compliance + Club + Reports (Day 5-6)
| Step | What | Files |
|------|------|-------|
| 6.1 | Create `ComplianceController``GET /compliance/quota/{memberId}`, `GET /compliance/check` (dry-run) | `cannamanage-api/.../controller/ComplianceController.java` |
| 6.2 | Create `ClubController``GET /clubs/me`, `PUT /clubs/me`, `GET /clubs/me/stats` | `cannamanage-api/.../controller/ClubController.java` |
| 6.3 | Create `ClubService` — club profile + dashboard stats aggregation | `cannamanage-service/.../ClubService.java` |
| 6.4 | Create `ReportController``GET /reports/monthly?format=json` (JSON only for Sprint 2) | `cannamanage-api/.../controller/ReportController.java` |
| 6.5 | Create DTOs for all above | `cannamanage-api/.../dto/club/`, `.../dto/compliance/`, `.../dto/report/` |
### Phase 7: OpenAPI + Integration Tests (Day 6-7)
| Step | What | Files |
|------|------|-------|
| 7.1 | Add `@Tag`, `@Operation`, `@ApiResponse` annotations to all controllers | All controllers |
| 7.2 | Configure `OpenApiConfig` — info, security scheme, server URL | `cannamanage-api/.../config/OpenApiConfig.java` |
| 7.3 | Write `AuthControllerTest` — MockMvc tests for login/refresh/logout | `cannamanage-api/src/test/...` |
| 7.4 | Write `MemberControllerTest` — CRUD + auth + tenant isolation | `cannamanage-api/src/test/...` |
| 7.5 | Write `DistributionControllerTest` — compliance rejection scenarios | `cannamanage-api/src/test/...` |
| 7.6 | Write `StockControllerTest` — batch lifecycle + recall | `cannamanage-api/src/test/...` |
| 7.7 | Write `ComplianceControllerTest` — dry-run check | `cannamanage-api/src/test/...` |
| 7.8 | Ensure JaCoCo stays ≥ 80% on service module | Build config |
---
## 6. Key Technical Patterns
### 6.1 Tenant Filter Activation (New — Critical)
The Hibernate `@Filter` requires explicit activation on each `Session`. Currently nothing activates it. We need:
```java
@Aspect
@Component
@RequiredArgsConstructor
public class TenantFilterAspect {
private final EntityManager entityManager;
@Before("@within(org.springframework.stereotype.Service)")
public void enableTenantFilter() {
UUID tenantId = TenantContext.getCurrentTenant();
if (tenantId != null) {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
}
}
}
```
**Alternative:** A `HandlerInterceptor` that runs after the JwtAuthFilter and activates the filter. Both work — AOP is more robust as it catches service-to-service calls too.
### 6.2 Controller DTO Mapping Pattern
```java
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
@Tag(name = "Members", description = "Member management")
public class MemberController {
private final MemberService memberService;
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.CREATED)
public MemberResponse create(@Valid @RequestBody CreateMemberRequest request) {
return memberService.createMember(request);
}
}
```
### 6.3 Pagination Response Pattern
Use Spring's `Page<T>` mapped to a custom envelope matching the API spec:
```java
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
}
```
---
## 7. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| Hibernate `@Filter` not activated → cross-tenant data leak | High (if forgotten) | Critical | `TenantFilterAspect` AOP guarantee + integration tests verifying isolation |
| Spring Security 7 API changes break SecurityConfig | Low | Medium | Code is already using Boot 4.0.6 patterns — verified compatible |
| springdoc-openapi incompatibility with Boot 4 | Medium | Low | Check version in POM, use `2.8.x` which supports Boot 4 |
| JaCoCo coverage drops below 80% with new controllers | Medium | Low | Write MockMvc tests for every endpoint, assert status codes |
| `@PreAuthorize` not working without `@EnableMethodSecurity` | Low | High | Already enabled in SecurityConfig ✅ |
| JWT secret hardcoded in properties committed to Git | Medium (already there) | Medium | Replace with `${JWT_SECRET}` env var pattern before any production use |
---
## 8. Open Questions for Patrick
1. **Tenancy model:** Architecture doc says schema-per-tenant, Sprint 1 uses shared-schema + Hibernate `@Filter`. I recommend keeping `@Filter` for MVP. Agree?
2. **Role model for Sprint 2:** Keep it to ADMIN + MEMBER only? Or do you want STAFF (with basic URL-level permissions, not the full `StaffPermission` JSONB model) included now?
3. **`UserRole` cleanup:** The current enum has `ROLE_MANAGER` which doesn't exist in any doc. Replace with `ROLE_STAFF`? Or just remove and keep ADMIN + MEMBER + PREVENTION_OFFICER for now?
4. **Test database:** Use H2 in-memory for Sprint 2 tests, or set up Testcontainers PostgreSQL? H2 is simpler for dev speed; Testcontainers is more realistic but slower.
5. **Flyway migrations:** Should I create the full schema migration now (all 8 entities → DDL), or keep `ddl-auto=update` for rapid iteration and create migrations when stable?
6. **Member portal auth (Sprint 3 preview):** The architecture doc and SecurityConfig both hint at a session-based `/portal/**` chain. Confirm this is deferred to Sprint 3?
7. **Prevention Officer:** The architecture doc says this is a separate role with access to under-21 member data. Should this be:
- A separate `UserRole` value with specific `@PreAuthorize` rules?
- Or a boolean flag on the User/Member that grants additional read access via service-layer checks?
---
## 9. Definition of Done — Sprint 2
- [ ] All 7 controllers implemented with full CRUD per API spec
- [ ] JWT auth flow working: login → access token → authenticated requests → refresh → logout
- [ ] Tenant isolation verified: integration tests proving cross-tenant queries return 0 results
- [ ] Compliance checks enforced on distribution creation (fail with 422 + custom error codes)
- [ ] OpenAPI Swagger UI accessible at `/swagger-ui.html` with all endpoints documented
- [ ] `@PreAuthorize` annotations on all endpoints with correct role requirements
- [ ] GlobalExceptionHandler covers all domain exceptions with RFC 9457 ProblemDetail
- [ ] MockMvc integration tests for all controllers (≥ 2 tests per endpoint: happy path + auth failure)
- [ ] JaCoCo ≥ 80% on `cannamanage-service`, ≥ 70% on `cannamanage-api`
- [ ] No hardcoded secrets in committed code (move JWT secret to env var pattern)
- [ ] Build passes: `mvn clean verify`
---
## 10. Estimated Effort
| Phase | Effort | Cumulative |
|-------|--------|------------|
| Phase 1: Foundation Fixes | 3-4 hours | Day 1 |
| Phase 2: Auth Controller | 4-5 hours | Day 1-2 |
| Phase 3: Member Controller | 4-5 hours | Day 2-3 |
| Phase 4: Distribution Controller | 4-5 hours | Day 3-4 |
| Phase 5: Stock Controller | 4-5 hours | Day 4-5 |
| Phase 6: Compliance + Club + Reports | 4-5 hours | Day 5-6 |
| Phase 7: OpenAPI + Tests | 6-8 hours | Day 6-7 |
| **Total** | **~30-37 hours** | **~7 working days** |
---
*This plan covers the REST API backbone. Sprint 3 will address: member portal (session auth), PDF/CSV reports (iText 7), email notifications, and the STAFF permission model.*