Merge branch 'develop' into TM-310

This commit is contained in:
m.nazemi
2026-06-13 14:50:00 +03:30
12 changed files with 428 additions and 19 deletions
+3 -1
View File
@@ -167,9 +167,11 @@ func main() {
// Initialize AI Summarizer service
var aiSummarizerClient tender.AISummarizerClient
var aiSummarizerStorage tender.AISummarizerStorage
var aiRecommendationClient company.AIRecommendationClient
if c := bootstrap.InitAISummarizerClient(conf.AISummarizer, mongoManager, logger); c != nil {
aiSummarizerClient = c
aiRecommendationClient = c
}
if s := bootstrap.InitAISummarizerStorage(conf.AISummarizer, logger); s != nil {
aiSummarizerStorage = s
@@ -210,7 +212,7 @@ func main() {
auditLogger := audit.NewLogger(logger)
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
categoryService := company_category.NewService(categoryRepository, logger)
companyService := company.NewService(companyRepository, categoryService, fileStoreService, logger)
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, logger)
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
+8
View File
@@ -291,6 +291,14 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
companiesGP.GET("", companyHandler.MyCompany)
}
// AI tender recommendation routes
recommendationGP := v1.Group("")
recommendationGP.Use(customerHandler.AuthMiddleware())
{
recommendationGP.POST("/onboarding", companyHandler.StartOnboarding)
recommendationGP.POST("/recommend", companyHandler.RecommendTenders)
}
// Public Inquiry Routes
inquiryGP := v1.Group("/inquiries")
{
+12
View File
@@ -185,6 +185,18 @@ func (c *CompanyTags) ToResponse(categories []*CategoryResponse) *CompanyTagsRes
}
}
// OnboardingResponse is returned after starting AI company onboarding.
type OnboardingResponse struct {
Status string `json:"status"`
}
// RecommendedTenderResponse is one ranked tender recommendation from the AI service.
type RecommendedTenderResponse struct {
Rank string `json:"rank"`
TenderID string `json:"tender_id"`
Analysis string `json:"analysis"`
}
// CompanyProfileResponse represents the company profile data sent in API responses
type CompanyProfileResponse struct {
ID string `json:"id"`
+67
View File
@@ -1,6 +1,7 @@
package company
import (
"errors"
"mime/multipart"
"tm/internal/user"
@@ -53,6 +54,72 @@ func (h *Handler) MyCompany(c echo.Context) error {
return response.Success(c, company, "Company retrieved successfully")
}
// StartOnboarding starts AI onboarding for the authenticated customer's company (Mobile App)
// @Summary Start AI company onboarding
// @Description Send company profile, uploaded documents, and website URL to the AI team to start tender recommendation onboarding
// @Tags Company
// @Accept json
// @Produce json
// @Success 200 {object} response.APIResponse{data=OnboardingResponse}
// @Failure 401 {object} response.APIResponse "Unauthorized - User not authenticated"
// @Failure 404 {object} response.APIResponse "Company not found"
// @Failure 503 {object} response.APIResponse "AI recommendation service unavailable"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /api/v1/onboarding [post]
func (h *Handler) StartOnboarding(c echo.Context) error {
companyID, err := user.GetCompanyIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "User not authenticated")
}
result, err := h.service.StartAIOnboarding(c.Request().Context(), companyID)
if err != nil {
if errors.Is(err, ErrAIRecommendationNotConfigured) {
return response.ServiceUnavailable(c, "AI recommendation service is not configured")
}
if err.Error() == "company not found" {
return response.NotFound(c, "Company not found")
}
return response.InternalServerError(c, "Failed to start AI onboarding")
}
return response.Success(c, result, "AI onboarding started successfully")
}
// RecommendTenders returns AI-ranked tender recommendations for the authenticated customer's company (Mobile App)
// @Summary Get AI tender recommendations
// @Description Retrieve ranked tender recommendations from the AI team for the authenticated customer's company
// @Tags Company
// @Accept json
// @Produce json
// @Success 200 {object} response.APIResponse{data=[]RecommendedTenderResponse}
// @Failure 401 {object} response.APIResponse "Unauthorized - User not authenticated"
// @Failure 404 {object} response.APIResponse "Company not found"
// @Failure 503 {object} response.APIResponse "AI recommendation service unavailable"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /api/v1/recommend [post]
func (h *Handler) RecommendTenders(c echo.Context) error {
companyID, err := user.GetCompanyIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "User not authenticated")
}
result, err := h.service.GetAIRecommendations(c.Request().Context(), companyID)
if err != nil {
if errors.Is(err, ErrAIRecommendationNotConfigured) {
return response.ServiceUnavailable(c, "AI recommendation service is not configured")
}
if err.Error() == "company not found" {
return response.NotFound(c, "Company not found")
}
return response.InternalServerError(c, "Failed to retrieve AI recommendations")
}
return response.Success(c, result, "AI recommendations retrieved successfully")
}
// **************************** ADMIN ENDPOINTS ****************************
// Create creates a new company (Web Panel)
+186
View File
@@ -0,0 +1,186 @@
package company
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"tm/pkg/ai_summarizer"
)
var ErrAIRecommendationNotConfigured = errors.New("AI recommendation service is not configured")
// AIRecommendationClient defines the interface for company tender recommendation AI operations.
type AIRecommendationClient interface {
StartOnboarding(ctx context.Context, reqBody ai_summarizer.OnboardingRequest) (*ai_summarizer.OnboardingResponse, error)
FetchRecommendations(ctx context.Context, reqBody ai_summarizer.RecommendRequest) ([]ai_summarizer.RecommendedTender, error)
}
type onboardingDocumentPayload struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Content string `json:"content"`
}
// StartAIOnboarding sends company profile data to the AI team to start recommendation onboarding.
func (s *companyService) StartAIOnboarding(ctx context.Context, companyID string) (*OnboardingResponse, error) {
if s.aiRecommendationClient == nil {
return nil, ErrAIRecommendationNotConfigured
}
if strings.TrimSpace(companyID) == "" {
return nil, errors.New("company ID is required")
}
company, err := s.repository.GetByID(ctx, companyID)
if err != nil {
s.logger.Error("Failed to load company for AI onboarding", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, errors.New("company not found")
}
documents, err := s.buildOnboardingDocuments(company.DocumentFileIDs)
if err != nil {
s.logger.Error("Failed to prepare company documents for AI onboarding", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to prepare company documents: %w", err)
}
websiteURL := ""
if company.Website != nil {
websiteURL = strings.TrimSpace(*company.Website)
}
s.logger.Info("Starting AI company onboarding", map[string]interface{}{
"company_id": companyID,
"documents_count": len(company.DocumentFileIDs),
"has_website_url": websiteURL != "",
})
result, err := s.aiRecommendationClient.StartOnboarding(ctx, ai_summarizer.OnboardingRequest{
CompanyID: companyID,
Documents: documents,
WebsiteURL: websiteURL,
})
if err != nil {
s.logger.Error("AI onboarding request failed", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to start AI onboarding: %w", err)
}
return &OnboardingResponse{Status: result.Status}, nil
}
// GetAIRecommendations retrieves ranked tender recommendations from the AI team.
func (s *companyService) GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) {
if s.aiRecommendationClient == nil {
return nil, ErrAIRecommendationNotConfigured
}
if strings.TrimSpace(companyID) == "" {
return nil, errors.New("company ID is required")
}
if _, err := s.repository.GetByID(ctx, companyID); err != nil {
s.logger.Error("Failed to load company for AI recommendations", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, errors.New("company not found")
}
s.logger.Info("Fetching AI tender recommendations", map[string]interface{}{
"company_id": companyID,
})
items, err := s.aiRecommendationClient.FetchRecommendations(ctx, ai_summarizer.RecommendRequest{
CompanyID: companyID,
})
if err != nil {
s.logger.Error("AI recommend request failed", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return nil, fmt.Errorf("failed to fetch AI recommendations: %w", err)
}
responses := make([]RecommendedTenderResponse, 0, len(items))
for _, item := range items {
responses = append(responses, RecommendedTenderResponse{
Rank: item.Rank,
TenderID: item.TenderID,
Analysis: item.Analysis,
})
}
return responses, nil
}
func (s *companyService) buildOnboardingDocuments(fileIDs []string) (string, error) {
if len(fileIDs) == 0 {
return "[]", nil
}
if s.fileStore == nil {
return "", errors.New("file storage is not configured")
}
payloads := make([]onboardingDocumentPayload, 0, len(fileIDs))
for _, fileID := range fileIDs {
if strings.TrimSpace(fileID) == "" {
continue
}
reader, info, err := s.fileStore.Download(fileID)
if err != nil {
s.logger.Warn("Skipping company document for AI onboarding", map[string]interface{}{
"file_id": fileID,
"error": err.Error(),
})
continue
}
content, readErr := io.ReadAll(reader)
closeErr := reader.Close()
if readErr != nil {
return "", fmt.Errorf("failed to read document %s: %w", fileID, readErr)
}
if closeErr != nil {
s.logger.Warn("Failed to close company document reader", map[string]interface{}{
"file_id": fileID,
"error": closeErr.Error(),
})
}
filename := fileID
contentType := "application/octet-stream"
if info != nil {
if info.Filename != "" {
filename = info.Filename
}
if info.ContentType != "" {
contentType = info.ContentType
}
}
payloads = append(payloads, onboardingDocumentPayload{
Filename: filename,
ContentType: contentType,
Content: base64.StdEncoding.EncodeToString(content),
})
}
encoded, err := json.Marshal(payloads)
if err != nil {
return "", fmt.Errorf("failed to encode company documents: %w", err)
}
return string(encoded), nil
}
+15 -1
View File
@@ -34,6 +34,12 @@ type Service interface {
// UploadDocuments uploads files and attaches them to a company
UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error)
// StartAIOnboarding sends company data to the AI team for recommendation onboarding
StartAIOnboarding(ctx context.Context, companyID string) (*OnboardingResponse, error)
// GetAIRecommendations returns ranked tender recommendations from the AI team
GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error)
}
// companyService implements the Service interface
@@ -41,15 +47,23 @@ type companyService struct {
repository Repository
categoryService company_category.Service
fileStore filestore.FileStoreService
aiRecommendationClient AIRecommendationClient
logger logger.Logger
}
// NewService creates a new company service
func NewService(repository Repository, categoryService company_category.Service, fileStore filestore.FileStoreService, logger logger.Logger) Service {
func NewService(
repository Repository,
categoryService company_category.Service,
fileStore filestore.FileStoreService,
aiRecommendationClient AIRecommendationClient,
logger logger.Logger,
) Service {
return &companyService{
repository: repository,
categoryService: categoryService,
fileStore: fileStore,
aiRecommendationClient: aiRecommendationClient,
logger: logger,
}
}
+15
View File
@@ -1,6 +1,8 @@
package notification
import (
"strings"
"tm/pkg/response"
)
@@ -21,6 +23,8 @@ type NotificationRequest struct {
// SearchForm represents the form for searching notifications
type SearchForm struct {
Q *string `query:"q" valid:"optional"`
Search *string `query:"search" valid:"optional"`
Status string `query:"status" valid:"optional"`
Method string `query:"method" valid:"optional"`
EventType string `query:"event_type" valid:"optional"`
@@ -30,6 +34,17 @@ type SearchForm struct {
Priority string `query:"priority" valid:"optional"`
}
// ResolvedSearch returns the search term from either the search or q query parameter.
func (f *SearchForm) ResolvedSearch() string {
if f.Search != nil && strings.TrimSpace(*f.Search) != "" {
return strings.TrimSpace(*f.Search)
}
if f.Q != nil {
return strings.TrimSpace(*f.Q)
}
return ""
}
type NotificationListResponse struct {
Notifications []*NotificationResponse `json:"notifications"`
Meta *response.Meta `json:"meta"`
+2
View File
@@ -58,6 +58,8 @@ func (h *NotificationHandler) Send(c echo.Context) error {
// @Tags Admin-Notification
// @Accept json
// @Produce json
// @Param search query string false "Search query for title and message"
// @Param q query string false "Search query for title and message (alias of search)"
// @Param status query string false "Status filter"
// @Param method query string false "Method filter"
// @Param event_type query string false "Event type filter"
+12 -2
View File
@@ -3,6 +3,8 @@ package notification
import (
"context"
"errors"
"regexp"
"strings"
"time"
"tm/pkg/logger"
@@ -52,7 +54,7 @@ func (n *Notification) GetID() string {
type Repository interface {
Create(ctx context.Context, notification *Notification) error
GetByID(ctx context.Context, id string) (*Notification, error)
GetByUserID(ctx context.Context, userIDs []string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error)
GetByUserID(ctx context.Context, userIDs []string, search, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error)
Update(ctx context.Context, notification *Notification) error
MarkAsSeen(ctx context.Context, notificationID, userID string) error
MarkAllAsSeen(ctx context.Context, userID string) error
@@ -115,9 +117,17 @@ func (r *notificationRepository) GetByID(ctx context.Context, id string) (*Notif
}
// GetByUserID retrieves notifications with optional user filters
func (r *notificationRepository) GetByUserID(ctx context.Context, userIDs []string, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) {
func (r *notificationRepository) GetByUserID(ctx context.Context, userIDs []string, search, status string, seen *bool, priority, eventType, notificationType string, pagination *response.Pagination) (*orm.PaginatedResult[Notification], error) {
filter := bson.M{}
if q := strings.TrimSpace(search); q != "" {
pattern := regexp.QuoteMeta(q)
filter["$or"] = bson.A{
bson.M{"title": bson.M{"$regex": pattern, "$options": "i"}},
bson.M{"message": bson.M{"$regex": pattern, "$options": "i"}},
}
}
if len(userIDs) == 1 {
filter["user_id"] = userIDs[0]
} else if len(userIDs) > 1 {
+4 -1
View File
@@ -183,7 +183,10 @@ func (s *notificationService) persistNotification(ctx context.Context, recipient
}
func (s *notificationService) GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error) {
search := req.ResolvedSearch()
s.logger.Info("Getting notifications", map[string]interface{}{
"search": search,
"status": req.Status,
"method": req.Method,
"event_type": req.EventType,
@@ -195,7 +198,7 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF
"offset": pagination.Offset,
})
page, err := s.repository.GetByUserID(ctx, req.Recipient, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination)
page, err := s.repository.GetByUserID(ctx, req.Recipient, search, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination)
if err != nil {
s.logger.Error("Failed to get notifications", map[string]interface{}{
"error": err.Error(),
+66
View File
@@ -209,6 +209,72 @@ func (c *Client) TriggerPipelineSummarize(ctx context.Context) (*PipelineActionR
return c.triggerPipelineAction(ctx, "/pipeline/summarize", "pipeline summarize")
}
// StartOnboarding calls POST /onboarding to start company profile indexing for recommendations.
func (c *Client) StartOnboarding(ctx context.Context, reqBody OnboardingRequest) (*OnboardingResponse, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal onboarding request body: %w", err)
}
url := c.config.APIBaseURL + "/onboarding"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result OnboardingResponse
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode onboarding response: %w", err)
}
}
c.logger.Info("AI onboarding request succeeded", map[string]interface{}{
"company_id": reqBody.CompanyID,
"status": result.Status,
})
return &result, nil
}
// FetchRecommendations calls POST /recommend to retrieve ranked tenders for a company.
func (c *Client) FetchRecommendations(ctx context.Context, reqBody RecommendRequest) ([]RecommendedTender, error) {
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to marshal recommend request body: %w", err)
}
url := c.config.APIBaseURL + "/recommend"
httpResp, bodyBytes, err := c.doRawPost(ctx, url, jsonBody)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode >= 400 {
return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPINonSuccess, httpResp.StatusCode, string(bodyBytes))
}
var result []RecommendedTender
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("ai_summarizer: failed to decode recommend response: %w", err)
}
}
c.logger.Info("AI recommend request succeeded", map[string]interface{}{
"company_id": reqBody.CompanyID,
"count": len(result),
})
return result, nil
}
func (c *Client) triggerPipelineAction(ctx context.Context, path, label string) (*PipelineActionResponse, error) {
url := c.config.APIBaseURL + path
httpResp, bodyBytes, err := c.doRawPost(ctx, url, nil)
+24
View File
@@ -138,6 +138,30 @@ type PipelineActionResponse struct {
Status string `json:"status"`
}
// OnboardingRequest is the payload for POST /onboarding.
type OnboardingRequest struct {
CompanyID string `json:"company_id"`
Documents string `json:"documents"`
WebsiteURL string `json:"website_url"`
}
// OnboardingResponse is returned by POST /onboarding.
type OnboardingResponse struct {
Status string `json:"status"`
}
// RecommendRequest is the payload for POST /recommend.
type RecommendRequest struct {
CompanyID string `json:"company_id"`
}
// RecommendedTender is one ranked tender from POST /recommend.
type RecommendedTender struct {
Rank string `json:"rank"`
TenderID string `json:"tender_id"`
Analysis string `json:"analysis"`
}
// StoredTranslation is one language entry inside tender.json translations map.
type StoredTranslation struct {
Title string `json:"title"`