fa258020f1
continuous-integration/drone/push Build is passing
- Introduced `FormatAIProcedureRef` and `ParseAIProcedureRef` functions in the `tender` domain for handling AI service tender references. - Added unit tests for these functions in `ai_reference_test.go` to ensure correct parsing and formatting behavior. - Updated the `TenderResponse` struct to include a new `ProcedureRef` field for improved data representation. - Enhanced the `GetByProcedureReference` method in the repository to retrieve tenders based on the new procedure reference format. - Modified the `Recommend` method in the service layer to utilize the new procedure reference handling, improving the recommendation process. This update enhances the handling of AI procedure references, ensuring better data integrity and usability in the tender management system.
781 lines
32 KiB
Go
781 lines
32 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")
|
|
}
|
|
|
|
// *****************************************************
|
|
// * 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. 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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|