// monorepo/cloud/backend/internal/maplefile/service/file/softdelete.go package file import ( "context" "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 SoftDeleteFileRequestDTO struct { FileID gocql.UUID `json:"file_id"` ForceHardDelete bool `json:"force_hard_delete"` // Skip tombstone for GDPR right-to-be-forgotten } type SoftDeleteFileResponseDTO struct { Success bool `json:"success"` Message string `json:"message"` ReleasedBytes int64 `json:"released_bytes"` // Amount of storage quota released } type SoftDeleteFileService interface { Execute(ctx context.Context, req *SoftDeleteFileRequestDTO) (*SoftDeleteFileResponseDTO, error) } type softDeleteFileServiceImpl struct { config *config.Configuration logger *zap.Logger collectionRepo dom_collection.CollectionRepository getMetadataUseCase uc_filemetadata.GetFileMetadataUseCase updateFileMetadataUseCase uc_filemetadata.UpdateFileMetadataUseCase softDeleteMetadataUseCase uc_filemetadata.SoftDeleteFileMetadataUseCase hardDeleteMetadataUseCase uc_filemetadata.HardDeleteFileMetadataUseCase deleteDataUseCase uc_fileobjectstorage.DeleteEncryptedDataUseCase listFilesByOwnerIDService ListFilesByOwnerIDService // Storage quota management storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase // Add storage usage tracking use cases createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase } func NewSoftDeleteFileService( config *config.Configuration, logger *zap.Logger, collectionRepo dom_collection.CollectionRepository, getMetadataUseCase uc_filemetadata.GetFileMetadataUseCase, updateFileMetadataUseCase uc_filemetadata.UpdateFileMetadataUseCase, softDeleteMetadataUseCase uc_filemetadata.SoftDeleteFileMetadataUseCase, hardDeleteMetadataUseCase uc_filemetadata.HardDeleteFileMetadataUseCase, deleteDataUseCase uc_fileobjectstorage.DeleteEncryptedDataUseCase, listFilesByOwnerIDService ListFilesByOwnerIDService, storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase, createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase, updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase, ) SoftDeleteFileService { logger = logger.Named("SoftDeleteFileService") return &softDeleteFileServiceImpl{ config: config, logger: logger, collectionRepo: collectionRepo, getMetadataUseCase: getMetadataUseCase, updateFileMetadataUseCase: updateFileMetadataUseCase, softDeleteMetadataUseCase: softDeleteMetadataUseCase, hardDeleteMetadataUseCase: hardDeleteMetadataUseCase, deleteDataUseCase: deleteDataUseCase, listFilesByOwnerIDService: listFilesByOwnerIDService, storageQuotaHelperUseCase: storageQuotaHelperUseCase, createStorageUsageEventUseCase: createStorageUsageEventUseCase, updateStorageUsageUseCase: updateStorageUsageUseCase, } } func (svc *softDeleteFileServiceImpl) Execute(ctx context.Context, req *SoftDeleteFileRequestDTO) (*SoftDeleteFileResponseDTO, error) { // // STEP 1: Validation // if req == nil { svc.logger.Warn("Failed validation with nil request") return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "File ID is 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 // 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)) svc.logger.Debug("Debugging started, will list all files that belong to the authenticated user") currentFiles, err := svc.listFilesByOwnerIDService.Execute(ctx, &ListFilesByOwnerIDRequestDTO{OwnerID: userID}) if err != nil { svc.logger.Error("Failed to list files by owner ID", zap.Any("error", err), zap.Any("user_id", userID)) return nil, err } for _, file := range currentFiles.Files { svc.logger.Debug("File", zap.Any("id", file.ID)) } return nil, err } // // STEP 4: Check if 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 deletion 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 delete this file") } // Check valid transitions. if err := dom_file.IsValidStateTransition(file.State, dom_file.FileStateDeleted); err != nil { svc.logger.Warn("Invalid file state transition", zap.Any("user_id", userID), zap.Error(err)) return nil, err } // // SAGA: Initialize distributed transaction manager // saga := transaction.NewSaga("soft-delete-file", svc.logger) // // STEP 5: Calculate storage space to be released // totalFileSize := file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes svc.logger.Info("Starting file soft-delete with SAGA protection", zap.String("file_id", req.FileID.String()), zap.Int64("file_size", file.EncryptedFileSizeInBytes), zap.Int64("thumbnail_size", file.EncryptedThumbnailSizeInBytes), zap.Int64("total_size_to_release", totalFileSize)) // // STEP 6: Update file metadata with tombstone (SAGA protected) // originalState := file.State originalTombstoneVersion := file.TombstoneVersion originalTombstoneExpiry := file.TombstoneExpiry file.State = dom_file.FileStateDeleted file.Version++ file.ModifiedAt = time.Now() file.ModifiedByUserID = userID file.TombstoneVersion = file.Version file.TombstoneExpiry = time.Now().Add(30 * 24 * time.Hour) if err := svc.updateFileMetadataUseCase.Execute(ctx, file); err != nil { svc.logger.Error("Failed to update file metadata with tombstone", zap.Error(err)) saga.Rollback(ctx) return nil, err } // Register compensation: restore original metadata fileIDCaptured := file.ID originalStateCaptured := originalState originalTombstoneVersionCaptured := originalTombstoneVersion originalTombstoneExpiryCaptured := originalTombstoneExpiry saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring file metadata", zap.String("file_id", fileIDCaptured.String())) restoredFile, err := svc.getMetadataUseCase.Execute(fileIDCaptured) if err != nil { return err } restoredFile.State = originalStateCaptured restoredFile.TombstoneVersion = originalTombstoneVersionCaptured restoredFile.TombstoneExpiry = originalTombstoneExpiryCaptured restoredFile.ModifiedAt = time.Now() return svc.updateFileMetadataUseCase.Execute(ctx, restoredFile) }) // // STEP 7: Delete file metadata record (SAGA protected) // if req.ForceHardDelete { // Hard delete - permanent removal for GDPR right-to-be-forgotten svc.logger.Info("Performing hard delete (GDPR mode) - no tombstone", zap.String("file_id", req.FileID.String())) err = svc.hardDeleteMetadataUseCase.Execute(req.FileID) if err != nil { svc.logger.Error("Failed to hard-delete file metadata", zap.Error(err)) saga.Rollback(ctx) // Restores tombstone metadata return nil, err } // No compensation for hard delete - GDPR compliance requires permanent deletion } else { // Soft delete - 30-day tombstone (standard deletion) err = svc.softDeleteMetadataUseCase.Execute(req.FileID) if err != nil { svc.logger.Error("Failed to soft-delete file metadata", zap.Error(err)) saga.Rollback(ctx) // Restores tombstone metadata return nil, err } // Register compensation: restore metadata record to active state saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring file metadata record to active state", zap.String("file_id", fileIDCaptured.String())) // Get the soft-deleted file deletedFile, err := svc.getMetadataUseCase.Execute(fileIDCaptured) if err != nil { return err } // Restore to active state deletedFile.State = dom_file.FileStateActive deletedFile.ModifiedAt = time.Now() deletedFile.Version++ deletedFile.TombstoneVersion = 0 deletedFile.TombstoneExpiry = time.Time{} return svc.updateFileMetadataUseCase.Execute(ctx, deletedFile) }) } // // STEP 8: Update collection file count (SAGA protected) // if originalState == dom_file.FileStateActive { err = svc.collectionRepo.DecrementFileCount(ctx, file.CollectionID) if err != nil { svc.logger.Error("Failed to decrement file count for collection", zap.String("collection_id", file.CollectionID.String()), zap.Error(err)) saga.Rollback(ctx) return nil, err } // Register compensation: increment the count back collectionIDCaptured := file.CollectionID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring file count", zap.String("collection_id", collectionIDCaptured.String())) return svc.collectionRepo.IncrementFileCount(ctx, collectionIDCaptured) }) } // // STEP 9: Release storage quota (SAGA protected) // var releasedBytes int64 = 0 if originalState == dom_file.FileStateActive && totalFileSize > 0 { err = svc.storageQuotaHelperUseCase.OnFileDeleted(ctx, userID, totalFileSize) if err != nil { svc.logger.Error("Failed to release storage quota after file deletion", zap.Error(err)) saga.Rollback(ctx) // Restores metadata + tombstone return nil, err } // Register compensation: re-reserve the released quota totalFileSizeCaptured := totalFileSize userIDCaptured := userID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: re-reserving released storage quota", zap.Int64("size", totalFileSizeCaptured)) return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, totalFileSizeCaptured) }) releasedBytes = totalFileSize svc.logger.Info("Storage quota released successfully", zap.Int64("released_bytes", releasedBytes)) // // STEP 10: Create storage usage event (SAGA protected) // err = svc.createStorageUsageEventUseCase.Execute(ctx, file.OwnerID, totalFileSize, "remove") if err != nil { svc.logger.Error("Failed to create storage usage event for deletion", zap.Error(err)) saga.Rollback(ctx) // Restores quota + metadata return nil, err } // Register compensation: create compensating "add" event ownerIDCaptured := file.OwnerID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: creating compensating usage event") return svc.createStorageUsageEventUseCase.Execute(ctx, ownerIDCaptured, totalFileSizeCaptured, "add") }) // // STEP 11: Update daily storage usage (SAGA protected) // today := time.Now().Truncate(24 * time.Hour) updateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{ UserID: file.OwnerID, UsageDay: &today, TotalBytes: -totalFileSize, AddBytes: 0, RemoveBytes: totalFileSize, IsIncrement: true, } err = svc.updateStorageUsageUseCase.Execute(ctx, updateReq) if err != nil { svc.logger.Error("Failed to update daily storage usage for deletion", zap.Error(err)) saga.Rollback(ctx) // Restores everything 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: totalFileSizeCaptured, // Positive to reverse AddBytes: totalFileSizeCaptured, RemoveBytes: 0, IsIncrement: true, } return svc.updateStorageUsageUseCase.Execute(ctx, compensateReq) }) } else if originalState == dom_file.FileStatePending { // For pending files, release the reserved quota (SAGA protected) err = svc.storageQuotaHelperUseCase.ReleaseQuota(ctx, userID, totalFileSize) if err != nil { svc.logger.Error("Failed to release reserved storage quota for pending file", zap.Error(err)) saga.Rollback(ctx) // Restores metadata + tombstone return nil, err } // Register compensation: re-reserve the released quota totalFileSizeCaptured := totalFileSize userIDCaptured := userID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: re-reserving pending file quota") return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, totalFileSizeCaptured) }) releasedBytes = totalFileSize svc.logger.Info("Reserved storage quota released for pending file", zap.Int64("released_bytes", releasedBytes)) } // // STEP 12: Delete S3 objects // var storagePaths []string storagePaths = append(storagePaths, file.EncryptedFileObjectKey) if file.EncryptedThumbnailObjectKey != "" { storagePaths = append(storagePaths, file.EncryptedThumbnailObjectKey) } svc.logger.Info("Deleting S3 objects for file", zap.String("file_id", req.FileID.String()), zap.Int("s3_objects_count", len(storagePaths))) for _, storagePath := range storagePaths { if err := svc.deleteDataUseCase.Execute(storagePath); err != nil { // Log but don't fail - S3 deletion is best effort after metadata is deleted svc.logger.Error("Failed to delete S3 object (continuing anyway)", zap.String("storage_path", storagePath), zap.Error(err)) } } // // SUCCESS: All operations completed with SAGA protection // svc.logger.Info("File deleted successfully with SAGA protection", zap.String("file_id", req.FileID.String()), zap.String("collection_id", file.CollectionID.String()), zap.Int64("released_bytes", releasedBytes), zap.Int("s3_objects_deleted", len(storagePaths))) return &SoftDeleteFileResponseDTO{ Success: true, Message: "File deleted successfully", ReleasedBytes: releasedBytes, }, nil }