initial commit
This commit is contained in:
349
internal/application/asset/service.go
Normal file
349
internal/application/asset/service.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
domainAsset "base/internal/domain/asset"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAssetNotFound = errors.New("asset not found")
|
||||
ErrCategoryNotFound = errors.New("asset category not found")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, req dto.CreateAssetRequest) (*dto.AssetResponse, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*dto.AssetResponse, error)
|
||||
Update(ctx context.Context, req dto.UpdateAssetRequest) (*dto.AssetResponse, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
FindByProfileID(ctx context.Context, profileID uuid.UUID) (*dto.ListAssetsResponse, error)
|
||||
ListCategories(ctx context.Context) (*dto.ListCategoriesResponse, error)
|
||||
ListByCategoryID(ctx context.Context, categoryID uuid.UUID, limit, page int) (*dto.ListAssetsByCategoryIDResponse, error)
|
||||
GetCategoriesWithPreview(ctx context.Context, req dto.CategoriesPreviewRequest) (*dto.CategoriesPreviewResponse, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
assetRepo domainAsset.AssetRepository
|
||||
categoryRepo domainAsset.CategoryRepository
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
AssetRepo domainAsset.AssetRepository
|
||||
CategoryRepo domainAsset.CategoryRepository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
assetRepo: param.AssetRepo,
|
||||
categoryRepo: param.CategoryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, req dto.CreateAssetRequest) (*dto.AssetResponse, error) {
|
||||
profileID, err := uuid.Parse(req.ProfileID)
|
||||
if err != nil {
|
||||
return nil, ErrAssetNotFound
|
||||
}
|
||||
categoryID, err := uuid.Parse(req.AssetCategoryID)
|
||||
if err != nil {
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
|
||||
// Verify category exists
|
||||
category, err := s.categoryRepo.FindByID(ctx, categoryID)
|
||||
if err != nil || category == nil {
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
|
||||
asset := &domainAsset.Asset{
|
||||
ID: uuid.New(),
|
||||
ProfileID: profileID,
|
||||
AssetCategoryID: categoryID,
|
||||
AssetCategory: *category,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Link: req.Link,
|
||||
Status: domainAsset.StatusPublished,
|
||||
}
|
||||
|
||||
if err := s.assetRepo.Create(ctx, asset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toAssetResponse(asset), nil
|
||||
}
|
||||
|
||||
func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.AssetResponse, error) {
|
||||
asset, err := s.assetRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, ErrAssetNotFound
|
||||
}
|
||||
return s.toAssetResponse(asset), nil
|
||||
}
|
||||
|
||||
func (s *service) Update(ctx context.Context, req dto.UpdateAssetRequest) (*dto.AssetResponse, error) {
|
||||
id, err := uuid.Parse(req.ID)
|
||||
if err != nil {
|
||||
return nil, ErrAssetNotFound
|
||||
}
|
||||
|
||||
asset, err := s.assetRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, ErrAssetNotFound
|
||||
}
|
||||
|
||||
asset.Title = req.Title
|
||||
asset.Description = req.Description
|
||||
asset.Link = req.Link
|
||||
|
||||
if req.AssetCategoryID != "" {
|
||||
categoryID, err := uuid.Parse(req.AssetCategoryID)
|
||||
if err == nil {
|
||||
category, err := s.categoryRepo.FindByID(ctx, categoryID)
|
||||
if err == nil && category != nil {
|
||||
asset.AssetCategoryID = categoryID
|
||||
asset.AssetCategory = *category
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Status != nil && *req.Status >= 0 && *req.Status <= 3 {
|
||||
asset.Status = domainAsset.Status(*req.Status)
|
||||
}
|
||||
|
||||
if err := s.assetRepo.Update(ctx, asset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toAssetResponse(asset), nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
asset, err := s.assetRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return ErrAssetNotFound
|
||||
}
|
||||
return s.assetRepo.Delete(ctx, asset)
|
||||
}
|
||||
|
||||
func (s *service) FindByProfileID(ctx context.Context, profileID uuid.UUID) (*dto.ListAssetsResponse, error) {
|
||||
assets, err := s.assetRepo.FindByProfileID(ctx, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &dto.ListAssetsResponse{
|
||||
Assets: make([]dto.AssetResponse, len(assets)),
|
||||
}
|
||||
for i, a := range assets {
|
||||
resp.Assets[i] = *s.toAssetResponse(a)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *service) ListCategories(ctx context.Context) (*dto.ListCategoriesResponse, error) {
|
||||
categories, err := s.categoryRepo.FindAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &dto.ListCategoriesResponse{
|
||||
Categories: make([]dto.CategoryDTO, len(categories)),
|
||||
}
|
||||
for i, c := range categories {
|
||||
resp.Categories[i] = dto.CategoryDTO{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Icon: c.Icon,
|
||||
Color: c.Color,
|
||||
CardType: c.CardType,
|
||||
Featured: c.Featured,
|
||||
Description: c.Description,
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *service) ListByCategoryID(ctx context.Context, categoryID uuid.UUID, limit, page int) (*dto.ListAssetsByCategoryIDResponse, error) {
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
category, err := s.categoryRepo.FindByID(ctx, categoryID)
|
||||
if err != nil || category == nil {
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
|
||||
total, err := s.assetRepo.CountByCategory(ctx, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
assets, err := s.assetRepo.FindLatestByCategoryPaginated(ctx, categoryID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := (total + limit - 1) / limit
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
resp := &dto.ListAssetsByCategoryIDResponse{
|
||||
Category: dto.CategoryDTO{
|
||||
ID: category.ID,
|
||||
Name: category.Name,
|
||||
Icon: category.Icon,
|
||||
Color: category.Color,
|
||||
CardType: category.CardType,
|
||||
Featured: category.Featured,
|
||||
Description: category.Description,
|
||||
},
|
||||
Assets: make([]dto.AssetResponse, len(assets)),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: limit,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
for i, a := range assets {
|
||||
resp.Assets[i] = *s.toAssetResponse(a)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *service) GetCategoriesWithPreview(ctx context.Context, req dto.CategoriesPreviewRequest) (*dto.CategoriesPreviewResponse, error) {
|
||||
perCategory := req.AssetsPerCategory
|
||||
if perCategory < 1 {
|
||||
perCategory = 8
|
||||
}
|
||||
if perCategory > 20 {
|
||||
perCategory = 20
|
||||
}
|
||||
|
||||
var categoryIDs []uuid.UUID
|
||||
for _, s := range req.CategoryIDs {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
categoryIDs = append(categoryIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
var categories []*domainAsset.Category
|
||||
var err error
|
||||
if len(categoryIDs) > 0 {
|
||||
categories, err = s.categoryRepo.FindByIDs(ctx, categoryIDs)
|
||||
} else {
|
||||
categories, err = s.categoryRepo.FindAll(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.FeaturedOnly {
|
||||
filtered := make([]*domainAsset.Category, 0, len(categories))
|
||||
for _, c := range categories {
|
||||
if c.Featured {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
categories = filtered
|
||||
}
|
||||
|
||||
results := make([]dto.CategoryWithPreviewAssetsDTO, len(categories))
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
for index, category := range categories {
|
||||
i, cat := index, category
|
||||
g.Go(func() error {
|
||||
assets, assetErr := s.assetRepo.FindLatestByCategory(gCtx, cat.ID, perCategory)
|
||||
if assetErr != nil {
|
||||
return assetErr
|
||||
}
|
||||
|
||||
total, _ := s.assetRepo.CountByCategory(gCtx, cat.ID)
|
||||
|
||||
assetResps := make([]dto.AssetResponse, len(assets))
|
||||
for j, a := range assets {
|
||||
assetResps[j] = *s.toAssetResponse(a)
|
||||
}
|
||||
|
||||
results[i] = dto.CategoryWithPreviewAssetsDTO{
|
||||
Category: dto.CategoryDTO{
|
||||
ID: cat.ID,
|
||||
Name: cat.Name,
|
||||
Icon: cat.Icon,
|
||||
Color: cat.Color,
|
||||
CardType: cat.CardType,
|
||||
Featured: cat.Featured,
|
||||
Description: cat.Description,
|
||||
},
|
||||
Assets: assetResps,
|
||||
TotalAssets: total,
|
||||
HasMore: total > perCategory,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.CategoriesPreviewResponse{Categories: results}, nil
|
||||
}
|
||||
|
||||
func (s *service) toAssetResponse(a *domainAsset.Asset) *dto.AssetResponse {
|
||||
coverImage := ""
|
||||
for _, art := range a.AssetArtifacts {
|
||||
if strings.Contains(strings.ToLower(art.Type), "image") {
|
||||
coverImage = art.DownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resp := &dto.AssetResponse{
|
||||
ID: a.ID,
|
||||
ProfileID: a.ProfileID,
|
||||
AssetCategoryID: a.AssetCategoryID,
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
Link: a.Link,
|
||||
CoverImage: coverImage,
|
||||
Status: int(a.Status),
|
||||
CreatedAt: formatTime(a.CreatedAt),
|
||||
UpdatedAt: formatTime(a.UpdatedAt),
|
||||
}
|
||||
|
||||
resp.Category = dto.CategoryDTO{
|
||||
ID: a.AssetCategory.ID,
|
||||
Name: a.AssetCategory.Name,
|
||||
Icon: a.AssetCategory.Icon,
|
||||
Color: a.AssetCategory.Color,
|
||||
CardType: a.AssetCategory.CardType,
|
||||
Featured: a.AssetCategory.Featured,
|
||||
Description: a.AssetCategory.Description,
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
49
internal/application/auth/account_info.go
Normal file
49
internal/application/auth/account_info.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"base/internal/domain/auth"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
func (s *service) GetUserInfo(ctx context.Context, userID uuid.UUID) (*dto.UserInfoResponse, error) {
|
||||
user, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
var profileID *uuid.UUID
|
||||
prof, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if err == nil && prof != nil {
|
||||
profileID = &prof.ID
|
||||
}
|
||||
|
||||
return &dto.UserInfoResponse{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
PhoneNumber: user.PhoneNumber,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Status: userStatusToString(user.Status),
|
||||
ProfileID: profileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userStatusToString(s auth.UserStatus) string {
|
||||
switch s {
|
||||
case auth.UserStatusActive:
|
||||
return "active"
|
||||
case auth.UserStatusInactive:
|
||||
return "inactive"
|
||||
case auth.UserStatusPending:
|
||||
return "pending"
|
||||
case auth.UserStatusDeleted:
|
||||
return "deleted"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
86
internal/application/auth/oauth.go
Normal file
86
internal/application/auth/oauth.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"base/pkg/jwt"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"base/internal/domain/auth"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
func (s *service) GetOAuthRedirectURL(ctx context.Context, request dto.OAuthRedirectURLRequest) (string, error) {
|
||||
provider := s.oauthService.Client(request.Provider)
|
||||
|
||||
state := uuid.New().String()
|
||||
redirectURL := provider.GetConsentAuthUrl(ctx, state)
|
||||
|
||||
return redirectURL, nil
|
||||
}
|
||||
|
||||
func (s *service) OAuthCallback(ctx context.Context, request dto.OAuthCallbackRequest) (*dto.OAuthCallbackResponse, error) {
|
||||
oauthProvider := s.oauthService.Client(request.Provider)
|
||||
|
||||
token, err := oauthProvider.ExchangeCodeWithToken(ctx, request.Code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||
}
|
||||
|
||||
userInfo, err := oauthProvider.GetUserInfo(ctx, token.AccessToken, token.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
accessExpiry := now.Add(time.Duration(token.ExpiresIn) * time.Second)
|
||||
refreshExpiry := now.Add(7 * 24 * time.Hour)
|
||||
|
||||
user := &auth.User{
|
||||
ID: uuid.New(), // Will be set by repository if user exists
|
||||
Email: userInfo.Email(),
|
||||
FirstName: userInfo.FirstName(),
|
||||
LastName: userInfo.LastName(),
|
||||
Status: auth.UserStatusActive,
|
||||
EmailVerified: true, // OAuth providers verify email
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
account := &auth.Account{
|
||||
ID: uuid.New(),
|
||||
Provider: request.Provider,
|
||||
AccessToken: &token.AccessToken,
|
||||
RefreshToken: &token.RefreshToken,
|
||||
AccessTokenExpiry: &accessExpiry,
|
||||
RefreshTokenExpiry: &refreshExpiry,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
isNewUser, err := s.userRepo.UpsertWithAccount(ctx, userInfo.Email(), user, account)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to upsert user and account")
|
||||
return nil, fmt.Errorf("failed to upsert user and account: %w", err)
|
||||
}
|
||||
|
||||
tokens, genErr := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
|
||||
if genErr != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens: %w", genErr)
|
||||
}
|
||||
|
||||
s.logger.Info().
|
||||
Str("user_id", user.ID.String()).
|
||||
Str("email", user.Email).
|
||||
Bool("is_new_user", isNewUser).
|
||||
Str("provider", request.Provider.String()).
|
||||
Msg("OAuth callback completed successfully")
|
||||
|
||||
return &dto.OAuthCallbackResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
IsNewUser: isNewUser,
|
||||
}, nil
|
||||
}
|
||||
210
internal/application/auth/register.go
Normal file
210
internal/application/auth/register.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"base/internal/domain/auth"
|
||||
"base/internal/dto"
|
||||
"base/internal/pkg/oauth"
|
||||
"base/pkg/jwt"
|
||||
)
|
||||
|
||||
func (s *service) RegisterWithCredentials(ctx context.Context, request dto.RegisterRequest) (*dto.TokenResponse, error) {
|
||||
// Check if user already exists
|
||||
existingUser, err := s.userRepo.FindByEmail(ctx, request.Email)
|
||||
if err == nil && existingUser != nil {
|
||||
return nil, ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to hash password")
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
hashedPasswordStr := string(hashedPassword)
|
||||
|
||||
id, genErr := uuid.NewV7()
|
||||
if genErr != nil {
|
||||
return nil, genErr
|
||||
}
|
||||
|
||||
// Create user and account within a transaction
|
||||
// If any operation fails, all changes are rolled back
|
||||
user := &auth.User{
|
||||
ID: id,
|
||||
Email: request.Email,
|
||||
FirstName: request.FirstName,
|
||||
LastName: request.LastName,
|
||||
PhoneNumber: request.PhoneNumber,
|
||||
Status: auth.UserStatusPending,
|
||||
EmailVerified: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
account := &auth.Account{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
Provider: oauth.Credentials,
|
||||
Password: &hashedPasswordStr,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err = s.userRepo.CreateWithAccount(ctx, user, account); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create user and account")
|
||||
return nil, fmt.Errorf("failed to create user and account: %w", err)
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
tokens, genTokenErr := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
|
||||
if genTokenErr != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens: %w", genTokenErr)
|
||||
}
|
||||
|
||||
// Update account with tokens
|
||||
account.AccessToken = &tokens.AccessToken
|
||||
account.RefreshToken = &tokens.RefreshToken
|
||||
now := time.Now()
|
||||
accessExpiry := now.Add(24 * time.Hour)
|
||||
refreshExpiry := now.Add(7 * 24 * time.Hour)
|
||||
account.AccessTokenExpiry = &accessExpiry
|
||||
account.RefreshTokenExpiry = &refreshExpiry
|
||||
|
||||
if err = s.accountRepo.Update(ctx, account); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update account with tokens")
|
||||
// Don't fail the registration, tokens are already generated
|
||||
}
|
||||
|
||||
// Profile is created when user calls setup-profile
|
||||
|
||||
return &dto.TokenResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) LoginWithCredentials(ctx context.Context, email, password string) (*dto.TokenResponse, error) {
|
||||
// Find user by email with accounts
|
||||
user, err := s.userRepo.FindByEmail(ctx, email, auth.WithAccounts())
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Check user status
|
||||
if user.Status == auth.UserStatusDeleted {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Find credentials account
|
||||
var credentialsAccount *auth.Account
|
||||
for _, acc := range user.Accounts {
|
||||
if acc.Provider == oauth.Credentials {
|
||||
credentialsAccount = &acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if credentialsAccount == nil || credentialsAccount.Password == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err = bcrypt.CompareHashAndPassword([]byte(*credentialsAccount.Password), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
||||
}
|
||||
|
||||
// Update account with tokens
|
||||
credentialsAccount.AccessToken = &tokens.AccessToken
|
||||
credentialsAccount.RefreshToken = &tokens.RefreshToken
|
||||
now := time.Now()
|
||||
accessExpiry := now.Add(24 * time.Hour)
|
||||
refreshExpiry := now.Add(7 * 24 * time.Hour)
|
||||
credentialsAccount.AccessTokenExpiry = &accessExpiry
|
||||
credentialsAccount.RefreshTokenExpiry = &refreshExpiry
|
||||
|
||||
if err := s.accountRepo.Update(ctx, credentialsAccount); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update account with tokens")
|
||||
// Don't fail the login, tokens are already generated
|
||||
}
|
||||
|
||||
return &dto.TokenResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error) {
|
||||
claims, err := s.jwtService.VerifyToken(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.Sub)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
|
||||
// Find user
|
||||
user, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
accounts, err := s.accountRepo.FindByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
|
||||
var matchingAccount *auth.Account
|
||||
for _, acc := range accounts {
|
||||
if acc.RefreshToken != nil && *acc.RefreshToken == refreshToken {
|
||||
// Check if refresh token is expired
|
||||
if acc.RefreshTokenExpiry != nil && acc.RefreshTokenExpiry.Before(time.Now()) {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
matchingAccount = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchingAccount == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
|
||||
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
||||
}
|
||||
|
||||
matchingAccount.AccessToken = &tokens.AccessToken
|
||||
matchingAccount.RefreshToken = &tokens.RefreshToken
|
||||
now := time.Now()
|
||||
accessExpiry := now.Add(24 * time.Hour)
|
||||
refreshExpiry := now.Add(7 * 24 * time.Hour)
|
||||
matchingAccount.AccessTokenExpiry = &accessExpiry
|
||||
matchingAccount.RefreshTokenExpiry = &refreshExpiry
|
||||
|
||||
if err = s.accountRepo.Update(ctx, matchingAccount); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update account with tokens")
|
||||
// Don't fail the refresh, tokens are already generated
|
||||
}
|
||||
|
||||
return &dto.TokenResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
131
internal/application/auth/reset_password.go
Normal file
131
internal/application/auth/reset_password.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"base/internal/domain/auth"
|
||||
"base/internal/dto"
|
||||
"base/internal/pkg/oauth"
|
||||
"base/pkg/email"
|
||||
"base/pkg/hashids"
|
||||
"base/pkg/jwt"
|
||||
)
|
||||
|
||||
// SendResetPasswordEmail sends a password reset email
|
||||
func (s *service) SendResetPasswordEmail(ctx context.Context, request dto.SendResetPasswordEmailRequest) error {
|
||||
user, err := s.userRepo.FindByEmail(ctx, request.Email)
|
||||
if err != nil {
|
||||
// Don't reveal if user exists or not for security
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate reset code
|
||||
code := hashids.GenerateCode(int64(user.ID.Time()))
|
||||
key := fmt.Sprintf("reset_password:%s", user.ID.String())
|
||||
|
||||
// Store code in cache (15 minutes TTL)
|
||||
if storeErr := s.resetPasswordStore.Set(ctx, key, code, 15*time.Minute); storeErr != nil {
|
||||
return fmt.Errorf("failed to store reset password code: %w", storeErr)
|
||||
}
|
||||
|
||||
// Send email
|
||||
emailData := map[string]interface{}{
|
||||
"Code": code,
|
||||
"Name": user.FirstName,
|
||||
}
|
||||
|
||||
emailMsg := email.Request{
|
||||
To: user.Email,
|
||||
Subject: "Reset Your Password",
|
||||
Template: email.TemplateData{
|
||||
EmailTemplateName: email.TemplatePasswordReset,
|
||||
Data: emailData,
|
||||
},
|
||||
}
|
||||
|
||||
if _, sendEmailErr := s.emailService.Send(ctx, emailMsg); sendEmailErr != nil {
|
||||
return fmt.Errorf("failed to send reset password email: %w", sendEmailErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword resets a user's password with the provided code
|
||||
func (s *service) ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (*dto.TokenResponse, error) {
|
||||
user, err := s.userRepo.FindByEmail(ctx, request.Email, auth.WithAccounts())
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// Get code from cache
|
||||
key := fmt.Sprintf("reset_password:%s", user.ID.String())
|
||||
|
||||
storedCode, found, getErr := s.resetPasswordStore.Get(ctx, key)
|
||||
if getErr != nil || !found {
|
||||
return nil, ErrInvalidVerificationCode
|
||||
}
|
||||
|
||||
if storedCode != request.Code {
|
||||
return nil, ErrInvalidVerificationCode
|
||||
}
|
||||
|
||||
// Find credentials account
|
||||
var credentialsAccount *auth.Account
|
||||
for _, acc := range user.Accounts {
|
||||
if acc.Provider == oauth.Credentials {
|
||||
credentialsAccount = &acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
hashedPassword, genHashPassErr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||
if genHashPassErr != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", genHashPassErr)
|
||||
}
|
||||
|
||||
hashedPasswordStr := string(hashedPassword)
|
||||
|
||||
if credentialsAccount != nil {
|
||||
// Update existing account
|
||||
credentialsAccount.Password = &hashedPasswordStr
|
||||
credentialsAccount.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.accountRepo.Update(ctx, credentialsAccount); err != nil {
|
||||
return nil, fmt.Errorf("failed to update account: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Create new credentials account
|
||||
account := &auth.Account{
|
||||
ID: uuid.New(),
|
||||
UserID: user.ID,
|
||||
Provider: oauth.Credentials,
|
||||
Password: &hashedPasswordStr,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||
return nil, fmt.Errorf("failed to create account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete reset code from cache
|
||||
_ = s.resetPasswordStore.Delete(ctx, key)
|
||||
|
||||
// Generate tokens
|
||||
tokens, err := s.jwtService.GenerateAccessRefreshTokenPair(ctx, &jwt.TokenData{Sub: user.ID.String()})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
||||
}
|
||||
|
||||
return &dto.TokenResponse{
|
||||
AccessToken: tokens.AccessToken,
|
||||
RefreshToken: tokens.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
87
internal/application/auth/service.go
Normal file
87
internal/application/auth/service.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"base/config"
|
||||
"base/internal/domain/auth"
|
||||
"base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
"base/internal/pkg/oauth"
|
||||
"base/pkg/email"
|
||||
"base/pkg/jwt"
|
||||
"base/pkg/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrInvalidVerificationCode = errors.New("invalid verification code")
|
||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrProfileNotFound = errors.New("profile not found")
|
||||
ErrProfileAlreadyExists = errors.New("profile already exists")
|
||||
ErrHandleAlreadyTaken = errors.New("handle already taken")
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
RegisterWithCredentials(ctx context.Context, request dto.RegisterRequest) (*dto.TokenResponse, error)
|
||||
LoginWithCredentials(ctx context.Context, email, password string) (*dto.TokenResponse, error)
|
||||
RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error)
|
||||
GetOAuthRedirectURL(ctx context.Context, request dto.OAuthRedirectURLRequest) (string, error)
|
||||
OAuthCallback(ctx context.Context, request dto.OAuthCallbackRequest) (*dto.OAuthCallbackResponse, error)
|
||||
SendVerificationEmail(ctx context.Context, request dto.SendVerificationEmailRequest) error
|
||||
VerifyAccount(ctx context.Context, request dto.VerifyAccountRequest) error
|
||||
SendResetPasswordEmail(ctx context.Context, request dto.SendResetPasswordEmailRequest) error
|
||||
ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (*dto.TokenResponse, error)
|
||||
SetupProfile(ctx context.Context, userID uuid.UUID, request dto.SetupProfileRequest) error
|
||||
GetUserInfo(ctx context.Context, userID uuid.UUID) (*dto.UserInfoResponse, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
config *config.AppConfig
|
||||
userRepo auth.UserRepository
|
||||
accountRepo auth.AccountRepository
|
||||
profileRepo profile.Repository
|
||||
emailService email.Email
|
||||
oauthService oauth.OAuth
|
||||
verificationStore store.Store[string]
|
||||
resetPasswordStore store.Store[string]
|
||||
jwtService jwt.TokenService
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
Config *config.AppConfig
|
||||
UserRepo auth.UserRepository
|
||||
AccountRepo auth.AccountRepository
|
||||
ProfileRepo profile.Repository
|
||||
EmailService email.Email
|
||||
OAuthService oauth.OAuth
|
||||
VerificationStore store.Store[string] `name:"verification_store"`
|
||||
ResetPasswordStore store.Store[string] `name:"reset_password_store"`
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
config: param.Config,
|
||||
userRepo: param.UserRepo,
|
||||
accountRepo: param.AccountRepo,
|
||||
profileRepo: param.ProfileRepo,
|
||||
emailService: param.EmailService,
|
||||
oauthService: param.OAuthService,
|
||||
verificationStore: param.VerificationStore,
|
||||
resetPasswordStore: param.ResetPasswordStore,
|
||||
jwtService: jwt.New(param.Config.JWT.Secret, param.Config.JWT.AccessTokenExpiration, param.Config.JWT.RefreshTokenExpiration),
|
||||
}
|
||||
}
|
||||
76
internal/application/auth/setup_profile.go
Normal file
76
internal/application/auth/setup_profile.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
var slugRe = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
func (s *service) SetupProfile(ctx context.Context, userID uuid.UUID, req dto.SetupProfileRequest) error {
|
||||
existingProfile, _ := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if existingProfile != nil {
|
||||
return ErrProfileAlreadyExists
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
handle := generateHandle(user.FirstName, user.LastName, userID)
|
||||
if req.Handle != "" {
|
||||
handle = req.Handle
|
||||
}
|
||||
other, _ := s.profileRepo.FindByHandle(ctx, handle)
|
||||
if other != nil {
|
||||
return ErrHandleAlreadyTaken
|
||||
}
|
||||
|
||||
newProfile := &profile.Profile{
|
||||
ID: uuid.New(),
|
||||
UserID: &userID,
|
||||
Handle: handle,
|
||||
Hero: profile.Hero{
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
ShortDescription: req.ShortDescription,
|
||||
},
|
||||
Contact: profile.Contact{
|
||||
Email: user.Email,
|
||||
Phone: user.PhoneNumber,
|
||||
},
|
||||
PageSetting: profile.PageSetting{
|
||||
VisibilityLevel: "public",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
newProfile.Hero.Role = &profile.Role{ID: req.RoleID}
|
||||
|
||||
if req.RoleLevel != "" && newProfile.Hero.Role != nil {
|
||||
newProfile.Hero.Role.Level = req.RoleLevel
|
||||
} else if req.RoleLevel != "" {
|
||||
newProfile.Hero.Role = &profile.Role{Level: req.RoleLevel}
|
||||
}
|
||||
|
||||
return s.profileRepo.Create(ctx, newProfile)
|
||||
}
|
||||
|
||||
func generateHandle(firstName, lastName string, userID uuid.UUID) string {
|
||||
slug := slugRe.ReplaceAllString(strings.ToLower(strings.TrimSpace(firstName+"-"+lastName)), "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if slug == "" {
|
||||
slug = "user"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", slug, userID.String()[:8])
|
||||
}
|
||||
16
internal/application/auth/utils.go
Normal file
16
internal/application/auth/utils.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
func generateOTP() (string, error) {
|
||||
newInt := big.NewInt(10000) // 0 .. 999999
|
||||
n, err := rand.Int(rand.Reader, newInt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%04d", n.Int64()), err
|
||||
}
|
||||
62
internal/application/auth/verify.go
Normal file
62
internal/application/auth/verify.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"base/internal/domain/auth"
|
||||
"base/internal/dto"
|
||||
"base/pkg/email"
|
||||
)
|
||||
|
||||
func (s *service) SendVerificationEmail(ctx context.Context, request dto.SendVerificationEmailRequest) error {
|
||||
emailMsg := email.Request{
|
||||
To: request.Email,
|
||||
Subject: "Verify Your Email",
|
||||
Template: email.TemplateData{EmailTemplateName: email.TemplateEmailVerification},
|
||||
}
|
||||
|
||||
if _, err := s.emailService.Send(ctx, emailMsg); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to send verification email")
|
||||
return fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) VerifyAccount(ctx context.Context, request dto.VerifyAccountRequest) error {
|
||||
user, err := s.userRepo.FindByEmail(ctx, request.Email)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
return ErrEmailAlreadyVerified
|
||||
}
|
||||
|
||||
// Get code from cache
|
||||
key := fmt.Sprintf("verification:%s", request.Email)
|
||||
|
||||
storedCode, found, err := s.verificationStore.Get(ctx, key)
|
||||
if err != nil || !found {
|
||||
return ErrInvalidVerificationCode
|
||||
}
|
||||
|
||||
if storedCode != request.Code {
|
||||
return ErrInvalidVerificationCode
|
||||
}
|
||||
|
||||
user.EmailVerified = true
|
||||
user.Status = auth.UserStatusActive
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
// Delete verification code from cache
|
||||
_ = s.verificationStore.Delete(ctx, key)
|
||||
|
||||
return nil
|
||||
}
|
||||
272
internal/application/discovery/service.go
Normal file
272
internal/application/discovery/service.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
domainAsset "base/internal/domain/asset"
|
||||
domainProfile "base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetDiscoveryOverview(ctx context.Context) (*dto.OverviewFetchedResponse, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
profileRepo domainProfile.Repository
|
||||
assetRepo domainAsset.AssetRepository
|
||||
categoryRepo domainAsset.CategoryRepository
|
||||
}
|
||||
|
||||
// Param holds dependencies for the discovery overview service.
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
ProfileRepo domainProfile.Repository
|
||||
AssetRepo domainAsset.AssetRepository
|
||||
CategoryRepo domainAsset.CategoryRepository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
profileRepo: param.ProfileRepo,
|
||||
assetRepo: param.AssetRepo,
|
||||
categoryRepo: param.CategoryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) GetDiscoveryOverview(ctx context.Context) (*dto.OverviewFetchedResponse, error) {
|
||||
resp := &struct {
|
||||
domainAssets []*domainAsset.Asset
|
||||
recentlyJoined []*domainProfile.Profile
|
||||
totalProfiles int
|
||||
totalAssets int
|
||||
}{}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
assets, err := s.assetRepo.FindLatest(gCtx, 6, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.domainAssets = assets
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
profiles, total, err := s.profileRepo.FindAll(gCtx, domainProfile.Filter{
|
||||
Page: 1,
|
||||
PageSize: 6,
|
||||
SortedBy: "created_at",
|
||||
Ascending: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.recentlyJoined = profiles
|
||||
resp.totalProfiles = total
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
count, err := s.assetRepo.Count(gCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.totalAssets = count
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assets := s.toOverviewAssets(ctx, resp.domainAssets)
|
||||
flatProfiles := ToFlatProfiles(resp.recentlyJoined)
|
||||
|
||||
return &dto.OverviewFetchedResponse{
|
||||
Message: "Overview fetched successfully",
|
||||
Data: dto.OverviewFetchedDataDTO{
|
||||
Assets: assets,
|
||||
RecentlyJoined: flatProfiles,
|
||||
Analytics: dto.AnalyticsDTO{
|
||||
TotalAssets: resp.totalAssets,
|
||||
TotalProfiles: resp.totalProfiles,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) toOverviewAssets(ctx context.Context, assets []*domainAsset.Asset) []dto.OverviewAssetDTO {
|
||||
out := make([]dto.OverviewAssetDTO, len(assets))
|
||||
for i, a := range assets {
|
||||
out[i] = s.toOverviewAsset(ctx, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *service) toOverviewAsset(ctx context.Context, a *domainAsset.Asset) dto.OverviewAssetDTO {
|
||||
price := 0
|
||||
coverImage := ""
|
||||
for _, art := range a.AssetArtifacts {
|
||||
if strings.Contains(strings.ToLower(art.Type), "image") {
|
||||
coverImage = art.DownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(a.AssetArtifacts) > 0 {
|
||||
price = a.AssetArtifacts[0].Price
|
||||
}
|
||||
|
||||
cat := (*dto.CategoryDTO)(nil)
|
||||
if a.AssetCategory.ID != uuid.Nil {
|
||||
cat = &dto.CategoryDTO{
|
||||
ID: a.AssetCategory.ID,
|
||||
Name: a.AssetCategory.Name,
|
||||
Icon: a.AssetCategory.Icon,
|
||||
Color: a.AssetCategory.Color,
|
||||
CardType: a.AssetCategory.CardType,
|
||||
Featured: a.AssetCategory.Featured,
|
||||
Description: a.AssetCategory.Description,
|
||||
}
|
||||
}
|
||||
|
||||
ownerID := a.ProfileID.String()
|
||||
if p, err := s.profileRepo.FindByID(ctx, a.ProfileID); err == nil && p.UserID != nil {
|
||||
ownerID = p.UserID.String()
|
||||
}
|
||||
|
||||
return dto.OverviewAssetDTO{
|
||||
ID: a.ID.String(),
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
Content: a.Description,
|
||||
AssetCategoryID: a.AssetCategoryID.String(),
|
||||
AssetCategory: cat,
|
||||
CoverImage: coverImage,
|
||||
Link: a.Link,
|
||||
OwnerID: ownerID,
|
||||
ProfileID: a.ProfileID.String(),
|
||||
Profile: nil,
|
||||
Price: price,
|
||||
Currency: "USD",
|
||||
Status: assetStatusToString(a.Status),
|
||||
Rating: 0,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func assetStatusToString(st domainAsset.Status) string {
|
||||
switch st {
|
||||
case domainAsset.StatusPublished:
|
||||
return "published"
|
||||
case domainAsset.StatusDisabled:
|
||||
return "disabled"
|
||||
case domainAsset.StatusPending:
|
||||
return "pending"
|
||||
case domainAsset.StatusDeleted:
|
||||
return "deleted"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// ToFlatProfiles converts domain profiles to flat DTOs.
|
||||
func ToFlatProfiles(profiles []*domainProfile.Profile) []dto.FlatProfileDTO {
|
||||
out := make([]dto.FlatProfileDTO, len(profiles))
|
||||
for i, p := range profiles {
|
||||
out[i] = ToFlatProfile(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToFlatProfile converts a single profile to flat DTO.
|
||||
func ToFlatProfile(p *domainProfile.Profile) dto.FlatProfileDTO {
|
||||
roleID := ""
|
||||
roleName := ""
|
||||
if p.Hero.Role != nil {
|
||||
roleID = p.Hero.Role.ID.String()
|
||||
if p.Hero.Role.Title != "" {
|
||||
roleName = p.Hero.Role.Title
|
||||
}
|
||||
}
|
||||
|
||||
achievements := make(map[string]dto.AchievementItemDTO)
|
||||
for _, a := range p.About.Achievements {
|
||||
key := strings.ToLower(strings.ReplaceAll(a.Title, " ", ""))
|
||||
if key == "" {
|
||||
key = a.Title
|
||||
}
|
||||
achievements[key] = dto.AchievementItemDTO{Value: a.Value, Enabled: a.Enabled}
|
||||
}
|
||||
if len(achievements) == 0 {
|
||||
achievements = map[string]dto.AchievementItemDTO{
|
||||
"happyClient": {Value: "", Enabled: true},
|
||||
"yearExperience": {Value: "", Enabled: true},
|
||||
"projectCompeleted": {Value: "", Enabled: true},
|
||||
}
|
||||
}
|
||||
|
||||
var socialLinks []dto.SocialLinkDTO
|
||||
for _, sl := range p.Contact.SocialLinks {
|
||||
socialLinks = append(socialLinks, dto.SocialLinkDTO{LinkType: sl.LinkType, Link: sl.Link})
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(p.Hero.FirstName + " " + p.Hero.LastName)
|
||||
if displayName == "" {
|
||||
displayName = p.Handle
|
||||
}
|
||||
|
||||
status := "published"
|
||||
if p.PageSetting.VisibilityLevel != "public" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
return dto.FlatProfileDTO{
|
||||
ID: p.ID.String(),
|
||||
ProfileHandle: p.Handle,
|
||||
Status: status,
|
||||
BackgroundImage: "",
|
||||
ProfilePicture: p.About.ProfilePicture,
|
||||
FirstName: p.Hero.FirstName,
|
||||
LastName: p.Hero.LastName,
|
||||
DisplayName: displayName,
|
||||
RoleID: roleID,
|
||||
Role: dto.RoleDTO{ID: roleID, Name: roleName},
|
||||
CurrentCompany: p.Hero.Company,
|
||||
ShortDescription: p.Hero.ShortDescription,
|
||||
CTAEnabled: p.Hero.CTAEnabled,
|
||||
CTAAction: "",
|
||||
ResumeLink: p.Hero.ResumeLink,
|
||||
About: p.About.About,
|
||||
ContactEmail: p.Contact.Email,
|
||||
Achievements: achievements,
|
||||
ContactPhone: p.Contact.Phone,
|
||||
Country: "",
|
||||
CustomRoles: "",
|
||||
RoleLevel: p.Hero.Role.Level,
|
||||
SocialLinks: socialLinks,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
HandleUpdatedAt: time.Time{},
|
||||
}
|
||||
}
|
||||
238
internal/application/landing/service.go
Normal file
238
internal/application/landing/service.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package landing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
domainAsset "base/internal/domain/asset"
|
||||
domainProfile "base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
"base/pkg/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAssetsPerCategory = 6
|
||||
defaultSpecialistsLimit = 6
|
||||
defaultRolesLimit = 20
|
||||
landingCacheKey = "landing:page"
|
||||
landingCacheTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetLanding(ctx context.Context) (*dto.Landing, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
cache cache.Cache[dto.Landing]
|
||||
categoryRepo domainAsset.CategoryRepository
|
||||
assetRepo domainAsset.AssetRepository
|
||||
profileRepo domainProfile.Repository
|
||||
roleRepo domainProfile.RoleRepository
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
Cache cache.Cache[dto.Landing]
|
||||
CategoryRepo domainAsset.CategoryRepository
|
||||
AssetRepo domainAsset.AssetRepository
|
||||
ProfileRepo domainProfile.Repository
|
||||
RoleRepo domainProfile.RoleRepository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
cache: param.Cache,
|
||||
categoryRepo: param.CategoryRepo,
|
||||
assetRepo: param.AssetRepo,
|
||||
profileRepo: param.ProfileRepo,
|
||||
roleRepo: param.RoleRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) GetLanding(ctx context.Context) (*dto.Landing, error) {
|
||||
result, err := s.cache.WithCache(ctx, landingCacheKey, s.fetchLanding, landingCacheTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *service) fetchLanding(ctx context.Context) (dto.Landing, error) {
|
||||
data := &dto.LandingPageData{
|
||||
Categories: []dto.CategoryDTO{},
|
||||
SpecialistRoles: []dto.ProfileRole{},
|
||||
Assets: []dto.LandingAssetData{},
|
||||
Specialists: []dto.Specialist{},
|
||||
Blogs: []dto.Blog{},
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
categories, err := s.categoryRepo.FindAll(gCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data.Categories = make([]dto.CategoryDTO, len(categories))
|
||||
for i, c := range categories {
|
||||
data.Categories[i] = dto.CategoryDTO{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Icon: c.Icon,
|
||||
Color: c.Color,
|
||||
CardType: c.CardType,
|
||||
Featured: c.Featured,
|
||||
Description: c.Description,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
domainRoles, err := s.roleRepo.List(gCtx, defaultRolesLimit, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data.SpecialistRoles = make([]dto.ProfileRole, len(domainRoles))
|
||||
for i, r := range domainRoles {
|
||||
data.SpecialistRoles[i] = dto.ProfileRole{
|
||||
Id: r.ID.String(),
|
||||
Title: r.Title,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
profiles, _, err := s.profileRepo.FindAll(
|
||||
gCtx,
|
||||
domainProfile.Filter{
|
||||
Page: 1,
|
||||
PageSize: defaultSpecialistsLimit,
|
||||
SortedBy: "created_at",
|
||||
Ascending: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data.Specialists = make([]dto.Specialist, len(profiles))
|
||||
for i, p := range profiles {
|
||||
data.Specialists[i] = dto.Specialist{
|
||||
Id: p.ID.String(),
|
||||
Handle: p.Handle,
|
||||
Avatar: p.About.ProfilePicture,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
categories, err := s.categoryRepo.FindAll(gCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
assetsByCat := make([]dto.LandingAssetData, len(categories))
|
||||
mu := &sync.Mutex{}
|
||||
eg, egCtx := errgroup.WithContext(gCtx)
|
||||
|
||||
for index, category := range categories {
|
||||
i, cat := index, category
|
||||
|
||||
eg.Go(func() error {
|
||||
assets, findLatestAssetErr := s.assetRepo.FindLatestByCategory(egCtx, cat.ID, defaultAssetsPerCategory)
|
||||
if findLatestAssetErr != nil {
|
||||
return findLatestAssetErr
|
||||
}
|
||||
|
||||
assetResp := make([]dto.AssetResponse, len(assets))
|
||||
for j, a := range assets {
|
||||
assetResp[j] = *s.toAssetResponse(a)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
|
||||
assetsByCat[i] = dto.LandingAssetData{
|
||||
AssetCategory: dto.AssetCategory{
|
||||
Id: cat.ID.String(),
|
||||
Title: cat.Name,
|
||||
Icon: cat.Icon,
|
||||
},
|
||||
Assets: assetResp,
|
||||
}
|
||||
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err = eg.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
data.Assets = assetsByCat
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return dto.Landing{}, err
|
||||
}
|
||||
|
||||
return dto.Landing{
|
||||
Message: "Landing page fetched successfully",
|
||||
Data: *data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) toAssetResponse(a *domainAsset.Asset) *dto.AssetResponse {
|
||||
coverImage := ""
|
||||
for _, art := range a.AssetArtifacts {
|
||||
if strings.Contains(strings.ToLower(art.Type), "image") {
|
||||
coverImage = art.DownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
resp := &dto.AssetResponse{
|
||||
ID: a.ID,
|
||||
ProfileID: a.ProfileID,
|
||||
AssetCategoryID: a.AssetCategoryID,
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
Link: a.Link,
|
||||
CoverImage: coverImage,
|
||||
Status: int(a.Status),
|
||||
CreatedAt: formatTime(a.CreatedAt),
|
||||
UpdatedAt: formatTime(a.UpdatedAt),
|
||||
}
|
||||
if a.AssetCategory.ID != uuid.Nil {
|
||||
resp.Category = dto.CategoryDTO{
|
||||
ID: a.AssetCategory.ID,
|
||||
Name: a.AssetCategory.Name,
|
||||
Icon: a.AssetCategory.Icon,
|
||||
Color: a.AssetCategory.Color,
|
||||
CardType: a.AssetCategory.CardType,
|
||||
Featured: a.AssetCategory.Featured,
|
||||
Description: a.AssetCategory.Description,
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
28
internal/application/module.go
Normal file
28
internal/application/module.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"go.uber.org/fx"
|
||||
|
||||
"base/internal/application/asset"
|
||||
"base/internal/application/auth"
|
||||
"base/internal/application/discovery"
|
||||
"base/internal/application/landing"
|
||||
"base/internal/application/profile"
|
||||
"base/internal/application/profilerole"
|
||||
"base/internal/application/skill"
|
||||
"base/internal/application/specialist"
|
||||
)
|
||||
|
||||
var Module = fx.Module(
|
||||
"application",
|
||||
fx.Provide(
|
||||
auth.New,
|
||||
profile.New,
|
||||
asset.New,
|
||||
discovery.New,
|
||||
landing.New,
|
||||
specialist.New,
|
||||
profilerole.New,
|
||||
skill.New,
|
||||
),
|
||||
)
|
||||
72
internal/application/profile/converter.go
Normal file
72
internal/application/profile/converter.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
// DomainProfileToProfileResponse converts a domain profile to ProfileResponse.
|
||||
// Used by specialist overview and other consumers that have a domain profile.
|
||||
func DomainProfileToProfileResponse(p *profile.Profile) *dto.ProfileResponse {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
roleLevel := ""
|
||||
if p.Hero.Role != nil {
|
||||
roleLevel = p.Hero.Role.Level
|
||||
}
|
||||
resp := &dto.ProfileResponse{
|
||||
ID: p.ID,
|
||||
Handle: p.Handle,
|
||||
PageSectionOrder: p.PageSectionOrder,
|
||||
Hero: dto.HeroDTO{
|
||||
RoleLevel: roleLevel,
|
||||
FirstName: p.Hero.FirstName,
|
||||
LastName: p.Hero.LastName,
|
||||
Company: p.Hero.Company,
|
||||
ShortDescription: p.Hero.ShortDescription,
|
||||
ResumeLink: p.Hero.ResumeLink,
|
||||
CTAEnabled: p.Hero.CTAEnabled,
|
||||
Avatar: p.Hero.Avatar,
|
||||
},
|
||||
About: dto.AboutDTO{
|
||||
ProfilePicture: p.About.ProfilePicture,
|
||||
About: p.About.About,
|
||||
},
|
||||
Contact: dto.ContactDTO{
|
||||
Email: p.Contact.Email,
|
||||
Phone: p.Contact.Phone,
|
||||
},
|
||||
PageSetting: dto.PageSettingDTO{
|
||||
VisibilityLevel: p.PageSetting.VisibilityLevel,
|
||||
},
|
||||
}
|
||||
|
||||
if p.Hero.Role != nil {
|
||||
resp.Hero.RoleID = &p.Hero.Role.ID
|
||||
}
|
||||
|
||||
for _, skill := range p.Skills {
|
||||
resp.Skills = append(resp.Skills, dto.SkillDTO{
|
||||
SkillName: skill.SkillName,
|
||||
Level: skill.Level,
|
||||
})
|
||||
}
|
||||
|
||||
for _, achievement := range p.About.Achievements {
|
||||
resp.About.Achievements = append(resp.About.Achievements, dto.AchievementDTO{
|
||||
Title: achievement.Title,
|
||||
Value: achievement.Value,
|
||||
Enabled: achievement.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
for _, sl := range p.Contact.SocialLinks {
|
||||
resp.Contact.SocialLinks = append(resp.Contact.SocialLinks, dto.SocialLinkDTO{
|
||||
LinkType: sl.LinkType,
|
||||
Link: sl.Link,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
315
internal/application/profile/service.go
Normal file
315
internal/application/profile/service.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/lo"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Create(ctx context.Context, req dto.CreateProfileRequest) (*dto.ProfileResponse, error)
|
||||
Update(ctx context.Context, req dto.UpdateProfileRequest) (*dto.ProfileResponse, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileResponse, error)
|
||||
GetByHandle(ctx context.Context, handle string) (*dto.ProfileResponse, error)
|
||||
List(ctx context.Context, req dto.ListProfilesRequest) (*dto.ListProfilesResponse, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
profileRepo profile.Repository
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
ProfileRepo profile.Repository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
profileRepo: param.ProfileRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, req dto.CreateProfileRequest) (*dto.ProfileResponse, error) {
|
||||
domainProfile := &profile.Profile{
|
||||
ID: uuid.New(),
|
||||
Handle: req.Handle,
|
||||
PageSectionOrder: req.PageSectionOrder,
|
||||
Hero: profile.Hero{
|
||||
Role: &profile.Role{
|
||||
ID: lo.FromPtr(req.Hero.RoleID),
|
||||
Level: req.Hero.RoleLevel,
|
||||
},
|
||||
FirstName: req.Hero.FirstName,
|
||||
LastName: req.Hero.LastName,
|
||||
Company: req.Hero.Company,
|
||||
ShortDescription: req.Hero.ShortDescription,
|
||||
ResumeLink: req.Hero.ResumeLink,
|
||||
CTAEnabled: req.Hero.CTAEnabled,
|
||||
Avatar: req.Hero.Avatar,
|
||||
},
|
||||
About: profile.About{
|
||||
ProfilePicture: req.About.ProfilePicture,
|
||||
About: req.About.About,
|
||||
},
|
||||
Contact: profile.Contact{
|
||||
Email: req.Contact.Email,
|
||||
Phone: req.Contact.Phone,
|
||||
},
|
||||
PageSetting: profile.PageSetting{
|
||||
VisibilityLevel: req.PageSetting.VisibilityLevel,
|
||||
},
|
||||
}
|
||||
|
||||
if req.Hero.RoleID != nil {
|
||||
domainProfile.Hero.Role = &profile.Role{
|
||||
ID: *req.Hero.RoleID,
|
||||
}
|
||||
}
|
||||
|
||||
for _, skill := range req.Skills {
|
||||
domainProfile.Skills = append(domainProfile.Skills, profile.Skill{
|
||||
SkillName: skill.SkillName,
|
||||
Level: skill.Level,
|
||||
})
|
||||
}
|
||||
|
||||
for _, achievement := range req.About.Achievements {
|
||||
domainProfile.About.Achievements = append(domainProfile.About.Achievements, profile.Achievement{
|
||||
Title: achievement.Title,
|
||||
Value: achievement.Value,
|
||||
Enabled: achievement.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
for _, socialLink := range req.Contact.SocialLinks {
|
||||
domainProfile.Contact.SocialLinks = append(domainProfile.Contact.SocialLinks, profile.SocialLink{
|
||||
LinkType: socialLink.LinkType,
|
||||
Link: socialLink.Link,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.profileRepo.Create(ctx, domainProfile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toProfileResponse(domainProfile), nil
|
||||
}
|
||||
|
||||
func (s *service) Update(ctx context.Context, req dto.UpdateProfileRequest) (*dto.ProfileResponse, error) {
|
||||
id, err := uuid.Parse(req.ID)
|
||||
if err != nil {
|
||||
return nil, profile.ErrProfileNotFound
|
||||
}
|
||||
|
||||
// First, get the existing profile to ensure it exists
|
||||
existingProfile, err := s.profileRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, profile.ErrProfileNotFound
|
||||
}
|
||||
|
||||
domainProfile := &profile.Profile{
|
||||
ID: id,
|
||||
Handle: req.Handle,
|
||||
PageSectionOrder: req.PageSectionOrder,
|
||||
Hero: profile.Hero{
|
||||
FirstName: req.Hero.FirstName,
|
||||
Role: &profile.Role{
|
||||
ID: lo.FromPtr(req.Hero.RoleID),
|
||||
Level: req.Hero.RoleLevel,
|
||||
},
|
||||
LastName: req.Hero.LastName,
|
||||
Company: req.Hero.Company,
|
||||
ShortDescription: req.Hero.ShortDescription,
|
||||
ResumeLink: req.Hero.ResumeLink,
|
||||
CTAEnabled: req.Hero.CTAEnabled,
|
||||
Avatar: req.Hero.Avatar,
|
||||
},
|
||||
About: profile.About{
|
||||
ProfilePicture: req.About.ProfilePicture,
|
||||
About: req.About.About,
|
||||
},
|
||||
Contact: profile.Contact{
|
||||
Email: req.Contact.Email,
|
||||
Phone: req.Contact.Phone,
|
||||
},
|
||||
PageSetting: profile.PageSetting{
|
||||
VisibilityLevel: req.PageSetting.VisibilityLevel,
|
||||
},
|
||||
}
|
||||
|
||||
if req.Hero.RoleID != nil {
|
||||
domainProfile.Hero.Role = &profile.Role{
|
||||
ID: *req.Hero.RoleID,
|
||||
}
|
||||
} else if existingProfile != nil && existingProfile.Hero.Role != nil {
|
||||
domainProfile.Hero.Role = existingProfile.Hero.Role
|
||||
}
|
||||
|
||||
if req.Hero.RoleLevel == "" && existingProfile != nil {
|
||||
domainProfile.Hero.Role.Level = existingProfile.Hero.Role.Level
|
||||
}
|
||||
|
||||
for _, skill := range req.Skills {
|
||||
domainProfile.Skills = append(domainProfile.Skills, profile.Skill{
|
||||
SkillName: skill.SkillName,
|
||||
Level: skill.Level,
|
||||
})
|
||||
}
|
||||
|
||||
for _, achievement := range req.About.Achievements {
|
||||
domainProfile.About.Achievements = append(domainProfile.About.Achievements, profile.Achievement{
|
||||
Title: achievement.Title,
|
||||
Value: achievement.Value,
|
||||
Enabled: achievement.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
for _, socialLink := range req.Contact.SocialLinks {
|
||||
domainProfile.Contact.SocialLinks = append(domainProfile.Contact.SocialLinks, profile.SocialLink{
|
||||
LinkType: socialLink.LinkType,
|
||||
Link: socialLink.Link,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.profileRepo.Update(ctx, domainProfile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toProfileResponse(domainProfile), nil
|
||||
}
|
||||
|
||||
func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileResponse, error) {
|
||||
profileData, err := s.profileRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, profile.ErrProfileNotFound
|
||||
}
|
||||
|
||||
return s.toProfileResponse(profileData), nil
|
||||
}
|
||||
|
||||
func (s *service) GetByHandle(ctx context.Context, handle string) (*dto.ProfileResponse, error) {
|
||||
profileData, err := s.profileRepo.FindByHandle(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, profile.ErrProfileNotFound
|
||||
}
|
||||
|
||||
return s.toProfileResponse(profileData), nil
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context, req dto.ListProfilesRequest) (*dto.ListProfilesResponse, error) {
|
||||
filter := profile.Filter{
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Company: req.Company,
|
||||
SkillName: req.SkillName,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
SortedBy: req.SortedBy,
|
||||
Ascending: req.Ascending,
|
||||
}
|
||||
|
||||
if req.Page == 0 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if req.PageSize == 0 {
|
||||
filter.PageSize = 10
|
||||
}
|
||||
|
||||
if req.RoleID != nil {
|
||||
filter.RoleID = *req.RoleID
|
||||
}
|
||||
|
||||
profiles, total, err := s.profileRepo.FindAll(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &dto.ListProfilesResponse{
|
||||
Profiles: make([]dto.ProfileResponse, len(profiles)),
|
||||
Total: total,
|
||||
Page: filter.Page,
|
||||
PageSize: filter.PageSize,
|
||||
}
|
||||
|
||||
for i, p := range profiles {
|
||||
response.Profiles[i] = *s.toProfileResponse(p)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
// Get profile first to ensure it exists
|
||||
profileData, err := s.profileRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return profile.ErrProfileNotFound
|
||||
}
|
||||
|
||||
return s.profileRepo.Delete(ctx, profileData)
|
||||
}
|
||||
|
||||
func (s *service) toProfileResponse(p *profile.Profile) *dto.ProfileResponse {
|
||||
resp := &dto.ProfileResponse{
|
||||
ID: p.ID,
|
||||
Handle: p.Handle,
|
||||
PageSectionOrder: p.PageSectionOrder,
|
||||
Hero: dto.HeroDTO{
|
||||
FirstName: p.Hero.FirstName,
|
||||
LastName: p.Hero.LastName,
|
||||
Company: p.Hero.Company,
|
||||
ShortDescription: p.Hero.ShortDescription,
|
||||
ResumeLink: p.Hero.ResumeLink,
|
||||
CTAEnabled: p.Hero.CTAEnabled,
|
||||
Avatar: p.Hero.Avatar,
|
||||
},
|
||||
About: dto.AboutDTO{
|
||||
ProfilePicture: p.About.ProfilePicture,
|
||||
About: p.About.About,
|
||||
},
|
||||
Contact: dto.ContactDTO{
|
||||
Email: p.Contact.Email,
|
||||
Phone: p.Contact.Phone,
|
||||
},
|
||||
PageSetting: dto.PageSettingDTO{
|
||||
VisibilityLevel: p.PageSetting.VisibilityLevel,
|
||||
},
|
||||
}
|
||||
|
||||
if p.Hero.Role != nil {
|
||||
resp.Hero.RoleID = &p.Hero.Role.ID
|
||||
}
|
||||
|
||||
for _, skill := range p.Skills {
|
||||
resp.Skills = append(resp.Skills, dto.SkillDTO{
|
||||
SkillName: skill.SkillName,
|
||||
Level: skill.Level,
|
||||
})
|
||||
}
|
||||
|
||||
for _, achievement := range p.About.Achievements {
|
||||
resp.About.Achievements = append(resp.About.Achievements, dto.AchievementDTO{
|
||||
Title: achievement.Title,
|
||||
Value: achievement.Value,
|
||||
Enabled: achievement.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
for _, socialLink := range p.Contact.SocialLinks {
|
||||
resp.Contact.SocialLinks = append(resp.Contact.SocialLinks, dto.SocialLinkDTO{
|
||||
LinkType: socialLink.LinkType,
|
||||
Link: socialLink.Link,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
121
internal/application/profilerole/service.go
Normal file
121
internal/application/profilerole/service.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package profilerole
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
|
||||
domainProfile "base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
var ErrNotFound = domainProfile.ErrRoleNotFound
|
||||
|
||||
type Service interface {
|
||||
List(ctx context.Context) ([]dto.ProfileRole, error)
|
||||
ListWithLimit(ctx context.Context, limit, offset int) ([]dto.ProfileRole, error)
|
||||
Create(ctx context.Context, req dto.CreateProfileRoleRequest) (*dto.ProfileRole, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileRole, error)
|
||||
Update(ctx context.Context, req dto.UpdateProfileRoleRequest) (*dto.ProfileRole, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
repo domainProfile.RoleRepository
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
Repo domainProfile.RoleRepository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
repo: param.Repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context) ([]dto.ProfileRole, error) {
|
||||
roles, err := s.repo.FindAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDTOs(roles), nil
|
||||
}
|
||||
|
||||
func (s *service) ListWithLimit(ctx context.Context, limit, offset int) ([]dto.ProfileRole, error) {
|
||||
roles, err := s.repo.List(ctx, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDTOs(roles), nil
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, req dto.CreateProfileRoleRequest) (*dto.ProfileRole, error) {
|
||||
role := &domainProfile.Role{
|
||||
ID: uuid.New(),
|
||||
Title: req.Title,
|
||||
}
|
||||
if err := s.repo.Create(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDTO(role), nil
|
||||
}
|
||||
|
||||
func (s *service) GetByID(ctx context.Context, id uuid.UUID) (*dto.ProfileRole, error) {
|
||||
role, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return toDTO(role), nil
|
||||
}
|
||||
|
||||
func (s *service) Update(ctx context.Context, req dto.UpdateProfileRoleRequest) (*dto.ProfileRole, error) {
|
||||
id, err := uuid.Parse(req.ID)
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
role, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
role.Title = req.Title
|
||||
|
||||
if err := s.repo.Update(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDTO(role), nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
role, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.repo.Delete(ctx, role.ID)
|
||||
}
|
||||
|
||||
func toDTO(r *domainProfile.Role) *dto.ProfileRole {
|
||||
return &dto.ProfileRole{
|
||||
Id: r.ID.String(),
|
||||
Title: r.Title,
|
||||
}
|
||||
}
|
||||
|
||||
func toDTOs(roles []*domainProfile.Role) []dto.ProfileRole {
|
||||
out := make([]dto.ProfileRole, len(roles))
|
||||
for i, r := range roles {
|
||||
out[i] = *toDTO(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
46
internal/application/skill/service.go
Normal file
46
internal/application/skill/service.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
|
||||
domainSkill "base/internal/domain/skill"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
List(ctx context.Context) ([]dto.Skill, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
repo domainSkill.Repository
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
Repo domainSkill.Repository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
repo: param.Repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context) ([]dto.Skill, error) {
|
||||
skills, err := s.repo.FindAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.Skill, len(skills))
|
||||
for i, sk := range skills {
|
||||
out[i] = dto.Skill{ID: sk.ID.String(), Name: sk.Name}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
426
internal/application/specialist/service.go
Normal file
426
internal/application/specialist/service.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package specialist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"go.uber.org/fx"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
appProfile "base/internal/application/profile"
|
||||
domainAsset "base/internal/domain/asset"
|
||||
domainProfile "base/internal/domain/profile"
|
||||
"base/internal/dto"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Overview(ctx context.Context, userID uuid.UUID) (*dto.SpecialistOverviewFetchedResponse, error)
|
||||
UpdateHero(ctx context.Context, userID uuid.UUID, req dto.HeroDTO) error
|
||||
UpdateContact(ctx context.Context, userID uuid.UUID, req dto.ContactDTO) error
|
||||
UpdateSkills(ctx context.Context, userID uuid.UUID, req dto.SkillsUpdateRequest) error
|
||||
GetPageSections(ctx context.Context, userID uuid.UUID) (*dto.PageSectionsResponse, error)
|
||||
GetProfile(ctx context.Context, userID uuid.UUID) (*dto.ProfileResponse, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger zerolog.Logger
|
||||
profileRepo domainProfile.Repository
|
||||
assetRepo domainAsset.AssetRepository
|
||||
}
|
||||
|
||||
// Param holds dependencies for the specialist overview service.
|
||||
type Param struct {
|
||||
Logger zerolog.Logger
|
||||
ProfileRepo domainProfile.Repository
|
||||
AssetRepo domainAsset.AssetRepository
|
||||
|
||||
fx.In
|
||||
}
|
||||
|
||||
func New(param Param) Service {
|
||||
return &service{
|
||||
logger: param.Logger,
|
||||
profileRepo: param.ProfileRepo,
|
||||
assetRepo: param.AssetRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Overview(ctx context.Context, userID uuid.UUID) (*dto.SpecialistOverviewFetchedResponse, error) {
|
||||
resp := &struct {
|
||||
profile *domainProfile.Profile
|
||||
assets []*domainAsset.Asset
|
||||
recentlyJoined []*domainProfile.Profile
|
||||
totalProfiles int
|
||||
totalAssets int
|
||||
}{}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
// 1. Profile by OwnerID (includes Skills) + Assets by ProfileID
|
||||
g.Go(func() error {
|
||||
profile, err := s.profileRepo.FindByUserID(gCtx, userID)
|
||||
if err != nil {
|
||||
return domainProfile.ErrProfileNotFound
|
||||
}
|
||||
resp.profile = profile
|
||||
|
||||
assets, err := s.assetRepo.FindByProfileID(gCtx, profile.ID)
|
||||
if err != nil {
|
||||
assets = []*domainAsset.Asset{}
|
||||
}
|
||||
resp.assets = assets
|
||||
return nil
|
||||
})
|
||||
|
||||
// 2. Latest 6 profiles + total profiles count (FindAll returns both)
|
||||
g.Go(func() error {
|
||||
profiles, total, err := s.profileRepo.FindAll(gCtx, domainProfile.Filter{
|
||||
Page: 1,
|
||||
PageSize: 6,
|
||||
SortedBy: "created_at",
|
||||
Ascending: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.recentlyJoined = profiles
|
||||
resp.totalProfiles = total
|
||||
return nil
|
||||
})
|
||||
|
||||
// 3. Total assets count
|
||||
g.Go(func() error {
|
||||
count, err := s.assetRepo.Count(gCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.totalAssets = count
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assets := s.toOverviewAssets(resp.assets, userID)
|
||||
flatProfiles := ToFlatProfiles(resp.recentlyJoined)
|
||||
|
||||
profileResp := appProfile.DomainProfileToProfileResponse(resp.profile)
|
||||
var skills []dto.SkillDTO
|
||||
if profileResp != nil {
|
||||
skills = profileResp.Skills
|
||||
}
|
||||
|
||||
tasks := s.computeTasks(resp.profile, resp.assets)
|
||||
completionPercent := s.computeCompletionPercent(tasks)
|
||||
|
||||
return &dto.SpecialistOverviewFetchedResponse{
|
||||
Message: "",
|
||||
Data: dto.SpecialistOverviewFetchedDataDTO{
|
||||
Assets: assets,
|
||||
RecentlyJoined: flatProfiles,
|
||||
Analytics: dto.AnalyticsDTO{TotalAssets: resp.totalAssets, TotalProfiles: resp.totalProfiles},
|
||||
Profile: profileResp,
|
||||
Skills: skills,
|
||||
CompletionPercent: completionPercent,
|
||||
Tasks: tasks,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeTasks derives task flags from profile and assets. true = needs action.
|
||||
func (s *service) computeTasks(p *domainProfile.Profile, assets []*domainAsset.Asset) dto.TasksDTO {
|
||||
tasks := dto.TasksDTO{}
|
||||
if p == nil {
|
||||
return tasks
|
||||
}
|
||||
// profile_action: Hero section (firstName, lastName, shortDescription) incomplete
|
||||
tasks.ProfileAction = strings.TrimSpace(p.Hero.FirstName) == "" ||
|
||||
strings.TrimSpace(p.Hero.LastName) == "" ||
|
||||
strings.TrimSpace(p.Hero.ShortDescription) == ""
|
||||
|
||||
// about_action: About section incomplete
|
||||
tasks.AboutAction = strings.TrimSpace(p.About.ProfilePicture) == "" ||
|
||||
strings.TrimSpace(p.About.About) == ""
|
||||
|
||||
// publish_action: not public
|
||||
tasks.PublishAction = p.PageSetting.VisibilityLevel != "public"
|
||||
|
||||
// works_action: no assets
|
||||
tasks.WorksAction = len(assets) == 0
|
||||
|
||||
// skills_action: no skills
|
||||
tasks.SkillsAction = len(p.Skills) == 0
|
||||
|
||||
// social_action: no social links
|
||||
tasks.SocialAction = len(p.Contact.SocialLinks) == 0
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
// computeCompletionPercent: 6 sections, each complete = !action. Percent = (6 - actionsNeeded) / 6 * 100
|
||||
func (s *service) computeCompletionPercent(tasks dto.TasksDTO) int {
|
||||
complete := 0
|
||||
if !tasks.ProfileAction {
|
||||
complete++
|
||||
}
|
||||
if !tasks.AboutAction {
|
||||
complete++
|
||||
}
|
||||
if !tasks.PublishAction {
|
||||
complete++
|
||||
}
|
||||
if !tasks.WorksAction {
|
||||
complete++
|
||||
}
|
||||
if !tasks.SkillsAction {
|
||||
complete++
|
||||
}
|
||||
if !tasks.SocialAction {
|
||||
complete++
|
||||
}
|
||||
if complete == 0 {
|
||||
return 0
|
||||
}
|
||||
return (complete * 100) / 6
|
||||
}
|
||||
|
||||
func (s *service) UpdateHero(ctx context.Context, userID uuid.UUID, req dto.HeroDTO) error {
|
||||
p, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if err != nil || p == nil {
|
||||
return domainProfile.ErrProfileNotFound
|
||||
}
|
||||
|
||||
p.Hero.FirstName = req.FirstName
|
||||
p.Hero.LastName = req.LastName
|
||||
p.Hero.Company = req.Company
|
||||
p.Hero.ShortDescription = req.ShortDescription
|
||||
p.Hero.ResumeLink = req.ResumeLink
|
||||
p.Hero.CTAEnabled = req.CTAEnabled
|
||||
p.Hero.Avatar = req.Avatar
|
||||
|
||||
if req.RoleID != nil {
|
||||
if p.Hero.Role == nil {
|
||||
p.Hero.Role = &domainProfile.Role{ID: *req.RoleID, Level: req.RoleLevel}
|
||||
} else {
|
||||
p.Hero.Role.ID = *req.RoleID
|
||||
p.Hero.Role.Level = req.RoleLevel
|
||||
}
|
||||
} else if req.RoleLevel != "" {
|
||||
if p.Hero.Role == nil {
|
||||
p.Hero.Role = &domainProfile.Role{Level: req.RoleLevel}
|
||||
} else {
|
||||
p.Hero.Role.Level = req.RoleLevel
|
||||
}
|
||||
}
|
||||
|
||||
p.UpdatedAt = time.Now()
|
||||
return s.profileRepo.Update(ctx, p)
|
||||
}
|
||||
|
||||
func (s *service) UpdateContact(ctx context.Context, userID uuid.UUID, req dto.ContactDTO) error {
|
||||
p, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if err != nil || p == nil {
|
||||
return domainProfile.ErrProfileNotFound
|
||||
}
|
||||
p.Contact.Email = req.Email
|
||||
p.Contact.Phone = req.Phone
|
||||
p.Contact.SocialLinks = make([]domainProfile.SocialLink, len(req.SocialLinks))
|
||||
for i, sl := range req.SocialLinks {
|
||||
p.Contact.SocialLinks[i] = domainProfile.SocialLink{LinkType: sl.LinkType, Link: sl.Link}
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
return s.profileRepo.Update(ctx, p)
|
||||
}
|
||||
|
||||
func (s *service) UpdateSkills(ctx context.Context, userID uuid.UUID, req dto.SkillsUpdateRequest) error {
|
||||
p, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if err != nil || p == nil {
|
||||
return domainProfile.ErrProfileNotFound
|
||||
}
|
||||
p.Skills = make([]domainProfile.Skill, len(req.Skills))
|
||||
for i, s := range req.Skills {
|
||||
p.Skills[i] = domainProfile.Skill{SkillName: s.SkillName, Level: s.Level}
|
||||
}
|
||||
p.UpdatedAt = time.Now()
|
||||
return s.profileRepo.Update(ctx, p)
|
||||
}
|
||||
|
||||
func (s *service) GetPageSections(ctx context.Context, userID uuid.UUID) (*dto.PageSectionsResponse, error) {
|
||||
p, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if err != nil || p == nil {
|
||||
return nil, domainProfile.ErrProfileNotFound
|
||||
}
|
||||
resp := appProfile.DomainProfileToProfileResponse(p)
|
||||
if resp == nil {
|
||||
return nil, domainProfile.ErrProfileNotFound
|
||||
}
|
||||
return &dto.PageSectionsResponse{
|
||||
Hero: resp.Hero,
|
||||
Contact: resp.Contact,
|
||||
Skills: resp.Skills,
|
||||
PageSectionOrder: resp.PageSectionOrder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) GetProfile(ctx context.Context, userID uuid.UUID) (*dto.ProfileResponse, error) {
|
||||
p, err := s.profileRepo.FindByUserID(ctx, userID)
|
||||
if err != nil || p == nil {
|
||||
return nil, domainProfile.ErrProfileNotFound
|
||||
}
|
||||
return appProfile.DomainProfileToProfileResponse(p), nil
|
||||
}
|
||||
|
||||
func (s *service) toOverviewAssets(assets []*domainAsset.Asset, ownerID uuid.UUID) []dto.OverviewAssetDTO {
|
||||
out := make([]dto.OverviewAssetDTO, len(assets))
|
||||
for i, a := range assets {
|
||||
out[i] = s.toOverviewAsset(a, ownerID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *service) toOverviewAsset(a *domainAsset.Asset, ownerID uuid.UUID) dto.OverviewAssetDTO {
|
||||
price := 0
|
||||
coverImage := ""
|
||||
for _, art := range a.AssetArtifacts {
|
||||
if strings.Contains(strings.ToLower(art.Type), "image") {
|
||||
coverImage = art.DownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(a.AssetArtifacts) > 0 {
|
||||
price = a.AssetArtifacts[0].Price
|
||||
}
|
||||
|
||||
cat := (*dto.CategoryDTO)(nil)
|
||||
if a.AssetCategory.ID != uuid.Nil {
|
||||
cat = &dto.CategoryDTO{
|
||||
ID: a.AssetCategory.ID,
|
||||
Name: a.AssetCategory.Name,
|
||||
Icon: a.AssetCategory.Icon,
|
||||
Color: a.AssetCategory.Color,
|
||||
CardType: a.AssetCategory.CardType,
|
||||
Featured: a.AssetCategory.Featured,
|
||||
Description: a.AssetCategory.Description,
|
||||
}
|
||||
}
|
||||
|
||||
return dto.OverviewAssetDTO{
|
||||
ID: a.ID.String(),
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
Content: a.Description,
|
||||
AssetCategoryID: a.AssetCategoryID.String(),
|
||||
AssetCategory: cat,
|
||||
CoverImage: coverImage,
|
||||
Link: a.Link,
|
||||
OwnerID: ownerID.String(),
|
||||
ProfileID: a.ProfileID.String(),
|
||||
Profile: nil,
|
||||
Price: price,
|
||||
Currency: "USD",
|
||||
Status: assetStatusToString(a.Status),
|
||||
Rating: 0,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func assetStatusToString(st domainAsset.Status) string {
|
||||
switch st {
|
||||
case domainAsset.StatusPublished:
|
||||
return "published"
|
||||
case domainAsset.StatusDisabled:
|
||||
return "disabled"
|
||||
case domainAsset.StatusPending:
|
||||
return "pending"
|
||||
case domainAsset.StatusDeleted:
|
||||
return "deleted"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ToFlatProfiles converts domain profiles to flat DTOs.
|
||||
func ToFlatProfiles(profiles []*domainProfile.Profile) []dto.FlatProfileDTO {
|
||||
out := make([]dto.FlatProfileDTO, len(profiles))
|
||||
for i, p := range profiles {
|
||||
out[i] = ToFlatProfile(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ToFlatProfile converts a single profile to flat DTO.
|
||||
func ToFlatProfile(p *domainProfile.Profile) dto.FlatProfileDTO {
|
||||
roleID := ""
|
||||
roleName := ""
|
||||
if p.Hero.Role != nil {
|
||||
roleID = p.Hero.Role.ID.String()
|
||||
if p.Hero.Role.Title != "" {
|
||||
roleName = p.Hero.Role.Title
|
||||
}
|
||||
}
|
||||
|
||||
achievements := make(map[string]dto.AchievementItemDTO)
|
||||
for _, a := range p.About.Achievements {
|
||||
key := strings.ToLower(strings.ReplaceAll(a.Title, " ", ""))
|
||||
if key == "" {
|
||||
key = a.Title
|
||||
}
|
||||
achievements[key] = dto.AchievementItemDTO{Value: a.Value, Enabled: a.Enabled}
|
||||
}
|
||||
if len(achievements) == 0 {
|
||||
achievements = map[string]dto.AchievementItemDTO{
|
||||
"happyClient": {Value: "", Enabled: true},
|
||||
"yearExperience": {Value: "", Enabled: true},
|
||||
"projectCompeleted": {Value: "", Enabled: true},
|
||||
}
|
||||
}
|
||||
|
||||
var socialLinks []dto.SocialLinkDTO
|
||||
for _, sl := range p.Contact.SocialLinks {
|
||||
socialLinks = append(socialLinks, dto.SocialLinkDTO{LinkType: sl.LinkType, Link: sl.Link})
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(p.Hero.FirstName + " " + p.Hero.LastName)
|
||||
if displayName == "" {
|
||||
displayName = p.Handle
|
||||
}
|
||||
|
||||
status := "published"
|
||||
if p.PageSetting.VisibilityLevel != "public" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
return dto.FlatProfileDTO{
|
||||
ID: p.ID.String(),
|
||||
ProfileHandle: p.Handle,
|
||||
Status: status,
|
||||
BackgroundImage: "",
|
||||
ProfilePicture: p.About.ProfilePicture,
|
||||
FirstName: p.Hero.FirstName,
|
||||
LastName: p.Hero.LastName,
|
||||
DisplayName: displayName,
|
||||
RoleID: roleID,
|
||||
Role: dto.RoleDTO{ID: roleID, Name: roleName},
|
||||
CurrentCompany: p.Hero.Company,
|
||||
ShortDescription: p.Hero.ShortDescription,
|
||||
CTAEnabled: p.Hero.CTAEnabled,
|
||||
CTAAction: "",
|
||||
ResumeLink: p.Hero.ResumeLink,
|
||||
About: p.About.About,
|
||||
ContactEmail: p.Contact.Email,
|
||||
Achievements: achievements,
|
||||
ContactPhone: p.Contact.Phone,
|
||||
Country: "",
|
||||
CustomRoles: "",
|
||||
RoleLevel: p.Hero.Role.Level,
|
||||
SocialLinks: socialLinks,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
HandleUpdatedAt: time.Time{},
|
||||
}
|
||||
}
|
||||
176
internal/application/specialist/service_test.go
Normal file
176
internal/application/specialist/service_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package specialist_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
appMock "base/internal/application/mock"
|
||||
"base/internal/application/specialist"
|
||||
domainAsset "base/internal/domain/asset"
|
||||
domainProfile "base/internal/domain/profile"
|
||||
)
|
||||
|
||||
func TestSpecialistService_Overview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := zerolog.Nop()
|
||||
userID := uuid.New()
|
||||
profileID := uuid.New()
|
||||
|
||||
t.Run("success - returns overview with profile, assets, tasks", func(t *testing.T) {
|
||||
assetRepo := new(appMock.MockAssetRepository)
|
||||
profileRepo := new(appMock.MockProfileRepository)
|
||||
|
||||
profile := &domainProfile.Profile{
|
||||
ID: profileID,
|
||||
Handle: "specialist-user",
|
||||
UserID: &userID,
|
||||
Hero: domainProfile.Hero{
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
ShortDescription: "ML Engineer",
|
||||
},
|
||||
About: domainProfile.About{
|
||||
ProfilePicture: "avatar.jpg",
|
||||
About: "About me",
|
||||
},
|
||||
Skills: []domainProfile.Skill{
|
||||
{SkillName: "Go", Level: "expert"},
|
||||
},
|
||||
Contact: domainProfile.Contact{
|
||||
Email: "jane@example.com",
|
||||
SocialLinks: []domainProfile.SocialLink{{LinkType: "github", Link: "https://github.com/jane"}},
|
||||
},
|
||||
PageSetting: domainProfile.PageSetting{VisibilityLevel: "public"},
|
||||
}
|
||||
|
||||
asset := &domainAsset.Asset{
|
||||
ID: uuid.New(),
|
||||
ProfileID: profileID,
|
||||
Status: domainAsset.StatusPublished,
|
||||
AssetCategoryID: uuid.New(),
|
||||
Title: "My Project",
|
||||
Description: "A cool project",
|
||||
AssetArtifacts: []domainAsset.Artifact{{Type: "image", DownloadURL: "cover.png", Price: 0}},
|
||||
}
|
||||
|
||||
otherProfile := &domainProfile.Profile{
|
||||
ID: uuid.New(),
|
||||
Handle: "other-user",
|
||||
Hero: domainProfile.Hero{FirstName: "Other", LastName: "User"},
|
||||
PageSetting: domainProfile.PageSetting{VisibilityLevel: "public"},
|
||||
}
|
||||
|
||||
profileRepo.On("FindByUserID", ctx, userID).Return(profile, nil)
|
||||
assetRepo.On("FindByProfileID", ctx, profileID).Return([]*domainAsset.Asset{asset}, nil)
|
||||
profileRepo.On("FindAll", ctx, mock.MatchedBy(func(arg interface{}) bool {
|
||||
f, ok := arg.(domainProfile.Filter)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return f.Page == 1 && f.PageSize == 6 && f.SortedBy == "created_at" && !f.Ascending
|
||||
})).Return([]*domainProfile.Profile{otherProfile}, 26, nil)
|
||||
assetRepo.On("Count", ctx).Return(42, nil)
|
||||
|
||||
svc := specialist.New(specialist.Param{
|
||||
Logger: logger,
|
||||
ProfileRepo: profileRepo,
|
||||
AssetRepo: assetRepo,
|
||||
})
|
||||
|
||||
resp, err := svc.Overview(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
assert.Equal(t, "", resp.Message)
|
||||
assert.Len(t, resp.Data.Assets, 1)
|
||||
assert.Equal(t, "My Project", resp.Data.Assets[0].Title)
|
||||
|
||||
assert.Len(t, resp.Data.RecentlyJoined, 1)
|
||||
assert.Equal(t, "other-user", resp.Data.RecentlyJoined[0].ProfileHandle)
|
||||
|
||||
assert.Equal(t, 42, resp.Data.Analytics.TotalAssets)
|
||||
assert.Equal(t, 26, resp.Data.Analytics.TotalProfiles)
|
||||
|
||||
require.NotNil(t, resp.Data.Profile)
|
||||
assert.Equal(t, "specialist-user", resp.Data.Profile.Handle)
|
||||
assert.Len(t, resp.Data.Skills, 1)
|
||||
assert.Equal(t, "Go", resp.Data.Skills[0].SkillName)
|
||||
|
||||
// All sections complete -> 100% or high completion
|
||||
assert.False(t, resp.Data.Tasks.ProfileAction)
|
||||
assert.False(t, resp.Data.Tasks.AboutAction)
|
||||
assert.False(t, resp.Data.Tasks.PublishAction)
|
||||
assert.False(t, resp.Data.Tasks.WorksAction)
|
||||
assert.False(t, resp.Data.Tasks.SkillsAction)
|
||||
assert.False(t, resp.Data.Tasks.SocialAction)
|
||||
assert.Equal(t, 100, resp.Data.CompletionPercent)
|
||||
|
||||
assetRepo.AssertExpectations(t)
|
||||
profileRepo.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("profile not found returns ErrProfileNotFound", func(t *testing.T) {
|
||||
assetRepo := new(appMock.MockAssetRepository)
|
||||
profileRepo := new(appMock.MockProfileRepository)
|
||||
|
||||
profileRepo.On("FindByUserID", ctx, userID).Return(nil, domainProfile.ErrProfileNotFound)
|
||||
|
||||
svc := specialist.New(specialist.Param{
|
||||
Logger: logger,
|
||||
ProfileRepo: profileRepo,
|
||||
AssetRepo: assetRepo,
|
||||
})
|
||||
|
||||
resp, err := svc.Overview(ctx, userID)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, domainProfile.ErrProfileNotFound))
|
||||
assert.Nil(t, resp)
|
||||
|
||||
profileRepo.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("incomplete profile computes tasks and completion percent", func(t *testing.T) {
|
||||
assetRepo := new(appMock.MockAssetRepository)
|
||||
profileRepo := new(appMock.MockProfileRepository)
|
||||
|
||||
profile := &domainProfile.Profile{
|
||||
ID: profileID,
|
||||
Handle: "incomplete",
|
||||
UserID: &userID,
|
||||
Hero: domainProfile.Hero{FirstName: "A"}, // missing LastName, ShortDescription
|
||||
About: domainProfile.About{}, // missing picture, about
|
||||
Skills: []domainProfile.Skill{},
|
||||
PageSetting: domainProfile.PageSetting{VisibilityLevel: "private"},
|
||||
}
|
||||
|
||||
profileRepo.On("FindByUserID", ctx, userID).Return(profile, nil)
|
||||
assetRepo.On("FindByProfileID", ctx, profileID).Return([]*domainAsset.Asset{}, nil)
|
||||
profileRepo.On("FindAll", ctx, mock.Anything).Return([]*domainProfile.Profile{}, 0, nil)
|
||||
assetRepo.On("Count", ctx).Return(0, nil)
|
||||
|
||||
svc := specialist.New(specialist.Param{
|
||||
Logger: logger,
|
||||
ProfileRepo: profileRepo,
|
||||
AssetRepo: assetRepo,
|
||||
})
|
||||
|
||||
resp, err := svc.Overview(ctx, userID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
assert.True(t, resp.Data.Tasks.ProfileAction)
|
||||
assert.True(t, resp.Data.Tasks.AboutAction)
|
||||
assert.True(t, resp.Data.Tasks.PublishAction)
|
||||
assert.True(t, resp.Data.Tasks.WorksAction)
|
||||
assert.True(t, resp.Data.Tasks.SkillsAction)
|
||||
assert.True(t, resp.Data.Tasks.SocialAction)
|
||||
assert.Equal(t, 0, resp.Data.CompletionPercent)
|
||||
})
|
||||
}
|
||||
76
internal/delivery/http/backoffice/back_office.go
Normal file
76
internal/delivery/http/backoffice/back_office.go
Normal 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)
|
||||
}
|
||||
7
internal/delivery/http/backoffice/permissions.go
Normal file
7
internal/delivery/http/backoffice/permissions.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package backoffice
|
||||
|
||||
var HttpRoutePermissionMap = map[string]string{}
|
||||
|
||||
var GrpcRoutePermissionMap = map[string]string{}
|
||||
|
||||
var ExcludedGrpcRoutePermissionMap = map[string]string{}
|
||||
216
internal/delivery/http/backoffice/profilerole.go
Normal file
216
internal/delivery/http/backoffice/profilerole.go
Normal 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)
|
||||
}
|
||||
50
internal/delivery/http/backoffice/utils.go
Normal file
50
internal/delivery/http/backoffice/utils.go
Normal 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
|
||||
}
|
||||
13
internal/delivery/http/module.go
Normal file
13
internal/delivery/http/module.go
Normal 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),
|
||||
)
|
||||
363
internal/delivery/http/platform/asset.go
Normal file
363
internal/delivery/http/platform/asset.go
Normal 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)
|
||||
}
|
||||
468
internal/delivery/http/platform/auth.go
Normal file
468
internal/delivery/http/platform/auth.go
Normal 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)
|
||||
}
|
||||
36
internal/delivery/http/platform/landing.go
Normal file
36
internal/delivery/http/platform/landing.go
Normal 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)
|
||||
}
|
||||
106
internal/delivery/http/platform/overview.go
Normal file
106
internal/delivery/http/platform/overview.go
Normal 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)
|
||||
}
|
||||
274
internal/delivery/http/platform/profile.go
Normal file
274
internal/delivery/http/platform/profile.go
Normal 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)
|
||||
}
|
||||
34
internal/delivery/http/platform/profilerole.go
Normal file
34
internal/delivery/http/platform/profilerole.go
Normal 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)
|
||||
}
|
||||
163
internal/delivery/http/platform/public.go
Normal file
163
internal/delivery/http/platform/public.go
Normal 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)
|
||||
}
|
||||
34
internal/delivery/http/platform/skill.go
Normal file
34
internal/delivery/http/platform/skill.go
Normal 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)
|
||||
}
|
||||
185
internal/delivery/http/platform/specialist.go
Normal file
185
internal/delivery/http/platform/specialist.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
internal/delivery/http/platform/user.go
Normal file
141
internal/delivery/http/platform/user.go
Normal 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)
|
||||
}
|
||||
58
internal/delivery/http/platform/utils.go
Normal file
58
internal/delivery/http/platform/utils.go
Normal 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
|
||||
}
|
||||
12
internal/delivery/module.go
Normal file
12
internal/delivery/module.go
Normal 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
0
internal/domain/.gitkeep
Normal file
17
internal/domain/asset/artifact.go
Normal file
17
internal/domain/asset/artifact.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
36
internal/domain/asset/asset.go
Normal file
36
internal/domain/asset/asset.go
Normal 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
|
||||
}
|
||||
|
||||
17
internal/domain/asset/category.go
Normal file
17
internal/domain/asset/category.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
22
internal/domain/asset/comment.go
Normal file
22
internal/domain/asset/comment.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
51
internal/domain/asset/report.go
Normal file
51
internal/domain/asset/report.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
29
internal/domain/asset/repository.go
Normal file
29
internal/domain/asset/repository.go
Normal 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)
|
||||
}
|
||||
25
internal/domain/auth/account.go
Normal file
25
internal/domain/auth/account.go
Normal 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
|
||||
}
|
||||
17
internal/domain/auth/events.go
Normal file
17
internal/domain/auth/events.go
Normal 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"`
|
||||
}
|
||||
33
internal/domain/auth/query.go
Normal file
33
internal/domain/auth/query.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
51
internal/domain/auth/repository.go
Normal file
51
internal/domain/auth/repository.go
Normal 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)
|
||||
}
|
||||
15
internal/domain/auth/role.go
Normal file
15
internal/domain/auth/role.go
Normal 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
|
||||
}
|
||||
65
internal/domain/auth/user.go
Normal file
65
internal/domain/auth/user.go
Normal 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
|
||||
}
|
||||
24
internal/domain/bookmark/asset_bookmark_group.go
Normal file
24
internal/domain/bookmark/asset_bookmark_group.go
Normal 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
|
||||
}
|
||||
|
||||
21
internal/domain/bookmark/specialist_bookmark.go
Normal file
21
internal/domain/bookmark/specialist_bookmark.go
Normal 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
|
||||
}
|
||||
|
||||
24
internal/domain/feedback/feedback.go
Normal file
24
internal/domain/feedback/feedback.go
Normal 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
|
||||
}
|
||||
27
internal/domain/notification/notification.go
Normal file
27
internal/domain/notification/notification.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
17
internal/domain/preference/preference.go
Normal file
17
internal/domain/preference/preference.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
13
internal/domain/profile/about.go
Normal file
13
internal/domain/profile/about.go
Normal 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
|
||||
}
|
||||
12
internal/domain/profile/contact.go
Normal file
12
internal/domain/profile/contact.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package profile
|
||||
|
||||
type Contact struct {
|
||||
Email string
|
||||
Phone string
|
||||
SocialLinks []SocialLink
|
||||
}
|
||||
|
||||
type SocialLink struct {
|
||||
LinkType string
|
||||
Link string
|
||||
}
|
||||
5
internal/domain/profile/errors.go
Normal file
5
internal/domain/profile/errors.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package profile
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrProfileNotFound = errors.New("profile not found")
|
||||
15
internal/domain/profile/filter.go
Normal file
15
internal/domain/profile/filter.go
Normal 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
|
||||
}
|
||||
12
internal/domain/profile/hero.go
Normal file
12
internal/domain/profile/hero.go
Normal 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
|
||||
}
|
||||
5
internal/domain/profile/page_setting.go
Normal file
5
internal/domain/profile/page_setting.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package profile
|
||||
|
||||
type PageSetting struct {
|
||||
VisibilityLevel string // enum: public, private, only_me
|
||||
}
|
||||
21
internal/domain/profile/profile.go
Normal file
21
internal/domain/profile/profile.go
Normal 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
|
||||
}
|
||||
17
internal/domain/profile/repository.go
Normal file
17
internal/domain/profile/repository.go
Normal 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)
|
||||
}
|
||||
9
internal/domain/profile/role.go
Normal file
9
internal/domain/profile/role.go
Normal 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
|
||||
}
|
||||
20
internal/domain/profile/role_repository.go
Normal file
20
internal/domain/profile/role_repository.go
Normal 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
|
||||
}
|
||||
6
internal/domain/profile/skill.go
Normal file
6
internal/domain/profile/skill.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package profile
|
||||
|
||||
type Skill struct {
|
||||
SkillName string
|
||||
Level string
|
||||
}
|
||||
16
internal/domain/profileold/achievement.go
Normal file
16
internal/domain/profileold/achievement.go
Normal 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
|
||||
}
|
||||
16
internal/domain/profileold/availability_exception.go
Normal file
16
internal/domain/profileold/availability_exception.go
Normal 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
|
||||
}
|
||||
16
internal/domain/profileold/availability_rule.go
Normal file
16
internal/domain/profileold/availability_rule.go
Normal 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
|
||||
}
|
||||
12
internal/domain/profileold/award.go
Normal file
12
internal/domain/profileold/award.go
Normal 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
|
||||
}
|
||||
22
internal/domain/profileold/booking_service.go
Normal file
22
internal/domain/profileold/booking_service.go
Normal 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
|
||||
}
|
||||
12
internal/domain/profileold/certification.go
Normal file
12
internal/domain/profileold/certification.go
Normal 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
|
||||
}
|
||||
18
internal/domain/profileold/education.go
Normal file
18
internal/domain/profileold/education.go
Normal 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
|
||||
}
|
||||
17
internal/domain/profileold/experience.go
Normal file
17
internal/domain/profileold/experience.go
Normal 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
|
||||
}
|
||||
52
internal/domain/profileold/profile.go
Normal file
52
internal/domain/profileold/profile.go
Normal 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"`
|
||||
}
|
||||
22
internal/domain/profileold/skill.go
Normal file
22
internal/domain/profileold/skill.go
Normal 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
|
||||
}
|
||||
12
internal/domain/profileold/social_link.go
Normal file
12
internal/domain/profileold/social_link.go
Normal 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
|
||||
}
|
||||
67
internal/domain/purchase/booked_service.go
Normal file
67
internal/domain/purchase/booked_service.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
38
internal/domain/purchase/purchased_asset.go
Normal file
38
internal/domain/purchase/purchased_asset.go
Normal 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
|
||||
}
|
||||
|
||||
|
||||
13
internal/domain/skill/repository.go
Normal file
13
internal/domain/skill/repository.go
Normal 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)
|
||||
}
|
||||
9
internal/domain/skill/skill.go
Normal file
9
internal/domain/skill/skill.go
Normal 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
|
||||
}
|
||||
36
internal/domain/ticket/ticket.go
Normal file
36
internal/domain/ticket/ticket.go
Normal 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
17
internal/dto/account.go
Normal 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
138
internal/dto/asset.go
Normal 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
291
internal/dto/auth.go
Normal 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
7
internal/dto/base.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package dto
|
||||
|
||||
import "base/pkg/validation"
|
||||
|
||||
type DTO interface {
|
||||
Schema() validation.Schema
|
||||
}
|
||||
26
internal/dto/blog.go
Normal file
26
internal/dto/blog.go
Normal 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
92
internal/dto/landing.go
Normal 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
157
internal/dto/overview.go
Normal 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
190
internal/dto/profile.go
Normal 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
107
internal/dto/response.go
Normal 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
50
internal/dto/role.go
Normal 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
7
internal/dto/skill.go
Normal 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"`
|
||||
}
|
||||
7
internal/dto/specialist.go
Normal file
7
internal/dto/specialist.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package dto
|
||||
|
||||
type Specialist struct {
|
||||
Id string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
17
internal/pkg/azure/azblob/azblob.go
Normal file
17
internal/pkg/azure/azblob/azblob.go
Normal 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
|
||||
}
|
||||
27
internal/pkg/azure/azbus/azbus.go
Normal file
27
internal/pkg/azure/azbus/azbus.go
Normal 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,
|
||||
)
|
||||
}
|
||||
15
internal/pkg/azure/azureidentity/azidentity.go
Normal file
15
internal/pkg/azure/azureidentity/azidentity.go
Normal 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
|
||||
}
|
||||
143
internal/pkg/azure/communication/azcommunication.go
Normal file
143
internal/pkg/azure/communication/azcommunication.go
Normal 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())
|
||||
}
|
||||
41
internal/pkg/azure/communication/dto.go
Normal file
41
internal/pkg/azure/communication/dto.go
Normal 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
Reference in New Issue
Block a user