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 } }