initial commit
This commit is contained in:
426
internal/application/specialist/service.go
Normal file
426
internal/application/specialist/service.go
Normal file
@@ -0,0 +1,426 @@
|
||||
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{},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user