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:
@@ -0,0 +1,129 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document"
|
||||
"queryorchestration/internal/harddelete"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HardDelete permanently removes a client and ALL associated data from the database.
|
||||
// This includes all documents (with full cascade), folders, collector configurations,
|
||||
// batch uploads, and sync records. The entire operation executes in a single transaction.
|
||||
//
|
||||
// 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
|
||||
// - clientID: the client identifier to delete
|
||||
//
|
||||
// Returns:
|
||||
// - []harddelete.S3PathInfo: S3 paths of orphaned source documents
|
||||
// - error: NotFoundError if client does not exist, or a database error
|
||||
func (s *Service) HardDelete(ctx context.Context, clientID string) ([]harddelete.S3PathInfo, error) {
|
||||
queries := s.cfg.GetDBQueries()
|
||||
|
||||
// Get all document IDs for the client
|
||||
docIDs, err := queries.GetAllDocumentIDsForClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get document IDs for client %s: %w", clientID, err)
|
||||
}
|
||||
|
||||
// Collect S3 paths for all documents before the transaction
|
||||
allS3Paths, err := harddelete.CollectS3PathsForDocuments(ctx, docIDs, wrapS3Query(queries))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error {
|
||||
// 1. Delete all documents with full cascade
|
||||
for _, docID := range docIDs {
|
||||
if err := document.DeleteDocumentCascade(ctx, q, docID); err != nil {
|
||||
return fmt.Errorf("cascade delete document %s: %w", docID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete all documentUploads for the client
|
||||
if err := q.DeleteDocumentUploadsForClient(ctx, clientID); err != nil {
|
||||
return fmt.Errorf("delete document uploads: %w", err)
|
||||
}
|
||||
|
||||
// 3. Delete all folders for the client (safe now that all documents are gone)
|
||||
if err := q.DeleteAllFoldersForClient(ctx, clientID); err != nil {
|
||||
return fmt.Errorf("delete folders: %w", err)
|
||||
}
|
||||
|
||||
// 4-6. Delete collector data, batch uploads, and sync records
|
||||
if err := deleteClientDependencies(ctx, q, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 7. Delete the client row
|
||||
rowsAffected, err := q.HardDeleteClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete client: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return &harddelete.NotFoundError{
|
||||
ResourceType: "client",
|
||||
ResourceID: clientID,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
harddelete.LogOrphanedS3Paths(allS3Paths)
|
||||
return allS3Paths, nil
|
||||
}
|
||||
|
||||
// clientDependencyDelete pairs a description with a delete function for use in
|
||||
// deleteClientDependencies. Each entry represents one step in the FK-ordered cascade.
|
||||
type clientDependencyDelete struct {
|
||||
name string
|
||||
fn func(ctx context.Context, clientID string) error
|
||||
}
|
||||
|
||||
// deleteClientDependencies removes collector data, batch uploads, and sync records
|
||||
// for a client within an existing transaction. Must be called after documents and
|
||||
// folders are already deleted. Executes deletes in FK dependency order.
|
||||
func deleteClientDependencies(ctx context.Context, q *repository.Queries, clientID string) error {
|
||||
deletes := []clientDependencyDelete{
|
||||
{"collector min clean versions", q.DeleteCollectorMinCleanVersions},
|
||||
{"collector active versions", q.DeleteCollectorActiveVersions},
|
||||
{"collector versions", q.DeleteCollectorVersions},
|
||||
{"batch uploads", q.DeleteBatchUploads},
|
||||
{"client can sync", q.DeleteClientCanSync},
|
||||
}
|
||||
for _, d := range deletes {
|
||||
if err := d.fn(ctx, clientID); err != nil {
|
||||
return fmt.Errorf("delete %s: %w", d.name, err)
|
||||
}
|
||||
}
|
||||
return 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/client"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/harddelete"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createDocWithEntryForClient creates a document with an S3 entry in the given folder for a client.
|
||||
func createDocWithEntryForClient(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)
|
||||
|
||||
err = q.AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "test-bucket",
|
||||
Key: "clients/" + clientID + "/" + hash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return docID
|
||||
}
|
||||
|
||||
func TestHardDeleteClient(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
|
||||
svc := client.New(cfg)
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
t.Run("deletes client and all associated data", func(t *testing.T) {
|
||||
clientID := "test-client-hard-delete"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Client Hard Delete")
|
||||
|
||||
// Get root folder
|
||||
rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
||||
Clientid: clientID,
|
||||
Path: "/",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create documents with S3 entries
|
||||
doc1ID := createDocWithEntryForClient(t, cfg, clientID, &rootFolder.ID, "client-del-doc1")
|
||||
doc2ID := createDocWithEntryForClient(t, cfg, clientID, &rootFolder.ID, "client-del-doc2")
|
||||
|
||||
// Create collector data
|
||||
versionID, err := q.AddLatestCollectorVersion(ctx, clientID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = q.SetActiveCollectorVersion(ctx, &repository.SetActiveCollectorVersionParams{
|
||||
Clientid: clientID,
|
||||
Versionid: versionID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = q.SetCollectorCleanVersion(ctx, &repository.SetCollectorCleanVersionParams{
|
||||
Clientid: clientID,
|
||||
Addedversion: versionID,
|
||||
Versionid: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create clientCanSync
|
||||
err = q.AddClientCanSync(ctx, &repository.AddClientCanSyncParams{
|
||||
Cansync: true,
|
||||
Clientid: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete the client
|
||||
s3Paths, err := svc.HardDelete(ctx, clientID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify S3 paths returned for both documents
|
||||
assert.Len(t, s3Paths, 2)
|
||||
|
||||
// Verify documents are gone
|
||||
_, err = q.GetDocumentSummary(ctx, doc1ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetDocumentSummary(ctx, doc2ID)
|
||||
require.Error(t, err)
|
||||
|
||||
// Verify client is gone
|
||||
_, err = q.GetClient(ctx, clientID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("returns NotFoundError for non-existent client", func(t *testing.T) {
|
||||
_, err := svc.HardDelete(ctx, "non-existent-client")
|
||||
require.Error(t, err)
|
||||
|
||||
var notFoundErr *harddelete.NotFoundError
|
||||
assert.True(t, errors.As(err, ¬FoundErr))
|
||||
})
|
||||
|
||||
t.Run("deletes client with no documents", func(t *testing.T) {
|
||||
clientID := "test-client-no-docs-del"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Client No Docs Del")
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, clientID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, s3Paths)
|
||||
|
||||
// Verify client and root folder are gone
|
||||
_, err = q.GetClient(ctx, clientID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("deletes client with nested folder structure", func(t *testing.T) {
|
||||
clientID := "test-client-nested-del"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Client Nested Del")
|
||||
|
||||
rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
||||
Clientid: clientID,
|
||||
Path: "/",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create subfolder
|
||||
subFolder, err := q.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/contracts",
|
||||
Parentid: &rootFolder.ID,
|
||||
Clientid: clientID,
|
||||
Createdby: "testuser",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create document in subfolder
|
||||
docID := createDocWithEntryForClient(t, cfg, clientID, &subFolder.ID, "nested-del-doc")
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, clientID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, s3Paths, 1)
|
||||
|
||||
// Verify all gone
|
||||
_, err = q.GetDocumentSummary(ctx, docID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetFolderByID(ctx, subFolder.ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetFolderByID(ctx, rootFolder.ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetClient(ctx, clientID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -57,12 +57,6 @@ UPDATE batch_uploads
|
||||
SET failed_filenames = failed_filenames || jsonb_build_array($2::text)
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CancelBatchUpload :exec
|
||||
UPDATE batch_uploads
|
||||
SET status = 'cancelled',
|
||||
completed_at = NOW()
|
||||
WHERE id = $1 AND client_id = $2 AND status = 'processing';
|
||||
|
||||
-- name: GetDocumentsByBatchId :many
|
||||
SELECT id, clientId, hash
|
||||
FROM documents
|
||||
|
||||
@@ -14,6 +14,50 @@ UPDATE clients SET name = $1 WHERE clientId = $2;
|
||||
-- name: AddClientCanSync :exec
|
||||
INSERT INTO clientCanSync (canSync, clientId) VALUES ($1, $2);
|
||||
|
||||
-- name: GetAllDocumentIDsForClient :many
|
||||
-- Get all document IDs for a client, used to cascade document deletes during client delete
|
||||
SELECT id FROM documents WHERE clientId = @client_id;
|
||||
|
||||
-- name: DeleteCollectorMinCleanVersions :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete collector minimum clean version records for a client
|
||||
DELETE FROM collectorMinCleanVersions WHERE clientId = @client_id;
|
||||
|
||||
-- name: DeleteCollectorActiveVersions :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete collector active version records for a client
|
||||
DELETE FROM collectorActiveVersions WHERE clientId = @client_id;
|
||||
|
||||
-- name: DeleteCollectorVersions :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete collector version records for a client
|
||||
DELETE FROM collectorVersions WHERE clientId = @client_id;
|
||||
|
||||
-- name: DeleteBatchUploads :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete all batch upload records for a client
|
||||
DELETE FROM batch_uploads WHERE client_id = @client_id;
|
||||
|
||||
-- name: DeleteClientCanSync :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete clientCanSync records for a client
|
||||
DELETE FROM clientCanSync WHERE clientId = @client_id;
|
||||
|
||||
-- name: DeleteDocumentUploadsForClient :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete all documentUploads for a client
|
||||
DELETE FROM documentUploads WHERE clientId = @client_id;
|
||||
|
||||
-- name: DeleteAllFoldersForClient :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete all folders for a client. All documents must already be deleted to avoid FK violations.
|
||||
DELETE FROM folders WHERE clientId = @client_id;
|
||||
|
||||
-- name: HardDeleteClient :execrows
|
||||
-- @sqlc-vet-disable
|
||||
-- Hard delete the client row itself. Returns the number of rows deleted (0 or 1).
|
||||
DELETE FROM clients WHERE clientId = @client_id;
|
||||
|
||||
-- name: IsClientSynced :one
|
||||
-- Text extraction has been removed. A client is considered synced when
|
||||
-- all documents have completed cleaning (clean entry exists OR clean failed).
|
||||
|
||||
@@ -72,3 +72,51 @@ SELECT
|
||||
d.file_size_bytes
|
||||
FROM documents d
|
||||
WHERE d.id = $1;
|
||||
|
||||
-- name: CollectDocumentS3Paths :many
|
||||
-- @sqlc-vet-disable
|
||||
-- Collect S3 bucket+key from documentEntries before deleting, for logging orphaned S3 objects
|
||||
SELECT de.bucket, de.key FROM documentEntries de WHERE de.documentId = @document_id;
|
||||
|
||||
-- name: DeleteDocumentFieldExtractionArrayFields :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete array fields for all field extractions belonging to this document
|
||||
DELETE FROM documentFieldExtractionArrayFields
|
||||
WHERE fieldExtractionId IN (
|
||||
SELECT id FROM documentFieldExtractions WHERE documentId = @document_id
|
||||
);
|
||||
|
||||
-- name: DeleteDocumentFieldExtractionVersions :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete version entries for all field extractions belonging to this document
|
||||
DELETE FROM documentFieldExtractionVersions
|
||||
WHERE documentId = @document_id;
|
||||
|
||||
-- name: DeleteDocumentFieldExtractions :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete field extraction records for this document
|
||||
DELETE FROM documentFieldExtractions WHERE documentId = @document_id;
|
||||
|
||||
-- name: DeleteDocumentCleanEntries :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete clean entries for all cleans belonging to this document
|
||||
DELETE FROM documentCleanEntries
|
||||
WHERE cleanId IN (
|
||||
SELECT id FROM documentCleans WHERE documentId = @document_id
|
||||
);
|
||||
|
||||
-- name: DeleteDocumentCleans :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete clean records for this document
|
||||
DELETE FROM documentCleans WHERE documentId = @document_id;
|
||||
|
||||
-- name: DeleteDocumentEntries :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete S3 entry records for this document
|
||||
DELETE FROM documentEntries WHERE documentId = @document_id;
|
||||
|
||||
-- name: HardDeleteDocument :execrows
|
||||
-- @sqlc-vet-disable
|
||||
-- Hard delete the document row itself. Returns the number of rows deleted (0 or 1).
|
||||
-- documentLabels are removed automatically via ON DELETE CASCADE.
|
||||
DELETE FROM documents WHERE id = @document_id;
|
||||
|
||||
@@ -100,3 +100,56 @@ SELECT
|
||||
FROM documents d
|
||||
WHERE d.folderId = $1
|
||||
ORDER BY d.id;
|
||||
|
||||
-- name: GetFolderTreeIDs :many
|
||||
-- Get all folder IDs in a folder tree (recursive) for cascade delete operations
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id, path FROM folders WHERE id = @folder_id
|
||||
UNION ALL
|
||||
SELECT f.id, f.path FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
SELECT id, path FROM folder_tree;
|
||||
|
||||
-- name: CountDocumentsInFolderTree :one
|
||||
-- Count documents in a folder tree to check before deletion without include_documents
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = @folder_id
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
SELECT COUNT(*) FROM documents WHERE folderId IN (SELECT id FROM folder_tree);
|
||||
|
||||
-- name: GetDocumentIDsInFolderTree :many
|
||||
-- Get all document IDs in a folder tree for cascade deletion
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = @folder_id
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
SELECT id FROM documents WHERE folderId IN (SELECT id FROM folder_tree);
|
||||
|
||||
-- name: DeleteDocumentUploadsInFolderTree :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete documentUploads rows that reference folders in the tree
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = @folder_id
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
DELETE FROM documentUploads WHERE folder_id IN (SELECT id FROM folder_tree);
|
||||
|
||||
-- name: HardDeleteFolderTree :execrows
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete all folders in the tree. PostgreSQL handles ordering within the CTE.
|
||||
-- Returns the number of rows deleted.
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = @folder_id
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
DELETE FROM folders WHERE id IN (SELECT id FROM folder_tree);
|
||||
|
||||
@@ -33,29 +33,6 @@ func (q *Queries) AddFailedFilename(ctx context.Context, arg *AddFailedFilenameP
|
||||
return err
|
||||
}
|
||||
|
||||
const cancelBatchUpload = `-- name: CancelBatchUpload :exec
|
||||
UPDATE batch_uploads
|
||||
SET status = 'cancelled',
|
||||
completed_at = NOW()
|
||||
WHERE id = $1 AND client_id = $2 AND status = 'processing'
|
||||
`
|
||||
|
||||
type CancelBatchUploadParams struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
}
|
||||
|
||||
// CancelBatchUpload
|
||||
//
|
||||
// UPDATE batch_uploads
|
||||
// SET status = 'cancelled',
|
||||
// completed_at = NOW()
|
||||
// WHERE id = $1 AND client_id = $2 AND status = 'processing'
|
||||
func (q *Queries) CancelBatchUpload(ctx context.Context, arg *CancelBatchUploadParams) error {
|
||||
_, err := q.db.Exec(ctx, cancelBatchUpload, arg.ID, arg.ClientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const countDocumentsByBatchId = `-- name: CountDocumentsByBatchId :one
|
||||
SELECT COUNT(*) as count
|
||||
FROM documents
|
||||
|
||||
@@ -264,47 +264,6 @@ func TestBatchUpload(t *testing.T) {
|
||||
assert.Contains(t, failedFilenamesStr, "document2.pdf")
|
||||
})
|
||||
|
||||
t.Run("CancelBatchUpload", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
queries := cfg.GetDBQueries()
|
||||
|
||||
// Create a client
|
||||
clientID := fmt.Sprintf("TEST_CLIENT_CANCEL_%s", uuid.New().String()[:8])
|
||||
err := queries.CreateClient(ctx, &repository.CreateClientParams{
|
||||
Name: fmt.Sprintf("Test Client Cancel %s", uuid.New().String()[:8]),
|
||||
Clientid: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch upload
|
||||
batchID, err := queries.CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "cancel.zip",
|
||||
TotalDocuments: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cancel batch upload
|
||||
err = queries.CancelBatchUpload(ctx, &repository.CancelBatchUploadParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify cancellation
|
||||
batch, err := queries.GetBatchUpload(ctx, &repository.GetBatchUploadParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, repository.BatchStatusCancelled, batch.Status)
|
||||
assert.True(t, batch.CompletedAt.Valid)
|
||||
})
|
||||
|
||||
t.Run("GetDocumentsByBatchId", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -7,6 +7,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const addClientCanSync = `-- name: AddClientCanSync :exec
|
||||
@@ -43,6 +45,124 @@ func (q *Queries) CreateClient(ctx context.Context, arg *CreateClientParams) err
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteAllFoldersForClient = `-- name: DeleteAllFoldersForClient :exec
|
||||
DELETE FROM folders WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete all folders for a client. All documents must already be deleted to avoid FK violations.
|
||||
//
|
||||
// DELETE FROM folders WHERE clientId = $1
|
||||
func (q *Queries) DeleteAllFoldersForClient(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteAllFoldersForClient, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteBatchUploads = `-- name: DeleteBatchUploads :exec
|
||||
DELETE FROM batch_uploads WHERE client_id = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete all batch upload records for a client
|
||||
//
|
||||
// DELETE FROM batch_uploads WHERE client_id = $1
|
||||
func (q *Queries) DeleteBatchUploads(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteBatchUploads, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteClientCanSync = `-- name: DeleteClientCanSync :exec
|
||||
DELETE FROM clientCanSync WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete clientCanSync records for a client
|
||||
//
|
||||
// DELETE FROM clientCanSync WHERE clientId = $1
|
||||
func (q *Queries) DeleteClientCanSync(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteClientCanSync, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteCollectorActiveVersions = `-- name: DeleteCollectorActiveVersions :exec
|
||||
DELETE FROM collectorActiveVersions WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete collector active version records for a client
|
||||
//
|
||||
// DELETE FROM collectorActiveVersions WHERE clientId = $1
|
||||
func (q *Queries) DeleteCollectorActiveVersions(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteCollectorActiveVersions, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteCollectorMinCleanVersions = `-- name: DeleteCollectorMinCleanVersions :exec
|
||||
DELETE FROM collectorMinCleanVersions WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete collector minimum clean version records for a client
|
||||
//
|
||||
// DELETE FROM collectorMinCleanVersions WHERE clientId = $1
|
||||
func (q *Queries) DeleteCollectorMinCleanVersions(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteCollectorMinCleanVersions, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteCollectorVersions = `-- name: DeleteCollectorVersions :exec
|
||||
DELETE FROM collectorVersions WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete collector version records for a client
|
||||
//
|
||||
// DELETE FROM collectorVersions WHERE clientId = $1
|
||||
func (q *Queries) DeleteCollectorVersions(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteCollectorVersions, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDocumentUploadsForClient = `-- name: DeleteDocumentUploadsForClient :exec
|
||||
DELETE FROM documentUploads WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete all documentUploads for a client
|
||||
//
|
||||
// DELETE FROM documentUploads WHERE clientId = $1
|
||||
func (q *Queries) DeleteDocumentUploadsForClient(ctx context.Context, clientID string) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentUploadsForClient, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAllDocumentIDsForClient = `-- name: GetAllDocumentIDsForClient :many
|
||||
SELECT id FROM documents WHERE clientId = $1
|
||||
`
|
||||
|
||||
// Get all document IDs for a client, used to cascade document deletes during client delete
|
||||
//
|
||||
// SELECT id FROM documents WHERE clientId = $1
|
||||
func (q *Queries) GetAllDocumentIDsForClient(ctx context.Context, clientID string) ([]uuid.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, getAllDocumentIDsForClient, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []uuid.UUID{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getClient = `-- name: GetClient :one
|
||||
SELECT clientid, name, cansync FROM fullClients WHERE clientId = $1
|
||||
`
|
||||
@@ -57,6 +177,22 @@ func (q *Queries) GetClient(ctx context.Context, clientid string) (*Fullclient,
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const hardDeleteClient = `-- name: HardDeleteClient :execrows
|
||||
DELETE FROM clients WHERE clientId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Hard delete the client row itself. Returns the number of rows deleted (0 or 1).
|
||||
//
|
||||
// DELETE FROM clients WHERE clientId = $1
|
||||
func (q *Queries) HardDeleteClient(ctx context.Context, clientID string) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, hardDeleteClient, clientID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const isClientSynced = `-- name: IsClientSynced :one
|
||||
WITH
|
||||
docs AS (
|
||||
|
||||
@@ -64,6 +64,39 @@ func (q *Queries) AddDocumentUpload(ctx context.Context, arg *AddDocumentUploadP
|
||||
return err
|
||||
}
|
||||
|
||||
const collectDocumentS3Paths = `-- name: CollectDocumentS3Paths :many
|
||||
SELECT de.bucket, de.key FROM documentEntries de WHERE de.documentId = $1
|
||||
`
|
||||
|
||||
type CollectDocumentS3PathsRow struct {
|
||||
Bucket string `db:"bucket"`
|
||||
Key string `db:"key"`
|
||||
}
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Collect S3 bucket+key from documentEntries before deleting, for logging orphaned S3 objects
|
||||
//
|
||||
// SELECT de.bucket, de.key FROM documentEntries de WHERE de.documentId = $1
|
||||
func (q *Queries) CollectDocumentS3Paths(ctx context.Context, documentID uuid.UUID) ([]*CollectDocumentS3PathsRow, error) {
|
||||
rows, err := q.db.Query(ctx, collectDocumentS3Paths, documentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []*CollectDocumentS3PathsRow{}
|
||||
for rows.Next() {
|
||||
var i CollectDocumentS3PathsRow
|
||||
if err := rows.Scan(&i.Bucket, &i.Key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const createDocument = `-- name: CreateDocument :one
|
||||
INSERT INTO documents (clientId, hash, batch_id, filename, folderId, originalPath, file_size_bytes) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id
|
||||
`
|
||||
@@ -96,6 +129,98 @@ func (q *Queries) CreateDocument(ctx context.Context, arg *CreateDocumentParams)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const deleteDocumentCleanEntries = `-- name: DeleteDocumentCleanEntries :exec
|
||||
DELETE FROM documentCleanEntries
|
||||
WHERE cleanId IN (
|
||||
SELECT id FROM documentCleans WHERE documentId = $1
|
||||
)
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete clean entries for all cleans belonging to this document
|
||||
//
|
||||
// DELETE FROM documentCleanEntries
|
||||
// WHERE cleanId IN (
|
||||
// SELECT id FROM documentCleans WHERE documentId = $1
|
||||
// )
|
||||
func (q *Queries) DeleteDocumentCleanEntries(ctx context.Context, documentID uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentCleanEntries, documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDocumentCleans = `-- name: DeleteDocumentCleans :exec
|
||||
DELETE FROM documentCleans WHERE documentId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete clean records for this document
|
||||
//
|
||||
// DELETE FROM documentCleans WHERE documentId = $1
|
||||
func (q *Queries) DeleteDocumentCleans(ctx context.Context, documentID uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentCleans, documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDocumentEntries = `-- name: DeleteDocumentEntries :exec
|
||||
DELETE FROM documentEntries WHERE documentId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete S3 entry records for this document
|
||||
//
|
||||
// DELETE FROM documentEntries WHERE documentId = $1
|
||||
func (q *Queries) DeleteDocumentEntries(ctx context.Context, documentID uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentEntries, documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDocumentFieldExtractionArrayFields = `-- name: DeleteDocumentFieldExtractionArrayFields :exec
|
||||
DELETE FROM documentFieldExtractionArrayFields
|
||||
WHERE fieldExtractionId IN (
|
||||
SELECT id FROM documentFieldExtractions WHERE documentId = $1
|
||||
)
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete array fields for all field extractions belonging to this document
|
||||
//
|
||||
// DELETE FROM documentFieldExtractionArrayFields
|
||||
// WHERE fieldExtractionId IN (
|
||||
// SELECT id FROM documentFieldExtractions WHERE documentId = $1
|
||||
// )
|
||||
func (q *Queries) DeleteDocumentFieldExtractionArrayFields(ctx context.Context, documentID uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentFieldExtractionArrayFields, documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDocumentFieldExtractionVersions = `-- name: DeleteDocumentFieldExtractionVersions :exec
|
||||
DELETE FROM documentFieldExtractionVersions
|
||||
WHERE documentId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete version entries for all field extractions belonging to this document
|
||||
//
|
||||
// DELETE FROM documentFieldExtractionVersions
|
||||
// WHERE documentId = $1
|
||||
func (q *Queries) DeleteDocumentFieldExtractionVersions(ctx context.Context, documentID uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentFieldExtractionVersions, documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteDocumentFieldExtractions = `-- name: DeleteDocumentFieldExtractions :exec
|
||||
DELETE FROM documentFieldExtractions WHERE documentId = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete field extraction records for this document
|
||||
//
|
||||
// DELETE FROM documentFieldExtractions WHERE documentId = $1
|
||||
func (q *Queries) DeleteDocumentFieldExtractions(ctx context.Context, documentID uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentFieldExtractions, documentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDocumentEnriched = `-- name: GetDocumentEnriched :one
|
||||
SELECT
|
||||
d.id,
|
||||
@@ -334,6 +459,23 @@ func (q *Queries) GetDocumentUploadCurrentPart(ctx context.Context, arg *GetDocu
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const hardDeleteDocument = `-- name: HardDeleteDocument :execrows
|
||||
DELETE FROM documents WHERE id = $1
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Hard delete the document row itself. Returns the number of rows deleted (0 or 1).
|
||||
// documentLabels are removed automatically via ON DELETE CASCADE.
|
||||
//
|
||||
// DELETE FROM documents WHERE id = $1
|
||||
func (q *Queries) HardDeleteDocument(ctx context.Context, documentID uuid.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, hardDeleteDocument, documentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const listDocumentIDsBatch = `-- name: ListDocumentIDsBatch :many
|
||||
SELECT id, totalCount FROM listDocumentIDs($1, $2, $3)
|
||||
`
|
||||
|
||||
@@ -28,6 +28,32 @@ func (q *Queries) CountDocumentsByFolder(ctx context.Context, folderid *uuid.UUI
|
||||
return total, err
|
||||
}
|
||||
|
||||
const countDocumentsInFolderTree = `-- name: CountDocumentsInFolderTree :one
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
SELECT COUNT(*) FROM documents WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
`
|
||||
|
||||
// Count documents in a folder tree to check before deletion without include_documents
|
||||
//
|
||||
// WITH RECURSIVE folder_tree AS (
|
||||
// SELECT id FROM folders WHERE id = $1
|
||||
// UNION ALL
|
||||
// SELECT f.id FROM folders f
|
||||
// JOIN folder_tree ft ON f.parentId = ft.id
|
||||
// )
|
||||
// SELECT COUNT(*) FROM documents WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
func (q *Queries) CountDocumentsInFolderTree(ctx context.Context, folderID *uuid.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countDocumentsInFolderTree, folderID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createFolder = `-- name: CreateFolder :one
|
||||
INSERT INTO folders (path, parentId, clientId, createdBy)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
@@ -65,6 +91,31 @@ func (q *Queries) CreateFolder(ctx context.Context, arg *CreateFolderParams) (*F
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const deleteDocumentUploadsInFolderTree = `-- name: DeleteDocumentUploadsInFolderTree :exec
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
DELETE FROM documentUploads WHERE folder_id IN (SELECT id FROM folder_tree)
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete documentUploads rows that reference folders in the tree
|
||||
//
|
||||
// WITH RECURSIVE folder_tree AS (
|
||||
// SELECT id FROM folders WHERE id = $1
|
||||
// UNION ALL
|
||||
// SELECT f.id FROM folders f
|
||||
// JOIN folder_tree ft ON f.parentId = ft.id
|
||||
// )
|
||||
// DELETE FROM documentUploads WHERE folder_id IN (SELECT id FROM folder_tree)
|
||||
func (q *Queries) DeleteDocumentUploadsInFolderTree(ctx context.Context, folderID *uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteDocumentUploadsInFolderTree, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getClientRootFolder = `-- name: GetClientRootFolder :one
|
||||
SELECT id, path, parentid, clientid, createdat, createdby FROM folders
|
||||
WHERE clientId = $1 AND path = '/'
|
||||
@@ -88,6 +139,45 @@ func (q *Queries) GetClientRootFolder(ctx context.Context, clientid string) (*Fo
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getDocumentIDsInFolderTree = `-- name: GetDocumentIDsInFolderTree :many
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
SELECT id FROM documents WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
`
|
||||
|
||||
// Get all document IDs in a folder tree for cascade deletion
|
||||
//
|
||||
// WITH RECURSIVE folder_tree AS (
|
||||
// SELECT id FROM folders WHERE id = $1
|
||||
// UNION ALL
|
||||
// SELECT f.id FROM folders f
|
||||
// JOIN folder_tree ft ON f.parentId = ft.id
|
||||
// )
|
||||
// SELECT id FROM documents WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
func (q *Queries) GetDocumentIDsInFolderTree(ctx context.Context, folderID *uuid.UUID) ([]uuid.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, getDocumentIDsInFolderTree, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []uuid.UUID{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDocumentsByFolder = `-- name: GetDocumentsByFolder :many
|
||||
SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes FROM documents
|
||||
WHERE folderId = $1
|
||||
@@ -355,6 +445,50 @@ func (q *Queries) GetFolderTree(ctx context.Context, dollar_1 *uuid.UUID) ([]*Ge
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFolderTreeIDs = `-- name: GetFolderTreeIDs :many
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id, path FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id, f.path FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
SELECT id, path FROM folder_tree
|
||||
`
|
||||
|
||||
type GetFolderTreeIDsRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Path string `db:"path"`
|
||||
}
|
||||
|
||||
// Get all folder IDs in a folder tree (recursive) for cascade delete operations
|
||||
//
|
||||
// WITH RECURSIVE folder_tree AS (
|
||||
// SELECT id, path FROM folders WHERE id = $1
|
||||
// UNION ALL
|
||||
// SELECT f.id, f.path FROM folders f
|
||||
// JOIN folder_tree ft ON f.parentId = ft.id
|
||||
// )
|
||||
// SELECT id, path FROM folder_tree
|
||||
func (q *Queries) GetFolderTreeIDs(ctx context.Context, folderID *uuid.UUID) ([]*GetFolderTreeIDsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getFolderTreeIDs, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []*GetFolderTreeIDsRow{}
|
||||
for rows.Next() {
|
||||
var i GetFolderTreeIDsRow
|
||||
if err := rows.Scan(&i.ID, &i.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFoldersByClientID = `-- name: GetFoldersByClientID :many
|
||||
SELECT id, path, parentid, clientid, createdat, createdby FROM folders
|
||||
WHERE clientId = $1
|
||||
@@ -510,6 +644,35 @@ func (q *Queries) GetTopLevelFolders(ctx context.Context, clientid string) ([]*F
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const hardDeleteFolderTree = `-- name: HardDeleteFolderTree :execrows
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
)
|
||||
DELETE FROM folders WHERE id IN (SELECT id FROM folder_tree)
|
||||
`
|
||||
|
||||
// @sqlc-vet-disable
|
||||
// Delete all folders in the tree. PostgreSQL handles ordering within the CTE.
|
||||
// Returns the number of rows deleted.
|
||||
//
|
||||
// WITH RECURSIVE folder_tree AS (
|
||||
// SELECT id FROM folders WHERE id = $1
|
||||
// UNION ALL
|
||||
// SELECT f.id FROM folders f
|
||||
// JOIN folder_tree ft ON f.parentId = ft.id
|
||||
// )
|
||||
// DELETE FROM folders WHERE id IN (SELECT id FROM folder_tree)
|
||||
func (q *Queries) HardDeleteFolderTree(ctx context.Context, folderID *uuid.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, hardDeleteFolderTree, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const renameFolder = `-- name: RenameFolder :exec
|
||||
UPDATE folders
|
||||
SET path = $2
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package folder_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/folder"
|
||||
"queryorchestration/internal/harddelete"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createDocWithEntry creates a document with an S3 entry in the given folder.
|
||||
// Returns the document ID.
|
||||
func createDocWithEntry(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)
|
||||
|
||||
err = q.AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "test-bucket",
|
||||
Key: "test-key/" + hash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return docID
|
||||
}
|
||||
|
||||
func TestHardDeleteFolder(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
|
||||
clientID := "test-folder-delete"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Folder Delete")
|
||||
|
||||
svc := folder.New(cfg)
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
// Get root folder for this client
|
||||
rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
||||
Clientid: clientID,
|
||||
Path: "/",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("rejects deleting root folder", func(t *testing.T) {
|
||||
_, err := svc.HardDelete(ctx, rootFolder.ID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *harddelete.ConflictError
|
||||
assert.True(t, errors.As(err, &conflictErr))
|
||||
assert.Contains(t, conflictErr.Message, "root folder")
|
||||
})
|
||||
|
||||
t.Run("returns NotFoundError for non-existent folder", func(t *testing.T) {
|
||||
fakeID := uuid.New()
|
||||
_, err := svc.HardDelete(ctx, fakeID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var notFoundErr *harddelete.NotFoundError
|
||||
assert.True(t, errors.As(err, ¬FoundErr))
|
||||
})
|
||||
|
||||
t.Run("deletes empty folder tree", func(t *testing.T) {
|
||||
parent, err := svc.CreateFolder(ctx, "/del-empty", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(ctx, "/del-empty/child", &parent.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, parent.ID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, s3Paths)
|
||||
|
||||
// Verify both folders are gone
|
||||
_, err = q.GetFolderByID(ctx, parent.ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetFolderByID(ctx, child.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("conflicts when folder has documents and include_documents is false", func(t *testing.T) {
|
||||
fdr, err := svc.CreateFolder(ctx, "/del-has-docs", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
createDocWithEntry(t, cfg, clientID, &fdr.ID, "folder-doc-conflict")
|
||||
|
||||
_, err = svc.HardDelete(ctx, fdr.ID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *harddelete.ConflictError
|
||||
assert.True(t, errors.As(err, &conflictErr))
|
||||
assert.Contains(t, conflictErr.Message, "documents")
|
||||
})
|
||||
|
||||
t.Run("deletes folder tree with documents when include_documents is true", func(t *testing.T) {
|
||||
parent, err := svc.CreateFolder(ctx, "/del-with-docs", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(ctx, "/del-with-docs/child", &parent.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
doc1ID := createDocWithEntry(t, cfg, clientID, &parent.ID, "parent-doc-1")
|
||||
doc2ID := createDocWithEntry(t, cfg, clientID, &child.ID, "child-doc-1")
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, parent.ID, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify S3 paths returned for both documents
|
||||
assert.Len(t, s3Paths, 2)
|
||||
|
||||
// Verify both documents are gone
|
||||
_, err = q.GetDocumentSummary(ctx, doc1ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetDocumentSummary(ctx, doc2ID)
|
||||
require.Error(t, err)
|
||||
|
||||
// Verify folders are gone
|
||||
_, err = q.GetFolderByID(ctx, parent.ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetFolderByID(ctx, child.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("counts documents in nested folder tree correctly", func(t *testing.T) {
|
||||
parent, err := svc.CreateFolder(ctx, "/del-count", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(ctx, "/del-count/sub", &parent.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Document in child, not parent
|
||||
createDocWithEntry(t, cfg, clientID, &child.ID, "nested-doc-count")
|
||||
|
||||
// Should conflict because the tree has a document (in the child)
|
||||
_, err = svc.HardDelete(ctx, parent.ID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *harddelete.ConflictError
|
||||
assert.True(t, errors.As(err, &conflictErr))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package harddelete defines shared types and errors for hard-delete operations.
|
||||
// These types are used by the service layer (client, document, folder) and the
|
||||
// API handler layer to communicate delete results and errors.
|
||||
package harddelete
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// S3PathCollector is the interface for querying document S3 paths before deletion.
|
||||
// It is satisfied by both *repository.Queries and transaction-scoped queries.
|
||||
type S3PathCollector interface {
|
||||
CollectDocumentS3Paths(ctx context.Context, documentID uuid.UUID) ([]*S3Row, error)
|
||||
}
|
||||
|
||||
// S3Row holds the bucket and key from a documentEntries row.
|
||||
// This mirrors the generated CollectDocumentS3PathsRow to avoid leaking repository types.
|
||||
type S3Row struct {
|
||||
Bucket string
|
||||
Key string
|
||||
}
|
||||
|
||||
// CollectS3PathsForDocuments gathers S3 path info for a list of document IDs.
|
||||
// This is used by both client and folder hard-delete to collect orphaned S3 paths
|
||||
// before starting the delete transaction.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - docs: list of document UUIDs to collect paths for
|
||||
// - queryFn: function that returns S3 rows for a single document ID
|
||||
//
|
||||
// Returns the collected S3PathInfo slice, or an error if any query fails.
|
||||
func CollectS3PathsForDocuments(
|
||||
ctx context.Context,
|
||||
docs []uuid.UUID,
|
||||
queryFn func(ctx context.Context, docID uuid.UUID) ([]S3Row, error),
|
||||
) ([]S3PathInfo, error) {
|
||||
var paths []S3PathInfo
|
||||
for _, docID := range docs {
|
||||
rows, err := queryFn(ctx, docID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect S3 paths for document %s: %w", docID, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
paths = append(paths, S3PathInfo{
|
||||
DocumentID: docID,
|
||||
Bucket: row.Bucket,
|
||||
Key: row.Key,
|
||||
})
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// LogOrphanedS3Paths logs each orphaned S3 path at INFO level for recordkeeping.
|
||||
func LogOrphanedS3Paths(paths []S3PathInfo) {
|
||||
for _, p := range paths {
|
||||
slog.Info("document deleted, S3 source orphaned",
|
||||
"documentId", p.DocumentID,
|
||||
"s3Path", fmt.Sprintf("s3://%s/%s", p.Bucket, p.Key),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// S3PathInfo represents the S3 location of an orphaned source document.
|
||||
// After a hard delete, source files in S3 are intentionally left in place.
|
||||
// This struct carries the information needed for logging and verbose responses.
|
||||
type S3PathInfo struct {
|
||||
DocumentID uuid.UUID
|
||||
Bucket string
|
||||
Key string
|
||||
}
|
||||
|
||||
// NotFoundError indicates the target resource does not exist.
|
||||
type NotFoundError struct {
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return fmt.Sprintf("%s not found: %s", e.ResourceType, e.ResourceID)
|
||||
}
|
||||
|
||||
// ConflictError indicates the delete cannot proceed due to a state conflict.
|
||||
// Examples: deleting root folder, folder contains documents without include_documents=true.
|
||||
type ConflictError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ConflictError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
Reference in New Issue
Block a user