diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 9e144b1..9ec7388 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -88,6 +88,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler companiesGP.PATCH("/:id/status", companyHandler.UpdateStatus) companiesGP.PATCH("/:id/verification", companyHandler.UpdateVerification) companiesGP.POST("/:id/documents", companyHandler.UploadDocuments) + companiesGP.POST("/:id/links", companyHandler.AddLinks) } // Admin Categories Routes diff --git a/internal/company/entity.go b/internal/company/entity.go index 24ecbd1..73dbde9 100644 --- a/internal/company/entity.go +++ b/internal/company/entity.go @@ -53,6 +53,9 @@ type Company struct { // File references stored in GridFS DocumentFileIDs []string `bson:"document_file_ids" json:"document_file_ids,omitempty"` + // External resource links (e.g. company profile pages, portfolios) + Links []CompanyLink `bson:"links,omitempty" json:"links,omitempty"` + // Tags for categorization and search Tags *CompanyTags `bson:"tags,omitempty" json:"tags,omitempty"` @@ -97,6 +100,12 @@ func (c *Company) GetUpdatedAt() int64 { return c.UpdatedAt } +// CompanyLink represents an external URL associated with a company. +type CompanyLink struct { + URL string `bson:"url" json:"url"` + Title *string `bson:"title,omitempty" json:"title,omitempty"` +} + // Address represents a company's address type Address struct { Street string `bson:"street" json:"street"` diff --git a/internal/company/form.go b/internal/company/form.go index 579e8f0..1d1c33e 100644 --- a/internal/company/form.go +++ b/internal/company/form.go @@ -24,7 +24,8 @@ type ( // Contact information Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"` - DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"` + DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"` + Links []CompanyLinkForm `json:"links,omitempty" valid:"optional"` // Tags for categorization and search Tags *CompanyTagsForm `json:"tags,omitempty"` @@ -35,6 +36,12 @@ type ( Timezone *string `json:"timezone,omitempty" valid:"optional,length(3|50)" example:"UTC"` } + // CompanyLinkForm represents the form for a company link. + CompanyLinkForm struct { + URL string `json:"url" valid:"required,url" example:"https://example.com/profile"` + Title *string `json:"title,omitempty" valid:"optional,length(1|200)" example:"Company profile"` + } + // AddressForm represents the form for company address AddressForm struct { Street string `json:"street" valid:"required,length(5|200)" example:"123 Main St"` @@ -90,6 +97,11 @@ type StatusForm struct { Reason *string `json:"reason" valid:"optional,length(10|500)"` } +// AddLinksForm is the request body for appending links to a company. +type AddLinksForm struct { + Links []CompanyLinkForm `json:"links" valid:"required"` +} + // VerificationForm represents the form for updating company verification status type VerificationForm struct { IsVerified bool `json:"is_verified"` @@ -128,6 +140,7 @@ type ( Phone *string `json:"phone,omitempty"` Email *string `json:"email,omitempty"` DocumentFileIDs []string `json:"document_file_ids,omitempty"` + Links []CompanyLink `json:"links,omitempty"` Tags *CompanyTagsResponse `json:"tags,omitempty"` IsVerified bool `json:"is_verified"` IsCompliant bool `json:"is_compliant"` @@ -167,6 +180,7 @@ func (c *Company) ToResponse(categories []*CategoryResponse) *CompanyResponse { Phone: c.Phone, Email: c.Email, DocumentFileIDs: c.DocumentFileIDs, + Links: c.Links, Tags: c.Tags.ToResponse(categories), IsVerified: c.IsVerified, IsCompliant: c.IsCompliant, @@ -208,8 +222,9 @@ type CompanyProfileResponse struct { RegistrationNumber string `json:"registration_number"` Industry string `json:"industry"` FoundedYear *int `json:"founded_year,omitempty"` - DocumentFileIDs []string `json:"document_file_ids,omitempty"` - CreatedAt int64 `json:"created_at"` + DocumentFileIDs []string `json:"document_file_ids,omitempty"` + Links []CompanyLink `json:"links,omitempty"` + CreatedAt int64 `json:"created_at"` } // ToResponse converts Company to CompanyResponse @@ -221,6 +236,7 @@ func (c *Company) ToProfileResponse() *CompanyProfileResponse { Industry: c.Industry, FoundedYear: c.FoundedYear, DocumentFileIDs: c.DocumentFileIDs, + Links: c.Links, CreatedAt: c.CreatedAt, } } diff --git a/internal/company/handler.go b/internal/company/handler.go index b4d9516..b8da1b6 100644 --- a/internal/company/handler.go +++ b/internal/company/handler.go @@ -168,6 +168,9 @@ func (h *Handler) Create(c echo.Context) error { err.Error() == "failed to validate categories" { return response.BadRequest(c, err.Error(), "Invalid category data") } + if err.Error() == "invalid link URL" || err.Error() == "too many links provided" { + return response.BadRequest(c, err.Error(), "") + } return response.InternalServerError(c, "Failed to create company") } @@ -238,6 +241,9 @@ func (h *Handler) Update(c echo.Context) error { err.Error() == "failed to validate categories" { return response.BadRequest(c, err.Error(), "Invalid category data") } + if err.Error() == "invalid link URL" || err.Error() == "too many links provided" { + return response.BadRequest(c, err.Error(), "") + } return response.InternalServerError(c, "Failed to update company") } @@ -455,6 +461,54 @@ func (h *Handler) UploadDocuments(c echo.Context) error { return response.Success(c, result, message) } +// AddLinks appends external links to a company (Web Panel) +// @Summary Add company links +// @Description Append one or more external URLs to the company (max 20 links per request) +// @Tags Admin-Companies +// @Accept json +// @Produce json +// @Param id path string true "Company ID" +// @Param links body AddLinksForm true "Links to append" +// @Success 200 {object} response.APIResponse{data=AddLinksResponse} "Links added successfully" +// @Failure 400 {object} response.APIResponse "Bad request - No links or invalid URLs" +// @Failure 404 {object} response.APIResponse "Not found - Company not found" +// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Security BearerAuth +// @Router /admin/v1/companies/{id}/links [post] +func (h *Handler) AddLinks(c echo.Context) error { + id := c.Param("id") + + form, err := response.Parse[AddLinksForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + if len(form.Links) > maxCompanyLinks { + return response.ValidationError(c, "Too many links", "Maximum 20 links per request") + } + + result, err := h.service.AddLinks(c.Request().Context(), id, form) + if err != nil { + switch err.Error() { + case "company not found": + return response.NotFound(c, "Company not found") + case "no links provided", "no valid links provided", "invalid link URL": + return response.BadRequest(c, err.Error(), "") + case "too many links in request", "company link limit exceeded": + return response.ValidationError(c, err.Error(), "") + default: + return response.InternalServerError(c, "Failed to add company links") + } + } + + message := "Company links added successfully" + if len(result.Added) == 0 { + message = "No new company links were added" + } + + return response.Success(c, result, message) +} + func collectCompanyUploadFiles(form *multipart.Form) []*multipart.FileHeader { if form == nil { return nil diff --git a/internal/company/links.go b/internal/company/links.go new file mode 100644 index 0000000..71c58ac --- /dev/null +++ b/internal/company/links.go @@ -0,0 +1,164 @@ +package company + +import ( + "context" + "errors" + "strings" + + "github.com/asaskevich/govalidator" +) + +const maxCompanyLinks = 20 + +// AddLinksResponse is returned after appending links to a company. +type AddLinksResponse struct { + Links []CompanyLink `json:"links"` + Added []CompanyLink `json:"added"` +} + +// AddLinks appends validated links to a company. +func (s *companyService) AddLinks(ctx context.Context, companyID string, form *AddLinksForm) (*AddLinksResponse, error) { + if form == nil || len(form.Links) == 0 { + return nil, errors.New("no links provided") + } + if len(form.Links) > maxCompanyLinks { + return nil, errors.New("too many links in request") + } + + company, err := s.repository.GetByID(ctx, companyID) + if err != nil { + return nil, errors.New("company not found") + } + + newLinks, err := sanitizeCompanyLinks(convertCompanyLinkForms(form.Links)) + if err != nil { + return nil, err + } + if len(newLinks) == 0 { + return nil, errors.New("no valid links provided") + } + + existing := sanitizeStoredCompanyLinks(company.Links) + merged, added := mergeCompanyLinks(existing, newLinks) + if len(merged) > maxCompanyLinks { + return nil, errors.New("company link limit exceeded") + } + + company.Links = merged + if err := s.repository.Update(ctx, company); err != nil { + s.logger.Error("Failed to save company links", map[string]interface{}{ + "error": err.Error(), + "company_id": companyID, + }) + return nil, errors.New("failed to update company links") + } + + s.logger.Info("Company links added", map[string]interface{}{ + "company_id": companyID, + "added_count": len(added), + "links_total": len(company.Links), + }) + + s.triggerAIOnboardingAsync(companyID) + + return &AddLinksResponse{ + Links: company.Links, + Added: added, + }, nil +} + +func convertCompanyLinkForms(forms []CompanyLinkForm) []CompanyLink { + if len(forms) == 0 { + return nil + } + + links := make([]CompanyLink, 0, len(forms)) + for _, form := range forms { + links = append(links, CompanyLink{ + URL: form.URL, + Title: form.Title, + }) + } + return links +} + +func sanitizeCompanyLinks(links []CompanyLink) ([]CompanyLink, error) { + if len(links) == 0 { + return nil, nil + } + + sanitized := make([]CompanyLink, 0, len(links)) + for _, link := range links { + url := strings.TrimSpace(link.URL) + if url == "" { + continue + } + if !govalidator.IsURL(url) { + return nil, errors.New("invalid link URL") + } + + var title *string + if link.Title != nil { + trimmed := strings.TrimSpace(*link.Title) + if trimmed != "" { + title = &trimmed + } + } + + sanitized = append(sanitized, CompanyLink{ + URL: url, + Title: title, + }) + } + return sanitized, nil +} + +func sanitizeStoredCompanyLinks(links []CompanyLink) []CompanyLink { + sanitized, err := sanitizeCompanyLinks(links) + if err != nil || len(sanitized) == 0 { + return nil + } + return sanitized +} + +func mergeCompanyLinks(existing, additional []CompanyLink) ([]CompanyLink, []CompanyLink) { + seen := make(map[string]struct{}, len(existing)+len(additional)) + merged := make([]CompanyLink, 0, len(existing)+len(additional)) + added := make([]CompanyLink, 0, len(additional)) + + appendUnique := func(items []CompanyLink, trackAdded bool) { + for _, item := range items { + key := strings.ToLower(strings.TrimSpace(item.URL)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + merged = append(merged, item) + if trackAdded { + added = append(added, item) + } + } + } + + appendUnique(existing, false) + appendUnique(additional, true) + return merged, added +} + +func companyLinkURLs(links []CompanyLink) []string { + if len(links) == 0 { + return nil + } + + urls := make([]string, 0, len(links)) + for _, link := range links { + url := strings.TrimSpace(link.URL) + if url != "" { + urls = append(urls, url) + } + } + return urls +} diff --git a/internal/company/links_test.go b/internal/company/links_test.go new file mode 100644 index 0000000..39e1e9b --- /dev/null +++ b/internal/company/links_test.go @@ -0,0 +1,70 @@ +package company + +import "testing" + +func TestMergeCompanyLinks(t *testing.T) { + existing := []CompanyLink{ + {URL: "https://example.com/a"}, + {URL: "https://example.com/b"}, + } + additional := []CompanyLink{ + {URL: " https://example.com/b "}, + {URL: "https://Example.com/C"}, + {URL: " "}, + } + + merged, added := mergeCompanyLinks(existing, additional) + if len(merged) != 3 { + t.Fatalf("mergeCompanyLinks() merged len = %d, want 3", len(merged)) + } + if len(added) != 1 { + t.Fatalf("mergeCompanyLinks() added len = %d, want 1", len(added)) + } + if added[0].URL != "https://Example.com/C" { + t.Fatalf("added link = %+v, want https://Example.com/C", added[0]) + } +} + +func TestSanitizeCompanyLinks(t *testing.T) { + title := " Profile " + got, err := sanitizeCompanyLinks([]CompanyLink{ + {URL: " https://example.com ", Title: &title}, + {URL: "not-a-url"}, + }) + if err == nil { + t.Fatal("sanitizeCompanyLinks() expected invalid URL error") + } + + got, err = sanitizeCompanyLinks([]CompanyLink{ + {URL: "https://example.com", Title: &title}, + }) + if err != nil { + t.Fatalf("sanitizeCompanyLinks() unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("sanitizeCompanyLinks() len = %d, want 1", len(got)) + } + if got[0].URL != "https://example.com" { + t.Fatalf("sanitized URL = %q, want https://example.com", got[0].URL) + } + if got[0].Title == nil || *got[0].Title != "Profile" { + t.Fatalf("sanitized title = %+v, want Profile", got[0].Title) + } +} + +func TestCompanyLinkURLs(t *testing.T) { + got := companyLinkURLs([]CompanyLink{ + {URL: " https://a.test "}, + {URL: " "}, + {URL: "https://b.test"}, + }) + want := []string{"https://a.test", "https://b.test"} + if len(got) != len(want) { + t.Fatalf("companyLinkURLs() len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("companyLinkURLs()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/company/recommendation.go b/internal/company/recommendation.go index 7efc2f8..6fe20a6 100644 --- a/internal/company/recommendation.go +++ b/internal/company/recommendation.go @@ -171,6 +171,7 @@ func (s *companyService) startAIOnboardingInternal(ctx context.Context, companyI s.logger.Info("Starting AI company onboarding", map[string]interface{}{ "company_id": companyID, "documents_count": len(company.DocumentFileIDs), + "links_count": len(company.Links), "has_website_url": websiteURL != "", }) @@ -178,6 +179,7 @@ func (s *companyService) startAIOnboardingInternal(ctx context.Context, companyI CompanyID: companyID, Documents: documents, WebsiteURL: websiteURL, + Links: companyLinkURLs(company.Links), }) if err != nil { s.logger.Error("AI onboarding request failed", map[string]interface{}{ diff --git a/internal/company/repository.go b/internal/company/repository.go index dd3cdd5..5a81dfb 100644 --- a/internal/company/repository.go +++ b/internal/company/repository.go @@ -170,6 +170,7 @@ func (r *companyRepository) Update(ctx context.Context, company *Company) error company.SetUpdatedAt(time.Now().Unix()) clearDocuments := company.DocumentFileIDs != nil && len(company.DocumentFileIDs) == 0 + clearLinks := company.Links != nil && len(company.Links) == 0 // Use ORM to update company err := r.ormRepo.Update(ctx, company) @@ -181,16 +182,23 @@ func (r *companyRepository) Update(ctx context.Context, company *Company) error return err } - // BSON omitempty can skip empty slices in $set, so persist an explicit empty array. - if clearDocuments { + // BSON omitempty can skip empty slices in $set, so persist explicit empty arrays. + if clearDocuments || clearLinks { + setFields := bson.M{ + "updated_at": company.UpdatedAt, + } + if clearDocuments { + setFields["document_file_ids"] = []string{} + } + if clearLinks { + setFields["links"] = []CompanyLink{} + } + _, err = r.collection.UpdateOne(ctx, bson.M{"_id": company.ID}, bson.M{ - "$set": bson.M{ - "document_file_ids": []string{}, - "updated_at": company.UpdatedAt, - }, + "$set": setFields, }) if err != nil { - r.logger.Error("Failed to clear company document file IDs", map[string]interface{}{ + r.logger.Error("Failed to clear company collection fields", map[string]interface{}{ "error": err.Error(), "company_id": company.ID, }) diff --git a/internal/company/service.go b/internal/company/service.go index 3a2a299..7fd2105 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -36,6 +36,9 @@ type Service interface { // 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 @@ -118,6 +121,14 @@ func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*Compan } } + 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, @@ -135,6 +146,7 @@ func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*Compan Phone: form.Phone, Email: form.Email, DocumentFileIDs: s.sanitizeDocumentFileIDs(form.DocumentFileIDs), + Links: links, Tags: s.convertCompanyTagsForm(form.Tags), IsVerified: false, IsCompliant: false, @@ -144,7 +156,7 @@ func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*Compan } // Save to database - err := s.repository.Create(ctx, company) + err = s.repository.Create(ctx, company) if err != nil { s.logger.Error("Failed to create company", map[string]interface{}{ "error": err.Error(), @@ -304,6 +316,17 @@ func (s *companyService) Update(ctx context.Context, id string, form *CompanyFor 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) } diff --git a/pkg/ai_summarizer/entities.go b/pkg/ai_summarizer/entities.go index 2fa3eb3..3d1b5eb 100644 --- a/pkg/ai_summarizer/entities.go +++ b/pkg/ai_summarizer/entities.go @@ -249,9 +249,10 @@ type PipelineMinioStatsResponse map[string]interface{} // OnboardingRequest is the payload for POST /onboarding. type OnboardingRequest struct { - CompanyID string `json:"company_id"` - Documents string `json:"documents"` - WebsiteURL string `json:"website_url"` + CompanyID string `json:"company_id"` + Documents string `json:"documents"` + WebsiteURL string `json:"website_url"` + Links []string `json:"links,omitempty"` } // OnboardingResponse is returned by POST /onboarding.