feat(profile): align chatwoot user serializer

This commit is contained in:
2026-06-05 01:07:40 +08:00
parent 6757e17c82
commit 56a8c5b806
9 changed files with 483 additions and 105 deletions
+1 -1
View File
@@ -607,7 +607,7 @@ func Bootstrap(env string) (*App, error) {
// Team + Profile services (P5 — Teams + Team Members + User Profiles)
teamService := service.NewTeamService(teamRepo, teamMemberRepo, db)
profileService := service.NewProfileService(userRepo, accountUserRepo)
profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo)
// Campaign + AutoAssignment services
campaignInternalSvc := campaign.NewCampaignService(db)
+20 -15
View File
@@ -30,14 +30,15 @@ func (h *ProfileHandler) Get(c *gin.Context) {
return
}
user, err := h.svc.Get(c.Request.Context(), userID)
accountID := c.GetUint("account_id")
user, err := h.svc.Get(c.Request.Context(), userID, accountID)
if err != nil {
applogger.L().Errorf("Get profile for user %d: %v", userID, err)
handleServiceError(c, err)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// Update updates the current user's profile.
@@ -55,14 +56,15 @@ func (h *ProfileHandler) Update(c *gin.Context) {
return
}
user, svcErr := h.svc.Update(c.Request.Context(), userID, req.Profile)
accountID := c.GetUint("account_id")
user, svcErr := h.svc.Update(c.Request.Context(), userID, accountID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("Update profile for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// UpdateAvatar updates the current user's avatar.
@@ -80,14 +82,15 @@ func (h *ProfileHandler) UpdateAvatar(c *gin.Context) {
return
}
user, svcErr := h.svc.UpdateAvatar(c.Request.Context(), userID, req)
accountID := c.GetUint("account_id")
user, svcErr := h.svc.UpdateAvatar(c.Request.Context(), userID, accountID, req)
if svcErr != nil {
applogger.L().Errorf("Update avatar for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// SetAvailability updates the user's availability status for a specific account.
@@ -106,14 +109,14 @@ func (h *ProfileHandler) SetAvailability(c *gin.Context) {
return
}
au, svcErr := h.svc.SetAvailability(c.Request.Context(), userID, req.Profile)
user, svcErr := h.svc.SetAvailability(c.Request.Context(), userID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("SetAvailability for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, au)
c.JSON(http.StatusOK, user)
}
// SetAutoOffline updates the user's auto_offline setting for a specific account.
@@ -132,14 +135,14 @@ func (h *ProfileHandler) SetAutoOffline(c *gin.Context) {
return
}
au, svcErr := h.svc.SetAutoOffline(c.Request.Context(), userID, req.Profile)
user, svcErr := h.svc.SetAutoOffline(c.Request.Context(), userID, req.Profile)
if svcErr != nil {
applogger.L().Errorf("SetAutoOffline for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, au)
c.JSON(http.StatusOK, user)
}
// SetActiveAccount sets the user's currently active account.
@@ -198,14 +201,15 @@ func (h *ProfileHandler) ResetAccessToken(c *gin.Context) {
return
}
user, svcErr := h.svc.ResetAccessToken(c.Request.Context(), userID)
accountID := c.GetUint("account_id")
user, svcErr := h.svc.ResetAccessToken(c.Request.Context(), userID, accountID)
if svcErr != nil {
applogger.L().Errorf("ResetAccessToken for user %d: %v", userID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, user)
c.JSON(http.StatusOK, user)
}
// DeleteAvatar removes the user's avatar.
@@ -217,11 +221,12 @@ func (h *ProfileHandler) DeleteAvatar(c *gin.Context) {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
return
}
user, err := h.svc.DeleteAvatar(c.Request.Context(), userID)
accountID := c.GetUint("account_id")
user, err := h.svc.DeleteAvatar(c.Request.Context(), userID, accountID)
if err != nil {
applogger.L().Errorf("DeleteAvatar for user %d: %v", userID, err)
handleServiceError(c, err)
return
}
response.OK(c, user)
}
c.JSON(http.StatusOK, user)
}
+124 -40
View File
@@ -54,29 +54,40 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.AccessToken{},
))
s.db = db
// Create test account
account := &model.Account{Name: "TestAccount"}
account := &model.Account{Name: "TestAccount", Status: "active", OnboardingStep: "profile"}
s.Require().NoError(db.Create(account).Error)
s.account = account
s.accountID = account.ID
// Create test user
user := &model.User{Name: "ProfileUser", Email: "profile@example.com", AccountID: account.ID}
user := &model.User{
Name: "ProfileUser",
Email: "profile@example.com",
AccountID: account.ID,
Provider: "email",
DisplayName: "Profile Display",
MessageSignature: "Regards",
PubsubToken: "pubsub-profile-user",
}
s.Require().NoError(db.Create(user).Error)
s.user = user
s.userID = user.ID
// Link user to account
accountUser := &model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}
accountUser := &model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator", Availability: "offline", AutoOffline: true}
s.Require().NoError(db.Create(accountUser).Error)
s.Require().NoError(db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: user.ID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
// Create real repos + service
userRepo := repository.NewUserRepo(db)
accountUserRepo := repository.NewAccountUserRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo)
accessTokenRepo := repository.NewAccessTokenRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo)
s.handler = NewProfileHandler(profileSvc)
// Build router with profile routes and auth middleware
@@ -110,11 +121,35 @@ func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine {
func (s *ProfileHandlerTestSuite) SetupTest() {
// Reset user to original state before each test
s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
"name": "ProfileUser",
"email": "profile@example.com",
"avatar_url": "",
"available": false,
"name": "ProfileUser",
"email": "profile@example.com",
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).Updates(map[string]interface{}{
"availability": "offline",
"auto_offline": true,
})
s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{})
s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
}
func (s *ProfileHandlerTestSuite) decodeProfileBody(w *httptest.ResponseRecorder) map[string]interface{} {
var payload map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
return payload
}
func (s *ProfileHandlerTestSuite) firstAccountFromProfile(payload map[string]interface{}) map[string]interface{} {
accounts, ok := payload["accounts"].([]interface{})
s.Require().True(ok)
s.Require().Len(accounts, 1)
account, ok := accounts[0].(map[string]interface{})
s.Require().True(ok)
return account
}
// ===================== Get Profile =====================
@@ -126,15 +161,23 @@ func (s *ProfileHandlerTestSuite) TestGet_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
// Data should contain user info
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "ProfileUser", dataMap["name"])
assert.Equal(s.T(), "profile@example.com", dataMap["email"])
assert.Equal(s.T(), "profile-token-1", dataMap["access_token"])
assert.Equal(s.T(), "Profile Display", dataMap["available_name"])
assert.Equal(s.T(), "Regards", dataMap["message_signature"])
assert.Equal(s.T(), "pubsub-profile-user", dataMap["pubsub_token"])
assert.Equal(s.T(), "administrator", dataMap["role"])
accounts, ok := dataMap["accounts"].([]interface{})
assert.True(s.T(), ok)
if assert.Len(s.T(), accounts, 1) {
account := accounts[0].(map[string]interface{})
assert.Equal(s.T(), "TestAccount", account["name"])
assert.Equal(s.T(), "offline", account["availability"])
assert.Equal(s.T(), "offline", account["availability_status"])
assert.Equal(s.T(), true, account["auto_offline"])
}
}
func (s *ProfileHandlerTestSuite) TestGet_Unauthorized() {
@@ -217,12 +260,7 @@ func (s *ProfileHandlerTestSuite) TestUpdate_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "UpdatedName", dataMap["name"])
}
@@ -241,9 +279,8 @@ func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithEmail() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "newemail@example.com", dataMap["email"])
}
func (s *ProfileHandlerTestSuite) TestUpdate_SuccessWithAvailabilityOffline() {
@@ -440,12 +477,7 @@ func (s *ProfileHandlerTestSuite) TestUpdateAvatar_Success() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "https://cdn.example.com/new-avatar.png", dataMap["avatar_url"])
}
@@ -570,6 +602,64 @@ func (s *ProfileHandlerTestSuite) TestUpdateAvatar_NilService() {
// Nil service causes panic — validates catastrophic failure path
}
// ===================== Chatwoot Profile Serializer Fixtures =====================
func (s *ProfileHandlerTestSuite) TestSetAvailability_ReturnsChatwootUserSerializer() {
body := map[string]interface{}{
"profile": map[string]interface{}{
"account_id": s.accountID,
"availability": "online",
},
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/api/v1/profile/availability", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
assert.Equal(s.T(), "ProfileUser", payload["name"])
assert.Equal(s.T(), "administrator", payload["role"])
account := s.firstAccountFromProfile(payload)
assert.Equal(s.T(), "online", account["availability"])
assert.Equal(s.T(), "online", account["availability_status"])
}
func (s *ProfileHandlerTestSuite) TestSetAutoOffline_ReturnsChatwootUserSerializer() {
body := map[string]interface{}{
"profile": map[string]interface{}{
"account_id": s.accountID,
"auto_offline": false,
},
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/api/v1/profile/auto_offline", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
account := s.firstAccountFromProfile(payload)
assert.Equal(s.T(), false, account["auto_offline"])
}
func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwootUserSerializer() {
req, _ := http.NewRequest("POST", "/api/v1/profile/reset_access_token", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
payload := s.decodeProfileBody(w)
token, ok := payload["access_token"].(string)
assert.True(s.T(), ok)
assert.NotEmpty(s.T(), token)
assert.NotEqual(s.T(), "profile-token-1", token)
}
// ===================== Edge Cases =====================
func (s *ProfileHandlerTestSuite) TestNewProfileHandler() {
@@ -591,9 +681,8 @@ func (s *ProfileHandlerTestSuite) TestGet_ViaHeaderUserID() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "ProfileUser", dataMap["name"])
}
func (s *ProfileHandlerTestSuite) TestGet_ZeroUserIDViaHeader() {
@@ -644,12 +733,7 @@ func (s *ProfileHandlerTestSuite) TestUpdate_MultipleFieldsAtOnce() {
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp response.APIResponse
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp.Success)
dataMap, ok := resp.Data.(map[string]interface{})
assert.True(s.T(), ok)
dataMap := s.decodeProfileBody(w)
assert.Equal(s.T(), "MultiUpdate", dataMap["name"])
assert.Equal(s.T(), "multi@example.com", dataMap["email"])
assert.Equal(s.T(), "https://example.com/multi.png", dataMap["avatar_url"])
@@ -682,4 +766,4 @@ func (s *ProfileHandlerTestSuite) TestUpdate_SoftDeletedUser() {
// strconvFormatUint helper for X-User-ID header tests
func strconvFormatUint(n uint) string {
return fmt.Sprintf("%d", n)
}
}
+11 -10
View File
@@ -3,15 +3,16 @@ package model
// Account represents a tenant/organization in the system.
type Account struct {
Base
Name string `gorm:"size:255;not null" json:"name"`
Domain string `gorm:"size:255" json:"domain,omitempty"`
Locale string `gorm:"size:10;default:en" json:"locale"`
Timezone string `gorm:"size:50;default:UTC" json:"timezone"`
Active bool `gorm:"default:true" json:"active"`
Status string `gorm:"size:50;default:active" json:"status"`
FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags
AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days
AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents]
Name string `gorm:"size:255;not null" json:"name"`
Domain string `gorm:"size:255" json:"domain,omitempty"`
Locale string `gorm:"size:10;default:en" json:"locale"`
Timezone string `gorm:"size:50;default:UTC" json:"timezone"`
Active bool `gorm:"default:true" json:"active"`
Status string `gorm:"size:50;default:active" json:"status"`
OnboardingStep string `gorm:"size:100" json:"onboarding_step,omitempty"`
FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags
AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days
AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents]
}
func (Account) TableName() string { return "accounts" }
func (Account) TableName() string { return "accounts" }
+53 -19
View File
@@ -1,30 +1,64 @@
package model
import (
"crypto/rand"
"encoding/hex"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// User represents an agent/admin user in the system.
type User struct {
Base
AccountID uint `gorm:"index;not null" json:"account_id"`
Name string `gorm:"size:255;not null" json:"name"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt)
PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service
Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, saml
UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers
AvatarURL string `gorm:"size:512" json:"avatar_url"`
Role string `gorm:"size:50;default:agent" json:"role"` // agent, admin
Active bool `gorm:"default:true" json:"active"`
Available bool `gorm:"default:false" json:"available"`
TOTPSecret string `gorm:"size:255" json:"totp_secret,omitempty"`
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"`
SignInCount int `gorm:"default:0" json:"sign_in_count"`
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"`
CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"`
AccountID uint `gorm:"index;not null" json:"account_id"`
Name string `gorm:"size:255;not null" json:"name"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt)
PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service
Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, saml
UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers
AvatarURL string `gorm:"size:512" json:"avatar_url"`
DisplayName string `gorm:"size:255" json:"display_name"`
MessageSignature string `gorm:"type:text" json:"message_signature"`
PubsubToken string `gorm:"size:255;uniqueIndex" json:"pubsub_token"`
UISettings datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"ui_settings"`
CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"`
Role string `gorm:"size:50;default:agent" json:"role"` // agent, admin
Type string `gorm:"size:50;default:user" json:"type"`
Active bool `gorm:"default:true" json:"active"`
Available bool `gorm:"default:false" json:"available"`
TOTPSecret string `gorm:"size:255" json:"totp_secret,omitempty"`
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"`
SignInCount int `gorm:"default:0" json:"sign_in_count"`
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"`
CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"`
}
func (User) TableName() string { return "users" }
func (User) TableName() string { return "users" }
// BeforeCreate mirrors Chatwoot's Pubsubable concern by assigning every user a cable token.
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.PubsubToken == "" {
token, err := generateSecureToken(32)
if err != nil {
return err
}
u.PubsubToken = token
}
if u.Type == "" {
u.Type = "User"
}
return nil
}
func generateSecureToken(size int) (string, error) {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+14 -1
View File
@@ -124,6 +124,7 @@ func (r *AccountUserRepo) UpdateActiveAt(ctx context.Context, accountID, userID
func (r *AccountUserRepo) FindByAccountAndUserOrFail(ctx context.Context, accountID, userID uint) (*model.AccountUser, error) {
var au model.AccountUser
err := r.db.WithContext(ctx).
Preload("Account").
Where("account_id = ? AND user_id = ?", accountID, userID).
First(&au).Error
if err != nil {
@@ -132,6 +133,18 @@ func (r *AccountUserRepo) FindByAccountAndUserOrFail(ctx context.Context, accoun
return &au, nil
}
// FindByUserWithAccounts retrieves all account memberships for a user with accounts loaded.
func (r *AccountUserRepo) FindByUserWithAccounts(ctx context.Context, userID uint) ([]model.AccountUser, error) {
var accountUsers []model.AccountUser
err := r.db.WithContext(ctx).
Preload("Account").
Where("user_id = ?", userID).
Order("active_at DESC").
Order("id ASC").
Find(&accountUsers).Error
return accountUsers, err
}
// Create inserts a new AccountUser record.
func (r *AccountUserRepo) Create(ctx context.Context, au *model.AccountUser) error {
return r.db.WithContext(ctx).Create(au).Error
@@ -145,4 +158,4 @@ func (r *AccountUserRepo) Delete(ctx context.Context, id uint) error {
// UpdateRole changes the role of an AccountUser.
func (r *AccountUserRepo) UpdateRole(ctx context.Context, id uint, role string) error {
return r.db.WithContext(ctx).Model(&model.AccountUser{}).Where("id = ?", id).Update("role", role).Error
}
}
+236 -19
View File
@@ -2,7 +2,9 @@ package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -15,11 +17,53 @@ import (
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) *ProfileService {
return &ProfileService{userRepo: userRepo, accountUserRepo: accountUserRepo}
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.
@@ -81,16 +125,16 @@ type ProfileSetActiveAccountPayload struct {
}
// Get retrieves the current user's profile.
func (s *ProfileService) Get(ctx context.Context, userID uint) (*model.User, error) {
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 user, nil
return s.serializeUser(ctx, user, accountID)
}
// Update updates the current user's profile.
func (s *ProfileService) Update(ctx context.Context, userID uint, req UpdateProfileRequest) (*model.User, error) {
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
}
@@ -120,11 +164,11 @@ func (s *ProfileService) Update(ctx context.Context, userID uint, req UpdateProf
applogger.L().Errorf("failed to update profile: %v", err)
return nil, fmt.Errorf("failed to update profile: %w", err)
}
return user, nil
return s.serializeUser(ctx, user, accountID)
}
// UpdateAvatar updates the current user's avatar URL.
func (s *ProfileService) UpdateAvatar(ctx context.Context, userID uint, req UpdateAvatarRequest) (*model.User, error) {
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
}
@@ -140,31 +184,39 @@ func (s *ProfileService) UpdateAvatar(ctx context.Context, userID uint, req Upda
applogger.L().Errorf("failed to update avatar: %v", err)
return nil, fmt.Errorf("failed to update avatar: %w", err)
}
return user, nil
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) (*model.AccountUser, error) {
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)
}
return s.accountUserRepo.FindByAccountAndUserOrFail(ctx, req.AccountID, userID)
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) (*model.AccountUser, error) {
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)
}
return s.accountUserRepo.FindByAccountAndUserOrFail(ctx, req.AccountID, userID)
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.
@@ -199,21 +251,20 @@ func (s *ProfileService) ResendConfirmation(ctx context.Context, userID uint) er
// 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) (*model.User, error) {
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)
}
user.SignInCount++
if err := s.userRepo.Update(ctx, user); err != nil {
if _, err := s.regenerateAccessToken(ctx, user.ID); err != nil {
return nil, fmt.Errorf("failed to reset access token: %w", err)
}
return user, nil
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) (*model.User, error) {
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)
@@ -222,5 +273,171 @@ func (s *ProfileService) DeleteAvatar(ctx context.Context, userID uint) (*model.
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, fmt.Errorf("failed to remove avatar: %w", err)
}
return user, nil
}
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
}
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_users_pubsub_token;
ALTER TABLE account_users DROP COLUMN IF EXISTS active_at;
ALTER TABLE account_users DROP COLUMN IF EXISTS auto_offline;
ALTER TABLE users DROP COLUMN IF EXISTS custom_attributes;
ALTER TABLE users DROP COLUMN IF EXISTS ui_settings;
ALTER TABLE users DROP COLUMN IF EXISTS pubsub_token;
ALTER TABLE users DROP COLUMN IF EXISTS message_signature;
ALTER TABLE accounts DROP COLUMN IF EXISTS onboarding_step;
@@ -0,0 +1,13 @@
-- Profile serializer parity fields from Chatwoot user/account/account_user payloads.
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS onboarding_step VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS message_signature TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS pubsub_token VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS ui_settings JSONB NOT NULL DEFAULT '{}';
ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_attributes JSONB NOT NULL DEFAULT '{}';
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_pubsub_token ON users(pubsub_token) WHERE pubsub_token IS NOT NULL AND deleted_at IS NULL;
ALTER TABLE account_users ADD COLUMN IF NOT EXISTS auto_offline BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE account_users ADD COLUMN IF NOT EXISTS active_at TIMESTAMP WITH TIME ZONE;