From d6b3aca87e871c84dfaf7f02d2ecfbad01243f56 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Fri, 26 Jun 2026 15:06:51 -0300 Subject: [PATCH] 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 --- engine.go | 3 + projects/types.go | 31 ++++++-- projects/types_test.go | 160 +++++++++++++++++++++++++++++++++++++++++ projects/workload.go | 10 +++ 4 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 projects/types_test.go diff --git a/engine.go b/engine.go index 4c85248..0208599 100644 --- a/engine.go +++ b/engine.go @@ -149,6 +149,9 @@ func ExecuteRaw[R HTTPRequester](ctx context.Context, engine *Engine, requester return nil, fmt.Errorf("failed to authenticate request: %w", err) } + // Make the API aware that the SDK can handle job roles + req.Header.Set("Jobroles-Enabled", "true") + resp, err := engine.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) diff --git a/projects/types.go b/projects/types.go index 8a45d13..1814ee2 100644 --- a/projects/types.go +++ b/projects/types.go @@ -82,25 +82,28 @@ func (l *LegacyNumericList) Add(n float64) { *l = append(*l, int64(n)) } -// UserGroups represents a collection of users, companies, and teams. +// UserGroups represents a collection of users, companies, teams, and job roles. type UserGroups struct { UserIDs []int64 `json:"userIds"` CompanyIDs []int64 `json:"companyIds"` TeamIDs []int64 `json:"teamIds"` + JobRoleIDs []int64 `json:"jobRoleIds"` } -// LegacyUserGroups represents a collection of users, companies, and teams -// in a legacy format, where IDs are represented as strings. +// LegacyUserGroups represents a collection of users, companies, teams, and job +// roles in a legacy format, where IDs are represented as strings. type LegacyUserGroups struct { UserIDs []int64 CompanyIDs []int64 TeamIDs []int64 + JobRoleIDs []int64 } // MarshalJSON encodes the LegacyUserGroups as a comma-separated string, where // user IDs are represented as plain numbers, company IDs are prefixed with "c", -// and team IDs are prefixed with "t". The output order is user IDs first, -// followed by team IDs, and then company IDs. +// team IDs are prefixed with "t", and job role IDs are prefixed with "r". The +// output order is user IDs first, followed by team IDs, then company IDs, and +// finally job role IDs. func (m LegacyUserGroups) MarshalJSON() ([]byte, error) { var result string for _, id := range m.UserIDs { @@ -121,6 +124,12 @@ func (m LegacyUserGroups) MarshalJSON() ([]byte, error) { } result += "c" + strconv.FormatInt(id, 10) } + for _, id := range m.JobRoleIDs { + if result != "" { + result += "," + } + result += "r" + strconv.FormatInt(id, 10) + } return []byte(`"` + result + `"`), nil } @@ -156,6 +165,15 @@ func (m *LegacyUserGroups) UnmarshalJSON(data []byte) error { return err } m.TeamIDs = append(m.TeamIDs, id) + case 'r': + if len(part) < 2 { + return fmt.Errorf("invalid job role ID format: %s", part) + } + id, err := strconv.ParseInt(part[1:], 10, 64) + if err != nil { + return err + } + m.JobRoleIDs = append(m.JobRoleIDs, id) default: id, err := strconv.ParseInt(part, 10, 64) if err != nil { @@ -169,7 +187,8 @@ func (m *LegacyUserGroups) UnmarshalJSON(data []byte) error { // IsEmpty checks if the LegacyUserGroups contains no IDs. func (m LegacyUserGroups) IsEmpty() bool { - return len(m.UserIDs) == 0 && len(m.CompanyIDs) == 0 && len(m.TeamIDs) == 0 + return len(m.UserIDs) == 0 && len(m.CompanyIDs) == 0 && + len(m.TeamIDs) == 0 && len(m.JobRoleIDs) == 0 } // LegacyRelationship describes the relation between the main entity and a diff --git a/projects/types_test.go b/projects/types_test.go new file mode 100644 index 0000000..6820d52 --- /dev/null +++ b/projects/types_test.go @@ -0,0 +1,160 @@ +package projects_test + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/teamwork/twapi-go-sdk/projects" +) + +func TestLegacyUserGroups_MarshalJSON(t *testing.T) { + tests := []struct { + name string + input projects.LegacyUserGroups + want string + }{{ + name: "empty", + input: projects.LegacyUserGroups{}, + want: `""`, + }, { + name: "users only", + input: projects.LegacyUserGroups{UserIDs: []int64{1, 2}}, + want: `"1,2"`, + }, { + name: "job roles only", + input: projects.LegacyUserGroups{JobRoleIDs: []int64{5}}, + want: `"r5"`, + }, { + name: "all groups ordered users,teams,companies,jobRoles", + input: projects.LegacyUserGroups{ + UserIDs: []int64{1}, + TeamIDs: []int64{2}, + CompanyIDs: []int64{3}, + JobRoleIDs: []int64{4}, + }, + want: `"1,t2,c3,r4"`, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.input) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if string(got) != tt.want { + t.Errorf("got %s, want %s", got, tt.want) + } + }) + } +} + +func TestLegacyUserGroups_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want projects.LegacyUserGroups + wantErr bool + }{{ + name: "empty", + input: `""`, + want: projects.LegacyUserGroups{}, + }, { + name: "job roles only", + input: `"r5"`, + want: projects.LegacyUserGroups{JobRoleIDs: []int64{5}}, + }, { + name: "all groups", + input: `"1,t2,c3,r4"`, + want: projects.LegacyUserGroups{ + UserIDs: []int64{1}, + TeamIDs: []int64{2}, + CompanyIDs: []int64{3}, + JobRoleIDs: []int64{4}, + }, + }, { + name: "invalid job role format", + input: `"r"`, + wantErr: true, + }, { + name: "invalid job role id", + input: `"rabc"`, + wantErr: true, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got projects.LegacyUserGroups + err := json.Unmarshal([]byte(tt.input), &got) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if !slices.Equal(got.UserIDs, tt.want.UserIDs) || + !slices.Equal(got.TeamIDs, tt.want.TeamIDs) || + !slices.Equal(got.CompanyIDs, tt.want.CompanyIDs) || + !slices.Equal(got.JobRoleIDs, tt.want.JobRoleIDs) { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestLegacyUserGroups_RoundTrip(t *testing.T) { + want := projects.LegacyUserGroups{ + UserIDs: []int64{1, 2}, + TeamIDs: []int64{3}, + CompanyIDs: []int64{4}, + JobRoleIDs: []int64{5, 6}, + } + + encoded, err := json.Marshal(want) + if err != nil { + t.Fatalf("unexpected marshal error: %s", err) + } + + var got projects.LegacyUserGroups + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("unexpected unmarshal error: %s", err) + } + + if !slices.Equal(got.UserIDs, want.UserIDs) || + !slices.Equal(got.TeamIDs, want.TeamIDs) || + !slices.Equal(got.CompanyIDs, want.CompanyIDs) || + !slices.Equal(got.JobRoleIDs, want.JobRoleIDs) { + t.Errorf("round trip mismatch: got %+v, want %+v", got, want) + } +} + +func TestLegacyUserGroups_IsEmpty(t *testing.T) { + tests := []struct { + name string + input projects.LegacyUserGroups + want bool + }{{ + name: "empty", + input: projects.LegacyUserGroups{}, + want: true, + }, { + name: "users only", + input: projects.LegacyUserGroups{UserIDs: []int64{1}}, + want: false, + }, { + name: "job roles only", + input: projects.LegacyUserGroups{JobRoleIDs: []int64{5}}, + want: false, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.input.IsEmpty(); got != tt.want { + t.Errorf("got %t, want %t", got, tt.want) + } + }) + } +} diff --git a/projects/workload.go b/projects/workload.go index 0280075..22dbeb0 100644 --- a/projects/workload.go +++ b/projects/workload.go @@ -87,6 +87,9 @@ type WorkloadRequestFilters struct { // UserTeamIDs is a list of users' team IDs to filter the workload by. UserTeamIDs []int64 + // UserJobRoleIDs is a list of users' job role IDs to filter the workload by. + UserJobRoleIDs []int64 + // ProjectIDs is a list of project IDs to filter the workload by. ProjectIDs []int64 @@ -129,6 +132,13 @@ func (w WorkloadRequestFilters) apply(req *http.Request) { } query.Set("teamIds", strings.Join(ids, ",")) } + if len(w.UserJobRoleIDs) > 0 { + var ids []string + for _, id := range w.UserJobRoleIDs { + ids = append(ids, strconv.FormatInt(id, 10)) + } + query.Set("jobRoleIds", strings.Join(ids, ",")) + } if len(w.ProjectIDs) > 0 { var ids []string for _, id := range w.ProjectIDs {