Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
298 lines
9.1 KiB
Go
298 lines
9.1 KiB
Go
package v1
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/gochat/gochat/internal/service"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
)
|
|
|
|
// ProfileHandler handles Profile get/update + avatar + availability + auto_offline + set_active_account + resend_confirmation + reset_access_token.
|
|
// Reference: Chatwoot app/controllers/api/v1/profile_controller.rb
|
|
type ProfileHandler struct {
|
|
svc *service.ProfileService
|
|
}
|
|
|
|
// NewProfileHandler creates a new Profile handler.
|
|
func NewProfileHandler(svc *service.ProfileService) *ProfileHandler {
|
|
return &ProfileHandler{svc: svc}
|
|
}
|
|
|
|
// Get returns the current user's profile.
|
|
// GET /api/v1/profile
|
|
func (h *ProfileHandler) Get(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
// Update updates the current user's profile.
|
|
// PUT /api/v1/profile
|
|
func (h *ProfileHandler) Update(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
req, err := bindProfileUpdate(c)
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
var req service.UpdateAvatarRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
// SetAvailability updates the user's availability status for a specific account.
|
|
// POST /api/v1/profile/availability
|
|
// Reference: Chatwoot profiles_controller#availability
|
|
func (h *ProfileHandler) SetAvailability(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
var req service.ProfileAvailabilityPayload
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
// SetAutoOffline updates the user's auto_offline setting for a specific account.
|
|
// POST /api/v1/profile/auto_offline
|
|
// Reference: Chatwoot profiles_controller#auto_offline
|
|
func (h *ProfileHandler) SetAutoOffline(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
var req service.ProfileAutoOfflinePayload
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
// SetActiveAccount sets the user's currently active account.
|
|
// PUT /api/v1/profile/set_active_account
|
|
// Reference: Chatwoot profiles_controller#set_active_account
|
|
func (h *ProfileHandler) SetActiveAccount(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
var req service.ProfileSetActiveAccountPayload
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
svcErr := h.svc.SetActiveAccount(c.Request.Context(), userID, req.Profile)
|
|
if svcErr != nil {
|
|
applogger.L().Errorf("SetActiveAccount for user %d: %v", userID, svcErr)
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
response.NoContent(c)
|
|
}
|
|
|
|
// ResendConfirmation sends a confirmation email to the user if not yet confirmed.
|
|
// POST /api/v1/profile/resend_confirmation
|
|
// Reference: Chatwoot auth/resend_confirmations_controller#create
|
|
func (h *ProfileHandler) ResendConfirmation(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
svcErr := h.svc.ResendConfirmation(c.Request.Context(), userID)
|
|
if svcErr != nil {
|
|
applogger.L().Errorf("ResendConfirmation for user %d: %v", userID, svcErr)
|
|
handleServiceError(c, svcErr)
|
|
return
|
|
}
|
|
|
|
response.NoContent(c)
|
|
}
|
|
|
|
// ResetAccessToken regenerates the user's access token, invalidating current JWTs.
|
|
// POST /api/v1/profile/reset_access_token
|
|
// Reference: Chatwoot profiles_controller#reset_access_token
|
|
func (h *ProfileHandler) ResetAccessToken(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
// DeleteAvatar removes the user's avatar.
|
|
// DELETE /api/v1/profile/avatar
|
|
// Reference: Chatwoot ProfilesController#destroy_avatar
|
|
func (h *ProfileHandler) DeleteAvatar(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
if userID == 0 {
|
|
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
c.JSON(http.StatusOK, user)
|
|
}
|