Merged in feature/add-deletes (pull request #214)

support delete for client, document and folder

* support delete

for client, document and folder

* remove batch cancel conflict

not used


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