Skip to content

Commit 34fe280

Browse files
committed
Enhancement: UserGroups - Add job role support
Add job roles to UserGroups and LegacyUserGroups so callers can assign and parse job-role members alongside users, teams, and companies. - Add JobRoleIDs to UserGroups (jobRoleIds) and LegacyUserGroups - Encode/decode job roles with the "r" prefix in the legacy comma-separated format, matching the backend convention - Send the Jobroles-Enabled header so the API opts the SDK into job-role-aware responses - Include JobRoleIDs in LegacyUserGroups.IsEmpty - Add unit tests covering marshal/unmarshal, round-trip, and IsEmpty
1 parent 16afa49 commit 34fe280

4 files changed

Lines changed: 198 additions & 6 deletions

File tree

engine.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ func ExecuteRaw[R HTTPRequester](ctx context.Context, engine *Engine, requester
149149
return nil, fmt.Errorf("failed to authenticate request: %w", err)
150150
}
151151

152+
// Make the API aware that the SDK can handle job roles
153+
req.Header.Set("Jobroles-Enabled", "true")
154+
152155
resp, err := engine.client.Do(req)
153156
if err != nil {
154157
return nil, fmt.Errorf("failed to execute request: %w", err)

projects/types.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,25 +82,28 @@ func (l *LegacyNumericList) Add(n float64) {
8282
*l = append(*l, int64(n))
8383
}
8484

85-
// UserGroups represents a collection of users, companies, and teams.
85+
// UserGroups represents a collection of users, companies, teams, and job roles.
8686
type UserGroups struct {
8787
UserIDs []int64 `json:"userIds"`
8888
CompanyIDs []int64 `json:"companyIds"`
8989
TeamIDs []int64 `json:"teamIds"`
90+
JobRoleIDs []int64 `json:"jobRoleIds"`
9091
}
9192

92-
// LegacyUserGroups represents a collection of users, companies, and teams
93-
// in a legacy format, where IDs are represented as strings.
93+
// LegacyUserGroups represents a collection of users, companies, teams, and job
94+
// roles in a legacy format, where IDs are represented as strings.
9495
type LegacyUserGroups struct {
9596
UserIDs []int64
9697
CompanyIDs []int64
9798
TeamIDs []int64
99+
JobRoleIDs []int64
98100
}
99101

100102
// MarshalJSON encodes the LegacyUserGroups as a comma-separated string, where
101103
// user IDs are represented as plain numbers, company IDs are prefixed with "c",
102-
// and team IDs are prefixed with "t". The output order is user IDs first,
103-
// followed by team IDs, and then company IDs.
104+
// team IDs are prefixed with "t", and job role IDs are prefixed with "r". The
105+
// output order is user IDs first, followed by team IDs, then company IDs, and
106+
// finally job role IDs.
104107
func (m LegacyUserGroups) MarshalJSON() ([]byte, error) {
105108
var result string
106109
for _, id := range m.UserIDs {
@@ -121,6 +124,12 @@ func (m LegacyUserGroups) MarshalJSON() ([]byte, error) {
121124
}
122125
result += "c" + strconv.FormatInt(id, 10)
123126
}
127+
for _, id := range m.JobRoleIDs {
128+
if result != "" {
129+
result += ","
130+
}
131+
result += "r" + strconv.FormatInt(id, 10)
132+
}
124133
return []byte(`"` + result + `"`), nil
125134
}
126135

@@ -156,6 +165,15 @@ func (m *LegacyUserGroups) UnmarshalJSON(data []byte) error {
156165
return err
157166
}
158167
m.TeamIDs = append(m.TeamIDs, id)
168+
case 'r':
169+
if len(part) < 2 {
170+
return fmt.Errorf("invalid job role ID format: %s", part)
171+
}
172+
id, err := strconv.ParseInt(part[1:], 10, 64)
173+
if err != nil {
174+
return err
175+
}
176+
m.JobRoleIDs = append(m.JobRoleIDs, id)
159177
default:
160178
id, err := strconv.ParseInt(part, 10, 64)
161179
if err != nil {
@@ -169,7 +187,8 @@ func (m *LegacyUserGroups) UnmarshalJSON(data []byte) error {
169187

170188
// IsEmpty checks if the LegacyUserGroups contains no IDs.
171189
func (m LegacyUserGroups) IsEmpty() bool {
172-
return len(m.UserIDs) == 0 && len(m.CompanyIDs) == 0 && len(m.TeamIDs) == 0
190+
return len(m.UserIDs) == 0 && len(m.CompanyIDs) == 0 &&
191+
len(m.TeamIDs) == 0 && len(m.JobRoleIDs) == 0
173192
}
174193

175194
// LegacyRelationship describes the relation between the main entity and a

projects/types_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package projects_test
2+
3+
import (
4+
"encoding/json"
5+
"slices"
6+
"testing"
7+
8+
"github.com/teamwork/twapi-go-sdk/projects"
9+
)
10+
11+
func TestLegacyUserGroups_MarshalJSON(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
input projects.LegacyUserGroups
15+
want string
16+
}{{
17+
name: "empty",
18+
input: projects.LegacyUserGroups{},
19+
want: `""`,
20+
}, {
21+
name: "users only",
22+
input: projects.LegacyUserGroups{UserIDs: []int64{1, 2}},
23+
want: `"1,2"`,
24+
}, {
25+
name: "job roles only",
26+
input: projects.LegacyUserGroups{JobRoleIDs: []int64{5}},
27+
want: `"r5"`,
28+
}, {
29+
name: "all groups ordered users,teams,companies,jobRoles",
30+
input: projects.LegacyUserGroups{
31+
UserIDs: []int64{1},
32+
TeamIDs: []int64{2},
33+
CompanyIDs: []int64{3},
34+
JobRoleIDs: []int64{4},
35+
},
36+
want: `"1,t2,c3,r4"`,
37+
}}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
got, err := json.Marshal(tt.input)
42+
if err != nil {
43+
t.Fatalf("unexpected error: %s", err)
44+
}
45+
if string(got) != tt.want {
46+
t.Errorf("got %s, want %s", got, tt.want)
47+
}
48+
})
49+
}
50+
}
51+
52+
func TestLegacyUserGroups_UnmarshalJSON(t *testing.T) {
53+
tests := []struct {
54+
name string
55+
input string
56+
want projects.LegacyUserGroups
57+
wantErr bool
58+
}{{
59+
name: "empty",
60+
input: `""`,
61+
want: projects.LegacyUserGroups{},
62+
}, {
63+
name: "job roles only",
64+
input: `"r5"`,
65+
want: projects.LegacyUserGroups{JobRoleIDs: []int64{5}},
66+
}, {
67+
name: "all groups",
68+
input: `"1,t2,c3,r4"`,
69+
want: projects.LegacyUserGroups{
70+
UserIDs: []int64{1},
71+
TeamIDs: []int64{2},
72+
CompanyIDs: []int64{3},
73+
JobRoleIDs: []int64{4},
74+
},
75+
}, {
76+
name: "invalid job role format",
77+
input: `"r"`,
78+
wantErr: true,
79+
}, {
80+
name: "invalid job role id",
81+
input: `"rabc"`,
82+
wantErr: true,
83+
}}
84+
85+
for _, tt := range tests {
86+
t.Run(tt.name, func(t *testing.T) {
87+
var got projects.LegacyUserGroups
88+
err := json.Unmarshal([]byte(tt.input), &got)
89+
if tt.wantErr {
90+
if err == nil {
91+
t.Fatal("expected an error but got nil")
92+
}
93+
return
94+
}
95+
if err != nil {
96+
t.Fatalf("unexpected error: %s", err)
97+
}
98+
if !slices.Equal(got.UserIDs, tt.want.UserIDs) ||
99+
!slices.Equal(got.TeamIDs, tt.want.TeamIDs) ||
100+
!slices.Equal(got.CompanyIDs, tt.want.CompanyIDs) ||
101+
!slices.Equal(got.JobRoleIDs, tt.want.JobRoleIDs) {
102+
t.Errorf("got %+v, want %+v", got, tt.want)
103+
}
104+
})
105+
}
106+
}
107+
108+
func TestLegacyUserGroups_RoundTrip(t *testing.T) {
109+
want := projects.LegacyUserGroups{
110+
UserIDs: []int64{1, 2},
111+
TeamIDs: []int64{3},
112+
CompanyIDs: []int64{4},
113+
JobRoleIDs: []int64{5, 6},
114+
}
115+
116+
encoded, err := json.Marshal(want)
117+
if err != nil {
118+
t.Fatalf("unexpected marshal error: %s", err)
119+
}
120+
121+
var got projects.LegacyUserGroups
122+
if err := json.Unmarshal(encoded, &got); err != nil {
123+
t.Fatalf("unexpected unmarshal error: %s", err)
124+
}
125+
126+
if !slices.Equal(got.UserIDs, want.UserIDs) ||
127+
!slices.Equal(got.TeamIDs, want.TeamIDs) ||
128+
!slices.Equal(got.CompanyIDs, want.CompanyIDs) ||
129+
!slices.Equal(got.JobRoleIDs, want.JobRoleIDs) {
130+
t.Errorf("round trip mismatch: got %+v, want %+v", got, want)
131+
}
132+
}
133+
134+
func TestLegacyUserGroups_IsEmpty(t *testing.T) {
135+
tests := []struct {
136+
name string
137+
input projects.LegacyUserGroups
138+
want bool
139+
}{{
140+
name: "empty",
141+
input: projects.LegacyUserGroups{},
142+
want: true,
143+
}, {
144+
name: "users only",
145+
input: projects.LegacyUserGroups{UserIDs: []int64{1}},
146+
want: false,
147+
}, {
148+
name: "job roles only",
149+
input: projects.LegacyUserGroups{JobRoleIDs: []int64{5}},
150+
want: false,
151+
}}
152+
153+
for _, tt := range tests {
154+
t.Run(tt.name, func(t *testing.T) {
155+
if got := tt.input.IsEmpty(); got != tt.want {
156+
t.Errorf("got %t, want %t", got, tt.want)
157+
}
158+
})
159+
}
160+
}

projects/workload.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ type WorkloadRequestFilters struct {
8787
// UserTeamIDs is a list of users' team IDs to filter the workload by.
8888
UserTeamIDs []int64
8989

90+
// UserJobRoleIDs is a list of users' job role IDs to filter the workload by.
91+
UserJobRoleIDs []int64
92+
9093
// ProjectIDs is a list of project IDs to filter the workload by.
9194
ProjectIDs []int64
9295

@@ -129,6 +132,13 @@ func (w WorkloadRequestFilters) apply(req *http.Request) {
129132
}
130133
query.Set("teamIds", strings.Join(ids, ","))
131134
}
135+
if len(w.UserJobRoleIDs) > 0 {
136+
var ids []string
137+
for _, id := range w.UserJobRoleIDs {
138+
ids = append(ids, strconv.FormatInt(id, 10))
139+
}
140+
query.Set("jobRoleIds", strings.Join(ids, ","))
141+
}
132142
if len(w.ProjectIDs) > 0 {
133143
var ids []string
134144
for _, id := range w.ProjectIDs {

0 commit comments

Comments
 (0)