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 }