# 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:

```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`
