initial commit

This commit is contained in:
m.zare
2026-04-10 18:25:21 +03:30
commit 77ca6c34a3
263 changed files with 34470 additions and 0 deletions

View 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{},
}
}

View File

@@ -0,0 +1,176 @@
package specialist_test
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
appMock "base/internal/application/mock"
"base/internal/application/specialist"
domainAsset "base/internal/domain/asset"
domainProfile "base/internal/domain/profile"
)
func TestSpecialistService_Overview(t *testing.T) {
ctx := context.Background()
logger := zerolog.Nop()
userID := uuid.New()
profileID := uuid.New()
t.Run("success - returns overview with profile, assets, tasks", func(t *testing.T) {
assetRepo := new(appMock.MockAssetRepository)
profileRepo := new(appMock.MockProfileRepository)
profile := &domainProfile.Profile{
ID: profileID,
Handle: "specialist-user",
UserID: &userID,
Hero: domainProfile.Hero{
FirstName: "Jane",
LastName: "Doe",
ShortDescription: "ML Engineer",
},
About: domainProfile.About{
ProfilePicture: "avatar.jpg",
About: "About me",
},
Skills: []domainProfile.Skill{
{SkillName: "Go", Level: "expert"},
},
Contact: domainProfile.Contact{
Email: "jane@example.com",
SocialLinks: []domainProfile.SocialLink{{LinkType: "github", Link: "https://github.com/jane"}},
},
PageSetting: domainProfile.PageSetting{VisibilityLevel: "public"},
}
asset := &domainAsset.Asset{
ID: uuid.New(),
ProfileID: profileID,
Status: domainAsset.StatusPublished,
AssetCategoryID: uuid.New(),
Title: "My Project",
Description: "A cool project",
AssetArtifacts: []domainAsset.Artifact{{Type: "image", DownloadURL: "cover.png", Price: 0}},
}
otherProfile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "other-user",
Hero: domainProfile.Hero{FirstName: "Other", LastName: "User"},
PageSetting: domainProfile.PageSetting{VisibilityLevel: "public"},
}
profileRepo.On("FindByUserID", ctx, userID).Return(profile, nil)
assetRepo.On("FindByProfileID", ctx, profileID).Return([]*domainAsset.Asset{asset}, nil)
profileRepo.On("FindAll", ctx, mock.MatchedBy(func(arg interface{}) bool {
f, ok := arg.(domainProfile.Filter)
if !ok {
return false
}
return f.Page == 1 && f.PageSize == 6 && f.SortedBy == "created_at" && !f.Ascending
})).Return([]*domainProfile.Profile{otherProfile}, 26, nil)
assetRepo.On("Count", ctx).Return(42, nil)
svc := specialist.New(specialist.Param{
Logger: logger,
ProfileRepo: profileRepo,
AssetRepo: assetRepo,
})
resp, err := svc.Overview(ctx, userID)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, "", resp.Message)
assert.Len(t, resp.Data.Assets, 1)
assert.Equal(t, "My Project", resp.Data.Assets[0].Title)
assert.Len(t, resp.Data.RecentlyJoined, 1)
assert.Equal(t, "other-user", resp.Data.RecentlyJoined[0].ProfileHandle)
assert.Equal(t, 42, resp.Data.Analytics.TotalAssets)
assert.Equal(t, 26, resp.Data.Analytics.TotalProfiles)
require.NotNil(t, resp.Data.Profile)
assert.Equal(t, "specialist-user", resp.Data.Profile.Handle)
assert.Len(t, resp.Data.Skills, 1)
assert.Equal(t, "Go", resp.Data.Skills[0].SkillName)
// All sections complete -> 100% or high completion
assert.False(t, resp.Data.Tasks.ProfileAction)
assert.False(t, resp.Data.Tasks.AboutAction)
assert.False(t, resp.Data.Tasks.PublishAction)
assert.False(t, resp.Data.Tasks.WorksAction)
assert.False(t, resp.Data.Tasks.SkillsAction)
assert.False(t, resp.Data.Tasks.SocialAction)
assert.Equal(t, 100, resp.Data.CompletionPercent)
assetRepo.AssertExpectations(t)
profileRepo.AssertExpectations(t)
})
t.Run("profile not found returns ErrProfileNotFound", func(t *testing.T) {
assetRepo := new(appMock.MockAssetRepository)
profileRepo := new(appMock.MockProfileRepository)
profileRepo.On("FindByUserID", ctx, userID).Return(nil, domainProfile.ErrProfileNotFound)
svc := specialist.New(specialist.Param{
Logger: logger,
ProfileRepo: profileRepo,
AssetRepo: assetRepo,
})
resp, err := svc.Overview(ctx, userID)
assert.Error(t, err)
assert.True(t, errors.Is(err, domainProfile.ErrProfileNotFound))
assert.Nil(t, resp)
profileRepo.AssertExpectations(t)
})
t.Run("incomplete profile computes tasks and completion percent", func(t *testing.T) {
assetRepo := new(appMock.MockAssetRepository)
profileRepo := new(appMock.MockProfileRepository)
profile := &domainProfile.Profile{
ID: profileID,
Handle: "incomplete",
UserID: &userID,
Hero: domainProfile.Hero{FirstName: "A"}, // missing LastName, ShortDescription
About: domainProfile.About{}, // missing picture, about
Skills: []domainProfile.Skill{},
PageSetting: domainProfile.PageSetting{VisibilityLevel: "private"},
}
profileRepo.On("FindByUserID", ctx, userID).Return(profile, nil)
assetRepo.On("FindByProfileID", ctx, profileID).Return([]*domainAsset.Asset{}, nil)
profileRepo.On("FindAll", ctx, mock.Anything).Return([]*domainProfile.Profile{}, 0, nil)
assetRepo.On("Count", ctx).Return(0, nil)
svc := specialist.New(specialist.Param{
Logger: logger,
ProfileRepo: profileRepo,
AssetRepo: assetRepo,
})
resp, err := svc.Overview(ctx, userID)
require.NoError(t, err)
require.NotNil(t, resp)
assert.True(t, resp.Data.Tasks.ProfileAction)
assert.True(t, resp.Data.Tasks.AboutAction)
assert.True(t, resp.Data.Tasks.PublishAction)
assert.True(t, resp.Data.Tasks.WorksAction)
assert.True(t, resp.Data.Tasks.SkillsAction)
assert.True(t, resp.Data.Tasks.SocialAction)
assert.Equal(t, 0, resp.Data.CompletionPercent)
})
}