Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 25 additions & 6 deletions projects/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
160 changes: 160 additions & 0 deletions projects/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
10 changes: 10 additions & 0 deletions projects/workload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down