- 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:
@@ -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) {}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user