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" ) // DownloadDocument streams the original uploaded document file to the client. func (s *Controllers) DownloadDocument(ctx echo.Context, id DocumentID) error { result, err := s.svc.DocumentDownload.GetDownload(ctx.Request().Context(), uuid.UUID(id)) if err != nil { slog.Error("unable to download document", "error", err, "document_id", id) return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Unable to download document: %s", err)) } defer result.Body.Close() ctx.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, result.Filename)) ctx.Response().Header().Set("Content-Length", fmt.Sprintf("%d", result.Size)) return ctx.Stream(http.StatusOK, result.ContentType, result.Body) } 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), 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 } // Mutable-metadata feature (Milestone 2.3a/2.7): surface the two // new fields directly from the DocumentEnriched service struct. // No follow-up DB call here — the extended GetDocumentEnriched SQL // query already populated both values in the single round-trip. if document.CustomSchemaID != nil { csid := openapi_types.UUID(*document.CustomSchemaID) response.CustomSchemaId = &csid } hasCustomMetadata := document.HasCustomMetadata response.HasCustomMetadata = &hasCustomMetadata // 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 including per-file document outcomes. 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 (includes document outcomes) batchDetails, 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(batchDetails.ID), ClientId: ClientID(batchDetails.ClientID), OriginalFilename: batchDetails.OriginalFilename, Status: BatchStatus(batchDetails.Status), TotalDocuments: batchDetails.TotalDocuments, ProcessedDocuments: batchDetails.ProcessedDocuments, FailedDocuments: batchDetails.FailedDocuments, InvalidTypeDocuments: batchDetails.InvalidTypeDocuments, ProgressPercent: batchDetails.ProgressPercent, FailedFilenames: &batchDetails.FailedFilenames, CreatedAt: batchDetails.CreatedAt, } // Handle nullable completed at if batchDetails.CompletedAt != nil { details.CompletedAt = nullable.NewNullableWithValue(*batchDetails.CompletedAt) } // Map document outcomes to API response format if len(batchDetails.DocumentOutcomes) > 0 { outcomes := make([]BatchDocumentOutcome, len(batchDetails.DocumentOutcomes)) for i, o := range batchDetails.DocumentOutcomes { outcome := BatchDocumentOutcome{ Filename: o.Filename, Outcome: BatchDocumentOutcomeOutcome(o.Outcome), CreatedAt: &o.CreatedAt, UpdatedAt: &o.UpdatedAt, } if o.ErrorDetail != nil { outcome.ErrorDetail = nullable.NewNullableWithValue(*o.ErrorDetail) } if o.DocumentID != nil { outcome.DocumentId = nullable.NewNullableWithValue(openapi_types.UUID(*o.DocumentID)) } if o.CleanFail != nil { outcome.CleanFailReason = nullable.NewNullableWithValue(*o.CleanFail) } outcomes[i] = outcome } details.DocumentOutcomes = &outcomes } return ctx.JSON(http.StatusOK, details) }