2025-03-17 18:14:15 +00:00
|
|
|
package queryapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2025-05-27 15:28:46 +00:00
|
|
|
"log/slog"
|
2025-03-17 18:14:15 +00:00
|
|
|
"net/http"
|
|
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
"queryorchestration/internal/document/batch/storage"
|
2025-05-27 15:28:46 +00:00
|
|
|
documentupload "queryorchestration/internal/document/upload"
|
|
|
|
|
|
2025-08-05 07:03:35 -07:00
|
|
|
"github.com/google/uuid"
|
2025-03-17 18:14:15 +00:00
|
|
|
"github.com/labstack/echo/v4"
|
2025-08-05 07:03:35 -07:00
|
|
|
"github.com/oapi-codegen/nullable"
|
2025-03-17 18:14:15 +00:00
|
|
|
)
|
|
|
|
|
|
2025-05-27 15:28:46 +00:00
|
|
|
func (s *Controllers) UploadDocument(ctx echo.Context, clientId ClientID) error {
|
|
|
|
|
form, err := ctx.MultipartForm()
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("unable to get form data", "error", err)
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "unable to get form data")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
files := form.File["file"]
|
|
|
|
|
if len(files) == 0 {
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "missing file")
|
|
|
|
|
} else if len(files) > 1 {
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "too many files")
|
|
|
|
|
}
|
|
|
|
|
file := files[0]
|
|
|
|
|
|
|
|
|
|
src, err := file.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "unable to read file")
|
|
|
|
|
}
|
|
|
|
|
defer src.Close()
|
|
|
|
|
|
|
|
|
|
err = s.svc.DocumentUpload.Upload(ctx.Request().Context(), documentupload.File{
|
|
|
|
|
ClientID: clientId,
|
|
|
|
|
Content: src,
|
2025-08-20 19:01:13 +00:00
|
|
|
Filename: file.Filename,
|
2025-05-27 15:28:46 +00:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("unable to get form data", "error", err)
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "unable to upload file")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.NoContent(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-17 18:14:15 +00:00
|
|
|
func (s *Controllers) ListDocumentsByClientId(ctx echo.Context, clientId ClientID) error {
|
2025-04-02 18:50:03 +00:00
|
|
|
documents, err := s.svc.Document.ListByClient(ctx.Request().Context(), clientId)
|
2025-03-17 18:14:15 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unable to list documents: %s", err))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
docs := make([]DocumentSummary, len(documents))
|
|
|
|
|
for i, doc := range documents {
|
|
|
|
|
docs[i] = DocumentSummary{
|
|
|
|
|
Id: doc.ID,
|
|
|
|
|
Hash: doc.Hash,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.JSON(http.StatusOK, docs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Controllers) GetDocument(ctx echo.Context, id DocumentID) error {
|
|
|
|
|
document, err := s.svc.Document.GetExternal(ctx.Request().Context(), id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unable to list documents: %s", err))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.JSON(http.StatusOK, Document{
|
|
|
|
|
Id: document.Id,
|
|
|
|
|
ClientId: document.ClientID,
|
|
|
|
|
Hash: document.Hash,
|
|
|
|
|
Fields: document.Fields,
|
|
|
|
|
})
|
|
|
|
|
}
|
2025-08-05 07:03:35 -07:00
|
|
|
|
|
|
|
|
// ListDocumentBatches lists batch uploads for a client
|
|
|
|
|
func (s *Controllers) ListDocumentBatches(ctx echo.Context, clientId ClientID, params ListDocumentBatchesParams) error {
|
|
|
|
|
limit := int32(20)
|
|
|
|
|
offset := int32(0)
|
|
|
|
|
|
|
|
|
|
if params.Limit != nil {
|
|
|
|
|
limit = *params.Limit
|
|
|
|
|
}
|
|
|
|
|
if params.Offset != nil {
|
|
|
|
|
offset = *params.Offset
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
batches, err := s.svc.DocumentBatch.List(ctx.Request().Context(), clientId, limit, offset)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("unable to list batches", "error", err)
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "unable to list batches")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert to API response format
|
|
|
|
|
batchList := BatchUploadList{
|
|
|
|
|
Batches: make([]BatchUploadSummary, len(batches)),
|
|
|
|
|
TotalCount: int32(len(batches)), // In real implementation, we'd get total count from DB
|
|
|
|
|
// #nosec G115 - len(batches) is bounded by database query limit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i, batch := range batches {
|
|
|
|
|
summary := BatchUploadSummary{
|
|
|
|
|
BatchId: BatchID(batch.ID),
|
|
|
|
|
ClientId: ClientID(batch.ClientID),
|
|
|
|
|
OriginalFilename: batch.OriginalFilename,
|
|
|
|
|
Status: BatchStatus(batch.Status),
|
|
|
|
|
TotalDocuments: batch.TotalDocuments,
|
|
|
|
|
ProcessedDocuments: batch.ProcessedDocuments,
|
|
|
|
|
FailedDocuments: batch.FailedDocuments,
|
|
|
|
|
InvalidTypeDocuments: batch.InvalidTypeDocuments,
|
|
|
|
|
ProgressPercent: batch.ProgressPercent,
|
|
|
|
|
CreatedAt: batch.CreatedAt,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle nullable completed at
|
|
|
|
|
if batch.CompletedAt != nil {
|
|
|
|
|
summary.CompletedAt = nullable.NewNullableWithValue(*batch.CompletedAt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
batchList.Batches[i] = summary
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.JSON(http.StatusOK, batchList)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UploadDocumentBatch handles ZIP archive upload containing multiple PDFs
|
|
|
|
|
func (s *Controllers) UploadDocumentBatch(ctx echo.Context, clientId ClientID) error {
|
2025-08-06 15:06:18 -07:00
|
|
|
// Parse multipart form to get ZIP file
|
|
|
|
|
file, err := ctx.FormFile("archive")
|
2025-08-05 07:03:35 -07:00
|
|
|
if err != nil {
|
2025-08-06 15:06:18 -07:00
|
|
|
slog.Error("failed to get uploaded file", "error", err, "client_id", clientId)
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "file upload required")
|
2025-08-05 07:03:35 -07:00
|
|
|
}
|
|
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
// Open uploaded file
|
|
|
|
|
src, err := file.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("failed to open uploaded file", "error", err, "client_id", clientId)
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to process uploaded file")
|
2025-08-05 07:03:35 -07:00
|
|
|
}
|
2025-08-06 15:06:18 -07:00
|
|
|
defer src.Close()
|
2025-08-05 07:03:35 -07:00
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
// Initialize storage service with objectstore config
|
|
|
|
|
storageService := storage.New(s.cfg)
|
2025-08-05 07:03:35 -07:00
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
// Validate ZIP file
|
|
|
|
|
err = storageService.ValidateZIPFile(file.Filename, file.Size)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("ZIP validation failed", "filename", file.Filename, "size", file.Size, "error", err, "client_id", clientId)
|
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid ZIP file: %v", err))
|
|
|
|
|
}
|
2025-08-05 07:03:35 -07:00
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
// Store ZIP file to S3
|
|
|
|
|
storageResult, err := storageService.StoreZIPFile(ctx.Request().Context(), string(clientId), file.Filename, src)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("failed to store ZIP file", "error", err, "client_id", clientId)
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to store ZIP file")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create batch record with storage metadata
|
|
|
|
|
// Note: totalDocs is unknown at this point, will be determined during extraction in later parts
|
|
|
|
|
batchID, err := s.svc.DocumentBatch.CreateWithStorage(
|
|
|
|
|
ctx.Request().Context(),
|
|
|
|
|
string(clientId),
|
|
|
|
|
file.Filename,
|
|
|
|
|
0, // totalDocs unknown in Part 1
|
|
|
|
|
storageResult.Bucket,
|
|
|
|
|
storageResult.Key,
|
|
|
|
|
storageResult.SizeBytes,
|
|
|
|
|
)
|
2025-08-05 07:03:35 -07:00
|
|
|
if err != nil {
|
2025-08-06 15:06:18 -07:00
|
|
|
slog.Error("failed to create batch record", "error", err, "client_id", clientId)
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create batch record")
|
2025-08-05 07:03:35 -07:00
|
|
|
}
|
|
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
slog.Info("ZIP batch upload completed",
|
|
|
|
|
"batch_id", batchID,
|
|
|
|
|
"filename", file.Filename,
|
|
|
|
|
"size_bytes", storageResult.SizeBytes,
|
|
|
|
|
"s3_key", storageResult.Key,
|
|
|
|
|
"client_id", clientId,
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-20 19:01:13 +00:00
|
|
|
// Signal background runner to process new batch
|
|
|
|
|
if runner := s.cfg.GetBackgroundRunner(); runner != nil {
|
|
|
|
|
if err := runner.Signal(); err != nil {
|
|
|
|
|
slog.Warn("Failed to signal background runner", "error", err, "batch_id", batchID)
|
|
|
|
|
} else {
|
|
|
|
|
slog.Info("Background runner signaled for batch processing", "batch_id", batchID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-06 15:06:18 -07:00
|
|
|
// Return 202 Accepted with batch ID and status URL
|
|
|
|
|
statusUrl := fmt.Sprintf("/clients/%s/documents/batches/%s", clientId, batchID)
|
2025-08-05 07:03:35 -07:00
|
|
|
response := BatchUploadResponse{
|
|
|
|
|
BatchId: BatchID(batchID),
|
2025-08-06 15:06:18 -07:00
|
|
|
Status: "processing",
|
|
|
|
|
StatusUrl: statusUrl,
|
2025-08-05 07:03:35 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.JSON(http.StatusAccepted, response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetDocumentBatch returns batch upload status
|
|
|
|
|
func (s *Controllers) GetDocumentBatch(ctx echo.Context, clientId ClientID, batchId BatchID) error {
|
|
|
|
|
// BatchID is already a UUID type
|
|
|
|
|
batchUUID := uuid.UUID(batchId)
|
|
|
|
|
|
|
|
|
|
// Get batch details
|
|
|
|
|
batch, err := s.svc.DocumentBatch.Get(ctx.Request().Context(), clientId, batchUUID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("unable to get batch", "error", err, "batchId", batchId)
|
|
|
|
|
return echo.NewHTTPError(http.StatusNotFound, "batch not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert to API response format
|
|
|
|
|
details := BatchUploadDetails{
|
|
|
|
|
BatchId: BatchID(batch.ID),
|
|
|
|
|
ClientId: ClientID(batch.ClientID),
|
|
|
|
|
OriginalFilename: batch.OriginalFilename,
|
|
|
|
|
Status: BatchStatus(batch.Status),
|
|
|
|
|
TotalDocuments: batch.TotalDocuments,
|
|
|
|
|
ProcessedDocuments: batch.ProcessedDocuments,
|
|
|
|
|
FailedDocuments: batch.FailedDocuments,
|
|
|
|
|
InvalidTypeDocuments: batch.InvalidTypeDocuments,
|
|
|
|
|
ProgressPercent: batch.ProgressPercent,
|
|
|
|
|
FailedFilenames: &batch.FailedFilenames,
|
|
|
|
|
CreatedAt: batch.CreatedAt,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle nullable completed at
|
|
|
|
|
if batch.CompletedAt != nil {
|
|
|
|
|
details.CompletedAt = nullable.NewNullableWithValue(*batch.CompletedAt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|