package folder import ( "context" "fmt" "queryorchestration/internal/database/repository" "queryorchestration/internal/document" "queryorchestration/internal/harddelete" "github.com/google/uuid" ) // HardDelete permanently removes a folder tree and optionally all documents within it. // The root folder (path '/') cannot be deleted and returns a ConflictError. // // If includeDocuments is false and the folder tree contains documents, a ConflictError // is returned. The caller must either delete the documents first or set includeDocuments // to true. // // When includeDocuments is true, all documents in the folder tree are cascade-deleted // (using the same document cascade as document.HardDelete), then documentUploads and // folder rows are removed. The entire operation executes in a single database transaction. // batch_document_outcomes are handled by database FK actions: document_id is SET NULL // when a document is deleted, and rows CASCADE-delete when their batch_upload is removed. // // S3 source files are intentionally NOT deleted. Their paths are collected, logged at // INFO level, and returned for optional verbose responses. // // Parameters: // - ctx: request context // - folderID: UUID of the root folder to delete // - includeDocuments: if true, cascade-delete all documents in the folder tree // // Returns: // - []harddelete.S3PathInfo: S3 paths of orphaned source documents (empty if no documents) // - error: NotFoundError, ConflictError, or database error func (s *Service) HardDelete(ctx context.Context, folderID uuid.UUID, includeDocuments bool) ([]harddelete.S3PathInfo, error) { queries := s.cfg.GetDBQueries() // Verify the folder exists and is not the root folder if err := validateFolderForDelete(ctx, queries, folderID); err != nil { return nil, err } // Gather document info and S3 paths before the transaction docIDs, allS3Paths, err := collectFolderDocumentInfo(ctx, queries, folderID, includeDocuments) if err != nil { return nil, err } err = s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error { // Delete all documents in the folder tree if requested for _, docID := range docIDs { if err := document.DeleteDocumentCascade(ctx, q, docID); err != nil { return fmt.Errorf("cascade delete document %s: %w", docID, err) } } // Delete documentUploads referencing folders in the tree if err := q.DeleteDocumentUploadsInFolderTree(ctx, &folderID); err != nil { return fmt.Errorf("delete document uploads in folder tree: %w", err) } // Delete the folder tree rowsAffected, err := q.HardDeleteFolderTree(ctx, &folderID) if err != nil { return fmt.Errorf("delete folder tree: %w", err) } if rowsAffected == 0 { return &harddelete.NotFoundError{ ResourceType: "folder", ResourceID: folderID.String(), } } return nil }) if err != nil { return nil, err } harddelete.LogOrphanedS3Paths(allS3Paths) return allS3Paths, nil } // validateFolderForDelete checks that a folder exists and is not the root folder. // Returns NotFoundError or ConflictError as appropriate. func validateFolderForDelete(ctx context.Context, queries *repository.Queries, folderID uuid.UUID) error { fdr, err := queries.GetFolderByID(ctx, folderID) if err != nil { return &harddelete.NotFoundError{ ResourceType: "folder", ResourceID: folderID.String(), } } if fdr.Path == "/" { return &harddelete.ConflictError{ Message: "cannot delete root folder", } } return nil } // collectFolderDocumentInfo checks for documents in the folder tree and collects their // S3 paths. Returns ConflictError if documents exist and includeDocuments is false. func collectFolderDocumentInfo( ctx context.Context, queries *repository.Queries, folderID uuid.UUID, includeDocuments bool, ) ([]uuid.UUID, []harddelete.S3PathInfo, error) { docCount, err := queries.CountDocumentsInFolderTree(ctx, &folderID) if err != nil { return nil, nil, fmt.Errorf("failed to count documents in folder tree %s: %w", folderID, err) } if docCount > 0 && !includeDocuments { return nil, nil, &harddelete.ConflictError{ Message: fmt.Sprintf("folder tree contains %d documents; set include_documents=true to delete them", docCount), } } if docCount == 0 { return nil, nil, nil } docIDs, err := queries.GetDocumentIDsInFolderTree(ctx, &folderID) if err != nil { return nil, nil, fmt.Errorf("failed to get document IDs in folder tree %s: %w", folderID, err) } allS3Paths, err := harddelete.CollectS3PathsForDocuments(ctx, docIDs, wrapS3Query(queries)) if err != nil { return nil, nil, err } return docIDs, allS3Paths, nil } // wrapS3Query adapts the repository's CollectDocumentS3Paths to the function signature // expected by harddelete.CollectS3PathsForDocuments. func wrapS3Query(queries *repository.Queries) func(ctx context.Context, docID uuid.UUID) ([]harddelete.S3Row, error) { return func(ctx context.Context, docID uuid.UUID) ([]harddelete.S3Row, error) { rows, err := queries.CollectDocumentS3Paths(ctx, docID) if err != nil { return nil, err } result := make([]harddelete.S3Row, len(rows)) for i, r := range rows { result[i] = harddelete.S3Row{Bucket: r.Bucket, Key: r.Key} } return result, nil } }