# 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` mapped to a custom envelope matching the API spec: ```java public record PageResponse( List content, int page, int size, long totalElements, int totalPages ) { public static PageResponse from(Page 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.*