Files
gochat/backend/internal/service/platform_user_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

282 lines
8.5 KiB
Go

package service
// PlatformUserService handles business logic for Platform API user endpoints.
// Reference: Chatwoot Platform::Api::V1::UsersController — AccessToken authenticated
//
// These endpoints are distinct from the SuperAdmin-platform routes because they use
// AccessToken authentication (api_access_token header) instead of JWT+SuperAdmin.
// 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
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 {
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.
func (s *PlatformUserService) ValidatePermissible(ctx context.Context, platformAppID uint, userID uint) error {
perm, err := s.permissibleRepo.FindByPlatformAppAndResource(ctx, platformAppID, model.PermissibleTypeUser, userID)
if err != nil {
// GORM returns "record not found" when no matching row — treat as non-permissible
return errors.New("non permissible resource")
}
if perm == nil {
return errors.New("non permissible resource")
}
return nil
}
// GetUser retrieves a user by ID, after verifying permissible access.
func (s *PlatformUserService) GetUser(ctx context.Context, platformAppID uint, userID uint) (*model.User, error) {
if err := s.ValidatePermissible(ctx, platformAppID, userID); err != nil {
return nil, err
}
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, 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)
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 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, req PlatformUserRequest) (*PlatformUserResponse, error) {
if err := s.ValidatePermissible(ctx, platformAppID, userID); err != nil {
return nil, err
}
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if req.Name != "" {
user.Name = req.Name
}
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 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.
// Reference: Chatwoot UsersController#destroy — uses DeleteObjectJob (async)
func (s *PlatformUserService) DeleteUser(ctx context.Context, platformAppID uint, userID uint) error {
if err := s.ValidatePermissible(ctx, platformAppID, userID); err != nil {
return err
}
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)
if err != nil {
return nil, err
}
var users []model.User
for _, perm := range permissibles {
if perm.PermissibleType == model.PermissibleTypeUser {
user, err := s.userRepo.FindByID(ctx, perm.PermissibleID)
if err != nil {
continue // skip missing users
}
users = append(users, *user)
}
}
return users, nil
}