feat(profile): support chatwoot settings updates
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user