feat: Sprint 14 — Marketing & Monetization
Deploy to TrueNAS / deploy (push) Failing after 33s

- 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:
Patrick Plate
2026-06-18 20:27:54 +02:00
parent 52d23053e7
commit dad798a904
24 changed files with 2485 additions and 212 deletions
@@ -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<StorageQuotaService.StorageUsageDTO> getUsage() {
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(storageQuotaService.getUsage(clubId));
}
}
@@ -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(
@@ -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
);