package specialist import ( "context" "strings" "time" "github.com/google/uuid" "github.com/rs/zerolog" "go.uber.org/fx" "golang.org/x/sync/errgroup" appProfile "base/internal/application/profile" domainAsset "base/internal/domain/asset" domainProfile "base/internal/domain/profile" "base/internal/dto" ) type Service interface { Overview(ctx context.Context, userID uuid.UUID) (*dto.SpecialistOverviewFetchedResponse, error) UpdateHero(ctx context.Context, userID uuid.UUID, req dto.HeroDTO) error UpdateContact(ctx context.Context, userID uuid.UUID, req dto.ContactDTO) error UpdateSkills(ctx context.Context, userID uuid.UUID, req dto.SkillsUpdateRequest) error GetPageSections(ctx context.Context, userID uuid.UUID) (*dto.PageSectionsResponse, error) GetProfile(ctx context.Context, userID uuid.UUID) (*dto.ProfileResponse, error) } type service struct { logger zerolog.Logger profileRepo domainProfile.Repository assetRepo domainAsset.AssetRepository } // Param holds dependencies for the specialist overview service. type Param struct { Logger zerolog.Logger ProfileRepo domainProfile.Repository AssetRepo domainAsset.AssetRepository fx.In } func New(param Param) Service { return &service{ logger: param.Logger, profileRepo: param.ProfileRepo, assetRepo: param.AssetRepo, } } func (s *service) Overview(ctx context.Context, userID uuid.UUID) (*dto.SpecialistOverviewFetchedResponse, error) { resp := &struct { profile *domainProfile.Profile assets []*domainAsset.Asset recentlyJoined []*domainProfile.Profile totalProfiles int totalAssets int }{} g, gCtx := errgroup.WithContext(ctx) // 1. Profile by OwnerID (includes Skills) + Assets by ProfileID g.Go(func() error { profile, err := s.profileRepo.FindByUserID(gCtx, userID) if err != nil { return domainProfile.ErrProfileNotFound } resp.profile = profile assets, err := s.assetRepo.FindByProfileID(gCtx, profile.ID) if err != nil { assets = []*domainAsset.Asset{} } resp.assets = assets return nil }) // 2. Latest 6 profiles + total profiles count (FindAll returns both) g.Go(func() error { profiles, total, err := s.profileRepo.FindAll(gCtx, domainProfile.Filter{ Page: 1, PageSize: 6, SortedBy: "created_at", Ascending: false, }) if err != nil { return err } resp.recentlyJoined = profiles resp.totalProfiles = total return nil }) // 3. Total assets count g.Go(func() error { count, err := s.assetRepo.Count(gCtx) if err != nil { return err } resp.totalAssets = count return nil }) if err := g.Wait(); err != nil { return nil, err } assets := s.toOverviewAssets(resp.assets, userID) flatProfiles := ToFlatProfiles(resp.recentlyJoined) profileResp := appProfile.DomainProfileToProfileResponse(resp.profile) var skills []dto.SkillDTO if profileResp != nil { skills = profileResp.Skills } tasks := s.computeTasks(resp.profile, resp.assets) completionPercent := s.computeCompletionPercent(tasks) return &dto.SpecialistOverviewFetchedResponse{ Message: "", Data: dto.SpecialistOverviewFetchedDataDTO{ Assets: assets, RecentlyJoined: flatProfiles, Analytics: dto.AnalyticsDTO{TotalAssets: resp.totalAssets, TotalProfiles: resp.totalProfiles}, Profile: profileResp, Skills: skills, CompletionPercent: completionPercent, Tasks: tasks, }, }, nil } // computeTasks derives task flags from profile and assets. true = needs action. func (s *service) computeTasks(p *domainProfile.Profile, assets []*domainAsset.Asset) dto.TasksDTO { tasks := dto.TasksDTO{} if p == nil { return tasks } // profile_action: Hero section (firstName, lastName, shortDescription) incomplete tasks.ProfileAction = strings.TrimSpace(p.Hero.FirstName) == "" || strings.TrimSpace(p.Hero.LastName) == "" || strings.TrimSpace(p.Hero.ShortDescription) == "" // about_action: About section incomplete tasks.AboutAction = strings.TrimSpace(p.About.ProfilePicture) == "" || strings.TrimSpace(p.About.About) == "" // publish_action: not public tasks.PublishAction = p.PageSetting.VisibilityLevel != "public" // works_action: no assets tasks.WorksAction = len(assets) == 0 // skills_action: no skills tasks.SkillsAction = len(p.Skills) == 0 // social_action: no social links tasks.SocialAction = len(p.Contact.SocialLinks) == 0 return tasks } // computeCompletionPercent: 6 sections, each complete = !action. Percent = (6 - actionsNeeded) / 6 * 100 func (s *service) computeCompletionPercent(tasks dto.TasksDTO) int { complete := 0 if !tasks.ProfileAction { complete++ } if !tasks.AboutAction { complete++ } if !tasks.PublishAction { complete++ } if !tasks.WorksAction { complete++ } if !tasks.SkillsAction { complete++ } if !tasks.SocialAction { complete++ } if complete == 0 { return 0 } return (complete * 100) / 6 } func (s *service) UpdateHero(ctx context.Context, userID uuid.UUID, req dto.HeroDTO) error { p, err := s.profileRepo.FindByUserID(ctx, userID) if err != nil || p == nil { return domainProfile.ErrProfileNotFound } p.Hero.FirstName = req.FirstName p.Hero.LastName = req.LastName p.Hero.Company = req.Company p.Hero.ShortDescription = req.ShortDescription p.Hero.ResumeLink = req.ResumeLink p.Hero.CTAEnabled = req.CTAEnabled p.Hero.Avatar = req.Avatar if req.RoleID != nil { if p.Hero.Role == nil { p.Hero.Role = &domainProfile.Role{ID: *req.RoleID, Level: req.RoleLevel} } else { p.Hero.Role.ID = *req.RoleID p.Hero.Role.Level = req.RoleLevel } } else if req.RoleLevel != "" { if p.Hero.Role == nil { p.Hero.Role = &domainProfile.Role{Level: req.RoleLevel} } else { p.Hero.Role.Level = req.RoleLevel } } p.UpdatedAt = time.Now() return s.profileRepo.Update(ctx, p) } func (s *service) UpdateContact(ctx context.Context, userID uuid.UUID, req dto.ContactDTO) error { p, err := s.profileRepo.FindByUserID(ctx, userID) if err != nil || p == nil { return domainProfile.ErrProfileNotFound } p.Contact.Email = req.Email p.Contact.Phone = req.Phone p.Contact.SocialLinks = make([]domainProfile.SocialLink, len(req.SocialLinks)) for i, sl := range req.SocialLinks { p.Contact.SocialLinks[i] = domainProfile.SocialLink{LinkType: sl.LinkType, Link: sl.Link} } p.UpdatedAt = time.Now() return s.profileRepo.Update(ctx, p) } func (s *service) UpdateSkills(ctx context.Context, userID uuid.UUID, req dto.SkillsUpdateRequest) error { p, err := s.profileRepo.FindByUserID(ctx, userID) if err != nil || p == nil { return domainProfile.ErrProfileNotFound } p.Skills = make([]domainProfile.Skill, len(req.Skills)) for i, s := range req.Skills { p.Skills[i] = domainProfile.Skill{SkillName: s.SkillName, Level: s.Level} } p.UpdatedAt = time.Now() return s.profileRepo.Update(ctx, p) } func (s *service) GetPageSections(ctx context.Context, userID uuid.UUID) (*dto.PageSectionsResponse, error) { p, err := s.profileRepo.FindByUserID(ctx, userID) if err != nil || p == nil { return nil, domainProfile.ErrProfileNotFound } resp := appProfile.DomainProfileToProfileResponse(p) if resp == nil { return nil, domainProfile.ErrProfileNotFound } return &dto.PageSectionsResponse{ Hero: resp.Hero, Contact: resp.Contact, Skills: resp.Skills, PageSectionOrder: resp.PageSectionOrder, }, nil } func (s *service) GetProfile(ctx context.Context, userID uuid.UUID) (*dto.ProfileResponse, error) { p, err := s.profileRepo.FindByUserID(ctx, userID) if err != nil || p == nil { return nil, domainProfile.ErrProfileNotFound } return appProfile.DomainProfileToProfileResponse(p), nil } func (s *service) toOverviewAssets(assets []*domainAsset.Asset, ownerID uuid.UUID) []dto.OverviewAssetDTO { out := make([]dto.OverviewAssetDTO, len(assets)) for i, a := range assets { out[i] = s.toOverviewAsset(a, ownerID) } return out } func (s *service) toOverviewAsset(a *domainAsset.Asset, ownerID uuid.UUID) dto.OverviewAssetDTO { price := 0 coverImage := "" for _, art := range a.AssetArtifacts { if strings.Contains(strings.ToLower(art.Type), "image") { coverImage = art.DownloadURL break } } if len(a.AssetArtifacts) > 0 { price = a.AssetArtifacts[0].Price } cat := (*dto.CategoryDTO)(nil) if a.AssetCategory.ID != uuid.Nil { cat = &dto.CategoryDTO{ ID: a.AssetCategory.ID, Name: a.AssetCategory.Name, Icon: a.AssetCategory.Icon, Color: a.AssetCategory.Color, CardType: a.AssetCategory.CardType, Featured: a.AssetCategory.Featured, Description: a.AssetCategory.Description, } } return dto.OverviewAssetDTO{ ID: a.ID.String(), Title: a.Title, Description: a.Description, Content: a.Description, AssetCategoryID: a.AssetCategoryID.String(), AssetCategory: cat, CoverImage: coverImage, Link: a.Link, OwnerID: ownerID.String(), ProfileID: a.ProfileID.String(), Profile: nil, Price: price, Currency: "USD", Status: assetStatusToString(a.Status), Rating: 0, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, } } func assetStatusToString(st domainAsset.Status) string { switch st { case domainAsset.StatusPublished: return "published" case domainAsset.StatusDisabled: return "disabled" case domainAsset.StatusPending: return "pending" case domainAsset.StatusDeleted: return "deleted" default: return "" } } // ToFlatProfiles converts domain profiles to flat DTOs. func ToFlatProfiles(profiles []*domainProfile.Profile) []dto.FlatProfileDTO { out := make([]dto.FlatProfileDTO, len(profiles)) for i, p := range profiles { out[i] = ToFlatProfile(p) } return out } // ToFlatProfile converts a single profile to flat DTO. func ToFlatProfile(p *domainProfile.Profile) dto.FlatProfileDTO { roleID := "" roleName := "" if p.Hero.Role != nil { roleID = p.Hero.Role.ID.String() if p.Hero.Role.Title != "" { roleName = p.Hero.Role.Title } } achievements := make(map[string]dto.AchievementItemDTO) for _, a := range p.About.Achievements { key := strings.ToLower(strings.ReplaceAll(a.Title, " ", "")) if key == "" { key = a.Title } achievements[key] = dto.AchievementItemDTO{Value: a.Value, Enabled: a.Enabled} } if len(achievements) == 0 { achievements = map[string]dto.AchievementItemDTO{ "happyClient": {Value: "", Enabled: true}, "yearExperience": {Value: "", Enabled: true}, "projectCompeleted": {Value: "", Enabled: true}, } } var socialLinks []dto.SocialLinkDTO for _, sl := range p.Contact.SocialLinks { socialLinks = append(socialLinks, dto.SocialLinkDTO{LinkType: sl.LinkType, Link: sl.Link}) } displayName := strings.TrimSpace(p.Hero.FirstName + " " + p.Hero.LastName) if displayName == "" { displayName = p.Handle } status := "published" if p.PageSetting.VisibilityLevel != "public" { status = "draft" } return dto.FlatProfileDTO{ ID: p.ID.String(), ProfileHandle: p.Handle, Status: status, BackgroundImage: "", ProfilePicture: p.About.ProfilePicture, FirstName: p.Hero.FirstName, LastName: p.Hero.LastName, DisplayName: displayName, RoleID: roleID, Role: dto.RoleDTO{ID: roleID, Name: roleName}, CurrentCompany: p.Hero.Company, ShortDescription: p.Hero.ShortDescription, CTAEnabled: p.Hero.CTAEnabled, CTAAction: "", ResumeLink: p.Hero.ResumeLink, About: p.About.About, ContactEmail: p.Contact.Email, Achievements: achievements, ContactPhone: p.Contact.Phone, Country: "", CustomRoles: "", RoleLevel: p.Hero.Role.Level, SocialLinks: socialLinks, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, HandleUpdatedAt: time.Time{}, } }