From 42c31d4b485bfab6903aeed0dd6fb2c43f61b4f2 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 9 Aug 2026 14:28:09 +0100 Subject: [PATCH] fix(crm): JSON person update and oo persons update CLI Form-encoded PUT ignores companyId/about and can unlink the employer; use JSON and re-fetch. Expose update plus contact-tag helpers on oo. --- cmd/oo/contacts.go | 160 +++++++++++++++++++++++++++++++++++++++++++++ crm.go | 99 +++++++++++++++++++++++++--- http.go | 5 ++ 3 files changed, 256 insertions(+), 8 deletions(-) diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go index aa0f533..e84114e 100644 --- a/cmd/oo/contacts.go +++ b/cmd/oo/contacts.go @@ -44,10 +44,16 @@ func init() { contactsCmd.AddCommand(contactsMergeCmd()) contactsCmd.AddCommand(contactsInfoAddCmd()) contactsCmd.AddCommand(contactsDedupeInfoCmd()) + contactsCmd.AddCommand(contactsTagsCmd()) + contactsCmd.AddCommand(contactsTagCreateCmd()) + contactsCmd.AddCommand(contactsTagAddCmd()) + contactsCmd.AddCommand(contactsTagRemoveCmd()) + contactsCmd.AddCommand(contactsByTagCmd()) only := true personsCmd.AddCommand(contactsListCmd(&only)) // persons only personsCmd.AddCommand(personsCreateCmd()) + personsCmd.AddCommand(personsUpdateCmd()) personsCmd.AddCommand(personsFixNamesCmd()) personsCmd.AddCommand(contactsDeleteCmd()) personsCmd.AddCommand(personsDedupeCmd()) @@ -245,6 +251,57 @@ func personsCreateCmd() *cobra.Command { return cmd } +func personsUpdateCmd() *cobra.Command { + var first, last string + var companyID int + var jobTitle, about string + cmd := &cobra.Command{ + Use: "update PERSON_ID", + Short: "Update a person (name, company, job title, about)", + Long: `Updates CRM person fields via PUT /crm/contact/person/{id}. + +--company-id 0 leaves the employer association unchanged. +Omit --first/--last to keep current names (fetched first).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + pid := args[0] + cur, err := c.GetContact(cmd.Context(), pid) + if err != nil { + return err + } + if first == "" { + if v, ok := cur["firstName"].(string); ok { + first = v + } + } + if last == "" { + if v, ok := cur["lastName"].(string); ok { + last = v + } + } + if first == "" || last == "" { + return fmt.Errorf("first and last name required (pass --first/--last or ensure contact has them)") + } + out, err := c.UpdatePerson(cmd.Context(), pid, first, last, companyID, jobTitle, about) + if err != nil { + return err + } + printObject(out) + return nil + }, + } + cmd.Flags().StringVar(&first, "first", "", "first name (default: current)") + cmd.Flags().StringVar(&last, "last", "", "last name (default: current)") + cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id (0 = leave unchanged)") + cmd.Flags().StringVar(&jobTitle, "job-title", "", "job title") + cmd.Flags().StringVar(&about, "about", "", "about / bio") + return cmd +} + func personsFixNamesCmd() *cobra.Command { var companyID int var dryRun bool @@ -473,3 +530,106 @@ func contactsDedupeInfoCmd() *cobra.Command { }), } } + +func contactsTagsCmd() *cobra.Command { + return &cobra.Command{ + Use: "tags", + Short: "List CRM contact tags", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, err := c.ListContactTags(cmd.Context()) + if err != nil { + return err + } + printTable([]string{"title", "relativeItemsCount"}, list) + return nil + }, + } +} + +func contactsTagCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "tag-create TAG", + Short: "Create a CRM contact tag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.CreateContactTag(cmd.Context(), args[0]); err != nil { + return err + } + fmt.Println(args[0]) + return nil + }, + } +} + +func contactsTagAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "tag-add CONTACT_ID TAG", + Short: "Attach a tag to a contact", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.AddContactTag(cmd.Context(), args[0], args[1]); err != nil { + return err + } + fmt.Printf("%s ← %s\n", args[0], args[1]) + return nil + }, + } +} + +func contactsTagRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "tag-remove CONTACT_ID TAG", + Short: "Remove a tag from a contact", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.RemoveContactTag(cmd.Context(), args[0], args[1]); err != nil { + return err + } + fmt.Printf("%s ✕ %s\n", args[0], args[1]) + return nil + }, + } +} + +func contactsByTagCmd() *cobra.Command { + var count, offset int + cmd := &cobra.Command{ + Use: "by-tag TAG", + Short: "List contacts with a given CRM tag (ignore-list = tag ignore)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + list, total, err := c.ListContactsByTag(cmd.Context(), args[0], count, offset) + if err != nil { + return err + } + if outputFormat == "table" { + fmt.Printf("tag=%s total: %d (shown: %d)\n", args[0], total, len(list)) + } + printTable([]string{"id", "displayName", "isCompany", "title", "about"}, list) + return nil + }, + } + cmd.Flags().IntVar(&count, "count", 50, "") + cmd.Flags().IntVar(&offset, "offset", 0, "") + return cmd +} diff --git a/crm.go b/crm.go index 0e36f4d..3e11d3c 100644 --- a/crm.go +++ b/crm.go @@ -188,21 +188,41 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID } // UpdatePerson updates first/last name and optional company link on a person. -// companyID == 0 leaves the company association unchanged. +// companyID == 0 leaves the company association unchanged (re-sends current +// company id when present). OnlyOffice ignores companyId/about on form-encoded +// PUT and may unlink the company when companyId is omitted — use JSON body. func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) { - fields := url.Values{} - fields.Set("firstName", first) - fields.Set("lastName", last) + body := map[string]any{ + "firstName": first, + "lastName": last, + } if companyID != 0 { - fields.Set("companyId", strconv.Itoa(companyID)) + body["companyId"] = companyID + } else { + // Preserve existing employer: omitted companyId unlinks on this API. + if cur, err := c.GetContact(ctx, personID); err == nil { + if co, ok := cur["company"].(map[string]any); ok { + if id := flexInt(co["id"]); id != 0 { + body["companyId"] = id + } + } + } } if jobTitle != "" { - fields.Set("jobTitle", jobTitle) + body["jobTitle"] = jobTitle } if about != "" { - fields.Set("about", about) + body["about"] = about } - return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), fields) + out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), body) + if err != nil { + return out, err + } + // PUT response body is often stale; re-fetch for authoritative fields. + if fresh, gerr := c.GetContact(ctx, personID); gerr == nil && fresh != nil { + out = fresh + } + return out, nil } // AddContactInfo attaches an email/website/phone/etc. to a contact. @@ -223,6 +243,69 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID))) } +// ListContactTags returns all CRM contact tags (title + relativeItemsCount). +func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) { + return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json") +} + +// CreateContactTag creates a contact tag by name. Idempotent: "already exists" is OK. +func (c *Client) CreateContactTag(ctx context.Context, tagName string) error { + fields := url.Values{} + fields.Set("tagName", tagName) + _, err := c.postFormObject(ctx, "/api/2.0/crm/contact/tag.json", fields) + if err != nil && strings.Contains(err.Error(), "already exists") { + return nil + } + return err +} + +// AddContactTag attaches tagName to a contact. +func (c *Client) AddContactTag(ctx context.Context, contactID, tagName string) error { + fields := url.Values{} + fields.Set("tagName", tagName) + _, err := c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields) + return err +} + +// RemoveContactTag removes tagName from a contact. +func (c *Client) RemoveContactTag(ctx context.Context, contactID, tagName string) error { + fields := url.Values{} + fields.Set("tagName", tagName) + raw, err := c.deleteForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields) + if err != nil { + return err + } + _, err = unmarshalResponseObject(raw) + return err +} + +// ListContactsByTag returns contacts filtered by a single CRM tag name. +func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, startIndex int) ([]map[string]any, int, error) { + if count <= 0 { + count = 50 + } + q := url.Values{} + q.Set("count", strconv.Itoa(count)) + q.Set("startIndex", strconv.Itoa(startIndex)) + q.Set("tags", tagName) + raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode()) + if err != nil { + return nil, 0, err + } + var env struct { + Response []map[string]any `json:"response"` + Total int `json:"total"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, 0, err + } + total := env.Total + if total == 0 && len(env.Response) > 0 { + total = len(env.Response) + } + return env.Response, total, nil +} + // ListAllContacts paginates through every CRM contact. func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) { const page = 100 diff --git a/http.go b/http.go index 570286f..ac55efd 100644 --- a/http.go +++ b/http.go @@ -181,6 +181,11 @@ func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (j return c.formRequest(ctx, http.MethodPut, path, fields) } +// deleteForm issues an authenticated DELETE with application/x-www-form-urlencoded body. +func (c *Client) deleteForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) { + return c.formRequest(ctx, http.MethodDelete, path, fields) +} + func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) { auth, err := c.authHeader() if err != nil {