Files
tm_back/internal/tender/handler.go
T
Mazyar 5c93e0f01b Add documents scraped filter functionality and related tests
- Introduced `documents_scraped_filter.go` to handle filtering tenders based on the presence of documents in MinIO, utilizing a caching mechanism to optimize performance.
- Implemented `enrichDocumentsScrapedFilter` and `cachedContractFolderIDsWithDocuments` methods in the `tenderService` for efficient retrieval of contract folder IDs with documents.
- Added unit tests in `documents_scraped_filter_test.go` to validate the new filtering logic and caching behavior, ensuring accurate results and error handling.
- Updated `SearchForm` documentation to clarify the caching aspect of `ContractFolderIDsWithDocuments`.
- Enhanced comments in the handler and repository to reflect the new filtering logic and its implications.

This update improves the tender search functionality by efficiently managing document presence checks, leading to better performance and user experience in the tender management system.
2026-07-12 00:20:39 +03:30

840 lines
35 KiB
Go

package tender
import (
"errors"
"mime"
"net/http"
"strconv"
"strings"
"time"
"tm/internal/company"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/response"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo/v4"
)
// TenderHandler handles tender-related HTTP requests
type TenderHandler struct {
service Service
logger logger.Logger
}
// NewHandler creates a new tender handler
func NewHandler(service Service, logger logger.Logger) *TenderHandler {
return &TenderHandler{
service: service,
logger: logger,
}
}
// *****************************************************
// * ADMIN HANDLERS *
// *****************************************************
// GetTender retrieves a tender by ID
// @Summary Get tender by ID
// @Description Retrieve a specific tender by its ID
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Param language query string false "Translation language code (defaults to en)"
// @Success 200 {object} response.APIResponse{data=TenderResponse}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id} [get]
// @Security BearerAuth
func (h *TenderHandler) Get(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
tender, err := h.service.GetByID(c.Request().Context(), id, c.QueryParam("language"))
if err != nil {
return response.NotFound(c, "Tender not found")
}
return response.Success(c, tender, "Tender retrieved successfully")
}
// UpdateTender updates an existing tender
// @Summary Update tender
// @Description Update an existing tender with the provided information
// @Tags Admin-Tenders
// @Accept json
// @Produce json
// @Param id path string true "Tender ID"
// @Param tender body UpdateTenderRequest true "Updated tender information"
// @Success 200 {object} response.APIResponse{data=TenderResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id} [put]
// @Security BearerAuth
func (h *TenderHandler) Update(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
var req UpdateTenderRequest
if err := c.Bind(&req); err != nil {
return response.ValidationError(c, "Invalid request format", err.Error())
}
req.ID = id
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
tender, err := h.service.Update(c.Request().Context(), req)
if err != nil {
if err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
return response.InternalServerError(c, "Failed to update tender")
}
return response.Success(c, tender, "Tender updated successfully")
}
// DeleteTender deletes a tender
// @Summary Delete tender
// @Description Delete a tender by its ID
// @Tags Admin-Tenders
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id} [delete]
// @Security BearerAuth
func (h *TenderHandler) Delete(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
err := h.service.Delete(c.Request().Context(), id)
if err != nil {
if err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
return response.InternalServerError(c, "Failed to delete tender")
}
return response.Success(c, map[string]interface{}{"deleted": true}, "Tender deleted successfully")
}
// ListTenders retrieves tenders with pagination and filtering
// @Summary List tenders
// @Description Retrieve tenders with pagination, filtering, and search capabilities with optional company-based matching
// @Tags Admin-Tenders
// @Produce json
// @Param company_id query string false "Company ID for match percentage calculation and sorting"
// @Param q query string false "Keyword search across title, description, per-document AI summaries (document_summaries), and optional stored overall summary (ai_overall_summary)"
// @Param notice_type query string false "Filter by TED notice type code (BT-02, notice_type_code)"
// @Param notice_types query []string false "Filter by multiple notice type codes (notice_type_code)"
// @Param form_types query []string false "Filter by TED form type / listName (e.g. competition, result, planning, contract-award, prior-information)"
// @Param procurement_type query string false "Filter by procurement type code"
// @Param country query string false "Filter by a single ISO country code"
// @Param country_codes query []string false "Filter by country codes (repeat param or comma-separated)"
// @Param status query []string false "Filter by status (comma-separated)"
// @Param source query []string false "Filter by source (comma-separated)"
// @Param classifications query []string false "Filter by CPV / classification codes (main, additional, or procurement lot CPVs)"
// @Param cpv_codes query []string false "Alias for classifications: one or more CPV codes"
// @Param min_estimated_value query number false "Minimum estimated value"
// @Param max_estimated_value query number false "Maximum estimated value"
// @Param currency query string false "Filter by currency"
// @Param created_at_from query number false "Created at lower bound (Unix timestamp, seconds)"
// @Param created_at_to query number false "Created at upper bound (Unix timestamp, seconds)"
// @Param deadline_from query number false "Tender deadline from (Unix timestamp, seconds)"
// @Param deadline_to query number false "Tender deadline to (Unix timestamp, seconds)"
// @Param publication_date_from query number false "Publication date from (Unix timestamp, seconds)"
// @Param publication_date_to query number false "Publication date to (Unix timestamp, seconds)"
// @Param submission_date_from query number false "Submission deadline from (Unix timestamp, seconds)"
// @Param submission_date_to query number false "Submission deadline to (Unix timestamp, seconds)"
// @Param languages query []string false "Filter by languages (comma-separated)"
// @Param buyer_organization_id query string false "Filter by buyer organization ID"
// @Param notice_publication_id query string false "Filter by TED notice publication ID"
// @Param documents_scraped query bool false "When true, only tenders whose contract_folder_id has documents in MinIO (cached scan)"
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0). Prefer cursor for deep pages."
// @Param cursor query string false "Opaque cursor from previous page meta.next_cursor"
// @Param include_total query bool false "When true, include meta.total and meta.pages (slower on large collections)"
// @Param sort_by query string false "Sort by field"
// @Param sort_order query string false "Sort order (asc or desc)"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders [get]
// @Security BearerAuth
func (h *TenderHandler) Search(c echo.Context) error {
form, err := response.Parse[SearchForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
// Validate form
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
tenders, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to list tenders")
}
return response.SuccessWithMeta(c, tenders, tenders.Metadata, "Tenders retrieved successfully")
}
// AdminRecommendTenders returns AI-ranked tender recommendations for a company (Web Panel)
// @Summary Get recommended tenders for a company
// @Description Retrieve AI-ranked tenders with full tender details for the given company. Unlike the customer API, active-only filters are not applied unless requested via query parameters.
// @Tags Admin-Companies
// @Produce json
// @Param id path string true "Company ID"
// @Param limit query int false "Number of items per page (default: 10, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0)"
// @Param lang query string false "Response language code (defaults to en)"
// @Param status query []string false "Filter by status (comma-separated)"
// @Param only_active_deadlines query bool false "When true, exclude tenders past their deadline"
// @Success 200 {object} response.APIResponse{data=SearchResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 503 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /admin/v1/companies/{id}/recommended-tenders [get]
func (h *TenderHandler) AdminRecommendTenders(c echo.Context) error {
companyID := strings.TrimSpace(c.Param("id"))
if companyID == "" {
return response.BadRequest(c, "Company ID is required", "")
}
form, err := response.Parse[SearchForm](c)
if err != nil {
return response.BadRequest(c, "Invalid request format", "")
}
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, err.Error(), "")
}
form.CompanyID = &companyID
return h.serveRecommendTenders(c, form)
}
// *****************************************************
// * PUBLIC HANDLERS *
// *****************************************************
// GetPublicTenders retrieves public tenders for mobile app
// @Summary Get public tenders
// @Description Retrieve active tenders for mobile application users with automatic company-based matching from customer profile
// @Tags Tenders
// @Produce json
// @Param q query string false "Keyword search across title, description, AI document summaries, optional stored overall summary"
// @Param notice_type query string false "Filter by TED notice type code (notice_type_code)"
// @Param notice_types query []string false "Multiple notice type codes"
// @Param form_types query []string false "TED form types: competition, result, planning, contract-award, prior-information, etc."
// @Param procurement_type query string false "Filter by procurement type code"
// @Param country query string false "Single ISO country code"
// @Param country_codes query []string false "Filter by country codes"
// @Param status query []string false "Filter by status (comma-separated)"
// @Param source query []string false "Filter by source (comma-separated)"
// @Param classifications query []string false "CPV / classification codes"
// @Param cpv_codes query []string false "One or more CPV codes (same as classifications)"
// @Param min_estimated_value query number false "Minimum estimated value"
// @Param max_estimated_value query number false "Maximum estimated value"
// @Param currency query string false "Filter by currency"
// @Param created_at_from query number false "Created at from (Unix seconds)"
// @Param created_at_to query number false "Created at to (Unix seconds)"
// @Param deadline_from query number false "Tender deadline from (Unix seconds)"
// @Param deadline_to query number false "Tender deadline to (Unix seconds)"
// @Param publication_date_from query number false "Publication date from (Unix seconds)"
// @Param publication_date_to query number false "Publication date to (Unix seconds)"
// @Param submission_date_from query number false "Submission deadline from (Unix seconds)"
// @Param submission_date_to query number false "Submission deadline to (Unix seconds)"
// @Param languages query []string false "Filter by languages (comma-separated)"
// @Param buyer_organization_id query string false "Filter by buyer organization ID"
// @Param notice_publication_id query string false "Filter by TED notice publication ID"
// @Param documents_scraped query bool false "When true, only tenders whose contract_folder_id has documents in MinIO (cached scan)"
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0). Prefer cursor for deep pages."
// @Param cursor query string false "Opaque cursor from previous page meta.next_cursor"
// @Param include_total query bool false "When true, include meta.total and meta.pages (slower on large collections)"
// @Param sort_by query string false "Sort by field"
// @Param sort_order query string false "Sort order (asc or desc)"
// @Success 200 {object} response.APIResponse{data=SearchResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tenders [get]
func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
form, err := response.Parse[SearchForm](c)
if err != nil {
return response.BadRequest(c, "Invalid request format", "")
}
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, err.Error(), "")
}
form.Status = []string{string(TenderStatusActive)}
form.OnlyActiveDeadlines = true
// Get company ID and customer ID from customer context for automatic matching
var companyID *string
if customerCompanyID, ok := c.Get("company_id").(string); ok {
companyID = &customerCompanyID
}
form.CompanyID = companyID
var customerID *string
if customerIDStr, ok := c.Get("customer_id").(string); ok {
customerID = &customerIDStr
}
form.CustomerID = customerID
pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.Search(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
return response.InternalServerError(c, "Failed to retrieve tenders")
}
return response.SuccessWithMeta(c, result, result.Metadata, "Tenders retrieved successfully")
}
// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company
// @Summary Get recommended tenders
// @Description Retrieve AI-ranked tenders with full tender details. Only active tenders whose publication-based submission window has not passed are returned (submission_deadline, or 48 working hours after publication_date). Company-rejected tenders are excluded. AI tender_id values use PROC_{contract_folder_id}/{notice_publication_id}; each item includes rank, analysis, procedure_ref, and the standard tender fields.
// @Tags Tenders
// @Produce json
// @Param limit query int false "Number of items per page (default: 10, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0)"
// @Param lang query string false "Response language code (defaults to en)"
// @Success 200 {object} response.APIResponse{data=SearchResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 503 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tenders/recommend [get]
func (h *TenderHandler) RecommendTenders(c echo.Context) error {
form, err := response.Parse[SearchForm](c)
if err != nil {
return response.BadRequest(c, "Invalid request format", "")
}
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, err.Error(), "")
}
form.Status = []string{string(TenderStatusActive)}
form.OnlyActiveDeadlines = true
form.ExcludeRejectedTenders = true
if customerIDStr, ok := c.Get("customer_id").(string); ok && customerIDStr != "" {
form.CustomerID = &customerIDStr
}
requestedCompanyID := ""
if form.CompanyID != nil {
requestedCompanyID = strings.TrimSpace(*form.CompanyID)
}
if requestedCompanyID != "" {
if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
form.CompanyID = &customerCompanyID
}
} else if customerCompanyIDs, ok := c.Get("company_ids").([]string); ok && len(customerCompanyIDs) > 0 {
form.CompanyIDs = append([]string(nil), customerCompanyIDs...)
} else if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" {
form.CompanyID = &customerCompanyID
}
return h.serveRecommendTenders(c, form)
}
func (h *TenderHandler) serveRecommendTenders(c echo.Context, form *SearchForm) error {
pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
result, err := h.service.Recommend(c.Request().Context(), form, pagination)
if err != nil {
if response.IsListPaginationError(err) {
return response.PaginationBadRequest(c, err)
}
if errors.Is(err, company.ErrAIRecommendationNotConfigured) {
return response.ServiceUnavailable(c, "AI recommendation service is not configured")
}
if err.Error() == "company ID is required" {
return response.BadRequest(c, "Company ID is required", "")
}
if err.Error() == "company not found" {
return response.NotFound(c, "Company not found")
}
return response.InternalServerError(c, "Failed to retrieve recommended tenders")
}
return response.SuccessWithMeta(c, result, result.Metadata, "Recommended tenders retrieved successfully")
}
// GetPublicTenderDetails retrieves public tender details for mobile app
// @Summary Get public tender details
// @Description Retrieve detailed information about a specific tender for mobile application users
// @Tags Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Param language query string false "Translation language code (defaults to en)"
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tenders/details/{id} [get]
func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
tender, err := h.service.GetByID(c.Request().Context(), id, c.QueryParam("language"))
if err != nil {
return response.NotFound(c, "Tender not found")
}
// Only show tenders whose deadlines have not been reached
if tender.TenderDeadline > 0 && tender.TenderDeadline <= time.Now().Unix() {
return response.NotFound(c, "Tender not found")
}
return response.Success(c, tender, "Tender details retrieved successfully")
}
// GetDocumentSummaries retrieves document summaries for a tender
// @Summary Get document summaries for a tender
// @Description Retrieve per-document AI summaries from the AI service MinIO storage for a specific tender
// @Tags Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=[]DocumentSummary}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/document-summaries [get]
func (h *TenderHandler) GetDocumentSummaries(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
summaries, err := h.service.GetDocumentSummaries(c.Request().Context(), id)
if err != nil {
h.logger.Error("Failed to get document summaries", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return response.NotFound(c, "Document summaries not found")
}
return response.Success(c, summaries, "Document summaries retrieved successfully")
}
// GetDocuments retrieves scraped documents for a tender
// @Summary Get scraped documents for a tender
// @Description Retrieve scraped document metadata for a specific tender
// @Tags Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=TenderDocumentsResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse "Tender not found"
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/documents [get]
func (h *TenderHandler) GetDocuments(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
documents, err := h.service.GetDocuments(c.Request().Context(), id)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return response.NotFound(c, "Tender not found")
}
if err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
return response.InternalServerError(c, "Failed to retrieve tender documents")
}
return response.Success(c, documents, "Tender documents retrieved successfully")
}
// DownloadDocuments downloads scraped documents for a tender
// @Summary Download scraped documents for a tender
// @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
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/documents/download [get]
func (h *TenderHandler) DownloadDocuments(c echo.Context) error {
id := c.Param("id")
if id == "" {
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 err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
if errors.Is(err, ErrTenderDocumentNotFound) {
return response.NotFound(c, "Tender documents not found")
}
if errors.Is(err, ErrTenderDocumentStorageUnavailable) {
return response.InternalServerError(c, "Tender document storage is unavailable")
}
return response.InternalServerError(c, "Failed to download tender documents")
}
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)
}
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 err.Error() == "tender not found" {
return response.NotFound(c, "Tender not found")
}
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. When documents are scraped but no summary exists yet, on-demand summarization is triggered automatically.
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AISummaryResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-summary [get]
// @Security BearerAuth
func (h *TenderHandler) GetAISummary(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
summary, err := h.service.GetAISummary(c.Request().Context(), id)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return response.NotFound(c, "Tender not found")
}
h.logger.Error("Failed to get AI summary", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return response.InternalServerError(c, "Failed to retrieve AI summary")
}
return response.Success(c, summary, "AI summary retrieved successfully")
}
// TriggerAISummarize triggers on-demand AI summarization for a tender
// @Summary Trigger AI summarization
// @Description Trigger on-demand AI summarization for a tender by calling the external AI service
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AISummarizeResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-summarize [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerAISummarize(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
result, err := h.service.TriggerAISummarize(c.Request().Context(), id)
if err != nil {
h.logger.Error("Failed to trigger AI summarization", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return mapAITriggerHTTPError(c, err, "AI summarization")
}
return response.Success(c, result, "AI summarization completed successfully")
}
// TriggerAIAnalyze triggers on-demand agentic analysis for a tender
// @Summary Trigger AI analysis
// @Description Trigger on-demand agentic document analysis for a tender via the external AI service
// @Tags Admin-Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AIAnalyzeResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-analyze [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerAIAnalyze(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
result, err := h.service.TriggerAIAnalyze(c.Request().Context(), id)
if err != nil {
h.logger.Error("Failed to trigger AI analysis", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return mapAITriggerHTTPError(c, err, "AI analysis")
}
return response.Success(c, result, "AI analysis completed successfully")
}
// mapAITriggerHTTPError maps AI trigger service errors to safe HTTP responses.
func mapAITriggerHTTPError(c echo.Context, err error, action string) error {
switch {
case errors.Is(err, ErrEmptyTenderID), errors.Is(err, ErrInvalidTenderID):
return response.BadRequest(c, "Invalid request", "Invalid tender ID")
case errors.Is(err, ErrTenderNotFound), errors.Is(err, orm.ErrDocumentNotFound):
return response.NotFound(c, "Tender not found")
case errors.Is(err, ErrTenderMissingNoticeID), errors.Is(err, ErrTenderMissingContractFolderID):
return response.BadRequest(c, "Tender not eligible", "Tender is missing required identifiers for AI processing")
case errors.Is(err, ErrTenderDocumentsNotScraped):
return response.BadRequest(c, "Tender not eligible", "Tender documents have not been scraped yet")
case errors.Is(err, ErrAINotConfigured):
return response.InternalServerError(c, "AI service is not available")
default:
return response.InternalServerError(c, action+" failed")
}
}
// TriggerAITranslate triggers on-demand AI translation for a tender
// @Summary Trigger AI translation
// @Description Trigger on-demand AI translation for tender title and description (cached in MinIO by the AI service)
// @Tags Admin-Tenders
// @Accept json
// @Produce json
// @Param id path string true "Tender ID"
// @Param request body AITranslateRequest false "Translation language request (defaults to en)"
// @Success 200 {object} response.APIResponse{data=AITranslateResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/{id}/ai-translate [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerAITranslate(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
req := AITranslateRequest{Language: "en"}
_ = c.Bind(&req)
if req.Language == "" {
req.Language = "en"
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
result, err := h.service.TriggerAITranslate(c.Request().Context(), id, req.Language)
if err != nil {
return response.InternalServerError(c, err.Error())
}
return response.Success(c, result, "AI translation completed successfully")
}
// GetPublicAISummary retrieves the AI-generated overall summary for a tender (public endpoint)
// @Summary Get AI-generated tender summary (public)
// @Description Retrieve the AI-generated overall summary for a tender. When documents are scraped but no summary exists yet, on-demand summarization is triggered automatically.
// @Tags Tenders
// @Produce json
// @Param id path string true "Tender ID"
// @Success 200 {object} response.APIResponse{data=AISummaryResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/ai-summary [get]
// @Security BearerAuth
func (h *TenderHandler) GetPublicAISummary(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
summary, err := h.service.GetAISummary(c.Request().Context(), id)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return response.NotFound(c, "Tender not found")
}
h.logger.Error("Failed to get AI summary", map[string]interface{}{
"tender_id": id,
"error": err.Error(),
})
return response.InternalServerError(c, "Failed to retrieve AI summary")
}
return response.Success(c, summary, "AI summary retrieved successfully")
}
// TriggerPublicAITranslate triggers on-demand AI translation for a tender (public endpoint)
// @Summary Trigger AI translation (public)
// @Description Trigger on-demand AI translation for tender title and description (cached in MinIO by the AI service)
// @Tags Tenders
// @Accept json
// @Produce json
// @Param id path string true "Tender ID"
// @Param request body AITranslateRequest false "Translation language request (defaults to en)"
// @Success 200 {object} response.APIResponse{data=AITranslateResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /api/v1/tenders/{id}/ai-translate [post]
// @Security BearerAuth
func (h *TenderHandler) TriggerPublicAITranslate(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Tender ID is required", "ID parameter cannot be empty")
}
req := AITranslateRequest{Language: "en"}
_ = c.Bind(&req)
if req.Language == "" {
req.Language = "en"
}
if _, err := govalidator.ValidateStruct(req); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
result, err := h.service.TriggerAITranslate(c.Request().Context(), id, req.Language)
if err != nil {
return response.InternalServerError(c, err.Error())
}
return response.Success(c, result, "AI translation completed successfully")
}
// ListTendersWithScrapedDocuments lists tenders that have scraped documents in the database
// @Summary List tenders with scraped documents
// @Description Lists database tenders whose contract_folder_id has documents in MinIO. By default returns quickly using the procedure scan only. Use sync=1 to re-list each procedure in MinIO and update Mongo scraped document metadata (slow).
// @Tags Admin-Tenders
// @Produce json
// @Param limit query int false "Number of items per page (default: 10, max: 100)"
// @Param offset query int false "Number of items to skip (default: 0)"
// @Param sync query bool false "When true or 1, list each tender's documents in MinIO and persist scraped metadata to Mongo (slow)"
// @Success 200 {object} response.APIResponse{data=TendersWithScrapedDocumentsResponse}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/scraped-documents [get]
// @Security BearerAuth
func (h *TenderHandler) ListTendersWithScrapedDocuments(c echo.Context) error {
pagination, err := response.NewPagination(c)
if err != nil {
return response.PaginationBadRequest(c, err)
}
syncMongo := c.QueryParam("sync") == "1" || strings.EqualFold(c.QueryParam("sync"), "true")
result, err := h.service.ListTendersWithScrapedDocuments(c.Request().Context(), pagination, syncMongo)
if err != nil {
return response.InternalServerError(c, "Failed to list tenders with scraped documents")
}
return response.SuccessWithMeta(c, result, result.Metadata, "Tenders with scraped documents retrieved successfully")
}
// GetAllAISummaries retrieves all AI-generated summaries from storage
// @Summary Get all AI-generated tender summaries
// @Description Retrieve all AI-generated summaries from the AI pipeline storage, including pending and completed ones
// @Tags Admin-Tenders
// @Produce json
// @Success 200 {object} response.APIResponse{data=[]ai_summarizer.TenderSummaryItem}
// @Failure 500 {object} response.APIResponse
// @Router /admin/v1/tenders/ai-summaries [get]
// @Security BearerAuth
func (h *TenderHandler) GetAllAISummaries(c echo.Context) error {
summaries, err := h.service.GetAllAISummaries(c.Request().Context())
if err != nil {
h.logger.Error("Failed to get all AI summaries", map[string]interface{}{
"error": err.Error(),
})
return response.InternalServerError(c, err.Error())
}
return response.Success(c, summaries, "AI summaries retrieved successfully")
}