feat(crm): dedupe duplicates, fix deal titles, and add cleanup CLI

Merge duplicate companies/persons/deals by name, remove duplicate contact
info and opportunity members, and rename malformed " @ Company" titles.
Applications sync now avoids empty-position titles and duplicate members.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 12:34:14 +01:00
co-authored by Cursor
parent e03fd62200
commit c77519fac5
21 changed files with 1832 additions and 46 deletions
+254 -19
View File
@@ -47,14 +47,20 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a
// FindCompany searches for a company contact with an exact (case-insensitive)
// displayName match. Returns nil when not found.
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, name)
if err != nil {
return nil, err
}
needle := strings.ToLower(strings.TrimSpace(name))
for _, co := range items {
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
return co, nil
const page = 50
for start := 0; ; start += page {
items, total, err := c.ListContacts(ctx, page, start, name)
if err != nil {
return nil, err
}
for _, co := range items {
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
return co, nil
}
}
if start+page >= total || len(items) == 0 {
break
}
}
return nil, nil
@@ -62,19 +68,25 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any,
// FindPerson searches for a person by first+last (case-insensitive).
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last)
if err != nil {
return nil, err
}
first = strings.ToLower(first)
last = strings.ToLower(last)
for _, p := range items {
if isCompany(p) {
continue
firstNeedle := strings.ToLower(strings.TrimSpace(first))
lastNeedle := strings.ToLower(strings.TrimSpace(last))
const page = 50
for start := 0; ; start += page {
items, total, err := c.ListContacts(ctx, page, start, first+" "+last)
if err != nil {
return nil, err
}
if strings.ToLower(fmt.Sprint(p["firstName"])) == first &&
strings.ToLower(fmt.Sprint(p["lastName"])) == last {
return p, nil
for _, p := range items {
if isCompany(p) {
continue
}
if strings.ToLower(fmt.Sprint(p["firstName"])) == firstNeedle &&
strings.ToLower(fmt.Sprint(p["lastName"])) == lastNeedle {
return p, nil
}
}
if start+page >= total || len(items) == 0 {
break
}
}
return nil, nil
@@ -127,6 +139,88 @@ 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)))
}
// ListAllContacts paginates through every CRM contact.
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
const page = 100
var all []map[string]any
for start := 0; ; start += page {
chunk, total, err := c.ListContacts(ctx, page, start, "")
if err != nil {
return nil, err
}
all = append(all, chunk...)
if start+page >= total || len(chunk) == 0 {
break
}
}
return all, nil
}
// MergeContacts merges secondary into primary (secondary is removed).
func (c *Client) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
fields := url.Values{}
fields.Set("fromContactId", secondaryID)
fields.Set("toContactId", primaryID)
out, err := c.putFormObject(ctx, "/api/2.0/crm/contact/merge.json", fields)
if err == nil {
return out, nil
}
// Some instances expect JSON body with alternate field names.
body := map[string]any{
"fromContactId": secondaryID,
"toContactId": primaryID,
}
return c.putJSONObject(ctx, "/api/2.0/crm/contact/merge.json", body)
}
// ListCompanyPersons returns persons linked to a company.
func (c *Client) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s/person.json", url.PathEscape(companyID)))
}
// DeleteContactInfo removes one info row from a contact.
func (c *Client) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data/%s.json", url.PathEscape(contactID), url.PathEscape(dataID)))
}
// ContactInfoRows returns commonData/info rows from a contact map.
func ContactInfoRows(contact map[string]any) []map[string]any {
for _, key := range []string{"commonData", "data", "contactData"} {
if rows, ok := contact[key].([]any); ok {
return mapsFromAnySlice(rows)
}
if rows, ok := contact[key].([]map[string]any); ok {
return rows
}
}
return nil
}
// HasContactInfo reports whether a contact already has the given type+value.
func HasContactInfo(contact map[string]any, infoType, value string) bool {
key := ContactInfoKey(infoType, value)
for _, row := range ContactInfoRows(contact) {
v := fmt.Sprint(row["data"])
if v == "" || v == "<nil>" {
v = fmt.Sprint(row["value"])
}
if ContactInfoKey(fmt.Sprint(row["infoType"]), v) == key {
return true
}
}
return false
}
func mapsFromAnySlice(rows []any) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
if m, ok := row.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// ListOpportunities returns a page of deals/opportunities and the total count.
func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{}
@@ -195,6 +289,147 @@ func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]a
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
}
// ListAllOpportunities paginates through every opportunity.
func (c *Client) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
const page = 100
var all []map[string]any
for start := 0; ; start += page {
chunk, total, err := c.ListOpportunities(ctx, page, start)
if err != nil {
return nil, err
}
all = append(all, chunk...)
if start+page >= total || len(chunk) == 0 {
break
}
}
return all, nil
}
// OpportunityMembers extracts the members slice from a GetOpportunity response.
func OpportunityMembers(opp map[string]any) []map[string]any {
raw, ok := opp["members"].([]any)
if !ok {
if rows, ok := opp["members"].([]map[string]any); ok {
return rows
}
return nil
}
return mapsFromAnySlice(raw)
}
// ListOpportunityMembers returns contacts linked to an opportunity.
func (c *Client) ListOpportunityMembers(ctx context.Context, oppID string) ([]map[string]any, error) {
opp, err := c.GetOpportunity(ctx, oppID)
if err != nil {
return nil, err
}
if members := OpportunityMembers(opp); len(members) > 0 {
return members, nil
}
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact.json", url.PathEscape(oppID)))
}
// RemoveOpportunityMember detaches a contact from an opportunity.
func (c *Client) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)))
}
// IsOpportunityMember reports whether contactID is already on the opportunity.
func (c *Client) IsOpportunityMember(ctx context.Context, oppID, contactID string) (bool, error) {
members, err := c.ListOpportunityMembers(ctx, oppID)
if err != nil {
return false, err
}
want := flexInt(contactID)
for _, m := range members {
if flexInt(m["id"]) == want {
return true, nil
}
}
return false, nil
}
// UpdateOpportunityTitle renames a deal; loads full record and PUTs it back.
func (c *Client) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
opp, err := c.GetOpportunity(ctx, id)
if err != nil {
return nil, err
}
body := opportunityUpdateBody(opp, newTitle)
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
}
func opportunityUpdateBody(opp map[string]any, title string) map[string]any {
body := map[string]any{
"opportunityid": flexInt(opp["id"]),
"title": title,
"description": stringField(opp, "description"),
"isPrivate": boolField(opp, "isPrivate"),
"isNotify": false,
}
if stage, ok := opp["stage"].(map[string]any); ok {
body["stageid"] = flexInt(stage["id"])
}
if resp, ok := opp["responsible"].(map[string]any); ok {
body["responsibleid"] = fmt.Sprint(resp["id"])
}
if cur, ok := opp["bidCurrency"].(map[string]any); ok {
body["bidCurrencyAbbr"] = stringField(cur, "abbreviation")
} else {
body["bidCurrencyAbbr"] = "EUR"
}
body["bidValue"] = floatField(opp, "bidValue")
body["bidType"] = 0
body["perPeriodValue"] = 0
body["successProbability"] = 1
var memberIDs []int64
seen := make(map[int64]bool)
for _, m := range OpportunityMembers(opp) {
id := flexInt(m["id"])
if id == 0 || seen[id] {
continue
}
seen[id] = true
memberIDs = append(memberIDs, id)
}
if len(memberIDs) > 0 {
body["members"] = memberIDs
body["contactid"] = memberIDs[0]
}
if al, ok := opp["accessList"].([]any); ok && len(al) > 0 {
body["accessList"] = al
} else {
body["accessList"] = []any{}
}
return body
}
func stringField(m map[string]any, key string) string {
v := fmt.Sprint(m[key])
if v == "<nil>" {
return ""
}
return v
}
func boolField(m map[string]any, key string) bool {
v, _ := m[key].(bool)
return v
}
func floatField(m map[string]any, key string) float64 {
switch x := m[key].(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
// ListCases returns a page of CRM cases and the total count.
func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{}