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
@@ -36,16 +36,22 @@ public class DocumentService {
private final DocumentRepository documentRepository;
private final AuditService auditService;
private final StorageQuotaService storageQuotaService;
public DocumentService(DocumentRepository documentRepository, AuditService auditService) {
public DocumentService(DocumentRepository documentRepository, AuditService auditService,
StorageQuotaService storageQuotaService) {
this.documentRepository = documentRepository;
this.auditService = auditService;
this.storageQuotaService = storageQuotaService;
}
@Transactional
public Document uploadDocument(UUID clubId, String title, DocumentCategory category,
DocumentAccessLevel accessLevel, String description,
MultipartFile file, UUID uploadedBy) throws IOException {
// Check storage quota before upload
storageQuotaService.checkQuota(clubId, file.getSize());
// Validate file
if (file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
@@ -88,6 +94,9 @@ public class DocumentService {
Document saved = documentRepository.save(doc);
// Increment storage usage counter after successful save
storageQuotaService.incrementUsage(clubId, file.getSize());
auditService.log(AuditEventType.DOCUMENT_UPLOADED, uploadedBy, clubId,
"Document uploaded: " + title + " (" + category + ")");
@@ -133,6 +142,9 @@ public class DocumentService {
// Delete DB record
documentRepository.delete(doc);
// Decrement storage usage counter after successful delete
storageQuotaService.decrementUsage(clubId, doc.getFileSize());
auditService.log(AuditEventType.DOCUMENT_DELETED, deletedBy, clubId,
"Document deleted: " + doc.getTitle());
@@ -0,0 +1,121 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.service.exception.StorageQuotaExceededException;
import de.cannamanage.service.repository.ClubRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* Manages storage quota enforcement for clubs.
* Each club has a storage_limit_bytes based on their subscription tier
* and a storage_used_bytes counter tracking actual usage.
*/
@Slf4j
@Service
public class StorageQuotaService {
// Plan tier limits
private static final long STARTER_LIMIT = 5L * 1024 * 1024 * 1024; // 5 GB
private static final long PRO_LIMIT = 50L * 1024 * 1024 * 1024; // 50 GB
private static final long ENTERPRISE_LIMIT = Long.MAX_VALUE; // Unlimited
private final ClubRepository clubRepository;
public StorageQuotaService(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
/**
* Get current storage usage for a club.
*/
public StorageUsageDTO getUsage(UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
long used = club.getStorageUsedBytes();
long limit = club.getStorageLimitBytes();
double percentage = limit > 0 ? (double) used / limit * 100 : 0;
return new StorageUsageDTO(used, limit, percentage);
}
/**
* Check if uploading additionalBytes would exceed the club's storage limit.
* Throws StorageQuotaExceededException if it would.
*/
public void checkQuota(UUID clubId, long additionalBytes) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
long newTotal = club.getStorageUsedBytes() + additionalBytes;
if (newTotal > club.getStorageLimitBytes()) {
throw new StorageQuotaExceededException(
club.getStorageUsedBytes(), club.getStorageLimitBytes(), additionalBytes);
}
}
/**
* Increment the club's storage usage counter after a successful upload.
*/
@Transactional
public void incrementUsage(UUID clubId, long bytes) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
club.setStorageUsedBytes(club.getStorageUsedBytes() + bytes);
clubRepository.save(club);
log.debug("Club {} storage incremented by {} bytes (total: {})", clubId, bytes, club.getStorageUsedBytes());
}
/**
* Decrement the club's storage usage counter after a successful delete.
*/
@Transactional
public void decrementUsage(UUID clubId, long bytes) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
long newUsage = Math.max(0, club.getStorageUsedBytes() - bytes);
club.setStorageUsedBytes(newUsage);
clubRepository.save(club);
log.debug("Club {} storage decremented by {} bytes (total: {})", clubId, bytes, newUsage);
}
/**
* Get the storage limit in bytes for a given plan tier name.
*/
public static long getLimitForTier(String tier) {
return switch (tier.toLowerCase()) {
case "starter", "trial" -> STARTER_LIMIT;
case "pro" -> PRO_LIMIT;
case "enterprise" -> ENTERPRISE_LIMIT;
default -> STARTER_LIMIT;
};
}
/**
* Called when a club's subscription tier changes.
* Updates storage_limit_bytes to match the new tier.
*/
@Transactional
public void onTierChange(UUID clubId, String newTier) {
long newLimit = getLimitForTier(newTier);
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new IllegalArgumentException("Club not found: " + clubId));
club.setStorageLimitBytes(newLimit);
clubRepository.save(club);
log.info("Club {} tier changed to '{}' — storage limit updated to {} bytes", clubId, newTier, newLimit);
}
/**
* Check if a club is at or above a given usage threshold percentage.
*/
public boolean isNearLimit(UUID clubId, int thresholdPercent) {
StorageUsageDTO usage = getUsage(clubId);
return usage.percentage() >= thresholdPercent;
}
/**
* DTO for storage usage response.
*/
public record StorageUsageDTO(long usedBytes, long limitBytes, double percentage) {}
}
@@ -0,0 +1,33 @@
package de.cannamanage.service.exception;
/**
* Thrown when a document upload would exceed the club's storage quota.
* Maps to HTTP 402 Payment Required — distinct from QuotaExceededException
* which handles CanG distribution quotas (25g/day, 50g/month).
*/
public class StorageQuotaExceededException extends RuntimeException {
private final long currentUsage;
private final long limit;
private final long requestedBytes;
public StorageQuotaExceededException(long currentUsage, long limit, long requestedBytes) {
super("Storage quota exceeded: current=%d, limit=%d, requested=%d"
.formatted(currentUsage, limit, requestedBytes));
this.currentUsage = currentUsage;
this.limit = limit;
this.requestedBytes = requestedBytes;
}
public long getCurrentUsage() {
return currentUsage;
}
public long getLimit() {
return limit;
}
public long getRequestedBytes() {
return requestedBytes;
}
}