Files
query-orchestration/api/queryAPI/documents.go
T

319 lines
10 KiB
Go
Raw Normal View History

package queryapi
import (
"fmt"
"log/slog"
"net/http"
"queryorchestration/internal/database/repository"
2025-08-06 15:06:18 -07:00
"queryorchestration/internal/document/batch/storage"
documentupload "queryorchestration/internal/document/upload"
2025-08-05 07:03:35 -07:00
"github.com/google/uuid"
"github.com/labstack/echo/v4"
2025-08-05 07:03:35 -07:00
"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),
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)
}
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,
)
// 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 including per-file document outcomes.
2025-08-05 07:03:35 -07:00
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)
2025-08-05 07:03:35 -07:00
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,
2025-08-05 07:03:35 -07:00
}
// 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
2025-08-05 07:03:35 -07:00
}
return ctx.JSON(http.StatusOK, details)
}