diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 81c643e..69555f4 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -453,12 +453,14 @@ func (h *TenderHandler) GetDocuments(c echo.Context) error { return response.Success(c, documents, "Tender documents retrieved successfully") } -// DownloadDocuments downloads all scraped documents for a tender +// DownloadDocuments downloads scraped documents for a tender // @Summary Download scraped documents for a tender -// @Description Stream all scraped tender documents from storage as a ZIP archive +// @Description Stream all scraped tender documents as a ZIP archive, or pass filename to download a single document // @Tags Tenders // @Produce application/zip +// @Produce application/octet-stream // @Param id path string true "Tender ID" +// @Param filename query string false "Document filename (from GET /documents); when set, returns that file instead of a ZIP" // @Success 200 {file} file // @Failure 400 {object} response.APIResponse // @Failure 404 {object} response.APIResponse @@ -470,6 +472,10 @@ func (h *TenderHandler) DownloadDocuments(c echo.Context) error { return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty") } + if filename := strings.TrimSpace(c.QueryParam("filename")); filename != "" { + return h.streamDocumentDownload(c, id, filename) + } + download, err := h.service.DownloadDocuments(c.Request().Context(), id) if err != nil { if errors.Is(err, ErrTenderDocumentNotFound) { @@ -493,6 +499,30 @@ func (h *TenderHandler) DownloadDocuments(c echo.Context) error { return c.Stream(http.StatusOK, download.ContentType, download.Reader) } +func (h *TenderHandler) streamDocumentDownload(c echo.Context, tenderID, filename string) error { + download, err := h.service.DownloadDocument(c.Request().Context(), tenderID, filename) + if err != nil { + if errors.Is(err, ErrTenderDocumentNotFound) { + return response.NotFound(c, "Tender document not found") + } + if errors.Is(err, ErrTenderDocumentStorageUnavailable) { + return response.InternalServerError(c, "Tender document storage is unavailable") + } + return response.InternalServerError(c, "Failed to download tender document") + } + defer download.Reader.Close() + + headers := c.Response().Header() + headers.Set(echo.HeaderContentDisposition, mime.FormatMediaType("attachment", map[string]string{ + "filename": download.Filename, + })) + if download.Size > 0 { + headers.Set(echo.HeaderContentLength, strconv.FormatInt(download.Size, 10)) + } + + return c.Stream(http.StatusOK, download.ContentType, download.Reader) +} + // GetAISummary retrieves the AI-generated overall summary for a tender // @Summary Get AI-generated tender summary // @Description Retrieve the AI-generated overall summary for a tender from the AI pipeline storage diff --git a/internal/tender/service.go b/internal/tender/service.go index 0938e8c..eb2cc78 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "mime" "path" "path/filepath" "strings" @@ -53,6 +54,7 @@ type Service interface { GetDocumentSummaries(ctx context.Context, id string) ([]DocumentSummary, error) GetDocuments(ctx context.Context, id string) (*TenderDocumentsResponse, error) DownloadDocuments(ctx context.Context, id string) (*TenderDocumentDownload, error) + DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) // AI summarization operations GetAISummary(ctx context.Context, id string) (*AISummaryResponse, error) @@ -624,6 +626,96 @@ func (s *tenderService) DownloadDocuments(ctx context.Context, id string) (*Tend }, nil } +// DownloadDocument streams a single scraped document for a tender. +func (s *tenderService) DownloadDocument(ctx context.Context, id, filename string) (*TenderDocumentDownload, error) { + if id == "" { + return nil, fmt.Errorf("tender ID cannot be empty") + } + if strings.TrimSpace(filename) == "" { + return nil, fmt.Errorf("document filename cannot be empty") + } + if s.aiSummarizerStorage == nil { + return nil, ErrTenderDocumentStorageUnavailable + } + + tender, err := s.repository.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get tender for document download", map[string]interface{}{ + "tender_id": id, + "filename": filename, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get tender: %w", err) + } + if tender == nil { + return nil, fmt.Errorf("tender not found") + } + if strings.TrimSpace(tender.ContractFolderID) == "" { + return nil, ErrTenderDocumentNotFound + } + + documents, err := s.listDocumentsForTender(ctx, tender) + if err != nil { + s.logger.Error("Failed to list tender documents for download", map[string]interface{}{ + "tender_id": id, + "filename": filename, + "contract_folder_id": tender.ContractFolderID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to list tender documents: %w", err) + } + + doc, ok := findStoredDocumentByFilename(documents, filename) + if !ok { + return nil, ErrTenderDocumentNotFound + } + + reader, err := s.aiSummarizerStorage.DownloadDocument(ctx, doc.ObjectName) + if err != nil { + s.logger.Error("Failed to open tender document from storage", map[string]interface{}{ + "tender_id": id, + "filename": filename, + "object_name": doc.ObjectName, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to download document: %w", err) + } + + downloadName := path.Base(doc.DocumentName) + contentType := mime.TypeByExtension(filepath.Ext(downloadName)) + if contentType == "" { + contentType = "application/octet-stream" + } + + s.logger.Info("Tender document download prepared", map[string]interface{}{ + "tender_id": id, + "filename": downloadName, + "size": doc.Size, + }) + + return &TenderDocumentDownload{ + Reader: reader, + Filename: downloadName, + ContentType: contentType, + Size: doc.Size, + }, nil +} + +func findStoredDocumentByFilename(documents []ai_summarizer.StoredDocument, filename string) (ai_summarizer.StoredDocument, bool) { + filename = strings.TrimSpace(filename) + if filename == "" { + return ai_summarizer.StoredDocument{}, false + } + + wantBase := path.Base(filename) + for _, doc := range documents { + if doc.DocumentName == filename || path.Base(doc.DocumentName) == wantBase { + return doc, true + } + } + return ai_summarizer.StoredDocument{}, false +} + func (s *tenderService) writeDocumentsArchive(ctx context.Context, writer *io.PipeWriter, documents []ai_summarizer.StoredDocument) { zipWriter := zip.NewWriter(writer) usedNames := make(map[string]int, len(documents))