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.
This commit is contained in:
2026-08-09 14:28:09 +01:00
parent f95830961d
commit 42c31d4b48
3 changed files with 256 additions and 8 deletions
+160
View File
@@ -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
}
+91 -8
View File
@@ -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
+5
View File
@@ -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 {