- @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)
37 KiB
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 1–5: Full-stack integration (~7 days)
- Sprint 5.b — Phases 6–7: 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:
// 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:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
3.2 TanStack React Query Setup (Decision D1)
// 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:
// 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:
// 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()
}
// 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:
// 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
// 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
// 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)
# 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
# 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)
# 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)
-- 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):
// 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:
onMutatesaves the previous state and applies the optimistic changeonErrorrestores the previous state if the server returns 409 (quota exceeded) or 500onSettledalways 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:
- 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 innext.config.mjs, verify all pages render. - Remove
profiles: [full]from backend/frontend indocker-compose.yml - Add backend health check (curl actuator/health)
- Add frontend dependency on
backend: condition: service_healthy - Add NEXTAUTH environment variables to frontend service
- Create/update
cannamanage-api/Dockerfile(multi-stage Maven build) - Add CORS configuration to
SecurityConfig.java - Add rate limiting to auth endpoints — Bucket4j or Spring
@RateLimiteron/api/v1/auth/login(max 5 attempts/min per IP) and/api/v1/staff/invite(max 10/hour per user). Prevents brute-force attacks. - Verify:
docker compose up→ all 3 containers healthy →curl http://localhost:8080/actuator/healthreturns UP - Run existing Playwright E2E suite to confirm no regressions from Next.js upgrade
Acceptance Criteria:
- Next.js upgraded to 15.5.18 —
pnpm list nextshows correct version - All 66+ existing Playwright E2E tests still pass after upgrade
docker compose upstarts all 3 services without errors- Backend responds to
/actuator/healthwithin 30s of start - Frontend loads at
http://localhost:3000and displays login page - CORS allows
localhost:3000to calllocalhost:8080/api/v1/auth/login - No
profilesgate — 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:
pnpm add @tanstack/react-query @tanstack/react-query-devtools- Create
src/lib/query-client.tswith default options - Create
src/lib/api-client-browser.ts— client-side fetch via Next.js rewrite proxy - Refactor existing
src/lib/api-client.ts→ rename to server-side usage (apiServer()) - Create
src/app/[locale]/providers.tsxwithQueryClientProvider - Wire providers into root layout
- Create
src/hooks/use-api-error.ts— error → toast mapping - Create service files:
src/services/dashboard.ts,members.ts,distributions.ts,stock.ts,reports.ts,portal.ts - Create skeleton components:
skeleton-card.tsx,skeleton-table.tsx,error-state.tsx - Add React Query DevTools (dev only)
Acceptance Criteria:
@tanstack/react-queryinstalled and provider mountedapiFetch()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:
- Dashboard page — Replace
mockClubStatswithuseClubStats()hook →GET /clubs/stats - Dashboard recent distributions —
useRecentDistributions()→GET /distributions?limit=5 - Dashboard stock chart —
useStockSummary()→GET /stock/batches/summary - Member list — Replace
mockMemberswithuseMembers()→GET /members - Member detail —
useMember(id)→GET /members/{id} - Add member form —
useCreateMember()→POST /members - Edit member form —
useUpdateMember()→PUT /members/{id} - Add loading skeletons to dashboard KPI cards and member table
- Add error states with retry button
- Handle empty states (no members yet, no distributions today)
Backend adjustments needed:
- Add
GET /api/v1/clubs/statsendpoint if not existing (or adaptClubController) - Ensure
GET /api/v1/membersreturns paginated response compatible with TanStack Table - Verify
GET /api/v1/distributionssupports?limit=Nquery 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:
- Distribution list —
useDistributions()→GET /distributions - New distribution form —
useCreateDistribution()→POST /distributions - Quota check (real-time) —
useQuotaCheck(memberId)→GET /compliance/quota/{memberId} - Wire member search in distribution form to
GET /members?search=... - Wire batch selection to
GET /stock/batches?status=AVAILABLE - Stock/batch list —
useBatches()→GET /stock/batches - Add batch form —
useCreateBatch()→POST /stock/batches - Recall batch —
useRecallBatch()→PUT /stock/batches/{id}/recall - Handle quota exceeded error (409) → show specific toast with remaining grams
- Optimistic update: after recording distribution, immediately update local cache
Key integration points:
- Distribution form must pass
batchId+memberId+amountGrams - Backend returns
409 ConflictwithQuotaExceededExceptiondetails 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:
- Monthly report —
useDownloadReport("monthly")→GET /reports/monthly?format=pdf - Member list report —
GET /reports/member-list?format=csv - Recall report —
GET /reports/recall?format=pdf - Handle binary download (PDF/CSV) — create blob URL, trigger download
- Portal dashboard —
usePortalDashboard()→GET /portal/dashboard - Portal history —
usePortalHistory()→GET /portal/history - Portal auth: wire session-based login (
/portal/loginform submit) - Show real quota radial with live monthly usage percentage
- 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:
- Create staff settings page at
/settings/staff - Add navigation entry in sidebar (under Settings section)
- Staff list component — table with name, email, permissions, status, actions
- Invite dialog — email input + permission checkboxes (8 granular permissions)
- Permissions editor — inline permission toggle grid per staff member
- Revoke dialog — confirmation dialog →
DELETE /staff/{id} - Wire to backend:
GET /api/v1/staff→ list staffPOST /api/v1/staff/invite→ send invitePUT /api/v1/staff/{id}/permissions→ update permissionsDELETE /api/v1/staff/{id}→ revoke
- Show pending invites (status: PENDING) with resend option
- Add i18n strings for staff management (de.json + en.json)
- Permission chips: visual badges for each permission (color-coded)
Permissions (from backend StaffPermission enum):
MANAGE_MEMBERS— add/edit/suspend membersRECORD_DISTRIBUTIONS— record cannabis distributionsMANAGE_STOCK— add batches, recallVIEW_REPORTS— download reportsMANAGE_CLUB_SETTINGS— edit club configurationMANAGE_COMPLIANCE— compliance dashboard accessINVITE_STAFF— can invite other staffMANAGE_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:
- Create
R__test_seed.sql— repeatable Flyway migration with deterministic test data - Configure Spring profile
test-seedto activate repeatable migration - Create
docker-compose.test.ymloverlay (Playwright container + test-seed profile) - 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
- Add npm script:
"test:system": "docker compose -f ... up --abort-on-container-exit" - Verify CI-readiness: exit code 0 on success, non-zero on failure
- 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-exitruns 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