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,349 @@
package asset
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.uber.org/fx"
"golang.org/x/sync/errgroup"
domainAsset "base/internal/domain/asset"
"base/internal/dto"
)
var (
ErrAssetNotFound = errors.New("asset not found")
ErrCategoryNotFound = errors.New("asset category not found")
)
type Service interface {
Create(ctx context.Context, req dto.CreateAssetRequest) (*dto.AssetResponse, error)
GetByID(ctx context.Context, id uuid.UUID) (*dto.AssetResponse, error)
Update(ctx context.Context, req dto.UpdateAssetRequest) (*dto.AssetResponse, error)
Delete(ctx context.Context, id uuid.UUID) error
FindByProfileID(ctx context.Context, profileID uuid.UUID) (*dto.ListAssetsResponse, error)
ListCategories(ctx context.Context) (*dto.ListCategoriesResponse, error)
ListByCategoryID(ctx context.Context, categoryID uuid.UUID, limit, page int) (*dto.ListAssetsByCategoryIDResponse, error)
GetCategoriesWithPreview(ctx context.Context, req dto.CategoriesPreviewRequest) (*dto.CategoriesPreviewResponse, error)
}
type service struct {
logger zerolog.Logger
assetRepo domainAsset.AssetRepository
categoryRepo domainAsset.CategoryRepository
}
type Param struct {
Logger zerolog.Logger
AssetRepo domainAsset.AssetRepository
CategoryRepo domainAsset.CategoryRepository
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
assetRepo: param.AssetRepo,
categoryRepo: param.CategoryRepo,
}
}
func (s *service) Create(ctx context.Context, req dto.CreateAssetRequest) (*dto.AssetResponse, error) {
profileID, err := uuid.Parse(req.ProfileID)
if err != nil {
return nil, ErrAssetNotFound
}
categoryID, err := uuid.Parse(req.AssetCategoryID)
if err != nil {
return nil, ErrCategoryNotFound
}
// Verify category exists
category, err := s.categoryRepo.FindByID(ctx, categoryID)
if err != nil || category == nil {
return nil, ErrCategoryNotFound
}
asset := &domainAsset.Asset{
ID: uuid.New(),
ProfileID: profileID,
AssetCategoryID: categoryID,
AssetCategory: *category,
Title: req.Title,
Description: req.Description,
Link: req.Link,
Status: domainAsset.StatusPublished,
}
if err := s.assetRepo.Create(ctx, asset); err != nil {
return nil, err
}
return s.toAssetResponse(asset), nil
}
func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.AssetResponse, error) {
asset, err := s.assetRepo.FindByID(ctx, id)
if err != nil {
return nil, ErrAssetNotFound
}
return s.toAssetResponse(asset), nil
}
func (s *service) Update(ctx context.Context, req dto.UpdateAssetRequest) (*dto.AssetResponse, error) {
id, err := uuid.Parse(req.ID)
if err != nil {
return nil, ErrAssetNotFound
}
asset, err := s.assetRepo.FindByID(ctx, id)
if err != nil {
return nil, ErrAssetNotFound
}
asset.Title = req.Title
asset.Description = req.Description
asset.Link = req.Link
if req.AssetCategoryID != "" {
categoryID, err := uuid.Parse(req.AssetCategoryID)
if err == nil {
category, err := s.categoryRepo.FindByID(ctx, categoryID)
if err == nil && category != nil {
asset.AssetCategoryID = categoryID
asset.AssetCategory = *category
}
}
}
if req.Status != nil && *req.Status >= 0 && *req.Status <= 3 {
asset.Status = domainAsset.Status(*req.Status)
}
if err := s.assetRepo.Update(ctx, asset); err != nil {
return nil, err
}
return s.toAssetResponse(asset), nil
}
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
asset, err := s.assetRepo.FindByID(ctx, id)
if err != nil {
return ErrAssetNotFound
}
return s.assetRepo.Delete(ctx, asset)
}
func (s *service) FindByProfileID(ctx context.Context, profileID uuid.UUID) (*dto.ListAssetsResponse, error) {
assets, err := s.assetRepo.FindByProfileID(ctx, profileID)
if err != nil {
return nil, err
}
resp := &dto.ListAssetsResponse{
Assets: make([]dto.AssetResponse, len(assets)),
}
for i, a := range assets {
resp.Assets[i] = *s.toAssetResponse(a)
}
return resp, nil
}
func (s *service) ListCategories(ctx context.Context) (*dto.ListCategoriesResponse, error) {
categories, err := s.categoryRepo.FindAll(ctx)
if err != nil {
return nil, err
}
resp := &dto.ListCategoriesResponse{
Categories: make([]dto.CategoryDTO, len(categories)),
}
for i, c := range categories {
resp.Categories[i] = dto.CategoryDTO{
ID: c.ID,
Name: c.Name,
Icon: c.Icon,
Color: c.Color,
CardType: c.CardType,
Featured: c.Featured,
Description: c.Description,
}
}
return resp, nil
}
func (s *service) ListByCategoryID(ctx context.Context, categoryID uuid.UUID, limit, page int) (*dto.ListAssetsByCategoryIDResponse, error) {
if limit < 1 {
limit = 10
}
if page < 1 {
page = 1
}
category, err := s.categoryRepo.FindByID(ctx, categoryID)
if err != nil || category == nil {
return nil, ErrCategoryNotFound
}
total, err := s.assetRepo.CountByCategory(ctx, categoryID)
if err != nil {
return nil, err
}
offset := (page - 1) * limit
assets, err := s.assetRepo.FindLatestByCategoryPaginated(ctx, categoryID, limit, offset)
if err != nil {
return nil, err
}
totalPages := (total + limit - 1) / limit
if totalPages < 1 {
totalPages = 1
}
resp := &dto.ListAssetsByCategoryIDResponse{
Category: dto.CategoryDTO{
ID: category.ID,
Name: category.Name,
Icon: category.Icon,
Color: category.Color,
CardType: category.CardType,
Featured: category.Featured,
Description: category.Description,
},
Assets: make([]dto.AssetResponse, len(assets)),
Total: total,
Page: page,
PageSize: limit,
TotalPages: totalPages,
}
for i, a := range assets {
resp.Assets[i] = *s.toAssetResponse(a)
}
return resp, nil
}
func (s *service) GetCategoriesWithPreview(ctx context.Context, req dto.CategoriesPreviewRequest) (*dto.CategoriesPreviewResponse, error) {
perCategory := req.AssetsPerCategory
if perCategory < 1 {
perCategory = 8
}
if perCategory > 20 {
perCategory = 20
}
var categoryIDs []uuid.UUID
for _, s := range req.CategoryIDs {
if id, err := uuid.Parse(s); err == nil {
categoryIDs = append(categoryIDs, id)
}
}
var categories []*domainAsset.Category
var err error
if len(categoryIDs) > 0 {
categories, err = s.categoryRepo.FindByIDs(ctx, categoryIDs)
} else {
categories, err = s.categoryRepo.FindAll(ctx)
}
if err != nil {
return nil, err
}
if req.FeaturedOnly {
filtered := make([]*domainAsset.Category, 0, len(categories))
for _, c := range categories {
if c.Featured {
filtered = append(filtered, c)
}
}
categories = filtered
}
results := make([]dto.CategoryWithPreviewAssetsDTO, len(categories))
g, gCtx := errgroup.WithContext(ctx)
for index, category := range categories {
i, cat := index, category
g.Go(func() error {
assets, assetErr := s.assetRepo.FindLatestByCategory(gCtx, cat.ID, perCategory)
if assetErr != nil {
return assetErr
}
total, _ := s.assetRepo.CountByCategory(gCtx, cat.ID)
assetResps := make([]dto.AssetResponse, len(assets))
for j, a := range assets {
assetResps[j] = *s.toAssetResponse(a)
}
results[i] = dto.CategoryWithPreviewAssetsDTO{
Category: dto.CategoryDTO{
ID: cat.ID,
Name: cat.Name,
Icon: cat.Icon,
Color: cat.Color,
CardType: cat.CardType,
Featured: cat.Featured,
Description: cat.Description,
},
Assets: assetResps,
TotalAssets: total,
HasMore: total > perCategory,
}
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return &dto.CategoriesPreviewResponse{Categories: results}, nil
}
func (s *service) toAssetResponse(a *domainAsset.Asset) *dto.AssetResponse {
coverImage := ""
for _, art := range a.AssetArtifacts {
if strings.Contains(strings.ToLower(art.Type), "image") {
coverImage = art.DownloadURL
break
}
}
resp := &dto.AssetResponse{
ID: a.ID,
ProfileID: a.ProfileID,
AssetCategoryID: a.AssetCategoryID,
Title: a.Title,
Description: a.Description,
Link: a.Link,
CoverImage: coverImage,
Status: int(a.Status),
CreatedAt: formatTime(a.CreatedAt),
UpdatedAt: formatTime(a.UpdatedAt),
}
resp.Category = 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 resp
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}

View File

@@ -0,0 +1,49 @@
package auth
import (
"context"
"github.com/google/uuid"
"base/internal/domain/auth"
"base/internal/dto"
)
func (s *service) GetUserInfo(ctx context.Context, userID uuid.UUID) (*dto.UserInfoResponse, error) {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
var profileID *uuid.UUID
prof, err := s.profileRepo.FindByUserID(ctx, userID)
if err == nil && prof != nil {
profileID = &prof.ID
}
return &dto.UserInfoResponse{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
PhoneNumber: user.PhoneNumber,
EmailVerified: user.EmailVerified,
Status: userStatusToString(user.Status),
ProfileID: profileID,
}, nil
}
func userStatusToString(s auth.UserStatus) string {
switch s {
case auth.UserStatusActive:
return "active"
case auth.UserStatusInactive:
return "inactive"
case auth.UserStatusPending:
return "pending"
case auth.UserStatusDeleted:
return "deleted"
default:
return "unknown"
}
}

View File

@@ -0,0 +1,86 @@
package auth
import (
"base/pkg/jwt"
"context"
"fmt"
"time"
"github.com/google/uuid"
"base/internal/domain/auth"
"base/internal/dto"
)
func (s *service) GetOAuthRedirectURL(ctx context.Context, request dto.OAuthRedirectURLRequest) (string, error) {
provider := s.oauthService.Client(request.Provider)
state := uuid.New().String()
redirectURL := provider.GetConsentAuthUrl(ctx, state)
return redirectURL, nil
}
func (s *service) OAuthCallback(ctx context.Context, request dto.OAuthCallbackRequest) (*dto.OAuthCallbackResponse, error) {
oauthProvider := s.oauthService.Client(request.Provider)
token, err := oauthProvider.ExchangeCodeWithToken(ctx, request.Code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
userInfo, err := oauthProvider.GetUserInfo(ctx, token.AccessToken, token.RefreshToken)
if err != nil {
return nil, fmt.Errorf("failed to get user info: %w", err)
}
now := time.Now()
accessExpiry := now.Add(time.Duration(token.ExpiresIn) * time.Second)
refreshExpiry := now.Add(7 * 24 * time.Hour)
user := &auth.User{
ID: uuid.New(), // Will be set by repository if user exists
Email: userInfo.Email(),
FirstName: userInfo.FirstName(),
LastName: userInfo.LastName(),
Status: auth.UserStatusActive,
EmailVerified: true, // OAuth providers verify email
CreatedAt: now,
UpdatedAt: now,
}
account := &auth.Account{
ID: uuid.New(),
Provider: request.Provider,
AccessToken: &token.AccessToken,
RefreshToken: &token.RefreshToken,
AccessTokenExpiry: &accessExpiry,
RefreshTokenExpiry: &refreshExpiry,
CreatedAt: now,
UpdatedAt: now,
}
isNewUser, err := s.userRepo.UpsertWithAccount(ctx, userInfo.Email(), user, account)
if err != nil {
s.logger.Error().Err(err).Msg("failed to upsert user and account")
return nil, fmt.Errorf("failed to upsert user and account: %w", err)
}
tokens, genErr := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if genErr != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", genErr)
}
s.logger.Info().
Str("user_id", user.ID.String()).
Str("email", user.Email).
Bool("is_new_user", isNewUser).
Str("provider", request.Provider.String()).
Msg("OAuth callback completed successfully")
return &dto.OAuthCallbackResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
IsNewUser: isNewUser,
}, nil
}

View File

@@ -0,0 +1,210 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"base/internal/domain/auth"
"base/internal/dto"
"base/internal/pkg/oauth"
"base/pkg/jwt"
)
func (s *service) RegisterWithCredentials(ctx context.Context, request dto.RegisterRequest) (*dto.TokenResponse, error) {
// Check if user already exists
existingUser, err := s.userRepo.FindByEmail(ctx, request.Email)
if err == nil && existingUser != nil {
return nil, ErrUserAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if err != nil {
s.logger.Error().Err(err).Msg("failed to hash password")
return nil, fmt.Errorf("failed to hash password: %w", err)
}
hashedPasswordStr := string(hashedPassword)
id, genErr := uuid.NewV7()
if genErr != nil {
return nil, genErr
}
// Create user and account within a transaction
// If any operation fails, all changes are rolled back
user := &auth.User{
ID: id,
Email: request.Email,
FirstName: request.FirstName,
LastName: request.LastName,
PhoneNumber: request.PhoneNumber,
Status: auth.UserStatusPending,
EmailVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
account := &auth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Credentials,
Password: &hashedPasswordStr,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err = s.userRepo.CreateWithAccount(ctx, user, account); err != nil {
s.logger.Error().Err(err).Msg("failed to create user and account")
return nil, fmt.Errorf("failed to create user and account: %w", err)
}
// Generate tokens
tokens, genTokenErr := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if genTokenErr != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", genTokenErr)
}
// Update account with tokens
account.AccessToken = &tokens.AccessToken
account.RefreshToken = &tokens.RefreshToken
now := time.Now()
accessExpiry := now.Add(24 * time.Hour)
refreshExpiry := now.Add(7 * 24 * time.Hour)
account.AccessTokenExpiry = &accessExpiry
account.RefreshTokenExpiry = &refreshExpiry
if err = s.accountRepo.Update(ctx, account); err != nil {
s.logger.Error().Err(err).Msg("failed to update account with tokens")
// Don't fail the registration, tokens are already generated
}
// Profile is created when user calls setup-profile
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}
func (s *service) LoginWithCredentials(ctx context.Context, email, password string) (*dto.TokenResponse, error) {
// Find user by email with accounts
user, err := s.userRepo.FindByEmail(ctx, email, auth.WithAccounts())
if err != nil {
return nil, ErrInvalidCredentials
}
// Check user status
if user.Status == auth.UserStatusDeleted {
return nil, ErrInvalidCredentials
}
// Find credentials account
var credentialsAccount *auth.Account
for _, acc := range user.Accounts {
if acc.Provider == oauth.Credentials {
credentialsAccount = &acc
break
}
}
if credentialsAccount == nil || credentialsAccount.Password == nil {
return nil, ErrInvalidCredentials
}
// Verify password
if err = bcrypt.CompareHashAndPassword([]byte(*credentialsAccount.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// Generate tokens
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
// Update account with tokens
credentialsAccount.AccessToken = &tokens.AccessToken
credentialsAccount.RefreshToken = &tokens.RefreshToken
now := time.Now()
accessExpiry := now.Add(24 * time.Hour)
refreshExpiry := now.Add(7 * 24 * time.Hour)
credentialsAccount.AccessTokenExpiry = &accessExpiry
credentialsAccount.RefreshTokenExpiry = &refreshExpiry
if err := s.accountRepo.Update(ctx, credentialsAccount); err != nil {
s.logger.Error().Err(err).Msg("failed to update account with tokens")
// Don't fail the login, tokens are already generated
}
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}
func (s *service) RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error) {
claims, err := s.jwtService.VerifyToken(ctx, refreshToken)
if err != nil {
return nil, ErrInvalidRefreshToken
}
userID, err := uuid.Parse(claims.Sub)
if err != nil {
return nil, ErrInvalidRefreshToken
}
// Find user
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
accounts, err := s.accountRepo.FindByUserID(ctx, userID)
if err != nil {
return nil, ErrAccountNotFound
}
var matchingAccount *auth.Account
for _, acc := range accounts {
if acc.RefreshToken != nil && *acc.RefreshToken == refreshToken {
// Check if refresh token is expired
if acc.RefreshTokenExpiry != nil && acc.RefreshTokenExpiry.Before(time.Now()) {
return nil, ErrInvalidRefreshToken
}
matchingAccount = acc
break
}
}
if matchingAccount == nil {
return nil, ErrInvalidRefreshToken
}
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
matchingAccount.AccessToken = &tokens.AccessToken
matchingAccount.RefreshToken = &tokens.RefreshToken
now := time.Now()
accessExpiry := now.Add(24 * time.Hour)
refreshExpiry := now.Add(7 * 24 * time.Hour)
matchingAccount.AccessTokenExpiry = &accessExpiry
matchingAccount.RefreshTokenExpiry = &refreshExpiry
if err = s.accountRepo.Update(ctx, matchingAccount); err != nil {
s.logger.Error().Err(err).Msg("failed to update account with tokens")
// Don't fail the refresh, tokens are already generated
}
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}

View File

@@ -0,0 +1,131 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"base/internal/domain/auth"
"base/internal/dto"
"base/internal/pkg/oauth"
"base/pkg/email"
"base/pkg/hashids"
"base/pkg/jwt"
)
// SendResetPasswordEmail sends a password reset email
func (s *service) SendResetPasswordEmail(ctx context.Context, request dto.SendResetPasswordEmailRequest) error {
user, err := s.userRepo.FindByEmail(ctx, request.Email)
if err != nil {
// Don't reveal if user exists or not for security
return err
}
// Generate reset code
code := hashids.GenerateCode(int64(user.ID.Time()))
key := fmt.Sprintf("reset_password:%s", user.ID.String())
// Store code in cache (15 minutes TTL)
if storeErr := s.resetPasswordStore.Set(ctx, key, code, 15*time.Minute); storeErr != nil {
return fmt.Errorf("failed to store reset password code: %w", storeErr)
}
// Send email
emailData := map[string]interface{}{
"Code": code,
"Name": user.FirstName,
}
emailMsg := email.Request{
To: user.Email,
Subject: "Reset Your Password",
Template: email.TemplateData{
EmailTemplateName: email.TemplatePasswordReset,
Data: emailData,
},
}
if _, sendEmailErr := s.emailService.Send(ctx, emailMsg); sendEmailErr != nil {
return fmt.Errorf("failed to send reset password email: %w", sendEmailErr)
}
return nil
}
// ResetPassword resets a user's password with the provided code
func (s *service) ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (*dto.TokenResponse, error) {
user, err := s.userRepo.FindByEmail(ctx, request.Email, auth.WithAccounts())
if err != nil {
return nil, ErrUserNotFound
}
// Get code from cache
key := fmt.Sprintf("reset_password:%s", user.ID.String())
storedCode, found, getErr := s.resetPasswordStore.Get(ctx, key)
if getErr != nil || !found {
return nil, ErrInvalidVerificationCode
}
if storedCode != request.Code {
return nil, ErrInvalidVerificationCode
}
// Find credentials account
var credentialsAccount *auth.Account
for _, acc := range user.Accounts {
if acc.Provider == oauth.Credentials {
credentialsAccount = &acc
break
}
}
// Hash new password
hashedPassword, genHashPassErr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
if genHashPassErr != nil {
return nil, fmt.Errorf("failed to hash password: %w", genHashPassErr)
}
hashedPasswordStr := string(hashedPassword)
if credentialsAccount != nil {
// Update existing account
credentialsAccount.Password = &hashedPasswordStr
credentialsAccount.UpdatedAt = time.Now()
if err := s.accountRepo.Update(ctx, credentialsAccount); err != nil {
return nil, fmt.Errorf("failed to update account: %w", err)
}
} else {
// Create new credentials account
account := &auth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Credentials,
Password: &hashedPasswordStr,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, fmt.Errorf("failed to create account: %w", err)
}
}
// Delete reset code from cache
_ = s.resetPasswordStore.Delete(ctx, key)
// Generate tokens
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err)
}
return &dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
}, nil
}

