Files
Jay Brown 09c61ea9b4 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
2026-03-04 18:30:13 +00:00

177 lines
5.7 KiB
Go

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