Files
tm_back/internal/company/service.go
T
Mazyar 0b74e9ad23
continuous-integration/drone/push Build is passing
Enhance tender recommendation caching and page refresh logic
- Introduced a new `RecommendedTendersPageCacheRefresher` interface to manage the asynchronous refresh of recommendation pages in Redis, improving cache management.
- Updated the `companyService` to support setting page cache languages and refreshing the recommended tenders page cache based on company IDs.
- Enhanced the `tenderService` to build and invalidate recommended tenders page caches, ensuring timely updates and efficient retrieval of cached recommendations.
- Added configuration options for recommendation page cache languages in the `AISummarizerConfig`, allowing for flexible language support.
- Implemented unit tests for the new caching logic and page refresh functionality, ensuring robust validation of the recommendation caching process.

This update significantly improves the efficiency and responsiveness of the tender recommendation service by integrating enhanced caching mechanisms and page refresh capabilities.
2026-07-08 02:05:36 +03:30

604 lines
18 KiB
Go

package company
import (
"context"
"errors"
"mime/multipart"
"time"
"tm/internal/company_category"
"tm/pkg/filestore"
"tm/pkg/logger"
"tm/pkg/redis"
"tm/pkg/response"
)
// Service defines business logic for company operations
type Service interface {
// Core company operations
Create(ctx context.Context, form *CompanyForm) (*CompanyResponse, error)
Update(ctx context.Context, id string, form *CompanyForm) (*CompanyResponse, error)
GetByID(ctx context.Context, id string) (*CompanyResponse, error)
GetByIDs(ctx context.Context, ids []string) ([]*CompanyResponse, error)
GetNamesByIDs(ctx context.Context, ids []string) (map[string]*CompanyResponse, error)
Delete(ctx context.Context, id string) error
// Company listing and search
SearchCompanies(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CompanyListResponse, error)
// Company management
UpdateStatus(ctx context.Context, id string, form *StatusForm) error
UpdateVerification(ctx context.Context, id string, form *VerificationForm) error
// Get my company
GetMyCompany(ctx context.Context, id string) (*CompanyProfileResponse, error)
// UploadDocuments uploads files and attaches them to a company
UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error)
// AddLinks appends external URLs to a company
AddLinks(ctx context.Context, companyID string, form *AddLinksForm) (*AddLinksResponse, error)
// DetachDocumentFileID removes a file ID from companies when the file is deleted
DetachDocumentFileID(ctx context.Context, fileID string) 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)
GetMergedAIRecommendations(ctx context.Context, companyIDs []string) ([]RecommendedTenderResponse, error)
// ScheduleRefreshCachedAIRecommendationsAfterPipeline re-fetches cached company recommendations after tender sync.
ScheduleRefreshCachedAIRecommendationsAfterPipeline()
}
// companyService implements the Service interface
type companyService struct {
repository Repository
categoryService company_category.Service
fileStore filestore.FileStoreService
aiRecommendationClient AIRecommendationClient
aiPipelineStatusClient AIPipelineStatusClient
pageCacheRefresher RecommendedTendersPageCacheRefresher
redisClient redis.Client
recommendationCacheTTL time.Duration
pageCacheLanguages []string
logger logger.Logger
}
// NewService creates a new company service
func NewService(
repository Repository,
categoryService company_category.Service,
fileStore filestore.FileStoreService,
aiRecommendationClient AIRecommendationClient,
aiPipelineStatusClient AIPipelineStatusClient,
redisClient redis.Client,
recommendationCacheTTL time.Duration,
logger logger.Logger,
) Service {
return &companyService{
repository: repository,
categoryService: categoryService,
fileStore: fileStore,
aiRecommendationClient: aiRecommendationClient,
aiPipelineStatusClient: aiPipelineStatusClient,
redisClient: redisClient,
recommendationCacheTTL: recommendationCacheTTL,
logger: logger,
}
}
// Create creates a new company
func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*CompanyResponse, error) {
// Validate categories if provided
if form.Tags != nil && len(form.Tags.Categories) > 0 {
validCategories, err := s.categoryService.GetByIDs(ctx, form.Tags.Categories)
if err != nil {
s.logger.Error("Failed to validate categories", map[string]interface{}{
"error": err.Error(),
"categories": form.Tags.Categories,
})
return nil, errors.New("failed to validate categories")
}
// Check if all provided categories exist
if len(validCategories) != len(form.Tags.Categories) {
return nil, errors.New("one or more categories are invalid")
}
}
// Check if company name already exists
existingCompany, _ := s.repository.GetByName(ctx, form.Name)
if existingCompany != nil {
return nil, errors.New("company with this name already exists")
}
if form.RegistrationNumber != "" {
existingCompany, _ = s.repository.GetByRegistrationNumber(ctx, form.RegistrationNumber)
if existingCompany != nil {
return nil, errors.New("company with this registration number already exists")
}
}
if form.TaxID != "" {
existingCompany, _ = s.repository.GetByTaxID(ctx, form.TaxID)
if existingCompany != nil {
return nil, errors.New("company with this tax ID already exists")
}
}
links, err := sanitizeCompanyLinks(convertCompanyLinkForms(form.Links))
if err != nil {
return nil, err
}
if len(links) > maxCompanyLinks {
return nil, errors.New("too many links provided")
}
// Create company
company := &Company{
Name: form.Name,
Type: CompanyType(form.Type),
Status: CompanyStatusActive,
RegistrationNumber: form.RegistrationNumber,
TaxID: form.TaxID,
Industry: form.Industry,
Description: form.Description,
Website: form.Website,
EmployeeCount: form.EmployeeCount,
AnnualRevenue: form.AnnualRevenue,
FoundedYear: form.FoundedYear,
Address: s.convertAddressForm(form.Address),
Phone: form.Phone,
Email: form.Email,
DocumentFileIDs: s.sanitizeDocumentFileIDs(form.DocumentFileIDs),
Links: links,
Tags: s.convertCompanyTagsForm(form.Tags),
IsVerified: false,
IsCompliant: false,
Language: s.getDefaultLanguage(form.Language),
Currency: s.getDefaultCurrency(form.Currency),
Timezone: s.getDefaultTimezone(form.Timezone),
}
// Save to database
err = s.repository.Create(ctx, company)
if err != nil {
s.logger.Error("Failed to create company", map[string]interface{}{
"error": err.Error(),
"name": form.Name,
"registration_number": form.RegistrationNumber,
})
return nil, err
}
s.logger.Info("Company created successfully", map[string]interface{}{
"company_id": company.ID,
"name": company.Name,
"type": company.Type,
})
s.triggerAIOnboardingAsync(company.GetID())
return company.ToResponse(nil), nil
}
// GetCompanyByID retrieves a company by ID
func (s *companyService) GetByID(ctx context.Context, id string) (*CompanyResponse, error) {
company, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get company by ID", map[string]interface{}{
"error": err.Error(),
"company_id": id,
})
return nil, err
}
s.logger.Info("Company retrieved successfully", map[string]interface{}{
"company_id": company.ID,
"name": company.Name,
})
// Get category details for response
categoryDetails, err := s.categoryService.GetByIDs(ctx, company.Tags.Categories)
if err != nil {
s.logger.Warn("Failed to fetch category details for response", map[string]interface{}{
"error": err.Error(),
"company_id": company.ID,
})
}
var convertedCategories []*CategoryResponse
for _, cat := range categoryDetails {
convertedCategories = append(convertedCategories, &CategoryResponse{
ID: cat.ID,
Name: cat.Name,
})
}
company.DocumentFileIDs = s.sanitizeDocumentFileIDs(company.DocumentFileIDs)
return company.ToResponse(convertedCategories), nil
}
// UpdateCompany updates a company
func (s *companyService) Update(ctx context.Context, id string, form *CompanyForm) (*CompanyResponse, error) {
// Validate categories if provided
if form.Tags != nil && len(form.Tags.Categories) > 0 {
validCategories, err := s.categoryService.GetByIDs(ctx, form.Tags.Categories)
if err != nil {
s.logger.Error("Failed to validate categories", map[string]interface{}{
"error": err.Error(),
"categories": form.Tags.Categories,
})
return nil, errors.New("failed to validate categories")
}
// Check if all provided categories exist
if len(validCategories) != len(form.Tags.Categories) {
return nil, errors.New("one or more categories are invalid")
}
}
// Get existing company
company, err := s.repository.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get company for update", map[string]interface{}{
"error": err.Error(),
"company_id": id,
})
return nil, errors.New("company not found")
}
// Check if name already exists (if changing name)
if form.Name != "" && form.Name != company.Name {
existingCompany, _ := s.repository.GetByName(ctx, form.Name)
if existingCompany != nil {
return nil, errors.New("company with this name already exists")
}
company.Name = form.Name
}
// Check if registration number already exists (if changing)
if form.RegistrationNumber != "" && form.RegistrationNumber != company.RegistrationNumber {
existingCompany, _ := s.repository.GetByRegistrationNumber(ctx, form.RegistrationNumber)
if existingCompany != nil {
return nil, errors.New("company with this registration number already exists")
}
company.RegistrationNumber = form.RegistrationNumber
}
// Check if tax ID already exists (if changing)
if form.TaxID != "" && form.TaxID != company.TaxID {
existingCompany, _ := s.repository.GetByTaxID(ctx, form.TaxID)
if existingCompany != nil {
return nil, errors.New("company with this tax ID already exists")
}
company.TaxID = form.TaxID
}
// Update fields if provided
if form.Type != "" {
company.Type = CompanyType(form.Type)
}
if form.Industry != "" {
company.Industry = form.Industry
}
if form.Description != nil {
company.Description = form.Description
}
if form.Website != nil {
company.Website = form.Website
}
if form.EmployeeCount != nil {
company.EmployeeCount = form.EmployeeCount
}
if form.AnnualRevenue != nil {
company.AnnualRevenue = form.AnnualRevenue
}
if form.FoundedYear != nil {
company.FoundedYear = form.FoundedYear
}
if form.Address != nil {
company.Address = s.convertAddressForm(form.Address)
}
if form.Phone != nil {
company.Phone = form.Phone
}
if form.Email != nil {
company.Email = form.Email
}
if form.DocumentFileIDs != nil {
company.DocumentFileIDs = s.sanitizeDocumentFileIDs(form.DocumentFileIDs)
}
if form.Links != nil {
links, linkErr := sanitizeCompanyLinks(convertCompanyLinkForms(form.Links))
if linkErr != nil {
return nil, linkErr
}
if len(links) > maxCompanyLinks {
return nil, errors.New("too many links provided")
}
company.Links = links
}
if form.Tags != nil {
company.Tags = s.convertCompanyTagsForm(form.Tags)
}
if form.Language != nil {
company.Language = *form.Language
}
if form.Currency != nil {
company.Currency = *form.Currency
}
if form.Timezone != nil {
company.Timezone = *form.Timezone
}
// Update in database
err = s.repository.Update(ctx, company)
if err != nil {
s.logger.Error("Failed to update company", map[string]interface{}{
"error": err.Error(),
"company_id": id,
})
return nil, errors.New("failed to update company")
}
s.logger.Info("Company updated successfully", map[string]interface{}{
"company_id": company.ID,
"name": company.Name,
})
s.triggerAIOnboardingAsync(company.GetID())
return company.ToResponse(nil), nil
}
// DeleteCompany deletes a company (soft delete)
func (s *companyService) Delete(ctx context.Context, id string) error {
// Delete company (soft delete)
err := s.repository.Delete(ctx, id)
if err != nil {
s.logger.Error("Failed to delete company", map[string]interface{}{
"error": err.Error(),
"company_id": id,
})
return errors.New("failed to delete company")
}
s.logger.Info("Company deleted successfully", map[string]interface{}{
"company_id": id,
})
return nil
}
// SearchCompanies searches companies with advanced filters
func (s *companyService) SearchCompanies(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*CompanyListResponse, error) {
// Get companies using search
page, err := s.repository.Search(ctx, form, pagination)
if err != nil {
s.logger.Error("Failed to search companies", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
var companyResponses []*CompanyResponse
for _, company := range page.Items {
// Get category details for response
categoryDetails, err := s.categoryService.GetByIDs(ctx, company.Tags.Categories)
if err != nil {
s.logger.Warn("Failed to fetch category details for response", map[string]interface{}{
"error": err.Error(),
"company_id": company.ID,
})
}
var convertedCategories []*CategoryResponse
for _, cat := range categoryDetails {
convertedCategories = append(convertedCategories, &CategoryResponse{
ID: cat.ID,
Name: cat.Name,
})
}
companyResponses = append(companyResponses, company.ToResponse(convertedCategories))
}
return &CompanyListResponse{
Companies: companyResponses,
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil
}
// GetByIDs retrieves companies by IDs
func (s *companyService) GetByIDs(ctx context.Context, ids []string) ([]*CompanyResponse, error) {
companies, err := s.repository.GetByIDs(ctx, ids)
if err != nil {
return nil, errors.New("failed to get companies by IDs")
}
var companyResponses []*CompanyResponse
for _, company := range companies {
// Get category details for response
categoryDetails, err := s.categoryService.GetByIDs(ctx, company.Tags.Categories)
if err != nil {
s.logger.Warn("Failed to fetch category details for response", map[string]interface{}{
"error": err.Error(),
"company_id": company.ID,
})
}
var convertedCategories []*CategoryResponse
for _, cat := range categoryDetails {
convertedCategories = append(convertedCategories, &CategoryResponse{
ID: cat.ID,
Name: cat.Name,
})
}
companyResponses = append(companyResponses, company.ToResponse(convertedCategories))
}
return companyResponses, nil
}
// GetNamesByIDs returns company id and name keyed by company ID without loading categories.
func (s *companyService) GetNamesByIDs(ctx context.Context, ids []string) (map[string]*CompanyResponse, error) {
if len(ids) == 0 {
return map[string]*CompanyResponse{}, nil
}
companies, err := s.repository.GetByIDs(ctx, ids)
if err != nil {
s.logger.Error("Failed to get companies by IDs for names", map[string]interface{}{
"count": len(ids),
"error": err.Error(),
})
return nil, errors.New("failed to get companies by IDs")
}
out := make(map[string]*CompanyResponse, len(companies))
for _, c := range companies {
id := c.ID.Hex()
out[id] = &CompanyResponse{ID: id, Name: c.Name}
}
return out, nil
}
// UpdateCompanyStatus updates company status
func (s *companyService) UpdateStatus(ctx context.Context, id string, form *StatusForm) error {
// Get company to check if exists
_, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("company not found")
}
// Update status
err = s.repository.UpdateStatus(ctx, id, CompanyStatus(form.Status))
if err != nil {
s.logger.Error("Failed to update company status", map[string]interface{}{
"error": err.Error(),
"company_id": id,
"status": CompanyStatus(form.Status),
})
return errors.New("failed to update company status")
}
s.logger.Info("Company status updated successfully", map[string]interface{}{
"company_id": id,
"status": form.Status,
})
return nil
}
// UpdateCompanyVerification updates company verification status
func (s *companyService) UpdateVerification(ctx context.Context, id string, form *VerificationForm) error {
// Get company to check if exists
_, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("company not found")
}
// Update verification
err = s.repository.UpdateVerification(ctx, id, form.IsVerified, form.IsCompliant, form.ComplianceNotes)
if err != nil {
s.logger.Error("Failed to update company verification", map[string]interface{}{
"error": err.Error(),
"company_id": id,
})
return errors.New("failed to update company verification")
}
s.logger.Info("Company verification updated successfully", map[string]interface{}{
"company_id": id,
"is_verified": form.IsVerified,
"is_compliant": form.IsCompliant,
})
return nil
}
// GetMyCompany retrieves the company for the authenticated user
func (s *companyService) GetMyCompany(ctx context.Context, id string) (*CompanyProfileResponse, error) {
company, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, errors.New("company not found")
}
company.DocumentFileIDs = s.sanitizeDocumentFileIDs(company.DocumentFileIDs)
return company.ToProfileResponse(), nil
}
// Helper methods for converting forms to domain objects
func (s *companyService) convertAddressForm(form *AddressForm) *Address {
if form == nil {
return nil
}
return &Address{
Street: form.Street,
City: form.City,
State: form.State,
PostalCode: form.PostalCode,
Country: form.Country,
}
}
func (s *companyService) convertCompanyTagsForm(form *CompanyTagsForm) *CompanyTags {
if form == nil {
return nil
}
return &CompanyTags{
CPVCodes: form.CPVCodes,
Categories: form.Categories,
Keywords: form.Keywords,
Specializations: form.Specializations,
Certifications: form.Certifications,
}
}
func (s *companyService) getDefaultLanguage(language *string) string {
if language != nil {
return *language
}
return "en"
}
func (s *companyService) getDefaultCurrency(currency *string) string {
if currency != nil {
return *currency
}
return "USD"
}
func (s *companyService) getDefaultTimezone(timezone *string) string {
if timezone != nil {
return *timezone
}
return "UTC"
}