monorepo/cloud/maplefile-backend/internal/service/file/complete_file_upload.go

442 lines
18 KiB
Go

// monorepo/cloud/backend/internal/maplefile/service/file/complete_file_upload.go
package file
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"github.com/gocql/gocql"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
dom_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/collection"
dom_file "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/file"
uc_filemetadata "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/filemetadata"
uc_fileobjectstorage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/fileobjectstorage"
uc_storagedailyusage "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storagedailyusage"
uc_storageusageevent "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/storageusageevent"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction"
)
type CompleteFileUploadRequestDTO struct {
FileID gocql.UUID `json:"file_id"`
// Optional: Client can provide actual file size for validation
ActualFileSizeInBytes int64 `json:"actual_file_size_in_bytes,omitempty"`
// Optional: Client can provide actual thumbnail size for validation
ActualThumbnailSizeInBytes int64 `json:"actual_thumbnail_size_in_bytes,omitempty"`
// Optional: Client can confirm successful upload
UploadConfirmed bool `json:"upload_confirmed,omitempty"`
ThumbnailUploadConfirmed bool `json:"thumbnail_upload_confirmed,omitempty"`
}
type CompleteFileUploadResponseDTO struct {
File *FileResponseDTO `json:"file"`
Success bool `json:"success"`
Message string `json:"message"`
ActualFileSize int64 `json:"actual_file_size"`
ActualThumbnailSize int64 `json:"actual_thumbnail_size"`
UploadVerified bool `json:"upload_verified"`
ThumbnailVerified bool `json:"thumbnail_verified"`
StorageAdjustment int64 `json:"storage_adjustment"` // Positive if more space used, negative if less
}
type CompleteFileUploadService interface {
Execute(ctx context.Context, req *CompleteFileUploadRequestDTO) (*CompleteFileUploadResponseDTO, error)
}
type completeFileUploadServiceImpl struct {
config *config.Configuration
logger *zap.Logger
collectionRepo dom_collection.CollectionRepository
getMetadataUseCase uc_filemetadata.GetFileMetadataUseCase
updateMetadataUseCase uc_filemetadata.UpdateFileMetadataUseCase
verifyObjectExistsUseCase uc_fileobjectstorage.VerifyObjectExistsUseCase
getObjectSizeUseCase uc_fileobjectstorage.GetObjectSizeUseCase
deleteDataUseCase uc_fileobjectstorage.DeleteEncryptedDataUseCase
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase
// Add storage usage tracking use cases
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase
}
func NewCompleteFileUploadService(
config *config.Configuration,
logger *zap.Logger,
collectionRepo dom_collection.CollectionRepository,
getMetadataUseCase uc_filemetadata.GetFileMetadataUseCase,
updateMetadataUseCase uc_filemetadata.UpdateFileMetadataUseCase,
verifyObjectExistsUseCase uc_fileobjectstorage.VerifyObjectExistsUseCase,
getObjectSizeUseCase uc_fileobjectstorage.GetObjectSizeUseCase,
deleteDataUseCase uc_fileobjectstorage.DeleteEncryptedDataUseCase,
storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase,
createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase,
updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase,
) CompleteFileUploadService {
logger = logger.Named("CompleteFileUploadService")
return &completeFileUploadServiceImpl{
config: config,
logger: logger,
collectionRepo: collectionRepo,
getMetadataUseCase: getMetadataUseCase,
updateMetadataUseCase: updateMetadataUseCase,
verifyObjectExistsUseCase: verifyObjectExistsUseCase,
getObjectSizeUseCase: getObjectSizeUseCase,
deleteDataUseCase: deleteDataUseCase,
storageQuotaHelperUseCase: storageQuotaHelperUseCase,
createStorageUsageEventUseCase: createStorageUsageEventUseCase,
updateStorageUsageUseCase: updateStorageUsageUseCase,
}
}
func (svc *completeFileUploadServiceImpl) Execute(ctx context.Context, req *CompleteFileUploadRequestDTO) (*CompleteFileUploadResponseDTO, error) {
//
// STEP 1: Validation
//
if req == nil {
svc.logger.Warn("⚠️ Failed validation with nil request")
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "File completion details are required")
}
if req.FileID.String() == "" {
svc.logger.Warn("⚠️ Empty file ID provided")
return nil, httperror.NewForBadRequestWithSingleField("file_id", "File ID is required")
}
//
// STEP 2: Get user ID from context
//
userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID)
if !ok {
svc.logger.Error("🔴 Failed getting user ID from context")
return nil, httperror.NewForInternalServerErrorWithSingleField("message", "Authentication context error")
}
//
// STEP 3: Get file metadata
//
// Developers note: Use `ExecuteWithAnyState` because initially created `FileMetadata` object has state set to `pending`.
file, err := svc.getMetadataUseCase.Execute(req.FileID)
if err != nil {
svc.logger.Error("🔴 Failed to get file metadata",
zap.Any("error", err),
zap.Any("file_id", req.FileID))
return nil, err
}
//
// STEP 4: Verify user has write access to the file's collection
//
hasAccess, err := svc.collectionRepo.CheckAccess(ctx, file.CollectionID, userID, dom_collection.CollectionPermissionReadWrite)
if err != nil {
svc.logger.Error("🔴 Failed to check collection access",
zap.Any("error", err),
zap.Any("collection_id", file.CollectionID),
zap.Any("user_id", userID))
return nil, err
}
if !hasAccess {
svc.logger.Warn("⚠️ Unauthorized file completion attempt",
zap.Any("user_id", userID),
zap.Any("file_id", req.FileID),
zap.Any("collection_id", file.CollectionID))
return nil, httperror.NewForForbiddenWithSingleField("message", "You don't have permission to complete this file upload")
}
//
// STEP 5: Verify file is in pending state
//
if file.State != dom_file.FileStatePending {
svc.logger.Warn("⚠️ File is not in pending state",
zap.Any("file_id", req.FileID),
zap.String("current_state", file.State))
return nil, httperror.NewForBadRequestWithSingleField("file_id", fmt.Sprintf("File is not in pending state (current state: %s)", file.State))
}
//
// STEP 6: Verify file exists in object storage and get actual size
//
fileExists, err := svc.verifyObjectExistsUseCase.Execute(file.EncryptedFileObjectKey)
if err != nil {
svc.logger.Error("🔴 Failed to verify file exists in storage",
zap.Any("error", err),
zap.Any("file_id", req.FileID),
zap.String("storage_path", file.EncryptedFileObjectKey))
return nil, httperror.NewForInternalServerErrorWithSingleField("message", "Failed to verify file upload")
}
if !fileExists {
svc.logger.Warn("⚠️ File does not exist in storage",
zap.Any("file_id", req.FileID),
zap.String("storage_path", file.EncryptedFileObjectKey))
return nil, httperror.NewForBadRequestWithSingleField("file_id", "File has not been uploaded yet")
}
// Get actual file size from storage
actualFileSize, err := svc.getObjectSizeUseCase.Execute(file.EncryptedFileObjectKey)
if err != nil {
svc.logger.Error("🔴 Failed to get file size from storage",
zap.Any("error", err),
zap.Any("file_id", req.FileID),
zap.String("storage_path", file.EncryptedFileObjectKey))
return nil, httperror.NewForInternalServerErrorWithSingleField("message", "Failed to verify file size")
}
//
// STEP 7: Verify thumbnail if expected
//
var actualThumbnailSize int64 = 0
var thumbnailVerified bool = true
if file.EncryptedThumbnailObjectKey != "" {
thumbnailExists, err := svc.verifyObjectExistsUseCase.Execute(file.EncryptedThumbnailObjectKey)
if err != nil {
svc.logger.Warn("⚠️ Failed to verify thumbnail exists, continuing without it",
zap.Any("error", err),
zap.Any("file_id", req.FileID),
zap.String("thumbnail_storage_path", file.EncryptedThumbnailObjectKey))
thumbnailVerified = false
} else if thumbnailExists {
actualThumbnailSize, err = svc.getObjectSizeUseCase.Execute(file.EncryptedThumbnailObjectKey)
if err != nil {
svc.logger.Warn("⚠️ Failed to get thumbnail size, continuing without it",
zap.Any("error", err),
zap.Any("file_id", req.FileID),
zap.String("thumbnail_storage_path", file.EncryptedThumbnailObjectKey))
thumbnailVerified = false
}
} else {
// Thumbnail was expected but not uploaded - clear the path
file.EncryptedThumbnailObjectKey = ""
thumbnailVerified = false
}
}
//
// SAGA: Initialize distributed transaction manager
//
saga := transaction.NewSaga("complete-file-upload", svc.logger)
//
// STEP 8: Calculate storage adjustment and update quota
//
expectedTotalSize := file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes
actualTotalSize := actualFileSize + actualThumbnailSize
storageAdjustment := actualTotalSize - expectedTotalSize
svc.logger.Info("Starting file upload completion with SAGA protection",
zap.String("file_id", req.FileID.String()),
zap.Int64("expected_file_size", file.EncryptedFileSizeInBytes),
zap.Int64("actual_file_size", actualFileSize),
zap.Int64("expected_thumbnail_size", file.EncryptedThumbnailSizeInBytes),
zap.Int64("actual_thumbnail_size", actualThumbnailSize),
zap.Int64("expected_total", expectedTotalSize),
zap.Int64("actual_total", actualTotalSize),
zap.Int64("adjustment", storageAdjustment))
// Handle storage quota adjustment (SAGA protected)
if storageAdjustment != 0 {
if storageAdjustment > 0 {
// Need more quota than originally reserved
err = svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userID, storageAdjustment)
if err != nil {
svc.logger.Error("Failed to reserve additional storage quota",
zap.String("user_id", userID.String()),
zap.Int64("additional_size", storageAdjustment),
zap.Error(err))
// Clean up the uploaded file since we can't complete due to quota
// Note: This is an exceptional case - quota exceeded before any SAGA operations
if deleteErr := svc.deleteDataUseCase.Execute(file.EncryptedFileObjectKey); deleteErr != nil {
svc.logger.Error("Failed to clean up file after quota exceeded", zap.Error(deleteErr))
}
if file.EncryptedThumbnailObjectKey != "" {
if deleteErr := svc.deleteDataUseCase.Execute(file.EncryptedThumbnailObjectKey); deleteErr != nil {
svc.logger.Error("Failed to clean up thumbnail after quota exceeded", zap.Error(deleteErr))
}
}
saga.Rollback(ctx)
return nil, err
}
// Register compensation: release the additional quota if later steps fail
storageAdjustmentCaptured := storageAdjustment
userIDCaptured := userID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: releasing additional reserved quota",
zap.Int64("size", storageAdjustmentCaptured))
return svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userIDCaptured, storageAdjustmentCaptured)
})
} else {
// Used less quota than originally reserved, release the difference
err = svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, -storageAdjustment)
if err != nil {
svc.logger.Error("Failed to release excess quota",
zap.String("user_id", userID.String()),
zap.Int64("excess_size", -storageAdjustment),
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: re-reserve the released quota if later steps fail
excessQuotaCaptured := -storageAdjustment
userIDCaptured := userID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: re-reserving released excess quota",
zap.Int64("size", excessQuotaCaptured))
return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, excessQuotaCaptured)
})
}
}
//
// STEP 9: Validate file size if client provided it
//
if req.ActualFileSizeInBytes > 0 && req.ActualFileSizeInBytes != actualFileSize {
svc.logger.Warn("⚠️ File size mismatch between client and storage",
zap.Any("file_id", req.FileID),
zap.Int64("client_reported_size", req.ActualFileSizeInBytes),
zap.Int64("storage_actual_size", actualFileSize))
// Continue with storage size as authoritative
}
//
// STEP 10: Update file metadata to active state (SAGA protected)
//
originalState := file.State
originalFileSizeInBytes := file.EncryptedFileSizeInBytes
originalThumbnailSizeInBytes := file.EncryptedThumbnailSizeInBytes
originalVersion := file.Version
file.EncryptedFileSizeInBytes = actualFileSize
file.EncryptedThumbnailSizeInBytes = actualThumbnailSize
file.State = dom_file.FileStateActive
file.ModifiedAt = time.Now()
file.ModifiedByUserID = userID
file.Version++ // Every mutation we need to keep a track of.
err = svc.updateMetadataUseCase.Execute(ctx, file)
if err != nil {
svc.logger.Error("Failed to update file metadata to active state",
zap.Error(err),
zap.String("file_id", req.FileID.String()))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: restore original metadata state
fileIDCaptured := file.ID
originalStateCaptured := originalState
originalFileSizeCaptured := originalFileSizeInBytes
originalThumbnailSizeCaptured := originalThumbnailSizeInBytes
originalVersionCaptured := originalVersion
collectionIDCaptured := file.CollectionID
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: restoring file metadata to pending state",
zap.String("file_id", fileIDCaptured.String()))
restoredFile, err := svc.getMetadataUseCase.Execute(fileIDCaptured)
if err != nil {
return err
}
restoredFile.State = originalStateCaptured
restoredFile.EncryptedFileSizeInBytes = originalFileSizeCaptured
restoredFile.EncryptedThumbnailSizeInBytes = originalThumbnailSizeCaptured
restoredFile.Version = originalVersionCaptured
restoredFile.ModifiedAt = time.Now()
// Note: The repository Update method handles file count adjustments based on state changes,
// so restoring to pending state will automatically decrement the file count
return svc.updateMetadataUseCase.Execute(ctx, restoredFile)
})
// Note: File count increment is handled by the repository's Update method when state changes
// from pending to active. No explicit increment needed here to avoid double counting.
//
// STEP 11: Create storage usage event (SAGA protected)
//
_ = collectionIDCaptured // Keep variable for potential future use
err = svc.createStorageUsageEventUseCase.Execute(ctx, file.OwnerID, actualTotalSize, "add")
if err != nil {
svc.logger.Error("Failed to create storage usage event",
zap.String("owner_id", file.OwnerID.String()),
zap.Int64("file_size", actualTotalSize),
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: create compensating "remove" event
ownerIDCaptured := file.OwnerID
actualTotalSizeCaptured := actualTotalSize
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: creating compensating usage event")
return svc.createStorageUsageEventUseCase.Execute(ctx, ownerIDCaptured, actualTotalSizeCaptured, "remove")
})
//
// STEP 13: Update daily storage usage (SAGA protected)
//
today := time.Now().Truncate(24 * time.Hour)
updateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{
UserID: file.OwnerID,
UsageDay: &today,
TotalBytes: actualTotalSize,
AddBytes: actualTotalSize,
RemoveBytes: 0,
IsIncrement: true, // Increment the existing values
}
err = svc.updateStorageUsageUseCase.Execute(ctx, updateReq)
if err != nil {
svc.logger.Error("Failed to update daily storage usage",
zap.String("owner_id", file.OwnerID.String()),
zap.Int64("file_size", actualTotalSize),
zap.Error(err))
saga.Rollback(ctx)
return nil, err
}
// Register compensation: reverse the usage update
saga.AddCompensation(func(ctx context.Context) error {
svc.logger.Warn("SAGA compensation: reversing daily usage update")
compensateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{
UserID: ownerIDCaptured,
UsageDay: &today,
TotalBytes: -actualTotalSizeCaptured, // Negative to reverse
AddBytes: 0,
RemoveBytes: actualTotalSizeCaptured,
IsIncrement: true,
}
return svc.updateStorageUsageUseCase.Execute(ctx, compensateReq)
})
//
// SUCCESS: All operations completed with SAGA protection
//
svc.logger.Info("File upload completed successfully with SAGA protection",
zap.String("file_id", req.FileID.String()),
zap.String("collection_id", file.CollectionID.String()),
zap.String("owner_id", file.OwnerID.String()),
zap.Int64("actual_file_size", actualFileSize),
zap.Int64("actual_thumbnail_size", actualThumbnailSize),
zap.Int64("storage_adjustment", storageAdjustment))
return &CompleteFileUploadResponseDTO{
File: mapFileToDTO(file),
Success: true,
Message: "File upload completed successfully with storage quota updated",
ActualFileSize: actualFileSize,
ActualThumbnailSize: actualThumbnailSize,
UploadVerified: true,
ThumbnailVerified: thumbnailVerified,
StorageAdjustment: storageAdjustment,
}, nil
}