- 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
This commit is contained in:
@@ -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 `<md` breakpoints |
|
||||
| Quota calculation performance (SUM query) | Low | Medium (slow uploads) | Cache quota in `storage_used_bytes` column; recalculate on upload/delete |
|
||||
|
||||
---
|
||||
|
||||
## 5. Solution Options
|
||||
|
||||
### Option A: Full Sprint — All 4 Areas (Recommended)
|
||||
|
||||
- Landing page, login redesign, pricing update, storage quota backend
|
||||
- **Effort:** ~16-20 hours
|
||||
- **Pros:** Complete marketing+monetization story, enables public launch
|
||||
- **Cons:** Larger scope, more testing surface
|
||||
|
||||
### Option B: Frontend Only — Landing + Login + Pricing (No Backend)
|
||||
|
||||
- Skip StorageQuotaService, just update frontend
|
||||
- **Effort:** ~8-10 hours
|
||||
- **Pros:** Faster delivery, lower risk
|
||||
- **Cons:** Storage limits are marketing fiction without enforcement
|
||||
|
||||
### Option C: Backend Only — Storage Quota (No Marketing)
|
||||
|
||||
- Implement quota enforcement, defer marketing pages
|
||||
- **Effort:** ~6-8 hours
|
||||
- **Pros:** Real monetization enforcement
|
||||
- **Cons:** No user-facing marketing value, can't launch publicly
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommendation
|
||||
|
||||
**Option A** — the full sprint. The four areas are interdependent: the pricing page promises storage limits that the backend must enforce, and the landing page is the entry point that drives users to pricing. Login redesign is a low-risk polish pass that significantly improves first impressions.
|
||||
|
||||
The storage quota backend should be designed as an **incremental counter** (update `storage_used_bytes` on upload/delete) rather than a `SUM` query on every upload — this keeps upload latency constant regardless of document count.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions
|
||||
|
||||
- [ ] Should the landing page include a product screenshot/mockup, or is an illustration-based hero preferred?
|
||||
- [ ] For portal login left panel: show rotating testimonials, or static feature highlights?
|
||||
- [ ] Storage overage billing (€0.15/GB/mo for Pro) — is this just displayed in pricing, or should we build the actual billing integration now?
|
||||
- [ ] Free trial — is TRIAL tier (PlanTier enum already has it) time-limited? Should landing page mention trial duration?
|
||||
@@ -0,0 +1,124 @@
|
||||
# Code Review: Sprint 14 — Marketing & Monetization
|
||||
|
||||
**Datum:** 2026-06-18
|
||||
**Reviewer:** Roo (Reviewer)
|
||||
**Plan:** cannamanage-sprint14-plan.md v2
|
||||
**Testplan:** cannamanage-sprint14-testplan.md v2
|
||||
**Status:** ⚠️ Approved with comments
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Implementation is solid and complete for all 5 phases. All plan components are present, i18n is complete with full DE/EN parity, the backend quota enforcement chain is correctly wired end-to-end, and the frontend properly handles 402 responses. Two warnings identified — one security-relevant deviation from plan (StorageController auth pattern) and one missing planned component (SubscriptionService tier-change hook).
|
||||
|
||||
## Geprüfte Dateien
|
||||
|
||||
| Datei | Änderung | Bewertung |
|
||||
|-------|---------|-----------|
|
||||
| `cannamanage-frontend/src/app/(marketing)/page.tsx` | Neu | ✅ |
|
||||
| `cannamanage-frontend/src/app/(marketing)/marketing-layout-client.tsx` | Neu | ✅ |
|
||||
| `cannamanage-frontend/src/app/(marketing)/layout.tsx` | Geändert | ✅ |
|
||||
| `cannamanage-frontend/src/app/(auth)/layout.tsx` | Geändert | ✅ |
|
||||
| `cannamanage-frontend/src/app/(auth)/login/page.tsx` | Geändert | ✅ |
|
||||
| `cannamanage-frontend/src/app/(portal)/portal-login/page.tsx` | Geändert | ✅ |
|
||||
| `cannamanage-frontend/src/app/(marketing)/pricing/page.tsx` | Geändert | ✅ (not reviewed in full — storage keys verified) |
|
||||
| `cannamanage-frontend/messages/de.json` | Geändert | ✅ |
|
||||
| `cannamanage-frontend/messages/en.json` | Geändert | ✅ |
|
||||
| `cannamanage-frontend/src/services/storage.ts` | Neu | ✅ |
|
||||
| `cannamanage-frontend/src/services/documents.ts` | Geändert | ✅ |
|
||||
| `cannamanage-service/src/main/java/.../StorageQuotaService.java` | Neu | ✅ |
|
||||
| `cannamanage-service/src/main/java/.../StorageQuotaExceededException.java` | Neu | ✅ |
|
||||
| `cannamanage-api/src/main/java/.../StorageController.java` | Neu | ⚠️ |
|
||||
| `cannamanage-api/src/main/java/.../GlobalExceptionHandler.java` | Geändert | ✅ |
|
||||
| `cannamanage-api/src/main/resources/db/migration/V36__storage_quota.sql` | Neu | ✅ |
|
||||
| `cannamanage-domain/src/main/java/.../Club.java` | Geändert | ✅ |
|
||||
| `cannamanage-service/src/main/java/.../DocumentService.java` | Geändert | ✅ |
|
||||
|
||||
## Checkliste
|
||||
|
||||
| # | Prüfpunkt | Ergebnis | Anmerkung |
|
||||
|---|-----------|----------|-----------|
|
||||
| 1 | Plan-Konformität | ⚠️ | StorageController uses @RequestParam instead of JWT extraction (Step 4.5). SubscriptionService hook (Step 4.9) not implemented. |
|
||||
| 2 | Kein Extra-Scope | ✅ | No scope creep detected |
|
||||
| 3 | Bestehende Patterns korrekt | ✅ | Follows existing codebase conventions (constructor injection, @Slf4j, RFC 9457 ProblemDetail) |
|
||||
| 4 | i18n vollständig (de + en) | ✅ | 26 `marketing.home` keys, 10 `marketing.nav` keys, 16 `marketing.pricing.storage` keys, 10 `comparison` keys, FAQ storage — all present in both DE and EN with zero mismatches |
|
||||
| 5 | StorageQuotaExceededException separat | ✅ | Separate class with incompatible constructor (long, long, long). QuotaExceededException maps to 409, StorageQuotaExceededException maps to 402 — correct separation. |
|
||||
| 6 | V36 Migration korrekt | ✅ | Non-destructive (`ADD COLUMN IF NOT EXISTS`), correct default (5 GB = 5368709120), backfill via SUM(documents.file_size) |
|
||||
| 7 | Frontend responsive + dark/light | ✅ | Landing page uses responsive grid (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`), auth layouts use `hidden md:flex`, proper use of Tailwind dark mode utilities |
|
||||
| 8 | Error handling (402 on quota exceeded) | ✅ | Full chain: StorageQuotaExceededException → GlobalExceptionHandler 402 → documents.ts catches status 402 → throws typed error with problemDetail |
|
||||
|
||||
## Befunde
|
||||
|
||||
### ⚠️ WARNING-1: StorageController auth deviation from plan
|
||||
|
||||
**Datei:** [`StorageController.java`](cannamanage-api/src/main/java/de/cannamanage/api/controller/StorageController.java:29)
|
||||
|
||||
**Plan (Step 4.5)** specified:
|
||||
```java
|
||||
@GetMapping("/usage")
|
||||
public ResponseEntity<StorageUsageDTO> getUsage(@AuthenticationPrincipal UserDetails user) {
|
||||
UUID clubId = extractClubId(user);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Actual implementation:**
|
||||
```java
|
||||
@GetMapping("/usage")
|
||||
public ResponseEntity<StorageUsageDTO> 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
|
||||
@@ -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.
|
||||
@@ -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 <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
|
||||
@@ -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** |
|
||||
Reference in New Issue
Block a user