diff --git a/internal/customer/form.go b/internal/customer/form.go index e39724c..f2c1259 100644 --- a/internal/customer/form.go +++ b/internal/customer/form.go @@ -24,11 +24,12 @@ type UpdateCustomerForm struct { FullName *string `json:"full_name,omitempty" valid:"optional,length(2|100)" example:"User"` Type string `json:"type" valid:"required,in(individual|company|government)" example:"individual"` Role string `json:"role" valid:"required,in(admin|analyst)" example:"analyst"` - Companies []string `json:"companies,omitempty" valid:"optional"` - Username string `json:"username" valid:"required,username,length(3|30)" example:"user"` - Email string `json:"email" valid:"required,email" example:"user@opplens.com"` - Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` - Keywords []string `json:"keywords,omitempty" valid:"optional"` + // Companies is a pointer so omitted/null leaves assignments unchanged; an empty JSON array clears them. + Companies *[]string `json:"companies,omitempty" valid:"optional"` + Username string `json:"username" valid:"required,username,length(3|30)" example:"user"` + Email string `json:"email" valid:"required,email" example:"user@opplens.com"` + Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` + Keywords []string `json:"keywords,omitempty" valid:"optional"` } // CustomerStatusForm represents the form for suspending a customer diff --git a/internal/customer/handler.go b/internal/customer/handler.go index 70451d0..5431089 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -133,6 +133,9 @@ func (h *Handler) UpdateCustomer(c echo.Context) error { err.Error() == "company with this tax ID already exists" { return response.Conflict(c, err.Error()) } + if strings.HasPrefix(err.Error(), "invalid company ID") { + return response.BadRequest(c, err.Error(), err.Error()) + } return response.InternalServerError(c, "Failed to update customer") } diff --git a/internal/customer/service.go b/internal/customer/service.go index 4cd6dba..a75b4fc 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -100,23 +100,13 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm return nil, errors.New("invalid username format") } - // Verify that all company IDs exist var Companies []string if len(form.Companies) > 0 { - for _, companyID := range form.Companies { - if _, err := bson.ObjectIDFromHex(companyID); err != nil { - return nil, errors.New("invalid company ID format: " + companyID) - } - _, err := s.companyService.GetByID(ctx, companyID) - if err != nil { - s.logger.Error("Invalid company ID provided", map[string]interface{}{ - "error": err.Error(), - "company_id": companyID, - }) - return nil, errors.New("invalid company ID: " + companyID) - } + validated, err := s.validateCompanyIDs(ctx, form.Companies) + if err != nil { + return nil, err } - Companies = form.Companies + Companies = validated } // Hash password @@ -362,36 +352,21 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus changeUsername = true } - // Handle company assignments - companies := make([]*CompanySummary, 0) companiesChanged := false - if len(form.Companies) > 0 { - // Check if companies changed - if len(form.Companies) != len(customer.Companies) { - companiesChanged = true - } else { - companyMap := make(map[string]bool) - for _, id := range customer.Companies { - companyMap[id] = true - } - for _, id := range form.Companies { - if !companyMap[id] { - companiesChanged = true - break - } - } - } - customer.Companies = form.Companies - companies, err = s.loadCompaniesForCustomer(ctx, customer) - if err != nil { - s.logger.Error("Failed to load companies for customer", map[string]interface{}{ - "error": err.Error(), - "customer_id": customer.ID, - }) + if form.Companies != nil { + companyIDs, validateErr := s.validateCompanyIDs(ctx, *form.Companies) + if validateErr != nil { + return nil, validateErr } + companiesChanged = !companyIDsEqual(customer.Companies, companyIDs) + customer.Companies = companyIDs } infoChanged := false + if form.Type != "" && customer.Type != CustomerType(form.Type) { + customer.Type = CustomerType(form.Type) + infoChanged = true + } if form.FullName != nil && (customer.FullName == nil || *form.FullName != *customer.FullName) { customer.FullName = form.FullName infoChanged = true @@ -458,7 +433,16 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus "email": customer.Email, }) - return customer.ToResponse(companies), nil + responseCompanies, err := s.loadCompaniesForCustomer(ctx, customer) + if err != nil { + s.logger.Error("Failed to load companies for customer response", map[string]interface{}{ + "error": err.Error(), + "customer_id": customer.ID, + }) + responseCompanies = []*CompanySummary{} + } + + return customer.ToResponse(responseCompanies), nil } // Delete deletes a customer (soft delete) @@ -1269,6 +1253,61 @@ func (s *customerService) isValidPassword(password string) bool { return hasUpper && hasLower && hasSpecial } +func (s *customerService) validateCompanyIDs(ctx context.Context, companyIDs []string) ([]string, error) { + if len(companyIDs) == 0 { + return []string{}, nil + } + + validated := make([]string, 0, len(companyIDs)) + seen := make(map[string]struct{}, len(companyIDs)) + for _, companyID := range companyIDs { + if companyID == "" { + continue + } + if _, exists := seen[companyID]; exists { + continue + } + if _, err := bson.ObjectIDFromHex(companyID); err != nil { + return nil, errors.New("invalid company ID format: " + companyID) + } + if _, err := s.companyService.GetByID(ctx, companyID); err != nil { + s.logger.Error("Invalid company ID provided", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID, + }) + return nil, errors.New("invalid company ID: " + companyID) + } + seen[companyID] = struct{}{} + validated = append(validated, companyID) + } + + return validated, nil +} + +func companyIDsEqual(existing, updated []string) bool { + if len(existing) != len(updated) { + return false + } + + counts := make(map[string]int, len(existing)) + for _, id := range existing { + counts[id]++ + } + for _, id := range updated { + counts[id]-- + if counts[id] < 0 { + return false + } + } + for _, count := range counts { + if count != 0 { + return false + } + } + + return true +} + // loadCompaniesForCustomer loads company summaries for a customer func (s *customerService) loadCompaniesForCustomer(ctx context.Context, customer *Customer) ([]*CompanySummary, error) { if len(customer.Companies) == 0 { diff --git a/internal/document_scraper/service.go b/internal/document_scraper/service.go index 4f13216..3a819d6 100644 --- a/internal/document_scraper/service.go +++ b/internal/document_scraper/service.go @@ -9,7 +9,16 @@ import ( "tm/pkg/response" ) -const documentScraperCountryCode = "SWE" +var documentScraperCountryCodes = []string{"SWE", "FIN", "NOR"} + +func isDocumentScraperCountry(countryCode string) bool { + for _, code := range documentScraperCountryCodes { + if countryCode == code { + return true + } + } + return false +} // Service defines business logic for the document scraper API type Service interface { @@ -33,7 +42,7 @@ func NewService(tenderRepo tender.TenderRepository, logger logger.Logger) Servic } // ListPendingTenders retrieves tenders that have not yet been scraped for documents. -// Only returns tenders from Sweden with active deadlines (deadline not yet reached). +// Only returns tenders from Sweden, Finland, and Norway with active deadlines (deadline not yet reached). func (s *service) ListPendingTenders(ctx context.Context, limit, offset int) (*DocumentScraperListResponse, *response.Meta, error) { s.logger.Info("Listing pending tenders for document scraper", map[string]interface{}{ "limit": limit, @@ -42,7 +51,7 @@ func (s *service) ListPendingTenders(ctx context.Context, limit, offset int) (*D now := time.Now().Unix() - tenders, hasMore, err := s.tenderRepo.GetPendingDocumentScrapeTenders(ctx, documentScraperCountryCode, now, limit, offset) + tenders, hasMore, err := s.tenderRepo.GetPendingDocumentScrapeTenders(ctx, documentScraperCountryCodes, now, limit, offset) if err != nil { s.logger.Error("Failed to list pending tenders", map[string]interface{}{ "error": err.Error(), @@ -77,7 +86,7 @@ func (s *service) ListPendingTenders(ctx context.Context, limit, offset int) (*D } // GetTenderByNoticeID retrieves a specific tender by its notice publication ID. -// Only returns tenders from Sweden with active deadlines (deadline not yet reached). +// Only returns tenders from Sweden, Finland, and Norway with active deadlines (deadline not yet reached). func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*DocumentScraperTenderResponse, error) { s.logger.Info("Getting tender by notice ID for document scraper", map[string]interface{}{ "notice_id": noticeID, @@ -97,8 +106,8 @@ func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*Do } now := time.Now().Unix() - if t.CountryCode != documentScraperCountryCode || t.TenderDeadline <= now { - s.logger.Info("Tender filtered out: not Sweden or deadline passed", map[string]interface{}{ + if !isDocumentScraperCountry(t.CountryCode) || t.TenderDeadline <= now { + s.logger.Info("Tender filtered out: unsupported country or deadline passed", map[string]interface{}{ "notice_id": noticeID, "country_code": t.CountryCode, "tender_deadline": t.TenderDeadline, diff --git a/internal/tender/repository.go b/internal/tender/repository.go index 1bcad44..b8a12a3 100644 --- a/internal/tender/repository.go +++ b/internal/tender/repository.go @@ -32,7 +32,7 @@ type TenderRepository interface { Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchPageResult, error) GetExpiredTenders(ctx context.Context, beforeDate int64) ([]Tender, error) GetUnScrapedTenders(ctx context.Context, limit, skip int) ([]Tender, int64, error) - GetPendingDocumentScrapeTenders(ctx context.Context, countryCode string, minDeadline int64, limit, skip int) ([]Tender, bool, error) + GetPendingDocumentScrapeTenders(ctx context.Context, countryCodes []string, minDeadline int64, limit, skip int) ([]Tender, bool, error) GetUnSummarizedTenders(ctx context.Context, limit, skip int) ([]Tender, int64, error) GetUnTranslatedTenders(ctx context.Context, language string, limit, skip int) ([]Tender, int64, error) Update(ctx context.Context, tender *Tender) error @@ -579,10 +579,14 @@ func (r *tenderRepository) GetUnScrapedTenders(ctx context.Context, limit, skip return result.Items, result.TotalCount, nil } -// GetPendingDocumentScrapeTenders returns unscraped tenders for one country with an active deadline. -func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, countryCode string, minDeadline int64, limit, skip int) ([]Tender, bool, error) { +// GetPendingDocumentScrapeTenders returns unscraped tenders for the given countries with an active deadline. +func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, countryCodes []string, minDeadline int64, limit, skip int) ([]Tender, bool, error) { + if len(countryCodes) == 0 { + return nil, false, nil + } + filter := bson.M{ - "country_code": countryCode, + "country_code": bson.M{"$in": countryCodes}, "tender_deadline": bson.M{ "$gt": minDeadline, }, @@ -601,7 +605,7 @@ func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, result, err := r.ormRepo.FindAll(ctx, filter, pagination) if err != nil { r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{ - "country_code": countryCode, + "country_codes": countryCodes, "min_deadline": minDeadline, "limit": limit, "skip": skip,