Files
cannamanage/docs/sprint-14/cannamanage-sprint14-plan.md
Patrick Plate dad798a904
Deploy to TrueNAS / deploy (push) Failing after 33s
feat: Sprint 14 — Marketing & Monetization
- 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
2026-06-18 20:28:35 +02:00

572 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <md) │ Right Panel (form) │
│ │ │
│ • App logo + name │ {children} │
│ • Tagline │ │
│ • 3 feature highlights │ │
│ • Background gradient │ │
│ │ │
└──────────────────────────────────────────────────┘
```
- Left panel: `hidden md:flex md:w-1/2 lg:w-[55%]` — dark gradient background with primary color accent
- Right panel: `w-full md:w-1/2 lg:w-[45%] flex items-center justify-center p-8`
- Left panel content: CannaManage logo, "Dein Verein, digital verwaltet" tagline, 3 bullet points with icons (Compliance, Mitglieder, Abgaben)
- Still wraps `{children}` in `NextIntlClientProvider`
#### Step 2.2 — Adjust admin login page
**File:** `cannamanage-frontend/src/app/(auth)/login/page.tsx`
- Remove the logo/branding section (now in layout's left panel)
- Keep only the form card itself: title "Anmelden", email, password, submit, forgot password, portal link
- The page becomes purely the form — layout handles the split
#### Step 2.3 — Create portal login with member theming
**File:** `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx`
Portal login gets its own split layout inline (since it's in a different route group):
- Left panel: member-focused messaging — "Willkommen zurück", "Dein persönlicher Bereich", icons for Abgabehistorie/Profil/Dokumente
- Right panel: login form (same structure as admin but portal-specific translations)
- Visual differentiation: left panel uses a slightly different gradient (e.g., emerald/teal tint vs. primary green)
- Full-page layout (no separate layout.tsx needed — inline the split)
---
### Phase 3: Pricing Page Update
#### Step 3.1 — Update pricing data model
**File:** `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx`
Update the `plans` array to include storage:
```typescript
const plans = [
{
id: "starter",
icon: Leaf,
price: "19",
memberLimit: "30",
storage: "5", // GB
features: [
"memberManagement",
"distributionTracking",
"complianceReports",
"quotaMonitoring",
"memberPortal",
"emailSupport",
],
},
{
id: "pro",
icon: Cannabis,
price: "49",
memberLimit: "100",
storage: "50", // GB
storageOverage: "0.15", // €/GB/month
popular: true,
features: [
"allStarter",
"growCalendar",
"staffManagement",
"advancedReports",
"pdfExport",
"apiAccess",
"prioritySupport",
],
},
{
id: "enterprise",
icon: Building2,
price: null,
memberLimit: "unlimited",
storage: "custom",
features: [
"allPro",
"unlimitedMembers",
"multiClub",
"customIntegrations",
"sla",
"dedicatedSupport",
"onboarding",
],
},
]
```
#### Step 3.2 — Render storage in plan cards
In each plan card, add a storage badge below the member limit:
- Starter: "5 GB Speicher"
- Pro: "50 GB Speicher" + small note "(danach 0,15 €/GB/Monat)"
- Enterprise: "Individueller Speicher"
#### Step 3.3 — Add storage row to feature comparison
Below the plan cards, add a comparison table section:
| Feature | Starter | Pro | Enterprise |
|---------|---------|-----|------------|
| Mitglieder | 30 | 100 | Unbegrenzt |
| Speicher | 5 GB | 50 GB | Individuell |
| Überschreitung | Upgrade erforderlich | 0,15 €/GB/Mo | — |
| Grow-Kalender | — | ✓ | ✓ |
| API-Zugang | — | ✓ | ✓ |
| Multi-Club | — | — | ✓ |
#### Step 3.4 — Add FAQ entry about storage
Add to the `faqs` array:
```typescript
{ id: "storage" } // "Was passiert wenn mein Speicherplatz voll ist?"
```
i18n keys:
- `marketing.pricing.faq.storage.question`: "Was passiert, wenn mein Speicher voll ist?"
- `marketing.pricing.faq.storage.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."
---
### Phase 4: Storage Quota Backend
#### Step 4.1 — Flyway migration V36
**File:** `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql`
```sql
-- Add storage 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
);
```
#### Step 4.2 — Update Club entity
**File:** `cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Club.java`
Add fields:
```java
@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
```
With getters/setters.
#### Step 4.3 — Create StorageQuotaService
**File:** `cannamanage-service/src/main/java/de/cannamanage/service/StorageQuotaService.java`
```java
@Service
@Slf4j
public class StorageQuotaService {
private final ClubRepository clubRepository;
// 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
public StorageUsageDTO getUsage(UUID clubId) {
Club club = clubRepository.findById(clubId).orElseThrow();
long used = club.getStorageUsedBytes();
long limit = club.getStorageLimitBytes();
double percentage = limit > 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<StorageUsageDTO> 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<ProblemDetail> 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<StorageUsage> {
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