Files
cannamanage/docs/sprint-5/cannamanage-sprint5-plan.md
Patrick Plate f42c166329 feat(sprint-5): Phase 2 — React Query API client layer
- @tanstack/react-query with QueryClientProvider in providers/index.tsx
- Typed api-client.ts fetch wrapper with ApiError class + apiDownload
- Service modules: members, distributions, stock, reports, dashboard, portal, staff
- Offline banner component (onlineManager subscription)
- API error boundary with retry button
- Loading skeleton components (card, table, chart, form, dashboard)
- i18n for error/loading states (de/en)
2026-06-12 19:59:41 +02:00

891 lines
37 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 5 Implementation Plan
**Date:** 2026-06-12
**Author:** Patrick Plate / Lumen (Planner)
**Status:** Draft v2
**Base Branch:** `main`
**Sprint Branch:** `sprint/5-integration`
**Sprint Goal:** Wire frontend to real backend — full-stack integration, Docker Compose test harness, staff management UI
> **Sprint Structure:** Sprint 5 is split into two sub-sprints:
> - **Sprint 5.a** — Phases 15: Full-stack integration (~7 days)
> - **Sprint 5.b** — Phases 67: Staff management + system tests (~3 days)
>
> Both are delivered within Sprint 5, organized as sequential sub-sprints (~10 days total).
---
## 0. Decisions (Confirmed ✅)
| # | Decision | Detail | Status |
|---|----------|--------|--------|
| D1 | API client library | **@tanstack/react-query** — caching, refetch, optimistic updates, loading/error states. Wraps the existing server-side `apiClient()` with client-side hooks. | ✅ Confirmed (Q1) |
| D2 | Loading strategy | **Per-component loading** — each card/table loads independently with shimmer skeleton. No full-page blocking. | ✅ Confirmed (Q2) |
| D3 | Offline resilience | **Stale-while-revalidate + "Offline" banner** — React Query serves stale cached data while attempting refetch. A persistent banner shows "Offline" status when backend is unreachable. No hard crash. | ✅ Confirmed (Q3) |
| D4 | Staff UI scope | **Full CRUD** — list, invite, edit permissions, revoke. Activity log deferred to Sprint 6. | ✅ Confirmed (Q4) |
| D5 | Seed data strategy | **SQL for dev/test** (Flyway repeatable migration `R__test_seed.sql` activated by Spring profile `test-seed`) + **API-driven for system E2E** (Playwright calls API to set up test state). Dual approach: deterministic SQL for fast local dev, API-driven for realistic integration coverage. | ✅ Confirmed (Q5) |
| D6 | CORS approach | **Spring Boot `@CrossOrigin` + `CorsConfigurationSource` bean** — allows `localhost:3000` in dev, configurable per environment. Needed when running frontend outside Docker (dev mode). | ✅ Carried from v1 |
| D7 | Docker profiles | Remove `profiles: [full]` from backend/frontend services → always-on. Add `docker-compose.test.yml` overlay for Playwright + seed data. | ✅ Carried from v1 |
| D8 | Next.js upgrade | **Upgrade 15.2.8 → 15.5.18** in Phase 1. Addresses 8+ Snyk CVEs (SSRF, auth bypass, resource allocation). Performed early to surface breaking changes before integration work. | ✅ Confirmed (Bonus) |
---
## 1. Sprint 4 Recap (Context)
| Delivered | Status |
|-----------|--------|
| Next.js 15 + shadcn/ui frontend (12 pages, 23K lines) | ✅ |
| Admin dashboard with KPIs, chart, sidebar nav | ✅ |
| Member management (TanStack Table, add/edit forms) | ✅ |
| Distribution recording (multi-step form + quota check) | ✅ |
| Stock/batch management (chart, recall, add batch) | ✅ |
| Reports page (PDF/CSV download triggers) | ✅ |
| Member portal (quota radial, history, profile) | ✅ |
| i18n (de/en), dark+light mode, Docker multi-stage | ✅ |
| Playwright E2E (66+ tests with mock backend) | ✅ |
**Critical gap from Sprint 4:** Frontend uses local mock data (`src/data/mock/*`). No real API calls. Frontend and backend are completely disconnected.
---
## 2. Sprint 5 Scope
### ✅ IN Scope — Sprint 5.a (Full-Stack Integration)
| # | Feature | Priority | Effort |
|---|---------|----------|--------|
| 1 | **Docker Compose full stack + Next.js upgrade** — upgrade Next.js 15.2.8→15.5.18, remove `full` profile, add CORS, health checks, Backend Dockerfile fix | P0 | 1 day |
| 2 | **API client layer (@tanstack/react-query)** — React Query provider, typed service hooks with caching/refetch/optimistic updates, per-component loading skeletons, error/offline patterns | P0 | 1 day |
| 3 | **Wire dashboard + members** — replace mock data with real API calls | P0 | 1.5 days |
| 4 | **Wire distributions + stock** — real distribution recording with quota check | P0 | 1.5 days |
| 5 | **Wire reports + portal** — real report downloads, portal dashboard with live data | P1 | 1.5 days |
**Sprint 5.a effort:** ~6.5 days
### ✅ IN Scope — Sprint 5.b (Staff UI + System Tests)
| # | Feature | Priority | Effort |
|---|---------|----------|--------|
| 6 | **Staff management UI** — list, invite, permissions editor, revoke | P1 | 2 days |
| 7 | **System test harness** — seed data, Docker Compose test profile, E2E against real stack | P1 | 2 days |
**Sprint 5.b effort:** ~4 days
**Total estimated effort:** ~10 days (single worker, sequential)
### ❌ OUT of Scope (Sprint 6+)
- WebSocket notifications (email + in-app)
- Inspector read-only mode
- DSGVO consent management UI
- PWA manifest + service worker
- Micro-interactions (Framer Motion)
- Monthly report auto-sealing
- Cryptographic hash chain
- 2FA (TOTP)
---
## 3. Architecture Decisions
### 3.1 CORS Configuration (Decision D6)
The frontend runs on `localhost:3000` in dev, but the backend is on `localhost:8080`. While Next.js rewrites (`/api/backend/:path*`) avoid CORS for server-side calls, client-side fetches (React Query) go directly. We add a proper CORS bean:
```java
// SecurityConfig.java — add to existing configuration
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000", // dev
"http://frontend:3000" // Docker network
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
```
Apply to the API filter chain:
```java
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
```
### 3.2 TanStack React Query Setup (Decision D1)
```typescript
// src/lib/query-client.ts
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s before refetch
gcTime: 5 * 60_000, // 5min garbage collection
retry: 1, // retry once on failure
refetchOnWindowFocus: true,
},
},
})
```
Provider setup in root layout:
```typescript
// app/[locale]/providers.tsx
"use client"
import { QueryClientProvider } from "@tanstack/react-query"
import { queryClient } from "@/lib/query-client"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
```
### 3.3 API Client Architecture (Server + Client)
Two layers — server-side for SSR/RSC, client-side for interactive pages:
```typescript
// src/lib/api-client.ts (SERVER-SIDE — used in Server Components + Route Handlers)
import { auth } from "@/lib/auth"
const BACKEND_BASE = process.env.BACKEND_URL || "http://localhost:8080"
export async function apiServer<T>(path: string, options: RequestInit = {}): Promise<T> {
const session = await auth()
if (!session?.user) throw new Error("Unauthorized")
const res = await fetch(`${BACKEND_BASE}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
...options.headers,
},
next: { revalidate: 30 }, // ISR-style caching
})
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }))
throw new ApiError(res.status, error.message)
}
return res.json()
}
```
```typescript
// src/lib/api-client-browser.ts (CLIENT-SIDE — used with React Query hooks)
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
// Uses Next.js rewrite proxy → /api/backend/* → backend:8080/api/v1/*
const res = await fetch(`/api/backend${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
credentials: "include", // sends session cookie for auth
})
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }))
throw new ApiError(res.status, error.message)
}
return res.json()
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = "ApiError"
}
}
```
### 3.4 Service Hooks Pattern
Each domain gets a service file with typed React Query hooks:
```typescript
// src/services/members.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { apiFetch } from "@/lib/api-client-browser"
import type { Member } from "@/types/api"
export function useMembers() {
return useQuery({
queryKey: ["members"],
queryFn: () => apiFetch<Member[]>("/members"),
})
}
export function useMember(id: string) {
return useQuery({
queryKey: ["members", id],
queryFn: () => apiFetch<Member>(`/members/${id}`),
enabled: !!id,
})
}
export function useCreateMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateMemberRequest) =>
apiFetch<Member>("/members", { method: "POST", body: JSON.stringify(data) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["members"] })
},
})
}
```
### 3.5 Error Handling + Toast Pattern
```typescript
// src/hooks/use-api-error.ts
import { useToast } from "@/hooks/use-toast"
import { ApiError } from "@/lib/api-client-browser"
export function useApiErrorHandler() {
const { toast } = useToast()
return (error: unknown) => {
if (error instanceof ApiError) {
switch (error.status) {
case 401:
toast({ title: "Sitzung abgelaufen", description: "Bitte erneut anmelden.", variant: "destructive" })
break
case 403:
toast({ title: "Zugriff verweigert", description: "Fehlende Berechtigung.", variant: "destructive" })
break
case 409:
toast({ title: "Kontingent überschritten", description: error.message, variant: "destructive" })
break
default:
toast({ title: "Fehler", description: error.message, variant: "destructive" })
}
} else {
toast({ title: "Verbindungsfehler", description: "Backend nicht erreichbar.", variant: "destructive" })
}
}
}
```
### 3.6 Loading Skeleton Pattern
```typescript
// src/components/cannamanage/skeleton-card.tsx
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export function SkeletonCard() {
return (
<Card>
<CardHeader>
<Skeleton className="h-4 w-[120px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-[80px]" />
<Skeleton className="h-3 w-[160px] mt-2" />
</CardContent>
</Card>
)
}
```
### 3.7 Docker Compose — Full Stack (Decision D7)
```yaml
# docker-compose.yml (updated — no profile gates)
version: '3.9'
services:
db:
image: postgres:16-alpine
container_name: cannamanage-db
environment:
POSTGRES_DB: cannamanage
POSTGRES_USER: cannamanage
POSTGRES_PASSWORD: dev_password_change_in_prod
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cannamanage"]
interval: 5s
timeout: 3s
retries: 5
backend:
build:
context: .
dockerfile: cannamanage-api/Dockerfile
container_name: cannamanage-backend
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
- SPRING_DATASOURCE_USERNAME=cannamanage
- SPRING_DATASOURCE_PASSWORD=dev_password_change_in_prod
- SPRING_PROFILES_ACTIVE=docker
- JWT_SECRET=dev-secret-change-in-prod-minimum-32-chars
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend:
build:
context: ./cannamanage-frontend
dockerfile: Dockerfile
container_name: cannamanage-frontend
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8080
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=dev-nextauth-secret-32-chars-min
- AUTH_TRUST_HOST=true
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
```
### 3.8 Test Compose Overlay
```yaml
# docker-compose.test.yml (overlay for system tests)
services:
backend:
environment:
- SPRING_PROFILES_ACTIVE=docker,test-seed
playwright:
image: mcr.microsoft.com/playwright:v1.52.0-noble
container_name: cannamanage-playwright
working_dir: /app
volumes:
- ./cannamanage-frontend:/app
environment:
- BASE_URL=http://frontend:3000
- CI=true
command: npx playwright test e2e/system-test.spec.ts
depends_on:
frontend:
condition: service_started
```
Run: `docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from playwright`
### 3.9 Backend Dockerfile (if not exists / needs update)
```dockerfile
# cannamanage-api/Dockerfile
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
COPY cannamanage-domain/pom.xml cannamanage-domain/
COPY cannamanage-service/pom.xml cannamanage-service/
COPY cannamanage-api/pom.xml cannamanage-api/
RUN mvn dependency:go-offline -B
COPY cannamanage-domain/src cannamanage-domain/src
COPY cannamanage-service/src cannamanage-service/src
COPY cannamanage-api/src cannamanage-api/src
RUN mvn package -pl cannamanage-api -am -DskipTests -B
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /build/cannamanage-api/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
```
### 3.10 Seed Data Script (Decision D5)
```sql
-- src/main/resources/db/migration/R__test_seed.sql
-- Repeatable migration: only runs when profile = test-seed
-- Club
INSERT INTO clubs (id, name, status, city, max_members, created_at)
VALUES ('club-001', 'Grüner Daumen e.V.', 'ACTIVE', 'Berlin', 500, NOW())
ON CONFLICT (id) DO NOTHING;
-- Admin user (password: admin123)
INSERT INTO users (id, email, password_hash, role, club_id, created_at)
VALUES ('user-admin', 'admin@gruener-daumen.de', '$2a$10$...bcrypt...', 'ADMIN', 'club-001', NOW())
ON CONFLICT (id) DO NOTHING;
-- Staff user (password: staff123)
INSERT INTO users (id, email, password_hash, role, club_id, created_at)
VALUES ('user-staff', 'staff@gruener-daumen.de', '$2a$10$...bcrypt...', 'STAFF', 'club-001', NOW())
ON CONFLICT (id) DO NOTHING;
-- 5 Members
INSERT INTO members (id, first_name, last_name, email, date_of_birth, member_number, status, club_id, joined_at)
VALUES
('m-001', 'Max', 'Müller', 'max@example.com', '1990-03-15', 'GD-001', 'ACTIVE', 'club-001', NOW()),
('m-002', 'Lisa', 'Weber', 'lisa@example.com', '1988-07-22', 'GD-002', 'ACTIVE', 'club-001', NOW()),
('m-003', 'Jonas', 'Fischer', 'jonas@example.com', '2005-11-01', 'GD-003', 'ACTIVE', 'club-001', NOW()),
('m-004', 'Sarah', 'Braun', 'sarah@example.com', '1995-01-30', 'GD-004', 'ACTIVE', 'club-001', NOW()),
('m-005', 'Kai', 'Hoffmann', 'kai@example.com', '1992-09-10', 'GD-005', 'ACTIVE', 'club-001', NOW())
ON CONFLICT (id) DO NOTHING;
-- Strains
INSERT INTO strains (id, name, default_thc_percent, default_cbd_percent, club_id)
VALUES
('s-001', 'Amnesia Haze', 22.0, 0.5, 'club-001'),
('s-002', 'White Widow', 18.0, 1.0, 'club-001'),
('s-003', 'Northern Lights', 20.0, 0.8, 'club-001')
ON CONFLICT (id) DO NOTHING;
-- Batches
INSERT INTO batches (id, strain_id, total_grams, available_grams, thc_percent, cbd_percent, supplier, status, club_id, received_at)
VALUES
('b-001', 's-001', 1000, 520, 22.0, 0.5, 'GreenGrow GmbH', 'AVAILABLE', 'club-001', NOW()),
('b-002', 's-002', 800, 430, 18.0, 1.0, 'BioHemp AG', 'AVAILABLE', 'club-001', NOW()),
('b-003', 's-003', 600, 380, 20.0, 0.8, 'GreenGrow GmbH', 'AVAILABLE', 'club-001', NOW())
ON CONFLICT (id) DO NOTHING;
```
### 3.11 Optimistic Update with Rollback (for Phase 4)
When recording a distribution, we optimistically update the UI (instant feedback) but roll back if the server rejects (e.g., quota exceeded):
```typescript
// src/services/distributions.ts — optimistic mutation pattern
export function useCreateDistribution() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateDistributionRequest) =>
apiFetch<Distribution>("/distributions", { method: "POST", body: JSON.stringify(data) }),
// Optimistic update: immediately reflect in UI
onMutate: async (newDistribution) => {
await queryClient.cancelQueries({ queryKey: ["distributions"] })
const previous = queryClient.getQueryData<Distribution[]>(["distributions"])
queryClient.setQueryData<Distribution[]>(["distributions"], (old = []) => [
{ ...newDistribution, id: "optimistic-temp", createdAt: new Date().toISOString() } as Distribution,
...old,
])
return { previous } // context for rollback
},
// Rollback on error
onError: (_err, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(["distributions"], context.previous)
}
},
// Always refetch after success or error to sync with server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["distributions"] })
queryClient.invalidateQueries({ queryKey: ["dashboard"] }) // refresh KPIs
},
})
}
```
**Key points:**
- `onMutate` saves the previous state and applies the optimistic change
- `onError` restores the previous state if the server returns 409 (quota exceeded) or 500
- `onSettled` always re-fetches the truth from the server regardless of success/failure
- The toast handler (from Section 3.5) shows the specific error message ("Kontingent überschritten: noch 12g verfügbar")
---
## 4. Directory Structure (New/Modified Files)
```
cannamanage-frontend/
├── src/
│ ├── lib/
│ │ ├── api-client.ts # MODIFIED — server-side apiServer()
│ │ ├── api-client-browser.ts # NEW — client-side apiFetch()
│ │ └── query-client.ts # NEW — React Query configuration
│ ├── services/ # NEW — domain service hooks
│ │ ├── auth.ts # useLogin, useLogout
│ │ ├── dashboard.ts # useClubStats
│ │ ├── members.ts # useMembers, useMember, useCreateMember, useUpdateMember
│ │ ├── distributions.ts # useDistributions, useCreateDistribution, useQuotaCheck
│ │ ├── stock.ts # useBatches, useCreateBatch, useRecallBatch
│ │ ├── reports.ts # useDownloadReport
│ │ ├── portal.ts # usePortalDashboard, usePortalHistory
│ │ └── staff.ts # useStaff, useInviteStaff, useUpdatePermissions
│ ├── components/cannamanage/
│ │ ├── skeleton-card.tsx # NEW — loading skeleton
│ │ ├── skeleton-table.tsx # NEW — table loading skeleton
│ │ ├── error-state.tsx # NEW — error boundary component
│ │ └── staff/ # NEW — staff management components
│ │ ├── staff-list.tsx
│ │ ├── invite-dialog.tsx
│ │ ├── permissions-editor.tsx
│ │ └── revoke-dialog.tsx
│ ├── hooks/
│ │ └── use-api-error.ts # NEW — error handler hook
│ ├── app/[locale]/(dashboard)/
│ │ ├── page.tsx # MODIFIED — real API data
│ │ ├── members/page.tsx # MODIFIED — real API data
│ │ ├── distributions/page.tsx # MODIFIED — real API data
│ │ ├── stock/page.tsx # MODIFIED — real API data
│ │ ├── reports/page.tsx # MODIFIED — real API data
│ │ └── settings/
│ │ └── staff/page.tsx # NEW — staff management page
│ ├── app/[locale]/(portal)/
│ │ ├── page.tsx # MODIFIED — real API data
│ │ └── history/page.tsx # MODIFIED — real API data
│ └── app/[locale]/providers.tsx # NEW — QueryClientProvider wrapper
├── e2e/
│ └── system-test.spec.ts # NEW — full-stack E2E test
└── package.json # MODIFIED — add @tanstack/react-query
cannamanage-api/
├── Dockerfile # NEW or MODIFIED
└── src/main/java/de/cannamanage/api/
├── security/SecurityConfig.java # MODIFIED — add CORS
└── config/CorsConfig.java # NEW (alternative: inline in SecurityConfig)
cannamanage-service/
└── src/main/resources/
└── db/migration/
└── R__test_seed.sql # NEW — repeatable test data migration
docker-compose.yml # MODIFIED — remove profiles, add healthchecks
docker-compose.test.yml # NEW — test overlay with Playwright
```
---
## 5. Implementation Phases
### Phase 1: Docker Compose Full Stack + Next.js Upgrade (1 day)
**Goal:** `docker compose up` brings up PostgreSQL + Spring Boot + Next.js, all healthy and communicating. Next.js upgraded to 15.5.18 for security.
**Tasks:**
1. **Next.js upgrade 15.2.8 → 15.5.18** — addresses 8+ Snyk CVEs (SSRF, auth bypass, resource exhaustion). Run `pnpm up next@15.5.18`, fix any breaking changes in `next.config.mjs`, verify all pages render.
2. Remove `profiles: [full]` from backend/frontend in `docker-compose.yml`
3. Add backend health check (curl actuator/health)
4. Add frontend dependency on `backend: condition: service_healthy`
5. Add NEXTAUTH environment variables to frontend service
6. Create/update `cannamanage-api/Dockerfile` (multi-stage Maven build)
7. Add CORS configuration to `SecurityConfig.java`
8. Add rate limiting to auth endpoints — Bucket4j or Spring `@RateLimiter` on `/api/v1/auth/login` (max 5 attempts/min per IP) and `/api/v1/staff/invite` (max 10/hour per user). Prevents brute-force attacks.
9. Verify: `docker compose up` → all 3 containers healthy → `curl http://localhost:8080/actuator/health` returns UP
10. Run existing Playwright E2E suite to confirm no regressions from Next.js upgrade
**Acceptance Criteria:**
- [ ] Next.js upgraded to 15.5.18 — `pnpm list next` shows correct version
- [ ] All 66+ existing Playwright E2E tests still pass after upgrade
- [ ] `docker compose up` starts all 3 services without errors
- [ ] Backend responds to `/actuator/health` within 30s of start
- [ ] Frontend loads at `http://localhost:3000` and displays login page
- [ ] CORS allows `localhost:3000` to call `localhost:8080/api/v1/auth/login`
- [ ] No `profiles` gate — services start by default
---
### Phase 2: API Client Layer (1 day)
**Goal:** Typed, reusable API layer with React Query for client-side data fetching.
**Tasks:**
1. `pnpm add @tanstack/react-query @tanstack/react-query-devtools`
2. Create `src/lib/query-client.ts` with default options
3. Create `src/lib/api-client-browser.ts` — client-side fetch via Next.js rewrite proxy
4. Refactor existing `src/lib/api-client.ts` → rename to server-side usage (`apiServer()`)
5. Create `src/app/[locale]/providers.tsx` with `QueryClientProvider`
6. Wire providers into root layout
7. Create `src/hooks/use-api-error.ts` — error → toast mapping
8. Create service files: `src/services/dashboard.ts`, `members.ts`, `distributions.ts`, `stock.ts`, `reports.ts`, `portal.ts`
9. Create skeleton components: `skeleton-card.tsx`, `skeleton-table.tsx`, `error-state.tsx`
10. Add React Query DevTools (dev only)
**Acceptance Criteria:**
- [ ] `@tanstack/react-query` installed and provider mounted
- [ ] `apiFetch()` calls go through `/api/backend/*` rewrite to Spring Boot
- [ ] Each domain has typed React Query hooks (queryKey, queryFn, types)
- [ ] Error handler shows German toast messages for 401/403/409/500
- [ ] Skeleton components render during loading state
- [ ] React Query DevTools visible in dev mode (bottom-left panel)
---
### Phase 3: Wire Dashboard + Members (1.5 days)
**Goal:** Dashboard shows real KPIs from backend. Member list fetches from real API.
**Tasks:**
1. **Dashboard page** — Replace `mockClubStats` with `useClubStats()` hook → `GET /clubs/stats`
2. **Dashboard recent distributions**`useRecentDistributions()``GET /distributions?limit=5`
3. **Dashboard stock chart**`useStockSummary()``GET /stock/batches/summary`
4. **Member list** — Replace `mockMembers` with `useMembers()``GET /members`
5. **Member detail**`useMember(id)``GET /members/{id}`
6. **Add member form**`useCreateMember()``POST /members`
7. **Edit member form**`useUpdateMember()``PUT /members/{id}`
8. Add loading skeletons to dashboard KPI cards and member table
9. Add error states with retry button
10. Handle empty states (no members yet, no distributions today)
**Backend adjustments needed:**
- Add `GET /api/v1/clubs/stats` endpoint if not existing (or adapt `ClubController`)
- Ensure `GET /api/v1/members` returns paginated response compatible with TanStack Table
- Verify `GET /api/v1/distributions` supports `?limit=N` query param
**Acceptance Criteria:**
- [ ] Dashboard loads real data — KPI cards show numbers from DB
- [ ] Member list shows real members from PostgreSQL
- [ ] Add member form creates a real member (visible after page refresh)
- [ ] Edit member form persists changes
- [ ] Loading skeletons appear during fetch
- [ ] Error state shows when backend is down (not a blank page)
- [ ] Empty state message when no data exists
- [ ] No mock data imports remain in dashboard or members pages
---
### Phase 4: Wire Distributions + Stock (1.5 days)
**Goal:** Distribution recording hits real backend with quota enforcement. Stock management is live.
**Tasks:**
1. **Distribution list**`useDistributions()``GET /distributions`
2. **New distribution form**`useCreateDistribution()``POST /distributions`
3. **Quota check (real-time)**`useQuotaCheck(memberId)``GET /compliance/quota/{memberId}`
4. Wire member search in distribution form to `GET /members?search=...`
5. Wire batch selection to `GET /stock/batches?status=AVAILABLE`
6. **Stock/batch list**`useBatches()``GET /stock/batches`
7. **Add batch form**`useCreateBatch()``POST /stock/batches`
8. **Recall batch**`useRecallBatch()``PUT /stock/batches/{id}/recall`
9. Handle quota exceeded error (409) → show specific toast with remaining grams
10. Optimistic update: after recording distribution, immediately update local cache
**Key integration points:**
- Distribution form must pass `batchId` + `memberId` + `amountGrams`
- Backend returns `409 Conflict` with `QuotaExceededException` details when over limit
- Stock chart re-fetches after batch creation or recall
**Acceptance Criteria:**
- [ ] Distribution recording persists to DB (verifiable via `GET /distributions`)
- [ ] Quota check prevents over-limit distribution (shows remaining grams in error)
- [ ] Under-21 members see 30g monthly limit enforced
- [ ] Batch creation adds real stock (visible in stock page)
- [ ] Batch recall changes status to RECALLED
- [ ] Stock chart updates after mutations
- [ ] No mock data imports remain in distributions or stock pages
---
### Phase 5: Wire Reports + Portal (1.5 days)
**Goal:** Report downloads generate real PDFs. Member portal shows live personal data.
**Tasks:**
1. **Monthly report**`useDownloadReport("monthly")``GET /reports/monthly?format=pdf`
2. **Member list report**`GET /reports/member-list?format=csv`
3. **Recall report**`GET /reports/recall?format=pdf`
4. Handle binary download (PDF/CSV) — create blob URL, trigger download
5. **Portal dashboard**`usePortalDashboard()``GET /portal/dashboard`
6. **Portal history**`usePortalHistory()``GET /portal/history`
7. Portal auth: wire session-based login (`/portal/login` form submit)
8. Show real quota radial with live monthly usage percentage
9. Add date range picker for report generation (month selector)
**Special handling:**
- Report endpoints return binary (PDF) or text (CSV), not JSON — fetch with `responseType: blob`
- Portal uses session auth (not JWT) — different fetch pattern (cookies, CSRF token)
- Portal API calls go to `/portal/*` endpoints, not `/api/v1/*`
**Acceptance Criteria:**
- [ ] PDF report downloads as file (opens in browser/downloads)
- [ ] CSV report downloads correctly formatted
- [ ] Portal login with member credentials works
- [ ] Portal dashboard shows real quota (used/remaining from DB)
- [ ] Portal distribution history shows actual past distributions
- [ ] No mock data imports remain in reports or portal pages
---
### Phase 6: Staff Management UI (2 days)
**Goal:** Admin can invite staff, configure permissions, and revoke access through the UI.
**Tasks:**
1. Create staff settings page at `/settings/staff`
2. Add navigation entry in sidebar (under Settings section)
3. **Staff list component** — table with name, email, permissions, status, actions
4. **Invite dialog** — email input + permission checkboxes (8 granular permissions)
5. **Permissions editor** — inline permission toggle grid per staff member
6. **Revoke dialog** — confirmation dialog → `DELETE /staff/{id}`
7. Wire to backend:
- `GET /api/v1/staff` → list staff
- `POST /api/v1/staff/invite` → send invite
- `PUT /api/v1/staff/{id}/permissions` → update permissions
- `DELETE /api/v1/staff/{id}` → revoke
8. Show pending invites (status: PENDING) with resend option
9. Add i18n strings for staff management (de.json + en.json)
10. Permission chips: visual badges for each permission (color-coded)
**Permissions (from backend `StaffPermission` enum):**
- `MANAGE_MEMBERS` — add/edit/suspend members
- `RECORD_DISTRIBUTIONS` — record cannabis distributions
- `MANAGE_STOCK` — add batches, recall
- `VIEW_REPORTS` — download reports
- `MANAGE_CLUB_SETTINGS` — edit club configuration
- `MANAGE_COMPLIANCE` — compliance dashboard access
- `INVITE_STAFF` — can invite other staff
- `MANAGE_STAFF` — full staff CRUD (admin-like)
**Acceptance Criteria:**
- [ ] Staff list page accessible from sidebar navigation
- [ ] Invite form sends email invite (or shows success if email service is stubbed)
- [ ] Permission editor shows 8 checkboxes per staff member
- [ ] Saving permissions persists to DB
- [ ] Revoke removes staff access (deleted from list)
- [ ] Pending invites shown with "Resend" action
- [ ] Only ADMIN role can access staff settings (403 for STAFF role without MANAGE_STAFF)
- [ ] i18n: all labels in both German and English
---
### Phase 7: System Test Harness (2 days)
**Goal:** One command runs the full stack with seed data and executes E2E tests against real backend.
**Tasks:**
1. Create `R__test_seed.sql` — repeatable Flyway migration with deterministic test data
2. Configure Spring profile `test-seed` to activate repeatable migration
3. Create `docker-compose.test.yml` overlay (Playwright container + test-seed profile)
4. Create `e2e/system-test.spec.ts` — full integration flow:
- Login as admin → verify dashboard loads with seed data
- Navigate to members → verify 5 seed members visible
- Add new member → verify appears in list
- Record distribution → verify quota updates
- Check stock decreases after distribution
- Download monthly report (verify PDF response)
- Login as member (portal) → verify personal quota visible
5. Add npm script: `"test:system": "docker compose -f ... up --abort-on-container-exit"`
6. Verify CI-readiness: exit code 0 on success, non-zero on failure
7. Add GitHub Actions workflow stub (`.github/workflows/system-test.yml`)
**Test flow (happy path):**
```
1. Admin login (admin@gruener-daumen.de / admin123)
2. Dashboard → verify KPIs match seed data (5 members, 3 batches)
3. Members → see "Max Müller", "Lisa Weber", etc.
4. Add member "Test Neuzugang" → verify appears
5. Distributions → record 10g Amnesia Haze to Max Müller
6. Verify quota check shows updated usage
7. Stock → verify Amnesia Haze batch decreased by 10g
8. Reports → download monthly PDF → verify 200 response + content-type
9. Portal login (max@example.com / member123)
10. Portal dashboard → verify quota shows 10g used
```
**Acceptance Criteria:**
- [ ] `docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit` runs full test
- [ ] Seed data creates club + admin + staff + 5 members + 3 batches
- [ ] System test covers: login → CRUD → quota → report → portal
- [ ] Test passes in < 3 minutes on clean start
- [ ] Exit code 0 on success, non-zero on any test failure
- [ ] Test is deterministic (no flaky timing issues)
- [ ] GitHub Actions workflow file created (can be enabled when GitHub repo is set up)
---
## 6. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Backend API mismatch (frontend expects fields that don't exist) | Medium | High | Read actual controller code before wiring; add DTO type assertions |
| NextAuth session timeout during system test | Medium | Medium | Set long session expiry in test profile (24h) |
| Docker build slow (Maven + Node rebuild each time) | High | Low | Use Docker build cache, `.dockerignore` excludes target/ |
| CORS issues between containers | Medium | Medium | Test CORS early in Phase 1; add integration test for preflight |
| React Query cache stale data after mutation | Low | Medium | Explicit `invalidateQueries` after every mutation |
| Seed data conflicts with Hibernate DDL auto | Medium | High | Ensure `spring.jpa.hibernate.ddl-auto=validate` in Docker profile (Flyway handles schema) |
| Playwright flaky on container startup timing | High | Medium | Add `waitForURL` + health check polling before test start |
| Backend Dockerfile missing (never existed) | Medium | Low | Create from scratch in Phase 1 — straightforward multi-stage Maven |
---
## 7. Open Questions — RESOLVED ✅
All questions resolved in v2 planning session (2026-06-12):
| # | Question | Decision | Rationale |
|---|----------|----------|-----------|
| Q1 | API client library? | ✅ **@tanstack/react-query** | Caching, refetch, optimistic updates built-in. Eliminates `useEffect` + `useState` boilerplate. DevTools for debugging. |
| Q2 | Loading strategy? | ✅ **Per-component loading** | Each card/table loads independently — no full-page blocking. Better perceived performance. |
| Q3 | Offline resilience? | ✅ **Stale-while-revalidate + "Offline" banner** | React Query serves cached data while retrying. Persistent banner communicates status without blocking interaction. |
| Q4 | Staff UI scope? | ✅ **Full CRUD** (list + invite + edit perms + revoke) | Activity log deferred to Sprint 6. Full CRUD is the minimum viable staff management. |
| Q5 | Seed data strategy? | ✅ **SQL for dev/test + API-driven for system E2E** | Dual approach: fast deterministic SQL seed for local dev, API-driven setup in system tests for realistic coverage. |
| Q6 (Bonus) | Next.js upgrade? | ✅ **Yes — 15.2.8 → 15.5.18 in Phase 1** | Addresses 8+ Snyk CVEs. Done early to catch breaking changes before integration work begins. |
Previously resolved (from v1):
| # | Question | Decision |
|---|----------|----------|
| Q-v1-1 | Proxy vs direct CORS? | **Proxy** (Next.js rewrite) + CORS for dev flexibility |
| Q-v1-2 | Activity log timing? | **Defer to Sprint 6** |
| Q-v1-3 | System test config? | **Separate Playwright config** for Docker-based system tests |
| Q-v1-4 | Keep mock data files? | **Yes** — fast E2E (<30s) coexists with system tests (minutes) |
---
## 8. Dependencies & Prerequisites
| Prerequisite | Status | Action |
|-------------|--------|--------|
| Backend compiles and starts on current `main` | ✅ Done | Sprint 3 verified |
| Backend `Dockerfile` exists | ❓ Check | Create in Phase 1 if missing |
| Backend `actuator/health` endpoint enabled | ✅ Done | Spring Boot default |
| Frontend `types/api.ts` matches backend DTOs | ⚠️ Partial | Verify during Phase 3; add missing types |
| PostgreSQL 16 compatible with Hibernate schema | ✅ Done | Testcontainers use PG 16 |
| `pnpm` and Node 22 available locally | ✅ Done | Sprint 4 verified |
| Docker Desktop running | Required | Developer responsibility |
---
## 9. Success Metrics (End of Sprint 5)
| Metric | Target |
|--------|--------|
| Pages using real API data | 12/12 (all pages, zero mock) |
| Docker Compose startup → healthy | < 60 seconds |
| System test pass rate | 100% (deterministic) |
| System test duration | < 3 minutes |
| API error handling coverage | 401, 403, 404, 409, 500 all handled |
| Staff management operations | List, Invite, Edit Permissions, Revoke |
| Remaining mock data files | Kept for fast E2E (not used in production pages) |
---
## 10. References
- Sprint 4 Plan: `docs/sprint-4/cannamanage-sprint4-plan.md` (v3)
- Sprint 5 Backlog: `docs/sprint-5/cannamanage-sprint5-backlog.md`
- Sprint 4 Persona Review: `docs/sprint-4/cannamanage-sprint4-plan-persona-review.md`
- Backend Security Config: `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java`
- Frontend API Types: `cannamanage-frontend/src/types/api.ts`
- Docker Compose: `docker-compose.yml`