Files
gochat/internal/service/profile_service.go
T

528 lines
19 KiB
Go

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"`
CustomRoleID *uint `json:"custom_role_id"`
CustomRole any `json:"custom_role"`
}
// 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")
role := defaultString(accountUser.Role, "agent")
permissions := []string{role}
var customRole any
var customRoleID *uint
if accountUser.CustomRole != nil && accountUser.CustomRoleID > 0 {
id := accountUser.CustomRoleID
customRoleID = &id
keys, err := accountUser.CustomRole.GetPermissionKeys()
if err == nil {
permissions = make([]string, 0, len(keys)+1)
for _, key := range keys {
permissions = append(permissions, string(key))
}
permissions = append(permissions, "custom_role")
}
customRole = map[string]any{
"id": accountUser.CustomRole.ID,
"name": accountUser.CustomRole.Name,
"description": accountUser.CustomRole.Description,
"permissions": permissionsWithoutMarker(permissions),
}
}
return ProfileAccountResponse{
ID: accountUser.AccountID,
Name: accountUser.Account.Name,
Status: status,
OnboardingStep: accountUser.Account.OnboardingStep,
ActiveAt: activeAt,
Role: role,
Permissions: permissions,
Availability: availability,
AvailabilityStatus: availability,
AutoOffline: accountUser.AutoOffline,
CustomRoleID: customRoleID,
CustomRole: customRole,
}
}
func permissionsWithoutMarker(permissions []string) []string {
keys := make([]string, 0, len(permissions))
for _, permission := range permissions {
if permission == "custom_role" {
continue
}
keys = append(keys, permission)
}
return keys
}
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
}