View File

@@ -0,0 +1,87 @@
package auth
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.uber.org/fx"
"base/config"
"base/internal/domain/auth"
"base/internal/domain/profile"
"base/internal/dto"
"base/internal/pkg/oauth"
"base/pkg/email"
"base/pkg/jwt"
"base/pkg/store"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidVerificationCode = errors.New("invalid verification code")
ErrEmailAlreadyVerified = errors.New("email already verified")
ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrAccountNotFound = errors.New("account not found")
ErrProfileNotFound = errors.New("profile not found")
ErrProfileAlreadyExists = errors.New("profile already exists")
ErrHandleAlreadyTaken = errors.New("handle already taken")
)
type Service interface {
RegisterWithCredentials(ctx context.Context, request dto.RegisterRequest) (*dto.TokenResponse, error)
LoginWithCredentials(ctx context.Context, email, password string) (*dto.TokenResponse, error)
RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error)
GetOAuthRedirectURL(ctx context.Context, request dto.OAuthRedirectURLRequest) (string, error)
OAuthCallback(ctx context.Context, request dto.OAuthCallbackRequest) (*dto.OAuthCallbackResponse, error)
SendVerificationEmail(ctx context.Context, request dto.SendVerificationEmailRequest) error
VerifyAccount(ctx context.Context, request dto.VerifyAccountRequest) error
SendResetPasswordEmail(ctx context.Context, request dto.SendResetPasswordEmailRequest) error
ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (*dto.TokenResponse, error)
SetupProfile(ctx context.Context, userID uuid.UUID, request dto.SetupProfileRequest) error
GetUserInfo(ctx context.Context, userID uuid.UUID) (*dto.UserInfoResponse, error)
}
type service struct {
logger zerolog.Logger
config *config.AppConfig
userRepo auth.UserRepository
accountRepo auth.AccountRepository
profileRepo profile.Repository
emailService email.Email
oauthService oauth.OAuth
verificationStore store.Store[string]
resetPasswordStore store.Store[string]
jwtService jwt.TokenService
}
type Param struct {
Logger zerolog.Logger
Config *config.AppConfig
UserRepo auth.UserRepository
AccountRepo auth.AccountRepository
ProfileRepo profile.Repository
EmailService email.Email
OAuthService oauth.OAuth
VerificationStore store.Store[string] `name:"verification_store"`
ResetPasswordStore store.Store[string] `name:"reset_password_store"`
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
config: param.Config,
userRepo: param.UserRepo,
accountRepo: param.AccountRepo,
profileRepo: param.ProfileRepo,
emailService: param.EmailService,
oauthService: param.OAuthService,
verificationStore: param.VerificationStore,
resetPasswordStore: param.ResetPasswordStore,
jwtService: jwt.New(param.Config.JWT.Secret, param.Config.JWT.AccessTokenExpiration, param.Config.JWT.RefreshTokenExpiration),
}
}

