feat(profile): support chatwoot settings updates

This commit is contained in:
2026-06-05 01:24:38 +08:00
parent a88b49bb04
commit 7aa3362185
3 changed files with 225 additions and 6 deletions
+67 -2
View File
@@ -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) {
@@ -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)
+51 -4
View File
@@ -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