feat(platform): align user payloads
This commit is contained in:
@@ -610,7 +610,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
summaryReportService := service.NewSummaryReportService(reportingEventsRollupRepo)
|
||||
dashboardAppService := service.NewDashboardAppService(dashboardAppRepo)
|
||||
platformAppService := service.NewPlatformAppService(platformAppRepo, accessTokenRepo, permissibleRepo)
|
||||
platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo)
|
||||
platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo, accessTokenRepo, accountUserRepo)
|
||||
// M12: AgentBot services (AgentBot + AgentBotInbox + Listener)
|
||||
agentBotService := service.NewAgentBotService(agentBotRepo)
|
||||
agentBotInboxService := service.NewAgentBotInboxService(agentBotInboxRepo, agentBotRepo)
|
||||
|
||||
@@ -33,6 +33,14 @@ func unpackData(t *testing.T, body []byte) map[string]interface{} {
|
||||
return dataMap
|
||||
}
|
||||
|
||||
func unpackRawObject(t *testing.T, body []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
err := json.Unmarshal(body, &data)
|
||||
require.NoError(t, err, "response is not valid JSON: %s", string(body))
|
||||
return data
|
||||
}
|
||||
|
||||
// parseID extracts the numeric "id" from a data envelope and returns it as a string for URL paths.
|
||||
func parseID(t *testing.T, data map[string]interface{}) string {
|
||||
t.Helper()
|
||||
@@ -54,10 +62,12 @@ func setupPlatformTokenTestE2E(t *testing.T) (*gin.Engine, *repository.Permissib
|
||||
|
||||
userRepo := repository.NewUserRepo(db)
|
||||
accountRepo := repository.NewAccountRepo(db)
|
||||
accountUserRepo := repository.NewAccountUserRepo(db)
|
||||
accessTokenRepo := repository.NewAccessTokenRepo(db)
|
||||
agentBotRepo := repository.NewAgentBotRepo(db)
|
||||
permissibleRepo := repository.NewPermissibleRepo(db)
|
||||
accountService := service.NewAccountService(accountRepo)
|
||||
platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo)
|
||||
platformUserService := service.NewPlatformUserService(userRepo, permissibleRepo, accessTokenRepo, accountUserRepo)
|
||||
|
||||
platformUser := v1.NewPlatformUserHandler(platformUserService)
|
||||
platformAccount := v1.NewPlatformAccountHandler(accountRepo, permissibleRepo, accountService)
|
||||
@@ -76,6 +86,7 @@ func setupPlatformTokenTestE2E(t *testing.T) (*gin.Engine, *repository.Permissib
|
||||
platformGroup.GET("/users", platformUser.List)
|
||||
platformGroup.GET("/users/:id", platformUser.Show)
|
||||
platformGroup.POST("/users", platformUser.Create)
|
||||
platformGroup.GET("/users/:id/login", platformUser.Login)
|
||||
platformGroup.POST("/users/:id/login", platformUser.Login)
|
||||
platformGroup.POST("/users/:id/token", platformUser.Token)
|
||||
platformGroup.PATCH("/users/:id", platformUser.Update)
|
||||
@@ -114,10 +125,32 @@ func TestPlatformUserE2E_Create(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
data := unpackData(t, w.Body.Bytes())
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
data := unpackRawObject(t, w.Body.Bytes())
|
||||
assert.Equal(t, "Test User", data["name"])
|
||||
assert.Equal(t, "test@example.com", data["email"])
|
||||
assert.NotEmpty(t, data["access_token"])
|
||||
assert.Contains(t, data, "accounts")
|
||||
}
|
||||
|
||||
func TestPlatformUserE2E_CreateExistingUserReturnsExistingAndPermits(t *testing.T) {
|
||||
engine, permissibleRepo, userRepo, _ := setupPlatformTokenTestE2E(t)
|
||||
ctx := t.Context()
|
||||
existing := &model.User{Name: "Old Name", Email: "existing@example.com", Provider: "email", Active: true}
|
||||
require.NoError(t, userRepo.Create(ctx, existing))
|
||||
|
||||
body := `{"name": "New Name", "email": "existing@example.com", "password": "secret123"}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
data := unpackRawObject(t, w.Body.Bytes())
|
||||
assert.Equal(t, "Old Name", data["name"])
|
||||
assert.Equal(t, float64(existing.ID), data["id"])
|
||||
_, err := permissibleRepo.FindByPlatformAppAndResource(ctx, uint(1), model.PermissibleTypeUser, existing.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPlatformUserE2E_Show(t *testing.T) {
|
||||
@@ -129,8 +162,8 @@ func TestPlatformUserE2E_Show(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
createData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
createData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, createData)
|
||||
|
||||
// Show user
|
||||
@@ -139,7 +172,7 @@ func TestPlatformUserE2E_Show(t *testing.T) {
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
showData := unpackData(t, w.Body.Bytes())
|
||||
showData := unpackRawObject(t, w.Body.Bytes())
|
||||
assert.Equal(t, "Show User", showData["name"])
|
||||
}
|
||||
|
||||
@@ -152,20 +185,22 @@ func TestPlatformUserE2E_Update(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
createData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
createData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, createData)
|
||||
|
||||
// Update user
|
||||
updateBody := `{"name": "Updated Name", "email": "updated@example.com"}`
|
||||
updateBody := `{"name": "Updated Name", "email": "updated@example.com", "custom_attributes": {"tier": "gold"}}`
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PATCH", "/platform/api/v1/users/"+userID, bytes.NewBufferString(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
updateData := unpackData(t, w.Body.Bytes())
|
||||
updateData := unpackRawObject(t, w.Body.Bytes())
|
||||
assert.Equal(t, "Updated Name", updateData["name"])
|
||||
attrs := updateData["custom_attributes"].(map[string]interface{})
|
||||
assert.Equal(t, "gold", attrs["tier"])
|
||||
}
|
||||
|
||||
func TestPlatformUserE2E_Destroy(t *testing.T) {
|
||||
@@ -177,8 +212,8 @@ func TestPlatformUserE2E_Destroy(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
createData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
createData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, createData)
|
||||
|
||||
// Delete user
|
||||
@@ -186,7 +221,7 @@ func TestPlatformUserE2E_Destroy(t *testing.T) {
|
||||
req, _ = http.NewRequest("DELETE", "/platform/api/v1/users/"+userID, nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestPlatformUserE2E_Login(t *testing.T) {
|
||||
@@ -198,16 +233,19 @@ func TestPlatformUserE2E_Login(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
createData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
createData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, createData)
|
||||
|
||||
// Login
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/platform/api/v1/users/"+userID+"/login", nil)
|
||||
req, _ = http.NewRequest("GET", "/platform/api/v1/users/"+userID+"/login", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
loginData := unpackRawObject(t, w.Body.Bytes())
|
||||
assert.Contains(t, loginData["url"], "email=sso%40example.com")
|
||||
assert.Contains(t, loginData["url"], "sso_auth_token=")
|
||||
}
|
||||
|
||||
func TestPlatformUserE2E_Token(t *testing.T) {
|
||||
@@ -219,8 +257,8 @@ func TestPlatformUserE2E_Token(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
createData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
createData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, createData)
|
||||
|
||||
// Token
|
||||
@@ -229,6 +267,11 @@ func TestPlatformUserE2E_Token(t *testing.T) {
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
tokenData := unpackRawObject(t, w.Body.Bytes())
|
||||
assert.NotEmpty(t, tokenData["access_token"])
|
||||
assert.Nil(t, tokenData["expiry"])
|
||||
userInfo := tokenData["user"].(map[string]interface{})
|
||||
assert.Equal(t, "Token User", userInfo["name"])
|
||||
}
|
||||
|
||||
func TestPlatformUserE2E_List(t *testing.T) {
|
||||
@@ -241,7 +284,7 @@ func TestPlatformUserE2E_List(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// List users
|
||||
@@ -393,8 +436,8 @@ func TestPlatformAccountUserE2E_Create(t *testing.T) {
|
||||
req, _ = http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(userBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
userData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
userData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, userData)
|
||||
|
||||
// Create AccountUser
|
||||
@@ -441,8 +484,8 @@ func TestPlatformAccountUserE2E_Index(t *testing.T) {
|
||||
req, _ = http.NewRequest("POST", "/platform/api/v1/users", bytes.NewBufferString(userBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
userData := unpackData(t, w.Body.Bytes())
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
userData := unpackRawObject(t, w.Body.Bytes())
|
||||
userID := parseID(t, userData)
|
||||
|
||||
// Add user to account
|
||||
|
||||
@@ -2,9 +2,11 @@ package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/pagination"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
@@ -37,13 +39,13 @@ func (h *PlatformUserHandler) Show(c *gin.Context) {
|
||||
|
||||
platformAppID := getPlatformAppID(c)
|
||||
|
||||
user, err := h.svc.GetUser(c.Request.Context(), platformAppID, userID)
|
||||
user, err := h.svc.GetUserResponse(c.Request.Context(), platformAppID, userID)
|
||||
if err != nil {
|
||||
handlePlatformError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, user)
|
||||
c.JSON(http.StatusOK, serializePlatformUser(user))
|
||||
}
|
||||
|
||||
// Create creates a new user and auto-creates Permissible record.
|
||||
@@ -53,27 +55,27 @@ func (h *PlatformUserHandler) Show(c *gin.Context) {
|
||||
func (h *PlatformUserHandler) Create(c *gin.Context) {
|
||||
platformAppID := getPlatformAppID(c)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
var req service.PlatformUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Email == "" {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.svc.CreateUser(c.Request.Context(), platformAppID, req.Name, req.Email, req.Password)
|
||||
user, err := h.svc.CreateUser(c.Request.Context(), platformAppID, req)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, user)
|
||||
c.JSON(http.StatusOK, serializePlatformUser(user))
|
||||
}
|
||||
|
||||
// Login generates an SSO login link for a user.
|
||||
// POST /platform/api/v1/users/:id/login
|
||||
// GET /platform/api/v1/users/:id/login
|
||||
// Reference: Chatwoot Platform::Api::V1::UsersController#login
|
||||
// Returns: { url: sso_redirect_url }
|
||||
func (h *PlatformUserHandler) Login(c *gin.Context) {
|
||||
@@ -85,18 +87,17 @@ func (h *PlatformUserHandler) Login(c *gin.Context) {
|
||||
|
||||
platformAppID := getPlatformAppID(c)
|
||||
|
||||
// Verify Permissible access first
|
||||
if err := h.svc.ValidatePermissible(c.Request.Context(), platformAppID, userID); err != nil {
|
||||
user, err := h.svc.GetUserResponse(c.Request.Context(), platformAppID, userID)
|
||||
if err != nil {
|
||||
handlePlatformError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// SSO link generation — requires full SSO implementation (HMAC/JWT token, user lookup).
|
||||
// Production note: When SSO middleware is wired, this endpoint will generate
|
||||
// a signed redirect URL based on the user record and SSO configuration.
|
||||
response.OK(c, gin.H{
|
||||
"url": "", // Would be populated with SSO redirect URL
|
||||
"id": userID,
|
||||
query := url.Values{}
|
||||
query.Set("email", user.User.Email)
|
||||
query.Set("sso_auth_token", user.AccessToken)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": "/app/login?" + query.Encode(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,15 +120,22 @@ func (h *PlatformUserHandler) Token(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.svc.GetUser(c.Request.Context(), platformAppID, userID)
|
||||
user, err := h.svc.TokenResponse(c.Request.Context(), platformAppID, userID)
|
||||
if err != nil {
|
||||
handlePlatformError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"id": user.ID,
|
||||
"sso_auth_token": "", // Would be populated with SSO token
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": user.AccessToken,
|
||||
"expiry": nil,
|
||||
"user": gin.H{
|
||||
"id": user.User.ID,
|
||||
"name": user.User.Name,
|
||||
"display_name": user.User.DisplayName,
|
||||
"email": user.User.Email,
|
||||
"pubsub_token": user.User.PubsubToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,22 +152,19 @@ func (h *PlatformUserHandler) Update(c *gin.Context) {
|
||||
|
||||
platformAppID := getPlatformAppID(c)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
var req service.PlatformUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.svc.UpdateUser(c.Request.Context(), platformAppID, userID, req.Name, req.Email)
|
||||
user, err := h.svc.UpdateUser(c.Request.Context(), platformAppID, userID, req)
|
||||
if err != nil {
|
||||
handlePlatformError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, user)
|
||||
c.JSON(http.StatusOK, serializePlatformUser(user))
|
||||
}
|
||||
|
||||
// Destroy deletes a user.
|
||||
@@ -180,7 +185,63 @@ func (h *PlatformUserHandler) Destroy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func serializePlatformUser(payload *service.PlatformUserResponse) gin.H {
|
||||
if payload == nil {
|
||||
return gin.H{}
|
||||
}
|
||||
user := payload.User
|
||||
out := gin.H{
|
||||
"access_token": payload.AccessToken,
|
||||
"account_id": activeAccountID(payload.AccountUsers),
|
||||
"available_name": nonEmpty(user.DisplayName, user.Name),
|
||||
"avatar_url": user.AvatarURL,
|
||||
"confirmed": user.ConfirmedAt != nil,
|
||||
"display_name": user.DisplayName,
|
||||
"message_signature": user.MessageSignature,
|
||||
"email": user.Email,
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"provider": nonEmpty(user.Provider, "email"),
|
||||
"pubsub_token": user.PubsubToken,
|
||||
"role": activeAccountRole(payload.AccountUsers),
|
||||
"ui_settings": jsonObject(user.UISettings),
|
||||
"uid": user.UID,
|
||||
"accounts": serializePlatformUserAccounts(payload.AccountUsers),
|
||||
}
|
||||
if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 {
|
||||
out["custom_attributes"] = attrs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serializePlatformUserAccounts(accountUsers []model.AccountUser) []gin.H {
|
||||
accounts := make([]gin.H, 0, len(accountUsers))
|
||||
for _, au := range accountUsers {
|
||||
accounts = append(accounts, gin.H{
|
||||
"id": au.AccountID,
|
||||
"name": au.Account.Name,
|
||||
"active_at": au.ActiveAt,
|
||||
"role": au.Role,
|
||||
})
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
func activeAccountID(accountUsers []model.AccountUser) any {
|
||||
if len(accountUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return accountUsers[0].AccountID
|
||||
}
|
||||
|
||||
func activeAccountRole(accountUsers []model.AccountUser) any {
|
||||
if len(accountUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return accountUsers[0].Role
|
||||
}
|
||||
|
||||
// --- Helper functions for Platform API handlers ---
|
||||
@@ -235,4 +296,4 @@ func (h *PlatformUserHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.OKWithMeta(c, users[start:end], page.Page, page.PerPage, total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,10 +1872,6 @@ func registerPlatformRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
g.PUT("/agent_bots/:id/avatar", h.AgentBot.PlatformUpdateAvatar)
|
||||
g.POST("/agent_bots/:id/reset", h.AgentBot.PlatformResetConfig)
|
||||
|
||||
// G16: Platform Users SSO — super-admin initiates SSO for a user
|
||||
// Reference: Chatwoot Platform::Api::V1::UsersController #login, #token
|
||||
v1.RegisterPlatformUserSSORoutes(g, h.PlatformUserSSO)
|
||||
|
||||
// InstallationConfig CRUD (super-admin, global key-value config)
|
||||
// Reference: Chatwoot Platform::Api::V1::InstallationConfigsController
|
||||
g.GET("/installation_configs", h.InstallationConfig.List)
|
||||
@@ -1906,6 +1902,7 @@ func registerPlatformTokenRoutes(g *gin.RouterGroup, h *Handlers) {
|
||||
g.GET("/users", h.PlatformUser.List)
|
||||
g.GET("/users/:id", h.PlatformUser.Show)
|
||||
g.POST("/users", h.PlatformUser.Create)
|
||||
g.GET("/users/:id/login", h.PlatformUser.Login)
|
||||
g.POST("/users/:id/login", h.PlatformUser.Login)
|
||||
g.POST("/users/:id/token", h.PlatformUser.Token)
|
||||
g.PATCH("/users/:id", h.PlatformUser.Update)
|
||||
|
||||
@@ -8,27 +8,58 @@ package service
|
||||
// The Permissible system governs which resources each PlatformApp can access.
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PlatformUserService provides user CRUD for Platform API (AccessToken auth).
|
||||
type PlatformUserService struct {
|
||||
userRepo *repository.UserRepo
|
||||
permissibleRepo *repository.PermissibleRepo
|
||||
userRepo *repository.UserRepo
|
||||
permissibleRepo *repository.PermissibleRepo
|
||||
accessTokenRepo *repository.AccessTokenRepo
|
||||
accountUserRepo *repository.AccountUserRepo
|
||||
}
|
||||
|
||||
type PlatformUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
CustomAttributes map[string]any `json:"custom_attributes"`
|
||||
}
|
||||
|
||||
type PlatformUserResponse struct {
|
||||
User model.User
|
||||
AccessToken string
|
||||
AccountUsers []model.AccountUser
|
||||
}
|
||||
|
||||
// NewPlatformUserService creates a new PlatformUserService.
|
||||
func NewPlatformUserService(
|
||||
userRepo *repository.UserRepo,
|
||||
permissibleRepo *repository.PermissibleRepo,
|
||||
extras ...any,
|
||||
) *PlatformUserService {
|
||||
return &PlatformUserService{
|
||||
svc := &PlatformUserService{
|
||||
userRepo: userRepo,
|
||||
permissibleRepo: permissibleRepo,
|
||||
}
|
||||
for _, extra := range extras {
|
||||
switch repo := extra.(type) {
|
||||
case *repository.AccessTokenRepo:
|
||||
svc.accessTokenRepo = repo
|
||||
case *repository.AccountUserRepo:
|
||||
svc.accountUserRepo = repo
|
||||
}
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// ValidatePermissible checks that the PlatformApp has permission to access the target user.
|
||||
@@ -52,38 +83,63 @@ func (s *PlatformUserService) GetUser(ctx context.Context, platformAppID uint, u
|
||||
return s.userRepo.FindByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *PlatformUserService) GetUserResponse(ctx context.Context, platformAppID uint, userID uint) (*PlatformUserResponse, error) {
|
||||
user, err := s.GetUser(ctx, platformAppID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.BuildUserResponse(ctx, user)
|
||||
}
|
||||
|
||||
// CreateUser creates a new user and auto-creates a Permissible record.
|
||||
// Reference: Chatwoot UsersController#create — skips confirmation, auto-permissible
|
||||
func (s *PlatformUserService) CreateUser(ctx context.Context, platformAppID uint, name, email, password string) (*model.User, error) {
|
||||
user := &model.User{
|
||||
Name: name,
|
||||
Email: email,
|
||||
Provider: "email",
|
||||
}
|
||||
if password != "" {
|
||||
user.Password = password
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
func (s *PlatformUserService) CreateUser(ctx context.Context, platformAppID uint, req PlatformUserRequest) (*PlatformUserResponse, error) {
|
||||
user, err := s.userRepo.FindByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
confirmedAt := time.Now().UTC()
|
||||
attrs, attrsErr := marshalCustomAttributes(req.CustomAttributes)
|
||||
if attrsErr != nil {
|
||||
return nil, attrsErr
|
||||
}
|
||||
user = &model.User{
|
||||
Name: req.Name,
|
||||
DisplayName: req.DisplayName,
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Provider: "email",
|
||||
CustomAttributes: attrs,
|
||||
ConfirmedAt: &confirmedAt,
|
||||
Active: true,
|
||||
}
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-create Permissible record (PlatformApp can access this user)
|
||||
perm := &model.Permissible{
|
||||
PlatformAppID: platformAppID,
|
||||
PermissibleType: model.PermissibleTypeUser,
|
||||
PermissibleID: user.ID,
|
||||
}
|
||||
if err := s.permissibleRepo.Create(ctx, perm); err != nil {
|
||||
// Non-critical — permissible creation failure shouldn't block user creation
|
||||
if _, err := s.permissibleRepo.FindByPlatformAppAndResource(ctx, platformAppID, model.PermissibleTypeUser, user.ID); err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
perm := &model.Permissible{
|
||||
PlatformAppID: platformAppID,
|
||||
PermissibleType: model.PermissibleTypeUser,
|
||||
PermissibleID: user.ID,
|
||||
}
|
||||
if err := s.permissibleRepo.Create(ctx, perm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
return s.BuildUserResponse(ctx, user)
|
||||
}
|
||||
|
||||
// UpdateUser updates a user, after verifying permissible access.
|
||||
// Reference: Chatwoot UsersController#update — merges custom_attributes, skips reconfirmation
|
||||
func (s *PlatformUserService) UpdateUser(ctx context.Context, platformAppID uint, userID uint, name, email string) (*model.User, error) {
|
||||
func (s *PlatformUserService) UpdateUser(ctx context.Context, platformAppID uint, userID uint, req PlatformUserRequest) (*PlatformUserResponse, error) {
|
||||
if err := s.ValidatePermissible(ctx, platformAppID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,17 +149,84 @@ func (s *PlatformUserService) UpdateUser(ctx context.Context, platformAppID uint
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
user.Name = name
|
||||
if req.Name != "" {
|
||||
user.Name = req.Name
|
||||
}
|
||||
if email != "" {
|
||||
user.Email = email
|
||||
if req.DisplayName != "" {
|
||||
user.DisplayName = req.DisplayName
|
||||
}
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Password != "" {
|
||||
user.Password = req.Password
|
||||
}
|
||||
if req.CustomAttributes != nil {
|
||||
merged := platformJSONObj(user.CustomAttributes)
|
||||
for key, value := range req.CustomAttributes {
|
||||
merged[key] = value
|
||||
}
|
||||
attrs, err := marshalCustomAttributes(merged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.CustomAttributes = attrs
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
return s.BuildUserResponse(ctx, user)
|
||||
}
|
||||
|
||||
func (s *PlatformUserService) BuildUserResponse(ctx context.Context, user *model.User) (*PlatformUserResponse, error) {
|
||||
if user == nil {
|
||||
return nil, errors.New("user is nil")
|
||||
}
|
||||
accessToken, err := s.currentAccessToken(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var accountUsers []model.AccountUser
|
||||
if s.accountUserRepo != nil {
|
||||
accountUsers, err = s.accountUserRepo.FindByUserWithAccounts(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &PlatformUserResponse{User: *user, AccessToken: accessToken, AccountUsers: accountUsers}, nil
|
||||
}
|
||||
|
||||
func (s *PlatformUserService) TokenResponse(ctx context.Context, platformAppID uint, userID uint) (*PlatformUserResponse, error) {
|
||||
return s.GetUserResponse(ctx, platformAppID, userID)
|
||||
}
|
||||
|
||||
func (s *PlatformUserService) 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user, after verifying permissible access.
|
||||
@@ -115,6 +238,28 @@ func (s *PlatformUserService) DeleteUser(ctx context.Context, platformAppID uint
|
||||
return s.userRepo.Delete(ctx, userID)
|
||||
}
|
||||
|
||||
func marshalCustomAttributes(attrs map[string]any) (datatypes.JSON, error) {
|
||||
if attrs == nil {
|
||||
return datatypes.JSON([]byte(`{}`)), nil
|
||||
}
|
||||
data, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return datatypes.JSON(data), nil
|
||||
}
|
||||
|
||||
func platformJSONObj(raw datatypes.JSON) map[string]any {
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil || obj == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// ListPermissibleUsers returns all users that the PlatformApp has permissible access to.
|
||||
func (s *PlatformUserService) ListPermissibleUsers(ctx context.Context, platformAppID uint) ([]model.User, error) {
|
||||
permissibles, err := s.permissibleRepo.FindByPlatformAppID(ctx, platformAppID)
|
||||
@@ -133,4 +278,4 @@ func (s *PlatformUserService) ListPermissibleUsers(ctx context.Context, platform
|
||||
}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ import (
|
||||
|
||||
func setupPlatformUserServiceTest(t *testing.T) (*service.PlatformUserService, *repository.PermissibleRepo) {
|
||||
t.Helper()
|
||||
db := testutil.NewTestDBWithModels(t, &model.User{}, &model.Permissible{}, &model.AccessToken{}, &model.PlatformApp{})
|
||||
db := testutil.NewTestDBWithModels(t, &model.User{}, &model.Account{}, &model.AccountUser{}, &model.Permissible{}, &model.AccessToken{}, &model.PlatformApp{})
|
||||
userRepo := repository.NewUserRepo(db)
|
||||
permissibleRepo := repository.NewPermissibleRepo(db)
|
||||
svc := service.NewPlatformUserService(userRepo, permissibleRepo)
|
||||
accessTokenRepo := repository.NewAccessTokenRepo(db)
|
||||
accountUserRepo := repository.NewAccountUserRepo(db)
|
||||
svc := service.NewPlatformUserService(userRepo, permissibleRepo, accessTokenRepo, accountUserRepo)
|
||||
return svc, permissibleRepo
|
||||
}
|
||||
|
||||
@@ -37,18 +39,19 @@ func TestPlatformUserService_CreateUser(t *testing.T) {
|
||||
// Since we don't have PlatformAppRepo in the test setup, we test the core logic:
|
||||
// CreateUser should create user + auto-permissible record
|
||||
|
||||
user, err := svc.CreateUser(ctx, 1, "Test User", "test@example.com", "password123")
|
||||
user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Test User", Email: "test@example.com", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Test User", user.Name)
|
||||
assert.Equal(t, "test@example.com", user.Email)
|
||||
assert.NotZero(t, user.ID)
|
||||
assert.Equal(t, "Test User", user.User.Name)
|
||||
assert.Equal(t, "test@example.com", user.User.Email)
|
||||
assert.NotZero(t, user.User.ID)
|
||||
assert.NotEmpty(t, user.AccessToken)
|
||||
|
||||
// Verify auto-permissible was created
|
||||
perm, err := permissibleRepo.FindByPlatformAppAndResource(ctx, 1, model.PermissibleTypeUser, user.ID)
|
||||
perm, err := permissibleRepo.FindByPlatformAppAndResource(ctx, 1, model.PermissibleTypeUser, user.User.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, perm)
|
||||
assert.Equal(t, model.PermissibleTypeUser, perm.PermissibleType)
|
||||
assert.Equal(t, user.ID, perm.PermissibleID)
|
||||
assert.Equal(t, user.User.ID, perm.PermissibleID)
|
||||
assert.Equal(t, uint(1), perm.PlatformAppID)
|
||||
}
|
||||
|
||||
@@ -57,15 +60,30 @@ func TestPlatformUserService_GetUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user with auto-permissible
|
||||
user, err := svc.CreateUser(ctx, 1, "Test User", "test@example.com", "password123")
|
||||
user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Test User", Email: "test@example.com", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// GetUser should succeed because auto-permissible was created
|
||||
retrieved, err := svc.GetUser(ctx, 1, user.ID)
|
||||
retrieved, err := svc.GetUser(ctx, 1, user.User.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, retrieved.ID)
|
||||
assert.Equal(t, user.Name, retrieved.Name)
|
||||
assert.Equal(t, user.Email, retrieved.Email)
|
||||
assert.Equal(t, user.User.ID, retrieved.ID)
|
||||
assert.Equal(t, user.User.Name, retrieved.Name)
|
||||
assert.Equal(t, user.User.Email, retrieved.Email)
|
||||
}
|
||||
|
||||
func TestPlatformUserService_CreateExistingUserReturnsExisting(t *testing.T) {
|
||||
svc, permissibleRepo := setupPlatformUserServiceTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Original", Email: "same@example.com", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
second, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Changed", Email: "same@example.com", Password: "password456"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.User.ID, second.User.ID)
|
||||
assert.Equal(t, "Original", second.User.Name)
|
||||
_, err = permissibleRepo.FindByPlatformAppAndResource(ctx, 1, model.PermissibleTypeUser, second.User.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPlatformUserService_GetUser_NonPermissible(t *testing.T) {
|
||||
@@ -73,11 +91,11 @@ func TestPlatformUserService_GetUser_NonPermissible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user under PlatformApp 1
|
||||
user, err := svc.CreateUser(ctx, 1, "Test User", "test1@example.com", "password123")
|
||||
user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Test User", Email: "test1@example.com", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to get user from a different PlatformApp (should fail — no permissible)
|
||||
_, err = svc.GetUser(ctx, 2, user.ID)
|
||||
_, err = svc.GetUser(ctx, 2, user.User.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "non permissible resource", err.Error())
|
||||
}
|
||||
@@ -86,27 +104,29 @@ func TestPlatformUserService_UpdateUser(t *testing.T) {
|
||||
svc, _ := setupPlatformUserServiceTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.CreateUser(ctx, 1, "Original Name", "original@example.com", "password123")
|
||||
user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Original Name", Email: "original@example.com", Password: "password123", CustomAttributes: map[string]any{"plan": "old"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := svc.UpdateUser(ctx, 1, user.ID, "Updated Name", "updated@example.com")
|
||||
updated, err := svc.UpdateUser(ctx, 1, user.User.ID, service.PlatformUserRequest{Name: "Updated Name", Email: "updated@example.com", CustomAttributes: map[string]any{"tier": "gold"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", updated.Name)
|
||||
assert.Equal(t, "updated@example.com", updated.Email)
|
||||
assert.Equal(t, "Updated Name", updated.User.Name)
|
||||
assert.Equal(t, "updated@example.com", updated.User.Email)
|
||||
assert.Contains(t, string(updated.User.CustomAttributes), "gold")
|
||||
assert.Contains(t, string(updated.User.CustomAttributes), "old")
|
||||
}
|
||||
|
||||
func TestPlatformUserService_DeleteUser(t *testing.T) {
|
||||
svc, _ := setupPlatformUserServiceTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.CreateUser(ctx, 1, "Delete Me", "delete@example.com", "password123")
|
||||
user, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "Delete Me", Email: "delete@example.com", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.DeleteUser(ctx, 1, user.ID)
|
||||
err = svc.DeleteUser(ctx, 1, user.User.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user is gone
|
||||
_, err = svc.GetUser(ctx, 1, user.ID)
|
||||
_, err = svc.GetUser(ctx, 1, user.User.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -115,13 +135,13 @@ func TestPlatformUserService_ListPermissibleUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create multiple users under PlatformApp 1
|
||||
user1, err := svc.CreateUser(ctx, 1, "User One", "user1@example.com", "pass1")
|
||||
user1, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "User One", Email: "user1@example.com", Password: "pass1"})
|
||||
require.NoError(t, err)
|
||||
user2, err := svc.CreateUser(ctx, 1, "User Two", "user2@example.com", "pass2")
|
||||
user2, err := svc.CreateUser(ctx, 1, service.PlatformUserRequest{Name: "User Two", Email: "user2@example.com", Password: "pass2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a user under PlatformApp 2 (should not appear in PlatformApp 1's list)
|
||||
_, err = svc.CreateUser(ctx, 2, "User Three", "user3@example.com", "pass3")
|
||||
_, err = svc.CreateUser(ctx, 2, service.PlatformUserRequest{Name: "User Three", Email: "user3@example.com", Password: "pass3"})
|
||||
require.NoError(t, err)
|
||||
|
||||
users, err := svc.ListPermissibleUsers(ctx, 1)
|
||||
@@ -133,6 +153,6 @@ func TestPlatformUserService_ListPermissibleUsers(t *testing.T) {
|
||||
for _, u := range users {
|
||||
userIDs[u.ID] = true
|
||||
}
|
||||
assert.True(t, userIDs[user1.ID])
|
||||
assert.True(t, userIDs[user2.ID])
|
||||
}
|
||||
assert.True(t, userIDs[user1.User.ID])
|
||||
assert.True(t, userIDs[user2.User.ID])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user