From 179f30644d3acf60698927922eff2ed703abd6dd Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 26 Jul 2026 13:11:54 +0100 Subject: [PATCH] fix(crm): clean person names; canonical project titles Strip company annotations from lastName, derive names from email locals, UpdatePerson on apply with companyId. Add persons fix-names, contacts merge, projects create --country/--company (CC | Company | Title). Fix project update (responsibleId) and company-person dedupe grouping. --- cmd/oo/contacts.go | 129 +++++++++++++++++++++++++++++++++++++++++++++ cmd/oo/projects.go | 47 ++++++++++++++++- crm.go | 21 +++++++- crm_dedupe_plan.go | 5 ++ crm_normalize.go | 47 ++++++++++++++++- projects.go | 24 ++++++--- 6 files changed, 261 insertions(+), 12 deletions(-) diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go index e70a029..5d838c6 100644 --- a/cmd/oo/contacts.go +++ b/cmd/oo/contacts.go @@ -3,8 +3,10 @@ package main import ( "fmt" "strconv" + "strings" onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/eslider/go-onlyoffice/catalog" "github.com/spf13/cobra" ) @@ -39,12 +41,14 @@ func init() { contactsCmd.AddCommand(contactsListCmd(nil)) contactsCmd.AddCommand(contactsGetCmd()) contactsCmd.AddCommand(contactsDeleteCmd()) + contactsCmd.AddCommand(contactsMergeCmd()) contactsCmd.AddCommand(contactsInfoAddCmd()) contactsCmd.AddCommand(contactsDedupeInfoCmd()) only := true personsCmd.AddCommand(contactsListCmd(&only)) // persons only personsCmd.AddCommand(personsCreateCmd()) + personsCmd.AddCommand(personsFixNamesCmd()) personsCmd.AddCommand(contactsDeleteCmd()) personsCmd.AddCommand(personsDedupeCmd()) @@ -146,6 +150,26 @@ func contactsDeleteCmd() *cobra.Command { } } +func contactsMergeCmd() *cobra.Command { + return &cobra.Command{ + Use: "merge FROM_ID INTO_ID", + Short: "Merge FROM contact into INTO (FROM is removed)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + out, err := c.MergeContacts(cmd.Context(), args[1], args[0]) + if err != nil { + return err + } + printObject(out) + return nil + }, + } +} + func contactsInfoAddCmd() *cobra.Command { var infoType, value, category string var isPrimary bool @@ -217,6 +241,111 @@ func personsCreateCmd() *cobra.Command { return cmd } +func personsFixNamesCmd() *cobra.Command { + var companyID int + var dryRun bool + var orgHint string + cmd := &cobra.Command{ + Use: "fix-names", + Short: "Strip company annotations from last names; keep companyId link", + Long: `Repairs CRM persons whose lastName embeds a company ("Thomsen (Acme)") +or whose firstName is an email. Company belongs on companyId, not in the name. + +With --company-id, only persons linked to that company are scanned.`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + ctx := cmd.Context() + var list []map[string]any + if companyID > 0 { + list, err = c.ListCompanyPersons(ctx, strconv.Itoa(companyID)) + } else { + all, err2 := c.ListAllContacts(ctx) + err = err2 + for _, row := range all { + if isCo, _ := row["isCompany"].(bool); !isCo { + list = append(list, row) + } + } + } + if err != nil { + return err + } + fixed, skipped := 0, 0 + for _, p := range list { + id := fmt.Sprint(p["id"]) + // List payloads often omit contactInfos; refresh when name looks like email. + fn := strings.TrimSpace(fmt.Sprint(p["firstName"])) + if strings.Contains(fn, "@") || fn == "" { + if full, gerr := c.GetContact(ctx, id); gerr == nil && full != nil { + p = full + } + } + fn = strings.TrimSpace(fmt.Sprint(p["firstName"])) + ln := strings.TrimSpace(fmt.Sprint(p["lastName"])) + dn := strings.TrimSpace(fmt.Sprint(p["displayName"])) + org := orgHint + if org == "" { + if co, ok := p["company"].(map[string]any); ok { + org = strings.TrimSpace(fmt.Sprint(co["displayName"])) + } + } + emails := contactEmailsFromMap(p) + for _, row := range onlyoffice.ContactInfoRows(p) { + if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) == "email" { + if data := strings.TrimSpace(fmt.Sprint(row["data"])); data != "" { + emails = append(emails, data) + } + } + } + cf, cl := catalog.CleanPersonNames(fn, ln, dn, org, emails) + needName := cf != fn || cl != ln + linkID := companyID + if linkID == 0 { + if co, ok := p["company"].(map[string]any); ok { + linkID = int(flexIDFloat(co["id"])) + } + } + if !needName { + skipped++ + continue + } + row := map[string]any{ + "id": id, "from": fn + " / " + ln, "to": cf + " / " + cl, "companyId": linkID, + } + if dryRun { + printObject(row) + fixed++ + continue + } + if _, err := c.UpdatePerson(ctx, id, cf, cl, linkID, "", ""); err != nil { + return fmt.Errorf("update %s: %w", id, err) + } + printObject(row) + fixed++ + } + printObject(map[string]any{"fixed": fixed, "skipped": skipped, "dry_run": dryRun}) + return nil + }, + } + cmd.Flags().IntVar(&companyID, "company-id", 0, "limit to persons of this company") + cmd.Flags().StringVar(&orgHint, "org", "", "org name hint for stripping suffixes") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned renames only") + return cmd +} + +func contactEmailsFromMap(p map[string]any) []string { + var out []string + for _, k := range []string{"email", "primaryEmail"} { + if v := strings.TrimSpace(fmt.Sprint(p[k])); v != "" && v != "" { + out = append(out, v) + } + } + return out +} + func companiesCreateCmd() *cobra.Command { var name, email, website string cmd := &cobra.Command{ diff --git a/cmd/oo/projects.go b/cmd/oo/projects.go index 24ce3f8..fc1a405 100644 --- a/cmd/oo/projects.go +++ b/cmd/oo/projects.go @@ -3,8 +3,10 @@ package main import ( "fmt" "strconv" + "strings" onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/eslider/go-onlyoffice/catalog" "github.com/spf13/cobra" ) @@ -116,17 +118,32 @@ func prjMilestonesCmd() *cobra.Command { func prjCreateCmd() *cobra.Command { var desc, resp string + var country, company string cmd := &cobra.Command{ Use: "create TITLE", Short: "Create a new project", - Args: cobra.ExactArgs(1), + Long: `Create a project. Prefer canonical titles: + + CC | Company | Title + +Examples: + oo projects create "Mapbender" --country DE --company "Stadt Mainz" + oo projects create "DE | Acme | Geo Engineer" + +When --country and --company are set, TITLE is only the third segment and the +full title is composed as "CC | Company | Title".`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { c, err := newOO(cmd) if err != nil { return err } + title := args[0] + if country != "" || company != "" { + title = catalog.FormatProjectTitle(country, company, args[0]) + } p, err := c.CreateProject(onlyoffice.NewProjectRequest{ - Title: args[0], + Title: title, Description: desc, ResponsibleID: resp, }) @@ -142,6 +159,8 @@ func prjCreateCmd() *cobra.Command { } cmd.Flags().StringVar(&desc, "description", "", "project description") cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)") + cmd.Flags().StringVar(&country, "country", "", "country/region code (DE, TF, UA, …)") + cmd.Flags().StringVar(&company, "company", "", "CRM company / engagement org name") return cmd } @@ -160,6 +179,25 @@ func prjUpdateCmd() *cobra.Command { if err != nil { return fmt.Errorf("project id must be integer: %w", err) } + // OO update requires responsibleId; reuse current when not passed. + if resp == "" { + cur, gerr := c.GetProjectByID(cmd.Context(), args[0]) + if gerr != nil { + return gerr + } + if r, ok := cur["responsible"].(map[string]any); ok { + resp = fmt.Sprint(r["id"]) + } + if resp == "" || resp == "" { + resp = strings.TrimSpace(fmt.Sprint(cur["responsibleId"])) + } + if title == "" { + title = strings.TrimSpace(fmt.Sprint(cur["title"])) + } + if desc == "" { + desc = strings.TrimSpace(fmt.Sprint(cur["description"])) + } + } p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{ ID: id, Title: title, @@ -173,6 +211,11 @@ func prjUpdateCmd() *cobra.Command { "id": derefInt(p.ID), "title": p.String(), }) + // Confirm via GET — some OO builds return an empty body on PUT. + got, gerr := c.GetProjectByID(cmd.Context(), args[0]) + if gerr == nil && got != nil { + printObject(map[string]any{"confirmed_title": got["title"], "confirmed_id": got["id"]}) + } return nil }, } diff --git a/crm.go b/crm.go index 54501c5..0e36f4d 100644 --- a/crm.go +++ b/crm.go @@ -144,8 +144,7 @@ func contactEmailValues(p map[string]any) []string { } } for _, row := range ContactInfoRows(p) { - t := strings.ToLower(fmt.Sprint(row["infoType"])) - if t != "email" { + if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" { continue } data := strings.TrimSpace(fmt.Sprint(row["data"])) @@ -188,6 +187,24 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields) } +// UpdatePerson updates first/last name and optional company link on a person. +// companyID == 0 leaves the company association unchanged. +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) + if companyID != 0 { + fields.Set("companyId", strconv.Itoa(companyID)) + } + if jobTitle != "" { + fields.Set("jobTitle", jobTitle) + } + if about != "" { + fields.Set("about", about) + } + return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), fields) +} + // AddContactInfo attaches an email/website/phone/etc. to a contact. func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) { if category == "" { diff --git a/crm_dedupe_plan.go b/crm_dedupe_plan.go index 46a1b46..a6abee2 100644 --- a/crm_dedupe_plan.go +++ b/crm_dedupe_plan.go @@ -227,6 +227,11 @@ func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[st continue } cid := flexInt(row["companyId"]) + if cid == 0 { + if co, ok := row["company"].(map[string]any); ok { + cid = flexInt(co["id"]) + } + } if cid == 0 { continue } diff --git a/crm_normalize.go b/crm_normalize.go index 7b2dcce..123e168 100644 --- a/crm_normalize.go +++ b/crm_normalize.go @@ -92,8 +92,53 @@ func FixDealTitle(s string) string { } // ContactInfoKey groups contact info rows by type and normalized value. +// ContactInfoKey builds a dedupe key for a contact-info row. func ContactInfoKey(infoType, value string) string { - return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value)) + return NormalizeContactInfoType(infoType) + "|" + strings.ToLower(strings.TrimSpace(value)) +} + +// NormalizeContactInfoType maps OO numeric infoType codes and string labels to +// a canonical lowercase label (email, phone, …). +func NormalizeContactInfoType(infoType string) string { + t := strings.ToLower(strings.TrimSpace(infoType)) + switch t { + case "1", "email": + return "email" + case "2", "phone": + return "phone" + case "3", "website": + return "website" + case "4", "skype": + return "skype" + case "5", "twitter": + return "twitter" + case "6", "linkedin": + return "linkedin" + case "7", "facebook": + return "facebook" + case "8", "address": + return "address" + case "9", "livejournal": + return "livejournal" + case "10", "myspace": + return "myspace" + case "11", "gmail": + return "gmail" + case "12", "blogger": + return "blogger" + case "13", "yahoo": + return "yahoo" + case "14", "msn": + return "msn" + case "15", "icq": + return "icq" + case "16", "jabber": + return "jabber" + case "17", "aim": + return "aim" + default: + return t + } } // MemberDisplayKey normalizes a member displayName for duplicate detection. diff --git a/projects.go b/projects.go index 8e1b0cf..d9d4a1c 100644 --- a/projects.go +++ b/projects.go @@ -193,16 +193,26 @@ func (c *Client) DeleteProject(id int) (*Project, error) { } // UpdateProject updates project fields. +// OnlyOffice requires responsibleId on PUT; callers should set ResponsibleID +// (the CLI fills it from the current project when omitted). func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) { - p := &Project{} - return p, c.Query(Request{ + p := new(Project) + body := map[string]any{ + "title": req.Title, + "description": req.Description, + "responsibleId": req.ResponsibleID, + } + err := c.Query(Request{ Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID), Method: "PUT", - Body: req, - }, - &struct { - Response *Project `json:"response"` - }{p}) + Body: body, + }, &struct { + Response *Project `json:"response"` + }{Response: p}) + if err != nil { + return p, err + } + return p, nil } // UpdateProjectStatus sets project lifecycle status (open, paused, closed).