// delete.go implements the hard-delete API handlers for client, document, and folder resources. // These handlers permanently remove data from the database. Source documents in S3 are // intentionally NOT deleted; their paths are logged and optionally returned in verbose mode. // Delete operations are restricted to super_admin via Permit.io middleware. package queryapi import ( "errors" "fmt" "log/slog" "net/http" "queryorchestration/internal/harddelete" "github.com/google/uuid" "github.com/labstack/echo/v4" openapi_types "github.com/oapi-codegen/runtime/types" ) // DeleteClient permanently deletes a client and all associated data. // DELETE /client/{id}?confirm=true&verbose=false // Requires confirm=true query parameter as a safety guard. // When verbose=true, returns 200 with S3 paths of orphaned source documents. // When verbose=false (default), returns 204 No Content. func (s *Controllers) DeleteClient(ctx echo.Context, id ClientID, params DeleteClientParams) error { if !params.Confirm { return ctx.JSON(http.StatusBadRequest, ErrorMessage{ Message: "confirm=true query parameter is required to delete a client", }) } adminUser, err := getAdminUserForAudit(ctx) if err != nil { return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Could not identify admin user", }) } slog.Info("client hard-delete requested", "clientId", id, "requestedBy", adminUser.Email, ) result, err := s.svc.Client.HardDelete(ctx.Request().Context(), string(id)) if err != nil { return handleDeleteError(ctx, err, "client", string(id)) } slog.Info("client hard-deleted", "clientId", id, "requestedBy", adminUser.Email, "orphanedS3Objects", len(result), ) return respondDelete(ctx, params.Verbose, result) } // DeleteDocument permanently deletes a document and all dependent data. // DELETE /document/{id}?verbose=false // When verbose=true, returns 200 with S3 paths of orphaned source documents. // When verbose=false (default), returns 204 No Content. func (s *Controllers) DeleteDocument(ctx echo.Context, id DocumentID, params DeleteDocumentParams) error { adminUser, err := getAdminUserForAudit(ctx) if err != nil { return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Could not identify admin user", }) } docUUID := uuid.UUID(id) slog.Info("document hard-delete requested", "documentId", docUUID, "requestedBy", adminUser.Email, ) result, err := s.svc.Document.HardDelete(ctx.Request().Context(), docUUID) if err != nil { return handleDeleteError(ctx, err, "document", docUUID.String()) } slog.Info("document hard-deleted", "documentId", docUUID, "requestedBy", adminUser.Email, "orphanedS3Objects", len(result), ) return respondDelete(ctx, params.Verbose, result) } // DeleteFolder permanently deletes a folder tree and optionally its documents. // DELETE /folders/{folderId}?include_documents=false&verbose=false // The root folder (path '/') cannot be deleted (returns 409). // If the folder tree contains documents and include_documents is false, returns 409. // When verbose=true, returns 200 with S3 paths of orphaned source documents. // When verbose=false (default), returns 204 No Content. func (s *Controllers) DeleteFolder(ctx echo.Context, folderId openapi_types.UUID, params DeleteFolderParams) error { adminUser, err := getAdminUserForAudit(ctx) if err != nil { return ctx.JSON(http.StatusUnauthorized, ErrorMessage{ Message: "Could not identify admin user", }) } includeDocuments := params.IncludeDocuments != nil && *params.IncludeDocuments slog.Info("folder hard-delete requested", "folderId", folderId, "includeDocuments", includeDocuments, "requestedBy", adminUser.Email, ) result, err := s.svc.Folder.HardDelete(ctx.Request().Context(), uuid.UUID(folderId), includeDocuments) if err != nil { return handleDeleteError(ctx, err, "folder", uuid.UUID(folderId).String()) } slog.Info("folder hard-deleted", "folderId", folderId, "includeDocuments", includeDocuments, "requestedBy", adminUser.Email, "orphanedS3Objects", len(result), ) return respondDelete(ctx, params.Verbose, result) } // handleDeleteError maps service-layer errors to appropriate HTTP responses. // Returns 404 for not-found, 409 for conflict, and 500 for unexpected errors. func handleDeleteError(ctx echo.Context, err error, resourceType string, resourceID string) error { var conflictErr *harddelete.ConflictError if errors.As(err, &conflictErr) { return ctx.JSON(http.StatusConflict, ErrorMessage{ Message: conflictErr.Error(), }) } var notFoundErr *harddelete.NotFoundError if errors.As(err, ¬FoundErr) { return ctx.JSON(http.StatusNotFound, ErrorMessage{ Message: notFoundErr.Error(), }) } slog.Error("hard-delete failed", "resourceType", resourceType, "resourceId", resourceID, "error", err, ) return ctx.JSON(http.StatusInternalServerError, ErrorMessage{ Message: fmt.Sprintf("failed to delete %s: internal error", resourceType), }) } // respondDelete sends the appropriate HTTP response based on the verbose flag. // When verbose is true, returns 200 with a DeleteResponse containing S3 paths. // When verbose is false or nil, returns 204 No Content. func respondDelete(ctx echo.Context, verbose *bool, s3Paths []harddelete.S3PathInfo) error { if verbose != nil && *verbose { docs := make([]struct { DocumentId openapi_types.UUID `json:"documentId"` S3Path string `json:"s3Path"` }, len(s3Paths)) for i, p := range s3Paths { docs[i].DocumentId = openapi_types.UUID(p.DocumentID) docs[i].S3Path = fmt.Sprintf("s3://%s/%s", p.Bucket, p.Key) } return ctx.JSON(http.StatusOK, DeleteResponse{ DeletedDocuments: docs, }) } return ctx.NoContent(http.StatusNoContent) }