package queryapi import ( "fmt" "log/slog" "net/http" "queryorchestration/internal/document/batch/storage" documentupload "queryorchestration/internal/document/upload" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" ) 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, }) 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) } func (s *Controllers) ListDocumentsByClientId(ctx echo.Context, clientId ClientID) error { documents, err := s.svc.Document.ListByClient(ctx.Request().Context(), clientId) 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, }) } // 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 { // Parse multipart form to get ZIP file file, err := ctx.FormFile("archive") if err != nil { slog.Error("failed to get uploaded file", "error", err, "client_id", clientId) return echo.NewHTTPError(http.StatusBadRequest, "file upload required") } // 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") } defer src.Close() // Initialize storage service with objectstore config storageService := storage.New(s.cfg) // 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)) } // 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, ) if err != nil { slog.Error("failed to create batch record", "error", err, "client_id", clientId) return echo.NewHTTPError(http.StatusInternalServerError, "failed to create batch record") } slog.Info("ZIP batch upload completed", "batch_id", batchID, "filename", file.Filename, "size_bytes", storageResult.SizeBytes, "s3_key", storageResult.Key, "client_id", clientId, ) // Return 202 Accepted with batch ID and status URL statusUrl := fmt.Sprintf("/clients/%s/documents/batches/%s", clientId, batchID) response := BatchUploadResponse{ BatchId: BatchID(batchID), Status: "processing", StatusUrl: statusUrl, } 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) }