From 7aa3362185c3755c0a1dfa5d82f780309a351d63 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 01:24:38 +0800 Subject: [PATCH] feat(profile): support chatwoot settings updates --- internal/handler/api/v1/profile_handler.go | 69 ++++++++++- .../handler/api/v1/profile_handler_test.go | 107 ++++++++++++++++++ internal/service/profile_service.go | 55 ++++++++- 3 files changed, 225 insertions(+), 6 deletions(-) diff --git a/internal/handler/api/v1/profile_handler.go b/internal/handler/api/v1/profile_handler.go index d6c960bb..7afd0f64 100644 --- a/internal/handler/api/v1/profile_handler.go +++ b/internal/handler/api/v1/profile_handler.go @@ -1,7 +1,9 @@ package v1 import ( + "encoding/json" "net/http" + "strings" "github.com/gin-gonic/gin" @@ -50,8 +52,8 @@ func (h *ProfileHandler) Update(c *gin.Context) { return } - var req service.ProfileUpdatePayload - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindProfileUpdate(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } @@ -67,6 +69,69 @@ func (h *ProfileHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, user) } +func bindProfileUpdate(c *gin.Context) (service.ProfileUpdatePayload, error) { + contentType := c.GetHeader("Content-Type") + if strings.Contains(contentType, "multipart/form-data") || strings.Contains(contentType, "application/x-www-form-urlencoded") { + return bindProfileUpdateForm(c) + } + var req service.ProfileUpdatePayload + if err := c.ShouldBindJSON(&req); err != nil { + return req, err + } + return req, nil +} + +func bindProfileUpdateForm(c *gin.Context) (service.ProfileUpdatePayload, error) { + var req service.ProfileUpdatePayload + if err := c.Request.ParseMultipartForm(32 << 20); err != nil && !strings.Contains(err.Error(), "request Content-Type isn't multipart/form-data") { + return req, err + } + form := c.Request.Form + profile := &req.Profile + if value := form.Get("profile[name]"); value != "" { + profile.Name = value + } + if value := form.Get("profile[email]"); value != "" { + profile.Email = value + } + if _, ok := form["profile[display_name]"]; ok { + value := form.Get("profile[display_name]") + profile.DisplayName = &value + } + if _, ok := form["profile[message_signature]"]; ok { + value := form.Get("profile[message_signature]") + profile.MessageSignature = &value + } + if _, ok := form["profile[phone_number]"]; ok { + value := form.Get("profile[phone_number]") + profile.PhoneNumber = &value + } + if value := form.Get("profile[avatar_url]"); value != "" { + profile.AvatarURL = value + } + if file, err := c.FormFile("profile[avatar]"); err == nil && file != nil { + profile.AvatarURL = file.Filename + } + uiSettings := map[string]any{} + for key, values := range form { + if !strings.HasPrefix(key, "profile[ui_settings][") || len(values) == 0 { + continue + } + settingKey := strings.TrimSuffix(strings.TrimPrefix(key, "profile[ui_settings]["), "]") + uiSettings[settingKey] = values[0] + } + if raw := form.Get("profile[ui_settings]"); raw != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err == nil { + uiSettings = parsed + } + } + if len(uiSettings) > 0 { + profile.UISettings = uiSettings + } + return req, nil +} + // UpdateAvatar updates the current user's avatar. // PUT /api/v1/profile/avatar func (h *ProfileHandler) UpdateAvatar(c *gin.Context) { diff --git a/internal/handler/api/v1/profile_handler_test.go b/internal/handler/api/v1/profile_handler_test.go index c2547e5b..2e22cf12 100644 --- a/internal/handler/api/v1/profile_handler_test.go +++ b/internal/handler/api/v1/profile_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -18,6 +19,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/pkg/crypto" "github.com/gochat/gochat/pkg/response" ) @@ -65,10 +67,14 @@ func (s *ProfileHandlerTestSuite) SetupSuite() { s.accountID = account.ID // Create test user + passwordDigest, err := crypto.HashPassword("oldpassword") + s.Require().NoError(err) user := &model.User{ Name: "ProfileUser", Email: "profile@example.com", AccountID: account.ID, + Password: passwordDigest, + PasswordDigest: passwordDigest, Provider: "email", DisplayName: "Profile Display", MessageSignature: "Regards", @@ -119,10 +125,14 @@ func (s *ProfileHandlerTestSuite) buildRouter() *gin.Engine { } func (s *ProfileHandlerTestSuite) SetupTest() { + passwordDigest, err := crypto.HashPassword("oldpassword") + s.Require().NoError(err) // 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", + "password": passwordDigest, + "password_digest": passwordDigest, "avatar_url": "", "available": false, "display_name": "Profile Display", @@ -739,6 +749,103 @@ func (s *ProfileHandlerTestSuite) TestUpdate_MultipleFieldsAtOnce() { assert.Equal(s.T(), "https://example.com/multi.png", dataMap["avatar_url"]) } +func (s *ProfileHandlerTestSuite) TestUpdate_SettingsJSONParity() { + body := map[string]interface{}{ + "profile": map[string]interface{}{ + "display_name": "Display Changed", + "message_signature": "Kind regards", + "phone_number": "+15551234567", + "ui_settings": map[string]interface{}{ + "editor_message_key": "cmd_enter", + "conversation_display_type": "expanded", + }, + }, + } + b, _ := json.Marshal(body) + + req, _ := http.NewRequest("PUT", "/api/v1/profile", 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(), "Display Changed", payload["display_name"]) + assert.Equal(s.T(), "Display Changed", payload["available_name"]) + assert.Equal(s.T(), "Kind regards", payload["message_signature"]) + uiSettings := payload["ui_settings"].(map[string]interface{}) + assert.Equal(s.T(), "cmd_enter", uiSettings["editor_message_key"]) + customAttrs := payload["custom_attributes"].(map[string]interface{}) + assert.Equal(s.T(), "+15551234567", customAttrs["phone_number"]) +} + +func (s *ProfileHandlerTestSuite) TestUpdate_PasswordJSONParity() { + body := map[string]interface{}{ + "profile": map[string]interface{}{ + "current_password": "oldpassword", + "password": "newpassword", + "password_confirmation": "newpassword", + }, + } + b, _ := json.Marshal(body) + + req, _ := http.NewRequest("PUT", "/api/v1/profile", 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) + var user model.User + s.Require().NoError(s.db.First(&user, s.userID).Error) + assert.True(s.T(), crypto.CheckPassword("newpassword", user.PasswordDigest)) +} + +func (s *ProfileHandlerTestSuite) TestUpdate_PasswordInvalidCurrentPassword() { + body := map[string]interface{}{ + "profile": map[string]interface{}{ + "current_password": "wrongpassword", + "password": "newpassword", + "password_confirmation": "newpassword", + }, + } + b, _ := json.Marshal(body) + + req, _ := http.NewRequest("PUT", "/api/v1/profile", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusBadRequest, w.Code) +} + +func (s *ProfileHandlerTestSuite) TestUpdate_MultipartFormProfileParity() { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + s.Require().NoError(writer.WriteField("profile[name]", "Multipart User")) + s.Require().NoError(writer.WriteField("profile[display_name]", "Multipart Display")) + s.Require().NoError(writer.WriteField("profile[message_signature]", "Sent from multipart")) + s.Require().NoError(writer.WriteField("profile[ui_settings][editor_message_key]", "enter")) + fileWriter, err := writer.CreateFormFile("profile[avatar]", "avatar.png") + s.Require().NoError(err) + _, err = fileWriter.Write([]byte("fake image bytes")) + s.Require().NoError(err) + s.Require().NoError(writer.Close()) + + req, _ := http.NewRequest("PUT", "/api/v1/profile", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + payload := s.decodeProfileBody(w) + assert.Equal(s.T(), "Multipart User", payload["name"]) + assert.Equal(s.T(), "Multipart Display", payload["display_name"]) + assert.Equal(s.T(), "Sent from multipart", payload["message_signature"]) + assert.Equal(s.T(), "avatar.png", payload["avatar_url"]) + uiSettings := payload["ui_settings"].(map[string]interface{}) + assert.Equal(s.T(), "enter", uiSettings["editor_message_key"]) +} + func (s *ProfileHandlerTestSuite) TestUpdate_SoftDeletedUser() { // Soft-delete user, then try to get/update profile s.db.Delete(&model.User{}, s.userID) diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index f03a8160..a9bfd152 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -6,8 +6,11 @@ import ( "fmt" "time" + "gorm.io/datatypes" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/pkg/crypto" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) @@ -69,10 +72,18 @@ type ProfileAccountResponse struct { // UpdateProfileRequest is the DTO for updating user profile. // Reference: Chatwoot profiles_controller#update — params wrapped in "profile" key type UpdateProfileRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Email string `json:"email,omitempty" validate:"omitempty,email"` - AvatarURL string `json:"avatar_url,omitempty"` - Availability string `json:"availability,omitempty" validate:"omitempty,oneof=online offline busy"` + Name string `json:"name,omitempty" validate:"omitempty,min=2"` + Email string `json:"email,omitempty" validate:"omitempty,email"` + DisplayName *string `json:"display_name,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + MessageSignature *string `json:"message_signature,omitempty"` + AccountID uint `json:"account_id,omitempty"` + UISettings map[string]any `json:"ui_settings,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` + Availability string `json:"availability,omitempty" validate:"omitempty,oneof=online offline busy"` + CurrentPassword string `json:"current_password,omitempty"` + Password string `json:"password,omitempty" validate:"omitempty,min=6"` + PasswordConfirmation string `json:"password_confirmation,omitempty"` } // ProfileUpdatePayload wraps UpdateProfileRequest under the "profile" key, @@ -150,9 +161,45 @@ func (s *ProfileService) Update(ctx context.Context, userID uint, accountID uint if req.Email != "" { user.Email = req.Email } + if req.DisplayName != nil { + user.DisplayName = *req.DisplayName + } if req.AvatarURL != "" { user.AvatarURL = req.AvatarURL } + if req.MessageSignature != nil { + user.MessageSignature = *req.MessageSignature + } + if req.UISettings != nil { + encoded, err := json.Marshal(req.UISettings) + if err != nil { + return nil, fmt.Errorf("invalid ui_settings: %w", err) + } + user.UISettings = datatypes.JSON(encoded) + } + if req.PhoneNumber != nil { + attrs := jsonObject(user.CustomAttributes) + attrs["phone_number"] = *req.PhoneNumber + encoded, err := json.Marshal(attrs) + if err != nil { + return nil, fmt.Errorf("invalid custom attributes: %w", err) + } + user.CustomAttributes = datatypes.JSON(encoded) + } + if req.Password != "" { + if req.Password != req.PasswordConfirmation { + return nil, fmt.Errorf("invalid password confirmation") + } + if !crypto.CheckPassword(req.CurrentPassword, user.PasswordDigest) && !crypto.CheckPassword(req.CurrentPassword, user.Password) { + return nil, fmt.Errorf("invalid current password") + } + passwordDigest, err := crypto.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + user.PasswordDigest = passwordDigest + user.Password = passwordDigest + } // Map availability to user.Available boolean if req.Availability == "online" { user.Available = true