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

View File

@@ -0,0 +1,76 @@
package backoffice
import (
"context"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"go.uber.org/fx"
"base/config"
appProfileRole "base/internal/application/profilerole"
"base/internal/server/middleware"
)
type Controller struct {
logger zerolog.Logger
middleware middleware.Middleware
config *config.AppConfig
e *gin.Engine
profileRoleService appProfileRole.Service
}
type Param struct {
Logger zerolog.Logger
Engine *gin.Engine
Middleware middleware.Middleware
Config *config.AppConfig
ProfileRoleService appProfileRole.Service
fx.In
}
func New(lc fx.Lifecycle, param Param) *Controller {
c := &Controller{
logger: param.Logger,
middleware: param.Middleware,
config: param.Config,
e: param.Engine,
profileRoleService: param.ProfileRoleService,
}
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
c.SetupRouter()
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
},
)
return c
}
// getMaxFileSize returns the maximum file size in bytes from configuration
func (ctl *Controller) getMaxFileSize() int64 {
return ctl.config.Server.GetMaxFileSizeBytes()
}
func (ctl *Controller) SetupRouter() {
router := ctl.e.Group("/api/v1")
ctl.registerRoutes(router)
}
func (ctl *Controller) registerRoutes(router *gin.RouterGroup) {
backofficeRouter := router.Group("/backoffice")
profileRoleRouter := backofficeRouter.Group("/profile-roles")
profileRoleRouter.GET("", ctl.ListProfileRoles)
profileRoleRouter.GET("/:id", ctl.GetProfileRole)
protected := profileRoleRouter.Use(ctl.middleware.AuthShield())
protected.POST("", ctl.CreateProfileRole)
protected.PUT("/:id", ctl.UpdateProfileRole)
protected.DELETE("/:id", ctl.DeleteProfileRole)
}

View File

@@ -0,0 +1,7 @@
package backoffice
var HttpRoutePermissionMap = map[string]string{}
var GrpcRoutePermissionMap = map[string]string{}
var ExcludedGrpcRoutePermissionMap = map[string]string{}

View File

@@ -0,0 +1,216 @@
package backoffice
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
appProfileRole "base/internal/application/profilerole"
"base/internal/dto"
)
// ListProfileRoles returns the list of profile roles.
// @Summary list profile roles
// @Description returns all profile roles (id, title, status)
// @Tags BackOffice
// @Accept json
// @Produce json
// @Success 200 {array} dto.ProfileRole "list of profile roles"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/backoffice/profile-roles [get]
func (ctl *Controller) ListProfileRoles(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "backoffice").
Str("router", "profile-roles").
Str("handler", "ListProfileRoles").
Logger()
roles, err := ctl.profileRoleService.List(c.Request.Context())
if err != nil {
lg.Error().Err(err).Msg("failed to list profile roles")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
c.JSON(http.StatusOK, roles)
}
// CreateProfileRole creates a new profile role.
// @Summary create profile role
// @Description create a new profile role
// @Tags BackOffice
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateProfileRoleRequest true "create request"
// @Success 201 {object} dto.ProfileRole "created profile role"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 401 {object} dto.ErrorResponse "unauthorized"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/backoffice/profile-roles [post]
func (ctl *Controller) CreateProfileRole(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "backoffice").
Str("router", "profile-roles").
Str("handler", "CreateProfileRole").
Logger()
var req dto.CreateProfileRoleRequest
if !ctl.validateRequest(c, &req) {
return
}
role, err := ctl.profileRoleService.Create(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to create profile role")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.Created(role)
c.JSON(r.Status, r)
}
// GetProfileRole returns a profile role by ID.
// @Summary get profile role by ID
// @Description get profile role by ID
// @Tags BackOffice
// @Accept json
// @Produce json
// @Param id path string true "profile role ID"
// @Success 200 {object} dto.ProfileRole "profile role"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/backoffice/profile-roles/{id} [get]
func (ctl *Controller) GetProfileRole(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "backoffice").
Str("router", "profile-roles").
Str("handler", "GetProfileRole").
Logger()
var req dto.GetProfileRoleRequest
if !ctl.validateRequest(c, &req) {
return
}
id, err := uuid.Parse(req.ID)
if err != nil {
r := dto.BadRequest().WithMessage("invalid profile role ID")
c.JSON(r.Status, r)
return
}
role, err := ctl.profileRoleService.GetByID(c.Request.Context(), id)
if err != nil {
if errors.Is(err, appProfileRole.ErrNotFound) {
r := dto.NotFound().WithMessage("profile role not found")
c.JSON(r.Status, r)
return
}
lg.Error().Err(err).Msg("failed to get profile role")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(role)
c.JSON(r.Status, r)
}
// UpdateProfileRole updates a profile role.
// @Summary update profile role
// @Description update an existing profile role
// @Tags BackOffice
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "profile role ID"
// @Param request body dto.UpdateProfileRoleRequest true "update request"
// @Success 200 {object} dto.ProfileRole "updated profile role"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 401 {object} dto.ErrorResponse "unauthorized"
// @Failure 404 {object} dto.ErrorResponse "not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/backoffice/profile-roles/{id} [put]
func (ctl *Controller) UpdateProfileRole(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "backoffice").
Str("router", "profile-roles").
Str("handler", "UpdateProfileRole").
Logger()
var req dto.UpdateProfileRoleRequest
if !ctl.validateRequest(c, &req) {
return
}
role, err := ctl.profileRoleService.Update(c.Request.Context(), req)
if err != nil {
if errors.Is(err, appProfileRole.ErrNotFound) {
r := dto.NotFound().WithMessage("profile role not found")
c.JSON(r.Status, r)
return
}
lg.Error().Err(err).Msg("failed to update profile role")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(role)
c.JSON(r.Status, r)
}
// DeleteProfileRole deletes a profile role.
// @Summary delete profile role
// @Description delete a profile role
// @Tags BackOffice
// @Produce json
// @Security BearerAuth
// @Param id path string true "profile role ID"
// @Success 200 {object} dto.Response "success"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 401 {object} dto.ErrorResponse "unauthorized"
// @Failure 404 {object} dto.ErrorResponse "not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/backoffice/profile-roles/{id} [delete]
func (ctl *Controller) DeleteProfileRole(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "backoffice").
Str("router", "profile-roles").
Str("handler", "DeleteProfileRole").
Logger()
var req dto.DeleteProfileRoleRequest
if !ctl.validateRequest(c, &req) {
return
}
id, err := uuid.Parse(req.ID)
if err != nil {
r := dto.BadRequest().WithMessage("invalid profile role ID")
c.JSON(r.Status, r)
return
}
if err := ctl.profileRoleService.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, appProfileRole.ErrNotFound) {
r := dto.NotFound().WithMessage("profile role not found")
c.JSON(r.Status, r)
return
}
lg.Error().Err(err).Msg("failed to delete profile role")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithMessage("profile role deleted successfully")
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,50 @@
package backoffice
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"base/internal/dto"
"base/pkg/helper"
"base/pkg/validation"
)
func shouldBindJSON(c *gin.Context) bool {
switch c.Request.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
default:
return false
}
contentType := c.ContentType()
return contentType == "application/json" || strings.HasSuffix(contentType, "+json")
}
func (ctl *Controller) validateRequest(c *gin.Context, request dto.DTO) bool {
if err := c.ShouldBindUri(request); err != nil {
ctl.logger.Error().Err(err).Msg("RequestBindErr")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request path parameters"})
return false
}
if err := c.ShouldBindQuery(request); err != nil {
ctl.logger.Error().Err(err).Msg("RequestBindErr")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request query parameters"})
return false
}
if shouldBindJSON(c) {
if err := c.ShouldBindJSON(request); err != nil {
ctl.logger.Error().Err(err).Msg("RequestBindErr")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return false
}
}
validator := validation.NewGenericValidator()
validator.Validate(helper.StructToMap(request), request.Schema())
if validator.HasErrors() {
ctl.logger.Error().Any("request", request).Any("error", validator.GetErrors()).Msg("validatorHasErrors")
c.JSON(http.StatusBadRequest, gin.H{"errors": validator.GetErrors()})
return false
}
return true
}

View File

@@ -0,0 +1,13 @@
package http
import (
"go.uber.org/fx"
"base/internal/delivery/http/backoffice"
"base/internal/delivery/http/platform"
)
var Module = fx.Module(
"http",
fx.Provide(platform.New, backoffice.New),
)

View File