View File

@@ -0,0 +1,76 @@
package auth
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"base/internal/domain/profile"
"base/internal/dto"
)
var slugRe = regexp.MustCompile(`[^a-z0-9-]+`)
func (s *service) SetupProfile(ctx context.Context, userID uuid.UUID, req dto.SetupProfileRequest) error {
existingProfile, _ := s.profileRepo.FindByUserID(ctx, userID)
if existingProfile != nil {
return ErrProfileAlreadyExists
}
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil {
return ErrUserNotFound
}
handle := generateHandle(user.FirstName, user.LastName, userID)
if req.Handle != "" {
handle = req.Handle
}
other, _ := s.profileRepo.FindByHandle(ctx, handle)
if other != nil {
return ErrHandleAlreadyTaken
}
newProfile := &profile.Profile{
ID: uuid.New(),
UserID: &userID,
Handle: handle,
Hero: profile.Hero{
FirstName: user.FirstName,
LastName: user.LastName,
ShortDescription: req.ShortDescription,
},
Contact: profile.Contact{
Email: user.Email,
Phone: user.PhoneNumber,
},
PageSetting: profile.PageSetting{
VisibilityLevel: "public",
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
newProfile.Hero.Role = &profile.Role{ID: req.RoleID}
if req.RoleLevel != "" && newProfile.Hero.Role != nil {
newProfile.Hero.Role.Level = req.RoleLevel
} else if req.RoleLevel != "" {
newProfile.Hero.Role = &profile.Role{Level: req.RoleLevel}
}
return s.profileRepo.Create(ctx, newProfile)
}
func generateHandle(firstName, lastName string, userID uuid.UUID) string {
slug := slugRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(firstName+"-"+lastName)), "-")
slug = strings.Trim(slug, "-")
if slug == "" {
slug = "user"
}
return fmt.Sprintf("%s-%s", slug, userID.String()[:8])
}

