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-12-11 14:25:51 +00:00
|
|
|
"queryorchestration/internal/database/repository"
|
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-12-11 14:25:51 +00:00
|
|
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 14:25:51 +00:00
|
|
|
// 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)
|
2025-03-17 18:14:15 +00:00
|
|
|
if err != nil {
|
2025-12-11 14:25:51 +00:00
|
|
|
slog.Error("unable to get document", "error", err, "document_id", id)
|
|
|
|
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Unable to get document: %s", err))
|
2025-03-17 18:14:15 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-11 14:25:51 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-01-12 17:46:07 +00:00
|
|
|
if document.FileSizeBytes != nil {
|
|
|
|
|
response.FileSizeBytes = document.FileSizeBytes
|
|
|
|
|
}
|
2025-12-11 14:25:51 +00:00
|
|
|
|
|
|
|
|
// 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-03-17 18:14:15 +00:00
|
|
|
}
|
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)
|
|
|
|
|
}
|