package queryapi import ( "fmt" "log/slog" "net/http" "queryorchestration/internal/database/repository" "queryorchestration/internal/document/batch/storage" documentupload "queryorchestration/internal/document/upload" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/oapi-codegen/nullable" openapi_types "github.com/oapi-codegen/runtime/types" ) 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, Filename: file.Filename, }) 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) } // GetDocument retrieves document details by ID with enriched metadata. // When textRecord=true, includes the full field extraction response. func (s *Controllers) GetDocument(ctx echo.Context, id DocumentID, params GetDocumentParams) error { includeTextRecord := params.TextRecord != nil && *params.TextRecord document, err := s.svc.Document.GetEnriched(ctx.Request().Context(), uuid.UUID(id), includeTextRecord) if err != nil { slog.Error("unable to get document", "error", err, "document_id", id) return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Unable to get document: %s", err)) } // Convert labels to API format labels := make([]LabelRecord, len(document.Labels)) for i, lbl := range document.Labels { labels[i] = LabelRecord{ Id: openapi_types.UUID(lbl.ID), DocumentId: DocumentID(lbl.DocumentID), Label: lbl.Label, AppliedBy: openapi_types.Email(lbl.AppliedBy), AppliedAt: lbl.AppliedAt, } } response := DocumentEnriched{ Id: DocumentID(document.ID), ClientId: ClientID(document.ClientID), Hash: Hash(document.Hash), Fields: document.Fields, HasTextRecord: document.HasTextRecord, Labels: labels, } if document.FolderID != nil { response.FolderId = (*openapi_types.UUID)(document.FolderID) } if document.Filename != nil { response.Filename = document.Filename } if document.OriginalPath != nil { response.OriginalPath = document.OriginalPath } if document.FileSizeBytes != nil { response.FileSizeBytes = document.FileSizeBytes } // Include text record if requested and available if document.TextRecord != nil { // The TextRecord contains repository types that we need to convert current, ok := document.TextRecord.SingleFields.(*repository.Currentfieldextraction) if ok && current != nil { arrayFields, ok := document.TextRecord.ArrayFields.([]*repository.Documentfieldextractionarrayfield) if ok { textRecord := buildFieldExtractionResponse(current, arrayFields) response.TextRecord = textRecord } } } return ctx.JSON(http.StatusOK, response) } // 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, ) // 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) } } // 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) }