View File

@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"fmt"
"math/big"
)
func generateOTP() (string, error) {
newInt := big.NewInt(10000) // 0 .. 999999
n, err := rand.Int(rand.Reader, newInt)
if err != nil {
return "", err
}
return fmt.Sprintf("%04d", n.Int64()), err
}

View File

@@ -0,0 +1,62 @@
package auth
import (
"context"
"fmt"
"time"
"base/internal/domain/auth"
"base/internal/dto"
"base/pkg/email"
)
func (s *service) SendVerificationEmail(ctx context.Context, request dto.SendVerificationEmailRequest) error {
emailMsg := email.Request{
To: request.Email,
Subject: "Verify Your Email",
Template: email.TemplateData{EmailTemplateName: email.TemplateEmailVerification},
}
if _, err := s.emailService.Send(ctx, emailMsg); err != nil {
s.logger.Error().Err(err).Msg("failed to send verification email")
return fmt.Errorf("failed to send verification email: %w", err)
}
return nil
}
func (s *service) VerifyAccount(ctx context.Context, request dto.VerifyAccountRequest) error {
user, err := s.userRepo.FindByEmail(ctx, request.Email)
if err != nil {
return ErrUserNotFound
}
if user.EmailVerified {
return ErrEmailAlreadyVerified
}
// Get code from cache
key := fmt.Sprintf("verification:%s", request.Email)
storedCode, found, err := s.verificationStore.Get(ctx, key)
if err != nil || !found {
return ErrInvalidVerificationCode
}
if storedCode != request.Code {
return ErrInvalidVerificationCode
}
user.EmailVerified = true
user.Status = auth.UserStatusActive
user.UpdatedAt = time.Now()
if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
// Delete verification code from cache
_ = s.verificationStore.Delete(ctx, key)
return nil
}

