From 410bb4da24ed250beab8cedc1e612fb70a5d1867 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Fri, 28 Aug 2026 16:17:47 -0300 Subject: [PATCH] Feature: Place a task in a workflow stage on create or update The v3 task request body carries a "workflows" object beside "task", taking workflowId, stageId and positionAfterTask, but nothing modelled it: only the response side existed, and TaskWorkflowStage's comment claimed a request role it never had. TaskWorkflows fills that in on TaskCreateRequest and TaskUpdateRequest, with IsZero so an unrelated request sends no such key. Its doc records what the two endpoints do and do not say: on create the task joins every workflow attached to its project in the backlog, and a workflow that is not one of the project's own is ignored with a 201; on update the task must already be in the named workflow or the answer is 404. Neither asks for permission to edit the workflow, unlike the routes behind WorkflowStageTaskMove. --- projects/task.go | 54 ++++++++++++++++++++++++-- projects/task_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/projects/task.go b/projects/task.go index 64e6412..d29c35d 100644 --- a/projects/task.go +++ b/projects/task.go @@ -223,19 +223,55 @@ func (t TaskAttachments) IsZero() bool { return len(t.Files) == 0 && len(t.PendingFiles) == 0 } -// TaskWorkflowStage represents the workflow stage associated with a task. This -// is used when creating or updating a task to set the workflow stage of the -// task. +// TaskWorkflowStage reports which stage of a workflow a task sits in. It is the +// response shape, carried by Task.WorkflowStages; the request side is +// TaskWorkflows, whose fields differ. type TaskWorkflowStage struct { // WorkflowID is the unique identifier of the workflow associated with the // task. WorkflowID *int64 `json:"workflowId"` // StageID is the unique identifier of the workflow stage associated with the - // task. + // task. A task in the workflow's backlog reports 0. StageID *int64 `json:"stageId"` } +// TaskWorkflows places a task in a stage of a workflow while creating or +// updating it, saving the follow-up request WorkflowStageTaskMove would cost. +// +// It reaches the endpoint as a "workflows" object beside "task", and both +// identifiers are needed: a stage without its workflow is ignored. The endpoints +// treat it differently, and neither says so in the response. +// +// On create, the task joins every workflow attached to its project, in the +// backlog. This moves it out of the backlog of the one workflow it names -- +// silently doing nothing at all if that workflow is not attached to the project, +// so the workflow has to be one of the project's own. +// +// On update, the task must already be in the named workflow or the endpoint +// answers 404. Unlike the routes behind WorkflowStageTaskMove, neither endpoint +// asks for permission to edit the workflow; the task's own edit permission is +// what is checked. +type TaskWorkflows struct { + // WorkflowID is the identifier of the workflow that owns the stage. Required + // alongside StageID. + WorkflowID *int64 `json:"workflowId,omitempty"` + + // StageID is the identifier of the stage to place the task in. Zero, or unset, + // leaves the task in the backlog. + StageID *int64 `json:"stageId,omitempty"` + + // PositionAfterTaskID is the identifier of the task after which this one is + // placed within the stage. Unset appends it to the end. + PositionAfterTaskID *int64 `json:"positionAfterTask,omitempty"` +} + +// IsZero reports whether no workflow placement was requested, so that the field +// is omitted from the request instead of being sent as an empty object. +func (t TaskWorkflows) IsZero() bool { + return t.WorkflowID == nil && t.StageID == nil && t.PositionAfterTaskID == nil +} + // TaskUpdateRequestPath contains the path parameters for creating a // task. type TaskCreateRequestPath struct { @@ -297,6 +333,9 @@ type TaskCreateRequest struct { // exist in the project, files uploaded with PendingFileCreate, or both. Attachments TaskAttachments `json:"-"` + // Workflows places the task in a stage of one of its project's workflows. + Workflows TaskWorkflows `json:"-"` + // ChangeFollowers is the list of users, teams or clients/companies that will // receive notifications when the task is updated. ChangeFollowers UserGroups `json:"changeFollowers,omitzero"` @@ -330,11 +369,13 @@ func (t TaskCreateRequest) HTTPRequest(ctx context.Context, server string) (*htt Options TaskOptions `json:"taskOptions"` Predecessors []TaskPredecessor `json:"predecessors,omitempty"` Attachments TaskAttachments `json:"attachments,omitzero"` + Workflows TaskWorkflows `json:"workflows,omitzero"` }{ Task: t, Options: t.Options, Predecessors: t.Predecessors, Attachments: t.Attachments, + Workflows: t.Workflows, } var body bytes.Buffer @@ -455,6 +496,9 @@ type TaskUpdateRequest struct { // additive, so files already attached to the task are left alone. Attachments TaskAttachments `json:"-"` + // Workflows moves the task to a stage of a workflow it is already in. + Workflows TaskWorkflows `json:"-"` + // ChangeFollowers is the list of users, teams or clients/companies that will // receive notifications when the task is updated. ChangeFollowers *UserGroups `json:"changeFollowers,omitempty"` @@ -487,11 +531,13 @@ func (t TaskUpdateRequest) HTTPRequest(ctx context.Context, server string) (*htt Options TaskOptions `json:"taskOptions"` Predecessors []TaskPredecessor `json:"predecessors,omitempty"` Attachments TaskAttachments `json:"attachments,omitzero"` + Workflows TaskWorkflows `json:"workflows,omitzero"` }{ Task: t, Options: t.Options, Predecessors: t.Predecessors, Attachments: t.Attachments, + Workflows: t.Workflows, } var body bytes.Buffer diff --git a/projects/task_test.go b/projects/task_test.go index 7cf53b6..cb25f27 100644 --- a/projects/task_test.go +++ b/projects/task_test.go @@ -700,3 +700,92 @@ func TestTaskSubTaskIDsAreDecoded(t *testing.T) { t.Errorf("unexpected subtask IDs: %v", resp.Task.SubTaskIDs) } } + +// TestTaskWorkflowsRequestGeneration pins where the workflows block lands and +// that it disappears when unset. It rides beside "task" rather than inside it, +// and an empty object is not inert on every endpoint, so sending one on an +// unrelated update would be a change of meaning rather than a wasted field. +func TestTaskWorkflowsRequestGeneration(t *testing.T) { + placement := projects.TaskWorkflows{ + WorkflowID: new(int64(123)), + StageID: new(int64(456)), + PositionAfterTaskID: new(int64(789)), + } + + tests := []struct { + name string + request twapi.HTTPRequester + want *projects.TaskWorkflows + }{{ + name: "create without a placement", + request: projects.NewTaskCreateRequest(888, "test"), + }, { + name: "create with a placement", + request: func() projects.TaskCreateRequest { + req := projects.NewTaskCreateRequest(888, "test") + req.Workflows = placement + return req + }(), + want: &placement, + }, { + name: "update without a placement", + request: projects.NewTaskUpdateRequest(12345), + }, { + name: "update with a placement", + request: func() projects.TaskUpdateRequest { + req := projects.NewTaskUpdateRequest(12345) + req.Workflows = placement + return req + }(), + want: &placement, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpReq, err := tt.request.HTTPRequest(context.Background(), "https://test.teamwork.com") + if err != nil { + t.Fatalf("unexpected error creating HTTP request: %s", err) + } + body, err := io.ReadAll(httpReq.Body) + if err != nil { + t.Fatalf("failed to read request body: %s", err) + } + + var payload struct { + Task map[string]any `json:"task"` + Workflows *projects.TaskWorkflows `json:"workflows"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to decode request body %q: %s", body, err) + } + + // A "workflows" key inside "task" is ignored by the endpoint, so the + // sibling position is the whole point. + if _, ok := payload.Task["workflows"]; ok { + t.Errorf("workflows must sit beside task, not inside it (body %q)", body) + } + + if tt.want == nil { + if payload.Workflows != nil { + t.Errorf("expected workflows to be omitted but got %+v (body %q)", *payload.Workflows, body) + } + return + } + if payload.Workflows == nil { + t.Fatalf("expected workflows to reach the wire (body %q)", body) + } + for _, field := range []struct { + name string + got, want *int64 + }{ + {"workflowId", payload.Workflows.WorkflowID, tt.want.WorkflowID}, + {"stageId", payload.Workflows.StageID, tt.want.StageID}, + {"positionAfterTask", payload.Workflows.PositionAfterTaskID, tt.want.PositionAfterTaskID}, + } { + if field.got == nil || *field.got != *field.want { + t.Errorf("expected %s %d but got %v (body %q)", field.name, *field.want, field.got, body) + } + } + }) + } +}