Files
Jay Brown ad7b21f3a2 Merged in bugfix/delete-client-cascade (pull request #220)
fix cascading delete issue

* test passing
2026-04-08 10:54:32 +00:00

119 lines
3.9 KiB
Go

package document
import (
"context"
"fmt"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/harddelete"
"github.com/google/uuid"
)
// HardDelete permanently removes a document and all its dependent data from the database.
// The delete cascade executes in FK order within a single transaction:
// - field extraction array fields, versions, and extractions
// - clean entries and cleans
// - document entries (S3 references)
// - the document row itself (documentLabels auto-cascade via ON DELETE CASCADE)
//
// batch_document_outcomes.document_id is SET NULL by the database FK constraint,
// preserving batch outcome history when a document is removed.
//
// S3 source files are intentionally NOT deleted. Their paths are collected before
// the transaction, logged at INFO level, and returned for optional verbose responses.
//
// Parameters:
// - ctx: request context
// - documentID: UUID of the document to delete
//
// Returns:
// - []harddelete.S3PathInfo: S3 paths of orphaned source documents
// - error: NotFoundError if the document does not exist, or a database error
func (s *Service) HardDelete(ctx context.Context, documentID uuid.UUID) ([]harddelete.S3PathInfo, error) {
queries := s.cfg.GetDBQueries()
// Collect S3 paths before starting the transaction so we can log them
// even if the transaction fails for some reason.
s3Rows, err := queries.CollectDocumentS3Paths(ctx, documentID)
if err != nil {
return nil, fmt.Errorf("failed to collect S3 paths for document %s: %w", documentID, err)
}
s3Paths := make([]harddelete.S3PathInfo, len(s3Rows))
for i, row := range s3Rows {
s3Paths[i] = harddelete.S3PathInfo{
DocumentID: documentID,
Bucket: row.Bucket,
Key: row.Key,
}
}
err = s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error {
return DeleteDocumentCascade(ctx, q, documentID)
})
if err != nil {
return nil, fmt.Errorf("failed to hard-delete document %s: %w", documentID, err)
}
harddelete.LogOrphanedS3Paths(s3Paths)
return s3Paths, nil
}
// DeleteDocumentCascade deletes a document and all dependent rows in FK order
// using the provided transactional queries. This function does NOT collect S3 paths
// or log them -- the caller is responsible for that.
// Exported so that folder and client delete services can reuse the cascade logic.
//
// Parameters:
// - ctx: request context
// - q: transactional repository queries
// - documentID: UUID of the document to delete
//
// Returns:
// - error: NotFoundError if the document does not exist, or a database error
func DeleteDocumentCascade(ctx context.Context, q *repository.Queries, documentID uuid.UUID) error {
// Delete in FK dependency order (children before parents)
if err := q.DeleteDocumentFieldExtractionArrayFields(ctx, documentID); err != nil {
return fmt.Errorf("delete field extraction array fields: %w", err)
}
if err := q.DeleteDocumentFieldExtractionVersions(ctx, documentID); err != nil {
return fmt.Errorf("delete field extraction versions: %w", err)
}
if err := q.DeleteDocumentFieldExtractions(ctx, documentID); err != nil {
return fmt.Errorf("delete field extractions: %w", err)
}
if err := q.DeleteDocumentCleanEntries(ctx, documentID); err != nil {
return fmt.Errorf("delete clean entries: %w", err)
}
if err := q.DeleteDocumentCleans(ctx, documentID); err != nil {
return fmt.Errorf("delete cleans: %w", err)
}
if err := q.DeleteDocumentEntries(ctx, documentID); err != nil {
return fmt.Errorf("delete document entries: %w", err)
}
// documentLabels auto-cascade via ON DELETE CASCADE, but the document
// row itself must be explicitly deleted.
rowsAffected, err := q.HardDeleteDocument(ctx, documentID)
if err != nil {
return fmt.Errorf("delete document: %w", err)
}
if rowsAffected == 0 {
return &harddelete.NotFoundError{
ResourceType: "document",
ResourceID: documentID.String(),
}
}
return nil
}