From dad798a904b02e01fc6e0cfd77942d0f5c8df5ab Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 18 Jun 2026 20:27:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=2014=20=E2=80=94=20Marketing=20&?= =?UTF-8?q?=20Monetization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Landing page with hero, feature grid, trust signals - Split-layout login redesign (admin + portal) - Pricing page with storage tiers (5GB/50GB/unlimited) - StorageQuotaService backend (V36 migration, 402 on exceeded) - Frontend storage integration + 402 error handling - StorageController uses TenantContext for tenant isolation - onTierChange() hook for subscription tier updates --- .gitignore | 2 + .../api/controller/StorageController.java | 34 + .../api/exception/GlobalExceptionHandler.java | 15 + .../db/migration/V36__storage_quota.sql | 9 + .../de/cannamanage/domain/entity/Club.java | 12 + cannamanage-frontend/messages/de.json | 79 ++- cannamanage-frontend/messages/en.json | 79 ++- .../src/app/(auth)/layout.tsx | 81 ++- .../src/app/(auth)/login/page.tsx | 13 +- .../src/app/(marketing)/layout.tsx | 107 +--- .../(marketing)/marketing-layout-client.tsx | 133 ++++ .../src/app/(marketing)/page.tsx | 163 +++++ .../src/app/(marketing)/pricing/page.tsx | 14 + .../src/app/(portal)/portal-login/page.tsx | 231 ++++--- .../src/services/documents.ts | 14 +- cannamanage-frontend/src/services/storage.ts | 43 ++ .../cannamanage/service/DocumentService.java | 14 +- .../service/StorageQuotaService.java | 121 ++++ .../StorageQuotaExceededException.java | 33 + .../cannamanage-sprint14-analysis.md | 123 ++++ .../cannamanage-sprint14-code-review.md | 124 ++++ .../cannamanage-sprint14-plan-review.md | 86 +++ docs/sprint-14/cannamanage-sprint14-plan.md | 571 +++++++++++++++++ .../cannamanage-sprint14-testplan.md | 596 ++++++++++++++++++ 24 files changed, 2485 insertions(+), 212 deletions(-) create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java create mode 100644 cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql create mode 100644 cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx create mode 100644 cannamanage-frontend/src/app/(marketing)/page.tsx create mode 100644 cannamanage-frontend/src/services/storage.ts create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java create mode 100644 docs/sprint-14/cannamanage-sprint14-analysis.md create mode 100644 docs/sprint-14/cannamanage-sprint14-code-review.md create mode 100644 docs/sprint-14/cannamanage-sprint14-plan-review.md create mode 100644 docs/sprint-14/cannamanage-sprint14-plan.md create mode 100644 docs/sprint-14/cannamanage-sprint14-testplan.md diff --git a/.gitignore b/.gitignore index d741a92..1ffa052 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ cannamanage-frontend/.env.local # Production secrets (never commit) .env +~/ +~/ diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java new file mode 100644 index 0000000..9b88daf --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java @@ -0,0 +1,34 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.service.StorageQuotaService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * REST controller for storage quota information. + * Provides endpoint to check current storage usage for the caller's club. + * Club ID is extracted from the JWT/tenant context — not from request params. + */ +@RestController +@RequestMapping("/api/v1/storage") +@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')") +public class StorageController { + + private final StorageQuotaService storageQuotaService; + + public StorageController(StorageQuotaService storageQuotaService) { + this.storageQuotaService = storageQuotaService; + } + + @GetMapping("/usage") + public ResponseEntity getUsage() { + UUID clubId = TenantContext.getCurrentTenant(); + return ResponseEntity.ok(storageQuotaService.getUsage(clubId)); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java index ecddc8f..cea8da3 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import de.cannamanage.service.exception.BatchNotFoundException; import de.cannamanage.service.exception.MemberNotFoundException; import de.cannamanage.service.exception.PreventionOfficerLimitExceededException; import de.cannamanage.service.exception.QuotaExceededException; +import de.cannamanage.service.exception.StorageQuotaExceededException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; @@ -121,6 +122,20 @@ public class GlobalExceptionHandler { return problem; } + @ExceptionHandler(StorageQuotaExceededException.class) + public ProblemDetail handleStorageQuotaExceeded(StorageQuotaExceededException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.PAYMENT_REQUIRED, ex.getMessage()); + problem.setTitle("Storage Quota Exceeded"); + problem.setType(URI.create("urn:cannamanage:error:STORAGE_QUOTA_EXCEEDED")); + problem.setProperty("code", "STORAGE_QUOTA_EXCEEDED"); + problem.setProperty("currentUsage", ex.getCurrentUsage()); + problem.setProperty("limit", ex.getLimit()); + problem.setProperty("requestedBytes", ex.getRequestedBytes()); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } + @ExceptionHandler(ResponseStatusException.class) public ProblemDetail handleResponseStatus(ResponseStatusException ex) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( diff --git a/cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql b/cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql new file mode 100644 index 0000000..090cbc1 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql @@ -0,0 +1,9 @@ +-- V36: Add storage quota tracking to clubs +ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_used_bytes BIGINT DEFAULT 0; +ALTER TABLE clubs ADD COLUMN IF NOT EXISTS storage_limit_bytes BIGINT DEFAULT 5368709120; +-- Default: 5 GB (5 * 1024^3) = Starter tier + +-- Backfill existing clubs with actual usage +UPDATE clubs c SET storage_used_bytes = COALESCE( + (SELECT SUM(d.file_size) FROM documents d WHERE d.club_id = c.id), 0 +); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java index af19101..f26a3d8 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java @@ -51,6 +51,12 @@ public class Club extends AbstractTenantEntity { @Column(name = "allowed_email_pattern", length = 255) private String allowedEmailPattern; + @Column(name = "storage_used_bytes", nullable = false) + private Long storageUsedBytes = 0L; + + @Column(name = "storage_limit_bytes", nullable = false) + private Long storageLimitBytes = 5_368_709_120L; // 5 GB default + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 50) private ClubStatus status = ClubStatus.ACTIVE; @@ -99,4 +105,10 @@ public class Club extends AbstractTenantEntity { public ClubStatus getStatus() { return status; } public void setStatus(ClubStatus status) { this.status = status; } + + public Long getStorageUsedBytes() { return storageUsedBytes; } + public void setStorageUsedBytes(Long storageUsedBytes) { this.storageUsedBytes = storageUsedBytes; } + + public Long getStorageLimitBytes() { return storageLimitBytes; } + public void setStorageLimitBytes(Long storageLimitBytes) { this.storageLimitBytes = storageLimitBytes; } } diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index aa2e569..5fbc77a 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -44,7 +44,8 @@ "emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.", "passwordRequired": "Bitte gib dein Passwort ein.", "passwordTooShort": "Passwort muss mindestens 8 Zeichen lang sein.", - "footerText": "Sichere Verwaltung für deinen Cannabis-Anbauverein" + "footerText": "Sichere Verwaltung für deinen Cannabis-Anbauverein", + "loginTitle": "Anmelden" }, "dashboard": { "title": "Dashboard", @@ -668,6 +669,18 @@ "starter": "E-Mail", "pro": "Priorität", "enterprise": "Dediziert" + }, + "compStorage": { + "label": "Speicher", + "starter": "5 GB", + "pro": "50 GB", + "enterprise": "Individuell" + }, + "compOverage": { + "label": "Überschreitung", + "starter": "Upgrade nötig", + "pro": "0,15 €/GB/Mo", + "enterprise": "—" } }, "faq": { @@ -690,7 +703,29 @@ "migration": { "question": "Kann ich den Plan später wechseln?", "answer": "Ja, du kannst jederzeit zwischen Starter und Pro wechseln. Ein Upgrade wird sofort wirksam, ein Downgrade zum nächsten Abrechnungszeitraum." + }, + "storage": { + "question": "Was passiert, wenn mein Speicher voll ist?", + "answer": "Im Starter-Plan kannst du auf Pro upgraden. Im Pro-Plan wird zusätzlicher Speicher mit 0,15 €/GB/Monat berechnet. Enterprise-Kunden haben individuelle Speichervereinbarungen." } + }, + "storage": { + "starter": "5 GB Speicher", + "pro": "50 GB Speicher", + "proOverage": "(danach 0,15 €/GB/Monat)", + "enterprise": "Individueller Speicher", + "comparisonTitle": "Funktionen im Vergleich", + "featureMembers": "Mitglieder", + "featureStorage": "Speicher", + "featureOverage": "Überschreitung", + "featureGrow": "Grow-Kalender", + "featureApi": "API-Zugang", + "featureMultiClub": "Multi-Club", + "overageUpgrade": "Upgrade erforderlich", + "overagePro": "0,15 €/GB/Mo", + "overageEnterprise": "—", + "unlimited": "Unbegrenzt", + "custom": "Individuell" } }, "impressum": { @@ -753,6 +788,46 @@ "s9Content": "Der Anbieter verarbeitet personenbezogene Daten gemäß der Datenschutzerklärung und den Bestimmungen der DSGVO. Soweit der Anbieter Daten im Auftrag des Nutzers verarbeitet, wird ein gesonderter Auftragsverarbeitungsvertrag geschlossen.", "s10Title": "§ 10 Schlussbestimmungen", "s10Content": "Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Anbieters. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt. Änderungen der AGB werden dem Nutzer rechtzeitig mitgeteilt." + }, + "home": { + "heroTitle": "Die smarte Verwaltung für deinen Anbauverein", + "heroSubtitle": "Compliance, Anbau, Mitglieder und Abgaben — alles in einer Plattform. Rechtssicher nach KCanG.", + "ctaPrimary": "Preise ansehen", + "ctaSecondary": "Jetzt anmelden", + "featuresTitle": "Alles, was dein Verein braucht", + "featuresSubtitle": "Von der Mitgliederverwaltung bis zur Behördenmeldung — CannaManage deckt den gesamten Vereinsbetrieb ab.", + "feature1Title": "Compliance Tracking", + "feature1Desc": "Automatische Überwachung der KCanG-Vorgaben mit Fristen und Checklisten.", + "feature2Title": "Grow Management", + "feature2Desc": "Anbaukalender, Wachstumsphasen und Sensorintegration.", + "feature3Title": "Mitglieder-Portal", + "feature3Desc": "Self-Service für Mitglieder: Profil, Abgabehistorie, Dokumente.", + "feature4Title": "Abgabe-Quotas", + "feature4Desc": "Automatische Einhaltung der 25g/Tag und 50g/Monat Grenzen.", + "feature5Title": "Dokumenten-Archiv", + "feature5Desc": "GoBD-konforme Ablage mit Aufbewahrungsfristen und Versionierung.", + "feature6Title": "Finanzverwaltung", + "feature6Desc": "Mitgliedsbeiträge, SEPA-Export und Bankimport.", + "trustTitle": "Vertrauen durch Compliance", + "trustCanverg": "CanVerG-konform", + "trustDsgvo": "DSGVO & GoBD", + "trustEncryption": "TLS-verschlüsselt", + "trustGerman": "Hosting in Deutschland", + "ctaFinalTitle": "Bereit für den nächsten Schritt?", + "ctaFinalSubtitle": "Starte jetzt mit CannaManage und bring deinen Verein auf das nächste Level.", + "ctaFinalButton": "Kostenlos testen" + }, + "nav": { + "features": "Features", + "pricing": "Preise", + "login": "Anmelden", + "footerTagline": "Die sichere Verwaltungssoftware für Cannabis-Anbauvereine in Deutschland.", + "footerProduct": "Produkt", + "footerLegal": "Rechtliches", + "impressum": "Impressum", + "datenschutz": "Datenschutz", + "agb": "AGB", + "allRightsReserved": "Alle Rechte vorbehalten." } }, "infoBoard": { @@ -1210,4 +1285,4 @@ "size": "Größe" } } -} \ No newline at end of file +} diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index 33808c2..44be5db 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -44,7 +44,8 @@ "emailInvalid": "Please enter a valid email address.", "passwordRequired": "Please enter your password.", "passwordTooShort": "Password must be at least 8 characters.", - "footerText": "Secure management for your cannabis cultivation club" + "footerText": "Secure management for your cannabis cultivation club", + "loginTitle": "Sign In" }, "dashboard": { "title": "Dashboard", @@ -668,6 +669,18 @@ "starter": "Email", "pro": "Priority", "enterprise": "Dedicated" + }, + "compStorage": { + "label": "Storage", + "starter": "5 GB", + "pro": "50 GB", + "enterprise": "Custom" + }, + "compOverage": { + "label": "Overage", + "starter": "Upgrade required", + "pro": "€0.15/GB/mo", + "enterprise": "—" } }, "faq": { @@ -690,7 +703,29 @@ "migration": { "question": "Can I switch plans later?", "answer": "Yes, you can switch between Starter and Pro at any time. Upgrades take effect immediately, downgrades at the next billing period." + }, + "storage": { + "question": "What happens when my storage is full?", + "answer": "On the Starter plan, you can upgrade to Pro. On the Pro plan, additional storage is billed at €0.15/GB/month. Enterprise customers have custom storage agreements." } + }, + "storage": { + "starter": "5 GB Storage", + "pro": "50 GB Storage", + "proOverage": "(then €0.15/GB/month)", + "enterprise": "Custom Storage", + "comparisonTitle": "Feature Comparison", + "featureMembers": "Members", + "featureStorage": "Storage", + "featureOverage": "Overage", + "featureGrow": "Grow Calendar", + "featureApi": "API Access", + "featureMultiClub": "Multi-Club", + "overageUpgrade": "Upgrade required", + "overagePro": "€0.15/GB/mo", + "overageEnterprise": "—", + "unlimited": "Unlimited", + "custom": "Custom" } }, "impressum": { @@ -753,6 +788,46 @@ "s9Content": "The provider processes personal data in accordance with the privacy policy and GDPR provisions. Where the provider processes data on behalf of the user, a separate data processing agreement is concluded.", "s10Title": "§ 10 Final Provisions", "s10Content": "The law of the Federal Republic of Germany applies. The place of jurisdiction is, to the extent legally permissible, the registered office of the provider. Should individual provisions of these terms be invalid, the validity of the remaining provisions remains unaffected. Changes to these terms will be communicated to the user in good time." + }, + "home": { + "heroTitle": "The Smart Management for Your Cannabis Club", + "heroSubtitle": "Compliance, growing, members and distributions — all in one platform. Legally compliant under KCanG.", + "ctaPrimary": "View Pricing", + "ctaSecondary": "Sign In", + "featuresTitle": "Everything Your Club Needs", + "featuresSubtitle": "From member management to regulatory reporting — CannaManage covers all aspects of your club operations.", + "feature1Title": "Compliance Tracking", + "feature1Desc": "Automatic monitoring of KCanG requirements with deadlines and checklists.", + "feature2Title": "Grow Management", + "feature2Desc": "Growing calendar, growth phases and sensor integration.", + "feature3Title": "Member Portal", + "feature3Desc": "Self-service for members: profile, distribution history, documents.", + "feature4Title": "Distribution Quotas", + "feature4Desc": "Automatic enforcement of 25g/day and 50g/month limits.", + "feature5Title": "Document Archive", + "feature5Desc": "GoBD-compliant storage with retention periods and versioning.", + "feature6Title": "Financial Management", + "feature6Desc": "Membership fees, SEPA export and bank import.", + "trustTitle": "Trust Through Compliance", + "trustCanverg": "CanVerG compliant", + "trustDsgvo": "GDPR & GoBD", + "trustEncryption": "TLS encrypted", + "trustGerman": "Hosted in Germany", + "ctaFinalTitle": "Ready for the Next Step?", + "ctaFinalSubtitle": "Start with CannaManage now and take your club to the next level.", + "ctaFinalButton": "Try for Free" + }, + "nav": { + "features": "Features", + "pricing": "Pricing", + "login": "Sign In", + "footerTagline": "The secure management software for cannabis cultivation clubs in Germany.", + "footerProduct": "Product", + "footerLegal": "Legal", + "impressum": "Imprint", + "datenschutz": "Privacy Policy", + "agb": "Terms", + "allRightsReserved": "All rights reserved." } }, "infoBoard": { @@ -1219,4 +1294,4 @@ "size": "Size" } } -} \ No newline at end of file +} diff --git a/cannamanage-frontend/src/app/(auth)/layout.tsx b/cannamanage-frontend/src/app/(auth)/layout.tsx index 4fc6104..b1bd993 100644 --- a/cannamanage-frontend/src/app/(auth)/layout.tsx +++ b/cannamanage-frontend/src/app/(auth)/layout.tsx @@ -1,5 +1,6 @@ import { NextIntlClientProvider } from "next-intl" import { getMessages } from "next-intl/server" +import { Cannabis, ClipboardCheck, Scale, Users } from "lucide-react" import type { ReactNode } from "react" @@ -11,10 +12,80 @@ export default async function AuthLayout({ const messages = await getMessages() return ( -
- - {children} - -
+ +
+ {/* Left panel — branding (hidden on mobile) */} +
+ {/* Decorative background blur */} +
+
+
+
+ +
+ {/* Logo */} +
+ +
+ + {/* App name & tagline */} +
+

