package tender import ( "strconv" "strings" "tm/pkg/logger" "tm/pkg/response" "github.com/asaskevich/govalidator" "github.com/labstack/echo/v4" ) // TenderHandler handles tender-related HTTP requests type TenderHandler struct { service TenderService logger logger.Logger } // NewTenderHandler creates a new tender handler func NewTenderHandler(service TenderService, logger logger.Logger) *TenderHandler { return &TenderHandler{ service: service, logger: logger, } } // CreateTender creates a new tender // @Summary Create a new tender // @Description Create a new tender with the provided information // @Tags Admin-Tenders // @Accept json // @Produce json // @Param tender body CreateTenderRequest true "Tender information" // @Success 201 {object} response.APIResponse{data=TenderResponse} // @Failure 400 {object} response.APIResponse // @Failure 409 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/tenders [post] // @Security BearerAuth func (h *TenderHandler) CreateTender(c echo.Context) error { var req CreateTenderRequest if err := c.Bind(&req); err != nil { return response.ValidationError(c, "Invalid request format", err.Error()) } if _, err := govalidator.ValidateStruct(req); err != nil { return response.ValidationError(c, "Validation failed", err.Error()) } tender, err := h.service.CreateTender(c.Request().Context(), req) if err != nil { if err.Error() == "tender with notice publication ID already exists" { return response.Conflict(c, "Tender already exists") } return response.InternalServerError(c, "Failed to create tender") } return response.Created(c, tender.ToTenderResponse(), "Tender created successfully") } // 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" // @Success 200 {object} response.APIResponse{data=TenderResponse} // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/tenders/{id} [get] // @Security BearerAuth func (h *TenderHandler) GetTender(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.GetTenderByID(c.Request().Context(), id) if err != nil { return response.NotFound(c, "Tender not found") } return response.Success(c, tender.ToTenderResponse(), "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/tenders/{id} [put] // @Security BearerAuth func (h *TenderHandler) UpdateTender(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.UpdateTender(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.ToTenderResponse(), "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/tenders/{id} [delete] // @Security BearerAuth func (h *TenderHandler) DeleteTender(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.DeleteTender(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 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)" // @Param company_id query string false "Company ID for match percentage calculation and sorting" // @Param query query string false "Search query for title and description" // @Param notice_type query string false "Filter by notice type code" // @Param procurement_type query string false "Filter by procurement type code" // @Param country_codes query []string false "Filter by country codes (comma-separated)" // @Param status query []string false "Filter by status (comma-separated)" // @Param source query []string false "Filter by source (comma-separated)" // @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" // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/tenders [get] // @Security BearerAuth func (h *TenderHandler) ListTenders(c echo.Context) error { // Parse pagination parameters limit := 20 if l := c.QueryParam("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { limit = parsed } } offset := 0 if o := c.QueryParam("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Build search criteria criteria := TenderSearchCriteria{ Query: c.QueryParam("query"), NoticeType: c.QueryParam("notice_type"), ProcurementType: c.QueryParam("procurement_type"), Currency: c.QueryParam("currency"), } // Parse array parameters if countryCodes := c.QueryParam("country_codes"); countryCodes != "" { criteria.CountryCodes = parseStringArray(countryCodes) } if statuses := c.QueryParam("status"); statuses != "" { statusStrings := parseStringArray(statuses) criteria.Status = make([]TenderStatus, len(statusStrings)) for i, s := range statusStrings { criteria.Status[i] = TenderStatus(s) } } if sources := c.QueryParam("source"); sources != "" { sourceStrings := parseStringArray(sources) criteria.Source = make([]TenderSource, len(sourceStrings)) for i, s := range sourceStrings { criteria.Source[i] = TenderSource(s) } } // Parse numeric parameters if minValue := c.QueryParam("min_estimated_value"); minValue != "" { if parsed, err := strconv.ParseFloat(minValue, 64); err == nil { criteria.MinEstimatedValue = &parsed } } if maxValue := c.QueryParam("max_estimated_value"); maxValue != "" { if parsed, err := strconv.ParseFloat(maxValue, 64); err == nil { criteria.MaxEstimatedValue = &parsed } } // Parse company_id parameter for matching var companyID *string if companyIDParam := c.QueryParam("company_id"); companyIDParam != "" { companyID = &companyIDParam } req := ListTendersRequest{ Criteria: criteria, Limit: limit, Offset: offset, CompanyID: companyID, } result, err := h.service.ListTenders(c.Request().Context(), req) if err != nil { return response.InternalServerError(c, "Failed to retrieve tenders") } responseData := map[string]interface{}{ "tenders": result.Tenders, "total": result.Total, "limit": result.Limit, "offset": result.Offset, } return response.Success(c, responseData, "Tenders retrieved successfully") } // SearchTenders searches tenders // @Summary Search tenders // @Description Search tenders using full-text search and filtering // @Tags Admin-Tenders // @Accept json // @Produce json // @Param search body SearchTendersRequest true "Search criteria" // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/tenders/search [post] // @Security BearerAuth func (h *TenderHandler) SearchTenders(c echo.Context) error { var req SearchTendersRequest if err := c.Bind(&req); err != nil { return response.ValidationError(c, "Invalid request format", err.Error()) } if _, err := govalidator.ValidateStruct(req); err != nil { return response.ValidationError(c, "Validation failed", err.Error()) } result, err := h.service.SearchTenders(c.Request().Context(), req) if err != nil { return response.InternalServerError(c, "Failed to search tenders") } // Convert tenders to response format tenderResponses := make([]TenderResponse, len(result.Tenders)) for i, tender := range result.Tenders { tenderResponses[i] = *tender.ToTenderResponse() } responseData := map[string]interface{}{ "tenders": tenderResponses, "query": result.Query, "total": result.Total, } return response.Success(c, responseData, "Tenders found successfully") } // GetTenderStatistics retrieves tender statistics // @Summary Get tender statistics // @Description Retrieve comprehensive statistics about tenders // @Tags Admin-Tenders // @Produce json // @Success 200 {object} response.APIResponse{data=TenderStatistics} // @Failure 500 {object} response.APIResponse // @Router /admin/tenders/statistics [get] // @Security BearerAuth func (h *TenderHandler) GetTenderStatistics(c echo.Context) error { stats, err := h.service.GetTenderStatistics(c.Request().Context()) if err != nil { return response.InternalServerError(c, "Failed to retrieve statistics") } return response.Success(c, stats, "Statistics retrieved successfully") } // GetDashboard retrieves dashboard data // @Summary Get dashboard data // @Description Retrieve dashboard data including statistics, recent tenders, and active jobs // @Tags Admin-Tenders // @Produce json // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 500 {object} response.APIResponse // @Router /admin/tenders/dashboard [get] // @Security BearerAuth func (h *TenderHandler) GetDashboard(c echo.Context) error { dashboard, err := h.service.GetDashboardData(c.Request().Context()) if err != nil { return response.InternalServerError(c, "Failed to retrieve dashboard data") } // Convert recent tenders to response format recentTenderResponses := make([]TenderResponse, len(dashboard.RecentTenders)) for i, tender := range dashboard.RecentTenders { recentTenderResponses[i] = *tender.ToTenderResponse() } // Convert active jobs to response format activeJobResponses := make([]ScrapingJobResponse, len(dashboard.ActiveJobs)) for i, job := range dashboard.ActiveJobs { activeJobResponses[i] = *job.ToScrapingJobResponse() } responseData := map[string]interface{}{ "statistics": dashboard.Statistics, "recent_tenders": recentTenderResponses, "active_jobs": activeJobResponses, } return response.Success(c, responseData, "Dashboard data retrieved successfully") } // StartScrapingJob starts a new scraping job // @Summary Start scraping job // @Description Start a new TED XML scraping job // @Tags Admin-Scraping // @Accept json // @Produce json // @Param job body StartScrapingJobRequest true "Scraping job configuration" // @Success 201 {object} response.APIResponse{data=ScrapingJobResponse} // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/scraping/jobs [post] // @Security BearerAuth func (h *TenderHandler) StartScrapingJob(c echo.Context) error { var req StartScrapingJobRequest if err := c.Bind(&req); err != nil { return response.ValidationError(c, "Invalid request format", err.Error()) } if _, err := govalidator.ValidateStruct(req); err != nil { return response.ValidationError(c, "Validation failed", err.Error()) } job, err := h.service.StartScrapingJob(c.Request().Context(), req) if err != nil { return response.InternalServerError(c, "Failed to start scraping job") } return response.Created(c, job.ToScrapingJobResponse(), "Scraping job started successfully") } // GetScrapingJob retrieves a scraping job by ID // @Summary Get scraping job // @Description Retrieve a specific scraping job by its ID // @Tags Admin-Scraping // @Produce json // @Param id path string true "Scraping job ID" // @Success 200 {object} response.APIResponse{data=ScrapingJobResponse} // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/scraping/jobs/{id} [get] // @Security BearerAuth func (h *TenderHandler) GetScrapingJob(c echo.Context) error { id := c.Param("id") if id == "" { return response.BadRequest(c, "Scraping job ID is required", "ID parameter cannot be empty") } job, err := h.service.GetScrapingJobStatus(c.Request().Context(), id) if err != nil { return response.NotFound(c, "Scraping job not found") } return response.Success(c, job.ToScrapingJobResponse(), "Scraping job retrieved successfully") } // ListScrapingJobs retrieves scraping jobs with pagination // @Summary List scraping jobs // @Description Retrieve scraping jobs with pagination // @Tags Admin-Scraping // @Produce json // @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)" // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/scraping/jobs [get] // @Security BearerAuth func (h *TenderHandler) ListScrapingJobs(c echo.Context) error { // Parse pagination parameters limit := 20 if l := c.QueryParam("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { limit = parsed } } offset := 0 if o := c.QueryParam("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } req := ListScrapingJobsRequest{ Limit: limit, Offset: offset, } result, err := h.service.ListScrapingJobs(c.Request().Context(), req) if err != nil { return response.InternalServerError(c, "Failed to retrieve scraping jobs") } // Convert jobs to response format jobResponses := make([]ScrapingJobResponse, len(result.Jobs)) for i, job := range result.Jobs { jobResponses[i] = *job.ToScrapingJobResponse() } responseData := map[string]interface{}{ "jobs": jobResponses, "total": result.Total, "limit": result.Limit, "offset": result.Offset, } return response.Success(c, responseData, "Scraping jobs retrieved successfully") } // CancelScrapingJob cancels a running scraping job // @Summary Cancel scraping job // @Description Cancel a running or pending scraping job // @Tags Admin-Scraping // @Param id path string true "Scraping job ID" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /admin/scraping/jobs/{id}/cancel [post] // @Security BearerAuth func (h *TenderHandler) CancelScrapingJob(c echo.Context) error { id := c.Param("id") if id == "" { return response.BadRequest(c, "Scraping job ID is required", "ID parameter cannot be empty") } err := h.service.CancelScrapingJob(c.Request().Context(), id) if err != nil { if err.Error() == "scraping job not found" { return response.NotFound(c, "Scraping job not found") } return response.InternalServerError(c, "Failed to cancel scraping job") } return response.Success(c, map[string]interface{}{"cancelled": true}, "Scraping job cancelled successfully") } // 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 limit query int false "Number of items per page (default: 20, max: 50)" // @Param offset query int false "Number of items to skip (default: 0)" // @Param country_code query string false "Filter by country code" // @Param min_estimated_value query number false "Minimum estimated value" // @Param max_estimated_value query number false "Maximum estimated value" // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /api/v1/tenders [get] func (h *TenderHandler) GetPublicTenders(c echo.Context) error { // Parse pagination parameters (more restrictive for public API) limit := 20 if l := c.QueryParam("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 50 { limit = parsed } } offset := 0 if o := c.QueryParam("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Build search criteria for public API (only active tenders) criteria := TenderSearchCriteria{ Status: []TenderStatus{TenderStatusActive}, } if countryCode := c.QueryParam("country_code"); countryCode != "" { criteria.CountryCodes = []string{countryCode} } // Parse numeric parameters if minValue := c.QueryParam("min_estimated_value"); minValue != "" { if parsed, err := strconv.ParseFloat(minValue, 64); err == nil { criteria.MinEstimatedValue = &parsed } } if maxValue := c.QueryParam("max_estimated_value"); maxValue != "" { if parsed, err := strconv.ParseFloat(maxValue, 64); err == nil { criteria.MaxEstimatedValue = &parsed } } // Get company ID from customer context for automatic matching var companyID *string if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { companyID = &customerCompanyID } req := ListTendersRequest{ Criteria: criteria, Limit: limit, Offset: offset, CompanyID: companyID, } result, err := h.service.ListTenders(c.Request().Context(), req) if err != nil { return response.InternalServerError(c, "Failed to retrieve tenders") } // Convert tenders to public response format (limited fields) publicTenders := make([]map[string]interface{}, len(result.Tenders)) for i, tender := range result.Tenders { tenderData := map[string]interface{}{ "id": tender.ID, "title": tender.Title, "description": tender.Description, "procurement_type_code": tender.ProcurementTypeCode, "main_classification": tender.MainClassification, "estimated_value": tender.EstimatedValue, "currency": tender.Currency, "formatted_estimated_value": tender.FormattedEstimatedValue, "tender_deadline": tender.TenderDeadline, "country_code": tender.CountryCode, "city_name": tender.CityName, "tender_url": tender.TenderURL, "publication_date": tender.PublicationDate, "buyer_organization": tender.BuyerOrganization, "is_expired": tender.IsExpired, } // Add match percentage if available if tender.MatchPercentage != nil { tenderData["match_percentage"] = *tender.MatchPercentage } publicTenders[i] = tenderData } responseData := map[string]interface{}{ "tenders": publicTenders, "total": result.Total, "limit": result.Limit, "offset": result.Offset, } return response.Success(c, responseData, "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" // @Success 200 {object} response.APIResponse{data=map[string]interface{}} // @Failure 404 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /api/v1/tenders/{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.GetTenderByID(c.Request().Context(), id) if err != nil { return response.NotFound(c, "Tender not found") } // Return public-safe tender details publicTender := map[string]interface{}{ "id": tender.ID, "notice_publication_id": tender.NoticePublicationID, "title": tender.Title, "description": tender.Description, "procurement_type_code": tender.ProcurementTypeCode, "procedure_code": tender.ProcedureCode, "main_classification": tender.MainClassification, "additional_classifications": tender.AdditionalClassifications, "estimated_value": tender.EstimatedValue, "currency": tender.Currency, "formatted_estimated_value": tender.GetFormattedEstimatedValue(), "duration": tender.Duration, "duration_unit": tender.DurationUnit, "tender_deadline": tender.TenderDeadline, "place_of_performance": tender.PlaceOfPerformance, "country_code": tender.CountryCode, "region_code": tender.RegionCode, "city_name": tender.CityName, "postal_code": tender.PostalCode, "document_uri": tender.DocumentURI, "tender_url": tender.TenderURL, "buyer_organization": tender.BuyerOrganization, "organizations": tender.Organizations, "selection_criteria": tender.SelectionCriteria, "official_languages": tender.OfficialLanguages, "publication_date": tender.PublicationDate, "gazette_id": tender.GazetteID, "is_active": tender.IsActive(), "is_expired": tender.IsExpired(), } return response.Success(c, publicTender, "Tender details retrieved successfully") } // parseStringArray parses a comma-separated string into a string array func parseStringArray(s string) []string { if s == "" { return nil } result := make([]string, 0) for _, item := range strings.Split(s, ",") { if trimmed := strings.TrimSpace(item); trimmed != "" { result = append(result, trimmed) } } return result }