From f60820734924284d6c40b46c79380a24573432f3 Mon Sep 17 00:00:00 2001 From: Andrey Oblivantsev Date: Thu, 23 Jul 2026 15:01:23 +0100 Subject: [PATCH] fix(oo): assign owner and deadline on task create (#9) * fix(oo): assign owner and deadline on task create CRM and project task create omitted responsibleId/responsibles, so the portal attached a removed stub profile and CRM create without a deadline failed or looked empty in the UI. Default both to the authenticated user and now+14d; add crm-tasks reassign-self for backfill. * fix(oo): collect all bad CRM tasks before reassign-self apply Offset-0 apply stopped early when the first page was already fixed. --- cmd/office/model/columns.go | 9 ++- cmd/office/model/task_display.go | 3 + cmd/oo/crm_tasks.go | 106 ++++++++++++++++++++++++++++++- cmd/oo/tasks.go | 2 +- crm.go | 38 +++++++++++ tasks.go | 15 ++++- 6 files changed, 164 insertions(+), 9 deletions(-) diff --git a/cmd/office/model/columns.go b/cmd/office/model/columns.go index f9cb53b..e39f14d 100644 --- a/cmd/office/model/columns.go +++ b/cmd/office/model/columns.go @@ -171,10 +171,15 @@ func CellText(it Item, key string) string { if it.Raw == nil { return "" } - if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" { + // CRM tasks use camelCase deadLine; project tasks use deadline. + rawDL := it.Raw["deadline"] + if rawDL == nil || formatAny(rawDL) == "" { + rawDL = it.Raw["deadLine"] + } + if rel := FormatRelativeDeadline(rawDL); rel != "" { return rel } - return formatAny(it.Raw["deadline"]) + return formatAny(rawDL) case "responsible": if it.Kind == KindTask || it.Kind == KindCRMTask { return TaskResponsibleLabel(it.Raw) diff --git a/cmd/office/model/task_display.go b/cmd/office/model/task_display.go index 4008dde..93574f9 100644 --- a/cmd/office/model/task_display.go +++ b/cmd/office/model/task_display.go @@ -169,6 +169,9 @@ func personName(v any) string { return "" } if name := strRaw(m, "displayName"); name != "" { + if strings.EqualFold(name, "Profile has been removed") { + return "" + } return name } first := strRaw(m, "firstName") diff --git a/cmd/oo/crm_tasks.go b/cmd/oo/crm_tasks.go index 76eef3d..229d6e5 100644 --- a/cmd/oo/crm_tasks.go +++ b/cmd/oo/crm_tasks.go @@ -3,6 +3,7 @@ package main import ( "fmt" "strconv" + "strings" "github.com/spf13/cobra" ) @@ -17,6 +18,7 @@ func init() { rootCmd.AddCommand(crmTasksCmd) crmTasksCmd.AddCommand(crmTasksListCmd()) crmTasksCmd.AddCommand(crmTasksCreateCmd()) + crmTasksCmd.AddCommand(crmTasksReassignSelfCmd()) crmTasksCmd.AddCommand(crmTasksDeleteCmd()) crmTasksCmd.AddCommand(crmTasksCategoriesCmd()) } @@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command { if outputFormat == "table" { fmt.Printf("total: %d (shown: %d)\n", total, len(list)) } - printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list) + printTable([]string{"id", "title", "deadLine", "isClosed", "categoryID"}, list) return nil }, } @@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create TITLE", Aliases: []string{"add"}, - Short: "Create a CRM task", + Short: "Create a CRM task (assigns self + deadline default now+14d)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) @@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command { return nil }, } - cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)") + cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601); default now+14d") cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)") cmd.Flags().IntVar(&contactID, "contact", 0, "contact id") cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact") @@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command { return cmd } +func crmTaskNeedsOwner(t map[string]any) (need bool, name string) { + resp, _ := t["responsible"].(map[string]any) + rid, _ := resp["id"].(string) + name, _ = resp["displayName"].(string) + need = rid == "" || strings.EqualFold(name, "Profile has been removed") + return need, name +} + +func crmTaskCategoryID(t map[string]any) int { + cat, ok := t["category"].(map[string]any) + if !ok { + return 0 + } + switch v := cat["id"].(type) { + case float64: + return int(v) + case int: + return v + default: + return 0 + } +} + +func crmTasksReassignSelfCmd() *cobra.Command { + var apply bool + var count, max int + cmd := &cobra.Command{ + Use: "reassign-self", + Short: "Reassign CRM tasks with missing/removed owner to the authenticated user", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + uid, err := c.SelfUserID(cmd.Context()) + if err != nil { + return err + } + + type item struct { + id, title, deadline, ownerName string + categoryID int + } + var todos []item + scanned := 0 + for start := 0; ; start += count { + list, total, err := c.ListCRMTasks(cmd.Context(), count, start) + if err != nil { + return err + } + for _, t := range list { + scanned++ + need, name := crmTaskNeedsOwner(t) + if !need { + continue + } + title, _ := t["title"].(string) + dl, _ := t["deadLine"].(string) + todos = append(todos, item{ + id: fmt.Sprint(t["id"]), title: title, deadline: dl, + ownerName: name, categoryID: crmTaskCategoryID(t), + }) + if max > 0 && len(todos) >= max { + break + } + } + if (max > 0 && len(todos) >= max) || start+count >= total || len(list) == 0 { + break + } + } + + fixed := 0 + for _, t := range todos { + fmt.Printf(" task %s %q owner=%q → %s\n", t.id, t.title, t.ownerName, uid) + if !apply { + fixed++ + continue + } + if _, err := c.UpdateCRMTask(cmd.Context(), t.id, t.title, t.deadline, t.categoryID, uid); err != nil { + return fmt.Errorf("update %s: %w", t.id, err) + } + fixed++ + } + + mode := "DRY-RUN" + if apply { + mode = "APPLIED" + } + fmt.Printf("%s: scanned=%d reassigned=%d (self=%s)\n", mode, scanned, fixed, uid) + return nil + }, + } + cmd.Flags().BoolVar(&apply, "apply", false, "write changes (default dry-run)") + cmd.Flags().IntVar(&count, "count", 100, "page size") + cmd.Flags().IntVar(&max, "max", 0, "stop after N candidates (0 = all)") + return cmd +} + func crmTasksDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "delete TASK_ID [TASK_ID...]", diff --git a/cmd/oo/tasks.go b/cmd/oo/tasks.go index ac0dbc4..3bcc301 100644 --- a/cmd/oo/tasks.go +++ b/cmd/oo/tasks.go @@ -110,7 +110,7 @@ func taskCreateCmd() *cobra.Command { } cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)") cmd.Flags().StringVar(&desc, "description", "", "description") - cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD") + cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d; always assigned to you)") cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low") return cmd } diff --git a/crm.go b/crm.go index ae7c349..ea1cd76 100644 --- a/crm.go +++ b/crm.go @@ -11,6 +11,7 @@ import ( "net/url" "strconv" "strings" + "time" ) // ListContacts returns a page of CRM contacts and the total count. @@ -491,11 +492,25 @@ func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map } // CreateCRMTask creates a CRM task (reminder) attached to an entity. +// Empty deadline defaults to now+14d (API requires a deadline). Empty +// responsibleID falls back to the authenticated user — without this the +// portal assigns a stub "Profile has been removed" owner. func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) { + if deadline == "" { + deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05") + } + if categoryID == 0 { + categoryID = 2 // Opportunity — matches applications sync + } + uid, err := c.SelfUserID(ctx) + if err != nil { + return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err) + } fields := url.Values{} fields.Set("title", title) fields.Set("deadline", deadline) fields.Set("categoryId", strconv.Itoa(categoryID)) + fields.Set("responsibleId", uid) if contactID != 0 { fields.Set("contactId", strconv.Itoa(contactID)) } @@ -511,6 +526,29 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields) } +// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task. +// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged +// by the API only if omitted — callers should pass deadLine when known. +func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) { + if responsibleID == "" { + uid, err := c.SelfUserID(ctx) + if err != nil { + return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err) + } + responsibleID = uid + } + fields := url.Values{} + fields.Set("title", title) + fields.Set("responsibleId", responsibleID) + if deadline != "" { + fields.Set("deadline", deadline) + } + if categoryID != 0 { + fields.Set("categoryId", strconv.Itoa(categoryID)) + } + return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields) +} + // DeleteCRMTask removes a CRM task by id. func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) { return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id))) diff --git a/tasks.go b/tasks.go index 113c596..d2c4b6c 100644 --- a/tasks.go +++ b/tasks.go @@ -233,17 +233,26 @@ func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any // AddTask creates a task via the form-encoded endpoint (no milestone/start). // Prefer the typed CreateProjectTask for new code; AddTask is kept for // parity with the Python reference tooling. +// +// Always assigns the authenticated user as responsible (form field +// "responsibles"). Empty deadline defaults to today+14 days. func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) { if projectID == "" { projectID = c.defaults.ProjectID } + if deadline == "" { + deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + } + uid, err := c.SelfUserID(ctx) + if err != nil { + return nil, fmt.Errorf("AddTask: resolve self: %w", err) + } fields := url.Values{} fields.Set("title", title) fields.Set("description", description) fields.Set("priority", strconv.Itoa(priority)) - if deadline != "" { - fields.Set("deadline", deadline) - } + fields.Set("deadline", deadline) + fields.Set("responsibles", uid) return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields) }