initial commit
This commit is contained in:
349
internal/application/asset/service.go
Normal file
349
internal/application/asset/service.go
Normal 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)
|
||||
}
|
||||
49
internal/application/auth/account_info.go
Normal file
49
internal/application/auth/account_info.go
Normal 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"
|
||||
}
|
||||
}
|
||||
86
internal/application/auth/oauth.go
Normal file
86
internal/application/auth/oauth.go
Normal 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
|
||||
}
|
||||
210
internal/application/auth/register.go
Normal file
210
internal/application/auth/register.go
Normal 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
|
||||
}
|
||||
131
internal/application/auth/reset_password.go
Normal file
131
internal/application/auth/reset_password.go
Normal 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
|
||||
}
|
||||
87
internal/application/auth/service.go
Normal file
87
internal/application/auth/service.go
Normal 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),
|
||||
}
|
||||
}
|
||||
76
internal/application/auth/setup_profile.go
Normal file
76
internal/application/auth/setup_profile.go
Normal 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])
|
||||
}
|
||||
16
internal/application/auth/utils.go
Normal file
16
internal/application/auth/utils.go
Normal 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
|
||||
}
|
||||
62
internal/application/auth/verify.go
Normal file
62
internal/application/auth/verify.go
Normal 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
|
||||
}
|
||||
272
internal/application/discovery/service.go
Normal file
272
internal/application/discovery/service.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
238
internal/application/landing/service.go
Normal file
238
internal/application/landing/service.go
Normal 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)
|
||||
}
|
||||
28
internal/application/module.go
Normal file
28
internal/application/module.go
Normal 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,
|
||||
),
|
||||
)
|
||||
72
internal/application/profile/converter.go
Normal file
72
internal/application/profile/converter.go
Normal 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
|
||||
}
|
||||
315
internal/application/profile/service.go
Normal file
315
internal/application/profile/service.go
Normal 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
|
||||
}
|
||||
121
internal/application/profilerole/service.go
Normal file
121
internal/application/profilerole/service.go
Normal 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
|
||||
}
|
||||
46
internal/application/skill/service.go
Normal file
46
internal/application/skill/service.go
Normal 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
|
||||
}
|
||||
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{},
|
||||
}
|
||||
}
|
||||
176
internal/application/specialist/service_test.go
Normal file
176
internal/application/specialist/service_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user