diff --git a/internal/company/handler.go b/internal/company/handler.go index 0dbd924..b4d9516 100644 --- a/internal/company/handler.go +++ b/internal/company/handler.go @@ -101,6 +101,21 @@ func (h *Handler) StartOnboarding(c echo.Context) error { // @Security BearerAuth // @Router /api/v1/recommend [post] func (h *Handler) RecommendTenders(c echo.Context) error { + if companyIDs, ok := c.Get("company_ids").([]string); ok && len(companyIDs) > 0 { + result, err := h.service.GetMergedAIRecommendations(c.Request().Context(), companyIDs) + 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") + } + companyID, err := user.GetCompanyIDFromContext(c) if err != nil { return response.Unauthorized(c, "User not authenticated") diff --git a/internal/company/recommendation.go b/internal/company/recommendation.go index 38e858c..3ff4191 100644 --- a/internal/company/recommendation.go +++ b/internal/company/recommendation.go @@ -24,6 +24,44 @@ const ( recommendationFetchInitialDelay = 10 * time.Second ) +func normalizeRecommendationCompanyIDs(companyIDs []string) []string { + normalized := make([]string, 0, len(companyIDs)) + seen := make(map[string]struct{}, len(companyIDs)) + for _, companyID := range companyIDs { + clean := strings.TrimSpace(companyID) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + +func mergeRecommendedTenders(recommendationLists ...[]RecommendedTenderResponse) []RecommendedTenderResponse { + merged := make([]RecommendedTenderResponse, 0) + seen := make(map[string]struct{}) + for _, recommendations := range recommendationLists { + for _, recommendation := range recommendations { + tenderID := strings.TrimSpace(recommendation.TenderID) + if tenderID == "" { + continue + } + if _, ok := seen[tenderID]; ok { + continue + } + seen[tenderID] = struct{}{} + recommendation.TenderID = tenderID + recommendation.Analysis = strings.TrimSpace(recommendation.Analysis) + merged = append(merged, recommendation) + } + } + return merged +} + func aiRecommendationCacheKey(companyID string) string { return aiRecommendationCacheKeyPrefix + companyID } @@ -182,6 +220,29 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str return []RecommendedTenderResponse{}, nil } +// GetMergedAIRecommendations unions cached AI recommendations across multiple companies. +func (s *companyService) GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error) { + if s.aiRecommendationClient == nil { + return nil, ErrAIRecommendationNotConfigured + } + + normalized := normalizeRecommendationCompanyIDs(companyIDs) + if len(normalized) == 0 { + return []RecommendedTenderResponse{}, nil + } + + recommendationLists := make([][]RecommendedTenderResponse, 0, len(normalized)) + for _, companyID := range normalized { + recommendations, err := s.GetAIRecommendations(ctx, companyID) + if err != nil { + return nil, err + } + recommendationLists = append(recommendationLists, recommendations) + } + + return mergeRecommendedTenders(recommendationLists...), nil +} + func (s *companyService) scheduleRecommendationRefreshAfterOnboarding(companyID string) { if s.aiRecommendationClient == nil { return diff --git a/internal/company/recommendation_test.go b/internal/company/recommendation_test.go new file mode 100644 index 0000000..4f02255 --- /dev/null +++ b/internal/company/recommendation_test.go @@ -0,0 +1,44 @@ +package company + +import "testing" + +func TestMergeRecommendedTenders(t *testing.T) { + first := []RecommendedTenderResponse{ + {Rank: 1, TenderID: " PROC-1 ", Analysis: " first "}, + {Rank: 2, TenderID: "PROC-2", Analysis: "second"}, + } + second := []RecommendedTenderResponse{ + {Rank: 1, TenderID: "PROC-2", Analysis: "duplicate"}, + {Rank: 3, TenderID: "PROC-3", Analysis: "third"}, + {Rank: 4, TenderID: " ", Analysis: "ignored"}, + } + + got := mergeRecommendedTenders(first, second) + if len(got) != 3 { + t.Fatalf("mergeRecommendedTenders() len = %d, want 3", len(got)) + } + + if got[0].TenderID != "PROC-1" || got[0].Analysis != "first" { + t.Fatalf("first recommendation = %+v, want trimmed PROC-1/first", got[0]) + } + if got[1].TenderID != "PROC-2" || got[1].Rank != 2 { + t.Fatalf("second recommendation = %+v, want original PROC-2 rank 2", got[1]) + } + if got[2].TenderID != "PROC-3" { + t.Fatalf("third recommendation = %+v, want PROC-3", got[2]) + } +} + +func TestNormalizeRecommendationCompanyIDs(t *testing.T) { + got := normalizeRecommendationCompanyIDs([]string{" company-a ", "", "company-b", "company-a"}) + want := []string{"company-a", "company-b"} + + if len(got) != len(want) { + t.Fatalf("normalizeRecommendationCompanyIDs() len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("normalizeRecommendationCompanyIDs()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/company/service.go b/internal/company/service.go index 6ed8685..3a2a299 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -44,17 +44,18 @@ type Service interface { // GetAIRecommendations returns ranked tender recommendations from the AI team GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) + GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error) } // companyService implements the Service interface type companyService struct { - repository Repository - categoryService company_category.Service - fileStore filestore.FileStoreService - aiRecommendationClient AIRecommendationClient - redisClient redis.Client - recommendationCacheTTL time.Duration - logger logger.Logger + repository Repository + categoryService company_category.Service + fileStore filestore.FileStoreService + aiRecommendationClient AIRecommendationClient + redisClient redis.Client + recommendationCacheTTL time.Duration + logger logger.Logger } // NewService creates a new company service @@ -68,7 +69,7 @@ func NewService( logger logger.Logger, ) Service { return &companyService{ - repository: repository, + repository: repository, categoryService: categoryService, fileStore: fileStore, aiRecommendationClient: aiRecommendationClient, diff --git a/internal/customer/company_context.go b/internal/customer/company_context.go index 58a6e63..ebfc339 100644 --- a/internal/customer/company_context.go +++ b/internal/customer/company_context.go @@ -8,10 +8,28 @@ import ( var errCompanyNotAssigned = errors.New("company is not assigned to customer") +func normalizeAssignedCompanyIDs(assigned []string) []string { + normalized := make([]string, 0, len(assigned)) + seen := make(map[string]struct{}, len(assigned)) + for _, id := range assigned { + clean := strings.TrimSpace(id) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + // pickActiveCompanyID chooses the company context for a customer request. // When requestedCompanyID is set it must be in assigned; otherwise a still-valid // token company is kept; if the token company was removed, the first assignment is used. func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID string) (string, error) { + assigned = normalizeAssignedCompanyIDs(assigned) if len(assigned) == 0 { return "", nil } @@ -38,12 +56,24 @@ func pickActiveCompanyID(assigned []string, tokenCompanyID, requestedCompanyID s return assigned[0], nil } -// ResolveActiveCompanyID resolves the active company for an authenticated customer. -func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) { +// ResolveCompanyContext resolves the active company and assigned companies for an authenticated customer. +func (s *customerService) ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) { customer, err := s.repository.GetByID(ctx, customerID) if err != nil { - return "", errors.New("customer not found") + return "", nil, errors.New("customer not found") } - return pickActiveCompanyID(customer.Companies, tokenCompanyID, requestedCompanyID) + assigned := normalizeAssignedCompanyIDs(customer.Companies) + companyID, err := pickActiveCompanyID(assigned, tokenCompanyID, requestedCompanyID) + if err != nil { + return "", nil, err + } + + return companyID, assigned, nil +} + +// ResolveActiveCompanyID resolves the active company for an authenticated customer. +func (s *customerService) ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) { + companyID, _, err := s.ResolveCompanyContext(ctx, customerID, tokenCompanyID, requestedCompanyID) + return companyID, err } diff --git a/internal/customer/middleware.go b/internal/customer/middleware.go index 76647b4..d8f0e12 100644 --- a/internal/customer/middleware.go +++ b/internal/customer/middleware.go @@ -76,7 +76,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc { tokenCompanyID, _ := c.Get("company_id").(string) requestedCompanyID := strings.TrimSpace(c.QueryParam("company_id")) - companyID, err := h.service.ResolveActiveCompanyID( + companyID, companyIDs, err := h.service.ResolveCompanyContext( c.Request().Context(), customerID, tokenCompanyID, @@ -90,6 +90,7 @@ func (h *Handler) CompanyContextMiddleware() echo.MiddlewareFunc { } c.Set("company_id", companyID) + c.Set("company_ids", companyIDs) return next(c) } } diff --git a/internal/customer/service.go b/internal/customer/service.go index d917974..14ad7f0 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -54,6 +54,10 @@ type Service interface { AssignRole(ctx context.Context, customerID string, form *AssignRoleForm) (*AssignRoleResponse, error) AssignCompanies(ctx context.Context, customerID string, Companies []string) error + // ResolveCompanyContext returns the active company and all assigned companies + // for an authenticated customer request. + ResolveCompanyContext(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, []string, error) + // ResolveActiveCompanyID returns the company context for an authenticated customer. ResolveActiveCompanyID(ctx context.Context, customerID, tokenCompanyID, requestedCompanyID string) (string, error) } diff --git a/internal/tender/form.go b/internal/tender/form.go index 493c0bb..5055050 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -32,6 +32,7 @@ type SearchForm struct { CountryCodes []string `query:"country_codes" valid:"optional"` RegionCodes []string `query:"region_codes" valid:"optional"` CompanyID *string `query:"company_id" valid:"optional"` + CompanyIDs []string `query:"-" valid:"optional"` CustomerID *string `query:"customer_id" valid:"optional"` Status []string `query:"status" valid:"optional"` MainClassification *string `query:"main_classification" valid:"optional"` diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 13b6574..eca3083 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -354,7 +354,22 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { form.OnlyActiveDeadlines = true form.ExcludeRejectedTenders = true - if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { + if customerIDStr, ok := c.Get("customer_id").(string); ok && customerIDStr != "" { + form.CustomerID = &customerIDStr + } + + requestedCompanyID := "" + if form.CompanyID != nil { + requestedCompanyID = strings.TrimSpace(*form.CompanyID) + } + + if requestedCompanyID != "" { + if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { + form.CompanyID = &customerCompanyID + } + } else if customerCompanyIDs, ok := c.Get("company_ids").([]string); ok && len(customerCompanyIDs) > 0 { + form.CompanyIDs = append([]string(nil), customerCompanyIDs...) + } else if customerCompanyID, ok := c.Get("company_id").(string); ok && customerCompanyID != "" { form.CompanyID = &customerCompanyID } @@ -820,4 +835,3 @@ func (h *TenderHandler) GetAllAISummaries(c echo.Context) error { return response.Success(c, summaries, "AI summaries retrieved successfully") } - diff --git a/internal/tender/recommendation_filter.go b/internal/tender/recommendation_filter.go index d15a504..f55b517 100644 --- a/internal/tender/recommendation_filter.go +++ b/internal/tender/recommendation_filter.go @@ -11,6 +11,23 @@ type CompanyRejectedTenderLister interface { ListRejectedTenderIDsByCompanyID(ctx context.Context, companyID string) ([]string, error) } +func normalizeRecommendationCompanyScope(companyIDs []string) []string { + normalized := make([]string, 0, len(companyIDs)) + seen := make(map[string]struct{}, len(companyIDs)) + for _, companyID := range companyIDs { + clean := strings.TrimSpace(companyID) + if clean == "" { + continue + } + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + normalized = append(normalized, clean) + } + return normalized +} + func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID string) (map[string]struct{}, error) { excluded := make(map[string]struct{}) @@ -27,6 +44,23 @@ func (s *tenderService) buildRejectedTenderIDSet(ctx context.Context, companyID return excluded, nil } +func (s *tenderService) buildRejectedTenderIDSetForCompanies(ctx context.Context, companyIDs []string) (map[string]struct{}, error) { + excluded := make(map[string]struct{}) + for _, companyID := range normalizeRecommendationCompanyScope(companyIDs) { + if s.rejectedTenderLister == nil { + return excluded, nil + } + + ids, err := s.rejectedTenderLister.ListRejectedTenderIDsByCompanyID(ctx, companyID) + if err != nil { + return nil, fmt.Errorf("failed to list rejected tender exclusions: %w", err) + } + + addTenderIDsToSet(excluded, ids) + } + return excluded, nil +} + func addTenderIDsToSet(set map[string]struct{}, ids []string) { for _, id := range ids { id = strings.TrimSpace(id) diff --git a/internal/tender/recommendation_filter_test.go b/internal/tender/recommendation_filter_test.go index dc1654f..6ce3937 100644 --- a/internal/tender/recommendation_filter_test.go +++ b/internal/tender/recommendation_filter_test.go @@ -1,6 +1,7 @@ package tender import ( + "context" "testing" "go.mongodb.org/mongo-driver/v2/bson" @@ -8,6 +9,14 @@ import ( "tm/pkg/mongo" ) +type stubRejectedTenderLister struct { + byCompany map[string][]string +} + +func (s stubRejectedTenderLister) ListRejectedTenderIDsByCompanyID(_ context.Context, companyID string) ([]string, error) { + return s.byCompany[companyID], nil +} + func TestIsRecommendedTenderExcluded(t *testing.T) { tender := Tender{ Model: mongo.Model{}, @@ -61,3 +70,28 @@ func TestIsRecommendedTenderExcluded(t *testing.T) { }) } } + +func TestBuildRejectedTenderIDSetForCompanies(t *testing.T) { + service := &tenderService{ + rejectedTenderLister: stubRejectedTenderLister{ + byCompany: map[string][]string{ + "company-a": {" tender-1 ", "tender-2"}, + "company-b": {"tender-2", "tender-3"}, + }, + }, + } + + got, err := service.buildRejectedTenderIDSetForCompanies(context.Background(), []string{"company-a", "company-b", "company-a"}) + if err != nil { + t.Fatalf("buildRejectedTenderIDSetForCompanies() error = %v", err) + } + + for _, tenderID := range []string{"tender-1", "tender-2", "tender-3"} { + if _, ok := got[tenderID]; !ok { + t.Fatalf("expected excluded set to contain %q", tenderID) + } + } + if len(got) != 3 { + t.Fatalf("buildRejectedTenderIDSetForCompanies() len = %d, want 3", len(got)) + } +} diff --git a/internal/tender/service.go b/internal/tender/service.go index 3f1817e..0ce99bd 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -932,19 +932,33 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination // Recommend retrieves AI-ranked tenders for the authenticated customer's company. func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { - if form.CompanyID == nil || strings.TrimSpace(*form.CompanyID) == "" { + companyIDs := normalizeRecommendationCompanyScope(form.CompanyIDs) + companyID := "" + if form.CompanyID != nil { + companyID = strings.TrimSpace(*form.CompanyID) + } + + if companyID == "" && len(companyIDs) == 0 { return nil, errors.New("company ID is required") } if pagination.Cursor != "" { return nil, fmt.Errorf("%w: cursor pagination is not supported for recommendations", response.ErrInvalidPagination) } - companyID := strings.TrimSpace(*form.CompanyID) s.logger.Info("Recommend tenders via AI service", map[string]interface{}{ - "company_id": companyID, + "company_id": companyID, + "company_ids": companyIDs, }) - recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID) + var ( + recommendations []company.RecommendedTenderResponse + err error + ) + if len(companyIDs) > 0 { + recommendations, err = s.companyService.GetMergedAIRecommendations(ctx, companyIDs) + } else { + recommendations, err = s.companyService.GetAIRecommendations(ctx, companyID) + } if err != nil { return nil, err } @@ -959,11 +973,16 @@ func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, paginat var excluded map[string]struct{} if form.ExcludeRejectedTenders { - excluded, err = s.buildRejectedTenderIDSet(ctx, companyID) + if len(companyIDs) > 0 { + excluded, err = s.buildRejectedTenderIDSetForCompanies(ctx, companyIDs) + } else { + excluded, err = s.buildRejectedTenderIDSet(ctx, companyID) + } if err != nil { s.logger.Error("Failed to build recommendation exclusions", map[string]interface{}{ - "company_id": companyID, - "error": err.Error(), + "company_id": companyID, + "company_ids": companyIDs, + "error": err.Error(), }) return nil, fmt.Errorf("failed to build recommendation exclusions: %w", err) }