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:
Jay Brown
2026-03-04 18:30:13 +00:00
parent 62b5de5722
commit 09c61ea9b4
28 changed files with 3438 additions and 632 deletions
-8
View File
@@ -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
+115
View File
@@ -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
}
+161
View File
@@ -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, &notFoundErr))
})
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)
})
}