// monorepo/cloud/backend/internal/maplefile/service/collection/softdelete.go package collection import ( "context" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants" 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_collection "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/collection" 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" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/transaction" "github.com/gocql/gocql" ) type SoftDeleteCollectionRequestDTO struct { ID gocql.UUID `json:"id"` ForceHardDelete bool `json:"force_hard_delete"` // Skip tombstone for GDPR right-to-be-forgotten } type SoftDeleteCollectionResponseDTO struct { Success bool `json:"success"` Message string `json:"message"` } type SoftDeleteCollectionService interface { Execute(ctx context.Context, req *SoftDeleteCollectionRequestDTO) (*SoftDeleteCollectionResponseDTO, error) } type softDeleteCollectionServiceImpl struct { config *config.Configuration logger *zap.Logger repo dom_collection.CollectionRepository fileRepo dom_file.FileMetadataRepository getCollectionUseCase uc_collection.GetCollectionUseCase updateCollectionUseCase uc_collection.UpdateCollectionUseCase hardDeleteCollectionUseCase uc_collection.HardDeleteCollectionUseCase deleteMultipleDataUseCase uc_fileobjectstorage.DeleteMultipleEncryptedDataUseCase // Storage quota management storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase } func NewSoftDeleteCollectionService( config *config.Configuration, logger *zap.Logger, repo dom_collection.CollectionRepository, fileRepo dom_file.FileMetadataRepository, getCollectionUseCase uc_collection.GetCollectionUseCase, updateCollectionUseCase uc_collection.UpdateCollectionUseCase, hardDeleteCollectionUseCase uc_collection.HardDeleteCollectionUseCase, deleteMultipleDataUseCase uc_fileobjectstorage.DeleteMultipleEncryptedDataUseCase, storageQuotaHelperUseCase uc_user.UserStorageQuotaHelperUseCase, createStorageUsageEventUseCase uc_storageusageevent.CreateStorageUsageEventUseCase, updateStorageUsageUseCase uc_storagedailyusage.UpdateStorageUsageUseCase, ) SoftDeleteCollectionService { logger = logger.Named("SoftDeleteCollectionService") return &softDeleteCollectionServiceImpl{ config: config, logger: logger, repo: repo, fileRepo: fileRepo, getCollectionUseCase: getCollectionUseCase, updateCollectionUseCase: updateCollectionUseCase, hardDeleteCollectionUseCase: hardDeleteCollectionUseCase, deleteMultipleDataUseCase: deleteMultipleDataUseCase, storageQuotaHelperUseCase: storageQuotaHelperUseCase, createStorageUsageEventUseCase: createStorageUsageEventUseCase, updateStorageUsageUseCase: updateStorageUsageUseCase, } } func (svc *softDeleteCollectionServiceImpl) Execute(ctx context.Context, req *SoftDeleteCollectionRequestDTO) (*SoftDeleteCollectionResponseDTO, error) { // // STEP 1: Validation // if req == nil { svc.logger.Warn("Failed validation with nil request") return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Collection ID is required") } if req.ID.String() == "" { svc.logger.Warn("Empty collection ID") return nil, httperror.NewForBadRequestWithSingleField("id", "Collection 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: Retrieve related records // collection, err := svc.getCollectionUseCase.Execute(ctx, req.ID) if err != nil { svc.logger.Error("Failed to get collection", zap.Any("error", err), zap.Any("collection_id", req.ID)) return nil, err } if collection == nil { svc.logger.Debug("Collection not found", zap.Any("collection_id", req.ID)) return nil, httperror.NewForNotFoundWithSingleField("message", "Collection not found") } // // STEP 4: Check if user has rights to delete this collection // if collection.OwnerID != userID { svc.logger.Warn("Unauthorized collection deletion attempt", zap.Any("user_id", userID), zap.Any("collection_id", req.ID)) return nil, httperror.NewForForbiddenWithSingleField("message", "Only the collection owner can delete a collection") } // Check valid transitions. if err := dom_collection.IsValidStateTransition(collection.State, dom_collection.CollectionStateDeleted); err != nil { svc.logger.Warn("Invalid collection state transition", zap.Any("user_id", userID), zap.Error(err)) return nil, err } svc.logger.Info("Starting soft delete of collection hierarchy", zap.String("collection_id", collection.ID.String()), zap.Int("member_count", len(collection.Members))) // // SAGA: Initialize distributed transaction manager // saga := transaction.NewSaga("soft-delete-collection", svc.logger) // // STEP 5: Find all descendant collections // descendants, err := svc.repo.FindDescendants(ctx, req.ID) if err != nil { svc.logger.Error("Failed to check for descendant collections", zap.Any("error", err), zap.Any("collection_id", req.ID)) return nil, err } svc.logger.Info("Found descendant collections for deletion", zap.Any("collection_id", req.ID), zap.Int("descendants_count", len(descendants))) // // STEP 6: Delete all files in the parent collection // parentFiles, err := svc.fileRepo.GetByCollection(req.ID) if err != nil { svc.logger.Error("Failed to get files for parent collection", zap.Any("error", err), zap.Any("collection_id", req.ID)) return nil, err } // Collect all S3 storage paths to delete and calculate total storage to release var allStoragePaths []string var totalStorageToRelease int64 = 0 if len(parentFiles) > 0 { parentFileIDs := make([]gocql.UUID, len(parentFiles)) for i, file := range parentFiles { parentFileIDs[i] = file.ID // Collect S3 paths for deletion allStoragePaths = append(allStoragePaths, file.EncryptedFileObjectKey) if file.EncryptedThumbnailObjectKey != "" { allStoragePaths = append(allStoragePaths, file.EncryptedThumbnailObjectKey) } // Calculate storage to release (only for active files) if file.State == dom_file.FileStateActive { totalStorageToRelease += file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes } } // Execute parent file deletion (hard or soft based on flag) if req.ForceHardDelete { svc.logger.Info("Hard deleting parent collection files (GDPR mode)", zap.Int("file_count", len(parentFileIDs))) if err := svc.fileRepo.HardDeleteMany(parentFileIDs); err != nil { svc.logger.Error("Failed to hard-delete files in parent collection", zap.Any("error", err), zap.Any("collection_id", req.ID), zap.Int("file_count", len(parentFileIDs))) saga.Rollback(ctx) return nil, err } // No compensation for hard delete - GDPR requires permanent deletion } else { if err := svc.fileRepo.SoftDeleteMany(parentFileIDs); err != nil { svc.logger.Error("Failed to soft-delete files in parent collection", zap.Any("error", err), zap.Any("collection_id", req.ID), zap.Int("file_count", len(parentFileIDs))) saga.Rollback(ctx) // Rollback any previous operations return nil, err } // SAGA: Register compensation for parent files deletion // IMPORTANT: Capture parentFileIDs by value for closure parentFileIDsCaptured := parentFileIDs saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring parent collection files", zap.String("collection_id", req.ID.String()), zap.Int("file_count", len(parentFileIDsCaptured))) return svc.fileRepo.RestoreMany(parentFileIDsCaptured) }) } svc.logger.Info("Deleted files in parent collection", zap.Any("collection_id", req.ID), zap.Int("file_count", len(parentFileIDs))) } // // STEP 7: Delete all files in descendant collections // totalDescendantFiles := 0 for _, descendant := range descendants { descendantFiles, err := svc.fileRepo.GetByCollection(descendant.ID) if err != nil { svc.logger.Error("Failed to get files for descendant collection", zap.Any("error", err), zap.Any("descendant_id", descendant.ID)) saga.Rollback(ctx) // Rollback all previous operations return nil, err } if len(descendantFiles) > 0 { descendantFileIDs := make([]gocql.UUID, len(descendantFiles)) for i, file := range descendantFiles { descendantFileIDs[i] = file.ID // Collect S3 paths for deletion allStoragePaths = append(allStoragePaths, file.EncryptedFileObjectKey) if file.EncryptedThumbnailObjectKey != "" { allStoragePaths = append(allStoragePaths, file.EncryptedThumbnailObjectKey) } // Calculate storage to release (only for active files) if file.State == dom_file.FileStateActive { totalStorageToRelease += file.EncryptedFileSizeInBytes + file.EncryptedThumbnailSizeInBytes } } // Execute descendant file deletion (hard or soft based on flag) if req.ForceHardDelete { if err := svc.fileRepo.HardDeleteMany(descendantFileIDs); err != nil { svc.logger.Error("Failed to hard-delete files in descendant collection", zap.Any("error", err), zap.Any("descendant_id", descendant.ID), zap.Int("file_count", len(descendantFileIDs))) saga.Rollback(ctx) return nil, err } // No compensation for hard delete - GDPR requires permanent deletion } else { if err := svc.fileRepo.SoftDeleteMany(descendantFileIDs); err != nil { svc.logger.Error("Failed to soft-delete files in descendant collection", zap.Any("error", err), zap.Any("descendant_id", descendant.ID), zap.Int("file_count", len(descendantFileIDs))) saga.Rollback(ctx) // Rollback all previous operations return nil, err } // SAGA: Register compensation for this batch of descendant files // IMPORTANT: Capture by value for closure descendantFileIDsCaptured := descendantFileIDs descendantIDCaptured := descendant.ID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring descendant collection files", zap.String("descendant_id", descendantIDCaptured.String()), zap.Int("file_count", len(descendantFileIDsCaptured))) return svc.fileRepo.RestoreMany(descendantFileIDsCaptured) }) } totalDescendantFiles += len(descendantFileIDs) svc.logger.Debug("Deleted files in descendant collection", zap.Any("descendant_id", descendant.ID), zap.Int("file_count", len(descendantFileIDs))) } } svc.logger.Info("Soft-deleted all files in descendant collections", zap.Int("total_descendant_files", totalDescendantFiles), zap.Int("descendants_count", len(descendants))) // // STEP 8: Delete all descendant collections // for _, descendant := range descendants { // Execute descendant collection deletion (hard or soft based on flag) if req.ForceHardDelete { if err := svc.hardDeleteCollectionUseCase.Execute(ctx, descendant.ID); err != nil { svc.logger.Error("Failed to hard-delete descendant collection", zap.Any("error", err), zap.Any("descendant_id", descendant.ID)) saga.Rollback(ctx) return nil, err } // No compensation for hard delete - GDPR requires permanent deletion } else { if err := svc.repo.SoftDelete(ctx, descendant.ID); err != nil { svc.logger.Error("Failed to soft-delete descendant collection", zap.Any("error", err), zap.Any("descendant_id", descendant.ID)) saga.Rollback(ctx) // Rollback all previous operations return nil, err } // SAGA: Register compensation for this descendant collection // IMPORTANT: Capture by value for closure descendantIDCaptured := descendant.ID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring descendant collection", zap.String("descendant_id", descendantIDCaptured.String())) return svc.repo.Restore(ctx, descendantIDCaptured) }) } svc.logger.Debug("Deleted descendant collection", zap.Any("descendant_id", descendant.ID), zap.String("descendant_name", descendant.EncryptedName)) } svc.logger.Info("Deleted all descendant collections", zap.Int("descendants_count", len(descendants))) // // STEP 9: Finally, delete the parent collection // if req.ForceHardDelete { svc.logger.Info("Hard deleting parent collection (GDPR mode)", zap.String("collection_id", req.ID.String())) if err := svc.hardDeleteCollectionUseCase.Execute(ctx, req.ID); err != nil { svc.logger.Error("Failed to hard-delete parent collection", zap.Any("error", err), zap.Any("collection_id", req.ID)) saga.Rollback(ctx) return nil, err } // No compensation for hard delete - GDPR requires permanent deletion } else { if err := svc.repo.SoftDelete(ctx, req.ID); err != nil { svc.logger.Error("Failed to soft-delete parent collection", zap.Any("error", err), zap.Any("collection_id", req.ID)) saga.Rollback(ctx) // Rollback all previous operations return nil, err } // SAGA: Register compensation for parent collection deletion // IMPORTANT: Capture by value for closure parentCollectionIDCaptured := req.ID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: restoring parent collection", zap.String("collection_id", parentCollectionIDCaptured.String())) return svc.repo.Restore(ctx, parentCollectionIDCaptured) }) } // // STEP 10: Update storage tracking (quota, events, daily usage) // if totalStorageToRelease > 0 { svc.logger.Info("Releasing storage quota for collection deletion", zap.String("collection_id", req.ID.String()), zap.Int64("total_storage_to_release", totalStorageToRelease)) // Release storage quota err = svc.storageQuotaHelperUseCase.OnFileDeleted(ctx, userID, totalStorageToRelease) if err != nil { svc.logger.Error("Failed to release storage quota after collection deletion", zap.Error(err)) saga.Rollback(ctx) return nil, err } // Register compensation: re-reserve the released quota totalStorageCaptured := totalStorageToRelease userIDCaptured := userID saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: re-reserving released storage quota", zap.Int64("size", totalStorageCaptured)) return svc.storageQuotaHelperUseCase.CheckAndReserveQuota(ctx, userIDCaptured, totalStorageCaptured) }) // Create storage usage event err = svc.createStorageUsageEventUseCase.Execute(ctx, userID, totalStorageToRelease, "remove") if err != nil { svc.logger.Error("Failed to create storage usage event for collection deletion", zap.Error(err)) saga.Rollback(ctx) return nil, err } // Register compensation: create compensating "add" event saga.AddCompensation(func(ctx context.Context) error { svc.logger.Warn("SAGA compensation: creating compensating usage event") return svc.createStorageUsageEventUseCase.Execute(ctx, userIDCaptured, totalStorageCaptured, "add") }) // Update daily storage usage today := time.Now().Truncate(24 * time.Hour) updateReq := &uc_storagedailyusage.UpdateStorageUsageRequest{ UserID: userID, UsageDay: &today, TotalBytes: -totalStorageToRelease, AddBytes: 0, RemoveBytes: totalStorageToRelease, IsIncrement: true, } err = svc.updateStorageUsageUseCase.Execute(ctx, updateReq) if err != nil { svc.logger.Error("Failed to update daily storage usage for collection deletion", 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: userIDCaptured, UsageDay: &today, TotalBytes: totalStorageCaptured, // Positive to reverse AddBytes: totalStorageCaptured, RemoveBytes: 0, IsIncrement: true, } return svc.updateStorageUsageUseCase.Execute(ctx, compensateReq) }) svc.logger.Info("Storage quota released successfully", zap.Int64("released_bytes", totalStorageToRelease)) } // // STEP 11: Delete all S3 objects // if len(allStoragePaths) > 0 { svc.logger.Info("Deleting S3 objects for collection", zap.Any("collection_id", req.ID), zap.Int("s3_objects_count", len(allStoragePaths))) if err := svc.deleteMultipleDataUseCase.Execute(allStoragePaths); err != nil { // Log but don't fail - S3 deletion is best effort after metadata is deleted svc.logger.Error("Failed to delete some S3 objects (continuing anyway)", zap.Any("error", err), zap.Int("s3_objects_count", len(allStoragePaths))) } else { svc.logger.Info("Successfully deleted all S3 objects", zap.Int("s3_objects_deleted", len(allStoragePaths))) } } svc.logger.Info("Collection hierarchy deleted successfully", zap.Any("collection_id", req.ID), zap.Int("parent_files_deleted", len(parentFiles)), zap.Int("descendant_files_deleted", totalDescendantFiles), zap.Int("descendants_deleted", len(descendants)), zap.Int("total_files_deleted", len(parentFiles)+totalDescendantFiles), zap.Int("s3_objects_deleted", len(allStoragePaths))) return &SoftDeleteCollectionResponseDTO{ Success: true, Message: "Collection, descendants, and all associated files deleted successfully", }, nil }