diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index d95ea31d..72942ebe 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -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) diff --git a/internal/handler/api/v1/profile_handler.go b/internal/handler/api/v1/profile_handler.go index 22268863..d6c960bb 100644 --- a/internal/handler/api/v1/profile_handler.go +++ b/internal/handler/api/v1/profile_handler.go @@ -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) -} \ No newline at end of file + c.JSON(http.StatusOK, user) +} diff --git a/internal/handler/api/v1/profile_handler_test.go b/internal/handler/api/v1/profile_handler_test.go index a5a41a50..c2547e5b 100644 --- a/internal/handler/api/v1/profile_handler_test.go +++ b/internal/handler/api/v1/profile_handler_test.go @@ -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) -} \ No newline at end of file +} diff --git a/internal/model/account.go b/internal/model/account.go index b7c97762..59e6bae2 100644 --- a/internal/model/account.go +++ b/internal/model/account.go @@ -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" } \ No newline at end of file +func (Account) TableName() string { return "accounts" } diff --git a/internal/model/user.go b/internal/model/user.go index 941e4d2d..2329156d 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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" } \ No newline at end of file +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 +} diff --git a/internal/repository/account_user_repo.go b/internal/repository/account_user_repo.go index 474baabe..5c5041e2 100644 --- a/internal/repository/account_user_repo.go +++ b/internal/repository/account_user_repo.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index be682285..f03a8160 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -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 -} \ No newline at end of file + 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 +} diff --git a/migrations/000017_add_profile_serializer_fields.down.sql b/migrations/000017_add_profile_serializer_fields.down.sql new file mode 100644 index 00000000..dfec279c --- /dev/null +++ b/migrations/000017_add_profile_serializer_fields.down.sql @@ -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; diff --git a/migrations/000017_add_profile_serializer_fields.up.sql b/migrations/000017_add_profile_serializer_fields.up.sql new file mode 100644 index 00000000..31620630 --- /dev/null +++ b/migrations/000017_add_profile_serializer_fields.up.sql @@ -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;