You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
481 lines
12 KiB
481 lines
12 KiB
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// CreateUserPayload represents the payload required to create a ServiceTrade user.
|
|
type CreateUserPayload struct {
|
|
Username string `json:"username"`
|
|
FirstName string `json:"firstName"`
|
|
LastName string `json:"lastName"`
|
|
Password string `json:"password"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone,omitempty"`
|
|
CompanyID int `json:"companyId"`
|
|
LocationID int `json:"locationId"`
|
|
Details string `json:"details,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
IsSales *bool `json:"isSales,omitempty"`
|
|
Timezone string `json:"timezone,omitempty"`
|
|
ServiceLineIDs []int `json:"serviceLineIds,omitempty"`
|
|
ManagerID *int `json:"managerId,omitempty"`
|
|
MFARequired *bool `json:"mfaRequired,omitempty"`
|
|
}
|
|
|
|
// UserRecord represents a simplified ServiceTrade user returned from the API.
|
|
type UserRecord struct {
|
|
ID int `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"firstName"`
|
|
LastName string `json:"lastName"`
|
|
Status string `json:"status"`
|
|
Roles []Role `json:"roles,omitempty"`
|
|
Raw []byte `json:"-"`
|
|
}
|
|
|
|
// Role represents a ServiceTrade role.
|
|
type Role struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Activities []string `json:"activities,omitempty"`
|
|
Global bool `json:"global"`
|
|
}
|
|
|
|
// UpdateUserPayload represents the fields that can be updated on an existing ServiceTrade user.
|
|
type UpdateUserPayload struct {
|
|
Username *string `json:"username,omitempty"`
|
|
FirstName *string `json:"firstName,omitempty"`
|
|
LastName *string `json:"lastName,omitempty"`
|
|
Password *string `json:"password,omitempty"`
|
|
Email *string `json:"email,omitempty"`
|
|
Phone *string `json:"phone,omitempty"`
|
|
CompanyID *int `json:"companyId,omitempty"`
|
|
LocationID *int `json:"locationId,omitempty"`
|
|
Details *string `json:"details,omitempty"`
|
|
Status *string `json:"status,omitempty"`
|
|
IsSales *bool `json:"isSales,omitempty"`
|
|
Timezone *string `json:"timezone,omitempty"`
|
|
ServiceLineIDs *[]int `json:"serviceLineIds,omitempty"`
|
|
ManagerID *int `json:"managerId,omitempty"`
|
|
MFARequired *bool `json:"mfaRequired,omitempty"`
|
|
}
|
|
|
|
// CreateUser creates a new ServiceTrade user and returns the created user record.
|
|
func (s *Session) CreateUser(payload CreateUserPayload) (*UserRecord, error) {
|
|
bodyBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error marshalling user payload: %w", err)
|
|
}
|
|
|
|
resp, err := s.DoRequest(http.MethodPost, "/user", bytes.NewBuffer(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error sending create user request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading create user response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
return nil, fmt.Errorf("create user failed: %s - %s", resp.Status, string(responseBody))
|
|
}
|
|
|
|
user, err := parseUserFromResponse(responseBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse create user response: %w", err)
|
|
}
|
|
user.Raw = responseBody
|
|
return user, nil
|
|
}
|
|
|
|
// AssignRoleToUser assigns a role to a ServiceTrade user.
|
|
func (s *Session) AssignRoleToUser(userID, roleID int) error {
|
|
endpoint := fmt.Sprintf("/user/%d/role/%d", userID, roleID)
|
|
resp, err := s.DoRequest(http.MethodPost, endpoint, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("error assigning role %d to user %d: %w", roleID, userID, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("assign role failed: %s - %s", resp.Status, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListRoles retrieves all ServiceTrade roles visible to the authenticated account.
|
|
func (s *Session) ListRoles() ([]Role, error) {
|
|
resp, err := s.DoRequest(http.MethodGet, "/role", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error requesting roles: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading roles response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("list roles failed: %s - %s", resp.Status, string(body))
|
|
}
|
|
|
|
roles, err := parseRolesFromResponse(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return roles, nil
|
|
}
|
|
|
|
// UpdateUser updates an existing ServiceTrade user with the provided fields.
|
|
func (s *Session) UpdateUser(userID int, payload UpdateUserPayload) (*UserRecord, error) {
|
|
bodyBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error marshalling user update payload: %w", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("/user/%d", userID)
|
|
resp, err := s.DoRequest(http.MethodPut, endpoint, bytes.NewBuffer(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error sending update user request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading update user response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("update user failed: %s - %s", resp.Status, string(responseBody))
|
|
}
|
|
|
|
user, err := parseUserFromResponse(responseBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse update user response: %w", err)
|
|
}
|
|
user.Raw = responseBody
|
|
return user, nil
|
|
}
|
|
|
|
// FindUserByUsername searches for a user by username. Returns an error if not exactly one match is found.
|
|
func (s *Session) FindUserByUsername(username string) (*UserRecord, error) {
|
|
if strings.TrimSpace(username) == "" {
|
|
return nil, fmt.Errorf("username is required")
|
|
}
|
|
|
|
query := url.Values{}
|
|
query.Set("search", username)
|
|
query.Set("searchFields", "username")
|
|
query.Set("status", "active,inactive,pending")
|
|
|
|
endpoint := fmt.Sprintf("/user?%s", query.Encode())
|
|
resp, err := s.DoRequest(http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error searching for user %q: %w", username, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading user search response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("user search failed: %s - %s", resp.Status, string(body))
|
|
}
|
|
|
|
users, err := parseUsersFromResponse(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error parsing user search response: %w", err)
|
|
}
|
|
|
|
var matches []*UserRecord
|
|
for i := range users {
|
|
if strings.EqualFold(users[i].Username, username) {
|
|
matches = append(matches, &users[i])
|
|
}
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return nil, fmt.Errorf("no user found with username %q", username)
|
|
}
|
|
if len(matches) > 1 {
|
|
return nil, fmt.Errorf("multiple users found with username %q", username)
|
|
}
|
|
|
|
return matches[0], nil
|
|
}
|
|
|
|
func parseUserFromResponse(body []byte) (*UserRecord, error) {
|
|
var root map[string]interface{}
|
|
if err := json.Unmarshal(body, &root); err != nil {
|
|
return nil, fmt.Errorf("error decoding user response: %w", err)
|
|
}
|
|
|
|
candidate := extractMap(root, "data", "user")
|
|
if candidate == nil {
|
|
candidate = extractFirstMapFromArray(root, "data", "users")
|
|
}
|
|
if candidate == nil {
|
|
if userVal, ok := root["user"].(map[string]interface{}); ok {
|
|
candidate = userVal
|
|
}
|
|
}
|
|
if candidate == nil {
|
|
if dataVal, ok := root["data"].(map[string]interface{}); ok {
|
|
candidate = dataVal
|
|
}
|
|
}
|
|
if candidate == nil {
|
|
candidate = root
|
|
}
|
|
|
|
return buildUserRecord(candidate)
|
|
}
|
|
|
|
func parseUsersFromResponse(body []byte) ([]UserRecord, error) {
|
|
var root map[string]interface{}
|
|
if err := json.Unmarshal(body, &root); err != nil {
|
|
return nil, fmt.Errorf("error decoding users response: %w", err)
|
|
}
|
|
|
|
var records []interface{}
|
|
if dataMap, ok := root["data"].(map[string]interface{}); ok {
|
|
if list, ok := dataMap["users"].([]interface{}); ok {
|
|
records = list
|
|
} else if list, ok := dataMap["records"].([]interface{}); ok {
|
|
records = list
|
|
}
|
|
}
|
|
if records == nil {
|
|
if list, ok := root["users"].([]interface{}); ok {
|
|
records = list
|
|
}
|
|
}
|
|
if records == nil {
|
|
return []UserRecord{}, nil
|
|
}
|
|
|
|
results := make([]UserRecord, 0, len(records))
|
|
for _, item := range records {
|
|
userMap, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
user, err := buildUserRecord(userMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, *user)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func parseRolesFromResponse(body []byte) ([]Role, error) {
|
|
var root map[string]interface{}
|
|
if err := json.Unmarshal(body, &root); err != nil {
|
|
return nil, fmt.Errorf("error decoding roles response: %w", err)
|
|
}
|
|
|
|
var rolesList []interface{}
|
|
if dataMap, ok := root["data"].(map[string]interface{}); ok {
|
|
if list, ok := dataMap["roles"].([]interface{}); ok {
|
|
rolesList = list
|
|
} else if list, ok := dataMap["records"].([]interface{}); ok {
|
|
rolesList = list
|
|
}
|
|
}
|
|
if rolesList == nil {
|
|
if list, ok := root["roles"].([]interface{}); ok {
|
|
rolesList = list
|
|
}
|
|
}
|
|
if rolesList == nil {
|
|
return nil, fmt.Errorf("roles not found in response")
|
|
}
|
|
|
|
return convertRoles(rolesList), nil
|
|
}
|
|
|
|
func extractMap(root map[string]interface{}, keys ...string) map[string]interface{} {
|
|
current := root
|
|
for _, key := range keys {
|
|
val, ok := current[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if nextMap, ok := val.(map[string]interface{}); ok {
|
|
current = nextMap
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
return current
|
|
}
|
|
|
|
func extractFirstMapFromArray(root map[string]interface{}, keys ...string) map[string]interface{} {
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
lastKey := keys[len(keys)-1]
|
|
parent := extractMap(root, keys[:len(keys)-1]...)
|
|
if parent == nil {
|
|
return nil
|
|
}
|
|
val, ok := parent[lastKey]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
arr, ok := val.([]interface{})
|
|
if !ok || len(arr) == 0 {
|
|
return nil
|
|
}
|
|
first, ok := arr[0].(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return first
|
|
}
|
|
|
|
func convertRoles(items []interface{}) []Role {
|
|
var roles []Role
|
|
for _, item := range items {
|
|
roleMap, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
role := Role{
|
|
ID: toInt(roleMap["id"]),
|
|
Name: toString(roleMap["name"]),
|
|
Description: toString(roleMap["description"]),
|
|
Global: toBool(roleMap["global"]),
|
|
Activities: toStringSlice(roleMap["activities"]),
|
|
}
|
|
roles = append(roles, role)
|
|
}
|
|
return roles
|
|
}
|
|
|
|
func toInt(value interface{}) int {
|
|
switch v := value.(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
case json.Number:
|
|
i, _ := v.Int64()
|
|
return int(i)
|
|
case string:
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
i, err := strconv.Atoi(strings.TrimSpace(v))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return i
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func toString(value interface{}) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return v
|
|
case json.Number:
|
|
return v.String()
|
|
case fmt.Stringer:
|
|
return v.String()
|
|
case int:
|
|
return strconv.Itoa(v)
|
|
case int64:
|
|
return strconv.FormatInt(v, 10)
|
|
case float64:
|
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func toBool(value interface{}) bool {
|
|
switch v := value.(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "true", "1", "yes", "y":
|
|
return true
|
|
case "false", "0", "no", "n":
|
|
return false
|
|
}
|
|
case float64:
|
|
return v != 0
|
|
case int:
|
|
return v != 0
|
|
case json.Number:
|
|
i, err := v.Int64()
|
|
if err == nil {
|
|
return i != 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toStringSlice(value interface{}) []string {
|
|
switch v := value.(type) {
|
|
case []string:
|
|
return v
|
|
case []interface{}:
|
|
result := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
result = append(result, toString(item))
|
|
}
|
|
return result
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func buildUserRecord(candidate map[string]interface{}) (*UserRecord, error) {
|
|
if candidate == nil {
|
|
return nil, fmt.Errorf("user data missing in response")
|
|
}
|
|
|
|
user := &UserRecord{
|
|
ID: toInt(candidate["id"]),
|
|
Username: toString(candidate["username"]),
|
|
Email: toString(candidate["email"]),
|
|
FirstName: toString(candidate["firstName"]),
|
|
LastName: toString(candidate["lastName"]),
|
|
Status: toString(candidate["status"]),
|
|
}
|
|
|
|
if user.ID == 0 {
|
|
return nil, fmt.Errorf("user ID missing in response")
|
|
}
|
|
|
|
if rolesVal, ok := candidate["roles"].([]interface{}); ok {
|
|
user.Roles = convertRoles(rolesVal)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|