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
+129
View File
@@ -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
}
}
+164
View File
@@ -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, &notFoundErr))
})
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)
})
}
-6
View File
@@ -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
+44
View File
@@ -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).
+48
View File
@@ -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;
+53
View File
@@ -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);
-23
View File
@@ -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()
+136
View File
@@ -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)
`
+163
View File
@@ -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
-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)
})
}
+156
View File
@@ -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
}
}
+157
View File
@@ -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, &notFoundErr))
})
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))
})
}
+96
View File
@@ -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
}