feat(sprint-6): Phase 2 — DSGVO consent management
- V6 migration: consents table with audit columns - Consent entity, repository, service (grant/revoke/check) - ConsentController: GET/POST/DELETE consent endpoints - DSGVO export (Art. 15): full personal data JSON download - DSGVO deletion (Art. 17): anonymization + account deactivation - Frontend: consent banner (modal, cannot dismiss), privacy settings page - React Query hooks for consent + DSGVO operations - Full i18n (de/en) for consent and DSGVO namespaces
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import de.cannamanage.service.ConsentService;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/consent")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Consent", description = "DSGVO consent management")
|
||||
public class ConsentController {
|
||||
|
||||
private final ConsentService consentService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get current user's consents")
|
||||
public ResponseEntity<List<ConsentResponse>> getConsents(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
List<ConsentResponse> consents = consentService.getUserConsents(userId).stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
return ResponseEntity.ok(consents);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Grant consent")
|
||||
public ResponseEntity<ConsentResponse> grantConsent(
|
||||
@RequestBody GrantConsentRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
String ipAddress = httpRequest.getRemoteAddr();
|
||||
String userAgent = httpRequest.getHeader("User-Agent");
|
||||
|
||||
Consent consent = consentService.grantConsent(
|
||||
userId,
|
||||
request.type(),
|
||||
request.version() != null ? request.version() : 1,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
return ResponseEntity.ok(toResponse(consent));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{type}")
|
||||
@Operation(summary = "Revoke consent")
|
||||
public ResponseEntity<Void> revokeConsent(@PathVariable String type, Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
ConsentType consentType = ConsentType.valueOf(type);
|
||||
consentService.revokeConsent(userId, consentType);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
@Operation(summary = "Check if user has required DATA_PROCESSING consent")
|
||||
public ResponseEntity<Map<String, Boolean>> checkConsent(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
boolean hasConsent = consentService.hasRequiredConsents(userId);
|
||||
return ResponseEntity.ok(Map.of("hasDataProcessingConsent", hasConsent));
|
||||
}
|
||||
|
||||
private UUID resolveUserId(Authentication auth) {
|
||||
String email = auth.getName();
|
||||
return userRepository.findByEmailAndTenantId(email, TenantContext.getCurrentTenant())
|
||||
.map(User::getId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
private ConsentResponse toResponse(Consent consent) {
|
||||
return new ConsentResponse(
|
||||
consent.getId(),
|
||||
consent.getConsentType().name(),
|
||||
consent.isGranted(),
|
||||
consent.getGrantedAt() != null ? consent.getGrantedAt().toString() : null,
|
||||
consent.getRevokedAt() != null ? consent.getRevokedAt().toString() : null,
|
||||
consent.getVersion()
|
||||
);
|
||||
}
|
||||
|
||||
public record GrantConsentRequest(ConsentType type, Integer version) {}
|
||||
public record ConsentResponse(UUID id, String type, boolean granted, String grantedAt, String revokedAt, int version) {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.service.DsgvoService;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dsgvo")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "DSGVO", description = "Data export and deletion (GDPR Art. 15 & 17)")
|
||||
public class DsgvoController {
|
||||
|
||||
private final DsgvoService dsgvoService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Art. 15 DSGVO — Export all personal data as JSON.
|
||||
*/
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "Export all personal data (Art. 15 DSGVO)")
|
||||
public ResponseEntity<Map<String, Object>> exportData(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Map<String, Object> data = dsgvoService.exportUserData(userId, tenantId);
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Art. 17 DSGVO — Right to erasure.
|
||||
* Anonymizes personal data, deactivates account.
|
||||
*/
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "Delete account and anonymize data (Art. 17 DSGVO)")
|
||||
public ResponseEntity<Map<String, String>> deleteAccount(Authentication auth) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
dsgvoService.deleteUserData(userId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "deleted",
|
||||
"message", "Dein Konto wurde gelöscht und deine Daten anonymisiert."
|
||||
));
|
||||
}
|
||||
|
||||
private UUID resolveUserId(Authentication auth) {
|
||||
String email = auth.getName();
|
||||
return userRepository.findByEmailAndTenantId(email, TenantContext.getCurrentTenant())
|
||||
.map(User::getId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- V6: DSGVO Consent Management
|
||||
-- Tracks user consent for data processing, marketing, analytics per GDPR Art. 6/7
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
consent_type VARCHAR(50) NOT NULL, -- 'DATA_PROCESSING', 'MARKETING', 'ANALYTICS'
|
||||
granted BOOLEAN NOT NULL DEFAULT false,
|
||||
granted_at TIMESTAMP WITH TIME ZONE,
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1, -- consent text version
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_consents_user ON consents(user_id);
|
||||
CREATE INDEX idx_consents_tenant ON consents(tenant_id);
|
||||
CREATE INDEX idx_consents_type ON consents(consent_type, user_id);
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "consents")
|
||||
public class Consent extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "consent_type", nullable = false, length = 50)
|
||||
private ConsentType consentType;
|
||||
|
||||
@Column(name = "granted", nullable = false)
|
||||
private boolean granted = false;
|
||||
|
||||
@Column(name = "granted_at")
|
||||
private Instant grantedAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "user_agent", columnDefinition = "TEXT")
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "version", nullable = false)
|
||||
private int version = 1;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// --- Getters & Setters ---
|
||||
|
||||
public UUID getUserId() { return userId; }
|
||||
public void setUserId(UUID userId) { this.userId = userId; }
|
||||
|
||||
public ConsentType getConsentType() { return consentType; }
|
||||
public void setConsentType(ConsentType consentType) { this.consentType = consentType; }
|
||||
|
||||
public boolean isGranted() { return granted; }
|
||||
public void setGranted(boolean granted) { this.granted = granted; }
|
||||
|
||||
public Instant getGrantedAt() { return grantedAt; }
|
||||
public void setGrantedAt(Instant grantedAt) { this.grantedAt = grantedAt; }
|
||||
|
||||
public Instant getRevokedAt() { return revokedAt; }
|
||||
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
|
||||
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; }
|
||||
|
||||
public String getUserAgent() { return userAgent; }
|
||||
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
|
||||
|
||||
public int getVersion() { return version; }
|
||||
public void setVersion(int version) { this.version = version; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.cannamanage.domain.enums;
|
||||
|
||||
/**
|
||||
* Types of DSGVO consent that can be granted or revoked by users.
|
||||
*/
|
||||
public enum ConsentType {
|
||||
DATA_PROCESSING,
|
||||
MARKETING,
|
||||
ANALYTICS
|
||||
}
|
||||
@@ -343,5 +343,32 @@
|
||||
"notFound": "Ressource nicht gefunden.",
|
||||
"quotaExceeded": "Kontingent überschritten.",
|
||||
"serverError": "Serverfehler. Bitte versuche es später erneut."
|
||||
},
|
||||
"consent": {
|
||||
"title": "Datenschutz-Einwilligung",
|
||||
"dataProcessing": "Datenverarbeitung",
|
||||
"dataProcessingDesc": "Ich willige ein, dass meine personenbezogenen Daten (Name, E-Mail, Geburtsdatum, Ausgabe-Historie) zum Zweck der Vereinsverwaltung verarbeitet werden. Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO.",
|
||||
"marketing": "Marketing-Benachrichtigungen",
|
||||
"marketingDesc": "Ich möchte über neue Funktionen und Angebote informiert werden.",
|
||||
"accept": "Ich stimme zu",
|
||||
"reject": "Ablehnen und Konto löschen",
|
||||
"required": "Erforderlich",
|
||||
"revoke": "Einwilligung widerrufen",
|
||||
"revokeWarning": "Ohne Einwilligung zur Datenverarbeitung kann der Dienst nicht genutzt werden.",
|
||||
"granted": "Erteilt am",
|
||||
"revoked": "Widerrufen"
|
||||
},
|
||||
"dsgvo": {
|
||||
"title": "Datenschutz",
|
||||
"export": "Meine Daten exportieren",
|
||||
"exportDesc": "Laden Sie alle über Sie gespeicherten Daten als JSON-Datei herunter (Art. 15 DSGVO).",
|
||||
"exportButton": "Daten herunterladen",
|
||||
"exporting": "Daten werden zusammengestellt...",
|
||||
"exported": "Datenexport heruntergeladen.",
|
||||
"delete": "Konto und Daten löschen",
|
||||
"deleteDesc": "Alle personenbezogenen Daten werden unwiderruflich gelöscht oder anonymisiert (Art. 17 DSGVO). Ausgabe-Daten bleiben anonymisiert erhalten (gesetzliche Aufbewahrungspflicht).",
|
||||
"deleteButton": "Konto endgültig löschen",
|
||||
"deleteConfirm": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteSuccess": "Dein Konto wurde gelöscht. Du wirst jetzt abgemeldet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,5 +343,32 @@
|
||||
"notFound": "Resource not found.",
|
||||
"quotaExceeded": "Quota exceeded.",
|
||||
"serverError": "Server error. Please try again later."
|
||||
},
|
||||
"consent": {
|
||||
"title": "Privacy Consent",
|
||||
"dataProcessing": "Data Processing",
|
||||
"dataProcessingDesc": "I consent to the processing of my personal data (name, email, date of birth, distribution history) for the purpose of club management. Legal basis: Art. 6(1)(a) GDPR.",
|
||||
"marketing": "Marketing Notifications",
|
||||
"marketingDesc": "I would like to receive information about new features and offers.",
|
||||
"accept": "I agree",
|
||||
"reject": "Decline and delete account",
|
||||
"required": "Required",
|
||||
"revoke": "Revoke consent",
|
||||
"revokeWarning": "Without consent for data processing, the service cannot be used.",
|
||||
"granted": "Granted on",
|
||||
"revoked": "Revoked"
|
||||
},
|
||||
"dsgvo": {
|
||||
"title": "Privacy",
|
||||
"export": "Export my data",
|
||||
"exportDesc": "Download all data stored about you as a JSON file (Art. 15 GDPR).",
|
||||
"exportButton": "Download data",
|
||||
"exporting": "Compiling data...",
|
||||
"exported": "Data export downloaded.",
|
||||
"delete": "Delete account and data",
|
||||
"deleteDesc": "All personal data will be irreversibly deleted or anonymized (Art. 17 GDPR). Distribution data will be retained in anonymized form (legal retention requirement).",
|
||||
"deleteButton": "Permanently delete account",
|
||||
"deleteConfirm": "Are you sure? This action cannot be undone.",
|
||||
"deleteSuccess": "Your account has been deleted. You will now be logged out."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextIntlClientProvider } from "next-intl"
|
||||
import { getMessages } from "next-intl/server"
|
||||
|
||||
import { ConsentBanner } from "@/components/consent-banner"
|
||||
import { Layout } from "@/components/layout"
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -13,6 +14,7 @@ export default async function DashboardLayout({
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Layout>{children}</Layout>
|
||||
<ConsentBanner />
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
useConsentsQuery,
|
||||
useDeleteAccountMutation,
|
||||
useExportDataMutation,
|
||||
useGrantConsentMutation,
|
||||
useRevokeConsentMutation,
|
||||
} from "@/services/consent"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Download, Shield, ToggleLeft, ToggleRight, Trash2 } from "lucide-react"
|
||||
|
||||
export default function PrivacySettingsPage() {
|
||||
const t = useTranslations("dsgvo")
|
||||
const tc = useTranslations("consent")
|
||||
const router = useRouter()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const { data: consents, isLoading } = useConsentsQuery()
|
||||
const grantMutation = useGrantConsentMutation()
|
||||
const revokeMutation = useRevokeConsentMutation()
|
||||
const exportMutation = useExportDataMutation()
|
||||
const deleteMutation = useDeleteAccountMutation()
|
||||
|
||||
const consentTypes = [
|
||||
{
|
||||
type: "DATA_PROCESSING" as const,
|
||||
label: tc("dataProcessing"),
|
||||
description: tc("dataProcessingDesc"),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: "MARKETING" as const,
|
||||
label: tc("marketing"),
|
||||
description: tc("marketingDesc"),
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
type: "ANALYTICS" as const,
|
||||
label: "Analytics",
|
||||
description: "Nutzungsanalysen zur Verbesserung des Dienstes.",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
|
||||
const isGranted = (type: string) =>
|
||||
consents?.find((c) => c.type === type)?.granted ?? false
|
||||
|
||||
const getGrantedDate = (type: string) =>
|
||||
consents?.find((c) => c.type === type)?.grantedAt
|
||||
|
||||
const handleToggle = async (type: string, currentlyGranted: boolean) => {
|
||||
if (currentlyGranted) {
|
||||
if (type === "DATA_PROCESSING") {
|
||||
setShowDeleteConfirm(true)
|
||||
return
|
||||
}
|
||||
await revokeMutation.mutateAsync(type)
|
||||
} else {
|
||||
await grantMutation.mutateAsync({
|
||||
type: type as "DATA_PROCESSING" | "MARKETING" | "ANALYTICS",
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
exportMutation.mutate()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteMutation.mutateAsync()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
</div>
|
||||
|
||||
{/* Consent Toggles */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">{tc("title")}</h2>
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
consentTypes.map((ct) => {
|
||||
const granted = isGranted(ct.type)
|
||||
const grantedDate = getGrantedDate(ct.type)
|
||||
return (
|
||||
<div
|
||||
key={ct.type}
|
||||
className="flex items-start justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{ct.label}</h3>
|
||||
{ct.required && (
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{tc("required")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{ct.description}
|
||||
</p>
|
||||
{granted && grantedDate && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{tc("granted")}{" "}
|
||||
{new Date(grantedDate).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggle(ct.type, granted)}
|
||||
disabled={grantMutation.isPending || revokeMutation.isPending}
|
||||
className="ml-4 shrink-0"
|
||||
aria-label={granted ? tc("revoke") : tc("accept")}
|
||||
>
|
||||
{granted ? (
|
||||
<ToggleRight className="h-8 w-8 text-primary" />
|
||||
) : (
|
||||
<ToggleLeft className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Data Export */}
|
||||
<section className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("export")}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("exportDesc")}</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exportMutation.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-secondary px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary/80 disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exportMutation.isPending ? t("exporting") : t("exportButton")}
|
||||
</button>
|
||||
{exportMutation.isSuccess && (
|
||||
<p className="text-sm text-green-600">{t("exported")}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Account Deletion */}
|
||||
<section className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<h2 className="text-lg font-semibold text-destructive">
|
||||
{t("delete")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("deleteDesc")}</p>
|
||||
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("deleteButton")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t("deleteConfirm")}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending ? "..." : t("deleteButton")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
useConsentCheckQuery,
|
||||
useGrantConsentMutation,
|
||||
} from "@/services/consent"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle, Shield } from "lucide-react"
|
||||
|
||||
/**
|
||||
* DSGVO Consent Banner — fullscreen modal overlay.
|
||||
* Shows when a logged-in user has NOT yet granted DATA_PROCESSING consent.
|
||||
* Cannot be dismissed without action.
|
||||
*/
|
||||
export function ConsentBanner() {
|
||||
const t = useTranslations("consent")
|
||||
const [marketingChecked, setMarketingChecked] = useState(false)
|
||||
|
||||
const { data: consentCheck, isLoading } = useConsentCheckQuery()
|
||||
const grantMutation = useGrantConsentMutation()
|
||||
|
||||
// Don't show if still loading or consent already granted
|
||||
if (isLoading || consentCheck?.hasDataProcessingConsent) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
// Grant DATA_PROCESSING consent (required)
|
||||
await grantMutation.mutateAsync({ type: "DATA_PROCESSING", version: 1 })
|
||||
// Grant MARKETING if checked
|
||||
if (marketingChecked) {
|
||||
await grantMutation.mutateAsync({ type: "MARKETING", version: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
// Redirect to deletion confirmation
|
||||
window.location.href = "/settings/privacy?action=delete"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="mx-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-xl bg-card p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Shield className="h-8 w-8 text-primary" />
|
||||
<h2 className="text-xl font-semibold">{t("title")}</h2>
|
||||
</div>
|
||||
|
||||
{/* Required: Data Processing */}
|
||||
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<h3 className="mb-1 font-medium">{t("dataProcessing")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dataProcessingDesc")}
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-primary">
|
||||
{t("required")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Optional: Marketing */}
|
||||
<div className="mb-6 rounded-lg border p-4">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={marketingChecked}
|
||||
onChange={(e) => setMarketingChecked(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium">{t("marketing")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("marketingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={grantMutation.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{grantMutation.isPending ? "..." : t("accept")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className="w-full text-center text-sm text-destructive underline transition-colors hover:text-destructive/80"
|
||||
>
|
||||
{t("reject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface ConsentRecord {
|
||||
id: string
|
||||
type: "DATA_PROCESSING" | "MARKETING" | "ANALYTICS"
|
||||
granted: boolean
|
||||
grantedAt: string | null
|
||||
revokedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ConsentCheckResponse {
|
||||
hasDataProcessingConsent: boolean
|
||||
}
|
||||
|
||||
export interface GrantConsentRequest {
|
||||
type: "DATA_PROCESSING" | "MARKETING" | "ANALYTICS"
|
||||
version?: number
|
||||
}
|
||||
|
||||
export interface DsgvoExportData {
|
||||
exportDate: string
|
||||
legalBasis: string
|
||||
personalData: Record<string, unknown>
|
||||
memberProfile?: Record<string, unknown>
|
||||
distributions?: Record<string, unknown>[]
|
||||
consents?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
// --- Query Hooks ---
|
||||
|
||||
export function useConsentsQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["consent", "list"],
|
||||
queryFn: () => apiClient<ConsentRecord[]>("/consent"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useConsentCheckQuery() {
|
||||
return useQuery({
|
||||
queryKey: ["consent", "check"],
|
||||
queryFn: () => apiClient<ConsentCheckResponse>("/consent/check"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useGrantConsentMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: GrantConsentRequest) =>
|
||||
apiClient<ConsentRecord>("/consent", { method: "POST", body: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["consent"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRevokeConsentMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (type: string) =>
|
||||
apiClient<void>(`/consent/${type}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["consent"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useExportDataMutation() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const data = await apiClient<DsgvoExportData>("/dsgvo/export")
|
||||
// Trigger download
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `meine-daten-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAccountMutation() {
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient<{ status: string; message: string }>("/dsgvo/delete", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import de.cannamanage.service.repository.ConsentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Manages DSGVO consent lifecycle: grant, revoke, check.
|
||||
*/
|
||||
@Service
|
||||
public class ConsentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConsentService.class);
|
||||
|
||||
private final ConsentRepository consentRepository;
|
||||
|
||||
public ConsentService(ConsentRepository consentRepository) {
|
||||
this.consentRepository = consentRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a specific consent type for a user.
|
||||
* Creates a new record or re-grants a previously revoked one.
|
||||
*/
|
||||
@Transactional
|
||||
public Consent grantConsent(UUID userId, ConsentType type, int version, String ipAddress, String userAgent) {
|
||||
Consent consent = consentRepository.findByUserIdAndConsentType(userId, type)
|
||||
.orElseGet(() -> {
|
||||
Consent c = new Consent();
|
||||
c.setUserId(userId);
|
||||
c.setConsentType(type);
|
||||
return c;
|
||||
});
|
||||
|
||||
consent.setGranted(true);
|
||||
consent.setGrantedAt(Instant.now());
|
||||
consent.setRevokedAt(null);
|
||||
consent.setVersion(version);
|
||||
consent.setIpAddress(ipAddress);
|
||||
consent.setUserAgent(userAgent);
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
|
||||
log.info("Consent granted: userId={}, type={}, version={}", userId, type, version);
|
||||
return consentRepository.save(consent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific consent type for a user.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeConsent(UUID userId, ConsentType type) {
|
||||
consentRepository.findByUserIdAndConsentType(userId, type)
|
||||
.ifPresent(consent -> {
|
||||
consent.setGranted(false);
|
||||
consent.setRevokedAt(Instant.now());
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
consentRepository.save(consent);
|
||||
log.info("Consent revoked: userId={}, type={}", userId, type);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all consents for a user (used during account deletion).
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeAllConsents(UUID userId) {
|
||||
List<Consent> consents = consentRepository.findByUserIdAndGrantedTrue(userId);
|
||||
Instant now = Instant.now();
|
||||
for (Consent consent : consents) {
|
||||
consent.setGranted(false);
|
||||
consent.setRevokedAt(now);
|
||||
consent.setUpdatedAt(now);
|
||||
}
|
||||
consentRepository.saveAll(consents);
|
||||
log.info("All consents revoked for userId={}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has the required DATA_PROCESSING consent.
|
||||
*/
|
||||
public boolean hasRequiredConsents(UUID userId) {
|
||||
return consentRepository.existsByUserIdAndConsentTypeAndGrantedTrue(userId, ConsentType.DATA_PROCESSING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all consent records for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Consent> getUserConsents(UUID userId) {
|
||||
return consentRepository.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.entity.Distribution;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.service.repository.ConsentRepository;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* DSGVO compliance service — data export (Art. 15) and data deletion (Art. 17).
|
||||
*/
|
||||
@Service
|
||||
public class DsgvoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DsgvoService.class);
|
||||
private static final String ANONYMIZED = "GELÖSCHT";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final ConsentService consentService;
|
||||
|
||||
public DsgvoService(UserRepository userRepository,
|
||||
MemberRepository memberRepository,
|
||||
DistributionRepository distributionRepository,
|
||||
ConsentRepository consentRepository,
|
||||
ConsentService consentService) {
|
||||
this.userRepository = userRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.distributionRepository = distributionRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.consentService = consentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all personal data for a user (Art. 15 DSGVO).
|
||||
* Returns a structured map suitable for JSON serialization.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Object> exportUserData(UUID userId, UUID tenantId) {
|
||||
Map<String, Object> export = new LinkedHashMap<>();
|
||||
export.put("exportDate", Instant.now().toString());
|
||||
export.put("legalBasis", "Art. 15 DSGVO — Auskunftsrecht der betroffenen Person");
|
||||
|
||||
// Personal data from user account
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user != null) {
|
||||
Map<String, Object> personal = new LinkedHashMap<>();
|
||||
personal.put("email", user.getEmail());
|
||||
personal.put("role", user.getRole().name());
|
||||
personal.put("active", user.isActive());
|
||||
personal.put("lastLogin", user.getLastLogin() != null ? user.getLastLogin().toString() : null);
|
||||
personal.put("createdAt", user.getCreatedAt() != null ? user.getCreatedAt().toString() : null);
|
||||
export.put("personalData", personal);
|
||||
}
|
||||
|
||||
// Member profile data
|
||||
if (user != null && user.getMemberId() != null) {
|
||||
memberRepository.findById(user.getMemberId()).ifPresent(member -> {
|
||||
Map<String, Object> profile = new LinkedHashMap<>();
|
||||
profile.put("firstName", member.getFirstName());
|
||||
profile.put("lastName", member.getLastName());
|
||||
profile.put("dateOfBirth", member.getDateOfBirth() != null ? member.getDateOfBirth().toString() : null);
|
||||
profile.put("memberNumber", member.getMemberNumber());
|
||||
profile.put("status", member.getStatus().name());
|
||||
profile.put("joinedAt", member.getCreatedAt() != null ? member.getCreatedAt().toString() : null);
|
||||
export.put("memberProfile", profile);
|
||||
});
|
||||
}
|
||||
|
||||
// Distribution history
|
||||
if (user != null && user.getMemberId() != null) {
|
||||
UUID memberId = user.getMemberId();
|
||||
List<Distribution> distributions = distributionRepository.findByMemberIdAndTenantId(memberId, tenantId);
|
||||
List<Map<String, Object>> distList = distributions.stream()
|
||||
.map(d -> {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("id", d.getId().toString());
|
||||
entry.put("amountGrams", d.getQuantityGrams());
|
||||
entry.put("distributedAt", d.getDistributedAt() != null ? d.getDistributedAt().toString() : null);
|
||||
return entry;
|
||||
})
|
||||
.toList();
|
||||
export.put("distributions", distList);
|
||||
}
|
||||
|
||||
// Consent records
|
||||
List<Consent> consents = consentRepository.findByUserId(userId);
|
||||
List<Map<String, Object>> consentList = consents.stream()
|
||||
.map(c -> {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("type", c.getConsentType().name());
|
||||
entry.put("granted", c.isGranted());
|
||||
entry.put("grantedAt", c.getGrantedAt() != null ? c.getGrantedAt().toString() : null);
|
||||
entry.put("revokedAt", c.getRevokedAt() != null ? c.getRevokedAt().toString() : null);
|
||||
entry.put("version", c.getVersion());
|
||||
return entry;
|
||||
})
|
||||
.toList();
|
||||
export.put("consents", consentList);
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete/anonymize all personal data for a user (Art. 17 DSGVO).
|
||||
* - Anonymizes personal data (name, email → "GELÖSCHT")
|
||||
* - Keeps distribution records (compliance) but anonymizes member reference
|
||||
* - Revokes all consents
|
||||
* - Deactivates account
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteUserData(UUID userId) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
// Anonymize member profile
|
||||
if (user.getMemberId() != null) {
|
||||
memberRepository.findById(user.getMemberId()).ifPresent(member -> {
|
||||
member.setFirstName(ANONYMIZED);
|
||||
member.setLastName(ANONYMIZED);
|
||||
member.setEmail(ANONYMIZED + "@deleted.local");
|
||||
member.setPhone(null);
|
||||
memberRepository.save(member);
|
||||
log.info("Member profile anonymized: memberId={}", member.getId());
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke all consents
|
||||
consentService.revokeAllConsents(userId);
|
||||
|
||||
// Anonymize and deactivate user account
|
||||
user.setEmail(ANONYMIZED + "_" + userId + "@deleted.local");
|
||||
user.setPasswordHash(ANONYMIZED);
|
||||
user.setRefreshTokenHash(null);
|
||||
user.setActive(false);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("User data deleted/anonymized: userId={}", userId);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ConsentRepository extends JpaRepository<Consent, UUID> {
|
||||
|
||||
Optional<Consent> findByUserIdAndConsentType(UUID userId, ConsentType consentType);
|
||||
|
||||
List<Consent> findByUserId(UUID userId);
|
||||
|
||||
List<Consent> findByUserIdAndGrantedTrue(UUID userId);
|
||||
|
||||
boolean existsByUserIdAndConsentTypeAndGrantedTrue(UUID userId, ConsentType consentType);
|
||||
}
|
||||
+5
@@ -64,4 +64,9 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
||||
* Paginated distribution history for a member, newest first (portal history).
|
||||
*/
|
||||
Page<Distribution> findByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Find all distributions for a specific member (for DSGVO export).
|
||||
*/
|
||||
List<Distribution> findByMemberIdAndTenantId(UUID memberId, UUID tenantId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# CannaManage Sprint 6 Plan — Multi-Persona Review Panel
|
||||
|
||||
**Date:** 2026-06-12
|
||||
**Reviewed Document:** `docs/sprint-6/cannamanage-sprint6-plan.md` (v2, ~1266 lines)
|
||||
**Review Method:** 6-persona stakeholder simulation, scoring on 4 dimensions (0–100%)
|
||||
**Iteration:** 1
|
||||
**Sprint Goal:** Production readiness — deploy, DSGVO compliance, Stripe payments, immutable audit log, grow calendar, notifications, launch
|
||||
|
||||
---
|
||||
|
||||
## Decisions Incorporated (13 confirmed ✅)
|
||||
|
||||
All open questions from the planning session are resolved:
|
||||
|
||||
1. ✅ **Stripe** (fresh account) — SEPA + PayPal + Card (D1)
|
||||
2. ✅ **SEPA Lastschrift** as primary payment method (D2)
|
||||
3. ✅ **IONOS VPS** (existing plate-software.de, 8 GB RAM) (D3)
|
||||
4. ✅ **Subdomain** `cannamanage.plate-software.de` (D4)
|
||||
5. ✅ **pg_dump** daily 7d + weekly 4w retention (D5)
|
||||
6. ✅ **Gitea Actions** self-hosted CI/CD (D6)
|
||||
7. ✅ **Uptime Kuma** for monitoring (D7)
|
||||
8. ✅ **Consent-first** DSGVO approach (D8)
|
||||
9. ✅ **Immutable append-only** audit log (D9)
|
||||
10. ✅ **FullCalendar** with FULL scope (sensors, photos, feeding) (D10)
|
||||
11. ✅ **WebSocket (SockJS/STOMP)** + Web Push API (D11)
|
||||
12. ✅ **3-month free trial** → tiered pricing (D12)
|
||||
13. ✅ **Basic PWA** (manifest + offline page) in Sprint 6 (D13)
|
||||
|
||||
---
|
||||
|
||||
## 1. 👤 Club Member (End User)
|
||||
|
||||
*"I'm a regular member of a cannabis social club. I want to use the app on my phone, get notified about my distributions, and trust that my data is protected under DSGVO."*
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Type | Observation |
|
||||
|---|------|-------------|
|
||||
| 1 | ✅ Positive | **PWA installable** — I can add CannaManage to my home screen and use it like a native app. Standalone mode, themed green, proper icons. This makes daily access much faster than bookmarking a URL. |
|
||||
| 2 | ✅ Positive | **Real-time distribution notifications** — When staff records a distribution for me, I get an instant notification via WebSocket. No more checking manually. The bell icon shows unread count. |
|
||||
| 3 | ✅ Positive | **Push notifications even when app is closed** — Web Push with opt-in means I get notified about quota warnings and batch recalls even with the browser closed. Critical for recalls. |
|
||||
| 4 | ✅ Positive | **DSGVO transparency** — I can see exactly what data is stored about me, export everything as ZIP (Art. 15), and request full deletion (Art. 17). The consent flow is clear: accept or leave. |
|
||||
| 5 | ✅ Positive | **Offline resilience** — If I lose signal, I see an offline page instead of a browser error. Not full offline mode, but graceful degradation. |
|
||||
| 6 | ⚠️ Minor | **No grow calendar visibility for members** — The grow calendar appears to be admin-only. As a member, I'd love to see what's currently growing (without sensitive details) — builds trust and community engagement. |
|
||||
| 7 | ⚠️ Minor | **Notification preferences not detailed** — Can I choose which notifications I receive? The plan mentions opt-in for push but doesn't describe granular notification preferences (e.g., only recalls, not every distribution). |
|
||||
| 8 | ✅ Positive | **Consent versioning** — If the privacy policy changes, I'm re-prompted. I'm not silently bound to new terms. |
|
||||
|
||||
### Scores
|
||||
|
||||
| Dimension | Score | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Precision | 88% | Notification events are explicitly listed (quota warning, batch recall, new distribution). PWA manifest is precisely defined. However, notification *preferences* and member-facing grow visibility are unspecified. |
|
||||
| Correctness | 92% | WebSocket + STOMP is a correct real-time architecture for this scale. Web Push with VAPID is the right standard. DSGVO implementation follows Arts. 6, 7, 15, 17 correctly. |
|
||||
| Usability | 86% | PWA + push + bell icon covers my daily needs well. Missing granular notification control and member-facing grow calendar slightly reduce the experience. |
|
||||
| Usefulness | 90% | The jump from "no notifications, no DSGVO, no mobile" to "push notifications, full DSGVO compliance, installable PWA" is massive. This sprint makes the app genuinely usable for daily members. |
|
||||
|
||||
**Composite Score: 89%**
|
||||
|
||||
### Recommended Fixes
|
||||
- Add a note about notification preferences (future sprint or a simple "mute all" toggle)
|
||||
- Consider a read-only grow progress view for members (e.g., "Northern Lights — Flowering 🌸" without quantities)
|
||||
|
||||
---
|
||||
|
||||
## 2. 🏢 Club Owner / Vorstand (Business Owner)
|
||||
|
||||
*"I run the Anbauvereinigung. I need Stripe payments to fund operations, an audit log for Behörde inspections, the grow calendar to manage our cultivation, and a reliable production deployment."*
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Type | Observation |
|
||||
|---|------|-------------|
|
||||
| 1 | ✅ Positive | **Stripe handles all billing complexity** — SEPA mandates, dunning, invoicing, billing portal, payment method updates — all offloaded to Stripe. I don't need to manually chase payments or generate invoices. |
|
||||
| 2 | ✅ Positive | **Tiered pricing is clear** — Starter (€29/50 members) vs. Pro (€79/unlimited) gives me room to grow. The 14-day free trial lowers the barrier for new clubs. |
|
||||
| 3 | ✅ Positive | **Immutable audit log for Behörde** — PDF export of the audit log is exactly what inspectors need. Every distribution, member change, and batch action is logged with server timestamps. Immutability (`@Immutable` + no DELETE) ensures credibility. |
|
||||
| 4 | ✅ Positive | **Grow calendar (FULL scope)** — Stage tracking from seedling to curing, batch linking for traceability, sensor data for environmental control, photo documentation for compliance evidence. This is a complete cultivation management tool. |
|
||||
| 5 | ✅ Positive | **Production deployment on existing infrastructure** — No new server to provision. Co-hosted on the existing IONOS VPS means lower cost and immediate availability. Gitea Actions CI/CD means I don't depend on GitHub. |
|
||||
| 6 | ⚠️ Medium | **Pricing discrepancy in plan** — Section 0 (Decisions) says "≤30 members, €19/mo" and "≤100 members, €49/mo" but Section 3.3 (Stripe Architecture) says "up to 50 members, €29/month" and "unlimited members, €79/month". These need to be reconciled before implementation. |
|
||||
| 7 | ⚠️ Medium | **Trial period discrepancy** — Decision D12 says "3-month free trial" but the Stripe code shows `setTrialPeriodDays(14L)` (14 days). Which is it? This directly affects my club's onboarding strategy. |
|
||||
| 8 | ⚠️ Minor | **No grace period defined** — What happens when a payment fails? How many retry attempts before lockout? The plan mentions `invoice.payment_failed` shows a warning but doesn't specify the dunning timeline (Stripe default is 3 attempts over ~14 days, but this should be documented). |
|
||||
| 9 | ✅ Positive | **Subscription enforcement middleware** — `SubscriptionFilter.java` blocks API access when subscription expires. No free-riding after trial ends. |
|
||||
| 10 | ✅ Positive | **Harvest → batch traceability** — Linking a grow entry to a batch creates the full chain: grow → batch → distribution → member. This is exactly what regulators want to see. |
|
||||
|
||||
### Scores
|
||||
|
||||
| Dimension | Score | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Precision | 82% | The pricing/trial discrepancies are concerning — they need resolution before implementation. Grow calendar and audit log are precisely specified. Dunning behavior is under-specified. |
|
||||
| Correctness | 88% | Stripe architecture follows best practices (webhook signature verification, idempotent handlers, billing portal). Audit log immutability approach is correct. Grow stages and batch linking are logically sound. |
|
||||
| Usability | 90% | Self-service billing portal, one-click PDF export for audits, visual grow calendar — all reduce my operational burden significantly. |
|
||||
| Usefulness | 93% | This sprint delivers the three hardest requirements for going live: payments (to sustain the business), audit (for legal compliance), and grow tracking (for cultivation management). |
|
||||
|
||||
**Composite Score: 88%**
|
||||
|
||||
### Must Fix Before Implementation
|
||||
- 🔴 **Reconcile pricing tiers** — D12 says €19/€49 (30/100 members), Section 3.3 says €29/€79 (50/unlimited). Pick one and update throughout.
|
||||
- 🔴 **Reconcile trial period** — D12 says 3 months, code says 14 days. Which is the actual business decision?
|
||||
- Add dunning timeline documentation (how many retries, what interval, when lockout)
|
||||
|
||||
---
|
||||
|
||||
## 3. 💻 Developer (Technical)
|
||||
|
||||
*"I'm building this on IONOS with Docker + Nginx, Gitea Actions for CI/CD, Stripe webhooks, WebSockets, and sensor ingestion. Is the architecture sound?"*
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Type | Observation |
|
||||
|---|------|-------------|
|
||||
| 1 | ✅ Positive | **Docker Compose prod is well-structured** — env vars via `.env`, proper health checks with `start_period`, restart policies, named volumes for pgdata. Production-ready configuration. |
|
||||
| 2 | ✅ Positive | **Nginx config is comprehensive** — TLS termination, HTTP/2, HSTS, rate limiting (30r/s with burst), WebSocket upgrade, security headers. The Stripe webhook path correctly skips rate limiting. |
|
||||
| 3 | ✅ Positive | **Gitea Actions CI/CD is practical** — Self-hosted, no external dependencies, build → Docker → deploy locally. Simple and fast for a single-server deployment. |
|
||||
| 4 | ⚠️ Medium | **Gitea Actions `cd /opt/cannamanage` in deploy step** — The deploy step assumes the runner IS the production server (running locally). This works but means the CI runner has root-level access to production. If the Gitea instance is compromised, production is compromised. Consider SSH-based deployment even for local deploys (separation of concerns). |
|
||||
| 5 | ⚠️ Medium | **No blue-green or rolling deploy** — `docker compose up -d --remove-orphans` causes downtime during image pull + container restart. For a single VPS this might be acceptable, but even a simple `docker compose pull && docker compose up -d` split would reduce downtime to seconds. |
|
||||
| 6 | ✅ Positive | **Stripe webhook signature verification** — `Webhook.constructEvent(payload, sigHeader, webhookSecret)` is the correct Stripe SDK pattern. Prevents webhook spoofing. |
|
||||
| 7 | ⚠️ Minor | **WebSocket auth not specified** — `WebSocketConfig` uses `setAllowedOriginPatterns("*")`. In production, this should be restricted to the actual domain. Also, how is the WebSocket connection authenticated? STOMP interceptor with JWT? Session cookie? |
|
||||
| 8 | ⚠️ Minor | **Sensor data ingestion endpoint unclear** — The plan mentions "API endpoint for IoT devices" for sensor readings but doesn't specify authentication for IoT devices. Are sensors authenticated with API keys? Device tokens? This is a different auth flow from user JWT. |
|
||||
| 9 | ✅ Positive | **Backup strategy is simple and effective** — `pg_dump | gzip` with cron rotation. For a single-VPS deployment with <1000 users, this is the right approach. No over-engineering. |
|
||||
| 10 | ✅ Positive | **Flyway migrations are well-sequenced** — V6 (consent + audit), V7 (stripe), V8 (grow), V9 (notifications). Clear dependency ordering. |
|
||||
| 11 | ⚠️ Minor | **Phase 1 header says "Hetzner VPS" but body says "IONOS VPS"** — The Phase 1 heading reads "Production Deployment (Hetzner VPS)" but the decision says IONOS. Inconsistency in the document. |
|
||||
| 12 | ✅ Positive | **Service worker scope is appropriately limited** — "Basic in Sprint 6" (offline page + asset caching) avoids the complexity of full offline sync, which would require IndexedDB and conflict resolution. |
|
||||
| 13 | ⚠️ Minor | **Photo upload size limit (10MB) but no storage strategy** — Where are grow photos stored? Local filesystem? S3-compatible object storage? For a single VPS, local disk works initially but needs a plan for growth. |
|
||||
|
||||
### Scores
|
||||
|
||||
| Dimension | Score | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Precision | 85% | Core architecture is well-specified (Docker, Nginx, Stripe, Flyway). Gaps in WebSocket auth, sensor device auth, photo storage, and deploy strategy reduce precision. |
|
||||
| Correctness | 87% | Docker Compose patterns, Nginx configuration, Stripe SDK usage, and STOMP/SockJS are all technically correct. The `allowedOriginPatterns("*")` and missing auth layers are correctness concerns for production. |
|
||||
| Usability | 88% | Gitea Actions CI/CD is developer-friendly. Single `docker compose up` for local dev. Flyway handles migrations automatically. Clear phase separation makes implementation order obvious. |
|
||||
| Usefulness | 91% | The plan delivers a complete production stack: deploy pipeline, payment processing, real-time notifications, and grow management — all on a single affordable VPS. Pragmatic engineering. |
|
||||
|
||||
**Composite Score: 88%**
|
||||
|
||||
### Recommended Fixes
|
||||
- 🔴 Fix Phase 1 heading: "Hetzner VPS" → "IONOS VPS"
|
||||
- Specify WebSocket authentication mechanism (STOMP CONNECT frame with JWT or session-based)
|
||||
- Restrict `setAllowedOriginPatterns` to `cannamanage.plate-software.de` in production
|
||||
- Document sensor device authentication strategy (API key-based recommended)
|
||||
- Add photo storage strategy note (local filesystem initially, `/opt/cannamanage/uploads/`, with future migration path to S3)
|
||||
- Consider `docker compose pull && docker compose up -d` two-step deploy to minimize downtime
|
||||
|
||||
---
|
||||
|
||||
## 4. 🛡️ Datenschutzbeauftragter (Privacy/DSGVO Officer)
|
||||
|
||||
*"I ensure this application complies with DSGVO. I check consent flows, data exports, erasure rights, data minimization, and third-party data processing agreements."*
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Type | Observation |
|
||||
|---|------|-------------|
|
||||
| 1 | ✅ Positive | **Art. 6 Rechtsgrundlage** — Consent-first approach (Art. 6 Abs. 1 lit. a) is implemented. Users must explicitly accept before using the app. Consent entity stores timestamp, IP, user agent, and policy version. |
|
||||
| 2 | ✅ Positive | **Art. 7 Einwilligung** — Consent is freely given (user can decline and is logged out), specific (separate consent types: PRIVACY_POLICY, TERMS_OF_SERVICE, DATA_PROCESSING), informed (links to full Datenschutzerklärung), and unambiguous ("Akzeptieren" button). Withdrawal is as easy as giving (revoke in settings). |
|
||||
| 3 | ✅ Positive | **Art. 15 Auskunftsrecht** — `GET /api/v1/dsgvo/export` returns a ZIP with all personal data. The implementation collects user profile, distributions, quota history, consent records, and login history. |
|
||||
| 4 | ✅ Positive | **Art. 17 Recht auf Löschung** — `DELETE /api/v1/dsgvo/erasure` with appropriate exemption: "Some data must be retained for legal compliance (tax records, 10-year retention)." This correctly implements the Art. 17(3)(b) exception for legal obligations. |
|
||||
| 5 | ⚠️ Medium | **Art. 28 Auftragsverarbeitung (Stripe)** — Stripe is a data processor handling payment data. The plan doesn't mention the AVV (Auftragsverarbeitungsvertrag) with Stripe. This is a legal requirement. Stripe provides a standard DPA — it must be accepted and documented. |
|
||||
| 6 | ⚠️ Medium | **IP address storage in consent** — Storing IP addresses in the consent record is legitimate for proof of consent (Art. 7(1)), but the Datenschutzerklärung must explicitly mention this. IP addresses are personal data (EuGH C-582/14). Ensure retention period is defined. |
|
||||
| 7 | ⚠️ Medium | **Audit log contains PII** — `actor_name` ("Patrick Plate"), `ip_address`, and `description` (may contain member names in distribution descriptions like "25g Northern Lights an Max Mustermann ausgegeben"). The audit log is immutable (no deletion), which conflicts with Art. 17. This needs a documented legal basis (Art. 6(1)(c) — legal obligation for compliance records) and a defined retention period. |
|
||||
| 8 | ⚠️ Minor | **Data minimization (Art. 5(1)(c))** — The consent entity stores `user_agent` (full browser string). Is the full user agent necessary? A truncated version or just the browser name would satisfy proof requirements while minimizing data. |
|
||||
| 9 | ✅ Positive | **Consent versioning** — When the privacy policy changes, users are re-prompted. This correctly implements the requirement for renewed consent when purposes change. |
|
||||
| 10 | ⚠️ Minor | **No cookie banner mentioned** — The consent modal handles app usage consent, but if any analytics cookies or third-party scripts are used (Uptime Kuma external check?), a separate cookie consent mechanism may be needed under ePrivacy/TTDSG. If no cookies beyond session cookies → document this. |
|
||||
| 11 | ✅ Positive | **Erasure with anonymization** — "Anonymize distributions (keep aggregates for compliance)" correctly balances Art. 17 deletion with legal retention. Aggregate data is no longer personal data. |
|
||||
|
||||
### Scores
|
||||
|
||||
| Dimension | Score | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Precision | 84% | Core DSGVO flows (consent, export, erasure) are well-specified. Missing: AVV documentation with Stripe, audit log retention periods, IP storage justification in Datenschutzerklärung. |
|
||||
| Correctness | 86% | Arts. 6, 7, 15, 17 are correctly implemented. The Art. 17(3)(b) exception for legal obligations is correctly applied. However, the audit log immutability vs. Art. 17 tension needs explicit legal basis documentation. |
|
||||
| Usability | 90% | The consent flow is user-friendly: clear modal, accept/decline, revoke in settings, export as ZIP. Privacy settings page gives users full control. |
|
||||
| Usefulness | 88% | The DSGVO implementation covers all critical requirements for launch. The few gaps (AVV, retention periods, audit log legal basis) are documentation tasks, not code changes. |
|
||||
|
||||
**Composite Score: 87%**
|
||||
|
||||
### Must Fix Before Launch
|
||||
- 🔴 **Document Stripe AVV** — Accept Stripe's DPA, reference it in the Datenschutzerklärung as a data processor (Art. 28)
|
||||
- 🔴 **Define audit log retention period** — Document the legal basis (Art. 6(1)(c) + §257 HGB 10-year retention for business records) and add it to the Datenschutzerklärung
|
||||
- 🟡 Mention IP address storage in the Datenschutzerklärung (why, how long, legal basis)
|
||||
- 🟡 Consider truncating `user_agent` to browser name + version only
|
||||
- Clarify cookie usage: if only session cookies, add a "no tracking cookies" statement to the privacy policy
|
||||
|
||||
---
|
||||
|
||||
## 5. 🔒 Security Auditor
|
||||
|
||||
*"I audit the security posture of this application before it goes live. I check PCI compliance, SEPA data handling, webhook verification, TLS configuration, secret management, and backup encryption."*
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Type | Observation |
|
||||
|---|------|-------------|
|
||||
| 1 | ✅ Positive | **Stripe handles PCI compliance** — By using Stripe Checkout (hosted payment page) and Payment Element (Stripe.js), card data never touches our servers. PCI SAQ-A eligible. No card numbers in our DB. |
|
||||
| 2 | ✅ Positive | **Webhook signature verification** — `Webhook.constructEvent(payload, sigHeader, webhookSecret)` validates the HMAC signature from Stripe. Rejects tampered or replayed webhook events. |
|
||||
| 3 | ✅ Positive | **TLS configuration** — Let's Encrypt certificate, HTTP→HTTPS redirect, HSTS with 2-year max-age. SSL Labs A+ should be achievable with these settings. |
|
||||
| 4 | ✅ Positive | **Security headers** — HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin. Good baseline. |
|
||||
| 5 | ⚠️ Medium | **Missing CSP header** — Content-Security-Policy is not in the Nginx config. Without CSP, XSS attacks can load arbitrary external scripts. Add at minimum: `default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com;` |
|
||||
| 6 | ⚠️ Medium | **Secrets in Docker env vars** — `STRIPE_SECRET_KEY`, `JWT_SECRET`, `DB_PASSWORD` are passed as environment variables. While this is common with Docker Compose, the `.env` file on the server must be root-only readable (chmod 600). The plan doesn't mention file permissions or secret rotation. |
|
||||
| 7 | ⚠️ Medium | **Backup encryption missing** — `pg_dump | gzip` creates unencrypted backups. If the VPS is compromised, all historical data is exposed. Add `| gpg --encrypt --recipient backup@cannamanage.de` or use `pg_dump ... | openssl enc -aes-256-cbc`. |
|
||||
| 8 | ⚠️ Minor | **Rate limiting only on `/api/`** — The `/` (frontend) path has no rate limiting. While Next.js SSR is less sensitive, a determined attacker could DOS the frontend rendering. Consider a lower global rate limit (e.g., 100r/s). |
|
||||
| 9 | ✅ Positive | **Stripe webhook path excluded from rate limiting** — Correct decision. Stripe retries on 429 with exponential backoff, but rate-limiting webhooks can cause payment state drift. |
|
||||
| 10 | ⚠️ Minor | **No fail2ban or IP blocking** — If the rate limiter triggers repeatedly for an IP, there's no mechanism to ban that IP temporarily. Consider adding `limit_req_status 429` + fail2ban watching for 429s in access.log. |
|
||||
| 11 | ✅ Positive | **Audit log immutability** — `@Immutable` annotation + no DELETE in repository. Server-generated timestamps prevent client manipulation. JSONB details field preserves before/after state for forensics. |
|
||||
| 12 | ⚠️ Medium | **No DB-level DELETE prevention on audit_event** — Hibernate `@Immutable` is application-level only. A compromised backend or direct DB access can still DELETE records. Add a PostgreSQL trigger: `CREATE RULE no_delete_audit AS ON DELETE TO audit_event DO INSTEAD NOTHING;` or use a `REVOKE DELETE` on the app DB user. |
|
||||
| 13 | ⚠️ Minor | **SEPA data handling** — SEPA bank details (IBAN) are handled by Stripe, not stored locally. Good. But the plan should explicitly state "No IBAN data touches our servers" as a security control statement. |
|
||||
| 14 | ✅ Positive | **JWT for API auth** — `JWT_SECRET` from env, Spring Security integration for API endpoints. Standard approach for SPA backends. |
|
||||
| 15 | ⚠️ Minor | **No JWT rotation or expiry mentioned** — The plan uses `JWT_SECRET` but doesn't specify token expiry, refresh token strategy, or secret rotation cadence. For a go-live, at minimum define token TTL (e.g., 15min access + 7d refresh). |
|
||||
|
||||
### Scores
|
||||
|
||||
| Dimension | Score | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Precision | 83% | TLS, Stripe, and webhook verification are precise. Missing: CSP header definition, backup encryption, secret file permissions, DB-level audit protection, JWT lifecycle. |
|
||||
| Correctness | 85% | The security architecture is fundamentally sound (Stripe handles PCI, TLS terminates at Nginx, rate limiting exists). But application-level immutability without DB enforcement is incomplete. Missing CSP is a significant gap. |
|
||||
| Usability | 91% | From a security operations standpoint: automated TLS renewal, simple backup cron, Docker restart policies, and Uptime Kuma alerting make ongoing security operations manageable. |
|
||||
| Usefulness | 87% | The plan delivers a production-ready security baseline. The gaps are hardening measures that should be addressed before public launch but don't block development. |
|
||||
|
||||
**Composite Score: 87%**
|
||||
|
||||
### Must Fix Before Launch
|
||||
- 🔴 **Add Content-Security-Policy header** — at minimum: `default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:;`
|
||||
- 🔴 **Encrypt backups** — Add GPG or AES-256 encryption to the backup script
|
||||
- 🔴 **Add DB-level audit protection** — PostgreSQL RULE or REVOKE DELETE on audit_event for the app user
|
||||
- 🟡 Document `.env` file permissions (chmod 600, root-only)
|
||||
- 🟡 Define JWT token lifecycle (access TTL, refresh strategy, secret rotation)
|
||||
- 🟡 Add explicit "No IBAN/card data stored locally" security control statement
|
||||
|
||||
---
|
||||
|
||||
## 6. 🌱 Cultivation Manager (Grower)
|
||||
|
||||
*"I manage the club's grows from seed to harvest. I need to track plant stages, monitor environment (temperature/humidity), document with photos, plan feeding schedules, and link harvests to inventory batches."*
|
||||
|
||||
### Findings
|
||||
|
||||
| # | Type | Observation |
|
||||
|---|------|-------------|
|
||||
| 1 | ✅ Positive | **Full lifecycle tracking** — Six stages (Seedling → Vegetative → Flowering → Harvest → Drying → Curing) cover the complete cannabis grow cycle. Color-coded calendar blocks give me instant visual status of all active grows. |
|
||||
| 2 | ✅ Positive | **Harvest → batch linking** — This is the killer feature for compliance. When I harvest, I link directly to a batch entity, creating full traceability: seed → plant → harvest → batch → distribution → member. Inspectors love this. |
|
||||
| 3 | ✅ Positive | **Sensor data** — Temperature and humidity readings with configurable threshold alerts (e.g., temp > 30°C → notification). This lets me react quickly to environmental issues even when I'm not in the grow room. |
|
||||
| 4 | ✅ Positive | **Photo documentation** — Timeline view with captions per grow entry. This serves both my own record-keeping (disease identification, growth progress) and compliance evidence for inspectors. |
|
||||
| 5 | ✅ Positive | **Feeding schedule** — Nutrient name, amount, frequency, and calendar overlay showing feeding days. This replaces my spreadsheet and integrates feeding with the grow timeline. |
|
||||
| 6 | ⚠️ Medium | **No multi-grow-room support** — The `GrowEntry` entity has no `room` or `location` field. In a real club, we might have multiple grow rooms (veg room, flower room, drying room). Entries should be groupable by location. |
|
||||
| 7 | ⚠️ Medium | **Sensor readings are per-grow-entry, not per-room** — In practice, a temperature sensor monitors a room, not a single plant. Multiple grows share the same room environment. The data model should allow sensor readings at a room level, shared across grow entries in that room. |
|
||||
| 8 | ⚠️ Minor | **No yield tracking compared to expected** — The entity has `expected_yield_grams` and `actual_yield_grams`, which is great. But the plan doesn't mention a yield comparison/analytics view. Over time, I want to see yield trends per strain. |
|
||||
| 9 | ⚠️ Minor | **No recurring grow templates** — If I grow Northern Lights every 3 months with the same feeding schedule, I'd love to create a template and duplicate it. Not essential for v1, but worth noting. |
|
||||
| 10 | ✅ Positive | **FullCalendar with drag-resize** — Adjusting end dates by dragging is intuitive. Starting a new grow by clicking a date is natural. The de locale (German month names, Monday start) is correct for our users. |
|
||||
| 11 | ✅ Positive | **Audit log integration** — Grow entries and harvest-to-batch links are logged in the audit trail. This creates an unbroken record of cultivation activities for regulatory compliance. |
|
||||
| 12 | ⚠️ Minor | **No strain-specific growing guides** — It would be helpful to see default durations per stage for a given strain (e.g., Northern Lights typical flowering: 8 weeks). Auto-suggest end dates based on strain genetics. Future feature. |
|
||||
|
||||
### Scores
|
||||
|
||||
| Dimension | Score | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| Precision | 84% | Core entities (GrowEntry, SensorReading, GrowPhoto, FeedingSchedule) are well-defined with specific fields. Stage enum is complete. However, the room/location dimension is missing, and sensor-to-room relationship is not modeled. |
|
||||
| Correctness | 85% | The stage progression, batch linking, and calendar visualization are all correct cannabis cultivation patterns. The sensor model (per-entry rather than per-room) is architecturally questionable but functionally correct for MVP. |
|
||||
| Usability | 88% | FullCalendar with color-coded stages, drag-resize, and click-to-create is highly usable. Photo gallery with timeline is intuitive. Feeding schedule with calendar overlay reduces context switching. |
|
||||
| Usefulness | 90% | This is the most comprehensive grow management I've seen in a club management app. The harvest-to-batch traceability alone justifies the feature. Sensors, photos, and feeding elevate it from a simple calendar to a real cultivation tool. |
|
||||
|
||||
**Composite Score: 87%**
|
||||
|
||||
### Recommended Improvements
|
||||
- 🟡 Add `room` or `location` field to `GrowEntry` (simple VARCHAR, e.g., "Veg Room A", "Flower Room 1")
|
||||
- 🟡 Consider a `SensorDevice` entity linked to a room, with readings associated to the device rather than individual grow entries
|
||||
- Add yield analytics as a future enhancement (strain performance over time)
|
||||
- Note: templates and strain guides are clear Sprint 7+ features
|
||||
|
||||
---
|
||||
|
||||
## Summary Matrix
|
||||
|
||||
| Persona | Precision | Correctness | Usability | Usefulness | Composite |
|
||||
|---------|-----------|-------------|-----------|------------|-----------|
|
||||
| 👤 Club Member | 88% | 92% | 86% | 90% | **89%** |
|
||||
| 🏢 Club Owner / Vorstand | 82% | 88% | 90% | 93% | **88%** |
|
||||
| 💻 Developer | 85% | 87% | 88% | 91% | **88%** |
|
||||
| 🛡️ Datenschutzbeauftragter | 84% | 86% | 90% | 88% | **87%** |
|
||||
| 🔒 Security Auditor | 83% | 85% | 91% | 87% | **87%** |
|
||||
| 🌱 Cultivation Manager | 84% | 85% | 88% | 90% | **87%** |
|
||||
| **Average** | **84%** | **87%** | **89%** | **90%** | **88%** |
|
||||
|
||||
---
|
||||
|
||||
## Verdict: 🟡 CONDITIONAL PASS (88% — below 85% target on Precision)
|
||||
|
||||
The Sprint 6 plan is ambitious and well-structured, covering all critical go-live requirements. However, it contains internal inconsistencies (pricing, trial period, server provider naming) and security gaps (CSP, backup encryption, DB-level audit protection) that must be resolved before implementation begins.
|
||||
|
||||
**Overall assessment:** The plan is *implementable as-is* for development purposes, but requires 4 must-fix items before production launch.
|
||||
|
||||
---
|
||||
|
||||
## Priority Fix List
|
||||
|
||||
### 🔴 Must Fix (Blocking launch)
|
||||
|
||||
| # | Source | Issue | Fix |
|
||||
|---|--------|-------|-----|
|
||||
| 1 | 🏢 Owner | Pricing tier discrepancy (D12 vs §3.3) | Reconcile: pick €19/€49 or €29/€79. Update all references. |
|
||||
| 2 | 🏢 Owner | Trial period discrepancy (3 months vs 14 days) | Confirm business decision. Update D12 or Stripe code. |
|
||||
| 3 | 🔒 Security | No Content-Security-Policy header | Add CSP to Nginx config targeting Stripe JS domains. |
|
||||
| 4 | 🔒 Security | Backups are unencrypted | Add GPG/AES-256 encryption to backup script. |
|
||||
| 5 | 🔒 Security | Audit log only app-level immutable | Add PostgreSQL RULE or REVOKE DELETE on audit_event. |
|
||||
| 6 | 🛡️ DSGVO | No Stripe AVV documentation | Accept Stripe DPA, reference in Datenschutzerklärung. |
|
||||
| 7 | 🛡️ DSGVO | Audit log retention period undefined | Document legal basis (§257 HGB) + retention in privacy policy. |
|
||||
| 8 | 💻 Dev | Phase 1 heading says "Hetzner" instead of "IONOS" | Fix heading text. |
|
||||
|
||||
### 🟡 Should Fix (Before launch, non-blocking for dev)
|
||||
|
||||
| # | Source | Issue | Fix |
|
||||
|---|--------|-------|-----|
|
||||
| 9 | 💻 Dev | WebSocket auth mechanism unspecified | Document STOMP CONNECT JWT or session-based auth. |
|
||||
| 10 | 💻 Dev | `allowedOriginPatterns("*")` in production | Restrict to `cannamanage.plate-software.de`. |
|
||||
| 11 | 💻 Dev | Photo storage location undefined | Document local filesystem path + future S3 migration. |
|
||||
| 12 | 💻 Dev | Sensor device auth for IoT undefined | Specify API key-based auth for devices. |
|
||||
| 13 | 🔒 Security | JWT lifecycle not specified | Define access token TTL (15min) + refresh token (7d). |
|
||||
| 14 | 🔒 Security | `.env` file permissions not documented | Add chmod 600 + root ownership requirement. |
|
||||
| 15 | 🛡️ DSGVO | IP address storage not in privacy policy | Add to Datenschutzerklärung with retention period. |
|
||||
| 16 | 🌱 Grower | No room/location field on GrowEntry | Add optional `location` VARCHAR field. |
|
||||
| 17 | 🌱 Grower | Sensors modeled per-entry not per-room | Consider `SensorDevice` entity linked to location. |
|
||||
|
||||
### 🟢 Nice to Have (Sprint 7+)
|
||||
|
||||
| # | Source | Issue | Fix |
|
||||
|---|--------|-------|-----|
|
||||
| 18 | 👤 Member | No granular notification preferences | Add mute/category toggles in settings. |
|
||||
| 19 | 👤 Member | No member-visible grow progress | Read-only grow view for members. |
|
||||
| 20 | 🏢 Owner | Dunning timeline not documented | Add retry schedule documentation. |
|
||||
| 21 | 🌱 Grower | No grow templates | Duplicate-from-previous feature. |
|
||||
| 22 | 🌱 Grower | No yield analytics | Strain performance dashboard. |
|
||||
| 23 | 💻 Dev | No blue-green deploy | Two-step pull + up for zero-downtime. |
|
||||
| 24 | 🔒 Security | No fail2ban integration | Watch for 429s, auto-ban repeat offenders. |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Proceed with development** after resolving the 8 🔴 must-fix items (most are documentation/config corrections, not architectural changes). The plan is solid architecturally and covers all critical go-live requirements. Target the 🟡 items during implementation as they come up naturally in each phase.
|
||||
|
||||
**Estimated fix effort:** ~2 hours for document corrections (items 1, 2, 8), ~1 day for security hardening specs (items 3–7).
|
||||
@@ -0,0 +1,881 @@
|
||||
# Testplan: CannaManage Sprint 6 — Production Readiness
|
||||
|
||||
**Datum:** 2026-06-12
|
||||
**Modul:** cannamanage (all modules)
|
||||
**Autor:** Patrick Plate
|
||||
**Status:** Entwurf v1
|
||||
**Basis:** cannamanage-sprint6-plan.md (v1)
|
||||
|
||||
---
|
||||
|
||||
## Testübersicht
|
||||
|
||||
| ID | Beschreibung | Typ | Klasse/Tool | Status |
|
||||
|----|-------------|-----|-------------|--------|
|
||||
| T-01 | ConsentService: accept consent | Unit | `ConsentServiceTest` | ⬜ |
|
||||
| T-02 | ConsentService: revoke consent | Unit | `ConsentServiceTest` | ⬜ |
|
||||
| T-03 | ConsentService: check consent status | Unit | `ConsentServiceTest` | ⬜ |
|
||||
| T-04 | ConsentService: re-prompt on new version | Unit | `ConsentServiceTest` | ⬜ |
|
||||
| T-05 | DsgvoService: export user data (ZIP) | Unit | `DsgvoServiceTest` | ⬜ |
|
||||
| T-06 | DsgvoService: erasure request (anonymize) | Unit | `DsgvoServiceTest` | ⬜ |
|
||||
| T-07 | DsgvoService: retain legally required data | Unit | `DsgvoServiceTest` | ⬜ |
|
||||
| T-08 | StripeService: create customer | Unit | `StripeServiceTest` | ⬜ |
|
||||
| T-09 | StripeService: create checkout session (SEPA) | Unit | `StripeServiceTest` | ⬜ |
|
||||
| T-10 | StripeService: create billing portal session | Unit | `StripeServiceTest` | ⬜ |
|
||||
| T-11 | StripeService: handle webhook invoice.paid | Unit | `StripeServiceTest` | ⬜ |
|
||||
| T-12 | StripeService: handle webhook payment_failed | Unit | `StripeServiceTest` | ⬜ |
|
||||
| T-13 | StripeService: handle webhook subscription.deleted | Unit | `StripeServiceTest` | ⬜ |
|
||||
| T-14 | StripeWebhookController: reject invalid signature | Unit | `StripeWebhookControllerTest` | ⬜ |
|
||||
| T-15 | SubscriptionFilter: block expired subscription | Unit | `SubscriptionFilterTest` | ⬜ |
|
||||
| T-16 | SubscriptionFilter: allow active subscription | Unit | `SubscriptionFilterTest` | ⬜ |
|
||||
| T-17 | SubscriptionFilter: allow trial period | Unit | `SubscriptionFilterTest` | ⬜ |
|
||||
| T-18 | AuditService: log event | Unit | `AuditServiceTest` | ⬜ |
|
||||
| T-19 | AuditService: immutability (no update/delete) | Integration | `AuditServiceIntegrationTest` | ⬜ |
|
||||
| T-20 | AuditService: JSON details serialization | Unit | `AuditServiceTest` | ⬜ |
|
||||
| T-21 | AuditService: server-generated timestamp (Europe/Berlin) | Unit | `AuditServiceTest` | ⬜ |
|
||||
| T-22 | AuditController: paginated list with filters | Integration | `AuditControllerIntegrationTest` | ⬜ |
|
||||
| T-23 | AuditController: PDF export | Integration | `AuditControllerIntegrationTest` | ⬜ |
|
||||
| T-24 | GrowCalendarService: create grow entry | Unit | `GrowCalendarServiceTest` | ⬜ |
|
||||
| T-25 | GrowCalendarService: update stage transition | Unit | `GrowCalendarServiceTest` | ⬜ |
|
||||
| T-26 | GrowCalendarService: link harvest to batch | Unit | `GrowCalendarServiceTest` | ⬜ |
|
||||
| T-27 | GrowCalendarService: reject invalid stage progression | Unit | `GrowCalendarServiceTest` | ⬜ |
|
||||
| T-28 | NotificationService: quota warning at 80% | Unit | `NotificationServiceTest` | ⬜ |
|
||||
| T-29 | NotificationService: batch recall notification | Unit | `NotificationServiceTest` | ⬜ |
|
||||
| T-30 | NotificationService: new distribution notification | Unit | `NotificationServiceTest` | ⬜ |
|
||||
| T-31 | Flyway V6: consent + audit_event tables created | Integration | `FlywayMigrationTest` | ⬜ |
|
||||
| T-32 | Flyway V7: stripe columns added to club | Integration | `FlywayMigrationTest` | ⬜ |
|
||||
| T-33 | Flyway V8: grow_entry table created | Integration | `FlywayMigrationTest` | ⬜ |
|
||||
| T-34 | Flyway V9: notification table created | Integration | `FlywayMigrationTest` | ⬜ |
|
||||
| T-35 | Consent flow E2E: login → banner → accept → app access | E2E | Playwright | ⬜ |
|
||||
| T-36 | Consent flow E2E: login → banner → reject → logout | E2E | Playwright | ⬜ |
|
||||
| T-37 | Billing page E2E: display plan + upgrade CTA | E2E | Playwright | ⬜ |
|
||||
| T-38 | Audit log page E2E: filter + pagination + PDF download | E2E | Playwright | ⬜ |
|
||||
| T-39 | Grow calendar E2E: create entry + drag resize | E2E | Playwright | ⬜ |
|
||||
| T-40 | Notification bell E2E: receive + display + mark read | E2E | Playwright | ⬜ |
|
||||
| T-41 | PWA manifest: installable, correct icons | E2E | Lighthouse | ⬜ |
|
||||
| T-42 | Production smoke test: full user journey | Manual | Production | ⬜ |
|
||||
| T-43 | Backup/restore: pg_dump + restore verification | Manual | Production | ⬜ |
|
||||
| T-44 | TLS verification: SSL Labs A+ rating | Manual | SSL Labs | ⬜ |
|
||||
| T-45 | Load test: 100 concurrent users, p95 < 500ms | Performance | k6 | ⬜ |
|
||||
|
||||
Status: ⬜ Offen | ✅ Bestanden | ❌ Fehlgeschlagen | ⏭️ Übersprungen
|
||||
|
||||
---
|
||||
|
||||
## Testfälle
|
||||
|
||||
### T-01: ConsentService — accept consent
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.ConsentServiceTest`
|
||||
**Methode:** `testAcceptConsent()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- User existiert in der DB
|
||||
- Kein vorhandener Consent-Eintrag für diesen User + Typ + Version
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Gültiger User, PRIVACY_POLICY, Version "2026-06-v1" | Consent-Entity gespeichert mit IP, User-Agent, Timestamp |
|
||||
| b | User mit bereits akzeptiertem Consent (gleiche Version) | Exception: ConsentAlreadyAcceptedException |
|
||||
| c | User mit revoked Consent (gleiche Version) | Neuer Consent-Eintrag erstellt (re-accept möglich) |
|
||||
|
||||
**Nachbedingungen:**
|
||||
- Audit-Log-Eintrag CONSENT_ACCEPTED erstellt
|
||||
- Consent in DB mit korrektem Timestamp
|
||||
|
||||
---
|
||||
|
||||
### T-02: ConsentService — revoke consent
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.ConsentServiceTest`
|
||||
**Methode:** `testRevokeConsent()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- User hat aktiven Consent (acceptedAt != null, revokedAt == null)
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | User mit aktivem Consent | revokedAt gesetzt, Audit-Log CONSENT_REVOKED |
|
||||
| b | User ohne aktiven Consent | Exception: NoActiveConsentException |
|
||||
|
||||
**Nachbedingungen:**
|
||||
- Nachfolgende API-Aufrufe blockiert (Consent-Check schlägt fehl)
|
||||
|
||||
---
|
||||
|
||||
### T-03: ConsentService — check consent status
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.ConsentServiceTest`
|
||||
**Methode:** `testCheckConsentStatus()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- Verschiedene Consent-Zustände in DB
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | User mit aktivem Consent (aktuelle Version) | `true` |
|
||||
| b | User ohne Consent | `false` |
|
||||
| c | User mit revoked Consent | `false` |
|
||||
| d | User mit Consent alter Version (neue Version verfügbar) | `false` (re-prompt nötig) |
|
||||
|
||||
---
|
||||
|
||||
### T-04: ConsentService — re-prompt on new version
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.ConsentServiceTest`
|
||||
**Methode:** `testRePromptOnNewVersion()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- User hat Consent für Version "2026-06-v1"
|
||||
- System-Consent-Version ist jetzt "2026-09-v2"
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Check mit aktueller Version "2026-09-v2" | `false` — user muss neue Version akzeptieren |
|
||||
| b | Nach Accept der neuen Version | `true` |
|
||||
|
||||
---
|
||||
|
||||
### T-05: DsgvoService — export user data (ZIP)
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.DsgvoServiceTest`
|
||||
**Methode:** `testExportUserData()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- User mit Profildaten, Distributions, Quota-History, Consent-Einträgen
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | User mit vollständigen Daten | ZIP enthält: profile.json, distributions.json, quota-history.json, consents.json |
|
||||
| b | User ohne Distributions | ZIP enthält leere distributions.json (leeres Array) |
|
||||
| c | User mit gelöschtem Account | Exception: UserNotFoundException |
|
||||
|
||||
**Nachbedingungen:**
|
||||
- Audit-Log-Eintrag DATA_EXPORT_REQUESTED
|
||||
- ZIP-Datei ist valides ZIP-Format
|
||||
|
||||
---
|
||||
|
||||
### T-06: DsgvoService — erasure request
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.DsgvoServiceTest`
|
||||
**Methode:** `testErasureRequest()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- User mit vollständigen Daten, aktiven Sessions
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Gültiger User | Name → "GELÖSCHT", Email → hash, Distributions anonymisiert (Mengen erhalten, Personenbezug entfernt) |
|
||||
| b | User mit aktivem Consent | Alle Consents revoked |
|
||||
| c | User mit aktiver Session | Alle Sessions invalidiert |
|
||||
|
||||
**Nachbedingungen:**
|
||||
- Audit-Log-Eintrag DATA_ERASURE_REQUESTED
|
||||
- Aggregat-Daten für Compliance erhalten (Gesamtmengen pro Monat)
|
||||
- Kein Personenbezug mehr herstellbar
|
||||
|
||||
---
|
||||
|
||||
### T-07: DsgvoService — retain legally required data
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.DsgvoServiceTest`
|
||||
**Methode:** `testRetainLegallyRequiredData()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- User mit Distributions (steuerrelevant, 10-Jahres-Aufbewahrungspflicht)
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Erasure-Request für User mit Distributions | Distributions anonymisiert (member_id → null oder "ANON"), aber Zeilen + Mengen erhalten |
|
||||
| b | Erasure-Request für User ohne Distributions | Vollständige Löschung aller Daten |
|
||||
|
||||
---
|
||||
|
||||
### T-08: StripeService — create customer
|
||||
|
||||
**Typ:** Unit (mit Stripe Mock)
|
||||
**Klasse:** `de.cannamanage.service.StripeServiceTest`
|
||||
**Methode:** `testCreateCustomer()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- Club und Owner existieren
|
||||
- Stripe API gemockt
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Club "Grüner Daumen", Owner email "owner@test.de" | Stripe Customer erstellt mit email + metadata (club_id, user_id) |
|
||||
| b | Club bereits mit stripe_customer_id | Exception: CustomerAlreadyExistsException |
|
||||
|
||||
**Nachbedingungen:**
|
||||
- club.stripeCustomerId gesetzt
|
||||
|
||||
---
|
||||
|
||||
### T-09: StripeService — create checkout session
|
||||
|
||||
**Typ:** Unit (mit Stripe Mock)
|
||||
**Klasse:** `de.cannamanage.service.StripeServiceTest`
|
||||
**Methode:** `testCreateCheckoutSession()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- Club hat stripe_customer_id
|
||||
- Price ID existiert
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Starter plan, SEPA | Session mit mode=subscription, payment_methods=[sepa_debit,card,paypal], trial=14 days |
|
||||
| b | Pro plan | Session mit korrektem price_id |
|
||||
| c | Club ohne stripe_customer_id | Exception: NoStripeCustomerException |
|
||||
|
||||
---
|
||||
|
||||
### T-10: StripeService — billing portal session
|
||||
|
||||
**Typ:** Unit (mit Stripe Mock)
|
||||
**Klasse:** `de.cannamanage.service.StripeServiceTest`
|
||||
**Methode:** `testCreateBillingPortalSession()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Club mit activem Subscription | Portal-Session-URL zurückgegeben |
|
||||
| b | Club ohne Subscription | Exception oder leere Portal-Session |
|
||||
|
||||
---
|
||||
|
||||
### T-11: StripeService — webhook invoice.paid
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.StripeServiceTest`
|
||||
**Methode:** `testHandleInvoicePaid()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Gültiges invoice.paid Event | Club subscription_status → ACTIVE, Audit-Log PAYMENT_RECEIVED |
|
||||
| b | Event für unbekannten Customer | Logged + ignoriert (kein Fehler) |
|
||||
|
||||
---
|
||||
|
||||
### T-12: StripeService — webhook payment_failed
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.StripeServiceTest`
|
||||
**Methode:** `testHandlePaymentFailed()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Gültiges invoice.payment_failed Event | Club subscription_status → PAST_DUE, Audit-Log PAYMENT_FAILED |
|
||||
| b | Wiederholter Fehlschlag | Status bleibt PAST_DUE (idempotent) |
|
||||
|
||||
---
|
||||
|
||||
### T-13: StripeService — webhook subscription.deleted
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.StripeServiceTest`
|
||||
**Methode:** `testHandleSubscriptionDeleted()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Gültiges customer.subscription.deleted Event | Club subscription_status → CANCELED, Audit-Log SUBSCRIPTION_CANCELED |
|
||||
|
||||
---
|
||||
|
||||
### T-14: StripeWebhookController — reject invalid signature
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.api.controller.StripeWebhookControllerTest`
|
||||
**Methode:** `testRejectInvalidSignature()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Request ohne Stripe-Signature Header | 400 Bad Request |
|
||||
| b | Request mit ungültiger Signatur | 400 Bad Request |
|
||||
| c | Request mit gültiger Signatur | 200 OK, Event verarbeitet |
|
||||
|
||||
---
|
||||
|
||||
### T-15: SubscriptionFilter — block expired subscription
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.api.security.SubscriptionFilterTest`
|
||||
**Methode:** `testBlockExpiredSubscription()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Club mit status CANCELED | 402 Payment Required |
|
||||
| b | Club mit status PAST_DUE (>grace period) | 402 Payment Required |
|
||||
| c | Actuator/health endpoint | Kein Filter (immer erlaubt) |
|
||||
| d | Webhook endpoint | Kein Filter (immer erlaubt) |
|
||||
|
||||
---
|
||||
|
||||
### T-16: SubscriptionFilter — allow active subscription
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.api.security.SubscriptionFilterTest`
|
||||
**Methode:** `testAllowActiveSubscription()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Club mit status ACTIVE | Request durchgelassen |
|
||||
| b | Club mit status PAST_DUE (innerhalb Grace Period) | Request durchgelassen (mit Warning-Header) |
|
||||
|
||||
---
|
||||
|
||||
### T-17: SubscriptionFilter — allow trial period
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.api.security.SubscriptionFilterTest`
|
||||
**Methode:** `testAllowTrialPeriod()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Club mit status TRIALING, trial_ends_at > now | Request durchgelassen |
|
||||
| b | Club mit status TRIALING, trial_ends_at < now | 402 Payment Required |
|
||||
|
||||
---
|
||||
|
||||
### T-18: AuditService — log event
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.AuditServiceTest`
|
||||
**Methode:** `testLogEvent()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | DISTRIBUTION_CREATED, actor, entity, description | AuditEvent in DB gespeichert mit allen Feldern |
|
||||
| b | System-Event (kein Actor) | actorId=null, actorName="SYSTEM" |
|
||||
| c | Event mit Details (JSON) | details-Feld enthält serialisiertes JSON |
|
||||
|
||||
**Nachbedingungen:**
|
||||
- createdAt server-generiert (nicht aus Request)
|
||||
|
||||
---
|
||||
|
||||
### T-19: AuditService — immutability
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.service.AuditServiceIntegrationTest`
|
||||
**Methode:** `testImmutability()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- AuditEvent in DB gespeichert
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | UPDATE auf audit_event Zeile via EntityManager | Exception (Hibernate @Immutable verhindert Update) |
|
||||
| b | DELETE auf audit_event Zeile via EntityManager | Exception oder ignoriert |
|
||||
| c | Native SQL UPDATE | DB-Trigger verhindert Änderung (wenn implementiert) |
|
||||
|
||||
---
|
||||
|
||||
### T-20: AuditService — JSON details serialization
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.AuditServiceTest`
|
||||
**Methode:** `testJsonDetailsSerialization()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Map mit before/after Werten | Valides JSON in details-Feld |
|
||||
| b | null details | details-Feld ist null (nicht "null" String) |
|
||||
| c | Komplexes Objekt mit verschachtelten Feldern | Korrekt serialisiert, deserialisierbar |
|
||||
|
||||
---
|
||||
|
||||
### T-21: AuditService — server-generated timestamp
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.AuditServiceTest`
|
||||
**Methode:** `testServerGeneratedTimestamp()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Event ohne explizites Timestamp | createdAt = Instant.now() (±1s Toleranz) |
|
||||
| b | Event mit manipuliertem createdAt im Request | Ignoriert — Server überschreibt via @PrePersist |
|
||||
|
||||
---
|
||||
|
||||
### T-22: AuditController — paginated list with filters
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.api.controller.AuditControllerIntegrationTest`
|
||||
**Methode:** `testPaginatedListWithFilters()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- 100+ Audit-Events in DB (verschiedene Typen, Zeiträume, Akteure)
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | GET /audit?page=0&size=50 | 50 Events, Pagination-Metadaten korrekt |
|
||||
| b | GET /audit?type=DISTRIBUTION_CREATED | Nur Distribution-Events |
|
||||
| c | GET /audit?from=2026-06-01&to=2026-06-30 | Nur Events im Juni |
|
||||
| d | GET /audit?actor=Patrick | Nur Events von Patrick |
|
||||
| e | GET /audit?search=Northern+Lights | Full-text Suche in description |
|
||||
|
||||
---
|
||||
|
||||
### T-23: AuditController — PDF export
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.api.controller.AuditControllerIntegrationTest`
|
||||
**Methode:** `testPdfExport()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | GET /audit/export?format=pdf&from=2026-06-01&to=2026-06-30 | Valides PDF, Content-Type application/pdf |
|
||||
| b | Export mit 0 Events | PDF mit "Keine Einträge im Zeitraum" Meldung |
|
||||
| c | Export mit 1000+ Events | PDF generiert ohne OutOfMemory (streamed) |
|
||||
|
||||
---
|
||||
|
||||
### T-24: GrowCalendarService — create grow entry
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.GrowCalendarServiceTest`
|
||||
**Methode:** `testCreateGrowEntry()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Strain "Northern Lights", SEEDLING, startDate=today, plantCount=10 | GrowEntry gespeichert, Audit-Log |
|
||||
| b | Ohne strain_id | Validation-Fehler |
|
||||
| c | endDate vor startDate | Validation-Fehler |
|
||||
|
||||
---
|
||||
|
||||
### T-25: GrowCalendarService — stage transition
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.GrowCalendarServiceTest`
|
||||
**Methode:** `testStageTransition()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | SEEDLING → VEGETATIVE | Erfolgreich, stage aktualisiert |
|
||||
| b | SEEDLING → FLOWERING (Stufe übersprungen) | Erlaubt (flexible Progression) |
|
||||
| c | HARVEST → SEEDLING (Rückwärts) | Erlaubt (Korrektur möglich) |
|
||||
|
||||
---
|
||||
|
||||
### T-26: GrowCalendarService — link harvest to batch
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.GrowCalendarServiceTest`
|
||||
**Methode:** `testLinkHarvestToBatch()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | GrowEntry im HARVEST-Stage + existierende Batch | linkedBatch gesetzt, Audit-Log HARVEST_LINKED_TO_BATCH |
|
||||
| b | GrowEntry nicht im HARVEST-Stage | Nur im HARVEST/DRYING/CURING-Stage erlaubt |
|
||||
| c | Batch bereits mit anderem GrowEntry verknüpft | Warnung (aber erlaubt — ein Batch kann mehrere Grows haben) |
|
||||
|
||||
---
|
||||
|
||||
### T-27: GrowCalendarService — reject invalid stage
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.GrowCalendarServiceTest`
|
||||
**Methode:** `testRejectInvalidStage()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Stage = null | Validation-Fehler |
|
||||
| b | Stage = ungültiger String (API-Ebene) | 400 Bad Request |
|
||||
|
||||
---
|
||||
|
||||
### T-28: NotificationService — quota warning
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.NotificationServiceTest`
|
||||
**Methode:** `testQuotaWarning()`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- SimpMessagingTemplate gemockt
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | userId=42, percentUsed=80 | Message an /user/42/queue/notifications mit type=QUOTA_WARNING |
|
||||
| b | userId=42, percentUsed=95 | Gleiche Nachricht (Schwellwert-Logik liegt im Aufrufer) |
|
||||
|
||||
---
|
||||
|
||||
### T-29: NotificationService — batch recall
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.NotificationServiceTest`
|
||||
**Methode:** `testBatchRecallNotification()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | clubId=1, batchName="Northern Lights #7" | Message an /topic/club/1/notifications mit type=BATCH_RECALL |
|
||||
|
||||
---
|
||||
|
||||
### T-30: NotificationService — new distribution
|
||||
|
||||
**Typ:** Unit
|
||||
**Klasse:** `de.cannamanage.service.NotificationServiceTest`
|
||||
**Methode:** `testNewDistributionNotification()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | memberId=7, "Northern Lights", 5.0g | Message an /user/7/queue/notifications mit type=DISTRIBUTION |
|
||||
|
||||
---
|
||||
|
||||
### T-31: Flyway V6 — consent + audit_event tables
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.FlywayMigrationTest`
|
||||
**Methode:** `testV6ConsentAndAuditTables()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Migration V6 ausführen | Tabellen `consent` und `audit_event` existieren |
|
||||
| b | consent: unique constraint (user_id, consent_type, version) | Duplikat-Insert schlägt fehl |
|
||||
| c | audit_event: JSONB column für details | INSERT mit JSON-Wert funktioniert |
|
||||
|
||||
---
|
||||
|
||||
### T-32: Flyway V7 — stripe columns
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.FlywayMigrationTest`
|
||||
**Methode:** `testV7StripeColumns()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Migration V7 ausführen | Spalten stripe_customer_id, subscription_status, subscription_plan, trial_ends_at in club |
|
||||
| b | Existierende Clubs | NULL-Werte für neue Spalten (keine Default-Pflicht) |
|
||||
|
||||
---
|
||||
|
||||
### T-33: Flyway V8 — grow_entry table
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.FlywayMigrationTest`
|
||||
**Methode:** `testV8GrowEntryTable()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Migration V8 ausführen | Tabelle `grow_entry` mit FK zu strain + batch |
|
||||
| b | INSERT mit gültigem strain_id | Erfolgreich |
|
||||
| c | INSERT mit ungültigem strain_id | FK-Violation |
|
||||
|
||||
---
|
||||
|
||||
### T-34: Flyway V9 — notification table
|
||||
|
||||
**Typ:** Integration
|
||||
**Klasse:** `de.cannamanage.FlywayMigrationTest`
|
||||
**Methode:** `testV9NotificationTable()`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Migration V9 ausführen | Tabelle `notification` existiert |
|
||||
| b | Index auf (user_id, read_at) | Performante Abfrage ungelesener Notifications |
|
||||
|
||||
---
|
||||
|
||||
### T-35: Consent Flow E2E — accept
|
||||
|
||||
**Typ:** E2E (Playwright)
|
||||
**Klasse:** `e2e/consent-flow.spec.ts`
|
||||
|
||||
**Vorbedingungen:**
|
||||
- Neuer User (kein Consent in DB)
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Login → Consent-Banner erscheint | Modal sichtbar, "Akzeptieren" + "Ablehnen" Buttons |
|
||||
| b | Klick "Akzeptieren" | Modal verschwindet, Dashboard wird geladen |
|
||||
| c | Erneuter Login (gleicher User) | Kein Banner (Consent bereits akzeptiert) |
|
||||
|
||||
---
|
||||
|
||||
### T-36: Consent Flow E2E — reject
|
||||
|
||||
**Typ:** E2E (Playwright)
|
||||
**Klasse:** `e2e/consent-flow.spec.ts`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Login → Klick "Ablehnen" | Redirect zu Login-Seite, Session invalidiert |
|
||||
| b | Versuch direkter Navigation zu /dashboard (ohne Consent) | Redirect zu Consent-Banner |
|
||||
|
||||
---
|
||||
|
||||
### T-37: Billing Page E2E
|
||||
|
||||
**Typ:** E2E (Playwright)
|
||||
**Klasse:** `e2e/billing.spec.ts`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Navigate zu /settings/billing (Trial-User) | Pricing Cards sichtbar (Starter + Pro) |
|
||||
| b | Navigate zu /settings/billing (Active Subscriber) | Aktueller Plan + "Verwalten" Button sichtbar |
|
||||
| c | Klick "Verwalten" | Redirect zu Stripe Billing Portal |
|
||||
|
||||
---
|
||||
|
||||
### T-38: Audit Log Page E2E
|
||||
|
||||
**Typ:** E2E (Playwright)
|
||||
**Klasse:** `e2e/audit-log.spec.ts`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Navigate zu /admin/audit-log | Tabelle mit Events sichtbar |
|
||||
| b | Filter nach Typ "Distribution" | Nur Distribution-Events angezeigt |
|
||||
| c | Klick "PDF Export" | PDF-Download startet |
|
||||
| d | Pagination (Seite 2) | Neue Events geladen |
|
||||
|
||||
---
|
||||
|
||||
### T-39: Grow Calendar E2E
|
||||
|
||||
**Typ:** E2E (Playwright)
|
||||
**Klasse:** `e2e/grow-calendar.spec.ts`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Navigate zu /grow | FullCalendar sichtbar mit Monatsansicht |
|
||||
| b | Klick auf Datum | Create-Dialog öffnet sich |
|
||||
| c | Formular ausfüllen + speichern | Neuer farbiger Block im Kalender |
|
||||
| d | Klick auf existierenden Block | Detail-Panel mit Edit/Delete |
|
||||
|
||||
---
|
||||
|
||||
### T-40: Notification Bell E2E
|
||||
|
||||
**Typ:** E2E (Playwright)
|
||||
**Klasse:** `e2e/notifications.spec.ts`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Notification empfangen (via WebSocket) | Bell-Icon zeigt Badge mit Anzahl |
|
||||
| b | Klick auf Bell | Dropdown mit Notification-Liste |
|
||||
| c | Klick auf einzelne Notification | Als gelesen markiert, Badge decremented |
|
||||
|
||||
---
|
||||
|
||||
### T-41: PWA Manifest
|
||||
|
||||
**Typ:** E2E (Lighthouse)
|
||||
**Tool:** Lighthouse PWA Audit
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Eingabe | Erwartetes Ergebnis |
|
||||
|---|---------|-------------------|
|
||||
| a | Lighthouse PWA Audit | installable=true, manifest valid, service worker registered |
|
||||
| b | Icons vorhanden | 192px + 512px PNG Icons |
|
||||
| c | Offline-Seite | Netzwerk trennen → offline.html angezeigt (statt Browser-Fehler) |
|
||||
|
||||
---
|
||||
|
||||
### T-42: Production Smoke Test
|
||||
|
||||
**Typ:** Manual (Production)
|
||||
**Tool:** Browser auf cannamanage.de
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Aktion | Erwartetes Ergebnis |
|
||||
|---|--------|-------------------|
|
||||
| a | Registrierung neuer Club | Account erstellt, Consent-Banner erscheint |
|
||||
| b | Consent akzeptieren | Zugang zur App |
|
||||
| c | Subscription (Stripe Test-Karte) | Checkout → Subscription aktiv |
|
||||
| d | Mitglied anlegen | Mitglied in Liste sichtbar |
|
||||
| e | Ausgabe aufzeichnen | Distribution erstellt, Quota aktualisiert |
|
||||
| f | Audit-Log prüfen | Alle Aktionen geloggt |
|
||||
| g | PDF Export | Valides PDF heruntergeladen |
|
||||
| h | Grow-Eintrag erstellen | Kalender zeigt Eintrag |
|
||||
| i | Notification empfangen | Bell-Icon zeigt neue Notification |
|
||||
|
||||
---
|
||||
|
||||
### T-43: Backup/Restore Verification
|
||||
|
||||
**Typ:** Manual (Production)
|
||||
**Tool:** SSH + pg_dump/pg_restore
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Aktion | Erwartetes Ergebnis |
|
||||
|---|--------|-------------------|
|
||||
| a | pg_dump auf Production | Komprimiertes SQL-File erstellt |
|
||||
| b | Restore in leere DB | Alle Tabellen + Daten wiederhergestellt |
|
||||
| c | Cron-Job Ausführung prüfen | Backup-Datei < 24h alt in /backup/ |
|
||||
| d | Rotation prüfen | Keine Dateien > 7 Tage in /backup/ |
|
||||
|
||||
---
|
||||
|
||||
### T-44: TLS Verification
|
||||
|
||||
**Typ:** Manual
|
||||
**Tool:** SSL Labs (ssllabs.com/ssltest)
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Aktion | Erwartetes Ergebnis |
|
||||
|---|--------|-------------------|
|
||||
| a | SSL Labs Test auf cannamanage.de | Rating: A+ |
|
||||
| b | HSTS Header prüfen | Strict-Transport-Security present, max-age ≥ 63072000 |
|
||||
| c | Mixed Content prüfen | Keine HTTP-Ressourcen auf HTTPS-Seite |
|
||||
|
||||
---
|
||||
|
||||
### T-45: Load Test
|
||||
|
||||
**Typ:** Performance
|
||||
**Tool:** k6
|
||||
|
||||
**Script:** `scripts/load-test.js`
|
||||
|
||||
**Szenarien:**
|
||||
|
||||
| # | Konfiguration | Erwartetes Ergebnis |
|
||||
|---|--------------|-------------------|
|
||||
| a | 100 VUs, 5 min, Ramp-up 30s | p95 < 500ms, 0 errors |
|
||||
| b | API endpoints: /dashboard, /members, /distributions | Alle < 500ms p95 |
|
||||
| c | WebSocket connections: 50 concurrent | Stable, kein Disconnect |
|
||||
|
||||
---
|
||||
|
||||
## Testdaten
|
||||
|
||||
### Seed-Daten (für Unit/Integration Tests)
|
||||
|
||||
- **Club:** "Testverein Grüner Daumen" (id=1)
|
||||
- **Users:** admin@test.de (ADMIN), staff@test.de (STAFF), member@test.de (MEMBER)
|
||||
- **Members:** 5 Testmitglieder mit verschiedenen Quota-Ständen (0%, 50%, 80%, 95%, 100%)
|
||||
- **Batches:** 3 Batches (Northern Lights, Amnesia Haze, White Widow)
|
||||
- **Distributions:** 20 historische Distributionen
|
||||
- **Strains:** 5 Sorten mit THC/CBD-Werten
|
||||
- **Grow Entries:** 3 in verschiedenen Stages
|
||||
|
||||
### Stripe Test-Daten
|
||||
|
||||
- Stripe Test-Karte: `4242424242424242` (Erfolg)
|
||||
- Stripe Test-SEPA: `DE89370400440532013000` (Erfolg)
|
||||
- Stripe Test-Karte: `4000000000000002` (Ablehnung)
|
||||
- Webhook Events: via Stripe CLI (`stripe listen --forward-to localhost:8080/api/v1/webhooks/stripe`)
|
||||
|
||||
---
|
||||
|
||||
## Testabdeckung
|
||||
|
||||
| Komponente | Unit | Integration | E2E | Manual | Gesamt |
|
||||
|-----------|------|-------------|-----|--------|--------|
|
||||
| ConsentService | 4 | 0 | 2 | 0 | 6 |
|
||||
| DsgvoService | 3 | 0 | 0 | 0 | 3 |
|
||||
| StripeService | 6 | 0 | 1 | 0 | 7 |
|
||||
| StripeWebhookController | 1 | 0 | 0 | 0 | 1 |
|
||||
| SubscriptionFilter | 3 | 0 | 0 | 0 | 3 |
|
||||
| AuditService | 4 | 2 | 1 | 0 | 7 |
|
||||
| GrowCalendarService | 4 | 0 | 1 | 0 | 5 |
|
||||
| NotificationService | 3 | 0 | 1 | 0 | 4 |
|
||||
| Flyway Migrations | 0 | 4 | 0 | 0 | 4 |
|
||||
| PWA/Production | 0 | 0 | 1 | 3 | 4 |
|
||||
| Performance | 0 | 0 | 0 | 1 | 1 |
|
||||
| **Summe** | **28** | **6** | **7** | **4** | **45** |
|
||||
|
||||
---
|
||||
|
||||
## Hinweise
|
||||
|
||||
- **Stripe-Tests:** Verwenden gemockten Stripe-Client (keine echten API-Calls in Unit-Tests). Integration mit Stripe CLI für Webhook-Testing.
|
||||
- **WebSocket-Tests:** SimpMessagingTemplate wird gemockt. E2E-Test prüft echte WS-Verbindung.
|
||||
- **Immutability:** Zusätzlich zum Hibernate @Immutable kann ein DB-Trigger empfohlen werden — Test T-19c prüft dies nur wenn implementiert.
|
||||
- **DSGVO-Tests:** Besonderes Augenmerk auf Datenminimierung — Tests verifizieren, dass Löschung vollständig ist UND dass legal notwendige Daten erhalten bleiben.
|
||||
- **Performance-Test:** k6 Script wird in Phase 7 erstellt. Baseline-Werte nach Phase 1 Deployment messen.
|
||||
Reference in New Issue
Block a user