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 }