CannaManage

+

+ Dein Verein, digital verwaltet +

+
+ + {/* Feature highlights */} +
+
+
+ +
+
+

+ KCanG-Compliance +

+

+ Automatische Vorgaben-Überwachung +

+
+
+
+
+ +
+
+

+ Mitgliederverwaltung +

+

+ Portal, Profile und Dokumente +

+
+
+
+
+ +
+
+

+ Abgabe-Tracking +

+

+ 25g/Tag und 50g/Monat automatisch +

+
+
+
+
+
+ + {/* Right panel — form */} +
+ {children} +
+
+ ) } diff --git a/cannamanage-frontend/src/app/(auth)/login/page.tsx b/cannamanage-frontend/src/app/(auth)/login/page.tsx index bef9446..4d0a7e6 100644 --- a/cannamanage-frontend/src/app/(auth)/login/page.tsx +++ b/cannamanage-frontend/src/app/(auth)/login/page.tsx @@ -8,7 +8,7 @@ import { signIn } from "next-auth/react" import { useTranslations } from "next-intl" import { useForm } from "react-hook-form" import { z } from "zod" -import { Cannabis, Loader2 } from "lucide-react" +import { Loader2 } from "lucide-react" const loginSchema = z.object({ email: z.string().email(), @@ -55,13 +55,10 @@ export default function LoginPage() { } return ( -
- {/* Logo & Branding */} -
-
- -
-

CannaManage

+
+ {/* Title — visible on mobile where left panel is hidden */} +
+

{t("loginTitle")}

{t("loginSubtitle")}

diff --git a/cannamanage-frontend/src/app/(marketing)/layout.tsx b/cannamanage-frontend/src/app/(marketing)/layout.tsx index 1dd6c32..0e5df83 100644 --- a/cannamanage-frontend/src/app/(marketing)/layout.tsx +++ b/cannamanage-frontend/src/app/(marketing)/layout.tsx @@ -1,10 +1,10 @@ -import Link from "next/link" import { NextIntlClientProvider } from "next-intl" import { getMessages } from "next-intl/server" -import { Cannabis } from "lucide-react" import type { ReactNode } from "react" +import MarketingLayoutClient from "./marketing-layout-client" + // Force dynamic rendering — prevents NextAuth from being called at build time // (AUTH_URL is not available during Docker image build) export const dynamic = "force-dynamic" @@ -18,108 +18,7 @@ export default async function MarketingLayout({ return ( -
- {/* Header */} -
-
- -
- -
- CannaManage - - -
-
- - {/* Main content */} -
{children}
- - {/* Footer */} -
-
-
-
-
- - CannaManage -
-

- Die sichere Verwaltungssoftware für Cannabis-Anbauvereine in - Deutschland. -

-
-
-

Produkt

-
    -
  • - - Preise - -
  • -
  • - - Anmelden - -
  • -
-
-
-

Rechtliches

-
    -
  • - - Impressum - -
  • -
  • - - Datenschutz - -
  • -
  • - - AGB - -
  • -
-
-
-
- © {new Date().getFullYear()} CannaManage — Plate Software. Alle - Rechte vorbehalten. -
-
-
-
+ {children}
) } diff --git a/cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx b/cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx new file mode 100644 index 0000000..fb1906e --- /dev/null +++ b/cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx @@ -0,0 +1,133 @@ +"use client" + +import Link from "next/link" +import { useTranslations } from "next-intl" +import { Cannabis } from "lucide-react" + +import type { ReactNode } from "react" + +export default function MarketingLayoutClient({ + children, +}: { + children: ReactNode +}) { + const t = useTranslations("marketing.nav") + + return ( +
+ {/* Header */} +
+
+ +
+ +
+ CannaManage + + +
+
+ + {/* Main content */} +
{children}
+ + {/* Footer */} +
+
+
+
+
+ + CannaManage +
+

+ {t("footerTagline")} +

+
+
+

{t("footerProduct")}

+
    +
  • + + {t("features")} + +
  • +
  • + + {t("pricing")} + +
  • +
  • + + {t("login")} + +
  • +
+
+
+

{t("footerLegal")}

+
    +
  • + + {t("impressum")} + +
  • +
  • + + {t("datenschutz")} + +
  • +
  • + + {t("agb")} + +
  • +
+
+
+
+ © {new Date().getFullYear()} CannaManage — Plate Software.{" "} + {t("allRightsReserved")} +
+
+
+
+ ) +} diff --git a/cannamanage-frontend/src/app/(marketing)/page.tsx b/cannamanage-frontend/src/app/(marketing)/page.tsx new file mode 100644 index 0000000..6b20c60 --- /dev/null +++ b/cannamanage-frontend/src/app/(marketing)/page.tsx @@ -0,0 +1,163 @@ +"use client" + +import Link from "next/link" +import { useTranslations } from "next-intl" +import { + ArrowRight, + Cannabis, + ClipboardCheck, + FileArchive, + Lock, + Scale, + Server, + ShieldCheck, + Sprout, + Users, + Wallet, +} from "lucide-react" + +const features = [ + { id: "feature1", icon: ClipboardCheck }, + { id: "feature2", icon: Sprout }, + { id: "feature3", icon: Users }, + { id: "feature4", icon: Scale }, + { id: "feature5", icon: FileArchive }, + { id: "feature6", icon: Wallet }, +] + +const trustSignals = [ + { id: "trustCanverg", icon: ShieldCheck }, + { id: "trustDsgvo", icon: ClipboardCheck }, + { id: "trustEncryption", icon: Lock }, + { id: "trustGerman", icon: Server }, +] + +export default function HomePage() { + const t = useTranslations("marketing.home") + + return ( +
+ {/* Hero Section */} +
+
+
+
+ + KCanG-konform +
+

+ {t("heroTitle")} +

+

+ {t("heroSubtitle")} +

+
+ + {t("ctaPrimary")} + + + + {t("ctaSecondary")} + +
+
+
+ {/* Decorative gradient */} +
+
+
+
+ + {/* Features Section */} +
+
+
+

+ {t("featuresTitle")} +

+

+ {t("featuresSubtitle")} +

+
+
+ {features.map((feature) => { + const Icon = feature.icon + return ( +
+
+ +
+

+ {t(`${feature.id}Title`)} +

+

+ {t(`${feature.id}Desc`)} +

+
+ ) + })} +
+
+
+ + {/* Trust Signals Section */} +
+
+
+

+ {t("trustTitle")} +

+
+
+ {trustSignals.map((signal) => { + const Icon = signal.icon + return ( +
+
+ +
+ {t(signal.id)} +
+ ) + })} +
+
+
+ + {/* Final CTA Section */} +
+
+
+

+ {t("ctaFinalTitle")} +

+

+ {t("ctaFinalSubtitle")} +

+
+ + {t("ctaFinalButton")} + + +
+
+
+
+
+ ) +} diff --git a/cannamanage-frontend/src/app/(marketing)/pricing/page.tsx b/cannamanage-frontend/src/app/(marketing)/pricing/page.tsx index aae3701..fcde452 100644 --- a/cannamanage-frontend/src/app/(marketing)/pricing/page.tsx +++ b/cannamanage-frontend/src/app/(marketing)/pricing/page.tsx @@ -10,6 +10,7 @@ const plans = [ icon: Leaf, price: "19", memberLimit: "30", + storage: "5", features: [ "memberManagement", "distributionTracking", @@ -24,6 +25,7 @@ const plans = [ icon: Cannabis, price: "49", memberLimit: "100", + storage: "50", popular: true, features: [ "allStarter", @@ -40,6 +42,7 @@ const plans = [ icon: Building2, price: null, memberLimit: "unlimited", + storage: "custom", features: [ "allPro", "unlimitedMembers", @@ -58,6 +61,7 @@ const faqs = [ { id: "cancel" }, { id: "data" }, { id: "migration" }, + { id: "storage" }, ] export default function PricingPage() { @@ -129,6 +133,14 @@ export default function PricingPage() { limit: plan.memberLimit, })}

+
+ {t(`storage.${plan.id}`)} + {plan.id === "pro" && ( + + {t("storage.proOverage")} + + )} +
    @@ -180,6 +192,8 @@ export default function PricingPage() { {[ "compMembers", + "compStorage", + "compOverage", "compDistributions", "compReports", "compGrow", diff --git a/cannamanage-frontend/src/app/(portal)/portal-login/page.tsx b/cannamanage-frontend/src/app/(portal)/portal-login/page.tsx index 679954e..3267f57 100644 --- a/cannamanage-frontend/src/app/(portal)/portal-login/page.tsx +++ b/cannamanage-frontend/src/app/(portal)/portal-login/page.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useTranslations } from "next-intl" import { useForm } from "react-hook-form" import { z } from "zod" -import { Cannabis, Loader2 } from "lucide-react" +import { Cannabis, ClockArrowUp, FileText, Loader2, User } from "lucide-react" const loginSchema = z.object({ email: z.string().email(), @@ -42,101 +42,154 @@ export default function PortalLoginPage() { } return ( -
    -
    - {/* Logo & Branding */} -
    -
    - +
    + {/* Left panel — member-focused branding (hidden on mobile) */} +
    + {/* Decorative background */} +
    +
    +
    +
    + +
    + {/* Logo */} +
    +
    -

    {t("title")}

    -

    {t("loginSubtitle")}

    -
    - {/* Login Card */} -
    -
    - {/* Error message */} - {error && ( -
    - {error} + {/* Branding */} +
    +

    Mitgliederportal

    +

    Willkommen zurück

    +
    + + {/* Feature highlights */} +
    +
    +
    + +
    +
    +

    Abgabehistorie

    +

    Alle Abgaben auf einen Blick

    - )} - - {/* Email field */} -
    - - - {errors.email && ( -

    - {t("invalidCredentials")} -

    - )}
    - - {/* Password field */} -
    - - - {errors.password && ( -

    - {t("invalidCredentials")} -

    - )} +
    +
    + +
    +
    +

    Profil verwalten

    +

    Daten und Einstellungen

    +
    - - {/* Submit button */} - - +
    +
    + +
    +
    +

    Dokumente

    +

    Bescheinigungen und Nachweise

    +
    +
    +
    +
    - {/* Footer link to admin */} -
    - - {t("adminLogin")} - + {/* Right panel — form */} +
    +
    + {/* Title */} +
    +

    {t("title")}

    +

    {t("loginSubtitle")}

    +
    + + {/* Login Card */} +
    +
    + {/* Error message */} + {error && ( +
    + {error} +
    + )} + + {/* Email field */} +
    + + + {errors.email && ( +

    + {t("invalidCredentials")} +

    + )} +
    + + {/* Password field */} +
    + + + {errors.password && ( +

    + {t("invalidCredentials")} +

    + )} +
    + + {/* Submit button */} + +
    +
    + + {/* Footer link to admin */} +
    + + {t("adminLogin")} + +
    diff --git a/cannamanage-frontend/src/services/documents.ts b/cannamanage-frontend/src/services/documents.ts index 6d299f3..accb539 100644 --- a/cannamanage-frontend/src/services/documents.ts +++ b/cannamanage-frontend/src/services/documents.ts @@ -73,7 +73,19 @@ export async function uploadDocument( body: formData, } ) - if (!res.ok) throw new Error("Upload failed") + if (!res.ok) { + if (res.status === 402) { + const problem = await res.json() + const error = new Error("Storage quota exceeded") as Error & { + status: number + problemDetail: unknown + } + error.status = 402 + error.problemDetail = problem + throw error + } + throw new Error("Upload failed") + } return res.json() } diff --git a/cannamanage-frontend/src/services/storage.ts b/cannamanage-frontend/src/services/storage.ts new file mode 100644 index 0000000..3d4813c --- /dev/null +++ b/cannamanage-frontend/src/services/storage.ts @@ -0,0 +1,43 @@ +import { apiClient } from "@/lib/api-client" + +export interface StorageUsage { + usedBytes: number + limitBytes: number + percentage: number +} + +/** + * Fetch current storage usage for the authenticated user's club. + * Club ID is derived from JWT on the backend — no param needed. + */ +export function getStorageUsage(): Promise { + return apiClient("/storage/usage") +} + +/** + * Format bytes into a human-readable string (e.g., "4.2 GB"). + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + const k = 1024 + const sizes = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i] +} + +/** + * Check if an API error response indicates a storage quota exceeded (HTTP 402). + */ +export function isStorageQuotaError(error: unknown): boolean { + if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response + ) { + return (error.response as { status: number }).status === 402 + } + return false +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java b/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java index 68faf42..81d4577 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java @@ -36,16 +36,22 @@ public class DocumentService { private final DocumentRepository documentRepository; private final AuditService auditService; + private final StorageQuotaService storageQuotaService; - public DocumentService(DocumentRepository documentRepository, AuditService auditService) { + public DocumentService(DocumentRepository documentRepository, AuditService auditService, + StorageQuotaService storageQuotaService) { this.documentRepository = documentRepository; this.auditService = auditService; + this.storageQuotaService = storageQuotaService; } @Transactional public Document uploadDocument(UUID clubId, String title, DocumentCategory category, DocumentAccessLevel accessLevel, String description, MultipartFile file, UUID uploadedBy) throws IOException { + // Check storage quota before upload + storageQuotaService.checkQuota(clubId, file.getSize()); + // Validate file if (file.isEmpty()) { throw new IllegalArgumentException("File is empty"); @@ -88,6 +94,9 @@ public class DocumentService { Document saved = documentRepository.save(doc); + // Increment storage usage counter after successful save + storageQuotaService.incrementUsage(clubId, file.getSize()); + auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId, "Document uploaded: " + title + " (" + category + ")"); @@ -133,6 +142,9 @@ public class DocumentService { // Delete DB record documentRepository.delete(doc); + // Decrement storage usage counter after successful delete + storageQuotaService.decrementUsage(clubId, doc.getFileSize()); + auditService.log(AuditEventType.DOCUMENT_DELETED, deletedBy, clubId, "Document deleted: " + doc.getTitle()); diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java b/cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java new file mode 100644 index 0000000..1f71672 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java @@ -0,0 +1,121 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.Club; +import de.cannamanage.service.exception.StorageQuotaExceededException; +import de.cannamanage.service.repository.ClubRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Manages storage quota enforcement for clubs. + * Each club has a storage_limit_bytes based on their subscription tier + * and a storage_used_bytes counter tracking actual usage. + */ +@Slf4j +@Service +public class StorageQuotaService { + + // Plan tier limits + private static final long STARTER_LIMIT = 5L * 1024 * 1024 * 1024; // 5 GB + private static final long PRO_LIMIT = 50L * 1024 * 1024 * 1024; // 50 GB + private static final long ENTERPRISE_LIMIT = Long.MAX_VALUE; // Unlimited + + private final ClubRepository clubRepository; + + public StorageQuotaService(ClubRepository clubRepository) { + this.clubRepository = clubRepository; + } + + /** + * Get current storage usage for a club. + */ + public StorageUsageDTO getUsage(UUID clubId) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId)); + long used = club.getStorageUsedBytes(); + long limit = club.getStorageLimitBytes(); + double percentage = limit > 0 ? (double) used / limit * 100 : 0; + return new StorageUsageDTO(used, limit, percentage); + } + + /** + * Check if uploading additionalBytes would exceed the club's storage limit. + * Throws StorageQuotaExceededException if it would. + */ + public void checkQuota(UUID clubId, long additionalBytes) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId)); + long newTotal = club.getStorageUsedBytes() + additionalBytes; + if (newTotal > club.getStorageLimitBytes()) { + throw new StorageQuotaExceededException( + club.getStorageUsedBytes(), club.getStorageLimitBytes(), additionalBytes); + } + } + + /** + * Increment the club's storage usage counter after a successful upload. + */ + @Transactional + public void incrementUsage(UUID clubId, long bytes) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId)); + club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes); + clubRepository.save(club); + log.debug("Club {} storage incremented by {} bytes (total: {})", clubId, bytes, club.getStorageUsedBytes()); + } + + /** + * Decrement the club's storage usage counter after a successful delete. + */ + @Transactional + public void decrementUsage(UUID clubId, long bytes) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId)); + long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes); + club.setStorageUsedBytes(newUsage); + clubRepository.save(club); + log.debug("Club {} storage decremented by {} bytes (total: {})", clubId, bytes, newUsage); + } + + /** + * Get the storage limit in bytes for a given plan tier name. + */ + public static long getLimitForTier(String tier) { + return switch (tier.toLowerCase()) { + case "starter", "trial" -> STARTER_LIMIT; + case "pro" -> PRO_LIMIT; + case "enterprise" -> ENTERPRISE_LIMIT; + default -> STARTER_LIMIT; + }; + } + + /** + * Called when a club's subscription tier changes. + * Updates storage_limit_bytes to match the new tier. + */ + @Transactional + public void onTierChange(UUID clubId, String newTier) { + long newLimit = getLimitForTier(newTier); + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId)); + club.setStorageLimitBytes(newLimit); + clubRepository.save(club); + log.info("Club {} tier changed to '{}' — storage limit updated to {} bytes", clubId, newTier, newLimit); + } + + /** + * Check if a club is at or above a given usage threshold percentage. + */ + public boolean isNearLimit(UUID clubId, int thresholdPercent) { + StorageUsageDTO usage = getUsage(clubId); + return usage.percentage() >= thresholdPercent; + } + + /** + * DTO for storage usage response. + */ + public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {} +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java new file mode 100644 index 0000000..bfc863c --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java @@ -0,0 +1,33 @@ +package de.cannamanage.service.exception; + +/** + * Thrown when a document upload would exceed the club's storage quota. + * Maps to HTTP 402 Payment Required — distinct from QuotaExceededException + * which handles CanG distribution quotas (25g/day, 50g/month). + */ +public class StorageQuotaExceededException extends RuntimeException { + + private final long currentUsage; + private final long limit; + private final long requestedBytes; + + public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) { + super("Storage quota exceeded: current=%d, limit=%d, requested=%d" + .formatted(currentUsage, limit, requestedBytes)); + this.currentUsage = currentUsage; + this.limit = limit; + this.requestedBytes = requestedBytes; + } + + public long getCurrentUsage() { + return currentUsage; + } + + public long getLimit() { + return limit; + } + + public long getRequestedBytes() { + return requestedBytes; + } +} diff --git a/docs/sprint-14/cannamanage-sprint14-analysis.md b/docs/sprint-14/cannamanage-sprint14-analysis.md new file mode 100644 index 0000000..b25b615 --- /dev/null +++ b/docs/sprint-14/cannamanage-sprint14-analysis.md @@ -0,0 +1,123 @@ +# Analysis: Sprint 14 — Marketing & Monetization + +**Date:** 2026-06-18 +**Author:** Patrick Plate / Lumen (Planner) +**Status:** v1 +**Sprint Theme:** Marketing & Monetization + +--- + +## 1. Problem Analysis + +CannaManage is production-ready after Sprint 13's hardening. However, the public-facing marketing surfaces are minimal — there is no landing page (the root `/` currently serves the pricing page directly via the marketing layout), the login pages use a basic centered-card layout that doesn't communicate product value, and the pricing page lacks storage quota information which is a core monetization lever. + +Additionally, the backend has no concept of storage quotas per tenant. Documents can be uploaded without limit, creating an unbounded cost liability on the file storage (TrueNAS/disk). Sprint 14 introduces a **StorageQuotaService** that enforces per-plan limits, making the pricing tiers meaningful at the infrastructure level. + +### Sprint Goals + +1. **Landing Page** — Create a professional homepage that converts visitors to signups +2. **Login Redesign** — Split-layout login pages that reinforce brand value during auth flow +3. **Pricing Rework** — Add storage tier information, update pricing model +4. **Storage Quota Backend** — Enforce plan-based storage limits on document uploads + +--- + +## 2. Affected Components + +| Component | Path | Role | +|-----------|------|------| +| Marketing layout | `cannamanage-frontend/src/app/(marketing)/layout.tsx` | Shared header/footer for marketing pages | +| Homepage (NEW) | `cannamanage-frontend/src/app/(marketing)/page.tsx` | Landing page — hero, features, trust signals | +| Pricing page | `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx` | Pricing cards with storage tiers | +| Auth layout | `cannamanage-frontend/src/app/(auth)/layout.tsx` | Centered flex container for login | +| Admin login | `cannamanage-frontend/src/app/(auth)/login/page.tsx` | Admin/staff login form | +| Portal login | `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx` | Member portal login form | +| PlanTier enum | `cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PlanTier.java` | TRIAL, STARTER, PRO, ENTERPRISE | +| Club entity | `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java` | Needs `storageUsedBytes` field | +| Document entity | `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Document.java` | Has `fileSize` field — source of truth for usage | +| DocumentService | `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java` | Upload logic — needs quota check | +| StorageQuotaService (NEW) | `cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java` | Quota calculation and enforcement | +| StorageController (NEW) | `cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java` | REST endpoint for storage usage | +| Flyway V36 (NEW) | `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql` | Add storage tracking column | +| i18n messages | `cannamanage-frontend/src/messages/de.json` | New keys for landing page, pricing storage | +| Documents frontend service | `cannamanage-frontend/src/services/documents.ts` | Needs quota-exceeded error handling | + +--- + +## 3. Current State (Ist-Zustand) + +### Marketing Pages + +- **No landing page** exists at `(marketing)/page.tsx` — the root `/` route likely falls through or shows a 404 +- **Pricing page** has 3 tiers (Starter €19, Pro €49, Enterprise) with member limits and feature lists, but **no storage information** +- **Marketing layout** has a sticky header with logo + "Preise" + "Anmelden" links, and a footer with Produkt/Rechtliches columns +- Navigation text is hardcoded German (not i18n) in the layout + +### Login Pages + +- **Auth layout** is a minimal centered flex container: `fixed inset-0 z-50 flex items-center justify-center` +- **Admin login** renders a centered card with logo, email/password form, forgot password link, and portal link +- **Portal login** is nearly identical but uses portal-specific translations and mock auth +- Both pages use the same visual pattern — no split-layout, no brand messaging during auth + +### Storage Backend + +- **Document entity** already tracks `fileSize` (Long) per file +- **PlanTier enum** exists: TRIAL, STARTER, PRO, ENTERPRISE +- **No storage quota concept** exists anywhere — no `storage_used_bytes` column, no quota checks on upload +- **DocumentService** handles upload/download/delete but never checks cumulative storage +- Latest Flyway migration: `V35__generated_reports_add_timestamps.sql` + +--- + +## 4. Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Landing page doesn't convert (poor copy/design) | Medium | Medium (lost signups) | Follow proven SaaS landing page patterns; iterate based on analytics | +| Storage quota breaks existing uploads | Low | High (data loss) | Implement as soft-limit first — warn but don't block for existing over-limit tenants | +| i18n key explosion | Low | Low (maintenance) | Group new keys under `marketing.home`, `marketing.pricing.storage` namespaces | +| Split login layout breaks on mobile | Medium | Medium (can't log in) | Mobile-first design: left panel hidden on ` getUsage(@AuthenticationPrincipal UserDetails user) { + UUID clubId = extractClubId(user); + // ... +} +``` + +**Actual implementation:** +```java +@GetMapping("/usage") +public ResponseEntity getUsage(@RequestParam UUID clubId) { + return ResponseEntity.ok(storageQuotaService.getUsage(clubId)); +} +``` + +- **Risiko:** Any authenticated ADMIN/STAFF user can query any club's storage usage by passing an arbitrary `clubId`. The plan's approach enforced tenant isolation by deriving the club from the JWT token. +- **Empfehlung:** Replace `@RequestParam UUID clubId` with `@AuthenticationPrincipal` extraction pattern used elsewhere (e.g., DocumentController). This ensures users can only see their own club's data. If cross-club access is intentional (admin panel use case), add a separate admin-only endpoint. + +--- + +### ⚠️ WARNING-2: SubscriptionService tier-change hook not implemented + +**Plan (Step 4.9)** specified a `SubscriptionService.onTierChange(UUID clubId, PlanTier newTier)` method to update `storage_limit_bytes` when a club changes tier. + +- **Not found** in the implementation. +- **Impact:** Low — the plan's own note says "Full subscription management (Stripe webhooks, billing cycle, trial-to-paid) is deferred to a future sprint." The hook would currently be dead code. +- **Empfehlung:** Acceptable to defer. When subscription management is implemented, this hook must be added. The `StorageQuotaService.getLimitForTier()` already exists to support it. + +--- + +### ℹ️ INFO-1: getLimitForTier takes String instead of PlanTier enum + +**Datei:** [`StorageQuotaService.java`](cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java:86) + +Plan specified `getLimitForTier(PlanTier tier)` but implementation uses `getLimitForTier(String tier)` with a switch on lowercase string matching. This is pragmatic since no `PlanTier` enum exists yet in the domain module. When the enum is created, update the method signature. + +--- + +### ℹ️ INFO-2: Marketing layout extracted to client component + +**Datei:** [`marketing-layout-client.tsx`](cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx:1) + +The plan didn't explicitly specify this separation, but it's a correct architectural choice: the server-side `layout.tsx` handles `getMessages()` and wraps with `NextIntlClientProvider`, while the client component handles the actual rendering with `useTranslations`. This follows Next.js 14 best practices for server/client component boundaries. + +--- + +### ℹ️ INFO-3: Frontend storage service uses apiClient with clubId param + +**Datei:** [`storage.ts`](cannamanage-frontend/src/services/storage.ts:12) + +The frontend `getStorageUsage(clubId: string)` matches the backend's `@RequestParam UUID clubId`. This is consistent — but both should be updated together if WARNING-1 is addressed (the frontend would then not need to pass clubId explicitly). + +## Tests + +- **Backend-Tests ausgeführt:** Nicht im Scope dieses Reviews (kein Build-Ausführung angefordert) +- **Testplan T-14 bis T-32:** Backend unit/integration tests not yet verified as passing +- **E2E T-01 bis T-13:** Frontend E2E tests not executed in this review + +## Empfehlung + +**⚠️ Approved with comments** — merge is acceptable, but WARNING-1 (StorageController tenant isolation) should be addressed before production deployment. WARNING-2 (SubscriptionService hook) is acceptable to defer. + +### Prioritized actions: +1. **Before production:** Fix StorageController to use JWT-based club extraction instead of `@RequestParam` +2. **Next sprint:** Add SubscriptionService tier-change hook when billing features are implemented +3. **Housekeeping:** Introduce `PlanTier` enum and update `getLimitForTier` signature diff --git a/docs/sprint-14/cannamanage-sprint14-plan-review.md b/docs/sprint-14/cannamanage-sprint14-plan-review.md new file mode 100644 index 0000000..16d6a0d --- /dev/null +++ b/docs/sprint-14/cannamanage-sprint14-plan-review.md @@ -0,0 +1,86 @@ +# Plan Review: Sprint 14 — Marketing & Monetization + +**Date:** 2026-06-18 +**Module:** cannamanage (full-stack) +**Reviewer:** Lumen (Plan Reviewer) +**Documents:** analysis v1, plan v2, testplan v2 +**Verdict:** ✅ APPROVED + +--- + +## Summary + +Re-review after v1 findings were addressed. All 4 findings (1 blocker, 3 warnings) from the v1 review have been properly resolved in v2. The plan is complete, correct, and ready for implementation. + +## v1 Finding Resolution + +| # | v1 Finding | Resolution in v2 | Status | +|---|-----------|------------------|--------| +| 1 | ❌ `QuotaExceededException` naming conflict | Renamed to `StorageQuotaExceededException` in Step 4.4 with explicit note about existing class | ✅ Fixed | +| 2 | ⚠️ No sync for `storage_limit_bytes` on tier change | Added Step 4.9 `onTierChange()` hook in `SubscriptionService` | ✅ Fixed | +| 3 | ⚠️ English i18n keys not specified | Step 1.3 now states both `de.json` and `en.json` must receive equivalent keys; T-32 tests both | ✅ Fixed | +| 4 | ⚠️ `extractClubId(user)` not defined | Step 4.5 now has full implementation with Javadoc referencing `DocumentController` pattern | ✅ Fixed | + +## Reviewed Documents + +| Document | Version | Assessment | +|----------|---------|-----------| +| Analysis | v1 | ✅ | +| Plan | v2 | ✅ | +| Testplan | v2 | ✅ | + +## Checklist + +### Assessment + +| # | Check | Result | Note | +|---|-------|--------|------| +| 1 | Problem statement complete | ✅ | Clear: no landing page, no quota enforcement, basic login UI | +| 2 | Affected components identified | ✅ | 15 components listed (was 14, +1 for new `StorageQuotaExceededException`) | +| 3 | Current state accurate | ✅ | Confirmed: no `(marketing)/page.tsx`, nav is hardcoded, V35 is latest migration | +| 4 | Risk assessment realistic | ✅ | 5 risks with appropriate mitigations | +| 5 | Solution options evaluated | ✅ | 3 options with effort estimates, Option A justified | + +### Implementation Plan + +| # | Check | Result | Note | +|---|-------|--------|------| +| 6 | All requirements covered | ✅ | All 4 areas: landing, login, pricing, storage quota | +| 7 | Correct patterns referenced | ✅ | `NextIntlClientProvider`, Spring `@Service`, `@PreAuthorize`, `CustomUserDetails` cast pattern | +| 8 | File paths correct | ✅ | All verified against codebase | +| 9 | Implementation order logical | ✅ | Frontend (phases 1-3) → backend (phase 4) → integration (phase 5) | +| 10 | No gaps in steps | ✅ | Migration → entity → service → controller → exception → security config → tier-sync — complete chain | +| 11 | Flyway migrations planned | ✅ | V36 correct next number, H2-only appropriate for this project | +| 12 | Error handling planned | ✅ | 402 with RFC 9457 ProblemDetail, floor-at-zero for decrements | +| 13 | No scope creep | ✅ | Explicitly defers overage billing and email notifications | + +### Testplan + +| # | Check | Result | Note | +|---|-------|--------|------| +| 14 | Coverage complete | ✅ | Every plan step has ≥1 test. 32 tests across 4 areas. | +| 15 | Test types appropriate | ✅ | E2E for UI (Playwright), Unit for service logic, Integration for controllers | +| 16 | Edge cases covered | ✅ | Floor-at-zero (T-19), exactly-at-limit (T-15c, T-16b), near-limit thresholds | +| 17 | Test class naming correct | ✅ | `StorageQuotaServiceTest`, `StorageControllerTest`, `DocumentServiceTest` | +| 18 | Test method naming correct | ✅ | `testGetUsage_calculatesCorrectly()`, `testCheckQuota_overLimit_throwsQuotaExceeded()` | +| 19 | Test data defined | ✅ | Explicit UUIDs, byte values, and preconditions documented | +| 20 | SSH/manual tests identified | N/A | Not a PAISY project | + +## Traceability Matrix + +| Acceptance Criterion | Plan Step | Test Case(s) | Status | +|---------------------|-----------|-------------|--------| +| AC1: Landing page renders | Step 1.1 | T-01 | ✅ Covered | +| AC2: Landing responsive + dark/light | Step 1.1 | T-02, T-03 | ✅ Covered | +| AC3: Admin login split layout | Steps 2.1, 2.2 | T-06, T-07, T-08 | ✅ Covered | +| AC4: Portal login member-themed | Step 2.3 | T-09, T-10 | ✅ Covered | +| AC5: Pricing shows storage | Steps 3.1-3.3 | T-11, T-12 | ✅ Covered | +| AC6: Pricing FAQ storage | Step 3.4 | T-13 | ✅ Covered | +| AC7: GET /api/v1/storage/usage | Steps 4.3, 4.5 | T-23, T-24, T-25 | ✅ Covered | +| AC8: Upload rejected 402 | Steps 4.3, 4.6, 4.7 | T-16, T-27, T-31 | ✅ Covered | +| AC9: Delete decrements counter | Step 4.6 | T-18, T-28 | ✅ Covered | +| AC10: Backfill on migration | Step 4.1 | T-29, T-30 | ✅ Covered | + +## Verdict + +**✅ APPROVED** — All 20 checklist items pass. All 4 v1 findings resolved. Plan is complete, correct, and ready for implementation. Recommend GO. diff --git a/docs/sprint-14/cannamanage-sprint14-plan.md b/docs/sprint-14/cannamanage-sprint14-plan.md new file mode 100644 index 0000000..231f7de --- /dev/null +++ b/docs/sprint-14/cannamanage-sprint14-plan.md @@ -0,0 +1,571 @@ +# Plan: Sprint 14 — Marketing & Monetization + +**Date:** 2026-06-18 +**Author:** Patrick Plate / Lumen (Planner) +**Status:** v2 +**Basis:** cannamanage-sprint14-analysis.md + +--- + +## Background + +Sprint 14 transforms CannaManage from an internal tool into a market-ready SaaS product. It delivers four interconnected pieces: a converting landing page, premium-feeling login experiences, an updated pricing page with storage tiers, and the backend enforcement that makes storage quotas real. Together these enable the public launch. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Frontend — Marketing Layer │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ (marketing)/page.tsx ──► Landing page (hero, features, CTA) │ +│ (marketing)/pricing/page.tsx ──► Updated with storage tiers │ +│ (marketing)/layout.tsx ──► Updated nav (Features link) │ +│ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Frontend — Auth Layer │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ (auth)/layout.tsx ──► Split layout (branding left, form right) │ +│ (auth)/login/page.tsx ──► Form-only (layout handles split) │ +│ (portal)/portal-login/page.tsx ──► Member-themed split login │ +│ │ +├─────────────────────────────────────────────────────────────────────┤ +│ Backend — Storage Quota │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ StorageQuotaService ──► getUsage(clubId), checkQuota(clubId, size) │ +│ StorageController ──► GET /api/v1/storage/usage │ +│ DocumentService ──► Pre-upload quota check │ +│ Club entity ──► storageUsedBytes column (incremental counter) │ +│ V36 migration ──► ALTER TABLE clubs ADD storage_used_bytes │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Components + +| # | Component | Module | Action | +|---|-----------|--------|--------| +| 1 | Landing page | cannamanage-frontend | **New** — `(marketing)/page.tsx` | +| 2 | Auth layout (split) | cannamanage-frontend | **Modify** — `(auth)/layout.tsx` | +| 3 | Admin login page | cannamanage-frontend | **Modify** — remove layout wrapper, form only | +| 4 | Portal login page | cannamanage-frontend | **Modify** — member-themed split variant | +| 5 | Pricing page | cannamanage-frontend | **Modify** — add storage tiers, update model | +| 6 | Marketing layout | cannamanage-frontend | **Modify** — add "Features" nav link | +| 7 | i18n messages (de) | cannamanage-frontend | **Modify** — add marketing.home, pricing.storage keys | +| 8 | i18n messages (en) | cannamanage-frontend | **Modify** — full English equivalents for marketing.home.* and marketing.pricing.storage.* | +| 9 | StorageQuotaService | cannamanage-service | **New** — quota logic | +| 10 | StorageController | cannamanage-api | **New** — REST endpoint | +| 11 | Club entity | cannamanage-domain | **Modify** — add storageUsedBytes | +| 12 | DocumentService | cannamanage-service | **Modify** — add quota check on upload | +| 13 | Flyway V36 | cannamanage-api | **New** — migration SQL | +| 14 | Frontend storage service | cannamanage-frontend | **New** — `services/storage.ts` | +| 15 | StorageQuotaExceededException | cannamanage-service | **New** — 402 storage exception | + +--- + +## Implementation Steps + +### Phase 1: Landing Page + +#### Step 1.1 — Create `(marketing)/page.tsx` + +**File:** `cannamanage-frontend/src/app/(marketing)/page.tsx` + +A full landing page with these sections: +- **Hero** — Headline ("Die smarte Verwaltung für deinen Anbauverein"), subheadline, primary CTA (→ /pricing), secondary CTA (→ /login) +- **Feature grid** — 6 cards in a 2×3 / 3×2 responsive grid: + 1. Compliance Tracking (§22 KCanG documentation) + 2. Grow Management (calendar, stages, sensors) + 3. Member Portal (self-service, history, profile) + 4. Distribution Quotas (25g/day, 50g/month enforcement) + 5. Document Archive (GoBD-compliant, retention periods) + 6. Financial Management (fees, SEPA, bank import) +- **Trust signals** — "Für Anbauvereine in Deutschland" badge, CanVerG compliance, DSGVO/GoBD, TLS encryption +- **Final CTA** — "Jetzt kostenlos testen" → /pricing + +Use `lucide-react` icons. All text via `useTranslations("marketing.home")`. Responsive: single column on mobile, grid on tablet+. Dark/light mode compatible. + +#### Step 1.2 — Update marketing layout navigation + +**File:** `cannamanage-frontend/src/app/(marketing)/layout.tsx` + +- Add "Features" link in header nav (scrolls to `#features` anchor on homepage, or links to `/#features`) +- Internationalize hardcoded "Preise" and "Anmelden" strings via `useTranslations` +- Add "Features" link to footer "Produkt" column + +#### Step 1.3 — Add i18n keys for landing page (de + en) + +**Files:** +- `cannamanage-frontend/messages/de.json` +- `cannamanage-frontend/messages/en.json` + +Both locale files must receive entries for the `marketing.home.*` and `marketing.pricing.storage.*` namespaces. The German keys are the primary source; the English file must contain equivalent translations for all keys. + +Add under `marketing.home` (German example): +```json +{ + "marketing": { + "home": { + "heroTitle": "Die smarte Verwaltung für deinen Anbauverein", + "heroSubtitle": "Compliance, Anbau, Mitglieder und Abgaben — alles in einer Plattform. Rechtssicher nach KCanG.", + "ctaPrimary": "Preise ansehen", + "ctaSecondary": "Jetzt anmelden", + "featuresTitle": "Alles, was dein Verein braucht", + "featuresSubtitle": "Von der Mitgliederverwaltung bis zur Behördenmeldung — CannaManage deckt den gesamten Vereinsbetrieb ab.", + "feature1Title": "Compliance Tracking", + "feature1Desc": "Automatische Überwachung der KCanG-Vorgaben mit Fristen und Checklisten.", + "feature2Title": "Grow Management", + "feature2Desc": "Anbaukalender, Wachstumsphasen und Sensorintegration.", + "feature3Title": "Mitglieder-Portal", + "feature3Desc": "Self-Service für Mitglieder: Profil, Abgabehistorie, Dokumente.", + "feature4Title": "Abgabe-Quotas", + "feature4Desc": "Automatische Einhaltung der 25g/Tag und 50g/Monat Grenzen.", + "feature5Title": "Dokumenten-Archiv", + "feature5Desc": "GoBD-konforme Ablage mit Aufbewahrungsfristen und Versionierung.", + "feature6Title": "Finanzverwaltung", + "feature6Desc": "Mitgliedsbeiträge, SEPA-Export und Bankimport.", + "trustTitle": "Vertrauen durch Compliance", + "trustCanverg": "CanVerG-konform", + "trustDsgvo": "DSGVO & GoBD", + "trustEncryption": "TLS-verschlüsselt", + "trustGerman": "Hosting in Deutschland", + "ctaFinalTitle": "Bereit für den nächsten Schritt?", + "ctaFinalSubtitle": "Starte jetzt mit CannaManage und bring deinen Verein auf das nächste Level.", + "ctaFinalButton": "Kostenlos testen" + } + } +} +``` + +--- + +### Phase 2: Login Redesign + +#### Step 2.1 — Redesign auth layout to split-layout + +**File:** `cannamanage-frontend/src/app/(auth)/layout.tsx` + +Replace the simple centered container with a split layout: +``` +┌──────────────────────────────────────────────────┐ +│ Left Panel (hidden 0 ? (double) used / limit * 100 : 0; + return new StorageUsageDTO(used, limit, percentage); + } + + public void checkQuota(UUID clubId, long additionalBytes) { + Club club = clubRepository.findById(clubId).orElseThrow(); + long newTotal = club.getStorageUsedBytes() + additionalBytes; + if (newTotal > club.getStorageLimitBytes()) { + throw new StorageQuotaExceededException(club.getStorageUsedBytes(), + club.getStorageLimitBytes(), additionalBytes); + } + } + + public void incrementUsage(UUID clubId, long bytes) { + Club club = clubRepository.findById(clubId).orElseThrow(); + club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes); + clubRepository.save(club); + } + + public void decrementUsage(UUID clubId, long bytes) { + Club club = clubRepository.findById(clubId).orElseThrow(); + long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes); + club.setStorageUsedBytes(newUsage); + clubRepository.save(club); + } + + public static long getLimitForTier(PlanTier tier) { + return switch (tier) { + case TRIAL, STARTER -> STARTER_LIMIT; + case PRO -> PRO_LIMIT; + case ENTERPRISE -> ENTERPRISE_LIMIT; + }; + } + + public boolean isNearLimit(UUID clubId, int thresholdPercent) { + StorageUsageDTO usage = getUsage(clubId); + return usage.percentage() >= thresholdPercent; + } +} +``` + +#### Step 4.4 — Create StorageQuotaExceededException + +**File:** `cannamanage-service/src/main/java/de/cannamanage/service/exception/StorageQuotaExceededException.java` + +> **Note:** The existing `QuotaExceededException` (at `cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaExceededException.java`) is reserved for CanG distribution quotas (25g/day, 50g/month) and takes a `QuotaViolationCode`. The storage exception needs its own class with an incompatible constructor signature. + +```java +public class StorageQuotaExceededException extends RuntimeException { + private final long currentUsage; + private final long limit; + private final long requestedBytes; + + public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) { + super("Storage quota exceeded: current=%d, limit=%d, requested=%d" + .formatted(currentUsage, limit, requestedBytes)); + this.currentUsage = currentUsage; + this.limit = limit; + this.requestedBytes = requestedBytes; + } + + // Getters +} +``` + +Map in `GlobalExceptionHandler` to HTTP 402 Payment Required with RFC 9457 problem detail. + +#### Step 4.5 — Create StorageController + +**File:** `cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java` + +```java +@RestController +@RequestMapping("/api/v1/storage") +@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')") +public class StorageController { + + private final StorageQuotaService storageQuotaService; + + @GetMapping("/usage") + public ResponseEntity getUsage(@AuthenticationPrincipal UserDetails user) { + UUID clubId = extractClubId(user); + return ResponseEntity.ok(storageQuotaService.getUsage(clubId)); + } + + /** + * Extracts the clubId from the authenticated user's JWT claims. + * The user's club association is stored as a "clubId" claim in the token, + * set during authentication by AuthService. This follows the same pattern + * used in DocumentController and other club-scoped controllers. + */ + private UUID extractClubId(UserDetails user) { + // Cast to our CustomUserDetails which carries the clubId from JWT + var customUser = (CustomUserDetails) user; + return customUser.getClubId(); + } +} +``` + +Response DTO: +```java +public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {} +``` + +#### Step 4.6 — Update DocumentService for quota check + +**File:** `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java` + +In the upload method, add before file write: +```java +// Check storage quota before upload +storageQuotaService.checkQuota(clubId, file.getSize()); + +// ... existing upload logic ... + +// After successful save, increment usage counter +storageQuotaService.incrementUsage(clubId, file.getSize()); +``` + +In the delete method, add after file removal: +```java +// Decrement usage counter after successful delete +storageQuotaService.decrementUsage(clubId, document.getFileSize()); +``` + +#### Step 4.7 — Handle 402 in GlobalExceptionHandler + +**File:** `cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java` + +Add handler: +```java +@ExceptionHandler(StorageQuotaExceededException.class) +public ResponseEntity handleStorageQuotaExceeded(StorageQuotaExceededException ex) { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.PAYMENT_REQUIRED); + problem.setTitle("Storage Quota Exceeded"); + problem.setDetail("Upload would exceed storage limit. Current: " + ex.getCurrentUsage() + + " bytes, Limit: " + ex.getLimit() + " bytes, Requested: " + ex.getRequestedBytes() + " bytes"); + problem.setProperty("currentUsage", ex.getCurrentUsage()); + problem.setProperty("limit", ex.getLimit()); + problem.setProperty("requestedBytes", ex.getRequestedBytes()); + return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(problem); +} +``` + +#### Step 4.8 — Add SecurityConfig matcher for storage endpoint + +**File:** `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java` + +Add: +```java +.requestMatchers(HttpMethod.GET, "/api/v1/storage/**").hasAnyRole("ADMIN", "STAFF") +``` + +#### Step 4.9 — Subscription tier change hook (storage_limit_bytes sync) + +**File:** `cannamanage-service/src/main/java/de/cannamanage/service/SubscriptionService.java` + +When a club's subscription tier changes (e.g., Starter→Pro), `storage_limit_bytes` must be updated to match the new tier. Add an `onTierChange()` hook: + +```java +/** + * Called when a club upgrades/downgrades their subscription tier. + * Updates the storage_limit_bytes to match the new tier's allocation. + */ +public void onTierChange(UUID clubId, PlanTier newTier) { + Club club = clubRepository.findById(clubId).orElseThrow(); + long newLimit = StorageQuotaService.getLimitForTier(newTier); + club.setStorageLimitBytes(newLimit); + clubRepository.save(club); + log.info("Club {} storage limit updated to {} bytes (tier: {})", clubId, newLimit, newTier); +} +``` + +> **Note:** This is a minimal hook. Full subscription management (Stripe webhooks, billing cycle, trial-to-paid) is deferred to a future sprint. For now, this method is called from the admin panel when manually changing a club's tier. + +--- + +### Phase 5: Frontend Storage Integration + +#### Step 5.1 — Create storage service + +**File:** `cannamanage-frontend/src/services/storage.ts` + +```typescript +export interface StorageUsage { + usedBytes: number + limitBytes: number + percentage: number +} + +export async function getStorageUsage(): Promise { + const response = await fetch('/api/v1/storage/usage', { ... }) + return response.json() +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} +``` + +#### Step 5.2 — Handle 402 in document upload UI + +**File:** `cannamanage-frontend/src/services/documents.ts` + +In the upload handler, catch 402 responses and display a quota-exceeded toast/dialog with upgrade CTA pointing to `/pricing`. + +--- + +## Open Questions + +- [ ] Should storage overage (Pro tier, €0.15/GB/mo) auto-allow uploads beyond limit, or still block? + - **Recommendation:** For now, block at limit for all tiers. Overage billing is a future sprint (requires Stripe integration). +- [ ] Email notifications at 80%/95% — implement the hook in this sprint or defer? + - **Recommendation:** Implement the detection (`isNearLimit`), log a warning, defer actual email sending. + +--- + +## Acceptance Criteria + +1. Landing page renders at `/` with hero, 6 features, trust signals, and CTA +2. Landing page is responsive (mobile/tablet/desktop) and supports dark/light mode +3. Admin login at `/login` shows split layout on desktop, full-width form on mobile +4. Portal login at `/portal-login` shows member-themed split layout +5. Pricing page shows storage limits per tier and comparison table with storage row +6. Pricing page has FAQ entry explaining storage limits +7. `GET /api/v1/storage/usage` returns `{ usedBytes, limitBytes, percentage }` for authenticated users +8. Document upload is rejected with HTTP 402 when quota would be exceeded +9. Document deletion decrements the storage counter +10. Existing clubs have their `storage_used_bytes` backfilled on migration diff --git a/docs/sprint-14/cannamanage-sprint14-testplan.md b/docs/sprint-14/cannamanage-sprint14-testplan.md new file mode 100644 index 0000000..85516b1 --- /dev/null +++ b/docs/sprint-14/cannamanage-sprint14-testplan.md @@ -0,0 +1,596 @@ +# Testplan: Sprint 14 — Marketing & Monetization + +**Date:** 2026-06-18 +**Author:** Patrick Plate / Lumen (Planner) +**Status:** v2 +**Basis:** cannamanage-sprint14-plan.md + +--- + +## Test Overview + +| ID | Description | Type | Class/Tool | Status | +|----|-------------|------|------------|--------| +| T-01 | Landing page renders all sections | E2E | Playwright | ⬜ | +| T-02 | Landing page responsive (mobile) | E2E | Playwright | ⬜ | +| T-03 | Landing page dark/light mode | E2E | Playwright | ⬜ | +| T-04 | Landing page CTA links work | E2E | Playwright | ⬜ | +| T-05 | Marketing nav shows Features link | E2E | Playwright | ⬜ | +| T-06 | Admin login — split layout on desktop | E2E | Playwright | ⬜ | +| T-07 | Admin login — full-width on mobile | E2E | Playwright | ⬜ | +| T-08 | Admin login — form still functional | E2E | Playwright | ⬜ | +| T-09 | Portal login — member-themed split | E2E | Playwright | ⬜ | +| T-10 | Portal login — form functional | E2E | Playwright | ⬜ | +| T-11 | Pricing — storage tiers displayed | E2E | Playwright | ⬜ | +| T-12 | Pricing — comparison table with storage row | E2E | Playwright | ⬜ | +| T-13 | Pricing — FAQ storage entry visible | E2E | Playwright | ⬜ | +| T-14 | Storage usage — correct calculation | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-15 | Storage quota — allows upload under limit | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-16 | Storage quota — rejects upload over limit | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-17 | Storage quota — increment on upload | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-18 | Storage quota — decrement on delete | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-19 | Storage quota — decrement floors at zero | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-20 | Storage quota — tier limit mapping | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-21 | Storage quota — near-limit detection (80%) | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-22 | Storage quota — near-limit detection (95%) | Unit | `StorageQuotaServiceTest` | ⬜ | +| T-23 | GET /api/v1/storage/usage — authenticated | Integration | `StorageControllerTest` | ⬜ | +| T-24 | GET /api/v1/storage/usage — unauthenticated 401 | Integration | `StorageControllerTest` | ⬜ | +| T-25 | GET /api/v1/storage/usage — correct DTO shape | Integration | `StorageControllerTest` | ⬜ | +| T-26 | Document upload — quota check integrated | Integration | `DocumentServiceTest` | ⬜ | +| T-27 | Document upload — 402 on quota exceeded | Integration | `DocumentControllerTest` | ⬜ | +| T-28 | Document delete — usage decremented | Integration | `DocumentServiceTest` | ⬜ | +| T-29 | Flyway V36 — migration applies cleanly | Integration | Flyway boot test | ⬜ | +| T-30 | Flyway V36 — backfill calculates correctly | Integration | SQL verification | ⬜ | +| T-31 | StorageQuotaExceededException — 402 response format | Unit | `GlobalExceptionHandlerTest` | ⬜ | +| T-32 | i18n — all marketing.home keys resolve (de + en) | Unit | Lint / next-intl | ⬜ | + +Status: ⬜ Open | ✅ Passed | ❌ Failed | ⏭️ Skipped + +--- + +## Test Cases + +### T-01: Landing Page Renders All Sections + +**Type:** E2E +**Tool:** Playwright +**Script:** `e2e/marketing/landing-page.spec.ts` + +**Preconditions:** +- App running at localhost:3000 +- No authentication required (public page) + +**Scenarios:** + +| # | Action | Expected Result | +|---|--------|-----------------| +| a | Navigate to `/` | Page loads without errors | +| b | Check hero section | Headline text visible, CTA buttons present | +| c | Check feature grid | 6 feature cards visible with titles and descriptions | +| d | Check trust signals | At least 4 trust badges visible | +| e | Check final CTA | "Kostenlos testen" button visible | + +**Postconditions:** +- All i18n keys resolve (no raw key strings visible) +- No console errors + +--- + +### T-02: Landing Page Responsive (Mobile) + +**Type:** E2E +**Tool:** Playwright (viewport: 375×812) + +**Scenarios:** + +| # | Viewport | Expected Result | +|---|----------|-----------------| +| a | 375×812 (iPhone) | Feature grid stacks to single column | +| b | 375×812 | Hero section full-width, text wraps cleanly | +| c | 768×1024 (iPad) | Feature grid shows 2 columns | +| d | 1280×720 (desktop) | Feature grid shows 3 columns | + +--- + +### T-03: Landing Page Dark/Light Mode + +**Type:** E2E +**Tool:** Playwright (`colorScheme: 'dark'` / `'light'`) + +**Scenarios:** + +| # | Mode | Expected Result | +|---|------|-----------------| +| a | Dark | Background is dark, text is light, no contrast issues | +| b | Light | Background is light, text is dark, cards have proper borders | +| c | Switch | Toggle theme mid-page — re-renders correctly | + +--- + +### T-04: Landing Page CTA Links + +**Type:** E2E +**Tool:** Playwright + +**Scenarios:** + +| # | Element | Expected Navigation | +|---|---------|-------------------| +| a | Primary CTA ("Preise ansehen") | Navigates to `/pricing` | +| b | Secondary CTA ("Jetzt anmelden") | Navigates to `/login` | +| c | Final CTA ("Kostenlos testen") | Navigates to `/pricing` | +| d | Header "Features" link | Scrolls to `#features` section or navigates to `/#features` | + +--- + +### T-05: Marketing Nav Features Link + +**Type:** E2E +**Tool:** Playwright + +**Scenarios:** + +| # | Page | Expected | +|---|------|----------| +| a | `/` | Header shows "Features" link | +| b | `/pricing` | Header shows "Features" link | +| c | Click "Features" from `/pricing` | Navigates to homepage features section | + +--- + +### T-06: Admin Login — Split Layout Desktop + +**Type:** E2E +**Tool:** Playwright (viewport: 1280×720) + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | Left panel visible | Branding panel with logo, tagline, feature bullets visible | +| b | Right panel visible | Login form visible | +| c | Layout proportions | Left panel ~55%, right panel ~45% | +| d | Left panel content | "CannaManage" text, tagline, 3 feature highlights with icons | + +--- + +### T-07: Admin Login — Full-Width Mobile + +**Type:** E2E +**Tool:** Playwright (viewport: 375×812) + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | Left panel | Hidden (`hidden md:flex`) | +| b | Form panel | Full width, centered vertically | +| c | Form usability | All fields accessible, submit button tappable | + +--- + +### T-08: Admin Login — Form Still Functional + +**Type:** E2E +**Tool:** Playwright + +**Preconditions:** +- Backend running with test credentials available + +**Scenarios:** + +| # | Input | Expected | +|---|-------|----------| +| a | Valid credentials | Redirects to `/dashboard` | +| b | Invalid password | Error message displayed | +| c | Empty fields | Validation errors shown | + +--- + +### T-09: Portal Login — Member-Themed Split + +**Type:** E2E +**Tool:** Playwright (viewport: 1280×720) + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | Left panel | Member-specific messaging ("Willkommen zurück") | +| b | Visual theme | Different gradient than admin login (teal/emerald vs. primary green) | +| c | Feature bullets | Member-relevant: Abgabehistorie, Profil, Dokumente | + +--- + +### T-10: Portal Login — Form Functional + +**Type:** E2E +**Tool:** Playwright + +**Scenarios:** + +| # | Input | Expected | +|---|-------|----------| +| a | Submit form | Redirects to `/portal/dashboard` | +| b | Invalid input | Error message shown | + +--- + +### T-11: Pricing — Storage Tiers Displayed + +**Type:** E2E +**Tool:** Playwright + +**Scenarios:** + +| # | Plan | Expected Storage Display | +|---|------|------------------------| +| a | Starter card | "5 GB Speicher" visible | +| b | Pro card | "50 GB Speicher" visible + overage note | +| c | Enterprise card | "Individueller Speicher" visible | + +--- + +### T-12: Pricing — Comparison Table + +**Type:** E2E +**Tool:** Playwright + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | Table exists | Comparison table rendered below plan cards | +| b | Storage row | "Speicher" row shows 5 GB / 50 GB / Individuell | +| c | Overage row | "Überschreitung" row shows values per plan | +| d | Responsive | Table scrollable on mobile | + +--- + +### T-13: Pricing — FAQ Storage Entry + +**Type:** E2E +**Tool:** Playwright + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | FAQ section | Contains storage question | +| b | Click expand | Answer mentions Starter upgrade + Pro overage pricing | + +--- + +### T-14: Storage Usage Calculation + +**Type:** Unit +**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/StorageQuotaServiceTest.java` +**Method:** `testGetUsage_calculatesCorrectly()` + +**Scenarios:** + +| # | Setup | Expected | +|---|-------|----------| +| a | Club with 1 GB used, 5 GB limit | `{ usedBytes: 1073741824, limitBytes: 5368709120, percentage: 20.0 }` | +| b | Club with 0 bytes used | `{ usedBytes: 0, percentage: 0.0 }` | +| c | Club with exactly limit used | `{ percentage: 100.0 }` | + +--- + +### T-15: Quota Allows Upload Under Limit + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testCheckQuota_underLimit_noException()` + +**Scenarios:** + +| # | Current Usage | Limit | Upload Size | Expected | +|---|---------------|-------|-------------|----------| +| a | 1 GB | 5 GB | 100 MB | No exception | +| b | 4.9 GB | 5 GB | 50 MB | No exception (4.95 GB < 5 GB) | +| c | 0 | 5 GB | 5 GB | No exception (exactly at limit) | + +--- + +### T-16: Quota Rejects Upload Over Limit + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testCheckQuota_overLimit_throwsQuotaExceeded()` + +**Scenarios:** + +| # | Current Usage | Limit | Upload Size | Expected | +|---|---------------|-------|-------------|----------| +| a | 4.9 GB | 5 GB | 200 MB | `StorageQuotaExceededException` thrown | +| b | 5 GB | 5 GB | 1 byte | `StorageQuotaExceededException` thrown | +| c | 50 GB | 50 GB | 1 KB | `StorageQuotaExceededException` thrown | + +--- + +### T-17: Increment On Upload + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testIncrementUsage_addsBytes()` + +**Scenarios:** + +| # | Initial | Increment | Expected | +|---|---------|-----------|----------| +| a | 0 | 1048576 (1 MB) | 1048576 | +| b | 1000000 | 500000 | 1500000 | + +--- + +### T-18: Decrement On Delete + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testDecrementUsage_subtractsBytes()` + +**Scenarios:** + +| # | Initial | Decrement | Expected | +|---|---------|-----------|----------| +| a | 5000000 | 1000000 | 4000000 | +| b | 1048576 | 1048576 | 0 | + +--- + +### T-19: Decrement Floors at Zero + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testDecrementUsage_floorsAtZero()` + +**Scenarios:** + +| # | Initial | Decrement | Expected | +|---|---------|-----------|----------| +| a | 100 | 200 | 0 (not negative) | +| b | 0 | 1000 | 0 | + +--- + +### T-20: Tier Limit Mapping + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testGetLimitForTier()` + +**Scenarios:** + +| # | Tier | Expected Limit | +|---|------|---------------| +| a | TRIAL | 5 GB (5368709120) | +| b | STARTER | 5 GB (5368709120) | +| c | PRO | 50 GB (53687091200) | +| d | ENTERPRISE | Long.MAX_VALUE | + +--- + +### T-21: Near-Limit Detection 80% + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testIsNearLimit_at80Percent()` + +**Scenarios:** + +| # | Usage | Limit | Threshold | Expected | +|---|-------|-------|-----------|----------| +| a | 4.0 GB | 5 GB | 80% | true (80%) | +| b | 3.9 GB | 5 GB | 80% | false (78%) | +| c | 4.1 GB | 5 GB | 80% | true (82%) | + +--- + +### T-22: Near-Limit Detection 95% + +**Type:** Unit +**Class:** `StorageQuotaServiceTest` +**Method:** `testIsNearLimit_at95Percent()` + +**Scenarios:** + +| # | Usage | Limit | Threshold | Expected | +|---|-------|-------|-----------|----------| +| a | 4.75 GB | 5 GB | 95% | true (95%) | +| b | 4.7 GB | 5 GB | 95% | false (94%) | + +--- + +### T-23: Storage Endpoint — Authenticated + +**Type:** Integration +**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/StorageControllerTest.java` +**Method:** `testGetUsage_authenticated_returns200()` + +**Preconditions:** +- Test user with ADMIN role and known clubId +- Club has pre-set `storageUsedBytes` and `storageLimitBytes` + +**Scenarios:** + +| # | Auth | Expected | +|---|------|----------| +| a | Valid ADMIN JWT | 200 with `{ usedBytes, limitBytes, percentage }` | +| b | Valid STAFF JWT | 200 with correct response | + +--- + +### T-24: Storage Endpoint — Unauthenticated + +**Type:** Integration +**Class:** `StorageControllerTest` +**Method:** `testGetUsage_unauthenticated_returns401()` + +**Scenarios:** + +| # | Auth | Expected | +|---|------|----------| +| a | No token | 401 Unauthorized | +| b | Expired token | 401 Unauthorized | +| c | MEMBER role | 403 Forbidden | + +--- + +### T-25: Storage Endpoint — DTO Shape + +**Type:** Integration +**Class:** `StorageControllerTest` +**Method:** `testGetUsage_responseShape()` + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | JSON keys | Response contains `usedBytes`, `limitBytes`, `percentage` | +| b | Types | `usedBytes` and `limitBytes` are numbers, `percentage` is double | +| c | Percentage calculation | Matches `usedBytes / limitBytes * 100` | + +--- + +### T-26: Document Upload — Quota Check Integrated + +**Type:** Integration +**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java` +**Method:** `testUploadDocument_checksQuotaBeforeWrite()` + +**Scenarios:** + +| # | Setup | Expected | +|---|-------|----------| +| a | Club under quota, upload 1 MB | Upload succeeds, `storageUsedBytes` incremented by file size | +| b | Club at quota limit | Upload rejected with `StorageQuotaExceededException` before file write | + +--- + +### T-27: Document Upload — 402 Response + +**Type:** Integration +**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/DocumentControllerTest.java` +**Method:** `testUploadDocument_quotaExceeded_returns402()` + +**Preconditions:** +- Club with `storageUsedBytes = storageLimitBytes` (fully used) + +**Scenarios:** + +| # | Action | Expected | +|---|--------|----------| +| a | POST multipart upload (1 byte file) | 402 Payment Required | +| b | Response body | RFC 9457 ProblemDetail with `currentUsage`, `limit`, `requestedBytes` | + +--- + +### T-28: Document Delete — Usage Decremented + +**Type:** Integration +**Class:** `DocumentServiceTest` +**Method:** `testDeleteDocument_decrementsUsage()` + +**Scenarios:** + +| # | Setup | Expected | +|---|-------|----------| +| a | Club with 5 MB used, delete 2 MB document | `storageUsedBytes` = 3 MB | +| b | Club with 1 MB used, delete 1 MB document | `storageUsedBytes` = 0 | + +--- + +### T-29: Flyway V36 — Migration Applies + +**Type:** Integration +**Tool:** Spring Boot test context startup + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | Application starts | V36 migration applies without error | +| b | Column exists | `SELECT storage_used_bytes FROM clubs LIMIT 1` succeeds | +| c | Default value | New clubs get `storage_used_bytes = 0` and `storage_limit_bytes = 5368709120` | + +--- + +### T-30: Flyway V36 — Backfill + +**Type:** Integration +**Tool:** SQL verification after migration + +**Preconditions:** +- Club exists with 3 documents (sizes: 1MB, 2MB, 3MB) + +**Scenarios:** + +| # | Check | Expected | +|---|-------|----------| +| a | After migration | `storage_used_bytes` = 6291456 (6 MB = sum of document sizes) | +| b | Club with no docs | `storage_used_bytes` = 0 | + +--- + +### T-31: StorageQuotaExceededException — 402 Format + +**Type:** Unit +**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/exception/GlobalExceptionHandlerTest.java` +**Method:** `testStorageQuotaExceeded_returns402WithProblemDetail()` + +**Scenarios:** + +| # | Input | Expected | +|---|-------|----------| +| a | `StorageQuotaExceededException(1GB, 5GB, 200MB)` | HTTP 402, title="Storage Quota Exceeded" | +| b | Response properties | Contains `currentUsage`, `limit`, `requestedBytes` numeric fields | + +--- + +### T-32: i18n Keys Resolve + +**Type:** Unit / Lint +**Tool:** next-intl compile check or custom script + +**Scenarios:** + +| # | Namespace | Expected | +|---|-----------|----------| +| a | `marketing.home.*` | All 20+ keys resolve in `de.json` | +| b | `marketing.home.*` | All 20+ keys resolve in `en.json` (English equivalents) | +| c | `marketing.pricing.faq.storage.*` | question + answer keys present in both locales | +| d | `marketing.pricing.plans.*.storage` | Storage labels present for each plan in both locales | + +--- + +## Test Data + +### Backend + +- **Test club:** UUID `00000000-0000-0000-0000-000000000001`, `storageUsedBytes = 1073741824` (1 GB), `storageLimitBytes = 5368709120` (5 GB) +- **Test documents:** 3 documents with `fileSize` = 100MB, 200MB, 773741824 bytes (total = 1 GB) +- **Full quota club:** UUID `00000000-0000-0000-0000-000000000002`, `storageUsedBytes = storageLimitBytes = 5368709120` + +### Frontend E2E + +- Landing page, pricing, login pages are public — no auth setup needed for T-01 through T-13 +- Login form tests (T-08, T-10) require running backend with test user `admin@gruener-daumen.de` / `TestAdmin123!` + +--- + +## Test Coverage + +| Component | Unit | Integration | E2E | Total | +|-----------|------|-------------|-----|-------| +| Landing page | 0 | 0 | 5 | 5 | +| Login redesign | 0 | 0 | 5 | 5 | +| Pricing update | 0 | 0 | 3 | 3 | +| StorageQuotaService | 9 | 0 | 0 | 9 | +| StorageController | 0 | 3 | 0 | 3 | +| DocumentService (quota) | 0 | 2 | 0 | 2 | +| DocumentController (402) | 0 | 1 | 0 | 1 | +| Flyway migration | 0 | 2 | 0 | 2 | +| Exception handling | 1 | 0 | 0 | 1 | +| i18n verification | 1 | 0 | 0 | 1 | +| **Total** | **11** | **8** | **13** | **32** |