fe3a71ba2b
- Updated the tender service to include a new dependency for listing rejected tenders by company, allowing for more refined tender recommendations. - Introduced a new field in the SearchForm to specify whether to exclude rejected tenders from the recommendation results. - Enhanced the Recommend method to filter out rejected tenders based on the new exclusion logic, improving the relevance of AI-ranked tender recommendations. - Added unit tests to verify the exclusion logic for rejected tenders, ensuring robust functionality. This update improves the tender recommendation process by ensuring that company-rejected tenders are not included in the results, enhancing user experience and satisfaction.
824 lines
34 KiB
Go
824 lines
34 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"
|
|
// @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). 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 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")
|
|
}
|
|
|