package service import ( "context" "encoding/json" "fmt" "time" "gorm.io/datatypes" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/pkg/crypto" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // ProfileService implements business logic for user profile operations. // Reference: Chatwoot app/controllers/api/v1/profile_controller.rb type ProfileService struct { userRepo *repository.UserRepo accountUserRepo *repository.AccountUserRepo accessTokenRepo *repository.AccessTokenRepo } // NewProfileService creates a new Profile service. func NewProfileService(userRepo *repository.UserRepo, accountUserRepo *repository.AccountUserRepo, accessTokenRepo ...*repository.AccessTokenRepo) *ProfileService { var tokenRepo *repository.AccessTokenRepo if len(accessTokenRepo) > 0 { tokenRepo = accessTokenRepo[0] } return &ProfileService{userRepo: userRepo, accountUserRepo: accountUserRepo, accessTokenRepo: tokenRepo} } // ProfileUserResponse matches Chatwoot app/views/api/v1/models/_user.json.jbuilder. type ProfileUserResponse struct { AccessToken string `json:"access_token"` AccountID *uint `json:"account_id"` AvailableName string `json:"available_name"` AvatarURL string `json:"avatar_url"` Confirmed bool `json:"confirmed"` DisplayName string `json:"display_name"` MessageSignature string `json:"message_signature"` Email string `json:"email"` ID uint `json:"id"` InviterID *uint `json:"inviter_id"` Name string `json:"name"` Provider string `json:"provider"` PubsubToken string `json:"pubsub_token"` CustomAttributes map[string]any `json:"custom_attributes,omitempty"` Role string `json:"role"` UISettings map[string]any `json:"ui_settings"` UID string `json:"uid"` Type string `json:"type"` Accounts []ProfileAccountResponse `json:"accounts"` } // ProfileAccountResponse is the nested account_user entry rendered by Chatwoot's user serializer. type ProfileAccountResponse struct { ID uint `json:"id"` Name string `json:"name"` Status string `json:"status"` OnboardingStep string `json:"onboarding_step"` ActiveAt *string `json:"active_at"` Role string `json:"role"` Permissions []string `json:"permissions"` Availability string `json:"availability"` AvailabilityStatus string `json:"availability_status"` AutoOffline bool `json:"auto_offline"` } // UpdateProfileRequest is the DTO for updating user profile. // Reference: Chatwoot profiles_controller#update — params wrapped in "profile" key type UpdateProfileRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` Email string `json:"email,omitempty" validate:"omitempty,email"` DisplayName *string `json:"display_name,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` MessageSignature *string `json:"message_signature,omitempty"` AccountID uint `json:"account_id,omitempty"` UISettings map[string]any `json:"ui_settings,omitempty"` PhoneNumber *string `json:"phone_number,omitempty"` Availability string `json:"availability,omitempty" validate:"omitempty,oneof=online offline busy"` CurrentPassword string `json:"current_password,omitempty"` Password string `json:"password,omitempty" validate:"omitempty,min=6"` PasswordConfirmation string `json:"password_confirmation,omitempty"` } // ProfileUpdatePayload wraps UpdateProfileRequest under the "profile" key, // matching Chatwoot's params.require(:profile).permit(:name, :email, ...) type ProfileUpdatePayload struct { Profile UpdateProfileRequest `json:"profile" validate:"required"` } // UpdateAvatarRequest is the DTO for updating user avatar. type UpdateAvatarRequest struct { AvatarURL string `json:"avatar_url" validate:"required"` } // AvailabilityRequest is the DTO for updating user availability per account. // Reference: Chatwoot profiles_controller#availability — params wrapped in "profile" key type AvailabilityRequest struct { AccountID uint `json:"account_id" validate:"required"` Availability string `json:"availability" validate:"required,oneof=online offline busy"` } // ProfileAvailabilityPayload wraps AvailabilityRequest under the "profile" key, // matching Chatwoot's params.require(:profile).permit(:account_id, :availability) type ProfileAvailabilityPayload struct { Profile AvailabilityRequest `json:"profile" validate:"required"` } // AutoOfflineRequest is the DTO for updating auto_offline setting per account. // Reference: Chatwoot profiles_controller#auto_offline — params wrapped in "profile" key type AutoOfflineRequest struct { AccountID uint `json:"account_id" validate:"required"` AutoOffline bool `json:"auto_offline"` } // ProfileAutoOfflinePayload wraps AutoOfflineRequest under the "profile" key, // matching Chatwoot's params.require(:profile).permit(:account_id, :auto_offline) type ProfileAutoOfflinePayload struct { Profile AutoOfflineRequest `json:"profile" validate:"required"` } // SetActiveAccountRequest is the DTO for setting the active account. // Reference: Chatwoot profiles_controller#set_active_account — params wrapped in "profile" key type SetActiveAccountRequest struct { AccountID uint `json:"account_id" validate:"required"` } // ProfileSetActiveAccountPayload wraps SetActiveAccountRequest under the "profile" key, // matching Chatwoot's params.require(:profile).permit(:account_id) type ProfileSetActiveAccountPayload struct { Profile SetActiveAccountRequest `json:"profile" validate:"required"` } // Get retrieves the current user's profile. func (s *ProfileService) Get(ctx context.Context, userID uint, accountID uint) (*ProfileUserResponse, error) { user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } return s.serializeUser(ctx, user, accountID) } // Update updates the current user's profile. func (s *ProfileService) Update(ctx context.Context, userID uint, accountID uint, req UpdateProfileRequest) (*ProfileUserResponse, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } if req.Name != "" { user.Name = req.Name } if req.Email != "" { user.Email = req.Email } if req.DisplayName != nil { user.DisplayName = *req.DisplayName } if req.AvatarURL != "" { user.AvatarURL = req.AvatarURL } if req.MessageSignature != nil { user.MessageSignature = *req.MessageSignature } if req.UISettings != nil { encoded, err := json.Marshal(req.UISettings) if err != nil { return nil, fmt.Errorf("invalid ui_settings: %w", err) } user.UISettings = datatypes.JSON(encoded) } if req.PhoneNumber != nil { attrs := jsonObject(user.CustomAttributes) attrs["phone_number"] = *req.PhoneNumber encoded, err := json.Marshal(attrs) if err != nil { return nil, fmt.Errorf("invalid custom attributes: %w", err) } user.CustomAttributes = datatypes.JSON(encoded) } if req.Password != "" { if req.Password != req.PasswordConfirmation { return nil, fmt.Errorf("invalid password confirmation") } if !crypto.CheckPassword(req.CurrentPassword, user.PasswordDigest) && !crypto.CheckPassword(req.CurrentPassword, user.Password) { return nil, fmt.Errorf("invalid current password") } passwordDigest, err := crypto.HashPassword(req.Password) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } user.PasswordDigest = passwordDigest user.Password = passwordDigest } // Map availability to user.Available boolean if req.Availability == "online" { user.Available = true } else if req.Availability == "offline" || req.Availability == "busy" { user.Available = false } if err := s.userRepo.Update(ctx, user); err != nil { applogger.L().Errorf("failed to update profile: %v", err) return nil, fmt.Errorf("failed to update profile: %w", err) } return s.serializeUser(ctx, user, accountID) } // UpdateAvatar updates the current user's avatar URL. func (s *ProfileService) UpdateAvatar(ctx context.Context, userID uint, accountID uint, req UpdateAvatarRequest) (*ProfileUserResponse, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } user.AvatarURL = req.AvatarURL if err := s.userRepo.Update(ctx, user); err != nil { applogger.L().Errorf("failed to update avatar: %v", err) return nil, fmt.Errorf("failed to update avatar: %w", err) } return s.serializeUser(ctx, user, accountID) } // SetAvailability updates the user's availability status for a specific account. // Reference: Chatwoot profiles_controller#availability — POST /api/v1/profile/availability func (s *ProfileService) SetAvailability(ctx context.Context, userID uint, req AvailabilityRequest) (*ProfileUserResponse, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if err := s.accountUserRepo.UpdateAvailability(ctx, req.AccountID, userID, req.Availability); err != nil { return nil, fmt.Errorf("failed to update availability: %w", err) } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } return s.serializeUser(ctx, user, req.AccountID) } // SetAutoOffline updates the user's auto_offline setting for a specific account. // Reference: Chatwoot profiles_controller#auto_offline — POST /api/v1/profile/auto_offline func (s *ProfileService) SetAutoOffline(ctx context.Context, userID uint, req AutoOfflineRequest) (*ProfileUserResponse, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if err := s.accountUserRepo.UpdateAutoOffline(ctx, req.AccountID, userID, req.AutoOffline); err != nil { return nil, fmt.Errorf("failed to update auto_offline: %w", err) } user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } return s.serializeUser(ctx, user, req.AccountID) } // SetActiveAccount updates the active_at timestamp for the user's account membership. // Reference: Chatwoot profiles_controller#set_active_account — PUT /api/v1/profile/set_active_account func (s *ProfileService) SetActiveAccount(ctx context.Context, userID uint, req SetActiveAccountRequest) error { if err := pkgvalidator.ValidateStruct(req); err != nil { return err } if err := s.accountUserRepo.UpdateActiveAt(ctx, req.AccountID, userID); err != nil { return fmt.Errorf("failed to set active account: %w", err) } return nil } // ResendConfirmation sends a confirmation email to the user if not yet confirmed. // Reference: Chatwoot auth/resend_confirmations_controller#create — POST /api/v1/resend_confirmation func (s *ProfileService) ResendConfirmation(ctx context.Context, userID uint) error { user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return fmt.Errorf("user not found: %w", err) } if user.ConfirmedAt != nil { // Already confirmed, do nothing (per chatwoot: skip if confirmed) return nil } // TODO: integrate email sending service for confirmation emails applogger.L().Infof("ResendConfirmation called for user %d (%s)", userID, user.Email) return nil } // ResetAccessToken regenerates the user's access token. // Reference: Chatwoot profiles_controller#reset_access_token — POST /api/v1/profile/reset_access_token // In chatwoot this regenerates the Doorkeeper OAuth token. In gochat with JWT auth, // the "reset" means the current JWT is invalidated and a new one must be obtained. func (s *ProfileService) ResetAccessToken(ctx context.Context, userID uint, accountID uint) (*ProfileUserResponse, error) { user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } if _, err := s.regenerateAccessToken(ctx, user.ID); err != nil { return nil, fmt.Errorf("failed to reset access token: %w", err) } return s.serializeUser(ctx, user, accountID) } // DeleteAvatar removes the user's avatar image. // Reference: Chatwoot ProfilesController#destroy_avatar (DELETE :avatar on: :collection) func (s *ProfileService) DeleteAvatar(ctx context.Context, userID uint, accountID uint) (*ProfileUserResponse, error) { user, err := s.userRepo.FindByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } user.AvatarURL = "" if err := s.userRepo.Update(ctx, user); err != nil { return nil, fmt.Errorf("failed to remove avatar: %w", err) } return s.serializeUser(ctx, user, accountID) } func (s *ProfileService) serializeUser(ctx context.Context, user *model.User, activeAccountID uint) (*ProfileUserResponse, error) { accountUsers, err := s.accountUserRepo.FindByUserWithAccounts(ctx, user.ID) if err != nil { return nil, fmt.Errorf("failed to load account memberships: %w", err) } active := selectActiveAccountUser(accountUsers, activeAccountID) accounts := make([]ProfileAccountResponse, 0, len(accountUsers)) for _, accountUser := range accountUsers { accounts = append(accounts, profileAccountResponse(accountUser)) } accessToken, err := s.currentAccessToken(ctx, user.ID) if err != nil { return nil, err } displayName := user.DisplayName availableName := user.Name if displayName != "" { availableName = displayName } userType := user.Type if userType == "" { userType = "User" } var accountID *uint var inviterID *uint role := "" if active != nil { id := active.AccountID accountID = &id role = active.Role if active.InvitedBy != 0 { inviter := active.InvitedBy inviterID = &inviter } } return &ProfileUserResponse{ AccessToken: accessToken, AccountID: accountID, AvailableName: availableName, AvatarURL: user.AvatarURL, Confirmed: user.ConfirmedAt != nil, DisplayName: displayName, MessageSignature: user.MessageSignature, Email: user.Email, ID: user.ID, InviterID: inviterID, Name: user.Name, Provider: defaultString(user.Provider, "email"), PubsubToken: user.PubsubToken, CustomAttributes: jsonObject(user.CustomAttributes), Role: role, UISettings: jsonObject(user.UISettings), UID: user.UID, Type: userType, Accounts: accounts, }, nil } func selectActiveAccountUser(accountUsers []model.AccountUser, accountID uint) *model.AccountUser { if len(accountUsers) == 0 { return nil } if accountID != 0 { for i := range accountUsers { if accountUsers[i].AccountID == accountID { return &accountUsers[i] } } } return &accountUsers[0] } func profileAccountResponse(accountUser model.AccountUser) ProfileAccountResponse { activeAt := timeStringPtr(accountUser.ActiveAt) availability := defaultString(accountUser.Availability, "offline") status := defaultString(accountUser.Account.Status, "active") return ProfileAccountResponse{ ID: accountUser.AccountID, Name: accountUser.Account.Name, Status: status, OnboardingStep: accountUser.Account.OnboardingStep, ActiveAt: activeAt, Role: accountUser.Role, Permissions: []string{}, Availability: availability, AvailabilityStatus: availability, AutoOffline: accountUser.AutoOffline, } } func timeStringPtr(t *time.Time) *string { if t == nil { return nil } formatted := t.UTC().Format(time.RFC3339Nano) return &formatted } func jsonObject(raw []byte) map[string]any { if len(raw) == 0 || string(raw) == "null" { return map[string]any{} } var out map[string]any if err := json.Unmarshal(raw, &out); err != nil || out == nil { return map[string]any{} } return out } func defaultString(value, fallback string) string { if value == "" { return fallback } return value } func (s *ProfileService) currentAccessToken(ctx context.Context, userID uint) (string, error) { if s.accessTokenRepo == nil { return "", nil } tokens, err := s.accessTokenRepo.FindActiveByOwner(ctx, model.AccessTokenOwnerTypeUser, userID) if err != nil { return "", fmt.Errorf("failed to load access token: %w", err) } if len(tokens) > 0 { return tokens[0].Token, nil } return s.regenerateAccessToken(ctx, userID) } func (s *ProfileService) regenerateAccessToken(ctx context.Context, userID uint) (string, error) { if s.accessTokenRepo == nil { return "", nil } tokens, err := s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypeUser, userID) if err != nil { return "", err } for _, token := range tokens { if err := s.accessTokenRepo.Delete(ctx, token.ID); err != nil { return "", err } } plainToken, err := generatePlatformAccessToken() if err != nil { return "", err } accessToken := &model.AccessToken{ OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: userID, Token: plainToken, TokenPrefix: tokenPrefix(plainToken), Name: "Personal Access Token", } if err := s.accessTokenRepo.Create(ctx, accessToken); err != nil { return "", err } return plainToken, nil }