Merged in feature/add-deletes (pull request #214)
support delete for client, document and folder * support delete for client, document and folder * remove batch cancel conflict not used Approved-by: Jacob Mathison
This commit is contained in:
@@ -123,14 +123,6 @@ func (s *Service) List(ctx context.Context, clientID string, limit int32, offset
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// Cancel cancels a batch upload in progress
|
||||
func (s *Service) Cancel(ctx context.Context, clientID string, batchID uuid.UUID) error {
|
||||
return s.cfg.GetDBQueries().CancelBatchUpload(ctx, &repository.CancelBatchUploadParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProgress updates batch processing progress
|
||||
func (s *Service) UpdateProgress(ctx context.Context, clientID string, batchID uuid.UUID, processed, failed, invalidType int32) error {
|
||||
// Calculate progress percentage
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package document_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document"
|
||||
"queryorchestration/internal/fieldextraction"
|
||||
"queryorchestration/internal/harddelete"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createTestDocumentWithDeps creates a document with entries, cleans, and field extractions.
|
||||
// Returns the document ID for use in delete tests.
|
||||
func createTestDocumentWithDeps(t *testing.T, cfg *TestConfig, clientID string, folderID *uuid.UUID, hash string) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
filename := hash + ".pdf"
|
||||
docID, err := q.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: hash,
|
||||
Filename: &filename,
|
||||
Folderid: folderID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a document entry (S3 reference)
|
||||
err = q.AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "test-bucket",
|
||||
Key: "test-key/" + hash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a document clean (must satisfy location_xor_fail constraint)
|
||||
cleanBucket := "clean-bucket"
|
||||
cleanKey := "clean-key/" + hash
|
||||
cleanHash := "cleanhash-" + hash
|
||||
cleanID, err := q.AddDocumentClean(ctx, &repository.AddDocumentCleanParams{
|
||||
Documentid: docID,
|
||||
Bucket: &cleanBucket,
|
||||
Key: &cleanKey,
|
||||
Hash: &cleanHash,
|
||||
Mimetype: repository.NullCleanmimetype{
|
||||
Cleanmimetype: repository.CleanmimetypeApplicationPdf,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a clean entry
|
||||
err = q.AddDocumentCleanEntry(ctx, &repository.AddDocumentCleanEntryParams{
|
||||
Cleanid: cleanID,
|
||||
Version: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add field extraction
|
||||
fieldSvc := fieldextraction.New(cfg)
|
||||
contractTitle := "Test Contract " + hash
|
||||
input := &fieldextraction.CreateFieldExtractionInput{
|
||||
DocumentID: docID,
|
||||
SingleFields: &repository.AddFieldExtractionParams{
|
||||
Documentid: docID,
|
||||
Filename: &filename,
|
||||
Contracttitle: &contractTitle,
|
||||
Createdby: "testuser",
|
||||
},
|
||||
ArrayFields: []*repository.AddFieldExtractionArrayFieldParams{},
|
||||
}
|
||||
_, err = fieldSvc.CreateFieldExtraction(ctx, input)
|
||||
require.NoError(t, err)
|
||||
|
||||
return docID
|
||||
}
|
||||
|
||||
func TestHardDeleteDocument(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
|
||||
clientID := "test-doc-delete"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Doc Delete")
|
||||
|
||||
svc := document.New(cfg)
|
||||
|
||||
t.Run("deletes document and all dependent rows", func(t *testing.T) {
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
||||
Clientid: clientID,
|
||||
Path: "/",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
docID := createTestDocumentWithDeps(t, cfg, clientID, &rootFolder.ID, "delete-cascade-test")
|
||||
|
||||
// Verify document and deps exist before delete
|
||||
_, err = q.GetDocumentSummary(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Rows, err := q.CollectDocumentS3Paths(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, s3Rows, 1)
|
||||
|
||||
// Delete the document
|
||||
s3Paths, err := svc.HardDelete(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify S3 paths returned
|
||||
assert.Len(t, s3Paths, 1)
|
||||
assert.Equal(t, "test-bucket", s3Paths[0].Bucket)
|
||||
assert.Equal(t, "test-key/delete-cascade-test", s3Paths[0].Key)
|
||||
assert.Equal(t, docID, s3Paths[0].DocumentID)
|
||||
|
||||
// Verify document is gone
|
||||
_, err = q.GetDocumentSummary(ctx, docID)
|
||||
require.Error(t, err)
|
||||
|
||||
// Verify document entries are gone
|
||||
s3Rows, err = q.CollectDocumentS3Paths(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, s3Rows)
|
||||
|
||||
// Verify field extractions are gone
|
||||
extractions, err := q.GetFieldExtractionsByDocumentID(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, extractions)
|
||||
})
|
||||
|
||||
t.Run("returns NotFoundError for non-existent document", func(t *testing.T) {
|
||||
fakeID := uuid.New()
|
||||
_, err := svc.HardDelete(ctx, fakeID)
|
||||
require.Error(t, err)
|
||||
|
||||
var notFoundErr *harddelete.NotFoundError
|
||||
assert.True(t, errors.As(err, ¬FoundErr))
|
||||
})
|
||||
|
||||
t.Run("returns empty S3 paths when document has no entries", func(t *testing.T) {
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
// Create a document with no entries
|
||||
docID, err := q.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "no-entries-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, s3Paths)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user