From 09c61ea9b435930eca9c915f82d907c1cfec8ab6 Mon Sep 17 00:00:00 2001 From: Jay Brown Date: Wed, 4 Mar 2026 18:30:13 +0000 Subject: [PATCH] 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 --- api/queryAPI/api.gen.go | 721 ++++++++++------- api/queryAPI/delete.go | 176 +++++ api/queryAPI/delete_test.go | 363 +++++++++ api/queryAPI/documents.go | 16 - api/queryAPI/documents_test.go | 35 - .../permit.setup/permit_policies.yaml | 6 +- docs/ai.generated/03-api-documentation.md | 129 ++- docs/ai.generated/09-authentication-guide.md | 13 +- docs/ai.generated/queryapi.summary.md | 35 +- internal/client/delete.go | 129 +++ internal/client/delete_test.go | 164 ++++ internal/database/queries/batch.sql | 6 - internal/database/queries/client.sql | 44 ++ internal/database/queries/document.sql | 48 ++ internal/database/queries/folders.sql | 53 ++ internal/database/repository/batch.sql.go | 23 - internal/database/repository/batch_test.go | 41 - internal/database/repository/client.sql.go | 136 ++++ internal/database/repository/document.sql.go | 142 ++++ internal/database/repository/folders.sql.go | 163 ++++ internal/document/batch/service.go | 8 - internal/document/delete.go | 115 +++ internal/document/delete_test.go | 161 ++++ internal/folder/delete.go | 156 ++++ internal/folder/delete_test.go | 157 ++++ internal/harddelete/types.go | 96 +++ pkg/queryAPI/api.gen.go | 733 ++++++++++++++---- serviceAPIs/queryAPI.yaml | 201 ++++- 28 files changed, 3438 insertions(+), 632 deletions(-) create mode 100644 api/queryAPI/delete.go create mode 100644 api/queryAPI/delete_test.go create mode 100644 internal/client/delete.go create mode 100644 internal/client/delete_test.go create mode 100644 internal/document/delete.go create mode 100644 internal/document/delete_test.go create mode 100644 internal/folder/delete.go create mode 100644 internal/folder/delete_test.go create mode 100644 internal/harddelete/types.go diff --git a/api/queryAPI/api.gen.go b/api/queryAPI/api.gen.go index 625b78c7..df5a49b1 100644 --- a/api/queryAPI/api.gen.go +++ b/api/queryAPI/api.gen.go @@ -534,6 +534,18 @@ type CollectorSet struct { MinimumCleanerVersion *CodeVersion `json:"minimum_cleaner_version,omitempty"` } +// DeleteResponse Response for hard-delete operations when verbose=true +type DeleteResponse struct { + // DeletedDocuments S3 paths of orphaned source documents (only populated when verbose=true) + DeletedDocuments []struct { + // DocumentId ID of the deleted document + DocumentId openapi_types.UUID `json:"documentId"` + + // S3Path Full S3 path (s3://bucket/key) of the orphaned source file + S3Path string `json:"s3Path"` + } `json:"deletedDocuments"` +} + // DocClient The properties of a client. type DocClient struct { // CanSync If the client is allowing active syncs @@ -1293,6 +1305,15 @@ type DeleteAdminUserParams struct { Confirm bool `form:"confirm" json:"confirm"` } +// DeleteClientParams defines parameters for DeleteClient. +type DeleteClientParams struct { + // Confirm Must be set to true to confirm deletion + Confirm bool `form:"confirm" json:"confirm"` + + // Verbose When true, returns S3 paths of orphaned source documents in the response body + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + // UploadDocumentMultipartBody defines parameters for UploadDocument. type UploadDocumentMultipartBody struct { // File The file to upload @@ -1324,6 +1345,12 @@ type ListClientFoldersParams struct { Metrics *bool `form:"metrics,omitempty" json:"metrics,omitempty"` } +// DeleteDocumentParams defines parameters for DeleteDocument. +type DeleteDocumentParams struct { + // Verbose When true, returns S3 paths of orphaned source documents in the response body + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + // GetDocumentParams defines parameters for GetDocument. type GetDocumentParams struct { // TextRecord When true, includes the full text extraction record in the response @@ -1351,6 +1378,15 @@ type GetFieldExtractionByVersionParams struct { Version int64 `form:"version" json:"version"` } +// DeleteFolderParams defines parameters for DeleteFolder. +type DeleteFolderParams struct { + // IncludeDocuments When true, also deletes all documents in the folder tree + IncludeDocuments *bool `form:"include_documents,omitempty" json:"include_documents,omitempty"` + + // Verbose When true, returns S3 paths of orphaned source documents in the response body + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + // GetFolderDocumentsParams defines parameters for GetFolderDocuments. type GetFolderDocumentsParams struct { // TextRecord When true, includes the full text extraction record for each document @@ -1464,6 +1500,9 @@ type ServerInterface interface { // Create a new client // (POST /client) CreateClient(ctx echo.Context) error + // Hard-delete a client and all its data + // (DELETE /client/{id}) + DeleteClient(ctx echo.Context, id ClientID, params DeleteClientParams) error // Get a client by ID // (GET /client/{id}) GetClient(ctx echo.Context, id ClientID) error @@ -1488,9 +1527,6 @@ type ServerInterface interface { // Upload multiple PDF documents as ZIP archive // (POST /client/{id}/document/batch) UploadDocumentBatch(ctx echo.Context, id ClientID) error - // Cancel a batch upload - // (DELETE /client/{id}/document/batch/{batch_id}) - CancelDocumentBatch(ctx echo.Context, id ClientID, batchId BatchID) error // Get batch upload status // (GET /client/{id}/document/batch/{batch_id}) GetDocumentBatch(ctx echo.Context, id ClientID, batchId BatchID) error @@ -1506,6 +1542,9 @@ type ServerInterface interface { // List all clients // (GET /clients) ListClients(ctx echo.Context) error + // Hard-delete a document + // (DELETE /document/{id}) + DeleteDocument(ctx echo.Context, id DocumentID, params DeleteDocumentParams) error // Get document details by its id // (GET /document/{id}) GetDocument(ctx echo.Context, id DocumentID, params GetDocumentParams) error @@ -1542,6 +1581,9 @@ type ServerInterface interface { // Create a new folder // (POST /folders) CreateFolder(ctx echo.Context) error + // Hard-delete a folder tree + // (DELETE /folders/{folderId}) + DeleteFolder(ctx echo.Context, folderId openapi_types.UUID, params DeleteFolderParams) error // Rename a folder // (PATCH /folders/{folderId}) RenameFolder(ctx echo.Context, folderId openapi_types.UUID) error @@ -1944,6 +1986,40 @@ func (w *ServerInterfaceWrapper) CreateClient(ctx echo.Context) error { return err } +// DeleteClient converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteClient(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id ClientID + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteClientParams + // ------------- Required query parameter "confirm" ------------- + + err = runtime.BindQueryParameter("form", true, true, "confirm", ctx.QueryParams(), ¶ms.Confirm) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter confirm: %s", err)) + } + + // ------------- Optional query parameter "verbose" ------------- + + err = runtime.BindQueryParameter("form", true, false, "verbose", ctx.QueryParams(), ¶ms.Verbose) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter verbose: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteClient(ctx, id, params) + return err +} + // GetClient converts echo context to params. func (w *ServerInterfaceWrapper) GetClient(ctx echo.Context) error { var err error @@ -2104,32 +2180,6 @@ func (w *ServerInterfaceWrapper) UploadDocumentBatch(ctx echo.Context) error { return err } -// CancelDocumentBatch converts echo context to params. -func (w *ServerInterfaceWrapper) CancelDocumentBatch(ctx echo.Context) error { - var err error - // ------------- Path parameter "id" ------------- - var id ClientID - - err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) - } - - // ------------- Path parameter "batch_id" ------------- - var batchId BatchID - - err = runtime.BindStyledParameterWithOptions("simple", "batch_id", ctx.Param("batch_id"), &batchId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter batch_id: %s", err)) - } - - ctx.Set(JwtAuthScopes, []string{}) - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.CancelDocumentBatch(ctx, id, batchId) - return err -} - // GetDocumentBatch converts echo context to params. func (w *ServerInterfaceWrapper) GetDocumentBatch(ctx echo.Context) error { var err error @@ -2230,6 +2280,33 @@ func (w *ServerInterfaceWrapper) ListClients(ctx echo.Context) error { return err } +// DeleteDocument converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteDocument(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id DocumentID + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteDocumentParams + // ------------- Optional query parameter "verbose" ------------- + + err = runtime.BindQueryParameter("form", true, false, "verbose", ctx.QueryParams(), ¶ms.Verbose) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter verbose: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteDocument(ctx, id, params) + return err +} + // GetDocument converts echo context to params. func (w *ServerInterfaceWrapper) GetDocument(ctx echo.Context) error { var err error @@ -2431,6 +2508,40 @@ func (w *ServerInterfaceWrapper) CreateFolder(ctx echo.Context) error { return err } +// DeleteFolder converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteFolder(ctx echo.Context) error { + var err error + // ------------- Path parameter "folderId" ------------- + var folderId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "folderId", ctx.Param("folderId"), &folderId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter folderId: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteFolderParams + // ------------- Optional query parameter "include_documents" ------------- + + err = runtime.BindQueryParameter("form", true, false, "include_documents", ctx.QueryParams(), ¶ms.IncludeDocuments) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter include_documents: %s", err)) + } + + // ------------- Optional query parameter "verbose" ------------- + + err = runtime.BindQueryParameter("form", true, false, "verbose", ctx.QueryParams(), ¶ms.Verbose) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter verbose: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteFolder(ctx, folderId, params) + return err +} + // RenameFolder converts echo context to params. func (w *ServerInterfaceWrapper) RenameFolder(ctx echo.Context) error { var err error @@ -2634,6 +2745,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.POST(baseURL+"/admin/users/:email/disable", wrapper.DisableAdminUser) router.POST(baseURL+"/admin/users/:email/enable", wrapper.EnableAdminUser) router.POST(baseURL+"/client", wrapper.CreateClient) + router.DELETE(baseURL+"/client/:id", wrapper.DeleteClient) router.GET(baseURL+"/client/:id", wrapper.GetClient) router.PATCH(baseURL+"/client/:id", wrapper.UpdateClient) router.GET(baseURL+"/client/:id/collector", wrapper.GetCollectorByClientId) @@ -2642,12 +2754,12 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.POST(baseURL+"/client/:id/document", wrapper.UploadDocument) router.GET(baseURL+"/client/:id/document/batch", wrapper.ListDocumentBatches) router.POST(baseURL+"/client/:id/document/batch", wrapper.UploadDocumentBatch) - router.DELETE(baseURL+"/client/:id/document/batch/:batch_id", wrapper.CancelDocumentBatch) router.GET(baseURL+"/client/:id/document/batch/:batch_id", wrapper.GetDocumentBatch) router.POST(baseURL+"/client/:id/export", wrapper.TriggerExport) router.GET(baseURL+"/client/:id/folders", wrapper.ListClientFolders) router.GET(baseURL+"/client/:id/status", wrapper.GetStatusByClientId) router.GET(baseURL+"/clients", wrapper.ListClients) + router.DELETE(baseURL+"/document/:id", wrapper.DeleteDocument) router.GET(baseURL+"/document/:id", wrapper.GetDocument) router.GET(baseURL+"/documents/:documentId/labels", wrapper.GetDocumentLabels) router.POST(baseURL+"/documents/:documentId/labels", wrapper.ApplyLabel) @@ -2660,6 +2772,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/field-extractions/history", wrapper.GetFieldExtractionHistory) router.GET(baseURL+"/field-extractions/version", wrapper.GetFieldExtractionByVersion) router.POST(baseURL+"/folders", wrapper.CreateFolder) + router.DELETE(baseURL+"/folders/:folderId", wrapper.DeleteFolder) router.PATCH(baseURL+"/folders/:folderId", wrapper.RenameFolder) router.GET(baseURL+"/folders/:folderId/documents", wrapper.GetFolderDocuments) router.GET(baseURL+"/folders/:folderId/metrics", wrapper.GetFolderMetrics) @@ -2675,276 +2788,284 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "sQEA", + "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", + "RzcHwbdP3/7/AAAA///pcQ5AzMEBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/queryAPI/delete.go b/api/queryAPI/delete.go new file mode 100644 index 00000000..eef4953e --- /dev/null +++ b/api/queryAPI/delete.go @@ -0,0 +1,176 @@ +// delete.go implements the hard-delete API handlers for client, document, and folder resources. +// These handlers permanently remove data from the database. Source documents in S3 are +// intentionally NOT deleted; their paths are logged and optionally returned in verbose mode. +// Delete operations are restricted to super_admin via Permit.io middleware. +package queryapi + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + + "queryorchestration/internal/harddelete" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// DeleteClient permanently deletes a client and all associated data. +// DELETE /client/{id}?confirm=true&verbose=false +// Requires confirm=true query parameter as a safety guard. +// When verbose=true, returns 200 with S3 paths of orphaned source documents. +// When verbose=false (default), returns 204 No Content. +func (s *Controllers) DeleteClient(ctx echo.Context, id ClientID, params DeleteClientParams) error { + if !params.Confirm { + return ctx.JSON(http.StatusBadRequest, ErrorMessage{ + Message: "confirm=true query parameter is required to delete a client", + }) + } + + adminUser, err := getAdminUserForAudit(ctx) + if err != nil { + return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ + Message: "Could not identify admin user", + }) + } + + slog.Info("client hard-delete requested", + "clientId", id, + "requestedBy", adminUser.Email, + ) + + result, err := s.svc.Client.HardDelete(ctx.Request().Context(), string(id)) + if err != nil { + return handleDeleteError(ctx, err, "client", string(id)) + } + + slog.Info("client hard-deleted", + "clientId", id, + "requestedBy", adminUser.Email, + "orphanedS3Objects", len(result), + ) + + return respondDelete(ctx, params.Verbose, result) +} + +// DeleteDocument permanently deletes a document and all dependent data. +// DELETE /document/{id}?verbose=false +// When verbose=true, returns 200 with S3 paths of orphaned source documents. +// When verbose=false (default), returns 204 No Content. +func (s *Controllers) DeleteDocument(ctx echo.Context, id DocumentID, params DeleteDocumentParams) error { + adminUser, err := getAdminUserForAudit(ctx) + if err != nil { + return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ + Message: "Could not identify admin user", + }) + } + + docUUID := uuid.UUID(id) + + slog.Info("document hard-delete requested", + "documentId", docUUID, + "requestedBy", adminUser.Email, + ) + + result, err := s.svc.Document.HardDelete(ctx.Request().Context(), docUUID) + if err != nil { + return handleDeleteError(ctx, err, "document", docUUID.String()) + } + + slog.Info("document hard-deleted", + "documentId", docUUID, + "requestedBy", adminUser.Email, + "orphanedS3Objects", len(result), + ) + + return respondDelete(ctx, params.Verbose, result) +} + +// DeleteFolder permanently deletes a folder tree and optionally its documents. +// DELETE /folders/{folderId}?include_documents=false&verbose=false +// The root folder (path '/') cannot be deleted (returns 409). +// If the folder tree contains documents and include_documents is false, returns 409. +// When verbose=true, returns 200 with S3 paths of orphaned source documents. +// When verbose=false (default), returns 204 No Content. +func (s *Controllers) DeleteFolder(ctx echo.Context, folderId openapi_types.UUID, params DeleteFolderParams) error { + adminUser, err := getAdminUserForAudit(ctx) + if err != nil { + return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ + Message: "Could not identify admin user", + }) + } + + includeDocuments := params.IncludeDocuments != nil && *params.IncludeDocuments + + slog.Info("folder hard-delete requested", + "folderId", folderId, + "includeDocuments", includeDocuments, + "requestedBy", adminUser.Email, + ) + + result, err := s.svc.Folder.HardDelete(ctx.Request().Context(), uuid.UUID(folderId), includeDocuments) + if err != nil { + return handleDeleteError(ctx, err, "folder", uuid.UUID(folderId).String()) + } + + slog.Info("folder hard-deleted", + "folderId", folderId, + "includeDocuments", includeDocuments, + "requestedBy", adminUser.Email, + "orphanedS3Objects", len(result), + ) + + return respondDelete(ctx, params.Verbose, result) +} + +// handleDeleteError maps service-layer errors to appropriate HTTP responses. +// Returns 404 for not-found, 409 for conflict, and 500 for unexpected errors. +func handleDeleteError(ctx echo.Context, err error, resourceType string, resourceID string) error { + var conflictErr *harddelete.ConflictError + if errors.As(err, &conflictErr) { + return ctx.JSON(http.StatusConflict, ErrorMessage{ + Message: conflictErr.Error(), + }) + } + + var notFoundErr *harddelete.NotFoundError + if errors.As(err, ¬FoundErr) { + return ctx.JSON(http.StatusNotFound, ErrorMessage{ + Message: notFoundErr.Error(), + }) + } + + slog.Error("hard-delete failed", + "resourceType", resourceType, + "resourceId", resourceID, + "error", err, + ) + return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ + Message: fmt.Sprintf("failed to delete %s: internal error", resourceType), + }) +} + +// respondDelete sends the appropriate HTTP response based on the verbose flag. +// When verbose is true, returns 200 with a DeleteResponse containing S3 paths. +// When verbose is false or nil, returns 204 No Content. +func respondDelete(ctx echo.Context, verbose *bool, s3Paths []harddelete.S3PathInfo) error { + if verbose != nil && *verbose { + docs := make([]struct { + DocumentId openapi_types.UUID `json:"documentId"` + S3Path string `json:"s3Path"` + }, len(s3Paths)) + + for i, p := range s3Paths { + docs[i].DocumentId = openapi_types.UUID(p.DocumentID) + docs[i].S3Path = fmt.Sprintf("s3://%s/%s", p.Bucket, p.Key) + } + + return ctx.JSON(http.StatusOK, DeleteResponse{ + DeletedDocuments: docs, + }) + } + + return ctx.NoContent(http.StatusNoContent) +} diff --git a/api/queryAPI/delete_test.go b/api/queryAPI/delete_test.go new file mode 100644 index 00000000..892a9d8d --- /dev/null +++ b/api/queryAPI/delete_test.go @@ -0,0 +1,363 @@ +package queryapi_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + queryapi "queryorchestration/api/queryAPI" + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/database/repository" + "queryorchestration/internal/test" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createDeleteContext creates an Echo context with DELETE method and admin user info set. +// This simulates an authenticated admin making a delete request. +func createDeleteContext(t testing.TB) (echo.Context, *httptest.ResponseRecorder) { + t.Helper() + e := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + ctx.Set("user_info", cognitoauth.UserInfo{ + Email: "admin@test.local", + Username: "admin", + Groups: []string{"admin"}, + }) + return ctx, rec +} + +// seedClientWithDocument creates a client, a root folder (via CreateTestClient), a sub-folder, +// and a document with an S3 entry. Returns the document ID and sub-folder ID. +func seedClientWithDocument(t *testing.T, cfg *ControllerConfig, clientID string) (uuid.UUID, uuid.UUID) { + t.Helper() + ctx := t.Context() + q := cfg.GetDBQueries() + + test.CreateTestClient(t, cfg, clientID, "Test Client "+clientID) + + // Get root folder created by CreateTestClient + rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{ + Clientid: clientID, + Path: "/", + }) + require.NoError(t, err) + + // Create a sub-folder + subFolder, err := q.CreateFolder(ctx, &repository.CreateFolderParams{ + Path: "/test-folder", + Clientid: clientID, + Createdby: "admin@test.local", + Parentid: &rootFolder.ID, + }) + require.NoError(t, err) + + // Create a document in the sub-folder + filename := "test-doc.pdf" + docID, err := q.CreateDocument(ctx, &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: "hash-" + clientID, + Filename: &filename, + Folderid: &subFolder.ID, + }) + require.NoError(t, err) + + // Add an S3 entry for the document + err = q.AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{ + Documentid: docID, + Bucket: "test-bucket", + Key: "test-key/" + clientID + "/doc.pdf", + }) + require.NoError(t, err) + + return docID, subFolder.ID +} + +func TestDeleteDocument_Success(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-doc-success" + docID, _ := seedClientWithDocument(t, cfg, clientID) + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteDocument(ctx, openapi_types.UUID(docID), queryapi.DeleteDocumentParams{}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + + // Verify document is actually gone + _, dbErr := cfg.GetDBQueries().GetDocumentSummary(t.Context(), docID) + require.Error(t, dbErr) +} + +// ptrBool returns a pointer to a bool value. +func ptrBool(b bool) *bool { + return &b +} + +func TestDeleteDocument_Verbose(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-doc-verbose" + docID, _ := seedClientWithDocument(t, cfg, clientID) + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteDocument(ctx, openapi_types.UUID(docID), queryapi.DeleteDocumentParams{ + Verbose: ptrBool(true), + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.DeleteResponse + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + + require.Len(t, resp.DeletedDocuments, 1) + assert.Equal(t, openapi_types.UUID(docID), resp.DeletedDocuments[0].DocumentId) + assert.Contains(t, resp.DeletedDocuments[0].S3Path, "s3://test-bucket/test-key/") +} + +func TestDeleteDocument_NotFound(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + ctx, rec := createDeleteContext(t) + + fakeID := uuid.New() + err := cons.DeleteDocument(ctx, openapi_types.UUID(fakeID), queryapi.DeleteDocumentParams{}) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Code) + + var resp queryapi.ErrorMessage + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp.Message, "not found") +} + +func TestDeleteClient_MissingConfirm(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-client-noconfirm" + test.CreateTestClient(t, cfg, clientID, "Test Client") + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteClient(ctx, queryapi.ClientID(clientID), queryapi.DeleteClientParams{ + Confirm: false, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var resp queryapi.ErrorMessage + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp.Message, "confirm=true") +} + +func TestDeleteClient_Success(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-client-ok" + seedClientWithDocument(t, cfg, clientID) + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteClient(ctx, queryapi.ClientID(clientID), queryapi.DeleteClientParams{ + Confirm: true, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + + // Verify client is gone + _, dbErr := cfg.GetDBQueries().GetClient(t.Context(), clientID) + require.Error(t, dbErr) +} + +func TestDeleteClient_Verbose(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-client-verbose" + docID, _ := seedClientWithDocument(t, cfg, clientID) + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteClient(ctx, queryapi.ClientID(clientID), queryapi.DeleteClientParams{ + Confirm: true, + Verbose: ptrBool(true), + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.DeleteResponse + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + + require.Len(t, resp.DeletedDocuments, 1) + assert.Equal(t, openapi_types.UUID(docID), resp.DeletedDocuments[0].DocumentId) + assert.Contains(t, resp.DeletedDocuments[0].S3Path, "s3://test-bucket/") +} + +func TestDeleteFolder_Success(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-folder-ok" + test.CreateTestClient(t, cfg, clientID, "Test Client") + + q := cfg.GetDBQueries() + rootFolder, err := q.GetFolderByPath(t.Context(), &repository.GetFolderByPathParams{ + Clientid: clientID, + Path: "/", + }) + require.NoError(t, err) + + subFolder, err := q.CreateFolder(t.Context(), &repository.CreateFolderParams{ + Path: "/empty-folder", + Clientid: clientID, + Createdby: "admin@test.local", + Parentid: &rootFolder.ID, + }) + require.NoError(t, err) + + ctx, rec := createDeleteContext(t) + + err = cons.DeleteFolder(ctx, openapi_types.UUID(subFolder.ID), queryapi.DeleteFolderParams{}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + + // Verify folder is gone + _, dbErr := q.GetFolderByID(t.Context(), subFolder.ID) + require.Error(t, dbErr) +} + +func TestDeleteFolder_RootFolderConflict(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-folder-root" + test.CreateTestClient(t, cfg, clientID, "Test Client") + + q := cfg.GetDBQueries() + rootFolder, err := q.GetFolderByPath(t.Context(), &repository.GetFolderByPathParams{ + Clientid: clientID, + Path: "/", + }) + require.NoError(t, err) + + ctx, rec := createDeleteContext(t) + + err = cons.DeleteFolder(ctx, openapi_types.UUID(rootFolder.ID), queryapi.DeleteFolderParams{}) + require.NoError(t, err) + assert.Equal(t, http.StatusConflict, rec.Code) + + var resp queryapi.ErrorMessage + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp.Message, "root folder") +} + +func TestDeleteFolder_DocumentsWithoutInclude(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-folder-nodocs" + _, folderID := seedClientWithDocument(t, cfg, clientID) + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteFolder(ctx, openapi_types.UUID(folderID), queryapi.DeleteFolderParams{}) + require.NoError(t, err) + assert.Equal(t, http.StatusConflict, rec.Code) + + var resp queryapi.ErrorMessage + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + assert.Contains(t, resp.Message, "documents") +} + +func TestDeleteFolder_IncludeDocumentsCascade(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + clientID := "del-folder-cascade" + docID, folderID := seedClientWithDocument(t, cfg, clientID) + + ctx, rec := createDeleteContext(t) + + err := cons.DeleteFolder(ctx, openapi_types.UUID(folderID), queryapi.DeleteFolderParams{ + IncludeDocuments: ptrBool(true), + Verbose: ptrBool(true), + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var resp queryapi.DeleteResponse + err = json.Unmarshal(rec.Body.Bytes(), &resp) + require.NoError(t, err) + + require.Len(t, resp.DeletedDocuments, 1) + assert.Equal(t, openapi_types.UUID(docID), resp.DeletedDocuments[0].DocumentId) + + // Verify both folder and document are gone + q := cfg.GetDBQueries() + _, dbErr := q.GetFolderByID(t.Context(), folderID) + require.Error(t, dbErr) + _, dbErr = q.GetDocumentSummary(t.Context(), docID) + require.Error(t, dbErr) +} diff --git a/api/queryAPI/documents.go b/api/queryAPI/documents.go index d11d55b4..3b777af1 100644 --- a/api/queryAPI/documents.go +++ b/api/queryAPI/documents.go @@ -288,19 +288,3 @@ func (s *Controllers) GetDocumentBatch(ctx echo.Context, clientId ClientID, batc return ctx.JSON(http.StatusOK, details) } - -// CancelDocumentBatch cancels a batch upload in progress -func (s *Controllers) CancelDocumentBatch(ctx echo.Context, clientId ClientID, batchId BatchID) error { - // BatchID is already a UUID type - batchUUID := uuid.UUID(batchId) - - // Cancel the batch - err := s.svc.DocumentBatch.Cancel(ctx.Request().Context(), clientId, batchUUID) - if err != nil { - slog.Error("unable to cancel batch", "error", err, "batchId", batchId) - return echo.NewHTTPError(http.StatusNotFound, "batch not found or cannot be canceled") - } - - // Return 204 No Content for successful cancellation - return ctx.NoContent(http.StatusNoContent) -} diff --git a/api/queryAPI/documents_test.go b/api/queryAPI/documents_test.go index 12051b30..f30af3ea 100644 --- a/api/queryAPI/documents_test.go +++ b/api/queryAPI/documents_test.go @@ -418,41 +418,6 @@ func TestGetDocumentBatch(t *testing.T) { assert.Equal(t, "test.zip", response.OriginalFilename) } -func TestCancelDocumentBatch(t *testing.T) { - cfg := &ControllerConfig{} - test.CreateDB(t, cfg) - initializeTestConfig(t, cfg) - - svc := createControllerServices(cfg) - cons := queryapi.NewControllers(svc, cfg) - - // Create test client with root folder - clientID := "test_client_cancel" - test.CreateTestClient(t, cfg, clientID, "Test Client Batch Cancel") - - // Create test batch - batchID, err := cfg.GetDBQueries().CreateBatchUpload(t.Context(), &repository.CreateBatchUploadParams{ - ClientID: clientID, - OriginalFilename: "test.zip", - TotalDocuments: 10, - }) - require.NoError(t, err) - - // Test canceling batch - ctx, rec := createContext(t) - err = cons.CancelDocumentBatch(ctx, clientID, queryapi.BatchID(batchID)) - require.NoError(t, err) - assert.Equal(t, http.StatusNoContent, rec.Code) - - // Verify batch was canceled - batch, err := cfg.GetDBQueries().GetBatchUpload(t.Context(), &repository.GetBatchUploadParams{ - ID: batchID, - ClientID: clientID, - }) - require.NoError(t, err) - assert.Equal(t, repository.BatchStatusCancelled, batch.Status) -} - func TestUploadDocumentBatch_Part1_Success(t *testing.T) { // Setup: Create testcontainers (database + localstack) cfg := &ControllerConfig{} diff --git a/cmd/auth_related/permit.setup/permit_policies.yaml b/cmd/auth_related/permit.setup/permit_policies.yaml index 64da3631..253cb138 100644 --- a/cmd/auth_related/permit.setup/permit_policies.yaml +++ b/cmd/auth_related/permit.setup/permit_policies.yaml @@ -61,6 +61,7 @@ resources: - get - post - patch + - delete - name: labels description: Label-based document lookup @@ -105,6 +106,7 @@ roles: - get - post - patch + - delete labels: - get @@ -157,7 +159,6 @@ roles: - get - post - patch - - delete clients: - get document: @@ -216,6 +217,7 @@ policy_mappings: methods: GET: client:get PATCH: client:patch + DELETE: client:delete - path: /client/{id}/status methods: GET: client:get @@ -251,6 +253,7 @@ policy_mappings: - path: /document/{id} methods: GET: document:get + DELETE: document:delete export_endpoints: - path: /export/{id} @@ -285,6 +288,7 @@ policy_mappings: - path: /folders/{folderId} methods: PATCH: folders:patch + DELETE: folders:delete - path: /folders/{folderId}/documents methods: GET: folders:get diff --git a/docs/ai.generated/03-api-documentation.md b/docs/ai.generated/03-api-documentation.md index c9d38291..cd496eaa 100644 --- a/docs/ai.generated/03-api-documentation.md +++ b/docs/ai.generated/03-api-documentation.md @@ -266,6 +266,36 @@ Updates client information. **Response**: `200` Client updated successfully +### `DELETE /client/{id}` + +Permanently deletes a client and all associated data: documents (with all dependent rows), folders, collector configurations, batch uploads, and clientCanSync records. Source documents in S3 are NOT deleted; their paths are logged and optionally returned. This operation is irreversible. + +**Security**: Requires JWT authentication. Restricted to `super_admin` role only. + +**Query Parameters**: + +- `confirm` (required, boolean): Must be `true` to proceed. Returns `400 Bad Request` if absent or false. +- `verbose` (optional, boolean, default: false): When `true`, returns S3 paths of orphaned source documents. + +**Response**: + +- `204`: Client deleted successfully (when `verbose=false`) +- `200`: Client deleted successfully with S3 paths (when `verbose=true`): + +```json +{ + "deletedDocuments": [ + { + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "s3Path": "s3://bucket-name/key/path/to/file" + } + ] +} +``` + +- `400`: Missing `confirm=true` query parameter +- `404`: Client not found + ### `GET /client/{id}/status` Returns client synchronization status. @@ -356,6 +386,38 @@ Retrieves enriched document details by ID, including metadata such as folder, fi } ``` +### `DELETE /document/{id}` + +Permanently deletes a document and all dependent data: document entries, cleans (and their entries), text extractions (and their entries), field extractions (versions and array fields), labels, results, and result dependencies. Source documents in S3 are NOT deleted; their paths are logged and optionally returned. + +**Security**: Requires JWT authentication. Restricted to `super_admin` role only. + +**Path Parameters**: + +- `id`: Document UUID (max 36 chars) + +**Query Parameters**: + +- `verbose` (optional, boolean, default: false): When `true`, returns S3 paths of orphaned source documents. + +**Response**: + +- `204`: Document deleted successfully (when `verbose=false`) +- `200`: Document deleted successfully with S3 paths (when `verbose=true`): + +```json +{ + "deletedDocuments": [ + { + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "s3Path": "s3://bucket-name/key/path/to/file" + } + ] +} +``` + +- `404`: Document not found + --- ## Batch Document Operations (DocumentsService) @@ -448,17 +510,6 @@ Retrieves detailed status information for a specific batch upload. } ``` -### `DELETE /client/{id}/document/batch/{batch_id}` - -Cancels a batch upload that is currently processing. - -**Security**: Requires JWT authentication - -**Response**: - -- `204`: Batch upload cancelled successfully -- `404`: Batch not found - --- ## Folder Operations (FolderService) @@ -612,6 +663,40 @@ Renames an existing folder. **Response** (`200 OK`): Updated folder object +### `DELETE /folders/{folderId}` + +Permanently deletes a folder and all child folders recursively. The root folder (`/`) cannot be deleted. If the folder tree contains documents, the caller must either delete the documents first or pass `include_documents=true`. + +**Security**: Requires JWT authentication. Restricted to `super_admin` role only. + +**Path Parameters**: + +- `folderId`: Folder UUID + +**Query Parameters**: + +- `include_documents` (optional, boolean, default: false): When `true`, deletes all documents in the folder tree along with the folders. When `false` and the folder tree contains documents, returns `409 Conflict`. +- `verbose` (optional, boolean, default: false): When `true`, returns S3 paths of orphaned source documents. + +**Response**: + +- `204`: Folder tree deleted successfully (when `verbose=false`) +- `200`: Folder tree deleted successfully with S3 paths (when `verbose=true`): + +```json +{ + "deletedDocuments": [ + { + "documentId": "019580df-b3f8-7348-9ab1-1e55b3f18ed7", + "s3Path": "s3://bucket-name/key/path/to/file" + } + ] +} +``` + +- `404`: Folder not found +- `409`: Cannot delete root folder, or folder tree contains documents and `include_documents` is not `true` + ### `GET /folders/{folderId}/documents` Retrieves all documents within a specific folder, including filename and labels. Returns a lighter-weight format than `GET /document/{id}` (excludes `client_id`). @@ -1637,20 +1722,22 @@ The following table shows what data is passed to Permit.io for each protected en | `GET` | `/clients` | `clients` | `get` | List all clients | | `GET` | `/client/{id}` | `client` | `get` | Get client details | | `PATCH` | `/client/{id}` | `client` | `patch` | Update client | +| `DELETE` | `/client/{id}` | `client` | `delete` | Hard-delete client (super_admin only) | | `GET` | `/client/{id}/status` | `client` | `get` | Get client sync status | | **Document Operations** | | `POST` | `/client/{id}/document` | `client` | `post` | Upload document | | `GET` | `/client/{id}/document` | `client` | `get` | List client documents | | `GET` | `/document/{id}` | `document` | `get` | Get document details | +| `DELETE` | `/document/{id}` | `document` | `delete` | Hard-delete document (super_admin only) | | **Batch Document Operations** | | `POST` | `/client/{id}/document/batch` | `client` | `post` | Upload batch (ZIP) | | `GET` | `/client/{id}/document/batch` | `client` | `get` | List batch uploads | | `GET` | `/client/{id}/document/batch/{batch_id}` | `client` | `get` | Get batch status | -| `DELETE` | `/client/{id}/document/batch/{batch_id}` | `client` | `delete` | Cancel batch upload | | **Folder Operations** | | `GET` | `/client/{id}/folders` | `client` | `get` | List client folders | | `POST` | `/folders` | `folders` | `post` | Create folder | | `PATCH` | `/folders/{folderId}` | `folders` | `patch` | Rename folder | +| `DELETE` | `/folders/{folderId}` | `folders` | `delete` | Hard-delete folder tree (super_admin only) | | `GET` | `/folders/{folderId}/documents` | `folders` | `get` | List folder documents | | `GET` | `/folders/{folderId}/metrics` | `folders` | `get` | Get folder metrics | | **Label Operations** | @@ -1717,16 +1804,18 @@ To configure Permit.io to work with this API, administrators must create: | `delete` | DELETE | Delete resources | | `head` | HEAD | Check resource existence | -#### Example Role Configuration +#### Role Configuration -Typical role assignments for common personas: +The following roles are defined in the Permit.io configuration (`cmd/auth_related/permit.setup/permit_policies.yaml`): -| Role | Resources | Actions | Use Case | -| ------------ | -------------------------------------------------------------------------- | ---------------------- | -------------------------- | -| `viewer` | `client`, `clients`, `document`, `documents`, `folders`, `export` | `get` | Read-only access | -| `editor` | `client`, `document`, `documents`, `folders`, `field-extractions` | `get`, `post`, `patch` | Content management | -| `admin` | All resources | All actions | Full administrative access | -| `user_admin` | `admin` | All actions | User management only | +| Role | Resources | Actions | Use Case | +| -------------- | -------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------- | +| `super_admin` | All resources | All actions including `delete` | Full administrative access | +| `user_admin` | `admin`, `documents`, `field-extractions`, `folders` | `get`, `post`, `patch`, `delete` (admin only) | User management | +| `auditor` | `admin`, `client`, `clients`, `document`, `export`, `documents`, `field-extractions`, `folders`, `labels` | `get` (and `head` for admin) | Read-only access for auditing | +| `client_user` | `client`, `clients`, `document`, `export`, `documents`, `field-extractions`, `folders`, `labels` | `get`, `post`, `patch` (varies by resource) | Client-specific access | + +**Delete permissions**: Only `super_admin` has `delete` access to `client`, `document`, and `folders` resources. The `client_user` role does NOT have `client:delete` or any other delete permissions on these resources. ### Authorization Error Responses diff --git a/docs/ai.generated/09-authentication-guide.md b/docs/ai.generated/09-authentication-guide.md index bba6fd49..914d93fc 100644 --- a/docs/ai.generated/09-authentication-guide.md +++ b/docs/ai.generated/09-authentication-guide.md @@ -309,10 +309,10 @@ Permissions follow the format `resource:action`: | Resource | Actions | Description | |----------|---------|-------------| -| `client` | `get`, `post`, `patch`, `delete` | Client management | +| `client` | `get`, `post`, `patch`, `delete` | Client management (delete: super_admin only) | | `clients` | `get` | List all clients | -| `document` | `get`, `post`, `patch`, `delete` | Document operations | -| `folders` | `get`, `post`, `patch`, `delete` | Folder management | +| `document` | `get`, `post`, `delete` | Document operations (delete: super_admin only) | +| `folders` | `get`, `post`, `patch`, `delete` | Folder management (delete: super_admin only) | | `export` | `get`, `post` | Export operations | | `admin` | `get`, `post`, `patch`, `delete` | User and EULA administration | | `eula` | `get`, `post` | EULA status and agreement | @@ -402,6 +402,9 @@ const UI_FEATURES = { viewDocuments: ['client:get', 'document:get'], uploadDocuments: ['client:post'], manageClients: ['client:post', 'client:patch'], + deleteClients: ['client:delete'], + deleteDocuments: ['document:delete'], + deleteFolders: ['folders:delete'], viewExports: ['export:get'], createExports: ['export:post'], adminPanel: ['admin:get'], @@ -431,6 +434,7 @@ function canAccessFeature( | `401` | Unauthorized | Token invalid/expired - refresh or re-login | | `403` | Forbidden | User lacks permission - show access denied | | `404` | Not Found | Resource doesn't exist | +| `409` | Conflict | Operation cannot proceed in current state (e.g., folder has documents) | | `429` | Too Many Requests | Rate limited - implement backoff | | `500` | Server Error | Show generic error, retry later | | `502` | Bad Gateway | Authorization service unavailable | @@ -898,6 +902,9 @@ Content-Type: application/json | `/client/{id}/document` | POST | Upload document (multipart/form-data) | | `/client/{id}/document/batch` | POST | Upload ZIP batch | | `/document/{id}` | GET | Get document details | +| `/client/{id}` | DELETE | Hard-delete client (super_admin, requires confirm=true) | +| `/document/{id}` | DELETE | Hard-delete document (super_admin) | +| `/folders/{folderId}` | DELETE | Hard-delete folder tree (super_admin) | | `/clients/{clientId}/folders` | GET | List folders | | `/eula` | GET | Get current EULA (no auth required) | | `/eula/status` | GET | Check user's EULA agreement status | diff --git a/docs/ai.generated/queryapi.summary.md b/docs/ai.generated/queryapi.summary.md index ce7f5ab5..979a8485 100644 --- a/docs/ai.generated/queryapi.summary.md +++ b/docs/ai.generated/queryapi.summary.md @@ -13,18 +13,18 @@ This document provides a comprehensive summary of all REST API endpoints availab | /identity | GET | AuthService | | /client | POST | ClientService | | /clients | GET | ClientService | -| /client/{id} | GET, PATCH | ClientService | +| /client/{id} | GET, PATCH, DELETE | ClientService | | /client/{id}/status | GET | ClientService | | /client/{id}/collector | GET, PATCH | CollectorService | | /client/{id}/folders | GET | FolderService | | /folders | POST | FolderService | -| /folders/{folderId} | PATCH | FolderService | +| /folders/{folderId} | PATCH, DELETE | FolderService | | /folders/{folderId}/documents | GET | FolderService | | /folders/{folderId}/metrics | GET | FolderService | | /client/{id}/document | GET, POST | DocumentsService | | /client/{id}/document/batch | GET, POST | DocumentsService | -| /client/{id}/document/batch/{batch_id} | GET, DELETE | DocumentsService | -| /document/{id} | GET | DocumentsService | +| /client/{id}/document/batch/{batch_id} | GET | DocumentsService | +| /document/{id} | GET, DELETE | DocumentsService | | /documents/{documentId}/labels | GET, POST | LabelService | | /labels/{labelName}/documents | GET | LabelService | | /field-extractions | GET, POST | FieldExtractionService | @@ -90,6 +90,14 @@ This document provides a comprehensive summary of all REST API endpoints availab - Request Body: `{name?, can_sync?}` (optional fields) - Returns: 200 OK on success +- **DELETE /client/{id}** - Hard-delete a client and all associated data (super_admin only) + - Query Parameters: + - `confirm=true` (required) - Must be set to confirm deletion + - `verbose` (optional, default: false) - When true, returns S3 paths of orphaned source documents + - Deletes all documents, folders, collector configs, batch uploads, and clientCanSync records + - S3 source files are NOT deleted; their paths are logged + - Returns: 204 No Content (or 200 with S3 paths when verbose=true) + - **GET /client/{id}/status** - Retrieve the sync status for a client - Returns: `{status}` where status is one of: `IN_SYNC`, `NOT_SYNCED`, `NOT_SYNCING` @@ -120,6 +128,15 @@ This document provides a comprehensive summary of all REST API endpoints availab - Request Body: `{path}` - Returns: `{id, client_id, path, parent_id}` (200 OK) +- **DELETE /folders/{folderId}** - Hard-delete a folder tree (super_admin only) + - Query Parameters: + - `include_documents` (optional, default: false) - When true, also deletes all documents in the folder tree + - `verbose` (optional, default: false) - When true, returns S3 paths of orphaned source documents + - Root folder (`/`) cannot be deleted (returns 409 Conflict) + - If folder tree contains documents and include_documents is false, returns 409 Conflict + - S3 source files are NOT deleted; their paths are logged + - Returns: 204 No Content (or 200 with S3 paths when verbose=true) + - **GET /folders/{folderId}/documents** - Get documents in a folder - Query Parameters: - `textRecord` (optional, default: false) - When true, includes full text extraction record @@ -146,6 +163,13 @@ This document provides a comprehensive summary of all REST API endpoints availab - `textRecord` (optional, default: false) - When true, includes full text extraction record - Returns: `{id, client_id, hash, folder_id, filename, labels[], fields{}, text_record?}` +- **DELETE /document/{id}** - Hard-delete a document and all dependent data (super_admin only) + - Query Parameters: + - `verbose` (optional, default: false) - When true, returns S3 paths of orphaned source documents + - Deletes all dependent rows: entries, cleans, text extractions, field extractions, labels, results + - S3 source files are NOT deleted; their paths are logged + - Returns: 204 No Content (or 200 with S3 paths when verbose=true) + ### Batch Document Processing - **POST /client/{id}/document/batch** - Upload multiple PDFs as ZIP archive for batch processing - Content-Type: `multipart/form-data` @@ -162,9 +186,6 @@ This document provides a comprehensive summary of all REST API endpoints availab - **GET /client/{id}/document/batch/{batch_id}** - Get detailed status of a specific batch upload - Returns: `{batch_id, client_id, original_filename, status, total_documents, processed_documents, failed_documents, invalid_type_documents, progress_percent, created_at, completed_at?, failed_filenames[]}` -- **DELETE /client/{id}/document/batch/{batch_id}** - Cancel a batch upload currently processing - - Returns: 204 No Content on successful cancellation - ## Label Service ### Document Label Management diff --git a/internal/client/delete.go b/internal/client/delete.go new file mode 100644 index 00000000..a52d35d8 --- /dev/null +++ b/internal/client/delete.go @@ -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 + } +} diff --git a/internal/client/delete_test.go b/internal/client/delete_test.go new file mode 100644 index 00000000..ae696268 --- /dev/null +++ b/internal/client/delete_test.go @@ -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) + }) +} diff --git a/internal/database/queries/batch.sql b/internal/database/queries/batch.sql index 141e2450..ac2d6fc3 100644 --- a/internal/database/queries/batch.sql +++ b/internal/database/queries/batch.sql @@ -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 diff --git a/internal/database/queries/client.sql b/internal/database/queries/client.sql index b750671c..387854c8 100644 --- a/internal/database/queries/client.sql +++ b/internal/database/queries/client.sql @@ -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). diff --git a/internal/database/queries/document.sql b/internal/database/queries/document.sql index 7b036b01..cca69ed0 100644 --- a/internal/database/queries/document.sql +++ b/internal/database/queries/document.sql @@ -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; diff --git a/internal/database/queries/folders.sql b/internal/database/queries/folders.sql index f43fa713..5f45d733 100644 --- a/internal/database/queries/folders.sql +++ b/internal/database/queries/folders.sql @@ -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); diff --git a/internal/database/repository/batch.sql.go b/internal/database/repository/batch.sql.go index 0f3730d4..510d2d7f 100644 --- a/internal/database/repository/batch.sql.go +++ b/internal/database/repository/batch.sql.go @@ -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 diff --git a/internal/database/repository/batch_test.go b/internal/database/repository/batch_test.go index 079f7ef8..0caa5e77 100644 --- a/internal/database/repository/batch_test.go +++ b/internal/database/repository/batch_test.go @@ -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() diff --git a/internal/database/repository/client.sql.go b/internal/database/repository/client.sql.go index 3bfb3d2c..dbc7f851 100644 --- a/internal/database/repository/client.sql.go +++ b/internal/database/repository/client.sql.go @@ -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 ( diff --git a/internal/database/repository/document.sql.go b/internal/database/repository/document.sql.go index 33708760..6f57a3ed 100644 --- a/internal/database/repository/document.sql.go +++ b/internal/database/repository/document.sql.go @@ -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) ` diff --git a/internal/database/repository/folders.sql.go b/internal/database/repository/folders.sql.go index a344f861..54cb4c20 100644 --- a/internal/database/repository/folders.sql.go +++ b/internal/database/repository/folders.sql.go @@ -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 diff --git a/internal/document/batch/service.go b/internal/document/batch/service.go index 20d26773..f17e01e3 100644 --- a/internal/document/batch/service.go +++ b/internal/document/batch/service.go @@ -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 diff --git a/internal/document/delete.go b/internal/document/delete.go new file mode 100644 index 00000000..8e05a4b8 --- /dev/null +++ b/internal/document/delete.go @@ -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 +} diff --git a/internal/document/delete_test.go b/internal/document/delete_test.go new file mode 100644 index 00000000..f88e6d4c --- /dev/null +++ b/internal/document/delete_test.go @@ -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) + }) +} diff --git a/internal/folder/delete.go b/internal/folder/delete.go new file mode 100644 index 00000000..b21e97b4 --- /dev/null +++ b/internal/folder/delete.go @@ -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 + } +} diff --git a/internal/folder/delete_test.go b/internal/folder/delete_test.go new file mode 100644 index 00000000..dba550b7 --- /dev/null +++ b/internal/folder/delete_test.go @@ -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)) + }) +} diff --git a/internal/harddelete/types.go b/internal/harddelete/types.go new file mode 100644 index 00000000..af6167e0 --- /dev/null +++ b/internal/harddelete/types.go @@ -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 +} diff --git a/pkg/queryAPI/api.gen.go b/pkg/queryAPI/api.gen.go index 0f738113..c3aa4901 100644 --- a/pkg/queryAPI/api.gen.go +++ b/pkg/queryAPI/api.gen.go @@ -532,6 +532,18 @@ type CollectorSet struct { MinimumCleanerVersion *CodeVersion `json:"minimum_cleaner_version,omitempty"` } +// DeleteResponse Response for hard-delete operations when verbose=true +type DeleteResponse struct { + // DeletedDocuments S3 paths of orphaned source documents (only populated when verbose=true) + DeletedDocuments []struct { + // DocumentId ID of the deleted document + DocumentId openapi_types.UUID `json:"documentId"` + + // S3Path Full S3 path (s3://bucket/key) of the orphaned source file + S3Path string `json:"s3Path"` + } `json:"deletedDocuments"` +} + // DocClient The properties of a client. type DocClient struct { // CanSync If the client is allowing active syncs @@ -1291,6 +1303,15 @@ type DeleteAdminUserParams struct { Confirm bool `form:"confirm" json:"confirm"` } +// DeleteClientParams defines parameters for DeleteClient. +type DeleteClientParams struct { + // Confirm Must be set to true to confirm deletion + Confirm bool `form:"confirm" json:"confirm"` + + // Verbose When true, returns S3 paths of orphaned source documents in the response body + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + // UploadDocumentMultipartBody defines parameters for UploadDocument. type UploadDocumentMultipartBody struct { // File The file to upload @@ -1322,6 +1343,12 @@ type ListClientFoldersParams struct { Metrics *bool `form:"metrics,omitempty" json:"metrics,omitempty"` } +// DeleteDocumentParams defines parameters for DeleteDocument. +type DeleteDocumentParams struct { + // Verbose When true, returns S3 paths of orphaned source documents in the response body + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + // GetDocumentParams defines parameters for GetDocument. type GetDocumentParams struct { // TextRecord When true, includes the full text extraction record in the response @@ -1349,6 +1376,15 @@ type GetFieldExtractionByVersionParams struct { Version int64 `form:"version" json:"version"` } +// DeleteFolderParams defines parameters for DeleteFolder. +type DeleteFolderParams struct { + // IncludeDocuments When true, also deletes all documents in the folder tree + IncludeDocuments *bool `form:"include_documents,omitempty" json:"include_documents,omitempty"` + + // Verbose When true, returns S3 paths of orphaned source documents in the response body + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + // GetFolderDocumentsParams defines parameters for GetFolderDocuments. type GetFolderDocumentsParams struct { // TextRecord When true, includes the full text extraction record for each document @@ -1543,6 +1579,9 @@ type ClientInterface interface { CreateClient(ctx context.Context, body CreateClientJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteClient request + DeleteClient(ctx context.Context, id ClientID, params *DeleteClientParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetClient request GetClient(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1571,9 +1610,6 @@ type ClientInterface interface { // UploadDocumentBatchWithBody request with any body UploadDocumentBatchWithBody(ctx context.Context, id ClientID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - // CancelDocumentBatch request - CancelDocumentBatch(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*http.Response, error) - // GetDocumentBatch request GetDocumentBatch(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1591,6 +1627,9 @@ type ClientInterface interface { // ListClients request ListClients(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteDocument request + DeleteDocument(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetDocument request GetDocument(ctx context.Context, id DocumentID, params *GetDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1633,6 +1672,9 @@ type ClientInterface interface { CreateFolder(ctx context.Context, body CreateFolderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteFolder request + DeleteFolder(ctx context.Context, folderId openapi_types.UUID, params *DeleteFolderParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // RenameFolderWithBody request with any body RenameFolderWithBody(ctx context.Context, folderId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1915,6 +1957,18 @@ func (c *Client) CreateClient(ctx context.Context, body CreateClientJSONRequestB return c.Client.Do(req) } +func (c *Client) DeleteClient(ctx context.Context, id ClientID, params *DeleteClientParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteClientRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetClient(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetClientRequest(c.Server, id) if err != nil { @@ -2035,18 +2089,6 @@ func (c *Client) UploadDocumentBatchWithBody(ctx context.Context, id ClientID, c return c.Client.Do(req) } -func (c *Client) CancelDocumentBatch(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewCancelDocumentBatchRequest(c.Server, id, batchId) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - func (c *Client) GetDocumentBatch(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetDocumentBatchRequest(c.Server, id, batchId) if err != nil { @@ -2119,6 +2161,18 @@ func (c *Client) ListClients(ctx context.Context, reqEditors ...RequestEditorFn) return c.Client.Do(req) } +func (c *Client) DeleteDocument(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteDocumentRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetDocument(ctx context.Context, id DocumentID, params *GetDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetDocumentRequest(c.Server, id, params) if err != nil { @@ -2299,6 +2353,18 @@ func (c *Client) CreateFolder(ctx context.Context, body CreateFolderJSONRequestB return c.Client.Do(req) } +func (c *Client) DeleteFolder(ctx context.Context, folderId openapi_types.UUID, params *DeleteFolderParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteFolderRequest(c.Server, folderId, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) RenameFolderWithBody(ctx context.Context, folderId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewRenameFolderRequestWithBody(c.Server, folderId, contentType, body) if err != nil { @@ -3277,6 +3343,74 @@ func NewCreateClientRequestWithBody(server string, contentType string, body io.R return req, nil } +// NewDeleteClientRequest generates requests for DeleteClient +func NewDeleteClientRequest(server string, id ClientID, params *DeleteClientParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/client/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "confirm", runtime.ParamLocationQuery, params.Confirm); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if params.Verbose != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "verbose", runtime.ParamLocationQuery, *params.Verbose); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetClientRequest generates requests for GetClient func NewGetClientRequest(server string, id ClientID) (*http.Request, error) { var err error @@ -3617,47 +3751,6 @@ func NewUploadDocumentBatchRequestWithBody(server string, id ClientID, contentTy return req, nil } -// NewCancelDocumentBatchRequest generates requests for CancelDocumentBatch -func NewCancelDocumentBatchRequest(server string, id ClientID, batchId BatchID) (*http.Request, error) { - var err error - - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) - if err != nil { - return nil, err - } - - var pathParam1 string - - pathParam1, err = runtime.StyleParamWithLocation("simple", false, "batch_id", runtime.ParamLocationPath, batchId) - if err != nil { - return nil, err - } - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/client/%s/document/batch/%s", pathParam0, pathParam1) - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("DELETE", queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - // NewGetDocumentBatchRequest generates requests for GetDocumentBatch func NewGetDocumentBatchRequest(server string, id ClientID, batchId BatchID) (*http.Request, error) { var err error @@ -3863,6 +3956,62 @@ func NewListClientsRequest(server string) (*http.Request, error) { return req, nil } +// NewDeleteDocumentRequest generates requests for DeleteDocument +func NewDeleteDocumentRequest(server string, id DocumentID, params *DeleteDocumentParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/document/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Verbose != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "verbose", runtime.ParamLocationQuery, *params.Verbose); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetDocumentRequest generates requests for GetDocument func NewGetDocumentRequest(server string, id DocumentID, params *GetDocumentParams) (*http.Request, error) { var err error @@ -4342,6 +4491,78 @@ func NewCreateFolderRequestWithBody(server string, contentType string, body io.R return req, nil } +// NewDeleteFolderRequest generates requests for DeleteFolder +func NewDeleteFolderRequest(server string, folderId openapi_types.UUID, params *DeleteFolderParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "folderId", runtime.ParamLocationPath, folderId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/folders/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.IncludeDocuments != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "include_documents", runtime.ParamLocationQuery, *params.IncludeDocuments); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Verbose != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "verbose", runtime.ParamLocationQuery, *params.Verbose); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewRenameFolderRequest calls the generic RenameFolder builder with application/json body func NewRenameFolderRequest(server string, folderId openapi_types.UUID, body RenameFolderJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -4797,6 +5018,9 @@ type ClientWithResponsesInterface interface { CreateClientWithResponse(ctx context.Context, body CreateClientJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateClientResponse, error) + // DeleteClientWithResponse request + DeleteClientWithResponse(ctx context.Context, id ClientID, params *DeleteClientParams, reqEditors ...RequestEditorFn) (*DeleteClientResponse, error) + // GetClientWithResponse request GetClientWithResponse(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*GetClientResponse, error) @@ -4825,9 +5049,6 @@ type ClientWithResponsesInterface interface { // UploadDocumentBatchWithBodyWithResponse request with any body UploadDocumentBatchWithBodyWithResponse(ctx context.Context, id ClientID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadDocumentBatchResponse, error) - // CancelDocumentBatchWithResponse request - CancelDocumentBatchWithResponse(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*CancelDocumentBatchResponse, error) - // GetDocumentBatchWithResponse request GetDocumentBatchWithResponse(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*GetDocumentBatchResponse, error) @@ -4845,6 +5066,9 @@ type ClientWithResponsesInterface interface { // ListClientsWithResponse request ListClientsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListClientsResponse, error) + // DeleteDocumentWithResponse request + DeleteDocumentWithResponse(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*DeleteDocumentResponse, error) + // GetDocumentWithResponse request GetDocumentWithResponse(ctx context.Context, id DocumentID, params *GetDocumentParams, reqEditors ...RequestEditorFn) (*GetDocumentResponse, error) @@ -4887,6 +5111,9 @@ type ClientWithResponsesInterface interface { CreateFolderWithResponse(ctx context.Context, body CreateFolderJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateFolderResponse, error) + // DeleteFolderWithResponse request + DeleteFolderWithResponse(ctx context.Context, folderId openapi_types.UUID, params *DeleteFolderParams, reqEditors ...RequestEditorFn) (*DeleteFolderResponse, error) + // RenameFolderWithBodyWithResponse request with any body RenameFolderWithBodyWithResponse(ctx context.Context, folderId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RenameFolderResponse, error) @@ -5362,6 +5589,33 @@ func (r CreateClientResponse) StatusCode() int { return 0 } +type DeleteClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DeleteClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetClientResponse struct { Body []byte HTTPResponse *http.Response @@ -5567,32 +5821,6 @@ func (r UploadDocumentBatchResponse) StatusCode() int { return 0 } -type CancelDocumentBatchResponse struct { - Body []byte - HTTPResponse *http.Response - JSON400 *InvalidRequest - JSON401 *Unauthorized - JSON404 *NotFound - JSON429 *TooManyRequests - JSON500 *InternalError -} - -// Status returns HTTPResponse.Status -func (r CancelDocumentBatchResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r CancelDocumentBatchResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - type GetDocumentBatchResponse struct { Body []byte HTTPResponse *http.Response @@ -5725,6 +5953,33 @@ func (r ListClientsResponse) StatusCode() int { return 0 } +type DeleteDocumentResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DeleteDocumentResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteDocumentResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetDocumentResponse struct { Body []byte HTTPResponse *http.Response @@ -6051,6 +6306,34 @@ func (r CreateFolderResponse) StatusCode() int { return 0 } +type DeleteFolderResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteResponse + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON404 *NotFound + JSON409 *Conflict + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DeleteFolderResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteFolderResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type RenameFolderResponse struct { Body []byte HTTPResponse *http.Response @@ -6473,6 +6756,15 @@ func (c *ClientWithResponses) CreateClientWithResponse(ctx context.Context, body return ParseCreateClientResponse(rsp) } +// DeleteClientWithResponse request returning *DeleteClientResponse +func (c *ClientWithResponses) DeleteClientWithResponse(ctx context.Context, id ClientID, params *DeleteClientParams, reqEditors ...RequestEditorFn) (*DeleteClientResponse, error) { + rsp, err := c.DeleteClient(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteClientResponse(rsp) +} + // GetClientWithResponse request returning *GetClientResponse func (c *ClientWithResponses) GetClientWithResponse(ctx context.Context, id ClientID, reqEditors ...RequestEditorFn) (*GetClientResponse, error) { rsp, err := c.GetClient(ctx, id, reqEditors...) @@ -6561,15 +6853,6 @@ func (c *ClientWithResponses) UploadDocumentBatchWithBodyWithResponse(ctx contex return ParseUploadDocumentBatchResponse(rsp) } -// CancelDocumentBatchWithResponse request returning *CancelDocumentBatchResponse -func (c *ClientWithResponses) CancelDocumentBatchWithResponse(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*CancelDocumentBatchResponse, error) { - rsp, err := c.CancelDocumentBatch(ctx, id, batchId, reqEditors...) - if err != nil { - return nil, err - } - return ParseCancelDocumentBatchResponse(rsp) -} - // GetDocumentBatchWithResponse request returning *GetDocumentBatchResponse func (c *ClientWithResponses) GetDocumentBatchWithResponse(ctx context.Context, id ClientID, batchId BatchID, reqEditors ...RequestEditorFn) (*GetDocumentBatchResponse, error) { rsp, err := c.GetDocumentBatch(ctx, id, batchId, reqEditors...) @@ -6623,6 +6906,15 @@ func (c *ClientWithResponses) ListClientsWithResponse(ctx context.Context, reqEd return ParseListClientsResponse(rsp) } +// DeleteDocumentWithResponse request returning *DeleteDocumentResponse +func (c *ClientWithResponses) DeleteDocumentWithResponse(ctx context.Context, id DocumentID, params *DeleteDocumentParams, reqEditors ...RequestEditorFn) (*DeleteDocumentResponse, error) { + rsp, err := c.DeleteDocument(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteDocumentResponse(rsp) +} + // GetDocumentWithResponse request returning *GetDocumentResponse func (c *ClientWithResponses) GetDocumentWithResponse(ctx context.Context, id DocumentID, params *GetDocumentParams, reqEditors ...RequestEditorFn) (*GetDocumentResponse, error) { rsp, err := c.GetDocument(ctx, id, params, reqEditors...) @@ -6755,6 +7047,15 @@ func (c *ClientWithResponses) CreateFolderWithResponse(ctx context.Context, body return ParseCreateFolderResponse(rsp) } +// DeleteFolderWithResponse request returning *DeleteFolderResponse +func (c *ClientWithResponses) DeleteFolderWithResponse(ctx context.Context, folderId openapi_types.UUID, params *DeleteFolderParams, reqEditors ...RequestEditorFn) (*DeleteFolderResponse, error) { + rsp, err := c.DeleteFolder(ctx, folderId, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteFolderResponse(rsp) +} + // RenameFolderWithBodyWithResponse request with arbitrary body returning *RenameFolderResponse func (c *ClientWithResponses) RenameFolderWithBodyWithResponse(ctx context.Context, folderId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RenameFolderResponse, error) { rsp, err := c.RenameFolderWithBody(ctx, folderId, contentType, body, reqEditors...) @@ -7911,6 +8212,67 @@ func ParseCreateClientResponse(rsp *http.Response) (*CreateClientResponse, error return response, nil } +// ParseDeleteClientResponse parses an HTTP response from a DeleteClientWithResponse call +func ParseDeleteClientResponse(rsp *http.Response) (*DeleteClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetClientResponse parses an HTTP response from a GetClientWithResponse call func ParseGetClientResponse(rsp *http.Response) (*GetClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -8322,60 +8684,6 @@ func ParseUploadDocumentBatchResponse(rsp *http.Response) (*UploadDocumentBatchR return response, nil } -// ParseCancelDocumentBatchResponse parses an HTTP response from a CancelDocumentBatchWithResponse call -func ParseCancelDocumentBatchResponse(rsp *http.Response) (*CancelDocumentBatchResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &CancelDocumentBatchResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest InvalidRequest - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON400 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest Unauthorized - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON401 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest NotFound - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON404 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: - var dest TooManyRequests - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON429 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: - var dest InternalError - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON500 = &dest - - } - - return response, nil -} - // ParseGetDocumentBatchResponse parses an HTTP response from a GetDocumentBatchWithResponse call func ParseGetDocumentBatchResponse(rsp *http.Response) (*GetDocumentBatchResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -8660,6 +8968,67 @@ func ParseListClientsResponse(rsp *http.Response) (*ListClientsResponse, error) return response, nil } +// ParseDeleteDocumentResponse parses an HTTP response from a DeleteDocumentWithResponse call +func ParseDeleteDocumentResponse(rsp *http.Response) (*DeleteDocumentResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteDocumentResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetDocumentResponse parses an HTTP response from a GetDocumentWithResponse call func ParseGetDocumentResponse(rsp *http.Response) (*GetDocumentResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -9382,6 +9751,74 @@ func ParseCreateFolderResponse(rsp *http.Response) (*CreateFolderResponse, error return response, nil } +// ParseDeleteFolderResponse parses an HTTP response from a DeleteFolderWithResponse call +func ParseDeleteFolderResponse(rsp *http.Response) (*DeleteFolderResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteFolderResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Conflict + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseRenameFolderResponse parses an HTTP response from a RenameFolderWithResponse call func ParseRenameFolderResponse(rsp *http.Response) (*RenameFolderResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/serviceAPIs/queryAPI.yaml b/serviceAPIs/queryAPI.yaml index 405c8a45..aaece8e3 100644 --- a/serviceAPIs/queryAPI.yaml +++ b/serviceAPIs/queryAPI.yaml @@ -159,6 +159,57 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalError" + delete: + operationId: deleteClient + tags: + - ClientService + summary: Hard-delete a client and all its data + description: >- + Permanently deletes a client and all associated data including documents, + folders, collectors, batch uploads, and sync records. Source documents in + S3 are NOT deleted but their paths are logged. This operation is + irreversible. Requires confirm=true query parameter. + security: + - jwtAuth: [] + parameters: + - name: confirm + in: query + description: Must be set to true to confirm deletion + required: true + schema: + type: boolean + - name: verbose + in: query + required: false + description: When true, returns S3 paths of orphaned source documents in the response body + schema: + type: boolean + default: false + responses: + "200": + description: Client deleted successfully (verbose mode). + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "204": + description: Client deleted successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" # Query paths have been removed - see remove_query_plan.md @@ -505,30 +556,6 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalError" - delete: - operationId: cancelDocumentBatch - tags: - - DocumentsService - summary: Cancel a batch upload - description: Cancels a batch upload that is currently processing - security: - - jwtAuth: [] - responses: - "204": - description: Batch upload cancelled successfully - headers: - RateLimit: - $ref: "#/components/headers/RateLimit" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/TooManyRequests" - "500": - $ref: "#/components/responses/InternalError" /document/{id}: parameters: @@ -571,6 +598,50 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalError" + delete: + operationId: deleteDocument + tags: + - DocumentsService + summary: Hard-delete a document + description: >- + Permanently deletes a document and all dependent data including entries, + cleans, text extractions, field extractions, labels, and results. Source + documents in S3 are NOT deleted but their paths are logged. + security: + - jwtAuth: [] + parameters: + - name: verbose + in: query + required: false + description: When true, returns S3 paths of orphaned source documents in the response body + schema: + type: boolean + default: false + responses: + "200": + description: Document deleted successfully (verbose mode). + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "204": + description: Document deleted successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" /folders: post: @@ -652,6 +723,62 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalError" + delete: + operationId: deleteFolder + tags: + - FolderService + summary: Hard-delete a folder tree + description: >- + Permanently deletes a folder and all its child folders recursively. The + root folder (path '/') cannot be deleted. If the folder tree contains + documents, include_documents=true is required or a 409 Conflict is + returned. When include_documents=true, all documents in the tree are + also permanently deleted. Source documents in S3 are NOT deleted but + their paths are logged. + security: + - jwtAuth: [] + parameters: + - name: include_documents + in: query + required: false + description: When true, also deletes all documents in the folder tree + schema: + type: boolean + default: false + - name: verbose + in: query + required: false + description: When true, returns S3 paths of orphaned source documents in the response body + schema: + type: boolean + default: false + responses: + "200": + description: Folder tree deleted successfully (verbose mode). + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "204": + description: Folder tree deleted successfully. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" /folders/{folderId}/documents: parameters: @@ -4817,3 +4944,29 @@ components: maximum: 2147483647 description: Total number of items example: 150 + + DeleteResponse: + description: Response for hard-delete operations when verbose=true + type: object + required: + - deletedDocuments + properties: + deletedDocuments: + type: array + description: S3 paths of orphaned source documents (only populated when verbose=true) + maxItems: 100000 + items: + type: object + required: + - documentId + - s3Path + properties: + documentId: + type: string + format: uuid + maxLength: 36 + description: ID of the deleted document + s3Path: + type: string + maxLength: 1024 + description: Full S3 path (s3://bucket/key) of the orphaned source file