@@ -0,0 +1,363 @@
package platform
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
appAsset "base/internal/application/asset"
"base/internal/dto"
)
// ListAssetCategories godoc
// @Summary list asset categories
// @Description returns all asset categories
// @Tags Asset
// @Accept json
// @Produce json
// @Success 200 {object} dto.ListCategoriesResponse "list of categories"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets/categories [get]
func (ctl *Controller) ListAssetCategories(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "ListAssetCategories").
Logger()
resp, err := ctl.assetService.ListCategories(c.Request.Context())
if err != nil {
lg.Error().Err(err).Msg("failed to list asset categories")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(resp)
c.JSON(r.Status, r)
}
// ListCategoriesWithPreview returns categories with up to 8 assets per category.
// @Summary list categories with preview assets
// @Description returns asset categories, each with up to N sample assets (default 8). Use for carousels and landing previews.
// @Tags Asset
// @Accept json
// @Produce json
// @Param request body dto.CategoriesPreviewRequest true "filter options"
// @Success 200 {object} dto.CategoriesPreviewResponse "categories with preview assets"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets/categories/preview [post]
func (ctl *Controller) ListCategoriesWithPreview(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "ListCategoriesWithPreview").
Logger()
var req dto.CategoriesPreviewRequest
if !ctl.validateRequest(c, &req) {
return
}
if req.AssetsPerCategory == 0 {
req.AssetsPerCategory = 8
}
resp, err := ctl.assetService.GetCategoriesWithPreview(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to list categories with preview")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(resp).WithMessage("Asset categories with sample assets")
c.JSON(r.Status, r)
}
// ListAssetsByCategoryID returns paginated assets for a single category (Phase 2 of two-phase loading).
// @Summary list assets by category ID
// @Description returns paginated assets for the given category. Use after fetching categories from GET /assets/categories.
// @Tags Asset
// @Accept json
// @Produce json
// @Param id path string true "category UUID"
// @Param limit query int false "max items per page (default 10)"
// @Param page query int false "page number (default 1)"
// @Success 200 {object} dto.ListAssetsByCategoryIDResponse "paginated assets for category"
// @Failure 400 {object} dto.ErrorResponse "invalid category ID"
// @Failure 404 {object} dto.ErrorResponse "category not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets/categories/{id}/assets [get]
func (ctl *Controller) ListAssetsByCategoryID(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "ListAssetsByCategoryID").
Logger()
categoryID, err := uuid.Parse(c.Param("id"))
if err != nil {
r := dto.BadRequest().WithMessage("invalid category ID")
c.JSON(r.Status, r)
return
}
limit, page := 10, 1
if v := c.Query("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if v := c.Query("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
page = n
}
}
resp, err := ctl.assetService.ListByCategoryID(c.Request.Context(), categoryID, limit, page)
if err != nil {
lg.Error().Err(err).Msg("failed to list assets by category")
switch {
case errors.Is(err, appAsset.ErrCategoryNotFound):
r := dto.NotFound().WithMessage("category not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(resp)
c.JSON(r.Status, r)
}
// CreateAsset godoc
// @Summary create asset
// @Description create a new asset
// @Tags Asset
// @Accept json
// @Produce json
// @Param request body dto.CreateAssetRequest true "create asset request"
// @Success 201 {object} dto.AssetResponse "asset response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "category not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets [post]
func (ctl *Controller) CreateAsset(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "CreateAsset").
Logger()
var req dto.CreateAssetRequest
if !ctl.validateRequest(c, &req) {
return
}
asset, err := ctl.assetService.Create(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to create asset")
switch {
case errors.Is(err, appAsset.ErrCategoryNotFound):
r := dto.NotFound().WithMessage("asset category not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError().WithMessage("failed to create asset")
c.JSON(r.Status, r)
}
return
}
r := dto.Created(asset)
c.JSON(r.Status, r)
}
// GetAsset godoc
// @Summary get asset by ID
// @Description get asset by ID
// @Tags Asset
// @Accept json
// @Produce json
// @Param id path string true "asset ID"
// @Success 200 {object} dto.AssetResponse "asset response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "asset not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets/{id} [get]
func (ctl *Controller) GetAsset(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "GetAsset").
Logger()
var req dto.GetAssetRequest
if !ctl.validateRequest(c, &req) {
return
}
id, err := uuid.Parse(req.ID)
if err != nil {
lg.Error().Err(err).Msg("invalid asset ID")
r := dto.BadRequest().WithMessage("invalid asset ID")
c.JSON(r.Status, r)
return
}
asset, err := ctl.assetService.GetByID(c.Request.Context(), id)
if err != nil {
lg.Error().Err(err).Msg("failed to get asset")
switch {
case errors.Is(err, appAsset.ErrAssetNotFound):
r := dto.NotFound().WithMessage("asset not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(asset)
c.JSON(r.Status, r)
}
// UpdateAsset godoc
// @Summary update asset
// @Description update an existing asset
// @Tags Asset
// @Accept json
// @Produce json
// @Param id path string true "asset ID"
// @Param request body dto.UpdateAssetRequest true "update asset request"
// @Success 200 {object} dto.AssetResponse "asset response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "asset not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets/{id} [put]
func (ctl *Controller) UpdateAsset(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "UpdateAsset").
Logger()
var req dto.UpdateAssetRequest
if !ctl.validateRequest(c, &req) {
return
}
asset, err := ctl.assetService.Update(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to update asset")
switch {
case errors.Is(err, appAsset.ErrAssetNotFound):
r := dto.NotFound().WithMessage("asset not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(asset)
c.JSON(r.Status, r)
}
// ListAssetsByProfile godoc
// @Summary list assets by profile ID
// @Description list all assets for a profile
// @Tags Asset
// @Accept json
// @Produce json
// @Param id path string true "profile ID"
// @Success 200 {object} dto.ListAssetsResponse "list assets response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles/{id}/assets [get]
func (ctl *Controller) ListAssetsByProfile(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "ListAssetsByProfile").
Logger()
var req dto.ListAssetsByProfileRequest
if !ctl.validateRequest(c, &req) {
return
}
profileID, err := uuid.Parse(req.ProfileID)
if err != nil {
lg.Error().Err(err).Msg("invalid profile ID")
r := dto.BadRequest().WithMessage("invalid profile ID")
c.JSON(r.Status, r)
return
}
assets, err := ctl.assetService.FindByProfileID(c.Request.Context(), profileID)
if err != nil {
lg.Error().Err(err).Msg("failed to list assets")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(assets)
c.JSON(r.Status, r)
}
// DeleteAsset godoc
// @Summary delete asset
// @Description delete an asset
// @Tags Asset
// @Accept json
// @Produce json
// @Param id path string true "asset ID"
// @Success 200 {object} dto.SuccessResponse "success response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "asset not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/assets/{id} [delete]
func (ctl *Controller) DeleteAsset(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "asset").
Str("handler", "DeleteAsset").
Logger()
var req dto.DeleteAssetRequest
if !ctl.validateRequest(c, &req) {
return
}
id, err := uuid.Parse(req.ID)
if err != nil {
lg.Error().Err(err).Msg("invalid asset ID")
r := dto.BadRequest().WithMessage("invalid asset ID")
c.JSON(r.Status, r)
return
}
if err := ctl.assetService.Delete(c.Request.Context(), id); err != nil {
lg.Error().Err(err).Msg("failed to delete asset")
switch {
case errors.Is(err, appAsset.ErrAssetNotFound):
r := dto.NotFound().WithMessage("asset not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithMessage("asset deleted successfully")
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,468 @@
package platform
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"base/internal/application/auth"
"base/internal/dto"
"base/internal/pkg/oauth"
)
// RegisterWithCredentials godoc
// @Summary register with credentials
// @Description register a new user with email and password
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.RegisterRequest true "register request"
// @Success 200 {object} dto.TokenResponse "token response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/register [post]
func (ctl *Controller) RegisterWithCredentials(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "RegisterWithCredentials").
Logger()
var req dto.RegisterRequest
if !ctl.validateRequest(c, &req) {
return
}
tokens, err := ctl.authService.RegisterWithCredentials(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to register user")
switch {
case errors.Is(err, auth.ErrUserAlreadyExists):
r := dto.Conflict().WithMessage("user already exists")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
})
c.JSON(r.Status, r)
}
// LoginWithCredentials godoc
// @Summary login with credentials
// @Description login with email and password
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.LoginRequest true "login request"
// @Success 200 {object} dto.TokenResponse "token response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 401 {object} dto.ErrorResponse "invalid credentials"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/login [post]
func (ctl *Controller) LoginWithCredentials(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "LoginWithCredentials").
Logger()
var req dto.LoginRequest
if !ctl.validateRequest(c, &req) {
return
}
tokens, err := ctl.authService.LoginWithCredentials(
c.Request.Context(),
req.Email,
req.Password,
)
if err != nil {
lg.Error().Err(err).Msg("failed to login")
switch {
case errors.Is(err, auth.ErrInvalidCredentials):
r := dto.Unauthorized().WithMessage("invalid credentials")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
})
c.JSON(r.Status, r)
}
// RefreshToken godoc
// @Summary refresh token
// @Description refresh access token using refresh token
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.RefreshTokenRequest true "refresh token request"
// @Success 200 {object} dto.TokenResponse "token response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 401 {object} dto.ErrorResponse "invalid refresh token"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/refresh-token [post]
func (ctl *Controller) RefreshToken(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "RefreshToken").
Logger()
var req dto.RefreshTokenRequest
if !ctl.validateRequest(c, &req) {
return
}
tokens, err := ctl.authService.RefreshToken(
c.Request.Context(),
req.RefreshToken,
)
if err != nil {
lg.Error().Err(err).Msg("failed to refresh token")
switch {
case errors.Is(err, auth.ErrInvalidRefreshToken):
r := dto.Unauthorized().WithMessage("invalid refresh token")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
})
c.JSON(r.Status, r)
}
// GetOauthRedirectURL godoc
// @Summary get oauth redirect url
// @Description get OAuth redirect URL for the specified provider
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.OAuthRedirectURLRequest true "oauth redirect url request"
// @Success 200 {object} dto.OAuthRedirectURLResponse "oauth redirect url response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/oauth/redirect-url [post]
func (ctl *Controller) GetOauthRedirectURL(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "GetOauthRedirectURL").
Logger()
var req dto.OAuthRedirectURLRequest
if !ctl.validateRequest(c, &req) {
return
}
redirectURL, err := ctl.authService.GetOAuthRedirectURL(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to get OAuth redirect URL")
r := dto.BadRequest().WithMessage(err.Error())
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(dto.OAuthRedirectURLResponse{
RedirectURL: redirectURL,
})
c.JSON(r.Status, r)
}
// OauthCallbackGET handles OAuth redirect from provider (GET with code, state in query).
// Compatible with OAuth 2.0 flow where provider redirects to redirect_uri?code=...&state=...
// Route: GET /api/v1/auth/oauth/callback/:provider
func (ctl *Controller) OauthCallbackGET(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "OauthCallbackGET").
Logger()
providerStr := c.Param("provider")
provider, err := oauth.ParseProvider(providerStr)
if err != nil {
r := dto.BadRequest().WithMessage("invalid provider")
c.JSON(r.Status, r)
return
}
code := c.Query("code")
if code == "" {
r := dto.BadRequest().WithMessage("code is required")
c.JSON(r.Status, r)
return
}
req := dto.OAuthCallbackRequest{Provider: provider, Code: code}
response, err := ctl.authService.OAuthCallback(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to handle OAuth callback")
msg := err.Error()
if errors.Is(err, oauth.ErrMockNotEnabled) {
msg = "OAuth mock is not enabled - set oauth.mock.enabled=true and oauth.mock.base_url for local development"
}
r := dto.BadRequest().WithMessage(msg)
c.JSON(r.Status, r)
return
}
// If success_redirect in query, redirect with tokens in fragment (OAuth-compatible)
if redirectTo := c.Query("success_redirect"); redirectTo != "" {
u, err := url.Parse(redirectTo)
if err == nil {
u.Fragment = fmt.Sprintf("access_token=%s&refresh_token=%s&is_new_user=%t",
response.AccessToken, response.RefreshToken, response.IsNewUser)
c.Redirect(http.StatusFound, u.String())
return
}
}
r := dto.OK().WithData(dto.OAuthCallbackResponse{
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
IsNewUser: response.IsNewUser,
})
c.JSON(r.Status, r)
}
// OauthCallback handles OAuth callback via POST (e.g. frontend posting code).
// @Summary oauth callback
// @Description handle OAuth callback and authenticate user
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.OAuthCallbackRequest true "oauth callback request"
// @Success 200 {object} dto.OAuthCallbackResponse "oauth callback response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/oauth/callback [post]
func (ctl *Controller) OauthCallback(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "OauthCallback").
Logger()
var req dto.OAuthCallbackRequest
if !ctl.validateRequest(c, &req) {
return
}
response, err := ctl.authService.OAuthCallback(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to handle OAuth callback")
msg := err.Error()
if errors.Is(err, oauth.ErrMockNotEnabled) {
msg = "OAuth mock is not enabled - set oauth.mock.enabled=true and oauth.mock.base_url for local development"
}
r := dto.BadRequest().WithMessage(msg)
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(response)
c.JSON(r.Status, r)
}
// SendVerificationEmail godoc
// @Summary send verification email
// @Description send verification email to the authenticated user
// @Tags Public
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.SendVerificationEmailRequest true "send verification email request"
// @Success 200 {object} dto.SuccessResponse "success response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/send-verification-email [post]
func (ctl *Controller) SendVerificationEmail(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "SendVerificationEmail").
Logger()
var req dto.SendVerificationEmailRequest
if !ctl.validateRequest(c, &req) {
return
}
err := ctl.authService.SendVerificationEmail(c.Request.Context(), dto.SendVerificationEmailRequest{})
if err != nil {
lg.Error().Err(err).Msg("failed to send verification email")
switch {
case errors.Is(err, auth.ErrUserNotFound):
r := dto.NotFound().WithMessage("user not found")
c.JSON(r.Status, r)
case errors.Is(err, auth.ErrEmailAlreadyVerified):
r := dto.BadRequest().WithMessage("email already verified")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithMessage("verification email sent")
c.JSON(r.Status, r)
}
// VerifyAccount godoc
// @Summary verify account
// @Description verify account with verification code
// @Tags Public
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.VerifyAccountRequest true "verify account request"
// @Success 200 {object} dto.SuccessResponse "success response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/verify-account [post]
func (ctl *Controller) VerifyAccount(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "VerifyAccount").
Logger()
var req dto.VerifyAccountRequest
if !ctl.validateRequest(c, &req) {
return
}
err := ctl.authService.VerifyAccount(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to verify account")
switch {
case errors.Is(err, auth.ErrUserNotFound):
r := dto.NotFound().WithMessage("user not found")
c.JSON(r.Status, r)
case errors.Is(err, auth.ErrInvalidVerificationCode):
r := dto.BadRequest().WithMessage("invalid verification code")
c.JSON(r.Status, r)
case errors.Is(err, auth.ErrEmailAlreadyVerified):
r := dto.BadRequest().WithMessage("email already verified")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithMessage("account verified successfully")
c.JSON(r.Status, r)
}
// SendResetPasswordEmail godoc
// @Summary send reset password email
// @Description send password reset email
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.SendResetPasswordEmailRequest true "send reset password email request"
// @Success 200 {object} dto.SuccessResponse "success response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/send-reset-password-email [post]
func (ctl *Controller) SendResetPasswordEmail(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "SendResetPasswordEmail").
Logger()
var req dto.SendResetPasswordEmailRequest
if !ctl.validateRequest(c, &req) {
return
}
err := ctl.authService.SendResetPasswordEmail(c.Request.Context(), req)
if err != nil {
// TODO: we should handle for when user not exist, email service goes wrong and ...
lg.Error().Err(err).Msg("failed to send reset password email")
// Don't reveal if user exists or not for security
r := dto.OK().WithMessage("if the email exists, a reset password email has been sent")
c.JSON(r.Status, r)
return
}
r := dto.OK().WithMessage("if the email exists, a reset password email has been sent")
c.JSON(r.Status, r)
}
// ResetPassword godoc
// @Summary reset password
// @Description reset password with reset code
// @Tags Public
// @Accept json
// @Produce json
// @Param request body dto.ResetPasswordRequest true "reset password request"
// @Success 200 {object} dto.TokenResponse "token response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/auth/reset-password [post]
func (ctl *Controller) ResetPassword(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "ResetPassword").
Logger()
var req dto.ResetPasswordRequest
if !ctl.validateRequest(c, &req) {
return
}
tokens, err := ctl.authService.ResetPassword(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to reset password")
switch {
case errors.Is(err, auth.ErrUserNotFound):
r := dto.NotFound().WithMessage("user not found")
c.JSON(r.Status, r)
case errors.Is(err, auth.ErrInvalidVerificationCode):
r := dto.BadRequest().WithMessage("invalid reset code")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
})
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,36 @@
package platform
import (
"github.com/gin-gonic/gin"
"base/internal/dto"
)
// GetLanding returns the landing page data.
// @Summary get landing page
// @Description returns landing page with categories, specialist roles, assets by category, specialists, and blogs
// @Tags Landing
// @Accept json
// @Produce json
// @Success 200 {object} dto.Landing "landing page data"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/landing [get]
func (ctl *Controller) GetLanding(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "landing").
Str("handler", "GetLanding").
Logger()
resp, err := ctl.landingService.GetLanding(c.Request.Context())
if err != nil {
lg.Error().Err(err).Msg("failed to get landing page")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(resp.Data).WithMessage(resp.Message)
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,106 @@
package platform
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"base/internal/domain/profile"
"base/internal/dto"
"base/internal/server/middleware"
)
// GetSpecialistOverview returns overview for specialist users with full asset details, profile, and skills.
// @Summary get specialist overview
// @Description get overview for specialist view with assets, profile, skills, recently joined, analytics
// @Tags Platform
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.SpecialistOverviewFetchedResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse "profile not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/platform/overview/specialist [get]
func (ctl *Controller) GetSpecialistOverview(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "overview").
Str("handler", "Overview").
Logger()
userIDVal, exists := c.Get(middleware.UserIDKey)
if !exists {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
userIDStr, ok := userIDVal.(string)
if !ok {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
r := dto.BadRequest().WithMessage("invalid user ID")
c.JSON(r.Status, r)
return
}
resp, err := ctl.specialistService.Overview(c.Request.Context(), userID)
if err != nil {
lg.Error().Err(err).Msg("failed to fetch overview")
switch {
case errors.Is(err, profile.ErrProfileNotFound):
r := dto.NotFound().WithMessage("profile not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(resp)
c.JSON(r.Status, r)
}
// GetDiscoveryOverview returns overview for non-specialist users discovering assets and specialists.
// No profile required - callers browse latest assets and profiles.
// @Summary get discovery overview
// @Description overview for browsing users (latest assets, recently joined profiles, analytics). No profile required.
// @Tags Platform
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} dto.OverviewFetchedResponse "overview response"
// @Failure 401 {object} dto.ErrorResponse "unauthorized"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/platform/overview/discovery [get]
func (ctl *Controller) GetDiscoveryOverview(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "overview").
Str("handler", "GetDiscoveryOverview").
Logger()
if _, exists := c.Get(middleware.UserIDKey); !exists {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
overview, err := ctl.discoveryService.GetDiscoveryOverview(c.Request.Context())
if err != nil {
lg.Error().Err(err).Msg("failed to get discovery overview")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(overview)
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,274 @@
package platform
import (
profileDomian "base/internal/domain/profile"
"errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"base/internal/dto"
)
// CreateProfile godoc
// @Summary create profile
// @Description create a new profile
// @Tags Profile
// @Accept json
// @Produce json
// @Param request body dto.CreateProfileRequest true "create profile request"
// @Success 201 {object} dto.ProfileResponse "profile response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles [post]
func (ctl *Controller) CreateProfile(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "profile").
Str("handler", "CreateProfile").
Logger()
var req dto.CreateProfileRequest
if !ctl.validateRequest(c, &req) {
return
}
profile, err := ctl.profileService.Create(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to create profile")
r := dto.InternalServerError().WithMessage("failed to create profile")
c.JSON(r.Status, r)
return
}
r := dto.Created(profile)
c.JSON(r.Status, r)
}
// GetProfile godoc
// @Summary get profile by ID
// @Description get profile by ID
// @Tags Profile
// @Accept json
// @Produce json
// @Param id path string true "profile ID"
// @Success 200 {object} dto.ProfileResponse "profile response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "profile not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles/{id} [get]
func (ctl *Controller) GetProfile(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "profile").
Str("handler", "GetProfile").
Logger()
var req dto.GetProfileRequest
if !ctl.validateRequest(c, &req) {
return
}
id, err := uuid.Parse(req.ID)
if err != nil {
lg.Error().Err(err).Msg("invalid profile ID")
r := dto.BadRequest().WithMessage("invalid profile ID")
c.JSON(r.Status, r)
return
}
profile, err := ctl.profileService.GetByID(c.Request.Context(), id)
if err != nil {
lg.Error().Err(err).Msg("failed to get profile")
switch {
case errors.Is(err, profileDomian.ErrProfileNotFound):
r := dto.NotFound().WithMessage("profile not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(profile)
c.JSON(r.Status, r)
}
// GetProfileByHandle godoc
// @Summary get profile by handle
// @Description get profile by handle
// @Tags Profile
// @Accept json
// @Produce json
// @Param handle path string true "profile handle"
// @Success 200 {object} dto.ProfileResponse "profile response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "profile not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles/handle/{handle} [get]
func (ctl *Controller) GetProfileByHandle(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "profile").
Str("handler", "GetProfileByHandle").
Logger()
var req dto.GetProfileByHandleRequest
if !ctl.validateRequest(c, &req) {
return
}
profile, err := ctl.profileService.GetByHandle(c.Request.Context(), req.Handle)
if err != nil {
lg.Error().Err(err).Msg("failed to get profile by handle")
switch {
case errors.Is(err, profileDomian.ErrProfileNotFound):
r := dto.NotFound().WithMessage("profile not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(profile)
c.JSON(r.Status, r)
}
// UpdateProfile godoc
// @Summary update profile
// @Description update an existing profile
// @Tags Profile
// @Accept json
// @Produce json
// @Param id path string true "profile ID"
// @Param request body dto.UpdateProfileRequest true "update profile request"
// @Success 200 {object} dto.ProfileResponse "profile response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "profile not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles/{id} [put]
func (ctl *Controller) UpdateProfile(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "profile").
Str("handler", "UpdateProfile").
Logger()
var req dto.UpdateProfileRequest
if !ctl.validateRequest(c, &req) {
return
}
profile, err := ctl.profileService.Update(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to update profile")
switch {
case errors.Is(err, profileDomian.ErrProfileNotFound):
r := dto.NotFound().WithMessage("profile not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(profile)
c.JSON(r.Status, r)
}
// ListProfiles godoc
// @Summary list profiles
// @Description list profiles with filtering and pagination
// @Tags Profile
// @Accept json
// @Produce json
// @Param role_id query string false "role ID"
// @Param first_name query string false "first name"
// @Param last_name query string false "last name"
// @Param company query string false "company"
// @Param skill_name query string false "skill name"
// @Param page query int false "page number" default(1)
// @Param page_size query int false "page size" default(10)
// @Param sorted_by query string false "sort field"
// @Param ascending query bool false "ascending order" default(false)
// @Success 200 {object} dto.ListProfilesResponse "list profiles response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles [get]
func (ctl *Controller) ListProfiles(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "profile").
Str("handler", "ListProfiles").
Logger()
var req dto.ListProfilesRequest
if !ctl.validateRequest(c, &req) {
return
}
profiles, err := ctl.profileService.List(c.Request.Context(), req)
if err != nil {
lg.Error().Err(err).Msg("failed to list profiles")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(profiles)
c.JSON(r.Status, r)
}
// DeleteProfile godoc
// @Summary delete profile
// @Description delete a profile
// @Tags Profile
// @Accept json
// @Produce json
// @Param id path string true "profile ID"
// @Success 200 {object} dto.SuccessResponse "success response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 404 {object} dto.ErrorResponse "profile not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/profiles/{id} [delete]
func (ctl *Controller) DeleteProfile(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "profile").
Str("handler", "DeleteProfile").
Logger()
var req dto.DeleteProfileRequest
if !ctl.validateRequest(c, &req) {
return
}
id, err := uuid.Parse(req.ID)
if err != nil {
lg.Error().Err(err).Msg("invalid profile ID")
r := dto.BadRequest().WithMessage("invalid profile ID")
c.JSON(r.Status, r)
return
}
err = ctl.profileService.Delete(c.Request.Context(), id)
if err != nil {
lg.Error().Err(err).Msg("failed to delete profile")
switch {
case errors.Is(err, profileDomian.ErrProfileNotFound):
r := dto.NotFound().WithMessage("profile not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithMessage("profile deleted successfully")
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,34 @@
package platform
import (
"github.com/gin-gonic/gin"
"base/internal/dto"
)
// ListProfileRoles returns the list of profile roles for setup-profile.
// @Summary list profile roles
// @Description returns all profile roles (id, title) for platform - use role_id when calling setup-profile
// @Tags Platform
// @Produce json
// @Success 200 {array} dto.ProfileRole "list of profile roles"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/platform/profile-roles [get]
func (ctl *Controller) ListProfileRoles(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "platform").
Str("handler", "ListProfileRoles").
Logger()
roles, err := ctl.profileRoleService.List(c.Request.Context())
if err != nil {
lg.Error().Err(err).Msg("failed to list profile roles")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(roles)
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,163 @@
package platform
import (
"context"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"go.uber.org/fx"
"base/config"
appAsset "base/internal/application/asset"
appAuth "base/internal/application/auth"
appDiscovery "base/internal/application/discovery"
appLanding "base/internal/application/landing"
appProfile "base/internal/application/profile"
appProfileRole "base/internal/application/profilerole"
appSkill "base/internal/application/skill"
appSpecialist "base/internal/application/specialist"
"base/internal/server/middleware"
)
type Controller struct {
logger zerolog.Logger
middleware middleware.Middleware
config *config.AppConfig
e *gin.Engine
authService appAuth.Service
profileService appProfile.Service
profileRoleService appProfileRole.Service
skillService appSkill.Service
assetService appAsset.Service
discoveryService appDiscovery.Service
landingService appLanding.Service
specialistService appSpecialist.Service
}
type Param struct {
Logger zerolog.Logger
Engine *gin.Engine
Middleware middleware.Middleware
Config *config.AppConfig
AuthService appAuth.Service
ProfileService appProfile.Service
ProfileRoleService appProfileRole.Service
SkillService appSkill.Service
AssetService appAsset.Service
DiscoveryService appDiscovery.Service
LandingService appLanding.Service
SpecialistService appSpecialist.Service
fx.In
}
func New(lc fx.Lifecycle, param Param) *Controller {
c := &Controller{
logger: param.Logger,
e: param.Engine,
middleware: param.Middleware,
config: param.Config,
authService: param.AuthService,
profileService: param.ProfileService,
profileRoleService: param.ProfileRoleService,
skillService: param.SkillService,
assetService: param.AssetService,
discoveryService: param.DiscoveryService,
landingService: param.LandingService,
specialistService: param.SpecialistService,
}
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
c.SetupRouter()
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
},
)
return c
}
func (ctl *Controller) SetupRouter() {
apiRouter := ctl.e.Group("/api")
ctl.registerRoutes(apiRouter.Group("/v1"))
ctl.registerSpecialistRoutes(apiRouter.Group("/specialists/v1"))
}
func (ctl *Controller) registerRoutes(router *gin.RouterGroup) {
authRouter := router.Group("/auth")
ctl.registerAuthRoutes(authRouter)
accountRouter := router.Group("/account")
ctl.registerAccountRoutes(accountRouter)
profileRouter := router.Group("/profiles")
ctl.registerProfileRoutes(profileRouter)
ctl.registerAssetRoutes(router)
platformRouter := router.Group("/platform")
ctl.registerPlatformRoutes(platformRouter)
landingRouter := router.Group("/landing")
ctl.registerLandingRoutes(landingRouter)
}
func (ctl *Controller) registerPlatformRoutes(platformRouter *gin.RouterGroup) {
protected := platformRouter.Use(ctl.middleware.AuthShield())
protected.GET("/profile-roles", ctl.ListProfileRoles)
protected.GET("/skills", ctl.ListSkills)
protected.GET("/overview/discovery", ctl.GetDiscoveryOverview)
protected.GET("/overview/specialist", ctl.GetSpecialistOverview)
protected.POST("/verify-account", ctl.VerifyAccount)
protected.POST("/setup-profile", ctl.SetupProfile)
}
func (ctl *Controller) registerLandingRoutes(landingRouter *gin.RouterGroup) {
landingRouter.GET("", ctl.GetLanding)
}
func (ctl *Controller) registerAuthRoutes(authRouter *gin.RouterGroup) {
authRouter.POST("/login", ctl.LoginWithCredentials)
authRouter.POST("/register", ctl.RegisterWithCredentials)
authRouter.POST("/refresh-token", ctl.RefreshToken)
authRouter.POST("/oauth/redirect-url", ctl.GetOauthRedirectURL)
authRouter.GET("/oauth/callback/:provider", ctl.OauthCallbackGET)
authRouter.POST("/oauth/callback", ctl.OauthCallback)
authRouter.POST("/send-reset-password-email", ctl.SendResetPasswordEmail)
authRouter.POST("/reset-password", ctl.ResetPassword)
// Protected routes
protectedRoutes := authRouter.Use(ctl.middleware.AuthShield())
protectedRoutes.POST("/send-verification-email", ctl.SendVerificationEmail)
}
func (ctl *Controller) registerAccountRoutes(accountRouter *gin.RouterGroup) {
protected := accountRouter.Use(ctl.middleware.AuthShield())
protected.GET("/info", ctl.GetUserInfo)
}
func (ctl *Controller) registerProfileRoutes(profileRouter *gin.RouterGroup) {
profileRouter.POST("", ctl.CreateProfile)
profileRouter.GET("", ctl.ListProfiles)
profileRouter.GET("/handle/:handle", ctl.GetProfileByHandle)
profileRouter.GET("/:id/assets", ctl.ListAssetsByProfile)
profileRouter.GET("/:id", ctl.GetProfile)
profileRouter.PUT("/:id", ctl.UpdateProfile)
profileRouter.DELETE("/:id", ctl.DeleteProfile)
}
func (ctl *Controller) registerAssetRoutes(router *gin.RouterGroup) {
assetRouter := router.Group("/assets")
assetRouter.GET("/categories", ctl.ListAssetCategories)
assetRouter.POST("/categories/preview", ctl.ListCategoriesWithPreview)
assetRouter.GET("/categories/:id/assets", ctl.ListAssetsByCategoryID)
assetRouter.POST("", ctl.CreateAsset)
assetRouter.GET("/:id", ctl.GetAsset)
assetRouter.PUT("/:id", ctl.UpdateAsset)
assetRouter.DELETE("/:id", ctl.DeleteAsset)
}

View File

@@ -0,0 +1,34 @@
package platform
import (
"github.com/gin-gonic/gin"
"base/internal/dto"
)
// ListSkills returns the list of skills for profile skill selection.
// @Summary list skills
// @Description returns all skills from the catalog for profile update skill selection
// @Tags Platform
// @Produce json
// @Success 200 {array} dto.Skill "list of skills"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/platform/skills [get]
func (ctl *Controller) ListSkills(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "platform").
Str("handler", "ListSkills").
Logger()
skills, err := ctl.skillService.List(c.Request.Context())
if err != nil {
lg.Error().Err(err).Msg("failed to list skills")
r := dto.InternalServerError()
c.JSON(r.Status, r)
return
}
r := dto.OK().WithData(skills)
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,185 @@
package platform
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"base/internal/domain/profile"
"base/internal/dto"
"base/internal/server/middleware"
)
func (ctl *Controller) registerSpecialistRoutes(router *gin.RouterGroup) {
protected := router.Use(ctl.middleware.AuthShield())
protected.PUT("/page-sections/hero", ctl.SpecialistUpdateHero)
protected.PUT("/page-sections/contact", ctl.SpecialistUpdateContact)
protected.PUT("/page-sections/skills", ctl.SpecialistUpdateSkills)
protected.GET("/page-sections", ctl.SpecialistGetPageSections)
protected.GET("/profile", ctl.SpecialistGetProfile)
}
// SpecialistUpdateHero updates the hero section of the specialist's profile.
// @Summary update hero section
// @Tags Specialist
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.HeroDTO true "hero section"
// @Success 200 {object} dto.SuccessResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/specialists/v1/page-sections/hero [put]
func (ctl *Controller) SpecialistUpdateHero(c *gin.Context) {
userID, err := getUserIDFromContext(c)
if err != nil {
return
}
var req dto.HeroDTO
if !ctl.validateRequest(c, &req) {
return
}
if err := ctl.specialistService.UpdateHero(c.Request.Context(), userID, req); err != nil {
ctl.handleSpecialistError(c, err)
return
}
r := dto.OK().WithMessage("hero updated")
c.JSON(r.Status, r)
}
// SpecialistUpdateContact updates the contact section.
// @Summary update contact section
// @Tags Specialist
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.ContactDTO true "contact section"
// @Success 200 {object} dto.SuccessResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/specialists/v1/page-sections/contact [put]
func (ctl *Controller) SpecialistUpdateContact(c *gin.Context) {
userID, err := getUserIDFromContext(c)
if err != nil {
return
}
var req dto.ContactDTO
if !ctl.validateRequest(c, &req) {
return
}
if err := ctl.specialistService.UpdateContact(c.Request.Context(), userID, req); err != nil {
ctl.handleSpecialistError(c, err)
return
}
r := dto.OK().WithMessage("contact updated")
c.JSON(r.Status, r)
}
// SpecialistUpdateSkills updates the skills section.
// @Summary update skills section
// @Tags Specialist
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.SkillsUpdateRequest true "skills section"
// @Success 200 {object} dto.SuccessResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/specialists/v1/page-sections/skills [put]
func (ctl *Controller) SpecialistUpdateSkills(c *gin.Context) {
userID, err := getUserIDFromContext(c)
if err != nil {
return
}
var req dto.SkillsUpdateRequest
if !ctl.validateRequest(c, &req) {
return
}
if err := ctl.specialistService.UpdateSkills(c.Request.Context(), userID, req); err != nil {
ctl.handleSpecialistError(c, err)
return
}
r := dto.OK().WithMessage("skills updated")
c.JSON(r.Status, r)
}
// SpecialistGetPageSections returns hero, contact, skills for the specialist.
// @Summary get page sections
// @Tags Specialist
// @Produce json
// @Security Bearer
// @Success 200 {object} dto.PageSectionsResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/specialists/v1/page-sections [get]
func (ctl *Controller) SpecialistGetPageSections(c *gin.Context) {
userID, err := getUserIDFromContext(c)
if err != nil {
return
}
resp, err := ctl.specialistService.GetPageSections(c.Request.Context(), userID)
if err != nil {
ctl.handleSpecialistError(c, err)
return
}
r := dto.OK().WithData(resp)
c.JSON(r.Status, r)
}
// SpecialistGetProfile returns the specialist's full profile.
// @Summary get specialist profile
// @Tags Specialist
// @Produce json
// @Security Bearer
// @Success 200 {object} dto.ProfileResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/specialists/v1/profile [get]
func (ctl *Controller) SpecialistGetProfile(c *gin.Context) {
userID, err := getUserIDFromContext(c)
if err != nil {
return
}
resp, err := ctl.specialistService.GetProfile(c.Request.Context(), userID)
if err != nil {
ctl.handleSpecialistError(c, err)
return
}
r := dto.OK().WithData(resp)
c.JSON(r.Status, r)
}
func getUserIDFromContext(c *gin.Context) (uuid.UUID, error) {
val, exists := c.Get(middleware.UserIDKey)
if !exists {
c.JSON(dto.Unauthorized().Status, dto.Unauthorized())
return uuid.Nil, errors.New("unauthorized")
}
str, ok := val.(string)
if !ok {
c.JSON(dto.Unauthorized().Status, dto.Unauthorized())
return uuid.Nil, errors.New("invalid user id type")
}
id, err := uuid.Parse(str)
if err != nil {
c.JSON(dto.BadRequest().Status, dto.BadRequest().WithMessage("invalid user ID"))
return uuid.Nil, err
}
return id, nil
}
func (ctl *Controller) handleSpecialistError(c *gin.Context, err error) {
switch {
case errors.Is(err, profile.ErrProfileNotFound):
r := dto.NotFound().WithMessage("profile not found")
c.JSON(r.Status, r)
default:
ctl.logger.Error().Err(err).Msg("specialist error")
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
}

View File

@@ -0,0 +1,141 @@
package platform
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"base/internal/application/auth"
"base/internal/dto"
"base/internal/server/middleware"
)
// SetupProfile godoc
// @Summary setup profile after registration
// @Description complete profile with handle, role, level, and short bio. Requires authentication.
// @Tags Platform
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body dto.SetupProfileRequest true "setup profile request"
// @Success 200 {object} dto.SuccessResponse "success response"
// @Failure 400 {object} dto.ErrorResponse "invalid request"
// @Failure 401 {object} dto.ErrorResponse "unauthorized"
// @Failure 404 {object} dto.ErrorResponse "user not found"
// @Failure 409 {object} dto.ErrorResponse "profile already exists or handle already taken"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/user/platform/setup-profile [post]
func (ctl *Controller) SetupProfile(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "auth").
Str("handler", "SetupProfile").
Logger()
userIDVal, exists := c.Get(middleware.UserIDKey)
if !exists {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
userIDStr, ok := userIDVal.(string)
if !ok {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
r := dto.BadRequest().WithMessage("invalid user ID")
c.JSON(r.Status, r)
return
}
var req dto.SetupProfileRequest
if !ctl.validateRequest(c, &req) {
return
}
err = ctl.authService.SetupProfile(c.Request.Context(), userID, req)
if err != nil {
lg.Error().Err(err).Msg("failed to setup profile")
switch {
case errors.Is(err, auth.ErrProfileAlreadyExists):
r := dto.Conflict().WithMessage("profile already exists")
c.JSON(r.Status, r)
case errors.Is(err, auth.ErrHandleAlreadyTaken):
r := dto.Conflict().WithMessage("handle already taken")
c.JSON(r.Status, r)
case errors.Is(err, auth.ErrUserNotFound):
r := dto.NotFound().WithMessage("user not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithMessage("profile created successfully")
c.JSON(r.Status, r)
}
// GetUserInfo godoc
// @Summary get account info
// @Description returns user and profile_id for the authenticated user
// @Tags Platform
// @Produce json
// @Security Bearer
// @Success 200 {object} dto.UserInfoResponse "account info"
// @Failure 401 {object} dto.ErrorResponse "unauthorized"
// @Failure 404 {object} dto.ErrorResponse "user not found"
// @Failure 500 {object} dto.ErrorResponse "internal server error"
// @Router /api/v1/platform/user/info [get]
func (ctl *Controller) GetUserInfo(c *gin.Context) {
lg := ctl.logger.With().
Str("module", "platform").
Str("router", "account").
Str("handler", "GetUserInfo").
Logger()
userIDVal, exists := c.Get(middleware.UserIDKey)
if !exists {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
userIDStr, ok := userIDVal.(string)
if !ok {
r := dto.Unauthorized()
c.JSON(r.Status, r)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
r := dto.BadRequest().WithMessage("invalid user ID")
c.JSON(r.Status, r)
return
}
info, err := ctl.authService.GetUserInfo(c.Request.Context(), userID)
if err != nil {
lg.Error().Err(err).Msg("failed to get account info")
switch {
case errors.Is(err, auth.ErrUserNotFound):
r := dto.NotFound().WithMessage("user not found")
c.JSON(r.Status, r)
default:
r := dto.InternalServerError()
c.JSON(r.Status, r)
}
return
}
r := dto.OK().WithData(info)
c.JSON(r.Status, r)
}

View File

@@ -0,0 +1,58 @@
package platform
import (
"base/internal/dto"
"base/pkg/helper"
"base/pkg/validation"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func shouldBindJSON(c *gin.Context) bool {
// Only bind JSON for methods that normally carry bodies
switch c.Request.Method {
case http.MethodPost,
http.MethodPut,
http.MethodPatch:
default:
return false
}
// Must actually be JSON
contentType := c.ContentType()
return contentType == "application/json" ||
strings.HasSuffix(contentType, "+json")
}
func (ctl *Controller) validateRequest(c *gin.Context, request dto.DTO) bool {
if err := c.ShouldBindUri(&request); err != nil {
ctl.logger.Error().Err(err).Msg("RequestBundErr")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request path parameters"})
return false
}
if err := c.ShouldBindQuery(&request); err != nil {
ctl.logger.Error().Err(err).Msg("RequestBundErr")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request query parameters"})
return false
}
if shouldBindJSON(c) {
if err := c.ShouldBindJSON(&request); err != nil {
ctl.logger.Error().Err(err).Msg("RequestBundErr")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return false
}
}
validator := validation.NewGenericValidator()
validator.Validate(helper.StructToMap(request), request.Schema())
if validator.HasErrors() {
ctl.logger.Error().Any("request", request).Any("error", validator.GetErrors()).Msg("validatorHasErrors")
c.JSON(http.StatusBadRequest, gin.H{"errors": validator.GetErrors()})
return false
}
return true
}

View File

@@ -0,0 +1,12 @@
package delivery
import (
"go.uber.org/fx"
"base/internal/delivery/http"
)
var Module = fx.Module(
"delivery",
http.Module,
)

0
internal/domain/.gitkeep Normal file
View File

View File

@@ -0,0 +1,17 @@
package asset
import (
"github.com/google/uuid"
)
type Artifact struct {
ID uuid.UUID
AssetID uuid.UUID
Type string
DownloadURL string
Price int // in cents or smallest currency unit
Title string
Description string
}

View File

@@ -0,0 +1,36 @@
package asset
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
//go:generate stringer -type=Status
type Status int
const (
StatusPublished Status = iota
StatusDisabled
StatusPending
StatusDeleted
)
type Asset struct {
ID uuid.UUID
ProfileID uuid.UUID
Status Status
AssetCategoryID uuid.UUID
AssetCategory Category
Title string
Description string
Link string
Analytics json.RawMessage
Reports []Report
AssetArtifacts []Artifact
Comments []Comment
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -0,0 +1,17 @@
package asset
import (
"github.com/google/uuid"
)
type Category struct {
ID uuid.UUID
Name string
Icon string
Color string
CardType string
Featured bool
Description string
}

View File

@@ -0,0 +1,22 @@
package asset
import (
"time"
"github.com/google/uuid"
)
type Comment struct {
ID uuid.UUID
AssetID uuid.UUID
Content string
CreatedAt time.Time
UpdatedAt time.Time
WriterID uuid.UUID
WriterType string
ParentID *uuid.UUID
Replies []Comment
}

View File

@@ -0,0 +1,51 @@
package asset
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
//go:generate stringer -type=ReportStatus
type ReportStatus int
const (
ReportStatusPending ReportStatus = iota
ReportStatusReviewed
ReportStatusResolved
ReportStatusDismissed
)
type Report struct {
ID uuid.UUID
AssetID uuid.UUID
ReportedBy ReportedBy
ReportedAt time.Time
Reason ReportReason
Status ReportStatus
Notes string
Attachments []Attachment
}
type ReportedBy struct {
ID uuid.UUID
Name string
Description string
RestOfFields json.RawMessage
}
type ReportReason struct {
ID uuid.UUID
Name string
Description string
RestOfFields json.RawMessage
}
type Attachment struct {
ID uuid.UUID
URL string
Type string
}

View File

@@ -0,0 +1,29 @@
package asset
import (
"context"
"github.com/google/uuid"
)
type AssetRepository interface {
Create(ctx context.Context, asset *Asset) error
FindByID(ctx context.Context, id uuid.UUID) (*Asset, error)
Update(ctx context.Context, asset *Asset) error
Delete(ctx context.Context, asset *Asset) error
FindByProfileID(ctx context.Context, profileID uuid.UUID) ([]*Asset, error)
FindLatest(ctx context.Context, limit, offset int) ([]*Asset, error)
FindLatestByCategory(ctx context.Context, categoryID uuid.UUID, limit int) ([]*Asset, error)
FindLatestByCategoryPaginated(ctx context.Context, categoryID uuid.UUID, limit, offset int) ([]*Asset, error)
CountByCategory(ctx context.Context, categoryID uuid.UUID) (int, error)
Count(ctx context.Context) (int, error)
}
type CategoryRepository interface {
Create(ctx context.Context, category *Category) error
FindByID(ctx context.Context, id uuid.UUID) (*Category, error)
Update(ctx context.Context, category *Category) error
Delete(ctx context.Context, id uuid.UUID) error
FindAll(ctx context.Context) ([]*Category, error)
FindByIDs(ctx context.Context, ids []uuid.UUID) ([]*Category, error)
}

View File

@@ -0,0 +1,25 @@
package auth
import (
"encoding/json"
"time"
"github.com/google/uuid"
"base/internal/pkg/oauth"
)
type Account struct {
ID uuid.UUID
UserID uuid.UUID
Provider oauth.Provider
Password *string
AccessToken *string
RefreshToken *string
AccessTokenExpiry *time.Time
RefreshTokenExpiry *time.Time
Scope []string
Meta json.RawMessage
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -0,0 +1,17 @@
package auth
import (
"time"
"github.com/google/uuid"
)
// AccountCreatedEvent represents the event when an account is created
type AccountCreatedEvent struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,33 @@
package auth
// UserQueryOption represents options for querying users
type UserQueryOption func(*UserQueryOptions)
// UserQueryOptions holds options for user queries
type UserQueryOptions struct {
LoadRoles bool
LoadAccounts bool
}
// WithRoles enables loading of user roles
func WithRoles() UserQueryOption {
return func(opts *UserQueryOptions) {
opts.LoadRoles = true
}
}
// WithAccounts enables loading of user accounts
func WithAccounts() UserQueryOption {
return func(opts *UserQueryOptions) {
opts.LoadAccounts = true
}
}
// WithRelations enables loading of all relations
func WithRelations() UserQueryOption {
return func(opts *UserQueryOptions) {
opts.LoadRoles = true
opts.LoadAccounts = true
}
}

View File

@@ -0,0 +1,51 @@
package auth
import (
"context"
"github.com/google/uuid"
)
type UserRepository interface {
Create(ctx context.Context, user *User) error
CreateWithAccount(ctx context.Context, user *User, account *Account) error
UpsertWithAccount(ctx context.Context, email string, user *User, account *Account) (isNewUser bool, err error)
FindByID(ctx context.Context, id uuid.UUID, opts ...UserQueryOption) (*User, error)
FindByEmail(ctx context.Context, email string, opts ...UserQueryOption) (*User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, limit, offset int, opts ...UserQueryOption) ([]*User, error)
Count(ctx context.Context) (int64, error)
UserRoles(ctx context.Context, userID uuid.UUID) ([]Role, error)
UserAccounts(ctx context.Context, userID uuid.UUID) ([]Account, error)
}
type RoleRepository interface {
Create(ctx context.Context, role *Role) error
FindByID(ctx context.Context, id uuid.UUID) (*Role, error)
FindByName(ctx context.Context, name string) (*Role, error)
Update(ctx context.Context, role *Role) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, limit, offset int) ([]*Role, error)
Count(ctx context.Context) (int64, error)
}
type AccountRepository interface {
Create(ctx context.Context, account *Account) error
FindByID(ctx context.Context, id uuid.UUID) (*Account, error)
FindByUserID(ctx context.Context, userID uuid.UUID) ([]*Account, error)
Update(ctx context.Context, account *Account) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, limit, offset int) ([]*Account, error)
Count(ctx context.Context) (int64, error)
}
type UserRoleRepository interface {
Create(ctx context.Context, userID, roleID uuid.UUID) error
FindByUserID(ctx context.Context, userID uuid.UUID) ([]*Role, error)
FindByRoleID(ctx context.Context, roleID uuid.UUID) ([]*User, error)
Delete(ctx context.Context, userID, roleID uuid.UUID) error
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
DeleteByRoleID(ctx context.Context, roleID uuid.UUID) error
Exists(ctx context.Context, userID, roleID uuid.UUID) (bool, error)
}

View File

@@ -0,0 +1,15 @@
package auth
import (
"time"
"github.com/google/uuid"
)
type Role struct {
ID uuid.UUID
Name string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -0,0 +1,65 @@
package auth
import (
"time"
"github.com/google/uuid"
)
//go:generate stringer -type=UserStatus
type UserStatus int
const (
UserStatusActive UserStatus = iota
UserStatusInactive
UserStatusPending
UserStatusDeleted
)
// User represents a user aggregate root
// The repository handles loading of related entities (Roles, Accounts)
// This keeps the domain entity pure and decoupled from infrastructure
type User struct {
ID uuid.UUID
FirstName string
LastName string
PhoneNumber string
Email string
EmailVerified bool
Status UserStatus
InvitationCode string
Roles []Role
Accounts []Account
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
}
// HasRole checks if the user has a specific role
func (u *User) HasRole(roleName string) bool {
for _, role := range u.Roles {
if role.Name == roleName {
return true
}
}
return false
}
// GetRoleNames returns a slice of role names
func (u *User) GetRoleNames() []string {
names := make([]string, len(u.Roles))
for i, role := range u.Roles {
names[i] = role.Name
}
return names
}
// HasAccount checks if the user has an account for the given provider
func (u *User) HasAccount(provider string) bool {
for _, account := range u.Accounts {
if account.Provider.String() == provider {
return true
}
}
return false
}

View File

@@ -0,0 +1,24 @@
package bookmark
import (
"encoding/json"
"github.com/google/uuid"
)
type AssetBookmarkGroup struct {
ID uuid.UUID
ProfileID uuid.UUID
Name string
Assets []BookmarkedAsset
}
type BookmarkedAsset struct {
ID uuid.UUID
BookmarkGroupID uuid.UUID
AssetID uuid.UUID
AssetType string
AssetName string
RestOfFields json.RawMessage
}

View File

@@ -0,0 +1,21 @@
package bookmark
import (
"encoding/json"
"github.com/google/uuid"
)
type SpecialistBookmark struct {
ID uuid.UUID
ProfileID uuid.UUID
Profile BookmarkedProfile
}
type BookmarkedProfile struct {
ID uuid.UUID
Name string
Description string
RestOfFields json.RawMessage
}

View File

@@ -0,0 +1,24 @@
package feedback
import (
"github.com/google/uuid"
)
//go:generate stringer -type=Status
type Status int
const (
StatusOpen Status = iota
StatusClosed
StatusPending
StatusDeleted
)
type Feedback struct {
ID uuid.UUID
UserID uuid.UUID
Title string
Description string
Status Status
Category string
}

View File

@@ -0,0 +1,27 @@
package notification
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
type Notification struct {
ID uuid.UUID
UserID uuid.UUID
Title string
Description string
CreatedAt time.Time
UpdatedAt time.Time
Read bool
ReadAt *time.Time
Action string
ActionData json.RawMessage
ActionURL string
ActionText string
ActionIcon string
}

View File

@@ -0,0 +1,17 @@
package preference
import (
"github.com/google/uuid"
)
type Preference struct {
ID uuid.UUID
UserID uuid.UUID
Name string
Value string
Type string
Description string
}

View File

@@ -0,0 +1,13 @@
package profile
type About struct {
ProfilePicture string
About string
Achievements []Achievement
}
type Achievement struct {
Title string
Value string
Enabled bool
}

View File

@@ -0,0 +1,12 @@
package profile
type Contact struct {
Email string
Phone string
SocialLinks []SocialLink
}
type SocialLink struct {
LinkType string
Link string
}

View File

@@ -0,0 +1,5 @@
package profile
import "errors"
var ErrProfileNotFound = errors.New("profile not found")

View File

@@ -0,0 +1,15 @@
package profile
import "github.com/google/uuid"
type Filter struct {
RoleID uuid.UUID
FirstName string
LastName string
Company string
SkillName string // Search by skill name
Page uint
PageSize uint
SortedBy string
Ascending bool
}

View File

@@ -0,0 +1,12 @@
package profile
type Hero struct {
Role *Role
FirstName string
LastName string
Company string
ShortDescription string
ResumeLink string
CTAEnabled bool
Avatar string
}

View File

@@ -0,0 +1,5 @@
package profile
type PageSetting struct {
VisibilityLevel string // enum: public, private, only_me
}

View File

@@ -0,0 +1,21 @@
package profile
import (
"time"
"github.com/google/uuid"
)
type Profile struct {
ID uuid.UUID
UserID *uuid.UUID // Optional: links profile to a user account
Handle string
PageSectionOrder map[string]int
Hero Hero
About About
Skills []Skill
Contact Contact
PageSetting PageSetting
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -0,0 +1,17 @@
package profile
import (
"context"
"github.com/google/uuid"
)
type Repository interface {
FindByID(ctx context.Context, id uuid.UUID) (*Profile, error)
FindByHandle(ctx context.Context, handle string) (*Profile, error)
Create(ctx context.Context, profile *Profile) error
Update(ctx context.Context, profile *Profile) error
Delete(ctx context.Context, profile *Profile) error
FindByUserID(ctx context.Context, userId uuid.UUID) (*Profile, error)
FindAll(ctx context.Context, filter Filter) ([]*Profile, int, error)
}

View File

@@ -0,0 +1,9 @@
package profile
import "github.com/google/uuid"
type Role struct {
ID uuid.UUID
Level string // e.g. Junior, Senior, Lead
Title string
}

View File

@@ -0,0 +1,20 @@
package profile
import (
"context"
"errors"
"github.com/google/uuid"
)
var ErrRoleNotFound = errors.New("profile role not found")
// RoleRepository provides access to profile_roles (roles for profiles).
type RoleRepository interface {
FindByID(ctx context.Context, id uuid.UUID) (*Role, error)
FindAll(ctx context.Context) ([]*Role, error)
List(ctx context.Context, limit, offset int) ([]*Role, error)
Create(ctx context.Context, role *Role) error
Update(ctx context.Context, role *Role) error
Delete(ctx context.Context, id uuid.UUID) error
}

View File

@@ -0,0 +1,6 @@
package profile
type Skill struct {
SkillName string
Level string
}

View File

@@ -0,0 +1,16 @@
package profile
import (
"time"
"github.com/google/uuid"
)
type Achievement struct {
ID uuid.UUID
ProfileID uuid.UUID
Name string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -0,0 +1,16 @@
package profile
import (
"time"
"github.com/google/uuid"
)
type AvailabilityException struct {
ID uuid.UUID
ProfileID uuid.UUID
Date time.Time
Start *time.Time
End *time.Time
DayUnavailable bool
}

View File

@@ -0,0 +1,16 @@
package profile
import (
"time"
"github.com/google/uuid"
)
type AvailabilityRule struct {
ID uuid.UUID
ProfileID uuid.UUID
Title string
Weekday int // 0-6, where 0 is Sunday
Start time.Time
End time.Time
}

View File

@@ -0,0 +1,12 @@
package profile
import (
"github.com/google/uuid"
)
type Award struct {
ID uuid.UUID
ProfileID uuid.UUID
Name string
Description string
}

View File

@@ -0,0 +1,22 @@
package profile
import (
"github.com/google/uuid"
)
type BookingService struct {
ID uuid.UUID
ProfileID uuid.UUID
BookingServiceTypeID uuid.UUID
BookingServiceType BookingServiceType
Title string
Description string
Duration int // in minutes
Price int // in cents or smallest currency unit
MaxBookingDays int
}
type BookingServiceType struct {
ID uuid.UUID
Name string
}

View File

@@ -0,0 +1,12 @@
package profile
import (
"github.com/google/uuid"
)
type Certification struct {
ID uuid.UUID
ProfileID uuid.UUID
Name string
Description string
}

View File

@@ -0,0 +1,18 @@
package profile
import (
"time"
"github.com/google/uuid"
)
type Education struct {
ID uuid.UUID
ProfileID uuid.UUID
SchoolName string
Degree string
FieldOfStudy string
StartDate *time.Time
EndDate *time.Time
Description string
}

View File

@@ -0,0 +1,17 @@
package profile
import (
"time"
"github.com/google/uuid"
)
type Experience struct {
ID uuid.UUID
ProfileID uuid.UUID
CompanyName string
Position string
StartDate *time.Time
EndDate *time.Time
Description string
}

View File

@@ -0,0 +1,52 @@
package profile
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
//go:generate stringer -type=Status
type Status int
const (
StatusPublished Status = iota
StatusDisabled
StatusPending
StatusDeleted
)
type Profile struct {
ID uuid.UUID
UserID uuid.UUID
ProfileHandle string
Status Status
Settings Settings
Skills []Skill
SocialLinks []SocialLink
Achievements []Achievement
Experiences []Experience
Educations []Education
Certifications []Certification
Awards []Award
AvailabilityRules []AvailabilityRule
AvailabilityExceptions []AvailabilityException
BookingServices []BookingService
// Note: These are typically loaded separately to avoid circular dependencies
// Assets, AssetBookmarkGroups, SpecialistBookmarks, PurchasedAssets, BookedServices
// are accessed through their respective repositories using ProfileID/UserID
CreatedAt time.Time
UpdatedAt time.Time
}
type Settings struct {
Theme ThemeSettings `json:"theme"`
Other json.RawMessage `json:"rest_of_fields"`
}
type ThemeSettings struct {
BackgroundColor string `json:"background_color"`
TextColor string `json:"text_color"`
RestOfFields json.RawMessage `json:"rest_of_fields"`
}

View File

@@ -0,0 +1,22 @@
package profile
import (
"github.com/google/uuid"
)
//go:generate stringer -type=SkillLevel
type SkillLevel int
const (
SkillLevelBeginner SkillLevel = iota
SkillLevelIntermediate
SkillLevelAdvanced
SkillLevelExpert
)
type Skill struct {
ID uuid.UUID
ProfileID uuid.UUID
Name string
Level SkillLevel
}

View File

@@ -0,0 +1,12 @@
package profile
import (
"github.com/google/uuid"
)
type SocialLink struct {
ID uuid.UUID
ProfileID uuid.UUID
LinkType string
Link string
}

View File

@@ -0,0 +1,67 @@
package purchase
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
//go:generate stringer -type=BookingStatus
type BookingStatus int
const (
BookingStatusPending BookingStatus = iota
BookingStatusConfirmed
BookingStatusCancelled
BookingStatusCompleted
BookingStatusRescheduled
)
type BookedService struct {
ID uuid.UUID
UserID uuid.UUID
Service BookedServiceInfo
BookingDate time.Time
BookingPrice int // in cents or smallest currency unit
BookingCurrency string
BookingStatus BookingStatus
BookingReceipt string
HostUser UserInfo
GuestUser UserInfo
RescheduleHistory []RescheduleHistory
}
type BookedServiceInfo struct {
ID uuid.UUID
Name string
Description string
RestOfFields json.RawMessage
}
type UserInfo struct {
ID uuid.UUID
Name string
Description string
RestOfFields json.RawMessage
}
type RescheduleHistory struct {
ID uuid.UUID
BookedServiceID uuid.UUID
RequestedBy UserInfo
RequestedTo UserInfo
RequestedAt time.Time
Status string
Reason string
Notes string
Attachments []RescheduleAttachment
}
type RescheduleAttachment struct {
ID uuid.UUID
URL string
Type string
}

View File

@@ -0,0 +1,38 @@
package purchase
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
//go:generate stringer -type=PurchaseStatus
type PurchaseStatus int
const (
PurchaseStatusPending PurchaseStatus = iota
PurchaseStatusCompleted
PurchaseStatusFailed
PurchaseStatusRefunded
)
type PurchasedAsset struct {
ID uuid.UUID
UserID uuid.UUID
Asset PurchasedAssetInfo
PurchaseDate time.Time
PurchasePrice int // in cents or smallest currency unit
PurchaseCurrency string
PurchaseStatus PurchaseStatus
PurchaseReceipt string
}
type PurchasedAssetInfo struct {
ID uuid.UUID
Name string
Description string
RestOfFields json.RawMessage
}

View File

@@ -0,0 +1,13 @@
package skill
import (
"context"
"github.com/google/uuid"
)
// Repository provides access to the skills catalog.
type Repository interface {
FindAll(ctx context.Context) ([]*Skill, error)
FindByID(ctx context.Context, id uuid.UUID) (*Skill, error)
}

View File

@@ -0,0 +1,9 @@
package skill
import "github.com/google/uuid"
// Skill represents a selectable skill from the catalog (for profile skill selection).
type Skill struct {
ID uuid.UUID
Name string
}

View File

@@ -0,0 +1,36 @@
package ticket
import (
"github.com/google/uuid"
)
//go:generate stringer -type=TicketStatus
type TicketStatus int
const (
TicketStatusOpen TicketStatus = iota
TicketStatusClosed
TicketStatusPending
TicketStatusDeleted
)
//go:generate stringer -type=TicketPriority
type TicketPriority int
const (
TicketPriorityLow TicketPriority = iota
TicketPriorityMedium
TicketPriorityHigh
)
type Ticket struct {
ID uuid.UUID
UserID uuid.UUID
Title string
Description string
Status TicketStatus
Priority TicketPriority
Category string
}

17
internal/dto/account.go Normal file
View File

@@ -0,0 +1,17 @@
package dto
import (
"github.com/google/uuid"
)
// UserInfoResponse is a flat response with user and profile_id.
type UserInfoResponse struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number"`
EmailVerified bool `json:"email_verified"`
Status string `json:"status"`
ProfileID *uuid.UUID `json:"profile_id,omitempty"`
}

138
internal/dto/asset.go Normal file
View File

@@ -0,0 +1,138 @@
package dto
import (
"base/pkg/validation"
"github.com/google/uuid"
)
type CreateAssetRequest struct {
ProfileID string `json:"profile_id"`
AssetCategoryID string `json:"asset_category_id"`
Title string `json:"title"`
Description string `json:"description"`
Link string `json:"link"`
}
func (*CreateAssetRequest) Schema() validation.Schema {
return validation.Schema{
"profile_id": validation.Rule{Field: "profile_id", Type: validation.ValidationTypeString, Required: true},
"asset_category_id": validation.Rule{Field: "asset_category_id", Type: validation.ValidationTypeString, Required: true},
"title": validation.Rule{Field: "title", Type: validation.ValidationTypeString, Required: true},
}
}
type UpdateAssetRequest struct {
ID string `uri:"id"`
AssetCategoryID string `json:"asset_category_id"`
Title string `json:"title"`
Description string `json:"description"`
Link string `json:"link"`
Status *int `json:"status"`
}
func (*UpdateAssetRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{Field: "id", Type: validation.ValidationTypeString, Required: true},
}
}
type GetAssetRequest struct {
ID string `uri:"id"`
}
func (*GetAssetRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{Field: "id", Type: validation.ValidationTypeString, Required: true},
}
}
type ListAssetsByProfileRequest struct {
ProfileID string `uri:"id"`
}
func (*ListAssetsByProfileRequest) Schema() validation.Schema {
return validation.Schema{
"ProfileID": validation.Rule{Field: "profile_id", Type: validation.ValidationTypeString, Required: true},
}
}
type DeleteAssetRequest struct {
ID string `uri:"id"`
}
func (*DeleteAssetRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{Field: "id", Type: validation.ValidationTypeString, Required: true},
}
}
type AssetResponse struct {
ID uuid.UUID `json:"id"`
ProfileID uuid.UUID `json:"profile_id"`
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
AssetCategoryID uuid.UUID `json:"asset_category_id"`
Title string `json:"title"`
Description string `json:"description"`
Link string `json:"link"`
CoverImage string `json:"cover_image,omitempty"`
Status int `json:"status"`
Category CategoryDTO `json:"category"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CategoryDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Color string `json:"color"`
CardType string `json:"card_type"`
Featured bool `json:"featured"`
Description string `json:"description"`
}
type ListAssetsResponse struct {
Assets []AssetResponse `json:"assets"`
}
// ListAssetsByCategoryIDResponse is paginated assets for a single category (Phase 2 of two-phase loading).
type ListAssetsByCategoryIDResponse struct {
Category CategoryDTO `json:"category"`
Assets []AssetResponse `json:"assets"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
type ListCategoriesResponse struct {
Categories []CategoryDTO `json:"categories"`
}
// CategoriesPreviewRequest holds the request body for POST /assets/categories/preview.
type CategoriesPreviewRequest struct {
CategoryIDs []string `json:"category_ids"`
AssetsPerCategory int `json:"assets_per_category"`
FeaturedOnly bool `json:"featured_only"`
}
func (*CategoriesPreviewRequest) Schema() validation.Schema {
return validation.Schema{
"category_ids": validation.Rule{Field: "category_ids", Type: validation.ValidationTypeArray, Required: false},
"assets_per_category": validation.Rule{Field: "assets_per_category", Type: validation.ValidationTypeInt, Required: false},
"featured_only": validation.Rule{Field: "featured_only", Type: validation.ValidationTypeBool, Required: false},
}
}
// CategoryWithPreviewAssetsDTO groups a category with up to N sample assets.
type CategoryWithPreviewAssetsDTO struct {
Category CategoryDTO `json:"category"`
Assets []AssetResponse `json:"assets"`
TotalAssets int `json:"total_assets,omitempty"`
HasMore bool `json:"has_more,omitempty"`
}
// CategoriesPreviewResponse is the response for POST /assets/categories/preview.
type CategoriesPreviewResponse struct {
Categories []CategoryWithPreviewAssetsDTO `json:"categories"`
}

291
internal/dto/auth.go Normal file
View File

@@ -0,0 +1,291 @@
package dto
import (
"github.com/google/uuid"
"base/internal/pkg/oauth"
"base/pkg/validation"
)
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PhoneNumber string `json:"phone_number"`
}
func (*RegisterRequest) Schema() validation.Schema {
return validation.Schema{
"email": validation.Rule{
Field: "email",
Type: validation.ValidationTypeEmail,
Required: true,
},
"password": validation.Rule{
Field: "password",
Type: validation.ValidationTypeString,
MinLength: validation.IntPtr(8),
MaxLength: validation.IntPtr(32),
Required: true,
},
"first_name": validation.Rule{
Field: "first_name",
Type: validation.ValidationTypeString,
Required: true,
},
"last_name": validation.Rule{
Field: "last_name",
Type: validation.ValidationTypeString,
Required: true,
},
"phone_number": validation.Rule{
Field: "phone_number",
Type: validation.ValidationTypeString,
Required: false,
},
}
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (*LoginRequest) Schema() validation.Schema {
return validation.Schema{
"email": validation.Rule{
Field: "email",
Type: validation.ValidationTypeEmail,
Required: true,
},
"password": validation.Rule{
Field: "password",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func (*TokenResponse) Schema() validation.Schema {
return validation.Schema{
"access_token": validation.Rule{
Field: "access_token",
Type: validation.ValidationTypeString,
Required: true,
},
"refresh_token": validation.Rule{
Field: "refresh_token",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token"`
}
func (*RefreshTokenRequest) Schema() validation.Schema {
return validation.Schema{
"refresh_token": validation.Rule{
Field: "refresh_token",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type SendVerificationEmailRequest struct {
Email string `json:"email"`
}
func (*SendVerificationEmailRequest) Schema() validation.Schema {
return validation.Schema{
"email": validation.Rule{
Field: "email",
Type: validation.ValidationTypeEmail,
Required: true,
},
}
}
type SetupProfileRequest struct {
Handle string `json:"handle"`
RoleID uuid.UUID `json:"role_id"`
RoleLevel string `json:"role_level"`
ShortDescription string `json:"short_description"`
}
func (*SetupProfileRequest) Schema() validation.Schema {
return validation.Schema{
"handle": validation.Rule{
Field: "handle",
Type: validation.ValidationTypeString,
MinLength: validation.IntPtr(2),
MaxLength: validation.IntPtr(80),
Required: true,
},
"role_id": validation.Rule{
Field: "role_id",
Type: validation.ValidationTypeUUID,
Required: false,
},
"role_level": validation.Rule{
Field: "role_level",
Type: validation.ValidationTypeString,
Required: false,
},
"short_description": validation.Rule{
Field: "short_description",
Type: validation.ValidationTypeString,
Required: false,
},
}
}
type VerifyAccountRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func (*VerifyAccountRequest) Schema() validation.Schema {
return validation.Schema{
"email": validation.Rule{
Field: "email",
Type: validation.ValidationTypeEmail,
Required: true,
},
"code": validation.Rule{
Field: "code",
Type: validation.ValidationTypeString,
MinLength: validation.IntPtr(6),
MaxLength: validation.IntPtr(6),
Required: true,
},
}
}
type OAuthRedirectURLRequest struct {
Provider oauth.Provider `json:"provider"`
}
func (*OAuthRedirectURLRequest) Schema() validation.Schema {
return validation.Schema{
"provider": validation.Rule{
Field: "provider",
Path: "provider",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type OAuthRedirectURLResponse struct {
RedirectURL string `json:"redirect_url"`
}
func (*OAuthRedirectURLResponse) Schema() validation.Schema {
return validation.Schema{
"redirect_url": validation.Rule{
Field: "redirect_url",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type OAuthCallbackRequest struct {
Provider oauth.Provider `json:"provider"`
Code string `json:"code"`
}
func (*OAuthCallbackRequest) Schema() validation.Schema {
return validation.Schema{
"provider": validation.Rule{
Field: "provider",
Type: validation.ValidationTypeString,
Required: true,
},
"code": validation.Rule{
Field: "code",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type OAuthCallbackResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IsNewUser bool `json:"is_new_user"`
}
func (*OAuthCallbackResponse) Schema() validation.Schema {
return validation.Schema{
"access_token": validation.Rule{
Field: "access_token",
Type: validation.ValidationTypeString,
Required: true,
},
"refresh_token": validation.Rule{
Field: "refresh_token",
Type: validation.ValidationTypeString,
Required: true,
},
"is_new_user": validation.Rule{
Field: "is_new_user",
Type: validation.ValidationTypeBool,
Required: true,
},
}
}
type SendResetPasswordEmailRequest struct {
Email string `json:"email"`
}
func (*SendResetPasswordEmailRequest) Schema() validation.Schema {
return validation.Schema{
"email": validation.Rule{
Field: "email",
Type: validation.ValidationTypeEmail,
Required: true,
},
}
}
type ResetPasswordRequest struct {
Email string `json:"email"`
Code string `json:"code"`
Password string `json:"password"`
}
func (*ResetPasswordRequest) Schema() validation.Schema {
return validation.Schema{
"email": validation.Rule{
Field: "email",
Type: validation.ValidationTypeEmail,
Required: true,
},
"code": validation.Rule{
Field: "code",
Type: validation.ValidationTypeString,
MinLength: validation.IntPtr(6),
MaxLength: validation.IntPtr(6),
Required: true,
},
"password": validation.Rule{
Field: "password",
Type: validation.ValidationTypeString,
MinLength: validation.IntPtr(8),
MaxLength: validation.IntPtr(32),
Required: true,
},
}
}

7
internal/dto/base.go Normal file
View File

@@ -0,0 +1,7 @@
package dto
import "base/pkg/validation"
type DTO interface {
Schema() validation.Schema
}

26
internal/dto/blog.go Normal file
View File

@@ -0,0 +1,26 @@
package dto
import "time"
type Blog struct {
Id string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Summary string `json:"summary"`
CoverImage string `json:"cover_image"`
ContentHtml string `json:"content_html"`
ContentJson interface{} `json:"content_json"`
Status string `json:"status"`
IsFeatured bool `json:"is_featured"`
ViewCount int `json:"view_count"`
Slug string `json:"slug"`
CategoryId string `json:"category_id"`
Category struct {
Id string `json:"id"`
Title string `json:"title"`
} `json:"category"`
MetaTags interface{} `json:"meta_tags"`
Author string `json:"author"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

92
internal/dto/landing.go Normal file
View File

@@ -0,0 +1,92 @@
package dto
//type Landing struct {
// Message string `json:"message"`
// Data struct {
// Categories []struct {
// Id string `json:"id"`
// Title string `json:"title"`
// Featured bool `json:"featured"`
// Icon string `json:"icon"`
// Color string `json:"color"`
// CardType string `json:"card_type"`
// Order int `json:"order"`
// CreatedAt time.Time `json:"created_at"`
// UpdatedAt time.Time `json:"updated_at"`
// } `json:"categories"`
// SpecialistRole []struct {
// Id string `json:"id"`
// Title string `json:"title"`
// Status string `json:"status"`
// } `json:"specialist_role"`
// Assets []struct {
// Id string `json:"id"`
// Title string `json:"title"`
// Icon string `json:"icon"`
// Assets []struct {
// Id string `json:"id"`
// CoverImage string `json:"cover_image"`
// Title string `json:"title"`
// Avatar string `json:"avatar"`
// Description string `json:"description"`
// AuthorName string `json:"author_name"`
// Price int `json:"price"`
// Currency string `json:"currency"`
// CategoryId string `json:"category_id"`
// CategoryName string `json:"category_name"`
// CardType string `json:"card_type"`
// } `json:"assets"`
// } `json:"assets"`
// Specialists []struct {
// Id string `json:"id"`
// Handle string `json:"handle"`
// Avatar string `json:"avatar"`
// } `json:"specialists"`
// Blog []struct {
// Id string `json:"id"`
// Title string `json:"title"`
// Content string `json:"content"`
// Summary string `json:"summary"`
// CoverImage string `json:"cover_image"`
// ContentHtml string `json:"content_html"`
// ContentJson interface{} `json:"content_json"`
// Status string `json:"status"`
// IsFeatured bool `json:"is_featured"`
// ViewCount int `json:"view_count"`
// Slug string `json:"slug"`
// CategoryId string `json:"category_id"`
// Category struct {
// Id string `json:"id"`
// Title string `json:"title"`
// } `json:"category"`
// MetaTags interface{} `json:"meta_tags"`
// Author string `json:"author"`
// CreatedAt time.Time `json:"created_at"`
// UpdatedAt time.Time `json:"updated_at"`
// } `json:"blog"`
// } `json:"data"`
//}
type Landing struct {
Message string `json:"message"`
Data LandingPageData `json:"data"`
}
type AssetCategory struct {
Id string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
}
type LandingAssetData struct {
AssetCategory
Assets []AssetResponse `json:"assets"`
}
type LandingPageData struct {
Categories []CategoryDTO `json:"categories"`
SpecialistRoles []ProfileRole `json:"specialist_roles"`
Assets []LandingAssetData `json:"assets"`
Specialists []Specialist `json:"specialists"`
Blogs []Blog `json:"blogs"`
}

157
internal/dto/overview.go Normal file
View File

@@ -0,0 +1,157 @@
package dto
import "time"
// OverviewResponse is the dashboard response for authenticated users with a profile
type OverviewResponse struct {
Message string `json:"message"`
Data OverviewDataDTO `json:"data"`
}
type OverviewDataDTO struct {
Analytics AnalyticsDTO `json:"analytics"`
RecentlyJoined []FlatProfileDTO `json:"recently_joined"`
Assets []AssetResponse `json:"assets"`
CompletionPercent int `json:"completionPercent"`
Tasks TasksDTO `json:"tasks"`
}
// OverviewFetchedResponse matches "Overview fetched successfully" format (assets with content, cover_image, etc.)
type OverviewFetchedResponse struct {
Message string `json:"message"`
Data OverviewFetchedDataDTO `json:"data"`
}
type OverviewFetchedDataDTO struct {
Assets []OverviewAssetDTO `json:"assets"`
RecentlyJoined []FlatProfileDTO `json:"recently_joined"`
Analytics AnalyticsDTO `json:"analytics"`
}
// SpecialistOverviewFetchedDataDTO extends OverviewFetchedDataDTO with specialist's Profile, Skills, completionPercent, and tasks
type SpecialistOverviewFetchedDataDTO struct {
Assets []OverviewAssetDTO `json:"assets"`
RecentlyJoined []FlatProfileDTO `json:"recently_joined"`
Analytics AnalyticsDTO `json:"analytics"`
Profile *ProfileResponse `json:"profile,omitempty"`
Skills []SkillDTO `json:"skills,omitempty"`
CompletionPercent int `json:"completionPercent"`
Tasks TasksDTO `json:"tasks"`
}
// SpecialistOverviewFetchedResponse is the specialist overview response (includes Profile + Skills)
type SpecialistOverviewFetchedResponse struct {
Message string `json:"message"`
Data SpecialistOverviewFetchedDataDTO `json:"data"`
}
// OverviewAssetDTO is the full asset format for overview (content, cover_image, price, etc.)
type OverviewAssetDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
AssetCategoryID string `json:"asset_category_id"`
AssetCategory *CategoryDTO `json:"asset_category"`
CoverImage string `json:"cover_image"`
Link string `json:"link"`
OwnerID string `json:"owner_id"`
ProfileID string `json:"profile_id"`
Profile interface{} `json:"profile"`
Price int `json:"price"`
Currency string `json:"currency"`
Status string `json:"status"`
Rating int `json:"rating"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AnalyticsDTO struct {
TotalAssets int `json:"total_assets"`
TotalProfiles int `json:"total_profiles"`
}
// CategoryAssetsDTO groups assets under a category for discovery.
type CategoryAssetsDTO struct {
Category CategoryDTO `json:"category"`
Assets []OverviewAssetDTO `json:"assets"`
}
// CategoryAssetsPaginatedDTO groups paginated assets under a category.
type CategoryAssetsPaginatedDTO struct {
Category CategoryDTO `json:"category"`
Assets []OverviewAssetDTO `json:"assets"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// ListAssetsByCategoryResponse is the paginated API response for assets by category.
type ListAssetsByCategoryResponse struct {
Data ListAssetsByCategoryResponseData `json:"data"`
}
// ListAssetsByCategoryResponseData holds the categories with paginated assets.
type ListAssetsByCategoryResponseData struct {
Categories []CategoryAssetsPaginatedDTO `json:"categories"`
}
// AssetsByCategoryResponse is the API response for assets grouped by category (at least 6 per category).
type AssetsByCategoryResponse struct {
Message string `json:"message"`
Data AssetsByCategoryResponseData `json:"data"`
}
type AssetsByCategoryResponseData struct {
Categories map[string]CategoryAssetsDTO `json:"categories"`
}
type TasksDTO struct {
ProfileAction bool `json:"profile_action"`
AboutAction bool `json:"about_action"`
PublishAction bool `json:"publish_action"`
WorksAction bool `json:"works_action"`
SkillsAction bool `json:"skills_action"`
SocialAction bool `json:"social_action"`
}
// FlatProfileDTO is the flat profile format for recently_joined and similar lists
type FlatProfileDTO struct {
ID string `json:"id"`
ProfileHandle string `json:"profile_handle"`
Status string `json:"status"`
BackgroundImage string `json:"background_image"`
ProfilePicture string `json:"profile_picture"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
DisplayName string `json:"display_name"`
RoleID string `json:"role_id"`
Role RoleDTO `json:"role"`
CurrentCompany string `json:"current_company"`
ShortDescription string `json:"short_description"`
CTAEnabled bool `json:"cta_enabled"`
CTAAction string `json:"cta_action"`
ResumeLink string `json:"resume_link"`
About string `json:"about"`
ContactEmail string `json:"contact_email"`
Achievements map[string]AchievementItemDTO `json:"achievements"`
ContactPhone string `json:"contact_phone"`
Country string `json:"country"`
CustomRoles string `json:"custom_roles"`
RoleLevel string `json:"role_level"`
SocialLinks []SocialLinkDTO `json:"social_links"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HandleUpdatedAt time.Time `json:"handle_updated_at"`
}
type RoleDTO struct {
ID string `json:"ID"`
Name string `json:"Name"`
}
type AchievementItemDTO struct {
Value string `json:"value"`
Enabled bool `json:"enabled"`
}

190
internal/dto/profile.go Normal file
View File

@@ -0,0 +1,190 @@
package dto
import (
"base/pkg/validation"
"github.com/google/uuid"
)
type CreateProfileRequest struct {
Handle string `json:"handle"`
PageSectionOrder map[string]int `json:"page_section_order"`
Hero HeroDTO `json:"hero"`
About AboutDTO `json:"about"`
Skills []SkillDTO `json:"skills"`
Contact ContactDTO `json:"contact"`
PageSetting PageSettingDTO `json:"page_setting"`
}
func (*CreateProfileRequest) Schema() validation.Schema {
return validation.Schema{
"handle": validation.Rule{
Field: "handle",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type HeroDTO struct {
RoleID *uuid.UUID `json:"role_id"`
RoleLevel string `json:"role_level"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Company string `json:"company"`
ShortDescription string `json:"short_description"`
ResumeLink string `json:"resume_link"`
CTAEnabled bool `json:"cta_enabled"`
Avatar string `json:"avatar"`
}
func (*HeroDTO) Schema() validation.Schema { return validation.Schema{} }
type AboutDTO struct {
ProfilePicture string `json:"profile_picture"`
About string `json:"about"`
Achievements []AchievementDTO `json:"achievements"`
}
type AchievementDTO struct {
Title string `json:"title"`
Value string `json:"value"`
Enabled bool `json:"enabled"`
}
type SkillDTO struct {
SkillName string `json:"skill_name"`
Level string `json:"level"`
}
type ContactDTO struct {
Email string `json:"email"`
Phone string `json:"phone"`
SocialLinks []SocialLinkDTO `json:"social_links"`
}
func (*ContactDTO) Schema() validation.Schema { return validation.Schema{} }
type SocialLinkDTO struct {
LinkType string `json:"link_type"`
Link string `json:"link"`
}
type PageSettingDTO struct {
VisibilityLevel string `json:"visibility_level"`
}
func (*PageSettingDTO) Schema() validation.Schema {
return validation.Schema{}
}
type UpdateProfileRequest struct {
ID string `uri:"id"`
Handle string `json:"handle"`
PageSectionOrder map[string]int `json:"page_section_order"`
Hero HeroDTO `json:"hero"`
About AboutDTO `json:"about"`
Skills []SkillDTO `json:"skills"`
Contact ContactDTO `json:"contact"`
PageSetting PageSettingDTO `json:"page_setting"`
}
func (*UpdateProfileRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{
Field: "id",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type GetProfileRequest struct {
ID string `uri:"id"`
}
func (*GetProfileRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{
Field: "id",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type GetProfileByHandleRequest struct {
Handle string `uri:"handle"`
}
func (*GetProfileByHandleRequest) Schema() validation.Schema {
return validation.Schema{
"handle": validation.Rule{
Field: "handle",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
type ListProfilesRequest struct {
RoleID *uuid.UUID `form:"role_id"`
FirstName string `form:"first_name"`
LastName string `form:"last_name"`
Company string `form:"company"`
SkillName string `form:"skill_name"`
Page uint `form:"page"`
PageSize uint `form:"page_size"`
SortedBy string `form:"sorted_by"`
Ascending bool `form:"ascending"`
}
func (*ListProfilesRequest) Schema() validation.Schema {
return validation.Schema{}
}
type ProfileResponse struct {
ID uuid.UUID `json:"id"`
Handle string `json:"handle"`
PageSectionOrder map[string]int `json:"page_section_order"`
Hero HeroDTO `json:"hero"`
About AboutDTO `json:"about"`
Skills []SkillDTO `json:"skills"`
Contact ContactDTO `json:"contact"`
PageSetting PageSettingDTO `json:"page_setting"`
}
type ListProfilesResponse struct {
Profiles []ProfileResponse `json:"profiles"`
Total int `json:"total"`
Page uint `json:"page"`
PageSize uint `json:"page_size"`
}
type DeleteProfileRequest struct {
ID string `uri:"id"`
}
func (*DeleteProfileRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{
Field: "id",
Type: validation.ValidationTypeString,
Required: true,
},
}
}
// SkillsUpdateRequest for PUT page-sections/skills
type SkillsUpdateRequest struct {
Skills []SkillDTO `json:"skills"`
}
func (*SkillsUpdateRequest) Schema() validation.Schema { return validation.Schema{} }
// PageSectionsResponse for GET page-sections (hero, contact, skills, page_section_order)
type PageSectionsResponse struct {
Hero HeroDTO `json:"hero"`
Contact ContactDTO `json:"contact"`
Skills []SkillDTO `json:"skills"`
PageSectionOrder map[string]int `json:"page_section_order"`
}

107
internal/dto/response.go Normal file
View File

@@ -0,0 +1,107 @@
package dto
import "net/http"
// SuccessResponse represents a successful response for setting stock.
type SuccessResponse struct {
Message string `json:"message"`
Status int `json:"status" example:"200"`
}
// ErrorResponse represents a generic error response.
type ErrorResponse struct {
Message string `json:"message"`
Status int `json:"status" example:"400"`
}
type Response struct {
Message string `json:"message"`
Status int `json:"status"`
Data any `json:"data,omitempty"`
}
func OK() Response {
return Response{
Message: "OK",
Status: http.StatusOK,
}
}
func Created(data any) Response {
return Response{
Message: "Created",
Status: http.StatusCreated,
Data: data,
}
}
func BadRequest() Response {
return Response{
Message: "bad request",
Status: http.StatusBadRequest,
}
}
func NotFound() Response {
return Response{
Message: "not found",
Status: http.StatusNotFound,
}
}
func InternalServerError() Response {
return Response{
Message: "internal server error",
Status: http.StatusInternalServerError,
}
}
func UnprocessableEntity() Response {
return Response{
Message: "unprocessable entity",
Status: http.StatusUnprocessableEntity,
}
}
func UnprocessableEntityException() Response {
return Response{
Message: "unprocessable entity exception",
Status: http.StatusUnprocessableEntity,
}
}
func Forbidden() Response {
return Response{
Message: "forbidden",
Status: http.StatusForbidden,
}
}
func Unauthorized() Response {
return Response{
Message: "unauthorized",
Status: http.StatusUnauthorized,
}
}
func Conflict() Response {
return Response{
Message: "conflict",
Status: http.StatusConflict,
}
}
func (r Response) WithMessage(msg string) Response {
r.Message = msg
return r
}
func (r Response) WithStatus(status int) Response {
r.Status = status
return r
}
func (r Response) WithData(data any) Response {
r.Data = data
return r
}

50
internal/dto/role.go Normal file
View File

@@ -0,0 +1,50 @@
package dto
import "base/pkg/validation"
type ProfileRole struct {
Id string `json:"id"`
Title string `json:"title"`
}
type CreateProfileRoleRequest struct {
Title string `json:"title"`
}
func (*CreateProfileRoleRequest) Schema() validation.Schema {
return validation.Schema{
"title": validation.Rule{Field: "title", Type: validation.ValidationTypeString, Required: true},
"status": validation.Rule{Field: "status", Type: validation.ValidationTypeString, Required: true},
}
}
type UpdateProfileRoleRequest struct {
ID string `uri:"id"`
Title string `json:"title"`
}
func (*UpdateProfileRoleRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{Field: "id", Type: validation.ValidationTypeString, Required: true},
}
}
type GetProfileRoleRequest struct {
ID string `uri:"id"`
}
func (*GetProfileRoleRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{Field: "id", Type: validation.ValidationTypeString, Required: true},
}
}
type DeleteProfileRoleRequest struct {
ID string `uri:"id"`
}
func (*DeleteProfileRoleRequest) Schema() validation.Schema {
return validation.Schema{
"id": validation.Rule{Field: "id", Type: validation.ValidationTypeString, Required: true},
}
}

7
internal/dto/skill.go Normal file
View File

@@ -0,0 +1,7 @@
package dto
// Skill represents a selectable skill from the catalog (for profile skill selection).
type Skill struct {
ID string `json:"id"`
Name string `json:"name"`
}

View File

@@ -0,0 +1,7 @@
package dto
type Specialist struct {
Id string `json:"id"`
Handle string `json:"handle"`
Avatar string `json:"avatar"`
}

View File

@@ -0,0 +1,17 @@
package azblob
import (
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/rs/zerolog"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
)
func New(logger zerolog.Logger, cred *azidentity.DefaultAzureCredential) (*azblob.Client, error) {
client, err := azblob.NewClientFromConnectionString("", nil)
if err != nil {
logger.Error().Err(err).Msg("failed to create azure blob storage client")
return nil, err
}
return client, nil
}

View File

@@ -0,0 +1,27 @@
package azbus
import (
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
"github.com/rs/zerolog"
"base/config"
"base/pkg/watermill/azsb"
)
func New(cfg *config.AppConfig, logger zerolog.Logger) (message.Subscriber, message.Publisher, error) {
if cfg.Environment == config.Local {
gch := gochannel.NewGoChannel(gochannel.Config{}, watermill.NewStdLogger(true, true))
return gch, gch, nil
}
return azsb.NewAzBus(
azsb.Config{
ConnectionString: cfg.AzureServiceBus.ConnectionString,
UseManagedIdentity: cfg.AzureServiceBus.UseManagedIdentity,
Namespace: cfg.AzureServiceBus.Namespace,
},
logger,
)
}

View File

@@ -0,0 +1,15 @@
package azureidentity
import (
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/rs/zerolog"
)
func New(logger zerolog.Logger) (*azidentity.DefaultAzureCredential, error) {
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
logger.Error().Err(err).Msg("azure identity error")
return nil, err
}
return cred, nil
}

View File

@@ -0,0 +1,143 @@
package communication
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"time"
"github.com/rs/zerolog"
"base/config"
"base/pkg/email"
)
type client struct {
logger zerolog.Logger
endpoint string
accessKey string
apiVersion string
senderAddress string
templates *template.Template
}
func New(logger zerolog.Logger, config *config.AppConfig) email.Email {
return &client{
logger: logger,
endpoint: config.AzureCommunicationConfig.Endpoint,
accessKey: config.AzureCommunicationConfig.AccessKey,
apiVersion: config.AzureCommunicationConfig.ApiVersion,
senderAddress: config.AzureCommunicationConfig.SenderAddress,
}
}
func (c client) Send(ctx context.Context, params email.Request) (*email.Response, error) {
var tpl bytes.Buffer
if err := c.templates.ExecuteTemplate(&tpl, generateTemplateName(params.Template.EmailTemplateName), params.Template.Data); err != nil {
return nil, err
}
html := tpl.String()
request := &ApiRequest{
SenderAddress: c.senderAddress,
Content: ApiContentDto{
Subject: params.Subject,
Html: html,
},
Recipients: ApiRecipientDto{
To: []ApiRecipientDetailDto{
{
Address: params.RecipientAddress,
DisplayName: params.UserFullName,
},
},
CC: make([]ApiRecipientDetailDto, 0),
BCC: make([]ApiRecipientDetailDto, 0),
},
}
byteBody, err := json.Marshal(&request)
if err != nil {
return nil, errors.New("marshaling error")
}
method := "POST"
endpoint := c.endpoint
u, _ := url.Parse(endpoint)
snedPathAndQuery := fmt.Sprintf(
"/emails:send?api-version=%s",
c.apiVersion,
)
date := time.Now().In(time.FixedZone("GMT", 0)).Format("Mon, 02 Jan 2006 15:04:05 GMT")
host := u.Host
contentHash := computeContentHash(byteBody)
stringToSign := fmt.Sprintf("%s\n%s\n%s;%s;%s", method, snedPathAndQuery, date, host, contentHash)
signature, err := computeSignature(stringToSign, c.accessKey)
if err != nil {
return nil, err
}
authHeader := fmt.Sprintf("HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=%s", signature)
fullURL := endpoint + snedPathAndQuery
req, _ := http.NewRequest(method, fullURL, bytes.NewReader(byteBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-ms-date", date)
req.Header.Set("x-ms-content-sha256", contentHash)
req.Header.Set("Authorization", authHeader)
req.Header.Set("Host", host)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
response := &ApiErrorResponse{}
if err := json.NewDecoder(resp.Body).Decode(response); err != nil {
return nil, err
}
c.logger.Info().Msgf("email sending failed. %v", response)
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
c.logger.Info().Msgf("email sending done. %v", resp.Body)
response := &email.Response{}
if err := json.NewDecoder(resp.Body).Decode(response); err != nil {
return nil, err
}
return response, nil
}
func computeContentHash(body []byte) string {
sum := sha256.Sum256(body)
return base64.StdEncoding.EncodeToString(sum[:])
}
func computeSignature(stringToSign, base64AccessKey string) (string, error) {
key, err := base64.StdEncoding.DecodeString(base64AccessKey)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, key)
_, err = mac.Write([]byte(stringToSign))
if err != nil {
return "", err
}
sig := mac.Sum(nil)
return base64.StdEncoding.EncodeToString(sig), nil
}
func generateTemplateName(emailTemplateName email.Template) string {
return fmt.Sprintf("%s.html", emailTemplateName.String())
}

View File

@@ -0,0 +1,41 @@
package communication
type ApiResponse struct {
ID string `json:"id"`
Status string `json:"status"`
}
type ApiContentDto struct {
Subject string `json:"subject"`
Html string `json:"html"`
PlainText string `json:"plainText"`
}
type ApiRecipientDetailDto struct {
Address string `json:"address"`
DisplayName string `json:"displayName"`
}
type ApiRecipientDto struct {
To []ApiRecipientDetailDto `json:"to"`
CC []ApiRecipientDetailDto `json:"cc"`
BCC []ApiRecipientDetailDto `json:"bcc"`
}
type ApiRequest struct {
SenderAddress string `json:"senderAddress"`
Content ApiContentDto `json:"content"`
Recipients ApiRecipientDto `json:"recipients"`
}
type ApiErrorResponse struct {
Error struct {
AdditionalInfo []struct {
Info any `json:"info"`
Type string `json:"type"`
} `json:"additionalInfo"`
Code string `json:"code"`
Message string `json:"message"`
Target string `json:"target"`
Details any `json:"details"`
} `json:"error"`
}

Some files were not shown because too many files have changed in this diff Show More