ad7b21f3a2
fix cascading delete issue * test passing
159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
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
|
|
}
|
|
}
|