Enhance Tender API with New Recommendation Endpoint and Route Updates

- Updated the tender API to include a new endpoint for recommending public tenders based on company profiles, enhancing user experience for mobile application users.
- Modified existing routes to reflect the new structure, changing the tender details route and adding a dedicated recommendation route.
- Improved Swagger documentation to accurately represent the new endpoint, including detailed descriptions, parameters, and response formats.
- Ensured adherence to Clean Architecture principles by maintaining clear separation of concerns in the service and handler layers.
This commit is contained in:
n.nakhostin
2025-08-17 08:40:28 +03:30
parent e986ee4135
commit 4663a9781e
8 changed files with 333 additions and 20 deletions
+83 -1
View File
@@ -5374,7 +5374,7 @@ const docTemplate = `{
}
}
},
"/api/v1/tenders/{id}": {
"/api/v1/tenders/read/{id}": {
"get": {
"security": [
{
@@ -5432,6 +5432,88 @@ const docTemplate = `{
}
}
}
},
"/api/v1/tenders/recommend": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve active tenders for mobile application users with automatic company-based matching from customer profile",
"produces": [
"application/json"
],
"tags": [
"Tenders"
],
"summary": "Get public tenders",
"parameters": [
{
"type": "integer",
"description": "Number of items per page (default: 20, max: 50)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip (default: 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter by country code",
"name": "country_code",
"in": "query"
},
{
"type": "number",
"description": "Minimum estimated value",
"name": "min_estimated_value",
"in": "query"
},
{
"type": "number",
"description": "Maximum estimated value",
"name": "max_estimated_value",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.APIResponse"
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"additionalProperties": true
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
}
}
}
}
},
"definitions": {
+83 -1
View File
@@ -5368,7 +5368,7 @@
}
}
},
"/api/v1/tenders/{id}": {
"/api/v1/tenders/read/{id}": {
"get": {
"security": [
{
@@ -5426,6 +5426,88 @@
}
}
}
},
"/api/v1/tenders/recommend": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve active tenders for mobile application users with automatic company-based matching from customer profile",
"produces": [
"application/json"
],
"tags": [
"Tenders"
],
"summary": "Get public tenders",
"parameters": [
{
"type": "integer",
"description": "Number of items per page (default: 20, max: 50)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of items to skip (default: 0)",
"name": "offset",
"in": "query"
},
{
"type": "string",
"description": "Filter by country code",
"name": "country_code",
"in": "query"
},
{
"type": "number",
"description": "Minimum estimated value",
"name": "min_estimated_value",
"in": "query"
},
{
"type": "number",
"description": "Maximum estimated value",
"name": "max_estimated_value",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.APIResponse"
},
{
"type": "object",
"properties": {
"data": {
"type": "object",
"additionalProperties": true
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/response.APIResponse"
}
}
}
}
}
},
"definitions": {
+52 -1
View File
@@ -4493,7 +4493,7 @@ paths:
summary: Get public tenders
tags:
- Tenders
/api/v1/tenders/{id}:
/api/v1/tenders/read/{id}:
get:
description: Retrieve detailed information about a specific tender for mobile
application users
@@ -4529,6 +4529,57 @@ paths:
summary: Get public tender details
tags:
- Tenders
/api/v1/tenders/recommend:
get:
description: Retrieve active tenders for mobile application users with automatic
company-based matching from customer profile
parameters:
- description: 'Number of items per page (default: 20, max: 50)'
in: query
name: limit
type: integer
- description: 'Number of items to skip (default: 0)'
in: query
name: offset
type: integer
- description: Filter by country code
in: query
name: country_code
type: string
- description: Minimum estimated value
in: query
name: min_estimated_value
type: number
- description: Maximum estimated value
in: query
name: max_estimated_value
type: number
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/response.APIResponse'
- properties:
data:
additionalProperties: true
type: object
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/response.APIResponse'
security:
- BearerAuth: []
summary: Get public tenders
tags:
- Tenders
securityDefinitions:
BearerAuth:
description: 'Type "Bearer" followed by a space and JWT token. Example: "Bearer
+2 -1
View File
@@ -128,7 +128,8 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
tendersGP.Use(customerHandler.AuthMiddleware())
{
tendersGP.GET("", tenderHandler.GetPublicTenders)
tendersGP.GET("/:id", tenderHandler.GetPublicTenderDetails)
tendersGP.GET("/recommend", tenderHandler.RecommendTenders)
tendersGP.GET("/details/:id", tenderHandler.GetPublicTenderDetails)
}
// Public Feedback Routes
-7
View File
@@ -27,13 +27,6 @@ type ListTendersResponse struct {
Metadata *response.Meta `json:"metadata"`
}
// SearchTendersResponse represents the response for searching tenders
type SearchTendersResponse struct {
Tenders []Tender `json:"tenders"`
Query string `json:"query"`
Total int `json:"total"`
}
// TenderResponse represents a tender in API responses
type TenderResponse struct {
ID string `json:"id"`
+60 -1
View File
@@ -285,6 +285,65 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
return response.Success(c, result, "Tenders retrieved 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
// @Security BearerAuth
// @Router /api/v1/tenders/recommend [get]
func (h *TenderHandler) RecommendTenders(c echo.Context) error {
// Parse pagination parameters (more restrictive for public API)
limit := 20
if parsed, err := strconv.Atoi(c.QueryParam("limit")); err == nil && parsed > 0 && parsed <= 50 {
limit = parsed
}
offset := 0
if parsed, err := strconv.Atoi(c.QueryParam("offset")); 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 parsed, err := strconv.ParseFloat(c.QueryParam("min_estimated_value"), 64); err == nil {
criteria.MinEstimatedValue = &parsed
}
if parsed, err := strconv.ParseFloat(c.QueryParam("max_estimated_value"), 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 {
companyID = &customerCompanyID
}
result, err := h.service.RecommendTenders(c.Request().Context(), companyID, limit, offset)
if err != nil {
return response.InternalServerError(c, "Failed to retrieve tenders")
}
return response.Success(c, result, "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
@@ -295,7 +354,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tenders/{id} [get]
// @Router /api/v1/tenders/details/{id} [get]
func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
id := c.Param("id")
if id == "" {
+1 -1
View File
@@ -220,7 +220,7 @@ func (r *tenderRepository) List(ctx context.Context, criteria TenderSearchCriter
pagination := mongopkg.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortField: "publication_date",
SortOrder: -1,
}
+52 -7
View File
@@ -3,6 +3,7 @@ package tender
import (
"context"
"fmt"
"math"
"time"
"tm/internal/company"
"tm/pkg/logger"
@@ -16,6 +17,7 @@ type TenderService interface {
UpdateTender(ctx context.Context, req UpdateTenderRequest) (*Tender, error)
DeleteTender(ctx context.Context, id string) error
ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error)
RecommendTenders(ctx context.Context, companyID *string, limit, offset int) (*ListTendersResponse, error)
// Maintenance operations
UpdateExpiredTenders(ctx context.Context) error
@@ -128,13 +130,6 @@ func (s *tenderService) DeleteTender(ctx context.Context, id string) error {
return nil
}
// TenderWithMatch represents a tender with calculated match percentage for sorting
type TenderWithMatch struct {
Tender *Tender
MatchPercentage int
DaysUntilDeadline int
}
// ListTenders retrieves tenders with pagination and filtering
func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest) (*ListTendersResponse, error) {
s.logger.Info("Listing tenders", map[string]interface{}{
@@ -224,6 +219,56 @@ func (s *tenderService) ListTenders(ctx context.Context, req ListTendersRequest)
}, nil
}
// ListTenders retrieves tenders with pagination and filtering
func (s *tenderService) RecommendTenders(ctx context.Context, companyID *string, limit, offset int) (*ListTendersResponse, error) {
s.logger.Info("Recommend tenders", map[string]interface{}{
"company_id": companyID,
})
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
company, err := s.companyService.GetCompanyByID(ctx, *companyID)
if err != nil {
s.logger.Error("Failed to get company", map[string]interface{}{
"company_id": *companyID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to get company: %w", err)
}
tenders, total, err := s.repository.List(ctx, TenderSearchCriteria{
Status: []TenderStatus{TenderStatusActive},
Classifications: company.Tags.CPVCodes,
}, limit, offset)
if err != nil {
s.logger.Error("Failed to list tenders", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to list tenders: %w", err)
}
tenderResponses := make([]TenderResponse, 0, len(tenders))
for _, tender := range tenders {
tenderResponses = append(tenderResponses, *tender.ToTenderResponse())
}
return &ListTendersResponse{
Tenders: tenderResponses,
Metadata: &response.Meta{
Total: int(total),
Limit: limit,
Offset: offset,
Pages: int(math.Ceil(float64(total) / float64(limit))),
Page: int(math.Ceil(float64(offset) / float64(limit))),
},
}, nil
}
// GetTenderStatistics retrieves tender statistics
func (s *tenderService) GetTenderStatistics(ctx context.Context) (*TenderStatistics, error) {
s.logger.Info("Retrieving tender statistics", map[string]interface{}{})