View File

@@ -0,0 +1,272 @@
package discovery
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.uber.org/fx"
"golang.org/x/sync/errgroup"
domainAsset "base/internal/domain/asset"
domainProfile "base/internal/domain/profile"
"base/internal/dto"
)
type Service interface {
GetDiscoveryOverview(ctx context.Context) (*dto.OverviewFetchedResponse, error)
}
type service struct {
logger zerolog.Logger
profileRepo domainProfile.Repository
assetRepo domainAsset.AssetRepository
categoryRepo domainAsset.CategoryRepository
}
// Param holds dependencies for the discovery overview service.
type Param struct {
Logger zerolog.Logger
ProfileRepo domainProfile.Repository
AssetRepo domainAsset.AssetRepository
CategoryRepo domainAsset.CategoryRepository
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
profileRepo: param.ProfileRepo,
assetRepo: param.AssetRepo,
categoryRepo: param.CategoryRepo,
}
}
func (s *service) GetDiscoveryOverview(ctx context.Context) (*dto.OverviewFetchedResponse, error) {
resp := &struct {
domainAssets []*domainAsset.Asset
recentlyJoined []*domainProfile.Profile
totalProfiles int
totalAssets int
}{}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
assets, err := s.assetRepo.FindLatest(gCtx, 6, 0)
if err != nil {
return err
}
resp.domainAssets = assets
return nil
})
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
})
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(ctx, resp.domainAssets)
flatProfiles := ToFlatProfiles(resp.recentlyJoined)
return &dto.OverviewFetchedResponse{
Message: "Overview fetched successfully",
Data: dto.OverviewFetchedDataDTO{
Assets: assets,
RecentlyJoined: flatProfiles,
Analytics: dto.AnalyticsDTO{
TotalAssets: resp.totalAssets,
TotalProfiles: resp.totalProfiles,
},
},
}, nil
}
func (s *service) toOverviewAssets(ctx context.Context, assets []*domainAsset.Asset) []dto.OverviewAssetDTO {
out := make([]dto.OverviewAssetDTO, len(assets))
for i, a := range assets {
out[i] = s.toOverviewAsset(ctx, a)
}
return out
}
func (s *service) toOverviewAsset(ctx context.Context, a *domainAsset.Asset) 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,
}
}
ownerID := a.ProfileID.String()
if p, err := s.profileRepo.FindByID(ctx, a.ProfileID); err == nil && p.UserID != nil {
ownerID = p.UserID.String()
}
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,
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 ""
}
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// 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,238 @@
package landing
import (
"context"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.uber.org/fx"
"golang.org/x/sync/errgroup"
domainAsset "base/internal/domain/asset"
domainProfile "base/internal/domain/profile"
"base/internal/dto"
"base/pkg/cache"
)
const (
defaultAssetsPerCategory = 6
defaultSpecialistsLimit = 6
defaultRolesLimit = 20
landingCacheKey = "landing:page"
landingCacheTTL = 5 * time.Minute
)
type Service interface {
GetLanding(ctx context.Context) (*dto.Landing, error)
}
type service struct {
logger zerolog.Logger
cache cache.Cache[dto.Landing]
categoryRepo domainAsset.CategoryRepository
assetRepo domainAsset.AssetRepository
profileRepo domainProfile.Repository
roleRepo domainProfile.RoleRepository
}
type Param struct {
Logger zerolog.Logger
Cache cache.Cache[dto.Landing]
CategoryRepo domainAsset.CategoryRepository
AssetRepo domainAsset.AssetRepository
ProfileRepo domainProfile.Repository
RoleRepo domainProfile.RoleRepository
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
cache: param.Cache,
categoryRepo: param.CategoryRepo,
assetRepo: param.AssetRepo,
profileRepo: param.ProfileRepo,
roleRepo: param.RoleRepo,
}
}
func (s *service) GetLanding(ctx context.Context) (*dto.Landing, error) {
result, err := s.cache.WithCache(ctx, landingCacheKey, s.fetchLanding, landingCacheTTL)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *service) fetchLanding(ctx context.Context) (dto.Landing, error) {
data := &dto.LandingPageData{
Categories: []dto.CategoryDTO{},
SpecialistRoles: []dto.ProfileRole{},
Assets: []dto.LandingAssetData{},
Specialists: []dto.Specialist{},
Blogs: []dto.Blog{},
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
categories, err := s.categoryRepo.FindAll(gCtx)
if err != nil {
return err
}
data.Categories = make([]dto.CategoryDTO, len(categories))
for i, c := range categories {
data.Categories[i] = dto.CategoryDTO{
ID: c.ID,
Name: c.Name,
Icon: c.Icon,
Color: c.Color,
CardType: c.CardType,
Featured: c.Featured,
Description: c.Description,
}
}
return nil
})
g.Go(func() error {
domainRoles, err := s.roleRepo.List(gCtx, defaultRolesLimit, 0)
if err != nil {
return err
}
data.SpecialistRoles = make([]dto.ProfileRole, len(domainRoles))
for i, r := range domainRoles {
data.SpecialistRoles[i] = dto.ProfileRole{
Id: r.ID.String(),
Title: r.Title,
}
}
return nil
})
g.Go(func() error {
profiles, _, err := s.profileRepo.FindAll(
gCtx,
domainProfile.Filter{
Page: 1,
PageSize: defaultSpecialistsLimit,
SortedBy: "created_at",
Ascending: false,
})
if err != nil {
return err
}
data.Specialists = make([]dto.Specialist, len(profiles))
for i, p := range profiles {
data.Specialists[i] = dto.Specialist{
Id: p.ID.String(),
Handle: p.Handle,
Avatar: p.About.ProfilePicture,
}
}
return nil
})
g.Go(func() error {
categories, err := s.categoryRepo.FindAll(gCtx)
if err != nil {
return err
}
assetsByCat := make([]dto.LandingAssetData, len(categories))
mu := &sync.Mutex{}
eg, egCtx := errgroup.WithContext(gCtx)
for index, category := range categories {
i, cat := index, category
eg.Go(func() error {
assets, findLatestAssetErr := s.assetRepo.FindLatestByCategory(egCtx, cat.ID, defaultAssetsPerCategory)
if findLatestAssetErr != nil {
return findLatestAssetErr
}
assetResp := make([]dto.AssetResponse, len(assets))
for j, a := range assets {
assetResp[j] = *s.toAssetResponse(a)
}
mu.Lock()
assetsByCat[i] = dto.LandingAssetData{
AssetCategory: dto.AssetCategory{
Id: cat.ID.String(),
Title: cat.Name,
Icon: cat.Icon,
},
Assets: assetResp,
}
mu.Unlock()
return nil
})
}
if err = eg.Wait(); err != nil {
return err
}
data.Assets = assetsByCat
return nil
})
if err := g.Wait(); err != nil {
return dto.Landing{}, err
}
return dto.Landing{
Message: "Landing page fetched successfully",
Data: *data,
}, nil
}
func (s *service) toAssetResponse(a *domainAsset.Asset) *dto.AssetResponse {
coverImage := ""
for _, art := range a.AssetArtifacts {
if strings.Contains(strings.ToLower(art.Type), "image") {
coverImage = art.DownloadURL
break
}
}
resp := &dto.AssetResponse{
ID: a.ID,
ProfileID: a.ProfileID,
AssetCategoryID: a.AssetCategoryID,
Title: a.Title,
Description: a.Description,
Link: a.Link,
CoverImage: coverImage,
Status: int(a.Status),
CreatedAt: formatTime(a.CreatedAt),
UpdatedAt: formatTime(a.UpdatedAt),
}
if a.AssetCategory.ID != uuid.Nil {
resp.Category = 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 resp
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}

View File

@@ -0,0 +1,28 @@
package application
import (
"go.uber.org/fx"
"base/internal/application/asset"
"base/internal/application/auth"
"base/internal/application/discovery"
"base/internal/application/landing"
"base/internal/application/profile"
"base/internal/application/profilerole"
"base/internal/application/skill"
"base/internal/application/specialist"
)
var Module = fx.Module(
"application",
fx.Provide(
auth.New,
profile.New,
asset.New,
discovery.New,
landing.New,
specialist.New,
profilerole.New,
skill.New,
),
)

View File

@@ -0,0 +1,72 @@
package profile
import (
"base/internal/domain/profile"
"base/internal/dto"
)
// DomainProfileToProfileResponse converts a domain profile to ProfileResponse.
// Used by specialist overview and other consumers that have a domain profile.
func DomainProfileToProfileResponse(p *profile.Profile) *dto.ProfileResponse {
if p == nil {
return nil
}
roleLevel := ""
if p.Hero.Role != nil {
roleLevel = p.Hero.Role.Level
}
resp := &dto.ProfileResponse{
ID: p.ID,
Handle: p.Handle,
PageSectionOrder: p.PageSectionOrder,
Hero: dto.HeroDTO{
RoleLevel: roleLevel,
FirstName: p.Hero.FirstName,
LastName: p.Hero.LastName,
Company: p.Hero.Company,
ShortDescription: p.Hero.ShortDescription,
ResumeLink: p.Hero.ResumeLink,
CTAEnabled: p.Hero.CTAEnabled,
Avatar: p.Hero.Avatar,
},
About: dto.AboutDTO{
ProfilePicture: p.About.ProfilePicture,
About: p.About.About,
},
Contact: dto.ContactDTO{
Email: p.Contact.Email,
Phone: p.Contact.Phone,
},
PageSetting: dto.PageSettingDTO{
VisibilityLevel: p.PageSetting.VisibilityLevel,
},
}
if p.Hero.Role != nil {
resp.Hero.RoleID = &p.Hero.Role.ID
}
for _, skill := range p.Skills {
resp.Skills = append(resp.Skills, dto.SkillDTO{
SkillName: skill.SkillName,
Level: skill.Level,
})
}
for _, achievement := range p.About.Achievements {
resp.About.Achievements = append(resp.About.Achievements, dto.AchievementDTO{
Title: achievement.Title,
Value: achievement.Value,
Enabled: achievement.Enabled,
})
}
for _, sl := range p.Contact.SocialLinks {
resp.Contact.SocialLinks = append(resp.Contact.SocialLinks, dto.SocialLinkDTO{
LinkType: sl.LinkType,
Link: sl.Link,
})
}
return resp
}

View File

@@ -0,0 +1,315 @@
package profile
import (
"context"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/samber/lo"
"go.uber.org/fx"
"base/internal/domain/profile"
"base/internal/dto"
)
type Service interface {
Create(ctx context.Context, req dto.CreateProfileRequest) (*dto.ProfileResponse, error)
Update(ctx context.Context, req dto.UpdateProfileRequest) (*dto.ProfileResponse, error)
GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileResponse, error)
GetByHandle(ctx context.Context, handle string) (*dto.ProfileResponse, error)
List(ctx context.Context, req dto.ListProfilesRequest) (*dto.ListProfilesResponse, error)
Delete(ctx context.Context, id uuid.UUID) error
}
type service struct {
logger zerolog.Logger
profileRepo profile.Repository
}
type Param struct {
Logger zerolog.Logger
ProfileRepo profile.Repository
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
profileRepo: param.ProfileRepo,
}
}
func (s *service) Create(ctx context.Context, req dto.CreateProfileRequest) (*dto.ProfileResponse, error) {
domainProfile := &profile.Profile{
ID: uuid.New(),
Handle: req.Handle,
PageSectionOrder: req.PageSectionOrder,
Hero: profile.Hero{
Role: &profile.Role{
ID: lo.FromPtr(req.Hero.RoleID),
Level: req.Hero.RoleLevel,
},
FirstName: req.Hero.FirstName,
LastName: req.Hero.LastName,
Company: req.Hero.Company,
ShortDescription: req.Hero.ShortDescription,
ResumeLink: req.Hero.ResumeLink,
CTAEnabled: req.Hero.CTAEnabled,
Avatar: req.Hero.Avatar,
},
About: profile.About{
ProfilePicture: req.About.ProfilePicture,
About: req.About.About,
},
Contact: profile.Contact{
Email: req.Contact.Email,
Phone: req.Contact.Phone,
},
PageSetting: profile.PageSetting{
VisibilityLevel: req.PageSetting.VisibilityLevel,
},
}
if req.Hero.RoleID != nil {
domainProfile.Hero.Role = &profile.Role{
ID: *req.Hero.RoleID,
}
}
for _, skill := range req.Skills {
domainProfile.Skills = append(domainProfile.Skills, profile.Skill{
SkillName: skill.SkillName,
Level: skill.Level,
})
}
for _, achievement := range req.About.Achievements {
domainProfile.About.Achievements = append(domainProfile.About.Achievements, profile.Achievement{
Title: achievement.Title,
Value: achievement.Value,
Enabled: achievement.Enabled,
})
}
for _, socialLink := range req.Contact.SocialLinks {
domainProfile.Contact.SocialLinks = append(domainProfile.Contact.SocialLinks, profile.SocialLink{
LinkType: socialLink.LinkType,
Link: socialLink.Link,
})
}
if err := s.profileRepo.Create(ctx, domainProfile); err != nil {
return nil, err
}
return s.toProfileResponse(domainProfile), nil
}
func (s *service) Update(ctx context.Context, req dto.UpdateProfileRequest) (*dto.ProfileResponse, error) {
id, err := uuid.Parse(req.ID)
if err != nil {
return nil, profile.ErrProfileNotFound
}
// First, get the existing profile to ensure it exists
existingProfile, err := s.profileRepo.FindByID(ctx, id)
if err != nil {
return nil, profile.ErrProfileNotFound
}
domainProfile := &profile.Profile{
ID: id,
Handle: req.Handle,
PageSectionOrder: req.PageSectionOrder,
Hero: profile.Hero{
FirstName: req.Hero.FirstName,
Role: &profile.Role{
ID: lo.FromPtr(req.Hero.RoleID),
Level: req.Hero.RoleLevel,
},
LastName: req.Hero.LastName,
Company: req.Hero.Company,
ShortDescription: req.Hero.ShortDescription,
ResumeLink: req.Hero.ResumeLink,
CTAEnabled: req.Hero.CTAEnabled,
Avatar: req.Hero.Avatar,
},
About: profile.About{
ProfilePicture: req.About.ProfilePicture,
About: req.About.About,
},
Contact: profile.Contact{
Email: req.Contact.Email,
Phone: req.Contact.Phone,
},
PageSetting: profile.PageSetting{
VisibilityLevel: req.PageSetting.VisibilityLevel,
},
}
if req.Hero.RoleID != nil {
domainProfile.Hero.Role = &profile.Role{
ID: *req.Hero.RoleID,
}
} else if existingProfile != nil && existingProfile.Hero.Role != nil {
domainProfile.Hero.Role = existingProfile.Hero.Role
}
if req.Hero.RoleLevel == "" && existingProfile != nil {
domainProfile.Hero.Role.Level = existingProfile.Hero.Role.Level
}
for _, skill := range req.Skills {
domainProfile.Skills = append(domainProfile.Skills, profile.Skill{
SkillName: skill.SkillName,
Level: skill.Level,
})
}
for _, achievement := range req.About.Achievements {
domainProfile.About.Achievements = append(domainProfile.About.Achievements, profile.Achievement{
Title: achievement.Title,
Value: achievement.Value,
Enabled: achievement.Enabled,
})
}
for _, socialLink := range req.Contact.SocialLinks {
domainProfile.Contact.SocialLinks = append(domainProfile.Contact.SocialLinks, profile.SocialLink{
LinkType: socialLink.LinkType,
Link: socialLink.Link,
})
}
if err := s.profileRepo.Update(ctx, domainProfile); err != nil {
return nil, err
}
return s.toProfileResponse(domainProfile), nil
}
func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileResponse, error) {
profileData, err := s.profileRepo.FindByID(ctx, id)
if err != nil {
return nil, profile.ErrProfileNotFound
}
return s.toProfileResponse(profileData), nil
}
func (s *service) GetByHandle(ctx context.Context, handle string) (*dto.ProfileResponse, error) {
profileData, err := s.profileRepo.FindByHandle(ctx, handle)
if err != nil {
return nil, profile.ErrProfileNotFound
}
return s.toProfileResponse(profileData), nil
}
func (s *service) List(ctx context.Context, req dto.ListProfilesRequest) (*dto.ListProfilesResponse, error) {
filter := profile.Filter{
FirstName: req.FirstName,
LastName: req.LastName,
Company: req.Company,
SkillName: req.SkillName,
Page: req.Page,
PageSize: req.PageSize,
SortedBy: req.SortedBy,
Ascending: req.Ascending,
}
if req.Page == 0 {
filter.Page = 1
}
if req.PageSize == 0 {
filter.PageSize = 10
}
if req.RoleID != nil {
filter.RoleID = *req.RoleID
}
profiles, total, err := s.profileRepo.FindAll(ctx, filter)
if err != nil {
return nil, err
}
response := &dto.ListProfilesResponse{
Profiles: make([]dto.ProfileResponse, len(profiles)),
Total: total,
Page: filter.Page,
PageSize: filter.PageSize,
}
for i, p := range profiles {
response.Profiles[i] = *s.toProfileResponse(p)
}
return response, nil
}
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
// Get profile first to ensure it exists
profileData, err := s.profileRepo.FindByID(ctx, id)
if err != nil {
return profile.ErrProfileNotFound
}
return s.profileRepo.Delete(ctx, profileData)
}
func (s *service) toProfileResponse(p *profile.Profile) *dto.ProfileResponse {
resp := &dto.ProfileResponse{
ID: p.ID,
Handle: p.Handle,
PageSectionOrder: p.PageSectionOrder,
Hero: dto.HeroDTO{
FirstName: p.Hero.FirstName,
LastName: p.Hero.LastName,
Company: p.Hero.Company,
ShortDescription: p.Hero.ShortDescription,
ResumeLink: p.Hero.ResumeLink,
CTAEnabled: p.Hero.CTAEnabled,
Avatar: p.Hero.Avatar,
},
About: dto.AboutDTO{
ProfilePicture: p.About.ProfilePicture,
About: p.About.About,
},
Contact: dto.ContactDTO{
Email: p.Contact.Email,
Phone: p.Contact.Phone,
},
PageSetting: dto.PageSettingDTO{
VisibilityLevel: p.PageSetting.VisibilityLevel,
},
}
if p.Hero.Role != nil {
resp.Hero.RoleID = &p.Hero.Role.ID
}
for _, skill := range p.Skills {
resp.Skills = append(resp.Skills, dto.SkillDTO{
SkillName: skill.SkillName,
Level: skill.Level,
})
}
for _, achievement := range p.About.Achievements {
resp.About.Achievements = append(resp.About.Achievements, dto.AchievementDTO{
Title: achievement.Title,
Value: achievement.Value,
Enabled: achievement.Enabled,
})
}
for _, socialLink := range p.Contact.SocialLinks {
resp.Contact.SocialLinks = append(resp.Contact.SocialLinks, dto.SocialLinkDTO{
LinkType: socialLink.LinkType,
Link: socialLink.Link,
})
}
return resp
}

