feat(profile): align chatwoot user serializer
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user