feat(sprint-5): Phase 2 — React Query API client layer
- @tanstack/react-query with QueryClientProvider in providers/index.tsx - Typed api-client.ts fetch wrapper with ApiError class + apiDownload - Service modules: members, distributions, stock, reports, dashboard, portal, staff - Offline banner component (onlineManager subscription) - API error boundary with retry button - Loading skeleton components (card, table, chart, form, dashboard) - i18n for error/loading states (de/en)
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -292,5 +292,17 @@
|
||||
"allMonths": "Alle Monate",
|
||||
"footerText": "Cannabis-Anbauverein — Sichere Mitgliederverwaltung",
|
||||
"adminLogin": "Zum Admin-Login"
|
||||
},
|
||||
"api": {
|
||||
"loading": "Wird geladen...",
|
||||
"error": "Fehler beim Laden der Daten.",
|
||||
"retry": "Erneut versuchen",
|
||||
"offline": "Keine Verbindung zum Server — Daten könnten veraltet sein.",
|
||||
"networkError": "Netzwerkfehler. Bitte prüfe deine Verbindung.",
|
||||
"unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||||
"forbidden": "Keine Berechtigung für diese Aktion.",
|
||||
"notFound": "Ressource nicht gefunden.",
|
||||
"quotaExceeded": "Kontingent überschritten.",
|
||||
"serverError": "Serverfehler. Bitte versuche es später erneut."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,5 +292,17 @@
|
||||
"allMonths": "All months",
|
||||
"footerText": "Cannabis cultivation club — Secure member management",
|
||||
"adminLogin": "Go to Admin Login"
|
||||
},
|
||||
"api": {
|
||||
"loading": "Loading...",
|
||||
"error": "Failed to load data.",
|
||||
"retry": "Try again",
|
||||
"offline": "No connection to server — data may be outdated.",
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"unauthorized": "Session expired. Please sign in again.",
|
||||
"forbidden": "You do not have permission for this action.",
|
||||
"notFound": "Resource not found.",
|
||||
"quotaExceeded": "Quota exceeded.",
|
||||
"serverError": "Server error. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-toast": "1.2.1",
|
||||
"@radix-ui/react-tooltip": "1.1.5",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
Generated
+18
@@ -53,6 +53,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: 1.1.5
|
||||
version: 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.101.0
|
||||
version: 5.101.0(react@19.1.3)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
@@ -1642,6 +1645,14 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.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
|
||||
|
||||
@@ -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 (
|
||||
<Card className="mx-auto mt-8 max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<AlertCircle className="text-destructive h-10 w-10" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
{isApiError && error.code !== "NETWORK_ERROR" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Code: {error.code} (HTTP {error.status})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 border-destructive/30 text-destructive flex items-center gap-2 border-b px-4 py-2 text-sm"
|
||||
>
|
||||
<WifiOff className="h-4 w-4 shrink-0" />
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}</>
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* Skeleton for KPI stat cards on the dashboard.
|
||||
*/
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border p-6">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a data table (header + rows).
|
||||
*/
|
||||
export function TableSkeleton({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
}: {
|
||||
rows?: number
|
||||
columns?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex gap-4 border-b pb-2">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={`header-${i}`} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<div key={`row-${rowIdx}`} className="flex gap-4 py-2">
|
||||
{Array.from({ length: columns }).map((_, colIdx) => (
|
||||
<Skeleton key={`cell-${rowIdx}-${colIdx}`} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for chart / Recharts areas.
|
||||
*/
|
||||
export function ChartSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a single form field.
|
||||
*/
|
||||
export function FormFieldSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid of card skeletons for the dashboard.
|
||||
*/
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<RequestInit, "body"> {
|
||||
token?: string
|
||||
body?: unknown
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T>(
|
||||
endpoint: string,
|
||||
options: ApiClientOptions = {}
|
||||
): Promise<T> {
|
||||
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<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...(extraHeaders as Record<string, string>),
|
||||
}
|
||||
|
||||
// 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<T>
|
||||
} 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<string, string> = {
|
||||
Accept: "application/octet-stream",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...(extraHeaders as Record<string, string>),
|
||||
}
|
||||
|
||||
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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<SessionProvider>
|
||||
<SettingsProvider locale={locale}>
|
||||
<ModeProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children}</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</ModeProvider>
|
||||
</SettingsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider locale={locale}>
|
||||
<ModeProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children}</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</ModeProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ClubStats>("/dashboard/stats"),
|
||||
refetchInterval: 60 * 1000, // auto-refresh every 60s
|
||||
})
|
||||
}
|
||||
|
||||
export function useRecentDistributionsQuery(limit = 5) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "recent-distributions", limit],
|
||||
queryFn: () =>
|
||||
apiClient<Distribution[]>("/distributions/recent", {
|
||||
params: { limit },
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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<DistributionsPage>("/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<Distribution[]>("/distributions/recent", {
|
||||
params: { limit },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useQuotaQuery(memberId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["members", memberId, "quota"],
|
||||
queryFn: () => apiClient<QuotaStatus>(`/members/${memberId}/quota`),
|
||||
enabled: !!memberId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAvailableBatchesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["batches", "available"],
|
||||
queryFn: () => apiClient<AvailableBatch[]>("/batches/available"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useCreateDistributionMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateDistributionRequest) =>
|
||||
apiClient<DistributionRecord>("/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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<CreateMemberRequest> {
|
||||
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<MembersPage>("/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<Member>(`/members/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMemberQuotaQuery(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["members", id, "quota"],
|
||||
queryFn: () => apiClient<QuotaStatus>(`/members/${id}/quota`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useCreateMemberMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateMemberRequest) =>
|
||||
apiClient<Member>("/members", { method: "POST", body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["members"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateMemberMutation(id: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateMemberRequest) =>
|
||||
apiClient<Member>(`/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<void>(`/members/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["members"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<PortalDashboardData>("/portal/dashboard"),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalHistoryQuery(params?: {
|
||||
page?: number
|
||||
size?: number
|
||||
month?: string
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ["portal", "history", params],
|
||||
queryFn: () =>
|
||||
apiClient<PortalHistoryPage>("/portal/history", {
|
||||
params: {
|
||||
page: params?.page,
|
||||
size: params?.size ?? 20,
|
||||
month: params?.month || undefined,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePortalProfileQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["portal", "profile"],
|
||||
queryFn: () => apiClient<PortalProfileData>("/portal/profile"),
|
||||
staleTime: 5 * 60 * 1000, // profile rarely changes
|
||||
})
|
||||
}
|
||||
@@ -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<MonthlyReportData>("/reports/monthly", {
|
||||
params: { month: month || undefined },
|
||||
}),
|
||||
enabled: !!month,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMemberListReportQuery(status?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "member-list", status],
|
||||
queryFn: () =>
|
||||
apiClient<MemberListReportData>("/reports/member-list", {
|
||||
params: { status: status || undefined },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRecallReportQuery(dateRange?: { from: string; to: string }) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "recalls", dateRange],
|
||||
queryFn: () =>
|
||||
apiClient<RecallReportData>("/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)
|
||||
}
|
||||
@@ -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<StaffMember[]>("/staff"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useInviteStaffMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: InviteStaffRequest) =>
|
||||
apiClient<StaffMember>("/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<StaffMember>(`/staff/${id}/permissions`, {
|
||||
method: "PUT",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["staff"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeStaffMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiClient<void>(`/staff/${id}/revoke`, { method: "POST" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["staff"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<BatchesPage>("/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<Batch>(`/batches/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStrainsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["strains"],
|
||||
queryFn: () => apiClient<Strain[]>("/strains"),
|
||||
staleTime: 5 * 60 * 1000, // strains rarely change — 5 min stale
|
||||
})
|
||||
}
|
||||
|
||||
export function useStockSummaryQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["batches", "summary"],
|
||||
queryFn: () => apiClient<BatchSummary[]>("/batches/summary"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useCreateBatchMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateBatchRequest) =>
|
||||
apiClient<Batch>("/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<Batch>(`/batches/${id}/recall`, { method: "POST" }),
|
||||
onSuccess: (_data, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["batches"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["batches", id] })
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API Client Architecture (Server + Client)
|
||||
|
||||
Two layers — server-side for SSR/RSC, client-side for interactive pages:
|
||||
|
||||
```typescript
|
||||
// src/lib/api-client.ts (SERVER-SIDE — used in Server Components + Route Handlers)
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
const BACKEND_BASE = process.env.BACKEND_URL || "http://localhost:8080"
|
||||
|
||||
export async function apiServer<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const session = await auth()
|
||||
if (!session?.user) throw new Error("Unauthorized")
|
||||
|
||||
const res = await fetch(`${BACKEND_BASE}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
...options.headers,
|
||||
},
|
||||
next: { revalidate: 30 }, // ISR-style caching
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new ApiError(res.status, error.message)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/lib/api-client-browser.ts (CLIENT-SIDE — used with React Query hooks)
|
||||
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
// Uses Next.js rewrite proxy → /api/backend/* → backend:8080/api/v1/*
|
||||
const res = await fetch(`/api/backend${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
credentials: "include", // sends session cookie for auth
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }))
|
||||
throw new ApiError(res.status, error.message)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Service Hooks Pattern
|
||||
|
||||
Each domain gets a service file with typed React Query hooks:
|
||||
|
||||
```typescript
|
||||
// src/services/members.ts
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiFetch } from "@/lib/api-client-browser"
|
||||
import type { Member } from "@/types/api"
|
||||
|
||||
export function useMembers() {
|
||||
return useQuery({
|
||||
queryKey: ["members"],
|
||||
queryFn: () => apiFetch<Member[]>("/members"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMember(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["members", id],
|
||||
queryFn: () => apiFetch<Member>(`/members/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMember() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateMemberRequest) =>
|
||||
apiFetch<Member>("/members", { method: "POST", body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["members"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Error Handling + Toast Pattern
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-api-error.ts
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { ApiError } from "@/lib/api-client-browser"
|
||||
|
||||
export function useApiErrorHandler() {
|
||||
const { toast } = useToast()
|
||||
|
||||
return (error: unknown) => {
|
||||
if (error instanceof ApiError) {
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
toast({ title: "Sitzung abgelaufen", description: "Bitte erneut anmelden.", variant: "destructive" })
|
||||
break
|
||||
case 403:
|
||||
toast({ title: "Zugriff verweigert", description: "Fehlende Berechtigung.", variant: "destructive" })
|
||||
break
|
||||
case 409:
|
||||
toast({ title: "Kontingent überschritten", description: error.message, variant: "destructive" })
|
||||
break
|
||||
default:
|
||||
toast({ title: "Fehler", description: error.message, variant: "destructive" })
|
||||
}
|
||||
} else {
|
||||
toast({ title: "Verbindungsfehler", description: "Backend nicht erreichbar.", variant: "destructive" })
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 Loading Skeleton Pattern
|
||||
|
||||
```typescript
|
||||
// src/components/cannamanage/skeleton-card.tsx
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-[120px]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-[80px]" />
|
||||
<Skeleton className="h-3 w-[160px] mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 Docker Compose — Full Stack (Decision D7)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (updated — no profile gates)
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: cannamanage-db
|
||||
environment:
|
||||
POSTGRES_DB: cannamanage
|
||||
POSTGRES_USER: cannamanage
|
||||
POSTGRES_PASSWORD: dev_password_change_in_prod
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cannamanage"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: cannamanage-api/Dockerfile
|
||||
container_name: cannamanage-backend
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
|
||||
- SPRING_DATASOURCE_USERNAME=cannamanage
|
||||
- SPRING_DATASOURCE_PASSWORD=dev_password_change_in_prod
|
||||
- SPRING_PROFILES_ACTIVE=docker
|
||||
- JWT_SECRET=dev-secret-change-in-prod-minimum-32-chars
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./cannamanage-frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cannamanage-frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- BACKEND_URL=http://backend:8080
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- NEXTAUTH_SECRET=dev-nextauth-secret-32-chars-min
|
||||
- AUTH_TRUST_HOST=true
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
### 3.8 Test Compose Overlay
|
||||
|
||||
```yaml
|
||||
# docker-compose.test.yml (overlay for system tests)
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- SPRING_PROFILES_ACTIVE=docker,test-seed
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.52.0-noble
|
||||
container_name: cannamanage-playwright
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./cannamanage-frontend:/app
|
||||
environment:
|
||||
- BASE_URL=http://frontend:3000
|
||||
- CI=true
|
||||
command: npx playwright test e2e/system-test.spec.ts
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_started
|
||||
```
|
||||
|
||||
Run: `docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from playwright`
|
||||
|
||||
### 3.9 Backend Dockerfile (if not exists / needs update)
|
||||
|
||||
```dockerfile
|
||||
# cannamanage-api/Dockerfile
|
||||
FROM maven:3.9-eclipse-temurin-17 AS builder
|
||||
WORKDIR /build
|
||||
COPY pom.xml .
|
||||
COPY cannamanage-domain/pom.xml cannamanage-domain/
|
||||
COPY cannamanage-service/pom.xml cannamanage-service/
|
||||
COPY cannamanage-api/pom.xml cannamanage-api/
|
||||
RUN mvn dependency:go-offline -B
|
||||
|
||||
COPY cannamanage-domain/src cannamanage-domain/src
|
||||
COPY cannamanage-service/src cannamanage-service/src
|
||||
COPY cannamanage-api/src cannamanage-api/src
|
||||
RUN mvn package -pl cannamanage-api -am -DskipTests -B
|
||||
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/cannamanage-api/target/*.jar app.jar
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
```
|
||||
|
||||
### 3.10 Seed Data Script (Decision D5)
|
||||
|
||||
```sql
|
||||
-- src/main/resources/db/migration/R__test_seed.sql
|
||||
-- Repeatable migration: only runs when profile = test-seed
|
||||
|
||||
-- Club
|
||||
INSERT INTO clubs (id, name, status, city, max_members, created_at)
|
||||
VALUES ('club-001', 'Grüner Daumen e.V.', 'ACTIVE', 'Berlin', 500, NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Admin user (password: admin123)
|
||||
INSERT INTO users (id, email, password_hash, role, club_id, created_at)
|
||||
VALUES ('user-admin', 'admin@gruener-daumen.de', '$2a$10$...bcrypt...', 'ADMIN', 'club-001', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Staff user (password: staff123)
|
||||
INSERT INTO users (id, email, password_hash, role, club_id, created_at)
|
||||
VALUES ('user-staff', 'staff@gruener-daumen.de', '$2a$10$...bcrypt...', 'STAFF', 'club-001', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 5 Members
|
||||
INSERT INTO members (id, first_name, last_name, email, date_of_birth, member_number, status, club_id, joined_at)
|
||||
VALUES
|
||||
('m-001', 'Max', 'Müller', 'max@example.com', '1990-03-15', 'GD-001', 'ACTIVE', 'club-001', NOW()),
|
||||
('m-002', 'Lisa', 'Weber', 'lisa@example.com', '1988-07-22', 'GD-002', 'ACTIVE', 'club-001', NOW()),
|
||||
('m-003', 'Jonas', 'Fischer', 'jonas@example.com', '2005-11-01', 'GD-003', 'ACTIVE', 'club-001', NOW()),
|
||||
('m-004', 'Sarah', 'Braun', 'sarah@example.com', '1995-01-30', 'GD-004', 'ACTIVE', 'club-001', NOW()),
|
||||
('m-005', 'Kai', 'Hoffmann', 'kai@example.com', '1992-09-10', 'GD-005', 'ACTIVE', 'club-001', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Strains
|
||||
INSERT INTO strains (id, name, default_thc_percent, default_cbd_percent, club_id)
|
||||
VALUES
|
||||
('s-001', 'Amnesia Haze', 22.0, 0.5, 'club-001'),
|
||||
('s-002', 'White Widow', 18.0, 1.0, 'club-001'),
|
||||
('s-003', 'Northern Lights', 20.0, 0.8, 'club-001')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Batches
|
||||
INSERT INTO batches (id, strain_id, total_grams, available_grams, thc_percent, cbd_percent, supplier, status, club_id, received_at)
|
||||
VALUES
|
||||
('b-001', 's-001', 1000, 520, 22.0, 0.5, 'GreenGrow GmbH', 'AVAILABLE', 'club-001', NOW()),
|
||||
('b-002', 's-002', 800, 430, 18.0, 1.0, 'BioHemp AG', 'AVAILABLE', 'club-001', NOW()),
|
||||
('b-003', 's-003', 600, 380, 20.0, 0.8, 'GreenGrow GmbH', 'AVAILABLE', 'club-001', NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
```
|
||||
|
||||
### 3.11 Optimistic Update with Rollback (for Phase 4)
|
||||
|
||||
When recording a distribution, we optimistically update the UI (instant feedback) but roll back if the server rejects (e.g., quota exceeded):
|
||||
|
||||
```typescript
|
||||
// src/services/distributions.ts — optimistic mutation pattern
|
||||
export function useCreateDistribution() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateDistributionRequest) =>
|
||||
apiFetch<Distribution>("/distributions", { method: "POST", body: JSON.stringify(data) }),
|
||||
|
||||
// Optimistic update: immediately reflect in UI
|
||||
onMutate: async (newDistribution) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["distributions"] })
|
||||
const previous = queryClient.getQueryData<Distribution[]>(["distributions"])
|
||||
queryClient.setQueryData<Distribution[]>(["distributions"], (old = []) => [
|
||||
{ ...newDistribution, id: "optimistic-temp", createdAt: new Date().toISOString() } as Distribution,
|
||||
...old,
|
||||
])
|
||||
return { previous } // context for rollback
|
||||
},
|
||||
|
||||
// Rollback on error
|
||||
onError: (_err, _variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(["distributions"], context.previous)
|
||||
}
|
||||
},
|
||||
|
||||
// Always refetch after success or error to sync with server state
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["distributions"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] }) // refresh KPIs
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `onMutate` saves the previous state and applies the optimistic change
|
||||
- `onError` restores the previous state if the server returns 409 (quota exceeded) or 500
|
||||
- `onSettled` always re-fetches the truth from the server regardless of success/failure
|
||||
- The toast handler (from Section 3.5) shows the specific error message ("Kontingent überschritten: noch 12g verfügbar")
|
||||
|
||||
---
|
||||
|
||||
## 4. Directory Structure (New/Modified Files)
|
||||
|
||||
```
|
||||
cannamanage-frontend/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── api-client.ts # MODIFIED — server-side apiServer()
|
||||
│ │ ├── api-client-browser.ts # NEW — client-side apiFetch()
|
||||
│ │ └── query-client.ts # NEW — React Query configuration
|
||||
│ ├── services/ # NEW — domain service hooks
|
||||
│ │ ├── auth.ts # useLogin, useLogout
|
||||
│ │ ├── dashboard.ts # useClubStats
|
||||
│ │ ├── members.ts # useMembers, useMember, useCreateMember, useUpdateMember
|
||||
│ │ ├── distributions.ts # useDistributions, useCreateDistribution, useQuotaCheck
|
||||
│ │ ├── stock.ts # useBatches, useCreateBatch, useRecallBatch
|
||||
│ │ ├── reports.ts # useDownloadReport
|
||||
│ │ ├── portal.ts # usePortalDashboard, usePortalHistory
|
||||
│ │ └── staff.ts # useStaff, useInviteStaff, useUpdatePermissions
|
||||
│ ├── components/cannamanage/
|
||||
│ │ ├── skeleton-card.tsx # NEW — loading skeleton
|
||||
│ │ ├── skeleton-table.tsx # NEW — table loading skeleton
|
||||
│ │ ├── error-state.tsx # NEW — error boundary component
|
||||
│ │ └── staff/ # NEW — staff management components
|
||||
│ │ ├── staff-list.tsx
|
||||
│ │ ├── invite-dialog.tsx
|
||||
│ │ ├── permissions-editor.tsx
|
||||
│ │ └── revoke-dialog.tsx
|
||||
│ ├── hooks/
|
||||
│ │ └── use-api-error.ts # NEW — error handler hook
|
||||
│ ├── app/[locale]/(dashboard)/
|
||||
│ │ ├── page.tsx # MODIFIED — real API data
|
||||
│ │ ├── members/page.tsx # MODIFIED — real API data
|
||||
│ │ ├── distributions/page.tsx # MODIFIED — real API data
|
||||
│ │ ├── stock/page.tsx # MODIFIED — real API data
|
||||
│ │ ├── reports/page.tsx # MODIFIED — real API data
|
||||
│ │ └── settings/
|
||||
│ │ └── staff/page.tsx # NEW — staff management page
|
||||
│ ├── app/[locale]/(portal)/
|
||||
│ │ ├── page.tsx # MODIFIED — real API data
|
||||
│ │ └── history/page.tsx # MODIFIED — real API data
|
||||
│ └── app/[locale]/providers.tsx # NEW — QueryClientProvider wrapper
|
||||
├── e2e/
|
||||
│ └── system-test.spec.ts # NEW — full-stack E2E test
|
||||
└── package.json # MODIFIED — add @tanstack/react-query
|
||||
|
||||
cannamanage-api/
|
||||
├── Dockerfile # NEW or MODIFIED
|
||||
└── src/main/java/de/cannamanage/api/
|
||||
├── security/SecurityConfig.java # MODIFIED — add CORS
|
||||
└── config/CorsConfig.java # NEW (alternative: inline in SecurityConfig)
|
||||
|
||||
cannamanage-service/
|
||||
└── src/main/resources/
|
||||
└── db/migration/
|
||||
└── R__test_seed.sql # NEW — repeatable test data migration
|
||||
|
||||
docker-compose.yml # MODIFIED — remove profiles, add healthchecks
|
||||
docker-compose.test.yml # NEW — test overlay with Playwright
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Phases
|
||||
|
||||
### Phase 1: Docker Compose Full Stack + Next.js Upgrade (1 day)
|
||||
|
||||
**Goal:** `docker compose up` brings up PostgreSQL + Spring Boot + Next.js, all healthy and communicating. Next.js upgraded to 15.5.18 for security.
|
||||
|
||||
**Tasks:**
|
||||
1. **Next.js upgrade 15.2.8 → 15.5.18** — addresses 8+ Snyk CVEs (SSRF, auth bypass, resource exhaustion). Run `pnpm up next@15.5.18`, fix any breaking changes in `next.config.mjs`, verify all pages render.
|
||||
2. Remove `profiles: [full]` from backend/frontend in `docker-compose.yml`
|
||||
3. Add backend health check (curl actuator/health)
|
||||
4. Add frontend dependency on `backend: condition: service_healthy`
|
||||
5. Add NEXTAUTH environment variables to frontend service
|
||||
6. Create/update `cannamanage-api/Dockerfile` (multi-stage Maven build)
|
||||
7. Add CORS configuration to `SecurityConfig.java`
|
||||
8. Add rate limiting to auth endpoints — Bucket4j or Spring `@RateLimiter` on `/api/v1/auth/login` (max 5 attempts/min per IP) and `/api/v1/staff/invite` (max 10/hour per user). Prevents brute-force attacks.
|
||||
9. Verify: `docker compose up` → all 3 containers healthy → `curl http://localhost:8080/actuator/health` returns UP
|
||||
10. Run existing Playwright E2E suite to confirm no regressions from Next.js upgrade
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Next.js upgraded to 15.5.18 — `pnpm list next` shows correct version
|
||||
- [ ] All 66+ existing Playwright E2E tests still pass after upgrade
|
||||
- [ ] `docker compose up` starts all 3 services without errors
|
||||
- [ ] Backend responds to `/actuator/health` within 30s of start
|
||||
- [ ] Frontend loads at `http://localhost:3000` and displays login page
|
||||
- [ ] CORS allows `localhost:3000` to call `localhost:8080/api/v1/auth/login`
|
||||
- [ ] No `profiles` gate — services start by default
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: API Client Layer (1 day)
|
||||
|
||||
**Goal:** Typed, reusable API layer with React Query for client-side data fetching.
|
||||
|
||||
**Tasks:**
|
||||
1. `pnpm add @tanstack/react-query @tanstack/react-query-devtools`
|
||||
2. Create `src/lib/query-client.ts` with default options
|
||||
3. Create `src/lib/api-client-browser.ts` — client-side fetch via Next.js rewrite proxy
|
||||
4. Refactor existing `src/lib/api-client.ts` → rename to server-side usage (`apiServer()`)
|
||||
5. Create `src/app/[locale]/providers.tsx` with `QueryClientProvider`
|
||||
6. Wire providers into root layout
|
||||
7. Create `src/hooks/use-api-error.ts` — error → toast mapping
|
||||
8. Create service files: `src/services/dashboard.ts`, `members.ts`, `distributions.ts`, `stock.ts`, `reports.ts`, `portal.ts`
|
||||
9. Create skeleton components: `skeleton-card.tsx`, `skeleton-table.tsx`, `error-state.tsx`
|
||||
10. Add React Query DevTools (dev only)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `@tanstack/react-query` installed and provider mounted
|
||||
- [ ] `apiFetch()` calls go through `/api/backend/*` rewrite to Spring Boot
|
||||
- [ ] Each domain has typed React Query hooks (queryKey, queryFn, types)
|
||||
- [ ] Error handler shows German toast messages for 401/403/409/500
|
||||
- [ ] Skeleton components render during loading state
|
||||
- [ ] React Query DevTools visible in dev mode (bottom-left panel)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Wire Dashboard + Members (1.5 days)
|
||||
|
||||
**Goal:** Dashboard shows real KPIs from backend. Member list fetches from real API.
|
||||
|
||||
**Tasks:**
|
||||
1. **Dashboard page** — Replace `mockClubStats` with `useClubStats()` hook → `GET /clubs/stats`
|
||||
2. **Dashboard recent distributions** — `useRecentDistributions()` → `GET /distributions?limit=5`
|
||||
3. **Dashboard stock chart** — `useStockSummary()` → `GET /stock/batches/summary`
|
||||
4. **Member list** — Replace `mockMembers` with `useMembers()` → `GET /members`
|
||||
5. **Member detail** — `useMember(id)` → `GET /members/{id}`
|
||||
6. **Add member form** — `useCreateMember()` → `POST /members`
|
||||
7. **Edit member form** — `useUpdateMember()` → `PUT /members/{id}`
|
||||
8. Add loading skeletons to dashboard KPI cards and member table
|
||||
9. Add error states with retry button
|
||||
10. Handle empty states (no members yet, no distributions today)
|
||||
|
||||
**Backend adjustments needed:**
|
||||
- Add `GET /api/v1/clubs/stats` endpoint if not existing (or adapt `ClubController`)
|
||||
- Ensure `GET /api/v1/members` returns paginated response compatible with TanStack Table
|
||||
- Verify `GET /api/v1/distributions` supports `?limit=N` query param
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Dashboard loads real data — KPI cards show numbers from DB
|
||||
- [ ] Member list shows real members from PostgreSQL
|
||||
- [ ] Add member form creates a real member (visible after page refresh)
|
||||
- [ ] Edit member form persists changes
|
||||
- [ ] Loading skeletons appear during fetch
|
||||
- [ ] Error state shows when backend is down (not a blank page)
|
||||
- [ ] Empty state message when no data exists
|
||||
- [ ] No mock data imports remain in dashboard or members pages
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Wire Distributions + Stock (1.5 days)
|
||||
|
||||
**Goal:** Distribution recording hits real backend with quota enforcement. Stock management is live.
|
||||
|
||||
**Tasks:**
|
||||
1. **Distribution list** — `useDistributions()` → `GET /distributions`
|
||||
2. **New distribution form** — `useCreateDistribution()` → `POST /distributions`
|
||||
3. **Quota check (real-time)** — `useQuotaCheck(memberId)` → `GET /compliance/quota/{memberId}`
|
||||
4. Wire member search in distribution form to `GET /members?search=...`
|
||||
5. Wire batch selection to `GET /stock/batches?status=AVAILABLE`
|
||||
6. **Stock/batch list** — `useBatches()` → `GET /stock/batches`
|
||||
7. **Add batch form** — `useCreateBatch()` → `POST /stock/batches`
|
||||
8. **Recall batch** — `useRecallBatch()` → `PUT /stock/batches/{id}/recall`
|
||||
9. Handle quota exceeded error (409) → show specific toast with remaining grams
|
||||
10. Optimistic update: after recording distribution, immediately update local cache
|
||||
|
||||
**Key integration points:**
|
||||
- Distribution form must pass `batchId` + `memberId` + `amountGrams`
|
||||
- Backend returns `409 Conflict` with `QuotaExceededException` details when over limit
|
||||
- Stock chart re-fetches after batch creation or recall
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Distribution recording persists to DB (verifiable via `GET /distributions`)
|
||||
- [ ] Quota check prevents over-limit distribution (shows remaining grams in error)
|
||||
- [ ] Under-21 members see 30g monthly limit enforced
|
||||
- [ ] Batch creation adds real stock (visible in stock page)
|
||||
- [ ] Batch recall changes status to RECALLED
|
||||
- [ ] Stock chart updates after mutations
|
||||
- [ ] No mock data imports remain in distributions or stock pages
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Wire Reports + Portal (1.5 days)
|
||||
|
||||
**Goal:** Report downloads generate real PDFs. Member portal shows live personal data.
|
||||
|
||||
**Tasks:**
|
||||
1. **Monthly report** — `useDownloadReport("monthly")` → `GET /reports/monthly?format=pdf`
|
||||
2. **Member list report** — `GET /reports/member-list?format=csv`
|
||||
3. **Recall report** — `GET /reports/recall?format=pdf`
|
||||
4. Handle binary download (PDF/CSV) — create blob URL, trigger download
|
||||
5. **Portal dashboard** — `usePortalDashboard()` → `GET /portal/dashboard`
|
||||
6. **Portal history** — `usePortalHistory()` → `GET /portal/history`
|
||||
7. Portal auth: wire session-based login (`/portal/login` form submit)
|
||||
8. Show real quota radial with live monthly usage percentage
|
||||
9. Add date range picker for report generation (month selector)
|
||||
|
||||
**Special handling:**
|
||||
- Report endpoints return binary (PDF) or text (CSV), not JSON — fetch with `responseType: blob`
|
||||
- Portal uses session auth (not JWT) — different fetch pattern (cookies, CSRF token)
|
||||
- Portal API calls go to `/portal/*` endpoints, not `/api/v1/*`
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] PDF report downloads as file (opens in browser/downloads)
|
||||
- [ ] CSV report downloads correctly formatted
|
||||
- [ ] Portal login with member credentials works
|
||||
- [ ] Portal dashboard shows real quota (used/remaining from DB)
|
||||
- [ ] Portal distribution history shows actual past distributions
|
||||
- [ ] No mock data imports remain in reports or portal pages
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Staff Management UI (2 days)
|
||||
|
||||
**Goal:** Admin can invite staff, configure permissions, and revoke access through the UI.
|
||||
|
||||
**Tasks:**
|
||||
1. Create staff settings page at `/settings/staff`
|
||||
2. Add navigation entry in sidebar (under Settings section)
|
||||
3. **Staff list component** — table with name, email, permissions, status, actions
|
||||
4. **Invite dialog** — email input + permission checkboxes (8 granular permissions)
|
||||
5. **Permissions editor** — inline permission toggle grid per staff member
|
||||
6. **Revoke dialog** — confirmation dialog → `DELETE /staff/{id}`
|
||||
7. Wire to backend:
|
||||
- `GET /api/v1/staff` → list staff
|
||||
- `POST /api/v1/staff/invite` → send invite
|
||||
- `PUT /api/v1/staff/{id}/permissions` → update permissions
|
||||
- `DELETE /api/v1/staff/{id}` → revoke
|
||||
8. Show pending invites (status: PENDING) with resend option
|
||||
9. Add i18n strings for staff management (de.json + en.json)
|
||||
10. Permission chips: visual badges for each permission (color-coded)
|
||||
|
||||
**Permissions (from backend `StaffPermission` enum):**
|
||||
- `MANAGE_MEMBERS` — add/edit/suspend members
|
||||
- `RECORD_DISTRIBUTIONS` — record cannabis distributions
|
||||
- `MANAGE_STOCK` — add batches, recall
|
||||
- `VIEW_REPORTS` — download reports
|
||||
- `MANAGE_CLUB_SETTINGS` — edit club configuration
|
||||
- `MANAGE_COMPLIANCE` — compliance dashboard access
|
||||
- `INVITE_STAFF` — can invite other staff
|
||||
- `MANAGE_STAFF` — full staff CRUD (admin-like)
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Staff list page accessible from sidebar navigation
|
||||
- [ ] Invite form sends email invite (or shows success if email service is stubbed)
|
||||
- [ ] Permission editor shows 8 checkboxes per staff member
|
||||
- [ ] Saving permissions persists to DB
|
||||
- [ ] Revoke removes staff access (deleted from list)
|
||||
- [ ] Pending invites shown with "Resend" action
|
||||
- [ ] Only ADMIN role can access staff settings (403 for STAFF role without MANAGE_STAFF)
|
||||
- [ ] i18n: all labels in both German and English
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: System Test Harness (2 days)
|
||||
|
||||
**Goal:** One command runs the full stack with seed data and executes E2E tests against real backend.
|
||||
|
||||
**Tasks:**
|
||||
1. Create `R__test_seed.sql` — repeatable Flyway migration with deterministic test data
|
||||
2. Configure Spring profile `test-seed` to activate repeatable migration
|
||||
3. Create `docker-compose.test.yml` overlay (Playwright container + test-seed profile)
|
||||
4. Create `e2e/system-test.spec.ts` — full integration flow:
|
||||
- Login as admin → verify dashboard loads with seed data
|
||||
- Navigate to members → verify 5 seed members visible
|
||||
- Add new member → verify appears in list
|
||||
- Record distribution → verify quota updates
|
||||
- Check stock decreases after distribution
|
||||
- Download monthly report (verify PDF response)
|
||||
- Login as member (portal) → verify personal quota visible
|
||||
5. Add npm script: `"test:system": "docker compose -f ... up --abort-on-container-exit"`
|
||||
6. Verify CI-readiness: exit code 0 on success, non-zero on failure
|
||||
7. Add GitHub Actions workflow stub (`.github/workflows/system-test.yml`)
|
||||
|
||||
**Test flow (happy path):**
|
||||
```
|
||||
1. Admin login (admin@gruener-daumen.de / admin123)
|
||||
2. Dashboard → verify KPIs match seed data (5 members, 3 batches)
|
||||
3. Members → see "Max Müller", "Lisa Weber", etc.
|
||||
4. Add member "Test Neuzugang" → verify appears
|
||||
5. Distributions → record 10g Amnesia Haze to Max Müller
|
||||
6. Verify quota check shows updated usage
|
||||
7. Stock → verify Amnesia Haze batch decreased by 10g
|
||||
8. Reports → download monthly PDF → verify 200 response + content-type
|
||||
9. Portal login (max@example.com / member123)
|
||||
10. Portal dashboard → verify quota shows 10g used
|
||||
```
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit` runs full test
|
||||
- [ ] Seed data creates club + admin + staff + 5 members + 3 batches
|
||||
- [ ] System test covers: login → CRUD → quota → report → portal
|
||||
- [ ] Test passes in < 3 minutes on clean start
|
||||
- [ ] Exit code 0 on success, non-zero on any test failure
|
||||
- [ ] Test is deterministic (no flaky timing issues)
|
||||
- [ ] GitHub Actions workflow file created (can be enabled when GitHub repo is set up)
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk Assessment
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|------------|
|
||||
| Backend API mismatch (frontend expects fields that don't exist) | Medium | High | Read actual controller code before wiring; add DTO type assertions |
|
||||
| NextAuth session timeout during system test | Medium | Medium | Set long session expiry in test profile (24h) |
|
||||
| Docker build slow (Maven + Node rebuild each time) | High | Low | Use Docker build cache, `.dockerignore` excludes target/ |
|
||||
| CORS issues between containers | Medium | Medium | Test CORS early in Phase 1; add integration test for preflight |
|
||||
| React Query cache stale data after mutation | Low | Medium | Explicit `invalidateQueries` after every mutation |
|
||||
| Seed data conflicts with Hibernate DDL auto | Medium | High | Ensure `spring.jpa.hibernate.ddl-auto=validate` in Docker profile (Flyway handles schema) |
|
||||
| Playwright flaky on container startup timing | High | Medium | Add `waitForURL` + health check polling before test start |
|
||||
| Backend Dockerfile missing (never existed) | Medium | Low | Create from scratch in Phase 1 — straightforward multi-stage Maven |
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions — RESOLVED ✅
|
||||
|
||||
All questions resolved in v2 planning session (2026-06-12):
|
||||
|
||||
| # | Question | Decision | Rationale |
|
||||
|---|----------|----------|-----------|
|
||||
| Q1 | API client library? | ✅ **@tanstack/react-query** | Caching, refetch, optimistic updates built-in. Eliminates `useEffect` + `useState` boilerplate. DevTools for debugging. |
|
||||
| Q2 | Loading strategy? | ✅ **Per-component loading** | Each card/table loads independently — no full-page blocking. Better perceived performance. |
|
||||
| Q3 | Offline resilience? | ✅ **Stale-while-revalidate + "Offline" banner** | React Query serves cached data while retrying. Persistent banner communicates status without blocking interaction. |
|
||||
| Q4 | Staff UI scope? | ✅ **Full CRUD** (list + invite + edit perms + revoke) | Activity log deferred to Sprint 6. Full CRUD is the minimum viable staff management. |
|
||||
| Q5 | Seed data strategy? | ✅ **SQL for dev/test + API-driven for system E2E** | Dual approach: fast deterministic SQL seed for local dev, API-driven setup in system tests for realistic coverage. |
|
||||
| Q6 (Bonus) | Next.js upgrade? | ✅ **Yes — 15.2.8 → 15.5.18 in Phase 1** | Addresses 8+ Snyk CVEs. Done early to catch breaking changes before integration work begins. |
|
||||
|
||||
Previously resolved (from v1):
|
||||
| # | Question | Decision |
|
||||
|---|----------|----------|
|
||||
| Q-v1-1 | Proxy vs direct CORS? | **Proxy** (Next.js rewrite) + CORS for dev flexibility |
|
||||
| Q-v1-2 | Activity log timing? | **Defer to Sprint 6** |
|
||||
| Q-v1-3 | System test config? | **Separate Playwright config** for Docker-based system tests |
|
||||
| Q-v1-4 | Keep mock data files? | **Yes** — fast E2E (<30s) coexists with system tests (minutes) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependencies & Prerequisites
|
||||
|
||||
| Prerequisite | Status | Action |
|
||||
|-------------|--------|--------|
|
||||
| Backend compiles and starts on current `main` | ✅ Done | Sprint 3 verified |
|
||||
| Backend `Dockerfile` exists | ❓ Check | Create in Phase 1 if missing |
|
||||
| Backend `actuator/health` endpoint enabled | ✅ Done | Spring Boot default |
|
||||
| Frontend `types/api.ts` matches backend DTOs | ⚠️ Partial | Verify during Phase 3; add missing types |
|
||||
| PostgreSQL 16 compatible with Hibernate schema | ✅ Done | Testcontainers use PG 16 |
|
||||
| `pnpm` and Node 22 available locally | ✅ Done | Sprint 4 verified |
|
||||
| Docker Desktop running | Required | Developer responsibility |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics (End of Sprint 5)
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Pages using real API data | 12/12 (all pages, zero mock) |
|
||||
| Docker Compose startup → healthy | < 60 seconds |
|
||||
| System test pass rate | 100% (deterministic) |
|
||||
| System test duration | < 3 minutes |
|
||||
| API error handling coverage | 401, 403, 404, 409, 500 all handled |
|
||||
| Staff management operations | List, Invite, Edit Permissions, Revoke |
|
||||
| Remaining mock data files | Kept for fast E2E (not used in production pages) |
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- Sprint 4 Plan: `docs/sprint-4/cannamanage-sprint4-plan.md` (v3)
|
||||
- Sprint 5 Backlog: `docs/sprint-5/cannamanage-sprint5-backlog.md`
|
||||
- Sprint 4 Persona Review: `docs/sprint-4/cannamanage-sprint4-plan-persona-review.md`
|
||||
- Backend Security Config: `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java`
|
||||
- Frontend API Types: `cannamanage-frontend/src/types/api.ts`
|
||||
- Docker Compose: `docker-compose.yml`
|
||||
@@ -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/`
|
||||
Reference in New Issue
Block a user