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" // @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 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" // @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 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). 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 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") }