Files
cannamanage/docs/sprint-5/cannamanage-sprint5-plan.md
T
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

37 KiB
Raw Blame History

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:

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

  • 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 distributionsuseRecentDistributions()GET /distributions?limit=5
  3. Dashboard stock chartuseStockSummary()GET /stock/batches/summary
  4. Member list — Replace mockMembers with useMembers()GET /members
  5. Member detailuseMember(id)GET /members/{id}
  6. Add member formuseCreateMember()POST /members
  7. Edit member formuseUpdateMember()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 listuseDistributions()GET /distributions
  2. New distribution formuseCreateDistribution()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 listuseBatches()GET /stock/batches
  7. Add batch formuseCreateBatch()POST /stock/batches
  8. Recall batchuseRecallBatch()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 reportuseDownloadReport("monthly")GET /reports/monthly?format=pdf
  2. Member list reportGET /reports/member-list?format=csv
  3. Recall reportGET /reports/recall?format=pdf
  4. Handle binary download (PDF/CSV) — create blob URL, trigger download
  5. Portal dashboardusePortalDashboard()GET /portal/dashboard
  6. Portal historyusePortalHistory()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