Files
tm_back/internal/user/service.go
T
Mazyar ce8a18aa8b
continuous-integration/drone/push Build is passing
Add unit tests for UpdateUserForm JSON unmarshalling
- Introduced `form_test.go` to validate the behavior of the `UpdateUserForm` struct's JSON unmarshalling, specifically for the `profile_image` field.
- Added tests to ensure omitted `profile_image` is not treated as an update, null values clear the image, and valid URLs are preserved.
- Enhanced the `UpdateUserForm` and `UpdateProfileForm` structs to include logic for distinguishing between omitted and explicitly null profile images during JSON unmarshalling.

This update improves the robustness of user profile updates by ensuring correct handling of profile image data in JSON requests, enhancing overall data integrity in the user management system.
2026-06-22 22:09:30 +03:30

704 lines
20 KiB
Go

package user
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"tm/pkg/audit"
"tm/pkg/authorization"
"tm/pkg/logger"
"tm/pkg/notification"
"tm/pkg/redis"
"tm/pkg/response"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// Service defines business logic for user operations
type Service interface {
// Main CRUD
Register(ctx context.Context, form *CreateUserForm) (*UserResponse, error)
Update(ctx context.Context, id string, form *UpdateUserForm) (*UserResponse, error)
UpdateStatus(ctx context.Context, id string, form *UpdateUserStatusForm) error
GetUserByID(ctx context.Context, userID string) (*UserResponse, error)
GetUsersByIDs(ctx context.Context, userIDs []string) (*UserListResponse, error)
Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error)
Delete(ctx context.Context, id string) error
AdminResetPassword(ctx context.Context, actorID, actorRole, targetID string) (*AdminResetPasswordResponse, error)
// Profile
Login(ctx context.Context, form *LoginForm) (*AuthResponse, error)
UpdateProfile(ctx context.Context, userID string, form *UpdateProfileForm) error
RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error)
ChangePassword(ctx context.Context, userID string, form *ChangePasswordForm) error
ResetPassword(ctx context.Context, form *ResetPasswordForm) error
Logout(ctx context.Context, userID string, accessToken string) error
}
// userService implements the Service interface
type userService struct {
repository Repository
logger logger.Logger
authService authorization.AuthorizationService
validator ValidationService
notification notification.SDK
redisClient redis.Client
auditLogger audit.Logger
}
// NewService creates a new user service
func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, validator ValidationService, notificationSDK notification.SDK, redisClient redis.Client, auditLogger audit.Logger) Service {
return &userService{
repository: repository,
logger: logger,
authService: authService,
validator: validator,
notification: notificationSDK,
redisClient: redisClient,
auditLogger: auditLogger,
}
}
// Register creates a new user
func (s *userService) Register(ctx context.Context, form *CreateUserForm) (*UserResponse, error) {
// Check if email already exists
existingUser, _ := s.repository.GetByEmail(ctx, form.Email)
if existingUser != nil {
return nil, errors.New("email already exists")
}
// Check if username already exists
existingUser, _ = s.repository.GetByUsername(ctx, strings.ToLower(form.Username))
if existingUser != nil {
return nil, errors.New("username already exists")
}
if form.Phone != nil && *form.Phone != "" {
existingUser, _ = s.repository.GetByPhone(ctx, *form.Phone)
if existingUser != nil {
return nil, errors.New("phone number already exists")
}
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.Password), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash password", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to process password")
}
// Validate role
role := UserRole(form.Role)
if !s.validator.IsValidRole(role) {
return nil, errors.New("invalid role")
}
// Create user
user := &User{
FullName: form.FullName,
Username: strings.ToLower(form.Username),
Email: form.Email,
Password: string(hashedPassword),
Role: role,
Status: UserStatusActive,
Department: form.Department,
Position: form.Position,
Phone: form.Phone,
ProfileImage: form.ProfileImage,
IsVerified: true,
}
// Save to database
err = s.repository.Create(ctx, user)
if err != nil {
if mapped := mapUserWriteError(err); mapped != err {
return nil, mapped
}
s.logger.Error("Failed to register user", map[string]interface{}{
"error": err.Error(),
"email": form.Email,
"username": form.Username,
})
return nil, err
}
s.logger.Info("User registered successfully", map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
"username": user.Username,
"role": user.Role,
})
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: form.Email,
Title: "Welcome to Opplens",
Message: fmt.Sprintf("Welcome to Opplens\nUsername: %s\nPassword: %s", strings.ToLower(form.Username), form.Password),
Type: "info",
Priority: "important",
UserID: user.ID.Hex(),
})
return user.ToResponse(), nil
}
// Update updates user information
func (s *userService) Update(ctx context.Context, id string, form *UpdateUserForm) (*UserResponse, error) {
// Get user
user, err := s.repository.GetByID(ctx, id)
if err != nil {
return nil, errors.New("user not found")
}
// Update fields if provided
if form.FullName != nil {
user.FullName = *form.FullName
}
if form.Username != nil {
// Check if username already exists
existingUser, _ := s.repository.GetByUsername(ctx, strings.ToLower(*form.Username))
if existingUser != nil && existingUser.ID.Hex() != id {
return nil, errors.New("username already exists")
}
user.Username = strings.ToLower(*form.Username)
}
if form.Email != nil {
// Check if email already exists
existingUser, _ := s.repository.GetByEmail(ctx, *form.Email)
if existingUser != nil && existingUser.ID.Hex() != id {
return nil, errors.New("email already exists")
}
user.Email = *form.Email
}
if form.Role != nil {
role := UserRole(*form.Role)
if !s.validator.IsValidRole(role) {
return nil, errors.New("invalid role")
}
user.Role = role
}
if form.Department != nil {
user.Department = form.Department
}
if form.Position != nil {
user.Position = form.Position
}
if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) {
if *form.Phone != "" {
existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone)
if existingUser != nil && existingUser.ID.Hex() != id {
return nil, errors.New("phone number already exists")
}
}
user.Phone = form.Phone
}
if form.isProfileImageSet() {
user.ProfileImage = form.ProfileImage
}
// Update in database
err = s.repository.Update(ctx, user)
if err != nil {
if mapped := mapUserWriteError(err); mapped != err {
return nil, mapped
}
s.logger.Error("Failed to update user", map[string]interface{}{
"error": err.Error(),
"user_id": id,
})
return nil, errors.New("failed to update user")
}
s.logger.Info("User updated successfully", map[string]interface{}{
"user_id": id,
})
return user.ToResponse(), nil
}
// UpdateStatus updates user status
func (s *userService) UpdateStatus(ctx context.Context, id string, form *UpdateUserStatusForm) error {
// Get user
user, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("user not found")
}
// Update status
user.Status = UserStatus(form.Status)
// Update in database
err = s.repository.Update(ctx, user)
if err != nil {
s.logger.Error("Failed to update user status", map[string]interface{}{
"error": err.Error(),
"user_id": id,
"status": form.Status,
})
return errors.New("failed to update user status")
}
s.logger.Info("User status updated successfully", map[string]interface{}{
"user_id": id,
"status": form.Status,
})
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: user.Email,
Title: "Account Status Updated",
Message: fmt.Sprintf("Your account has been %s", strings.ToUpper(form.Status)),
Type: "warning",
Priority: "important",
UserID: user.ID.Hex(),
})
return nil
}
// GetUserByID retrieves a user by ID
func (s *userService) GetUserByID(ctx context.Context, id string) (*UserResponse, error) {
user, err := s.repository.GetByID(ctx, id)
if err != nil {
if err.Error() == "user not found" {
return nil, errors.New("user not found")
}
s.logger.Error("Failed to get user by ID", map[string]interface{}{
"error": err.Error(),
"user_id": id,
})
return nil, err
}
return user.ToResponse(), nil
}
// GetUsersByIDs retrieves users by IDs
func (s *userService) GetUsersByIDs(ctx context.Context, userIDs []string) (*UserListResponse, error) {
users, err := s.repository.GetByIDs(ctx, userIDs)
if err != nil {
return nil, err
}
var userResponses []*UserResponse
for _, user := range users {
userResponses = append(userResponses, user.ToResponse())
}
return &UserListResponse{
Users: userResponses,
}, nil
}
// Search searches users with search and filters
func (s *userService) Search(ctx context.Context, form *SearchUsersForm, pagination *response.Pagination) (*UserListResponse, error) {
// Get users
page, err := s.repository.Search(ctx, form, pagination)
if err != nil {
s.logger.Error("Failed to list users", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
var userResponses []*UserResponse
for _, user := range page.Items {
userResponses = append(userResponses, user.ToResponse())
}
return &UserListResponse{
Users: userResponses,
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil
}
// Delete deletes a user (hard delete)
func (s *userService) Delete(ctx context.Context, id string) error {
err := s.repository.Delete(ctx, id)
if err != nil {
s.logger.Error("Failed to delete user", map[string]interface{}{
"error": err.Error(),
"user_id": id,
})
return errors.New("failed to delete user")
}
s.logger.Info("User deleted successfully", map[string]interface{}{
"user_id": id,
})
return nil
}
// Login authenticates a user
func (s *userService) Login(ctx context.Context, form *LoginForm) (*AuthResponse, error) {
// Find user by email or username
var user *User
var err error
if strings.Contains(form.Username, "@") {
user, err = s.repository.GetByEmail(ctx, form.Username)
} else {
user, err = s.repository.GetByUsername(ctx, strings.ToLower(form.Username))
}
if err != nil {
s.logger.Warn("Login attempt with invalid credentials", map[string]interface{}{
"email_or_username": form.Username,
"error": err.Error(),
})
return nil, errors.New("invalid credentials")
}
// Check if user is active
if user.Status != UserStatusActive {
return nil, errors.New("account is inactive")
}
// Verify password
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.Password))
if err != nil {
s.logger.Warn("Login attempt with wrong password", map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
})
return nil, errors.New("invalid credentials")
}
// append device token to user if not exists
if !slices.Contains(user.DeviceToken, form.DeviceToken) {
user.DeviceToken = append(user.DeviceToken, form.DeviceToken)
}
// Update last login
err = s.repository.Login(ctx, user.ID.Hex())
if err != nil {
s.logger.Error("Failed to update last login", map[string]interface{}{
"error": err.Error(),
"user_id": user.ID,
})
}
tokenPair, err := s.authService.GenerateTokenPair(
user.ID.Hex(),
user.Email,
string(user.Role),
"",
)
if err != nil {
s.logger.Error("Failed to generate JWT tokens", map[string]interface{}{
"error": err.Error(),
"user_id": user.ID,
})
return nil, errors.New("failed to generate authentication tokens")
}
s.logger.Info("User logged in successfully", map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
"role": user.Role,
})
return &AuthResponse{
User: user.ToResponse(),
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now
}, nil
}
// RefreshToken refreshes the access token
func (s *userService) RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) {
// Validate refresh token and generate new access token
newAccessToken, err := s.authService.RefreshAccessToken(refreshToken)
if err != nil {
s.logger.Warn("Failed to refresh access token", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("invalid or expired refresh token")
}
// Parse the new access token to get user information
validationResult, err := s.authService.ValidateAccessToken(newAccessToken)
if err != nil {
s.logger.Error("Failed to validate new access token", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("failed to generate valid access token")
}
if !validationResult.Valid {
s.logger.Error("New access token validation failed", map[string]interface{}{
"error": validationResult.Error,
})
return nil, errors.New("failed to generate valid access token")
}
// Get user information
userID, err := bson.ObjectIDFromHex(validationResult.Claims.UserID)
if err != nil {
s.logger.Error("Failed to parse user ID from token", map[string]interface{}{
"error": err.Error(),
})
return nil, errors.New("invalid token format")
}
user, err := s.repository.GetByID(ctx, userID.Hex())
if err != nil {
s.logger.Error("Failed to get user for token refresh", map[string]interface{}{
"error": err.Error(),
"user_id": userID.Hex(),
})
return nil, errors.New("user not found")
}
// Check if user is still active
if user.Status != UserStatusActive {
return nil, errors.New("user account is inactive")
}
s.logger.Info("Access token refreshed successfully", map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
})
return &AuthResponse{
User: user.ToResponse(),
AccessToken: newAccessToken,
RefreshToken: refreshToken, // Return the same refresh token
ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), // 15 minutes from now
}, nil
}
// ChangePassword changes user password
func (s *userService) ChangePassword(ctx context.Context, userID string, form *ChangePasswordForm) error {
// Get user
user, err := s.repository.GetByID(ctx, userID)
if err != nil {
return errors.New("user not found")
}
// Verify old password
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(form.OldPassword))
if err != nil {
return errors.New("invalid old password")
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Failed to hash new password", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return errors.New("failed to process new password")
}
// Update password
user.Password = string(hashedPassword)
err = s.repository.Update(ctx, user)
if err != nil {
s.logger.Error("Failed to update password", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return errors.New("failed to update password")
}
s.logger.Info("Password changed successfully", map[string]interface{}{
"user_id": userID,
})
go s.notification.SendEmail(context.Background(), notification.Model{
Recipient: user.Email,
Title: "Password Reset Success",
Message: "Your password has been reset successfully",
Type: "info",
Priority: "important",
UserID: user.ID.Hex(),
})
return nil
}
// ResetPassword initiates password reset process
func (s *userService) ResetPassword(ctx context.Context, form *ResetPasswordForm) error {
// Get user by email
user, err := s.repository.GetByEmail(ctx, form.Email)
if err != nil {
// Don't reveal if user exists or not for security
s.logger.Info("Password reset requested for non-existent email", map[string]interface{}{
"email": form.Email,
})
return nil // Return success even if user doesn't exist
}
// TODO: Implement password reset logic (send email with reset link)
s.logger.Info("Password reset requested", map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
})
return nil
}
// GetProfile retrieves the current user's profile
func (s *userService) GetProfile(ctx context.Context, userID string) (*User, error) {
user, err := s.repository.GetByID(ctx, userID)
if err != nil {
s.logger.Error("Failed to get user profile", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return nil, errors.New("user not found")
}
s.logger.Info("User profile retrieved successfully", map[string]interface{}{
"user_id": userID,
})
return user, nil
}
// UpdateProfile updates the current user's profile
func (s *userService) UpdateProfile(ctx context.Context, userID string, form *UpdateProfileForm) error {
user, err := s.repository.GetByID(ctx, userID)
if err != nil {
s.logger.Error("Failed to get user for profile update", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return errors.New("user not found")
}
// Update fields if provided
if form.FullName != nil {
user.FullName = *form.FullName
}
if form.Department != nil {
user.Department = form.Department
}
if form.Position != nil {
user.Position = form.Position
}
if form.Phone != nil && (user.Phone == nil || *form.Phone != *user.Phone) {
if *form.Phone != "" {
existingUser, _ := s.repository.GetByPhone(ctx, *form.Phone)
if existingUser != nil && existingUser.ID.Hex() != userID {
return errors.New("phone number already exists")
}
}
user.Phone = form.Phone
}
if form.isProfileImageSet() {
user.ProfileImage = form.ProfileImage
}
// Update timestamp
user.UpdatedAt = time.Now().Unix()
// Save to database
err = s.repository.Update(ctx, user)
if err != nil {
if mapped := mapUserWriteError(err); mapped != err {
return mapped
}
s.logger.Error("Failed to update user profile", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return errors.New("failed to update profile")
}
s.logger.Info("User profile updated successfully", map[string]interface{}{
"user_id": userID,
})
return nil
}
// UpdateUserRole updates user role
func (s *userService) UpdateRole(ctx context.Context, id string, form *UpdateUserRoleForm) error {
// Get user
user, err := s.repository.GetByID(ctx, id)
if err != nil {
return errors.New("user not found")
}
// Validate role
role := UserRole(form.Role)
if !s.validator.IsValidRole(role) {
return errors.New("invalid role")
}
// Update role
user.Role = role
// Update in database
err = s.repository.Update(ctx, user)
if err != nil {
s.logger.Error("Failed to update user role", map[string]interface{}{
"error": err.Error(),
"user_id": id,
"role": form.Role,
})
return errors.New("failed to update user role")
}
s.logger.Info("User role updated successfully", map[string]interface{}{
"user_id": id,
"role": form.Role,
})
return nil
}
// Logout logs out a user and invalidates tokens
func (s *userService) Logout(ctx context.Context, userID string, accessToken string) error {
// get user by id
user, err := s.repository.GetByID(ctx, userID)
if err != nil {
s.logger.Error("Failed to get user for logout", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
}
// Invalidate the access token
err = s.authService.InvalidateToken(accessToken)
if err != nil {
s.logger.Error("Failed to invalidate access token", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return errors.New("failed to invalidate access token")
}
// delete device token from user
user.DeviceToken = make([]string, 0)
err = s.repository.Update(ctx, user)
if err != nil {
s.logger.Error("Failed to update user device token", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
}
s.logger.Info("User logged out successfully", map[string]interface{}{
"user_id": userID,
})
return nil
}