View File

@@ -0,0 +1,121 @@
package profilerole
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.uber.org/fx"
domainProfile "base/internal/domain/profile"
"base/internal/dto"
)
var ErrNotFound = domainProfile.ErrRoleNotFound
type Service interface {
List(ctx context.Context) ([]dto.ProfileRole, error)
ListWithLimit(ctx context.Context, limit, offset int) ([]dto.ProfileRole, error)
Create(ctx context.Context, req dto.CreateProfileRoleRequest) (*dto.ProfileRole, error)
GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileRole, error)
Update(ctx context.Context, req dto.UpdateProfileRoleRequest) (*dto.ProfileRole, error)
Delete(ctx context.Context, id uuid.UUID) error
}
type service struct {
logger zerolog.Logger
repo domainProfile.RoleRepository
}
type Param struct {
Logger zerolog.Logger
Repo domainProfile.RoleRepository
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
repo: param.Repo,
}
}
func (s *service) List(ctx context.Context) ([]dto.ProfileRole, error) {
roles, err := s.repo.FindAll(ctx)
if err != nil {
return nil, err
}
return toDTOs(roles), nil
}
func (s *service) ListWithLimit(ctx context.Context, limit, offset int) ([]dto.ProfileRole, error) {
roles, err := s.repo.List(ctx, limit, offset)
if err != nil {
return nil, err
}
return toDTOs(roles), nil
}
func (s *service) Create(ctx context.Context, req dto.CreateProfileRoleRequest) (*dto.ProfileRole, error) {
role := &domainProfile.Role{
ID: uuid.New(),
Title: req.Title,
}
if err := s.repo.Create(ctx, role); err != nil {
return nil, err
}
return toDTO(role), nil
}
func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileRole, error) {
role, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return toDTO(role), nil
}
func (s *service) Update(ctx context.Context, req dto.UpdateProfileRoleRequest) (*dto.ProfileRole, error) {
id, err := uuid.Parse(req.ID)
if err != nil {
return nil, ErrNotFound
}
role, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, ErrNotFound
}
role.Title = req.Title
if err := s.repo.Update(ctx, role); err != nil {
return nil, err
}
return toDTO(role), nil
}
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
role, err := s.repo.FindByID(ctx, id)
if err != nil {
return ErrNotFound
}
return s.repo.Delete(ctx, role.ID)
}
func toDTO(r *domainProfile.Role) *dto.ProfileRole {
return &dto.ProfileRole{
Id: r.ID.String(),
Title: r.Title,
}
}
func toDTOs(roles []*domainProfile.Role) []dto.ProfileRole {
out := make([]dto.ProfileRole, len(roles))
for i, r := range roles {
out[i] = *toDTO(r)
}
return out
}

View File

@@ -0,0 +1,46 @@
package skill
import (
"context"
"github.com/rs/zerolog"
"go.uber.org/fx"
domainSkill "base/internal/domain/skill"
"base/internal/dto"
)
type Service interface {
List(ctx context.Context) ([]dto.Skill, error)
}
type service struct {
logger zerolog.Logger
repo domainSkill.Repository
}
type Param struct {
Logger zerolog.Logger
Repo domainSkill.Repository
fx.In
}
func New(param Param) Service {
return &service{
logger: param.Logger,
repo: param.Repo,
}
}
func (s *service) List(ctx context.Context) ([]dto.Skill, error) {
skills, err := s.repo.FindAll(ctx)
if err != nil {
return nil, err
}
out := make([]dto.Skill, len(skills))
for i, sk := range skills {
out[i] = dto.Skill{ID: sk.ID.String(), Name: sk.Name}
}
return out, nil
}

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)
})
}