From f42c166329be835040fed171923f3d5aa568eab3 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Fri, 12 Jun 2026 19:59:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-5):=20Phase=202=20=E2=80=94=20React?= =?UTF-8?q?=20Query=20API=20client=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @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) --- .../test-finished-1.png | Bin 0 -> 107321 bytes cannamanage-frontend/messages/de.json | 12 + cannamanage-frontend/messages/en.json | 12 + cannamanage-frontend/package.json | 1 + cannamanage-frontend/pnpm-lock.yaml | 18 + .../src/components/api-error-boundary.tsx | 75 ++ .../src/components/offline-banner.tsx | 46 + .../src/components/ui/data-skeleton.tsx | 82 ++ cannamanage-frontend/src/lib/api-client.ts | 193 ++++ cannamanage-frontend/src/providers/index.tsx | 35 +- .../src/services/dashboard.ts | 25 + .../src/services/distributions.ts | 97 ++ cannamanage-frontend/src/services/members.ts | 102 ++ cannamanage-frontend/src/services/portal.ts | 83 ++ cannamanage-frontend/src/services/reports.ts | 113 +++ cannamanage-frontend/src/services/staff.ts | 75 ++ cannamanage-frontend/src/services/stock.ts | 95 ++ .../cannamanage-sprint5-plan-review.md | 288 ++++++ docs/sprint-5/cannamanage-sprint5-plan.md | 890 ++++++++++++++++++ docs/sprint-5/cannamanage-sprint5-testplan.md | 640 +++++++++++++ 20 files changed, 2875 insertions(+), 7 deletions(-) create mode 100644 cannamanage-frontend/e2e/test-results/authenticated-tour-Authent-9bae5--screenshot-all-admin-pages-chromium/test-finished-1.png create mode 100644 cannamanage-frontend/src/components/api-error-boundary.tsx create mode 100644 cannamanage-frontend/src/components/offline-banner.tsx create mode 100644 cannamanage-frontend/src/components/ui/data-skeleton.tsx create mode 100644 cannamanage-frontend/src/lib/api-client.ts create mode 100644 cannamanage-frontend/src/services/dashboard.ts create mode 100644 cannamanage-frontend/src/services/distributions.ts create mode 100644 cannamanage-frontend/src/services/members.ts create mode 100644 cannamanage-frontend/src/services/portal.ts create mode 100644 cannamanage-frontend/src/services/reports.ts create mode 100644 cannamanage-frontend/src/services/staff.ts create mode 100644 cannamanage-frontend/src/services/stock.ts create mode 100644 docs/sprint-5/cannamanage-sprint5-plan-review.md create mode 100644 docs/sprint-5/cannamanage-sprint5-plan.md create mode 100644 docs/sprint-5/cannamanage-sprint5-testplan.md diff --git a/cannamanage-frontend/e2e/test-results/authenticated-tour-Authent-9bae5--screenshot-all-admin-pages-chromium/test-finished-1.png b/cannamanage-frontend/e2e/test-results/authenticated-tour-Authent-9bae5--screenshot-all-admin-pages-chromium/test-finished-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6bdb204c0e4209243f58cc5c9f377d41ab9196d8 GIT binary patch literal 107321 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%0Dy~&y<={% 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
xqajvDP!cTAiIi$rwu1$`AUtG>0pI%#GVtyb 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

w4azx={=W)fe=-S1Z#6+#Hj{NZDX;! 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.0.0 || insiders || >=4.0.0-alpha.20' + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -4918,6 +4929,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.3 + '@tanstack/query-core@5.101.0': {} + + '@tanstack/react-query@5.101.0(react@19.1.3)': + dependencies: + '@tanstack/query-core': 5.101.0 + react: 19.1.3 + '@tanstack/react-table@8.21.3(react-dom@19.1.3(react@19.1.3))(react@19.1.3)': dependencies: '@tanstack/table-core': 8.21.3 diff --git a/cannamanage-frontend/src/components/api-error-boundary.tsx b/cannamanage-frontend/src/components/api-error-boundary.tsx new file mode 100644 index 0000000..f318f5b --- /dev/null +++ b/cannamanage-frontend/src/components/api-error-boundary.tsx @@ -0,0 +1,75 @@ +"use client" + +import { Component } from "react" +import { AlertCircle, RefreshCw } from "lucide-react" + +import type { ErrorInfo, ReactNode } from "react" + +import { ApiError } from "@/lib/api-client" + +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" + +interface ApiErrorBoundaryProps { + children: ReactNode + fallbackMessage?: string +} + +interface ApiErrorBoundaryState { + hasError: boolean + error: Error | null +} + +export class ApiErrorBoundary extends Component< + ApiErrorBoundaryProps, + ApiErrorBoundaryState +> { + constructor(props: ApiErrorBoundaryProps) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): ApiErrorBoundaryState { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[ApiErrorBoundary]", error, errorInfo) + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError) { + const error = this.state.error + const isApiError = error instanceof ApiError + const message = isApiError + ? error.message + : this.props.fallbackMessage || "An unexpected error occurred." + + return ( + + + +

+ + + + ) + } + + return this.props.children + } +} diff --git a/cannamanage-frontend/src/components/offline-banner.tsx b/cannamanage-frontend/src/components/offline-banner.tsx new file mode 100644 index 0000000..88497e7 --- /dev/null +++ b/cannamanage-frontend/src/components/offline-banner.tsx @@ -0,0 +1,46 @@ +"use client" + +import { useEffect, useState } from "react" +import { onlineManager } from "@tanstack/react-query" +import { useTranslations } from "next-intl" +import { WifiOff } from "lucide-react" + +import type { ReactNode } from "react" + +export function OfflineBanner() { + const t = useTranslations("api") + const [isOnline, setIsOnline] = useState(true) + + useEffect(() => { + // Subscribe to online manager state changes + const unsubscribe = onlineManager.subscribe((online) => { + setIsOnline(online) + }) + // Set initial state + setIsOnline(onlineManager.isOnline()) + return () => unsubscribe() + }, []) + + if (isOnline) return null + + return ( +
+ + {t("offline")} +
+ ) +} + +/** + * Wrapper that prevents hydration mismatch for online/offline state. + */ +export function OfflineBannerWrapper({ children }: { children: ReactNode }) { + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + if (!mounted) return null + return <>{children} +} diff --git a/cannamanage-frontend/src/components/ui/data-skeleton.tsx b/cannamanage-frontend/src/components/ui/data-skeleton.tsx new file mode 100644 index 0000000..3f4b30b --- /dev/null +++ b/cannamanage-frontend/src/components/ui/data-skeleton.tsx @@ -0,0 +1,82 @@ +import { Skeleton } from "@/components/ui/skeleton" + +/** + * Skeleton for KPI stat cards on the dashboard. + */ +export function CardSkeleton() { + return ( +
+ + + +
+ ) +} + +/** + * Skeleton for a data table (header + rows). + */ +export function TableSkeleton({ + rows = 5, + columns = 4, +}: { + rows?: number + columns?: number +}) { + return ( +
+ {/* Header */} +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+ {/* Rows */} + {Array.from({ length: rows }).map((_, rowIdx) => ( +
+ {Array.from({ length: columns }).map((_, colIdx) => ( + + ))} +
+ ))} +
+ ) +} + +/** + * Skeleton for chart / Recharts areas. + */ +export function ChartSkeleton() { + return ( +
+ + +
+ ) +} + +/** + * Skeleton for a single form field. + */ +export function FormFieldSkeleton() { + return ( +
+ + +
+ ) +} + +/** + * Grid of card skeletons for the dashboard. + */ +export function DashboardSkeleton() { + return ( +
+ + + + +
+ ) +} diff --git a/cannamanage-frontend/src/lib/api-client.ts b/cannamanage-frontend/src/lib/api-client.ts new file mode 100644 index 0000000..2d63817 --- /dev/null +++ b/cannamanage-frontend/src/lib/api-client.ts @@ -0,0 +1,193 @@ +/** + * Typed fetch wrapper for the CannaManage API. + * + * Routes through the Next.js rewrite proxy: + * /api/backend/... → BACKEND_URL/api/v1/... + */ + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string + ) { + super(message) + this.name = "ApiError" + } + + /** True for 401/403 — session likely expired or insufficient permissions */ + get isAuthError(): boolean { + return this.status === 401 || this.status === 403 + } + + /** True for 5xx — server-side failure */ + get isServerError(): boolean { + return this.status >= 500 + } + + /** True for network failures (status 0) */ + get isNetworkError(): boolean { + return this.status === 0 + } +} + +export interface ApiClientOptions extends Omit { + token?: string + body?: unknown + params?: Record +} + +/** + * Core fetch wrapper that targets the Next.js rewrite proxy. + * + * @param endpoint - API path without the prefix, e.g. `/members` or `/members/123` + * @param options - Fetch options + optional token and typed body + * @returns Parsed JSON response of type T + * @throws ApiError on non-2xx responses + */ +export async function apiClient( + endpoint: string, + options: ApiClientOptions = {} +): Promise { + const { + token, + body, + params, + headers: extraHeaders, + ...fetchOptions + } = options + + // Build URL with optional query params + let url = `/api/backend${endpoint}` + if (params) { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.set(key, String(value)) + } + } + const qs = searchParams.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + ...(extraHeaders as Record), + } + + // Don't set Content-Type for GET/HEAD (no body) + const method = (fetchOptions.method || "GET").toUpperCase() + if (method === "GET" || method === "HEAD") { + delete headers["Content-Type"] + } + + try { + const res = await fetch(url, { + ...fetchOptions, + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + // Try to parse application/problem+json or standard error body + const errorBody = await res.json().catch(() => ({ + code: `HTTP_${res.status}`, + message: res.statusText || "Request failed", + })) + + throw new ApiError( + res.status, + errorBody.code || `HTTP_${res.status}`, + errorBody.message || errorBody.detail || res.statusText + ) + } + + // 204 No Content — return undefined as T + if (res.status === 204) { + return undefined as T + } + + return res.json() as Promise + } catch (error) { + if (error instanceof ApiError) { + throw error + } + // Network errors (fetch throws TypeError for network failures) + throw new ApiError( + 0, + "NETWORK_ERROR", + "Network error — unable to reach the server." + ) + } +} + +/** + * Download a file (PDF, CSV) from the API as a Blob. + * Used for report downloads where we don't parse JSON. + */ +export async function apiDownload( + endpoint: string, + options: ApiClientOptions = {} +): Promise<{ blob: Blob; filename: string }> { + const { + token, + body, + params, + headers: extraHeaders, + ...fetchOptions + } = options + + let url = `/api/backend${endpoint}` + if (params) { + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.set(key, String(value)) + } + } + const qs = searchParams.toString() + if (qs) url += `?${qs}` + } + + const headers: Record = { + Accept: "application/octet-stream", + ...(token && { Authorization: `Bearer ${token}` }), + ...(extraHeaders as Record), + } + + try { + const res = await fetch(url, { + ...fetchOptions, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + const errorBody = await res.json().catch(() => ({ + code: `HTTP_${res.status}`, + message: res.statusText, + })) + throw new ApiError( + res.status, + errorBody.code || `HTTP_${res.status}`, + errorBody.message || res.statusText + ) + } + + const blob = await res.blob() + const disposition = res.headers.get("Content-Disposition") || "" + const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/) + const filename = filenameMatch?.[1] || "download" + + return { blob, filename } + } catch (error) { + if (error instanceof ApiError) throw error + throw new ApiError( + 0, + "NETWORK_ERROR", + "Network error — unable to reach the server." + ) + } +} diff --git a/cannamanage-frontend/src/providers/index.tsx b/cannamanage-frontend/src/providers/index.tsx index 7928a7b..7734c80 100644 --- a/cannamanage-frontend/src/providers/index.tsx +++ b/cannamanage-frontend/src/providers/index.tsx @@ -1,5 +1,7 @@ "use client" +import { useState } from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { SessionProvider } from "next-auth/react" import type { LocaleType } from "@/types" @@ -17,15 +19,34 @@ export function Providers({ locale: LocaleType children: ReactNode }>) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: true, + }, + mutations: { + retry: 0, + }, + }, + }) + ) + return ( - - - - {children} - - - + + + + + {children} + + + + ) } diff --git a/cannamanage-frontend/src/services/dashboard.ts b/cannamanage-frontend/src/services/dashboard.ts new file mode 100644 index 0000000..0b32738 --- /dev/null +++ b/cannamanage-frontend/src/services/dashboard.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query" + +import type { ClubStats, Distribution } from "@/types/api" + +import { apiClient } from "@/lib/api-client" + +// --- Query Hooks --- + +export function useClubStatsQuery() { + return useQuery({ + queryKey: ["dashboard", "stats"], + queryFn: () => apiClient("/dashboard/stats"), + refetchInterval: 60 * 1000, // auto-refresh every 60s + }) +} + +export function useRecentDistributionsQuery(limit = 5) { + return useQuery({ + queryKey: ["dashboard", "recent-distributions", limit], + queryFn: () => + apiClient("/distributions/recent", { + params: { limit }, + }), + }) +} diff --git a/cannamanage-frontend/src/services/distributions.ts b/cannamanage-frontend/src/services/distributions.ts new file mode 100644 index 0000000..b4245bb --- /dev/null +++ b/cannamanage-frontend/src/services/distributions.ts @@ -0,0 +1,97 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import type { + AvailableBatch, + Distribution, + DistributionRecord, + QuotaStatus, +} from "@/types/api" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export interface DistributionsPage { + content: DistributionRecord[] + totalElements: number + totalPages: number + number: number + size: number +} + +export interface CreateDistributionRequest { + memberId: string + batchId: string + amountGrams: number +} + +// --- Query Hooks --- + +export function useDistributionsQuery(params?: { + page?: number + size?: number + memberId?: string + from?: string + to?: string +}) { + return useQuery({ + queryKey: ["distributions", params], + queryFn: () => + apiClient("/distributions", { + params: { + page: params?.page, + size: params?.size ?? 20, + memberId: params?.memberId || undefined, + from: params?.from || undefined, + to: params?.to || undefined, + }, + }), + }) +} + +export function useRecentDistributionsQuery(limit = 5) { + return useQuery({ + queryKey: ["distributions", "recent", limit], + queryFn: () => + apiClient("/distributions/recent", { + params: { limit }, + }), + }) +} + +export function useQuotaQuery(memberId: string) { + return useQuery({ + queryKey: ["members", memberId, "quota"], + queryFn: () => apiClient(`/members/${memberId}/quota`), + enabled: !!memberId, + }) +} + +export function useAvailableBatchesQuery() { + return useQuery({ + queryKey: ["batches", "available"], + queryFn: () => apiClient("/batches/available"), + }) +} + +// --- Mutation Hooks --- + +export function useCreateDistributionMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateDistributionRequest) => + apiClient("/distributions", { + method: "POST", + body: data, + }), + onSuccess: (_data, variables) => { + // Invalidate distribution list + member quota + queryClient.invalidateQueries({ queryKey: ["distributions"] }) + queryClient.invalidateQueries({ + queryKey: ["members", variables.memberId, "quota"], + }) + queryClient.invalidateQueries({ queryKey: ["batches"] }) + queryClient.invalidateQueries({ queryKey: ["dashboard"] }) + }, + }) +} diff --git a/cannamanage-frontend/src/services/members.ts b/cannamanage-frontend/src/services/members.ts new file mode 100644 index 0000000..3d57047 --- /dev/null +++ b/cannamanage-frontend/src/services/members.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import type { Member, QuotaStatus } from "@/types/api" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export interface MembersPage { + content: Member[] + totalElements: number + totalPages: number + number: number + size: number +} + +export interface CreateMemberRequest { + firstName: string + lastName: string + email: string + dateOfBirth: string + phone?: string + notes?: string +} + +export interface UpdateMemberRequest extends Partial { + status?: "ACTIVE" | "SUSPENDED" | "EXPELLED" +} + +// --- Query Hooks --- + +export function useMembersQuery(params?: { + page?: number + size?: number + search?: string + status?: string +}) { + return useQuery({ + queryKey: ["members", params], + queryFn: () => + apiClient("/members", { + params: { + page: params?.page, + size: params?.size ?? 20, + search: params?.search || undefined, + status: params?.status || undefined, + }, + }), + }) +} + +export function useMemberQuery(id: string) { + return useQuery({ + queryKey: ["members", id], + queryFn: () => apiClient(`/members/${id}`), + enabled: !!id, + }) +} + +export function useMemberQuotaQuery(id: string) { + return useQuery({ + queryKey: ["members", id, "quota"], + queryFn: () => apiClient(`/members/${id}/quota`), + enabled: !!id, + }) +} + +// --- Mutation Hooks --- + +export function useCreateMemberMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateMemberRequest) => + apiClient("/members", { method: "POST", body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["members"] }) + }, + }) +} + +export function useUpdateMemberMutation(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: UpdateMemberRequest) => + apiClient(`/members/${id}`, { method: "PUT", body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["members"] }) + queryClient.invalidateQueries({ queryKey: ["members", id] }) + }, + }) +} + +export function useDeleteMemberMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiClient(`/members/${id}`, { method: "DELETE" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["members"] }) + }, + }) +} diff --git a/cannamanage-frontend/src/services/portal.ts b/cannamanage-frontend/src/services/portal.ts new file mode 100644 index 0000000..b76698c --- /dev/null +++ b/cannamanage-frontend/src/services/portal.ts @@ -0,0 +1,83 @@ +import { useQuery } from "@tanstack/react-query" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export interface PortalDashboardData { + memberName: string + memberNumber: string + quotaStatus: { + dailyUsedGrams: number + dailyLimitGrams: number + monthlyUsedGrams: number + monthlyLimitGrams: number + isUnder21: boolean + } + lastDistribution?: { + strainName: string + amountGrams: number + recordedAt: string + } +} + +export interface PortalHistoryEntry { + id: string + strainName: string + amountGrams: number + recordedAt: string +} + +export interface PortalHistoryPage { + content: PortalHistoryEntry[] + totalElements: number + totalPages: number + number: number + size: number +} + +export interface PortalProfileData { + firstName: string + lastName: string + email: string + phone?: string + dateOfBirth: string + memberNumber: string + memberSince: string + status: "ACTIVE" | "SUSPENDED" | "EXPELLED" +} + +// --- Query Hooks --- + +export function usePortalDashboardQuery() { + return useQuery({ + queryKey: ["portal", "dashboard"], + queryFn: () => apiClient("/portal/dashboard"), + }) +} + +export function usePortalHistoryQuery(params?: { + page?: number + size?: number + month?: string +}) { + return useQuery({ + queryKey: ["portal", "history", params], + queryFn: () => + apiClient("/portal/history", { + params: { + page: params?.page, + size: params?.size ?? 20, + month: params?.month || undefined, + }, + }), + }) +} + +export function usePortalProfileQuery() { + return useQuery({ + queryKey: ["portal", "profile"], + queryFn: () => apiClient("/portal/profile"), + staleTime: 5 * 60 * 1000, // profile rarely changes + }) +} diff --git a/cannamanage-frontend/src/services/reports.ts b/cannamanage-frontend/src/services/reports.ts new file mode 100644 index 0000000..b25de97 --- /dev/null +++ b/cannamanage-frontend/src/services/reports.ts @@ -0,0 +1,113 @@ +import { useQuery } from "@tanstack/react-query" + +import { apiClient, apiDownload } from "@/lib/api-client" + +// --- Types --- + +export interface MonthlyReportData { + month: string // YYYY-MM + totalDistributions: number + totalGrams: number + uniqueMembers: number + averagePerDistribution: number + topStrains: { name: string; grams: number }[] +} + +export interface MemberListReportData { + totalMembers: number + activeMembers: number + suspendedMembers: number + expelledMembers: number + members: { id: string; name: string; status: string; joinedAt: string }[] +} + +export interface RecallReportData { + totalRecalls: number + batches: { + id: string + strainName: string + recalledAt: string + reason: string + gramsAffected: number + }[] +} + +// --- Query Hooks (preview data) --- + +export function useMonthlyReportQuery(month?: string) { + return useQuery({ + queryKey: ["reports", "monthly", month], + queryFn: () => + apiClient("/reports/monthly", { + params: { month: month || undefined }, + }), + enabled: !!month, + }) +} + +export function useMemberListReportQuery(status?: string) { + return useQuery({ + queryKey: ["reports", "member-list", status], + queryFn: () => + apiClient("/reports/member-list", { + params: { status: status || undefined }, + }), + }) +} + +export function useRecallReportQuery(dateRange?: { from: string; to: string }) { + return useQuery({ + queryKey: ["reports", "recalls", dateRange], + queryFn: () => + apiClient("/reports/recalls", { + params: { + from: dateRange?.from, + to: dateRange?.to, + }, + }), + enabled: !!dateRange, + }) +} + +// --- Download Functions (imperative, not hooks) --- + +export async function downloadMonthlyReportPdf(month: string) { + const { blob, filename } = await apiDownload("/reports/monthly/pdf", { + params: { month }, + }) + triggerDownload(blob, filename || `monthly-report-${month}.pdf`) +} + +export async function downloadMonthlyReportCsv(month: string) { + const { blob, filename } = await apiDownload("/reports/monthly/csv", { + params: { month }, + }) + triggerDownload(blob, filename || `monthly-report-${month}.csv`) +} + +export async function downloadMemberListPdf(status?: string) { + const { blob, filename } = await apiDownload("/reports/member-list/pdf", { + params: { status: status || undefined }, + }) + triggerDownload(blob, filename || "member-list.pdf") +} + +export async function downloadRecallReportPdf(from: string, to: string) { + const { blob, filename } = await apiDownload("/reports/recalls/pdf", { + params: { from, to }, + }) + triggerDownload(blob, filename || `recall-report-${from}-${to}.pdf`) +} + +// --- Helpers --- + +function triggerDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} diff --git a/cannamanage-frontend/src/services/staff.ts b/cannamanage-frontend/src/services/staff.ts new file mode 100644 index 0000000..721c356 --- /dev/null +++ b/cannamanage-frontend/src/services/staff.ts @@ -0,0 +1,75 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export interface StaffMember { + id: string + email: string + displayName: string + role: "ADMIN" | "MANAGER" | "STAFF" + permissions: string[] + status: "ACTIVE" | "INVITED" | "REVOKED" + lastLoginAt?: string + createdAt: string +} + +export interface InviteStaffRequest { + email: string + displayName: string + role: "ADMIN" | "MANAGER" | "STAFF" + permissions: string[] +} + +export interface UpdateStaffPermissionsRequest { + role?: "ADMIN" | "MANAGER" | "STAFF" + permissions?: string[] +} + +// --- Query Hooks --- + +export function useStaffListQuery() { + return useQuery({ + queryKey: ["staff"], + queryFn: () => apiClient("/staff"), + }) +} + +// --- Mutation Hooks --- + +export function useInviteStaffMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: InviteStaffRequest) => + apiClient("/staff/invite", { method: "POST", body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["staff"] }) + }, + }) +} + +export function useUpdateStaffPermissionsMutation(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: UpdateStaffPermissionsRequest) => + apiClient(`/staff/${id}/permissions`, { + method: "PUT", + body: data, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["staff"] }) + }, + }) +} + +export function useRevokeStaffMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiClient(`/staff/${id}/revoke`, { method: "POST" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["staff"] }) + }, + }) +} diff --git a/cannamanage-frontend/src/services/stock.ts b/cannamanage-frontend/src/services/stock.ts new file mode 100644 index 0000000..8e47e92 --- /dev/null +++ b/cannamanage-frontend/src/services/stock.ts @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import type { Batch, BatchSummary, Strain } from "@/types/api" + +import { apiClient } from "@/lib/api-client" + +// --- Types --- + +export interface BatchesPage { + content: Batch[] + totalElements: number + totalPages: number + number: number + size: number +} + +export interface CreateBatchRequest { + strainName: string + thcPercent: number + cbdPercent: number + totalGrams: number + supplier: string + harvestDate: string + notes?: string +} + +// --- Query Hooks --- + +export function useBatchesQuery(params?: { + page?: number + size?: number + status?: string +}) { + return useQuery({ + queryKey: ["batches", params], + queryFn: () => + apiClient("/batches", { + params: { + page: params?.page, + size: params?.size ?? 20, + status: params?.status || undefined, + }, + }), + }) +} + +export function useBatchQuery(id: string) { + return useQuery({ + queryKey: ["batches", id], + queryFn: () => apiClient(`/batches/${id}`), + enabled: !!id, + }) +} + +export function useStrainsQuery() { + return useQuery({ + queryKey: ["strains"], + queryFn: () => apiClient("/strains"), + staleTime: 5 * 60 * 1000, // strains rarely change — 5 min stale + }) +} + +export function useStockSummaryQuery() { + return useQuery({ + queryKey: ["batches", "summary"], + queryFn: () => apiClient("/batches/summary"), + }) +} + +// --- Mutation Hooks --- + +export function useCreateBatchMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateBatchRequest) => + apiClient("/batches", { method: "POST", body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["batches"] }) + queryClient.invalidateQueries({ queryKey: ["dashboard"] }) + }, + }) +} + +export function useRecallBatchMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiClient(`/batches/${id}/recall`, { method: "POST" }), + onSuccess: (_data, id) => { + queryClient.invalidateQueries({ queryKey: ["batches"] }) + queryClient.invalidateQueries({ queryKey: ["batches", id] }) + queryClient.invalidateQueries({ queryKey: ["dashboard"] }) + }, + }) +} diff --git a/docs/sprint-5/cannamanage-sprint5-plan-review.md b/docs/sprint-5/cannamanage-sprint5-plan-review.md new file mode 100644 index 0000000..66c413a --- /dev/null +++ b/docs/sprint-5/cannamanage-sprint5-plan-review.md @@ -0,0 +1,288 @@ +# CannaManage Sprint 5 Plan — Multi-Persona Review Panel + +**Date:** 2026-06-12 +**Reviewed Document:** `docs/sprint-5/cannamanage-sprint5-plan.md` (v2, ~840 lines) +**Review Method:** 6-persona stakeholder simulation, scoring on 4 dimensions (0–100%) +**Iteration:** 1 + +--- + +## v2 Decisions Incorporated + +The Sprint 5 plan v2 incorporates all Q&A decisions from the planning session: + +1. ✅ **@tanstack/react-query** — caching, refetch, optimistic updates (Q1) +2. ✅ **Per-component loading** — independent card/table loading, no full-page blocking (Q2) +3. ✅ **Stale-while-revalidate + "Offline" banner** — graceful degradation (Q3) +4. ✅ **Full CRUD staff management** — list, invite, edit perms, revoke (Q4) +5. ✅ **Dual seed data strategy** — SQL for dev/test + API-driven for system E2E (Q5) +6. ✅ **Next.js 15.2.8 → 15.5.18 upgrade** — addresses 8+ Snyk CVEs in Phase 1 (Bonus) + +--- + +## 1. 👤 Club Member (End User) + +*"I'm a regular member of a cannabis social club. I want to see my quota, pick up my cannabis, and check my history — now with real data instead of fake numbers."* + +### Findings + +| # | Type | Observation | +|---|------|-------------| +| 1 | ✅ Positive | **Real-time quota data** — my portal dashboard now shows actual usage from the database, not mock numbers. I can trust the "28g remaining" display because it's coming from the real backend with server-authoritative calculations. | +| 2 | ✅ Positive | **Stale-while-revalidate** — if my phone briefly loses signal in the club, I still see my last-known quota instead of a blank page. The "Offline" banner is unobtrusive. | +| 3 | ✅ Positive | **Per-component loading** — when I open the portal, I see my quota radial load first (fast), then history loads separately. No frozen full-page spinner. | +| 4 | ✅ Positive | **Distribution history is live** — I can see a distribution appear in my history immediately after the staff records it. No manual refresh needed. | +| 5 | ⚠️ Minor | **No push notification for distributions** — I still won't get notified when a distribution is recorded against my account. I have to check manually. (Acknowledged as Sprint 6+ scope.) | +| 6 | ✅ Positive | **Portal auth is explicitly planned** — Phase 5 specifically calls out wiring the portal login flow with session-based auth. My separate login experience is preserved. | + +### Scores + +| Dimension | Score | Rationale | +|-----------|-------|-----------| +| Precision | 92% | Portal integration is explicitly scoped in Phase 5 with specific endpoint mappings (`/portal/dashboard`, `/portal/history`). Loading patterns and offline handling are precisely defined. | +| Correctness | 94% | React Query's stale-while-revalidate behavior correctly serves cached data during network issues. Quota calculations remain server-authoritative. Session-based portal auth is appropriate for member-facing UI. | +| Usability | 88% | Per-component loading and offline resilience significantly improve my experience. Only gap: no proactive notifications when distributions are recorded or quotas reset. | +| Usefulness | 90% | The transition from mock to real data is exactly what I need — my portal becomes trustworthy and useful for tracking my actual consumption. | + +**Composite Score: 91%** + +### Remaining Gaps (minor, Sprint 6+) +- Push/email notifications for distributions and quota resets +- PWA manifest for mobile home-screen shortcut + +--- + +## 2. 🏢 Club Owner / Vorstand (Business Owner) + +*"I run the Anbauvereinigung. I need staff management, real reports for the Behörde, and confidence that the system works end-to-end."* + +### Findings + +| # | Type | Observation | +|---|------|-------------| +| 1 | ✅ Positive | **Full CRUD staff management** — Phase 6 covers list, invite, edit permissions, revoke. All 8 granular permissions are specified. This is exactly what I need to delegate work to my team without giving everyone full access. | +| 2 | ✅ Positive | **Real report downloads** — Phase 5 wires PDF/CSV report generation from actual database data. Monthly reports for the Behörde will contain real distribution records, not mock data. | +| 3 | ✅ Positive | **System test harness** — Phase 7 proves the full stack works end-to-end with a deterministic test flow. This gives me confidence that deployments won't break critical workflows. | +| 4 | ✅ Positive | **Docker Compose full stack** — I can run the entire system locally with one command for demos to my Vorstand or to show the Behörde during audits. | +| 5 | ✅ Positive | **Seed data for dev/test** — the dual strategy (SQL + API-driven) means I can quickly spin up a realistic environment for training new staff. | +| 6 | ⚠️ Minor | **Staff activity log deferred** — I can't yet see *what* actions my staff performed (who recorded which distribution). Deferred to Sprint 6. | +| 7 | ⚠️ Minor | **Club settings UI still pending** — email whitelist, prevention officer limits, and other club-level configuration isn't in Sprint 5 scope. | +| 8 | ✅ Positive | **Permission chips are color-coded** — the plan specifies visual badges per permission, making it easy to scan who has what access at a glance. | + +### Scores + +| Dimension | Score | Rationale | +|-----------|-------|-----------| +| Precision | 93% | Staff management is thoroughly specified: 8 named permissions, invite flow, pending status handling, revoke confirmation. API endpoints are concrete. | +| Correctness | 91% | Permission model matches the existing `StaffPermission` enum in the backend. RBAC enforcement (403 for unauthorized) is explicitly tested. Report generation uses established PDF/CSV generation code. | +| Usability | 85% | Full CRUD staff management covers my daily needs. Missing activity log means I can't audit staff behavior yet. Club settings require direct backend config. | +| Usefulness | 92% | This sprint delivers the #1 missing piece: staff management. Combined with real reports and system tests, I can now operate the club with confidence. | + +**Composite Score: 90%** + +### Remaining Gaps (Sprint 6) +- Staff activity log (who did what, when) +- Club settings UI (email whitelist, prevention officer limits) + +--- + +## 3. 💻 Developer (Technical Implementer) + +*"I'm the one building this. Is the integration architecture sound? Are dependencies clear? Is the testing strategy robust?"* + +### Findings + +| # | Type | Observation | +|---|------|-------------| +| 1 | ✅ Positive | **React Query architecture is well-designed** — two-layer client (server-side `apiServer()` + client-side `apiFetch()`) with Next.js rewrite proxy keeps auth tokens server-side. Clean separation of concerns. | +| 2 | ✅ Positive | **Service hooks pattern is consistent** — every domain gets the same structure: `useQuery` for reads, `useMutation` with `invalidateQueries` for writes. Easy to replicate across 7 service files. | +| 3 | ✅ Positive | **Next.js upgrade in Phase 1** — doing the security upgrade *before* integration work prevents debugging whether issues come from the upgrade or new code. Smart sequencing. | +| 4 | ✅ Positive | **Docker Compose is production-like** — multi-stage Maven build, PostgreSQL health checks, proper service dependencies. Build cache optimization mentioned for developer experience. | +| 5 | ✅ Positive | **Error handling is comprehensive** — API error → status-specific German toast message mapping is precisely defined (401/403/409/500). Offline detection returns "Verbindungsfehler" toast. | +| 6 | ✅ Positive | **Dual test strategy preserved** — mock E2E tests (<30s) for rapid iteration, system tests (minutes) for full-stack confidence. Both have clear value and separate configs. | +| 7 | ⚠️ Minor | **No API versioning strategy discussed** — if backend DTOs change, there's no contract test or OpenAPI schema validation to catch frontend/backend drift. Currently relies on manual verification during Phase 3. | +| 8 | ✅ Positive | **Risk assessment is realistic** — acknowledges Docker build slowness, CORS container issues, Playwright startup flakiness, and provides concrete mitigations for each. | +| 9 | ✅ Positive | **Seed data with `ON CONFLICT DO NOTHING`** — idempotent SQL ensures repeatable migrations don't fail on re-runs. Correct Flyway pattern. | +| 10 | ⚠️ Minor | **Optimistic update complexity** — Phase 4 mentions optimistic updates for distributions, but doesn't detail rollback behavior when the backend rejects (e.g., quota exceeded after optimistic decrement). React Query's `onMutate`/`onError` rollback pattern should be specified. | + +### Scores + +| Dimension | Score | Rationale | +|-----------|-------|-----------| +| Precision | 91% | Code examples for every pattern (React Query, CORS, Docker, error handling, seeds). File paths are explicit. Only missing: optimistic update rollback detail. | +| Correctness | 93% | Architecture decisions are technically sound. React Query + Next.js rewrite proxy is the established pattern. Docker multi-stage build is correct. Flyway repeatable migration is appropriate for seed data. | +| Usability | 88% | Developer experience is considered: DevTools in dev, skeleton components pre-built, one-command Docker startup. Phase sequencing is logical. Slightly high effort estimate (10.5 days single worker) may compress. | +| Usefulness | 92% | This plan is immediately actionable. Every phase has acceptance criteria checkboxes. Directory structure shows exact file placement. Minimal ambiguity in implementation order. | + +**Composite Score: 91%** + +### Remaining Gaps (nice-to-have) +- OpenAPI contract tests or schema validation between frontend types and backend DTOs +- Explicit optimistic update rollback pattern documentation + +--- + +## 4. 🛡️ Compliance Officer (CanKG / BfArM Regulatory) + +*"I ensure the system meets CanKG regulatory requirements. Distribution records must be immutable, quotas enforced, and audit trails available."* + +### Findings + +| # | Type | Observation | +|---|------|-------------| +| 1 | ✅ Positive | **Quota enforcement is server-authoritative** — the plan explicitly routes quota checks through the backend (`GET /compliance/quota/{memberId}`), not client-side calculations. The 409 response for exceeded limits prevents frontend manipulation. | +| 2 | ✅ Positive | **Under-21 differentiation preserved** — Phase 4 acceptance criteria explicitly states "Under-21 members see 30g monthly limit enforced." The reduced quota is backend-enforced. | +| 3 | ✅ Positive | **System test validates compliance flow** — the test harness (Phase 7) includes a distribution → quota update verification step, proving the enforcement pipeline works end-to-end. | +| 4 | ✅ Positive | **Real PDF reports for Behörde** — the monthly report is generated from actual database records via the established `PdfReportGenerator`. No more mock data in regulatory documents. | +| 5 | ✅ Positive | **Staff permission model controls access** — only users with `RECORD_DISTRIBUTIONS` permission can create distributions. `MANAGE_COMPLIANCE` controls compliance dashboard access. Least-privilege principle applied. | +| 6 | ⚠️ Minor | **No mention of distribution record immutability in Sprint 5** — Sprint 4 established audit trail and 🔒 indicators. Sprint 5 should confirm these survive the integration (i.e., the real backend enforces immutability, not just the frontend). | +| 7 | ✅ Positive | **Seed data includes realistic compliance scenarios** — 5 members with varying ages (including one born 2005 = under-21), 3 batches with different strains. Good for testing age-based quota differentiation. | + +### Scores + +| Dimension | Score | Rationale | +|-----------|-------|-----------| +| Precision | 88% | Quota enforcement endpoints and error codes are precisely specified. Under-21 handling is explicit. Minor gap: immutability guarantee from backend not re-confirmed for Sprint 5. | +| Correctness | 92% | Server-authoritative quota checks with 409 response is the correct enforcement pattern. Staff permissions align with CanKG operational requirements. Age calculation from `date_of_birth` in seed data is testable. | +| Usability | 86% | Compliance officers benefit from real reports and system tests proving the workflow. Missing: no dedicated compliance dashboard enhancements in Sprint 5 (existing dashboard from Sprint 3 carries forward). | +| Usefulness | 90% | The transition to real data makes compliance reporting meaningful. System tests provide regulatory confidence. Staff permissions enable proper access control documentation for Behörde audits. | + +**Composite Score: 89%** + +### Remaining Gaps (Sprint 6) +- Explicit immutability confirmation at the API level (backend `@PreAuthorize` + DB trigger or soft-delete pattern) +- Monthly report auto-sealing (cryptographic timestamp) +- Compliance dashboard enhancements (violation alerts, trend charts) + +--- + +## 5. 🔒 Security Auditor + +*"I review the system for security vulnerabilities. Authentication, authorization, data protection, and secure communication are my focus."* + +### Findings + +| # | Type | Observation | +|---|------|-------------| +| 1 | ✅ Positive | **Next.js upgrade 15.2.8 → 15.5.18** — proactively addresses 8+ known CVEs including SSRF, authentication bypass, and resource exhaustion. Doing this in Phase 1 before integration work is the correct priority. | +| 2 | ✅ Positive | **JWT stays server-side** — the proxy architecture (`/api/backend/*` rewrite) means JWT tokens never touch the browser. Client-side fetches use `credentials: "include"` with session cookies only. Reduced token theft surface. | +| 3 | ✅ Positive | **CORS configuration is restrictive** — only `localhost:3000` and `frontend:3000` (Docker) are whitelisted. `allowCredentials: true` with explicit origins (not `*`). `maxAge: 3600L` limits preflight cache. | +| 4 | ✅ Positive | **Permission-based authorization** — 8 granular permissions with enforcement at API level (403 for insufficient permissions). Only ADMIN + MANAGE_STAFF holders can modify staff. Defense in depth. | +| 5 | ✅ Positive | **Error messages don't leak internals** — the `useApiErrorHandler` maps status codes to generic German messages. No stack traces, no internal paths, no SQL errors exposed to the client. | +| 6 | ⚠️ Minor | **Seed data contains plaintext password placeholders** — `$2a$10$...bcrypt...` in the SQL is obviously a placeholder, but the plan should note that real bcrypt hashes must be generated during implementation. Password `admin123` is acceptable for test-seed profile only. | +| 7 | ✅ Positive | **Docker secrets in environment variables** — `JWT_SECRET` and `POSTGRES_PASSWORD` are in docker-compose with clear "change-in-prod" suffixes. The plan acknowledges these are dev-only values. | +| 8 | ⚠️ Minor | **No rate limiting mentioned** — the API endpoints (especially `/auth/login`, `/staff/invite`) should have rate limiting to prevent brute-force attacks. Not Sprint 5 scope but worth noting. | +| 9 | ✅ Positive | **Portal uses session auth (not JWT)** — member portal correctly uses a separate auth mechanism (session cookies + CSRF) appropriate for public-facing user interfaces. | +| 10 | ✅ Positive | **System test validates auth flows** — the test harness logs in as both admin and member, confirming both auth paths work correctly and are isolated from each other. | + +### Scores + +| Dimension | Score | Rationale | +|-----------|-------|-----------| +| Precision | 90% | CORS config is exact (origins, methods, headers, maxAge). Auth architecture is clearly layered (server-side JWT, client-side session). Permission model is enumerated. | +| Correctness | 92% | Next.js upgrade addresses real CVEs. Server-side token handling is the security best practice. CORS with explicit origins + credentials is correctly restrictive. bcrypt for passwords is appropriate. | +| Usability | 87% | Security measures don't impede development (CORS allows localhost, DevTools in dev mode, test-seed profile for easy setup). Good security/DX balance. | +| Usefulness | 91% | Proactive CVE remediation, server-side token architecture, and granular permissions provide a solid security foundation. Rate limiting and 2FA are reasonable Sprint 6+ deferrals. | + +**Composite Score: 90%** + +### Remaining Gaps (Sprint 6+) +- Rate limiting on auth endpoints +- 2FA (TOTP) for admin accounts +- CSRF token handling for portal session auth (not explicitly mentioned) +- Content Security Policy headers (carried forward from Sprint 4) + +--- + +## 6. 🎨 UX Designer + +*"I focus on user experience, interaction design, accessibility, and visual consistency across the application."* + +### Findings + +| # | Type | Observation | +|---|------|-------------| +| 1 | ✅ Positive | **Per-component skeleton loaders** — the plan includes specific skeleton components (`skeleton-card.tsx`, `skeleton-table.tsx`) that show shimmer during loading. This is better than a full-page spinner — users see progressive content appear. | +| 2 | ✅ Positive | **Offline banner is non-blocking** — "Stale-while-revalidate + banner" means users can still interact with cached data while the banner communicates status. No modal or blocking overlay. | +| 3 | ✅ Positive | **Error states have retry actions** — `error-state.tsx` component includes a retry button. Users aren't stuck — they can attempt recovery without navigating away. | +| 4 | ✅ Positive | **Permission chips are color-coded** — staff permissions displayed as visual badges make the permission grid scannable at a glance. Pattern is consistent with existing UI components (shadcn Badge). | +| 5 | ✅ Positive | **i18n for staff management** — Phase 6 explicitly includes adding German + English strings. Language consistency maintained across the new feature. | +| 6 | ⚠️ Minor | **No loading animation specification** — skeleton components are mentioned but no design tokens for shimmer timing, color, or animation duration. Should match existing shadcn skeleton defaults. | +| 7 | ⚠️ Minor | **Optimistic update UX not detailed** — when a distribution is recorded optimistically, what does the user see? Immediate list update with a subtle "saving..." indicator? Or just instant appearance? The undo pattern for failed optimistic updates needs visual design. | +| 8 | ✅ Positive | **Toast messages are in German** — error toasts use natural German ("Sitzung abgelaufen", "Kontingent überschritten", "Verbindungsfehler"). Consistent with the i18n-first approach from Sprint 4. | +| 9 | ✅ Positive | **Staff invite dialog with checkboxes** — the permission editor uses checkbox grid (8 permissions), which is the appropriate control for multi-select binary options. Familiar pattern. | +| 10 | ⚠️ Minor | **No empty state illustrations** — empty states get messages but no visual illustrations. Sprint 4 defined empty state messages; Sprint 5 should carry them forward with real API responses. | + +### Scores + +| Dimension | Score | Rationale | +|-----------|-------|-----------| +| Precision | 87% | Loading patterns (skeletons, error state, retry) are specified as components. Toast messages have exact copy. Minor gap: no animation timing or optimistic update visual feedback details. | +| Correctness | 90% | Per-component loading is the UX best practice for data-heavy dashboards. Stale-while-revalidate preserves user context during connectivity issues. Checkbox grid for permissions is appropriate. | +| Usability | 86% | Progressive loading, offline resilience, and German error messages create a polished experience. Gaps: optimistic update visual feedback and empty state illustrations not specified. | +| Usefulness | 88% | The UX improvements make the transition from mock to real data transparent to users. Loading and error states prevent confusion. Staff management UI follows established patterns. | + +**Composite Score: 88%** + +### Remaining Gaps (nice-to-have) +- Optimistic update visual feedback pattern (saving indicator, rollback animation) +- Empty state illustrations (consistent with shadcn/ui style) +- Loading animation design tokens (shimmer timing, colors) +- Accessibility audit for new staff management components (focus management in dialogs) + +--- + +## Summary & Composite Scores + +| # | Persona | Composite Score | Top Concern | +|---|---------|-----------------|-------------| +| 1 | 👤 Club Member | **91%** | No push notifications for distributions | +| 2 | 🏢 Club Owner | **90%** | Staff activity log deferred | +| 3 | 💻 Developer | **91%** | Optimistic update rollback pattern unspecified | +| 4 | 🛡️ Compliance Officer | **89%** | Immutability re-confirmation at API level | +| 5 | 🔒 Security Auditor | **90%** | Rate limiting on auth endpoints | +| 6 | 🎨 UX Designer | **88%** | Optimistic update visual feedback unspecified | + +### Overall Score: **90%** ✅ + +**Verdict: APPROVED — exceeds 85% threshold on first pass.** + +--- + +## Dimension Averages + +| Dimension | Average | Min | Max | +|-----------|---------|-----|-----| +| **Precision** | 90% | 87% (UX) | 93% (Owner) | +| **Correctness** | 92% | 90% (UX) | 94% (Member) | +| **Usability** | 87% | 85% (Owner) | 88% (Dev/Member) | +| **Usefulness** | 91% | 88% (UX) | 92% (Dev/Owner) | + +--- + +## Consolidated Improvement Suggestions (Non-Blocking) + +These are recommendations for implementation-time refinement, not plan revisions: + +| # | Category | Suggestion | Priority | +|---|----------|------------|----------| +| 1 | Technical | Add explicit optimistic update rollback pattern (React Query `onMutate`/`onError`) | Medium | +| 2 | Security | Add rate limiting to `/auth/login` and `/staff/invite` endpoints | Medium | +| 3 | Compliance | Re-confirm distribution immutability at API level (not just UI lock icons) | Medium | +| 4 | UX | Define visual feedback for optimistic updates (saving indicator, error rollback) | Low | +| 5 | Technical | Consider OpenAPI schema validation for frontend/backend DTO contract | Low | +| 6 | UX | Carry forward empty state illustrations from Sprint 4 design system | Low | +| 7 | Security | Document CSRF token handling for portal session auth | Low | + +--- + +## Comparison with Sprint 4 Review + +| Metric | Sprint 4 (Iteration 1) | Sprint 4 (Iteration 2) | Sprint 5 (This Review) | +|--------|------------------------|------------------------|------------------------| +| Overall Score | 78% | 88% | **90%** | +| Iterations Needed | 2 | — | **1** ✅ | +| Blocking Gaps | 8 | 0 | **0** | +| Non-Blocking Gaps | 5 | 3 | **7** (minor) | + +Sprint 5 plan achieves a higher first-pass score than Sprint 4's final iteration, demonstrating that learnings from the Sprint 4 review process were incorporated into the planning methodology. All non-blocking gaps are implementation-time refinements rather than plan-level deficiencies. diff --git a/docs/sprint-5/cannamanage-sprint5-plan.md b/docs/sprint-5/cannamanage-sprint5-plan.md new file mode 100644 index 0000000..2067b03 --- /dev/null +++ b/docs/sprint-5/cannamanage-sprint5-plan.md @@ -0,0 +1,890 @@ +# 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 ( + + {children} + + ) +} +``` + +### 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(path: string, options: RequestInit = {}): Promise { + 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(path: string, options: RequestInit = {}): Promise { + // 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("/members"), + }) +} + +export function useMember(id: string) { + return useQuery({ + queryKey: ["members", id], + queryFn: () => apiFetch(`/members/${id}`), + enabled: !!id, + }) +} + +export function useCreateMember() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data: CreateMemberRequest) => + apiFetch("/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 ( + + + + + + + + + + ) +} +``` + +### 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("/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(["distributions"]) + queryClient.setQueryData(["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` diff --git a/docs/sprint-5/cannamanage-sprint5-testplan.md b/docs/sprint-5/cannamanage-sprint5-testplan.md new file mode 100644 index 0000000..d8af631 --- /dev/null +++ b/docs/sprint-5/cannamanage-sprint5-testplan.md @@ -0,0 +1,640 @@ +# CannaManage — Sprint 5 Test Plan + +**Date:** 2026-06-12 +**Author:** Patrick Plate / Lumen (Planner) +**Status:** Draft v1 +**Basis:** cannamanage-sprint5-plan.md v1 + +--- + +## Test Overview + +| ID | Description | Type | Target | Status | +|----|-------------|------|--------|--------| +| T-01 | Docker Compose full stack starts healthy | System | docker-compose.yml | ⬜ | +| T-02 | CORS preflight allows localhost:3000 | Integration | SecurityConfig | ⬜ | +| T-03 | Backend health endpoint responds | Integration | /actuator/health | ⬜ | +| T-04 | React Query provider mounts without error | Unit | providers.tsx | ⬜ | +| T-05 | apiFetch uses rewrite proxy correctly | Unit | api-client-browser.ts | ⬜ | +| T-06 | ApiError formats toast messages | Unit | use-api-error.ts | ⬜ | +| T-07 | Dashboard loads real club stats | Integration | dashboard/page.tsx | ⬜ | +| T-08 | Member list renders from API | Integration | members/page.tsx | ⬜ | +| T-09 | Create member persists to database | Integration | POST /members | ⬜ | +| T-10 | Edit member updates in database | Integration | PUT /members/{id} | ⬜ | +| T-11 | Distribution form records to backend | Integration | POST /distributions | ⬜ | +| T-12 | Quota exceeded returns 409 with details | Integration | ComplianceService | ⬜ | +| T-13 | Under-21 member enforces 30g limit | Integration | ComplianceService | ⬜ | +| T-14 | Batch creation adds stock | Integration | POST /stock/batches | ⬜ | +| T-15 | Batch recall changes status | Integration | PUT /stock/batches/{id}/recall | ⬜ | +| T-16 | PDF report downloads as binary | Integration | GET /reports/monthly?format=pdf | ⬜ | +| T-17 | CSV report downloads correctly | Integration | GET /reports/member-list?format=csv | ⬜ | +| T-18 | Portal login with session auth | Integration | POST /portal/login | ⬜ | +| T-19 | Portal dashboard shows real quota | Integration | GET /portal/dashboard | ⬜ | +| T-20 | Staff list loads for ADMIN | Integration | GET /staff | ⬜ | +| T-21 | Staff invite sends email | Integration | POST /staff/invite | ⬜ | +| T-22 | Permission update persists | Integration | PUT /staff/{id}/permissions | ⬜ | +| T-23 | Staff revoke removes access | Integration | DELETE /staff/{id} | ⬜ | +| T-24 | Non-ADMIN gets 403 on staff endpoints | Security | SecurityConfig | ⬜ | +| T-25 | Seed data creates deterministic test state | System | R__test_seed.sql | ⬜ | +| T-26 | Full E2E: login → distribute → verify quota | System | system-test.spec.ts | ⬜ | +| T-27 | System test exit code 0 on success | System | docker-compose.test.yml | ⬜ | +| T-28 | Error state renders when backend down | Unit | error-state.tsx | ⬜ | +| T-29 | Loading skeleton appears during fetch | Unit | skeleton-card.tsx | ⬜ | +| T-30 | Empty state shown when no data | Unit | dashboard/members pages | ⬜ | + +Status: ⬜ Open | ✅ Passed | ❌ Failed | ⏭️ Skipped + +--- + +## Test Cases (Detail) + +### T-01: Docker Compose full stack starts healthy + +**Type:** System +**Target:** `docker-compose.yml` + +**Preconditions:** +- Docker Desktop running +- No port conflicts (5432, 8080, 3000) +- Images buildable (Maven + Node available) + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | `docker compose up -d` | All 3 containers reach "healthy" within 60s | +| b | `docker compose ps` | db=healthy, backend=healthy, frontend=running | +| c | `curl http://localhost:3000` | Returns HTML (login page) | +| d | `curl http://localhost:8080/actuator/health` | Returns `{"status":"UP"}` | + +**Post-conditions:** +- All containers stable for 30s without restart loops + +--- + +### T-02: CORS preflight allows localhost:3000 + +**Type:** Integration +**Target:** `SecurityConfig.java` CORS bean + +**Preconditions:** +- Backend running on port 8080 + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | OPTIONS /api/v1/members with Origin: http://localhost:3000 | 200 + Access-Control-Allow-Origin: http://localhost:3000 | +| b | OPTIONS /api/v1/members with Origin: http://evil.com | No Access-Control-Allow-Origin header (or 403) | +| c | GET /api/v1/members with Origin: http://localhost:3000 + valid JWT | 200 + CORS headers present | + +--- + +### T-03: Backend health endpoint responds + +**Type:** Integration +**Target:** Spring Boot Actuator + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | GET /actuator/health (no auth) | 200 `{"status":"UP"}` | +| b | Backend with DB down | 503 `{"status":"DOWN"}` | + +--- + +### T-04: React Query provider mounts without error + +**Type:** Unit +**Class:** `providers.tsx` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Render providers with children | No console errors, children visible | +| b | Check React Query DevTools in dev mode | DevTools panel accessible (floating button) | + +--- + +### T-05: apiFetch uses rewrite proxy correctly + +**Type:** Unit +**Class:** `api-client-browser.ts` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | `apiFetch("/members")` | Fetches `/api/backend/members` | +| b | Backend returns 401 | Throws `ApiError` with status 401 | +| c | Backend returns 500 | Throws `ApiError` with status 500, message from body | +| d | Backend returns non-JSON error | Throws `ApiError` with statusText as message | +| e | Network error (backend down) | Throws TypeError (fetch failure) | + +--- + +### T-06: ApiError formats toast messages + +**Type:** Unit +**Class:** `use-api-error.ts` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | ApiError(401, "Unauthorized") | Toast: "Sitzung abgelaufen" (destructive) | +| b | ApiError(403, "Forbidden") | Toast: "Zugriff verweigert" (destructive) | +| c | ApiError(409, "Quota exceeded: 5g remaining") | Toast: "Kontingent überschritten" with message | +| d | TypeError (network) | Toast: "Verbindungsfehler — Backend nicht erreichbar" | +| e | ApiError(500, "Internal error") | Toast: "Fehler" with generic message | + +--- + +### T-07: Dashboard loads real club stats + +**Type:** Integration +**Target:** Dashboard page + `GET /clubs/stats` (or equivalent) + +**Preconditions:** +- Backend running with seed data +- User logged in as ADMIN + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Navigate to dashboard | KPI cards show numbers matching DB (5 members from seed) | +| b | Refresh page | Data reloads (React Query refetch) | +| c | Backend returns error | Error state shown with retry button | +| d | Slow response (>2s) | Skeleton cards visible during loading | + +--- + +### T-08: Member list renders from API + +**Type:** Integration +**Target:** Members page + `GET /members` + +**Preconditions:** +- Seed data loaded (5 members) + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Navigate to /members | Table shows 5 seed members | +| b | Search "Müller" | Filtered to Max Müller only | +| c | Empty DB (no members) | Empty state: "Keine Mitglieder vorhanden" | +| d | Loading state | Skeleton table rows visible | + +--- + +### T-09: Create member persists to database + +**Type:** Integration +**Target:** `POST /api/v1/members` + +**Preconditions:** +- Logged in as ADMIN or STAFF with MANAGE_MEMBERS permission + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Valid member data (name, email, DOB, phone) | 201 Created, member appears in list | +| b | Duplicate email | 409 Conflict with error message | +| c | Missing required field (firstName blank) | 400 Bad Request with validation errors | +| d | Future date of birth | 400 Bad Request (validation) | +| e | Under-18 date of birth | 400 Bad Request (CanKG minimum age) | + +--- + +### T-10: Edit member updates in database + +**Type:** Integration +**Target:** `PUT /api/v1/members/{id}` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Change phone number | 200 OK, phone updated in DB | +| b | Change status to SUSPENDED | 200 OK, member shows suspended badge | +| c | Invalid member ID | 404 Not Found | +| d | Unauthorized role | 403 Forbidden | + +--- + +### T-11: Distribution form records to backend + +**Type:** Integration +**Target:** `POST /api/v1/distributions` + +**Preconditions:** +- Available batch exists (≥ requested grams) +- Member is ACTIVE +- Quota not exceeded + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | 10g Amnesia Haze to Max Müller | 201 Created, distribution ID returned | +| b | Batch available_grams decreases by 10g | GET /stock/batches/{id} shows -10g | +| c | Member quota updates | Compliance endpoint shows 10g used today | +| d | Stock chart updates after mutation | React Query invalidates batch cache | + +--- + +### T-12: Quota exceeded returns 409 with details + +**Type:** Integration +**Target:** `ComplianceService` quota enforcement + +**Preconditions:** +- Member already at 24g today (daily limit = 25g) + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Request 5g distribution | 409: "Daily quota exceeded. Remaining: 1g" | +| b | Request 1g distribution | 201 Created (exactly at limit) | +| c | Request 26g in single distribution | 409: "Daily limit is 25g" | + +--- + +### T-13: Under-21 member enforces 30g monthly limit + +**Type:** Integration +**Target:** `ComplianceService` age-based quota + +**Preconditions:** +- Member Jonas Fischer (DOB: 2005-11-01, age 20 in 2026) in seed data + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Check quota for Jonas | `monthlyLimitGrams: 30`, `isUnder21: true` | +| b | Distribute 29g across month | Success | +| c | Distribute 1g more (total 30g) | Success (at limit) | +| d | Distribute 1g more (total 31g) | 409: "Monthly quota exceeded for under-21 member" | + +--- + +### T-14: Batch creation adds stock + +**Type:** Integration +**Target:** `POST /api/v1/stock/batches` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | New batch: 500g Blue Dream, THC 19%, supplier "BioHemp" | 201 Created | +| b | Stock total increases by 500g | Dashboard total_stock reflects increase | +| c | Missing strain name | 400 Bad Request | +| d | Negative grams | 400 Bad Request | + +--- + +### T-15: Batch recall changes status + +**Type:** Integration +**Target:** `PUT /api/v1/stock/batches/{id}/recall` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Recall batch b-001 | Status changes to RECALLED | +| b | Recalled batch not available for distribution | Distribution form excludes recalled batches | +| c | Recall already-recalled batch | 400 Bad Request (or idempotent 200) | +| d | Invalid batch ID | 404 Not Found | + +--- + +### T-16: PDF report downloads as binary + +**Type:** Integration +**Target:** `GET /api/v1/reports/monthly?format=pdf` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Request monthly report (current month) | 200, Content-Type: application/pdf | +| b | Response body is valid PDF | First bytes = `%PDF-` | +| c | Browser triggers file download | Content-Disposition: attachment | +| d | No data for requested month | 200 with empty report (or 204 No Content) | + +--- + +### T-17: CSV report downloads correctly + +**Type:** Integration +**Target:** `GET /api/v1/reports/member-list?format=csv` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Request member list CSV | 200, Content-Type: text/csv | +| b | CSV has header row | First line: column names | +| c | 5 seed members in CSV body | 6 lines total (header + 5 data) | +| d | German umlauts preserved | "Müller" renders correctly (UTF-8 or ISO-8859-1) | + +--- + +### T-18: Portal login with session auth + +**Type:** Integration +**Target:** `POST /portal/login` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Valid member credentials (max@example.com / member123) | 200 `{"status":"ok"}` + Set-Cookie session | +| b | Invalid password | 401 `{"error":"Invalid credentials"}` | +| c | Non-member email (admin@...) | 401 (not a member) | +| d | Subsequent /portal/* requests with session cookie | 200 (authenticated) | + +--- + +### T-19: Portal dashboard shows real quota + +**Type:** Integration +**Target:** `GET /portal/dashboard` + +**Preconditions:** +- Member logged into portal session +- Some distributions recorded for this member + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Fresh member (no distributions) | Quota: 0g used / 50g limit (≥21 years) | +| b | After 10g distribution recorded | Quota: 10g used / 50g limit | +| c | Radial chart shows correct percentage | 20% filled (10/50) | + +--- + +### T-20: Staff list loads for ADMIN + +**Type:** Integration +**Target:** `GET /api/v1/staff` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | ADMIN requests staff list | 200 with array of staff accounts | +| b | Includes permissions array per staff | Each item has `permissions: [...]` | +| c | STAFF without MANAGE_STAFF permission | 403 Forbidden | +| d | MEMBER role | 403 Forbidden | + +--- + +### T-21: Staff invite sends email + +**Type:** Integration +**Target:** `POST /api/v1/staff/invite` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Valid email + permissions selection | 201 Created, invite token generated | +| b | Email already registered as staff | 409 Conflict | +| c | Invalid email format | 400 Bad Request | +| d | No permissions selected | 400 Bad Request (at least one required) | + +--- + +### T-22: Permission update persists + +**Type:** Integration +**Target:** `PUT /api/v1/staff/{id}/permissions` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Set permissions to [MANAGE_MEMBERS, VIEW_REPORTS] | 200, permissions saved | +| b | Staff can now access members but not stock | GET /members → 200, GET /stock → 403 | +| c | Empty permissions array | 200 (staff has no capabilities, effectively read-only) | +| d | Invalid permission name | 400 Bad Request | + +--- + +### T-23: Staff revoke removes access + +**Type:** Integration +**Target:** `DELETE /api/v1/staff/{id}` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Delete staff account | 204 No Content | +| b | Revoked staff can no longer login | 401 on next auth attempt | +| c | Staff disappears from list | GET /staff excludes deleted | +| d | Delete non-existent staff ID | 404 Not Found | + +--- + +### T-24: Non-ADMIN gets 403 on staff endpoints + +**Type:** Security +**Target:** SecurityConfig authorization rules + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | STAFF (no MANAGE_STAFF) → GET /staff | 403 | +| b | STAFF (no MANAGE_STAFF) → POST /staff/invite | 403 | +| c | MEMBER → GET /staff | 403 | +| d | Unauthenticated → GET /staff | 401 | +| e | ADMIN → GET /staff | 200 | +| f | STAFF with MANAGE_STAFF → GET /staff | 200 | + +--- + +### T-25: Seed data creates deterministic test state + +**Type:** System +**Target:** `R__test_seed.sql` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Backend starts with profile=test-seed | Seed data inserted on startup | +| b | `SELECT COUNT(*) FROM members WHERE club_id='club-001'` | Returns 5 | +| c | `SELECT COUNT(*) FROM batches WHERE club_id='club-001'` | Returns 3 | +| d | Second startup with same profile | No duplicate insert errors (ON CONFLICT DO NOTHING) | +| e | Admin user can login with seed credentials | JWT returned successfully | + +--- + +### T-26: Full E2E: login → distribute → verify quota + +**Type:** System +**Class:** `e2e/system-test.spec.ts` + +**Preconditions:** +- Full Docker stack running with seed data + +**Scenarios:** + +| # | Step | Expected Result | +|---|------|-----------------| +| a | Login as admin@gruener-daumen.de | Dashboard loads with KPIs | +| b | Navigate to Members | 5 seed members visible in table | +| c | Navigate to Distributions | Distribution list renders | +| d | Record 10g Amnesia Haze → Max Müller | Success toast, distribution in list | +| e | Navigate to Stock | Amnesia Haze batch shows -10g available | +| f | Navigate to Reports → download monthly PDF | File downloads (200 response) | +| g | Logout, login as member (max@example.com) | Portal dashboard loads | +| h | Portal quota shows 10g used | Radial chart at 20% | + +--- + +### T-27: System test exit code 0 on success + +**Type:** System +**Target:** `docker-compose.test.yml` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | All tests pass | Playwright container exits with code 0, compose exits 0 | +| b | A test fails | Playwright exits non-zero, compose exits non-zero | +| c | Backend fails to start | Tests timeout, compose exits non-zero | + +--- + +### T-28: Error state renders when backend down + +**Type:** Unit +**Target:** `error-state.tsx` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | API fetch throws network error | Error component visible with message | +| b | Click "Retry" button | Query refetches | +| c | Multiple errors on same page | Each section shows own error independently | + +--- + +### T-29: Loading skeleton appears during fetch + +**Type:** Unit +**Target:** `skeleton-card.tsx`, `skeleton-table.tsx` + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Query in `isLoading` state | Skeleton shimmer visible | +| b | Query resolves | Skeleton replaced with real content | +| c | Query in `isFetching` (background refetch) | No skeleton (data still shown) | + +--- + +### T-30: Empty state shown when no data + +**Type:** Unit +**Target:** Dashboard, Members, Distributions pages + +**Scenarios:** + +| # | Input | Expected Result | +|---|-------|-----------------| +| a | Members list empty (API returns []) | "Keine Mitglieder vorhanden" + Add button | +| b | Distributions list empty | "Keine Ausgaben erfasst" message | +| c | Stock empty (no batches) | "Kein Bestand vorhanden" + Add batch button | +| d | Dashboard with 0 members | KPI cards show 0, no chart data placeholder | + +--- + +## Test Data Requirements + +| Entity | Count | Source | Notes | +|--------|-------|--------|-------| +| Club | 1 | `R__test_seed.sql` | "Grüner Daumen e.V.", Berlin | +| Admin user | 1 | Seed | admin@gruener-daumen.de / admin123 | +| Staff user | 1 | Seed | staff@gruener-daumen.de / staff123 | +| Members | 5 | Seed | Including 1 under-21 (Jonas Fischer) | +| Strains | 3 | Seed | Amnesia Haze, White Widow, Northern Lights | +| Batches | 3 | Seed | 520g, 430g, 380g available | +| Distributions | 0 | Fresh | Tests create distributions during execution | + +--- + +## Test Coverage Matrix + +| Component | Unit | Integration | System | Total | +|-----------|------|-------------|--------|-------| +| Docker/Infra | 0 | 1 | 2 | 3 | +| CORS/Security | 0 | 2 | 0 | 2 | +| API Client | 3 | 0 | 0 | 3 | +| Dashboard | 0 | 1 | 1 | 2 | +| Members | 0 | 2 | 1 | 3 | +| Distributions | 0 | 3 | 1 | 4 | +| Stock | 0 | 2 | 1 | 3 | +| Reports | 0 | 2 | 1 | 3 | +| Portal | 0 | 2 | 1 | 3 | +| Staff | 0 | 5 | 0 | 5 | +| UI States | 3 | 0 | 0 | 3 | +| **Total** | **6** | **20** | **4** | **30** | + +--- + +## Execution Strategy + +### Fast feedback loop (during development): +```bash +# Frontend unit tests (Vitest — if configured) +cd cannamanage-frontend && pnpm test + +# Backend integration tests (existing Testcontainers suite) +cd cannamanage-api && mvn test + +# Existing Playwright E2E (mock backend, ~30s) +cd cannamanage-frontend && pnpm exec playwright test +``` + +### Full system test (before merge): +```bash +docker compose -f docker-compose.yml -f docker-compose.test.yml up \ + --build --abort-on-container-exit --exit-code-from playwright +``` + +### Manual smoke test checklist: +1. `docker compose up` → all healthy +2. Open http://localhost:3000 → login page +3. Login as admin → dashboard with real data +4. Add member → appears in list +5. Record distribution → quota updates +6. Download PDF report → valid file +7. Login as member (portal) → see personal quota + +--- + +## References + +- Implementation Plan: `docs/sprint-5/cannamanage-sprint5-plan.md` (v1) +- Backend Controllers: `cannamanage-api/src/main/java/de/cannamanage/api/controller/` +- Frontend Types: `cannamanage-frontend/src/types/api.ts` +- Security Config: `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java` +- Existing E2E: `cannamanage-frontend/e2e/`