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 }