Add functionality to manage external links for companies
continuous-integration/drone/push Build is passing

- Introduced the ability to append external links to company profiles through a new API endpoint.
- Enhanced the `Company` entity to include a `Links` field for storing external resource links.
- Created `AddLinksForm` for validating incoming link data and implemented corresponding logic in the service layer.
- Added error handling for link validation, ensuring only valid URLs are accepted and limiting the number of links to 20.
- Implemented unit tests for link management functions, including sanitization and merging of links.
- Updated relevant API documentation to reflect the new functionality.

This update significantly enhances the company management capabilities by allowing the addition of external links, improving the overall user experience and data richness in company profiles.
This commit is contained in:
Mazyar
2026-07-06 00:05:48 +03:30
parent 3aeb8630e3
commit 89faa08b1c
10 changed files with 362 additions and 14 deletions
+1
View File
@@ -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
+9
View File
@@ -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"`
+19 -3
View File
@@ -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,
}
}
+54
View File
@@ -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
+164
View File
@@ -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
}
+70
View File
@@ -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])
}
}
}
+2
View File
@@ -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{}{
+15 -7
View File
@@ -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,
})
+24 -1
View File
@@ -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)
}
+4 -3
View File
@@ -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.