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
+6 -1
View File
@@ -1,4 +1,4 @@
# OnlyOffice Workspace credentials. Used by cmd/oo-cli (the library itself # OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
# does not load .env files — only its CLI does, via godotenv). # does not load .env files — only its CLI does, via godotenv).
# #
# Primary names: # Primary names:
@@ -10,6 +10,11 @@ ONLYOFFICE_PASS=
# ONLYOFFICE_HOST= # ONLYOFFICE_HOST=
# ONLYOFFICE_NAME= # ONLYOFFICE_NAME=
# ONLYOFFICE_PASSWORD= # ONLYOFFICE_PASSWORD=
#
# produktor.io operator aliases (CLI-only):
# OO_URL=
# OO_USER=
# OO_PASS=
# Optional defaults for calendar/project helpers: # Optional defaults for calendar/project helpers:
ONLYOFFICE_CALENDAR_ID=1 ONLYOFFICE_CALENDAR_ID=1
+28
View File
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added — library
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
merge duplicate companies/persons/deals by normalized name; remove duplicate
contact-info rows and opportunity members (including same `displayName` with
different ids); fix malformed deal titles (` @ Company` → `Company`).
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
dedupe smoke tests.
### Added — CLI
- `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
### Changed — CLI
- `oo applications sync` — empty position uses company-only deal title; skips
duplicate opportunity members; paginated find-or-create lookups.
- `oo` now loads OnlyOffice credentials only from `.env` in the current
working directory.
- Produktor.io shorthand env names are accepted by the CLI dotenv loader:
`OO_URL` → `ONLYOFFICE_URL`, `OO_USER` → `ONLYOFFICE_USER`, and
`OO_PASS` → `ONLYOFFICE_PASS`. Canonical `ONLYOFFICE_*` values still win
when already set in the process environment.
## [0.6.0] - 2026-04-24 ## [0.6.0] - 2026-04-24
### Added — library ### Added — library
+14 -4
View File
@@ -580,14 +580,23 @@ oo tasks files detach 208 12345
| `contacts` | `list`, `get`, `delete`, `info-add` | | `contacts` | `list`, `get`, `delete`, `info-add` |
| `persons` | `list` (filtered), `create`, `delete` | | `persons` | `list` (filtered), `create`, `delete` |
| `companies` | `list` (filtered), `create`, `delete` | | `companies` | `list` (filtered), `create`, `delete` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add` | | `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
| `persons` | `list`, `create`, `delete`, `dedupe` |
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
| `crm` | `cleanup` |
| `cases` | `list`, `create`, `delete`, `member-add` | | `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` | | `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` | | `applications` | `sync` |
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library The CLI reads only `.env` from the current working directory (godotenv is a
itself never loads dotfiles). Run `oo --help` or `oo <subject> --help` for CLI-only concern — the library itself never loads dotfiles).
the full command reference.
Canonical `ONLYOFFICE_*` variables win over aliases. For produktor.io operator
files, `OO_URL` / `OO_USER` / `OO_PASS` are accepted as CLI-only aliases for
`ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
Run `oo --help` or `oo <subject> --help` for the full command reference.
> **0.5.0 migration note:** the command tree was flattened per-subject. Old > **0.5.0 migration note:** the command tree was flattened per-subject. Old
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, > flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`,
@@ -604,6 +613,7 @@ the full command reference.
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password | | `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) | | `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) | | `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
## Examples ## Examples
+19 -14
View File
@@ -254,10 +254,7 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v
deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05") deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
const stageInitial = 1 const stageInitial = 1
for _, app := range apps { for _, app := range apps {
dealTitle := app.Position dealTitle := onlyoffice.DealTitleForApplication(app.Position, app.Company)
if app.Company != "" {
dealTitle = app.Position + " @ " + app.Company
}
fmt.Println(strings.Repeat("─", 60)) fmt.Println(strings.Repeat("─", 60))
fmt.Println(" ", app.Folder) fmt.Println(" ", app.Folder)
fmt.Println(" ", dealTitle) fmt.Println(" ", dealTitle)
@@ -344,10 +341,16 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v
st.Deals++ st.Deals++
if !dryRun && dealID != 0 { if !dryRun && dealID != 0 {
if companyID != 0 { if companyID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID)) cid := strconv.Itoa(companyID)
if ok, _ := client.IsOpportunityMember(ctx, strconv.Itoa(dealID), cid); !ok {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), cid)
}
} }
if personID != 0 { if personID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID)) pid := strconv.Itoa(personID)
if ok, _ := client.IsOpportunityMember(ctx, strconv.Itoa(dealID), pid); !ok {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), pid)
}
} }
} }
if app.Summary != "" { if app.Summary != "" {
@@ -395,18 +398,20 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v
} }
func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} { func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} {
deals, total, _ := c.ListOpportunities(ctx, 100, 0) needle := strings.TrimSpace(title)
const page = 100
for start := 0; ; start += page {
deals, total, err := c.ListOpportunities(ctx, page, start)
if err != nil {
return nil
}
for _, d := range deals { for _, d := range deals {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { if strings.TrimSpace(fmt.Sprint(d["title"])) == needle {
return d return d
} }
} }
if total > 100 { if start+page >= total || len(deals) == 0 {
deals2, _, _ := c.ListOpportunities(ctx, 100, 100) break
for _, d := range deals2 {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
return d
}
} }
} }
return nil return nil
+30
View File
@@ -0,0 +1,30 @@
package applications
import (
"testing"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func TestDealTitleForApplication(t *testing.T) {
if got := onlyoffice.DealTitleForApplication("", "711media"); got != "711media" {
t.Fatalf("got %q", got)
}
if got := onlyoffice.DealTitleForApplication("Dev", "Acme"); got != "Dev @ Acme" {
t.Fatalf("got %q", got)
}
}
func TestHasContactInfoHelper(t *testing.T) {
contact := map[string]any{
"commonData": []any{
map[string]any{"infoType": "Email", "data": "a@b.com"},
},
}
if !onlyoffice.HasContactInfo(contact, "Email", "a@b.com") {
t.Fatal("expected match")
}
if onlyoffice.HasContactInfo(contact, "Email", "other@b.com") {
t.Fatal("unexpected match")
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestRootRegistersSubjects(t *testing.T) {
want := []string{
"calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies",
"opportunities", "cases", "crm-tasks", "applications", "crm",
}
got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() {
got[c.Name()] = true
}
for _, name := range want {
if !got[name] {
t.Fatalf("missing root subcommand %q; have %v", name, rootCmd.Commands())
}
}
}
func TestRootHelpListsSubjects(t *testing.T) {
out := &bytes.Buffer{}
rootCmd.SetOut(out)
rootCmd.SetErr(&bytes.Buffer{})
rootCmd.SetArgs([]string{"--help"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
if err := rootCmd.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet)
}
}
}
func TestProjectsAlias(t *testing.T) {
cmd, _, err := rootCmd.Find([]string{"prj"})
if err != nil {
t.Fatal(err)
}
if cmd.Name() != "projects" {
t.Fatalf("prj alias resolved to %q", cmd.Name())
}
}
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
errBuf := &bytes.Buffer{}
rootCmd.SetErr(errBuf)
rootCmd.SetOut(&bytes.Buffer{})
rootCmd.SetArgs([]string{"users", "list"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
err = rootCmd.Execute()
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
+23 -3
View File
@@ -32,13 +32,13 @@ func init() {
// execute runs the root command. Exported only to main.go in the same package. // execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() } func execute() error { return rootCmd.Execute() }
// newOO loads env (incl. .env in CWD) and returns an authenticated client. // newOO loads env (only .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles. // godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) { func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
_ = godotenv.Load() loadOOEnv()
creds := onlyoffice.GetEnvironmentCredentials() creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" { if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)") return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
} }
c := onlyoffice.NewClient(creds) c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults()) c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
@@ -48,6 +48,26 @@ func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
return c, nil return c, nil
} }
func loadOOEnv() {
_ = godotenv.Load(".env")
applyOOEnvAliases()
}
func applyOOEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
// printJSON dumps any value as indented JSON. // printJSON dumps any value as indented JSON.
func printJSON(v any) { func printJSON(v any) {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestLoadOOEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.produktor.io\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
loadOOEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.produktor.io" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestApplyOOEnvAliasesDoesNotOverrideCanonical(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
applyOOEnvAliases()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestFmtCell(t *testing.T) {
tests := []struct {
in any
want string
}{
{nil, ""},
{"hello", "hello"},
{true, "true"},
{false, "false"},
{float64(42), "42"},
{float64(1.5), "1.5"},
{int64(7), "7"},
{map[string]any{"a": 1}, `{"a":1}`},
}
for _, tc := range tests {
if got := fmtCell(tc.in); got != tc.want {
t.Fatalf("fmtCell(%#v)=%q want %q", tc.in, got, tc.want)
}
}
long := strings.Repeat("x", 100)
got := fmtCell(long)
if !strings.HasSuffix(got, "…") || len(got) > 82 {
t.Fatalf("fmtCell(long)=%q len=%d", got, len(got))
}
}
func TestTruncate(t *testing.T) {
if got := truncate("a\nb", 10); got != "a b" {
t.Fatalf("truncate newline=%q", got)
}
got := truncate(strings.Repeat("z", 20), 10)
if !strings.HasSuffix(got, "…") || len(got) >= 20 {
t.Fatalf("truncate=%q len=%d", got, len(got))
}
}
func TestSortedKeys(t *testing.T) {
got := sortedKeys(map[string]any{"c": 1, "a": 2, "b": 3})
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortedKeys=%v want %v", got, want)
}
}
}
func TestFlexIDFloat(t *testing.T) {
if got := flexIDFloat(float64(12)); got != 12 {
t.Fatalf("float64=%v", got)
}
if got := flexIDFloat(7); got != 7 {
t.Fatalf("int=%v", got)
}
if got := flexIDFloat("3.5"); got != 3.5 {
t.Fatalf("string=%v", got)
}
}
func TestIdString(t *testing.T) {
m := map[string]any{"id": float64(99), "s": "x", "i": 5}
if got := idString(m, "id"); got != "99" {
t.Fatalf("float id=%q", got)
}
if got := idString(m, "s"); got != "x" {
t.Fatalf("string id=%q", got)
}
if got := idString(m, "i"); got != "5" {
t.Fatalf("int id=%q", got)
}
if got := idString(m, "missing"); got != "" {
t.Fatalf("missing=%q", got)
}
}
func TestPrintTableJSON(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "json"
out := captureStdout(t, func() {
printTable([]string{"id", "name"}, []map[string]any{{"id": 1, "name": "alpha"}})
})
if !strings.Contains(out, `"id": 1`) || !strings.Contains(out, `"name": "alpha"`) {
t.Fatalf("json output=%q", out)
}
}
func TestPrintTableEmpty(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() { printTable([]string{"id"}, nil) })
if strings.TrimSpace(out) != "(empty)" {
t.Fatalf("empty table=%q", out)
}
}
func TestPrintObjectTable(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() {
printObject(map[string]any{"id": float64(1), "title": "Demo"})
})
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "Demo") {
t.Fatalf("table object=%q", out)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = old
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
_ = r.Close()
return buf.String()
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+65
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -39,16 +40,20 @@ func init() {
contactsCmd.AddCommand(contactsGetCmd()) contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd()) contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsInfoAddCmd()) contactsCmd.AddCommand(contactsInfoAddCmd())
contactsCmd.AddCommand(contactsDedupeInfoCmd())
only := true only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd()) personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(contactsDeleteCmd()) personsCmd.AddCommand(contactsDeleteCmd())
personsCmd.AddCommand(personsDedupeCmd())
onlyCo := false onlyCo := false
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
companiesCmd.AddCommand(companiesCreateCmd()) companiesCmd.AddCommand(companiesCreateCmd())
companiesCmd.AddCommand(contactsDeleteCmd()) companiesCmd.AddCommand(contactsDeleteCmd())
companiesCmd.AddCommand(companiesDedupeCmd())
companiesCmd.AddCommand(companiesDedupePersonsCmd())
} }
// contactsListCmd returns a `list` subcommand. // contactsListCmd returns a `list` subcommand.
@@ -246,3 +251,63 @@ func companiesCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)") cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
return cmd return cmd
} }
func companiesDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate companies by normalized name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanies(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func companiesDedupePersonsCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-persons",
Short: "Merge duplicate persons under each company",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanyPersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func personsDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate persons by normalized first+last name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupePersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func contactsDedupeInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-info",
Short: "Remove duplicate contact info rows (email, phone, …)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeContactInfo(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var crmCmd = &cobra.Command{
Use: "crm",
Short: "CRM maintenance (dedupe, cleanup)",
}
func init() {
rootCmd.AddCommand(crmCmd)
crmCmd.AddCommand(crmCleanupCmd())
}
func crmCleanupCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Run all CRM dedupe passes (companies, persons, associations, titles)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := onlyoffice.CleanupCRM(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group deals by position only (strip ' @ Company')")
return cmd
}
func dedupeRunE(fn func(cmd *cobra.Command, c *onlyoffice.Client) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
return fn(cmd, c)
}
}
+5 -4
View File
@@ -6,12 +6,13 @@
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete) // oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach) // oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami) // oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add // oo contacts list | get | delete | info-add | dedupe-info
// oo persons list | create | delete // oo persons list | create | delete | dedupe
// oo companies list | create | delete // oo companies list | create | delete | dedupe | dedupe-persons
// oo opportunities list | get | create | delete | stages | member-add // oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
// oo cases list | create | delete | member-add // oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories // oo crm-tasks list | create | delete | categories
// oo crm cleanup
// oo applications sync // oo applications sync
// //
// Every list supports `--output/-o json|table` (table is the default). // Every list supports `--output/-o json|table` (table is the default).
+52
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -21,6 +22,9 @@ func init() {
opportunitiesCmd.AddCommand(oppDeleteCmd()) opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd()) opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd()) opportunitiesCmd.AddCommand(oppMemberAddCmd())
opportunitiesCmd.AddCommand(oppDedupeCmd())
opportunitiesCmd.AddCommand(oppDedupeMembersCmd())
opportunitiesCmd.AddCommand(oppFixTitlesCmd())
} }
func oppListCmd() *cobra.Command { func oppListCmd() *cobra.Command {
@@ -175,3 +179,51 @@ func oppMemberAddCmd() *cobra.Command {
}, },
} }
} }
func oppDedupeCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate opportunities by title",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunities(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group by position only")
return cmd
}
func oppDedupeMembersCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-members",
Short: "Remove duplicate contacts on each deal (by id and display name)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunityMembers(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func oppFixTitlesCmd() *cobra.Command {
return &cobra.Command{
Use: "fix-titles",
Short: "Fix malformed deal titles (leading @, spacing)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.FixOpportunityTitles(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+242 -7
View File
@@ -47,36 +47,48 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a
// FindCompany searches for a company contact with an exact (case-insensitive) // FindCompany searches for a company contact with an exact (case-insensitive)
// displayName match. Returns nil when not found. // displayName match. Returns nil when not found.
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) { func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, name) needle := strings.ToLower(strings.TrimSpace(name))
const page = 50
for start := 0; ; start += page {
items, total, err := c.ListContacts(ctx, page, start, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
needle := strings.ToLower(strings.TrimSpace(name))
for _, co := range items { for _, co := range items {
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle { if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
return co, nil return co, nil
} }
} }
if start+page >= total || len(items) == 0 {
break
}
}
return nil, nil return nil, nil
} }
// FindPerson searches for a person by first+last (case-insensitive). // FindPerson searches for a person by first+last (case-insensitive).
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) { func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last) 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 { if err != nil {
return nil, err return nil, err
} }
first = strings.ToLower(first)
last = strings.ToLower(last)
for _, p := range items { for _, p := range items {
if isCompany(p) { if isCompany(p) {
continue continue
} }
if strings.ToLower(fmt.Sprint(p["firstName"])) == first && if strings.ToLower(fmt.Sprint(p["firstName"])) == firstNeedle &&
strings.ToLower(fmt.Sprint(p["lastName"])) == last { strings.ToLower(fmt.Sprint(p["lastName"])) == lastNeedle {
return p, nil return p, nil
} }
} }
if start+page >= total || len(items) == 0 {
break
}
}
return nil, nil 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))) 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. // 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) { func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{} 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))) 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. // 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) { func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{} q := url.Values{}
+272
View File
@@ -0,0 +1,272 @@
package onlyoffice
import (
"context"
"fmt"
"strconv"
)
// DedupeResult summarizes a cleanup pass.
type DedupeResult struct {
Groups int `json:"groups"`
Merged int `json:"merged"`
Deleted int `json:"deleted"`
Renamed int `json:"renamed"`
Removed int `json:"removed"`
Errors []string `json:"errors,omitempty"`
}
func (r *DedupeResult) addErr(err error) {
if err != nil {
r.Errors = append(r.Errors, err.Error())
}
}
type crmDedupeClient interface {
ListAllContacts(ctx context.Context) ([]map[string]any, error)
ListAllOpportunities(ctx context.Context) ([]map[string]any, error)
GetContact(ctx context.Context, contactID string) (map[string]any, error)
GetOpportunity(ctx context.Context, id string) (map[string]any, error)
MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error)
DeleteOpportunity(ctx context.Context, id string) (map[string]any, error)
DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error)
ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error)
AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error)
}
func executeMergePlans(ctx context.Context, client crmDedupeClient, plans []MergePlan, res *DedupeResult) {
for _, plan := range plans {
res.Groups++
for _, sec := range plan.Secondary {
_, err := client.MergeContacts(ctx, strconv.FormatInt(plan.Primary, 10), strconv.FormatInt(sec, 10))
if err != nil {
res.addErr(fmt.Errorf("merge %d into %d: %w", sec, plan.Primary, err))
continue
}
res.Merged++
}
}
}
// DedupeCompanies merges duplicate company contacts by normalized name.
func DedupeCompanies(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
plans := BuildMergePlans(GroupCompaniesByName(items))
executeMergePlans(ctx, client, plans, &res)
return res, nil
}
// DedupePersons merges duplicate person contacts by normalized first+last.
func DedupePersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
plans := BuildMergePlans(GroupPersonsByKey(items))
executeMergePlans(ctx, client, plans, &res)
return res, nil
}
// DedupeCompanyPersons merges same-name persons within each company.
func DedupeCompanyPersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
grouped := GroupCompanyPersons(items)
for companyID, byName := range grouped {
plans := BuildMergePlans(byName)
if len(plans) == 0 {
continue
}
_ = companyID
executeMergePlans(ctx, client, plans, &res)
}
return res, nil
}
// DedupeContactInfo removes duplicate email/phone/etc rows on all contacts.
func DedupeContactInfo(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
for _, row := range items {
cid := strconv.FormatInt(rowID(row), 10)
contact, err := client.GetContact(ctx, cid)
if err != nil {
res.addErr(err)
continue
}
rows := ContactInfoRows(contact)
for _, dataID := range GroupContactInfoRows(rows) {
_, err := client.DeleteContactInfo(ctx, cid, strconv.FormatInt(dataID, 10))
if err != nil {
res.addErr(err)
continue
}
res.Removed++
}
}
return res, nil
}
// DedupeOpportunities merges duplicate deals by title; relinks members first.
func DedupeOpportunities(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
groups := GroupOpportunitiesByTitle(items, ignoreCompanySuffix)
for _, rows := range groups {
if len(rows) < 2 {
continue
}
res.Groups++
ids := make([]int64, len(rows))
for i, row := range rows {
ids[i] = rowID(row)
}
primary := PickCanonicalID(ids)
primaryID := strconv.FormatInt(primary, 10)
for _, row := range rows {
sec := rowID(row)
if sec == primary {
continue
}
secID := strconv.FormatInt(sec, 10)
opp, err := client.GetOpportunity(ctx, secID)
if err != nil {
res.addErr(err)
continue
}
for _, m := range OpportunityMembers(opp) {
mid := strconv.FormatInt(rowID(m), 10)
_, _ = client.AddOpportunityMember(ctx, primaryID, mid)
}
if _, err := client.DeleteOpportunity(ctx, secID); err != nil {
res.addErr(err)
continue
}
res.Deleted++
}
}
return res, nil
}
// DedupeOpportunityMembers removes duplicate members on each deal (by id and displayName).
func DedupeOpportunityMembers(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
for _, row := range items {
oppID := strconv.FormatInt(rowID(row), 10)
opp, err := client.GetOpportunity(ctx, oppID)
if err != nil {
res.addErr(err)
continue
}
members := OpportunityMembers(opp)
if len(members) == 0 {
continue
}
var ids []int64
for _, m := range members {
ids = append(ids, rowID(m))
}
remove := append(DedupeMemberIDs(ids), DedupeMembersByDisplayName(members)...)
seen := make(map[int64]bool)
for _, contactID := range remove {
if contactID == 0 || seen[contactID] {
continue
}
seen[contactID] = true
if _, err := client.RemoveOpportunityMember(ctx, oppID, strconv.FormatInt(contactID, 10)); err != nil {
res.addErr(err)
continue
}
res.Removed++
}
}
return res, nil
}
// FixOpportunityTitles renames deals with malformed titles.
func FixOpportunityTitles(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
for _, row := range items {
old := fmt.Sprint(row["title"])
newTitle := FixDealTitle(old)
if newTitle == old || newTitle == "" {
continue
}
oppID := strconv.FormatInt(rowID(row), 10)
if _, err := client.UpdateOpportunityTitle(ctx, oppID, newTitle); err != nil {
res.addErr(err)
continue
}
res.Renamed++
}
return res, nil
}
// CleanupCRM runs all dedupe passes in dependency order.
func CleanupCRM(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (map[string]DedupeResult, error) {
out := make(map[string]DedupeResult)
steps := []struct {
name string
fn func(context.Context, crmDedupeClient) (DedupeResult, error)
}{
{"companies", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeCompanies(ctx, c)
}},
{"persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupePersons(ctx, c)
}},
{"company-persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeCompanyPersons(ctx, c)
}},
{"contact-info", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeContactInfo(ctx, c)
}},
{"opportunity-members", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeOpportunityMembers(ctx, c)
}},
}
for _, step := range steps {
r, err := step.fn(ctx, client)
out[step.name] = r
if err != nil {
return out, err
}
}
r, err := DedupeOpportunities(ctx, client, ignoreCompanySuffix)
out["opportunities"] = r
if err != nil {
return out, err
}
r, err = FixOpportunityTitles(ctx, client)
out["fix-titles"] = r
if err != nil {
return out, err
}
return out, nil
}
var _ crmDedupeClient = (*Client)(nil)
+232
View File
@@ -0,0 +1,232 @@
package onlyoffice
import (
"fmt"
"sort"
"strings"
)
// PickCanonicalID returns the lowest non-zero id, or 0 when empty.
func PickCanonicalID(ids []int64) int64 {
if len(ids) == 0 {
return 0
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids[0]
}
func rowID(m map[string]any) int64 {
return flexInt(m["id"])
}
func rowDisplayName(m map[string]any) string {
if v := strings.TrimSpace(fmt.Sprint(m["displayName"])); v != "" && v != "<nil>" {
return v
}
if v := strings.TrimSpace(fmt.Sprint(m["companyName"])); v != "" && v != "<nil>" {
return v
}
first := strings.TrimSpace(fmt.Sprint(m["firstName"]))
last := strings.TrimSpace(fmt.Sprint(m["lastName"]))
return strings.TrimSpace(first + " " + last)
}
// GroupCompaniesByName buckets company contacts by normalized display name.
func GroupCompaniesByName(items []map[string]any) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
if !isCompany(row) {
continue
}
key := NormalizeCompanyName(rowDisplayName(row))
if key == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// GroupPersonsByKey buckets person contacts by normalized first+last.
func GroupPersonsByKey(items []map[string]any) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
if isCompany(row) {
continue
}
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
if strings.TrimSpace(key) == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// DealTitleKey returns the grouping key for an opportunity title.
func DealTitleKey(title string, ignoreCompanySuffix bool) string {
if ignoreCompanySuffix {
return NormalizeOpportunityTitle(StripCompanySuffix(title))
}
return NormalizeOpportunityTitle(title)
}
// GroupOpportunitiesByTitle buckets deals by title key.
func GroupOpportunitiesByTitle(items []map[string]any, ignoreCompanySuffix bool) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
title := fmt.Sprint(row["title"])
key := DealTitleKey(title, ignoreCompanySuffix)
if key == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// MergePlan lists secondary ids to merge into primary.
type MergePlan struct {
Primary int64
Secondary []int64
}
// BuildMergePlans creates merge plans from duplicate groups (lowest id wins).
func BuildMergePlans(groups map[string][]map[string]any) []MergePlan {
var plans []MergePlan
for _, rows := range groups {
if len(rows) < 2 {
continue
}
ids := make([]int64, len(rows))
for i, row := range rows {
ids[i] = rowID(row)
}
primary := PickCanonicalID(ids)
var secondary []int64
for _, id := range ids {
if id != primary {
secondary = append(secondary, id)
}
}
plans = append(plans, MergePlan{Primary: primary, Secondary: secondary})
}
return plans
}
// DedupeMemberIDs returns duplicate member ids to remove (keep first occurrence).
func DedupeMemberIDs(ids []int64) []int64 {
seen := make(map[int64]int)
var remove []int64
for _, id := range ids {
seen[id]++
if seen[id] > 1 {
remove = append(remove, id)
}
}
return remove
}
// DedupeMembersByDisplayName returns member ids to remove when the same
// displayName appears with different ids (keep lowest id per name).
func DedupeMembersByDisplayName(members []map[string]any) []int64 {
type slot struct {
id int64
}
byName := make(map[string][]slot)
for _, m := range members {
key := MemberDisplayKey(rowDisplayName(m))
if key == "" {
continue
}
byName[key] = append(byName[key], slot{id: rowID(m)})
}
var remove []int64
for _, slots := range byName {
if len(slots) < 2 {
continue
}
ids := make([]int64, len(slots))
for i, s := range slots {
ids[i] = s.id
}
keep := PickCanonicalID(ids)
for _, s := range slots {
if s.id != keep {
remove = append(remove, s.id)
}
}
}
return remove
}
// GroupContactInfoRows returns info row ids to delete (duplicates by type+value).
func GroupContactInfoRows(rows []map[string]any) []int64 {
type rowSlot struct {
id int64
isPrimary bool
}
byKey := make(map[string][]rowSlot)
for _, row := range rows {
infoType := fmt.Sprint(row["infoType"])
value := fmt.Sprint(row["data"])
if value == "" || value == "<nil>" {
value = fmt.Sprint(row["value"])
}
key := ContactInfoKey(infoType, value)
if key == "|" || value == "" || value == "<nil>" {
continue
}
primary, _ := row["isPrimary"].(bool)
byKey[key] = append(byKey[key], rowSlot{id: rowID(row), isPrimary: primary})
}
var remove []int64
for _, slots := range byKey {
if len(slots) < 2 {
continue
}
keep := int64(0)
for _, s := range slots {
if s.isPrimary {
keep = s.id
break
}
}
if keep == 0 {
ids := make([]int64, len(slots))
for i, s := range slots {
ids[i] = s.id
}
keep = PickCanonicalID(ids)
}
for _, s := range slots {
if s.id != keep {
remove = append(remove, s.id)
}
}
}
return remove
}
// GroupCompanyPersons groups persons by company id and person name key.
func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[string]any {
out := make(map[int64]map[string][]map[string]any)
for _, row := range persons {
if isCompany(row) {
continue
}
cid := flexInt(row["companyId"])
if cid == 0 {
continue
}
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
if strings.TrimSpace(key) == "" {
continue
}
if out[cid] == nil {
out[cid] = make(map[string][]map[string]any)
}
out[cid][key] = append(out[cid][key], row)
}
return out
}
+71
View File
@@ -0,0 +1,71 @@
package onlyoffice
import (
"reflect"
"testing"
)
func TestPickCanonicalID(t *testing.T) {
if got := PickCanonicalID([]int64{908, 857, 900}); got != 857 {
t.Fatalf("got %d", got)
}
if got := PickCanonicalID(nil); got != 0 {
t.Fatalf("got %d", got)
}
}
func TestGroupCompaniesByName(t *testing.T) {
items := []map[string]any{
{"id": float64(857), "displayName": "711media", "isCompany": true},
{"id": float64(908), "displayName": "711media", "isCompany": true},
{"id": float64(1), "displayName": "Acme", "isCompany": true},
}
groups := GroupCompaniesByName(items)
if len(groups) != 2 {
t.Fatalf("groups: %d", len(groups))
}
key := NormalizeCompanyName("711media")
if len(groups[key]) != 2 {
t.Fatalf("711media group: %d", len(groups[key]))
}
}
func TestDedupeMembersByDisplayName(t *testing.T) {
members := []map[string]any{
{"id": float64(857), "displayName": "711media"},
{"id": float64(908), "displayName": "711media"},
{"id": float64(10), "displayName": "Acme"},
}
remove := DedupeMembersByDisplayName(members)
want := []int64{908}
if !reflect.DeepEqual(remove, want) {
t.Fatalf("remove %v want %v", remove, want)
}
}
func TestDedupeMemberIDs(t *testing.T) {
remove := DedupeMemberIDs([]int64{1, 2, 2, 3, 1})
if !reflect.DeepEqual(remove, []int64{2, 1}) {
t.Fatalf("got %v", remove)
}
}
func TestGroupContactInfoRows(t *testing.T) {
rows := []map[string]any{
{"id": float64(1), "infoType": "Email", "data": "a@b.com", "isPrimary": false},
{"id": float64(2), "infoType": "Email", "data": "a@b.com", "isPrimary": true},
}
remove := GroupContactInfoRows(rows)
if len(remove) != 1 || remove[0] != 1 {
t.Fatalf("remove %v", remove)
}
}
func TestDealTitleKey(t *testing.T) {
if got := DealTitleKey("Dev @ Acme", false); got != NormalizeOpportunityTitle("Dev @ Acme") {
t.Fatalf("got %q", got)
}
if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") {
t.Fatalf("got %q", got)
}
}
+133
View File
@@ -0,0 +1,133 @@
package onlyoffice
import (
"context"
"reflect"
"testing"
)
type fakeDedupeClient struct {
contacts []map[string]any
opportunities []map[string]any
contactByID map[string]map[string]any
oppByID map[string]map[string]any
merged [][2]string
deletedOpp []string
deletedInfo [][2]string
removedMember [][2]string
renamed map[string]string
}
func (f *fakeDedupeClient) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
return f.contacts, nil
}
func (f *fakeDedupeClient) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
return f.opportunities, nil
}
func (f *fakeDedupeClient) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
return f.contactByID[contactID], nil
}
func (f *fakeDedupeClient) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
return f.oppByID[id], nil
}
func (f *fakeDedupeClient) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
f.merged = append(f.merged, [2]string{primaryID, secondaryID})
return map[string]any{"id": primaryID}, nil
}
func (f *fakeDedupeClient) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
f.deletedOpp = append(f.deletedOpp, id)
return map[string]any{}, nil
}
func (f *fakeDedupeClient) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
f.deletedInfo = append(f.deletedInfo, [2]string{contactID, dataID})
return map[string]any{}, nil
}
func (f *fakeDedupeClient) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeDedupeClient) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
return map[string]any{}, nil
}
func (f *fakeDedupeClient) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
f.removedMember = append(f.removedMember, [2]string{oppID, contactID})
return map[string]any{}, nil
}
func (f *fakeDedupeClient) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
if f.renamed == nil {
f.renamed = make(map[string]string)
}
f.renamed[id] = newTitle
return map[string]any{"title": newTitle}, nil
}
func TestDedupeCompaniesOrchestration(t *testing.T) {
f := &fakeDedupeClient{
contacts: []map[string]any{
{"id": float64(857), "displayName": "711media", "isCompany": true},
{"id": float64(908), "displayName": "711media", "isCompany": true},
},
}
res, err := DedupeCompanies(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Merged != 1 || res.Groups != 1 {
t.Fatalf("res %+v", res)
}
want := [][2]string{{"857", "908"}}
if !reflect.DeepEqual(f.merged, want) {
t.Fatalf("merged %v", f.merged)
}
}
func TestDedupeOpportunityMembersOrchestration(t *testing.T) {
f := &fakeDedupeClient{
opportunities: []map[string]any{{"id": float64(231), "title": " @ 711media"}},
oppByID: map[string]map[string]any{
"231": {
"id": float64(231),
"members": []any{
map[string]any{"id": float64(857), "displayName": "711media"},
map[string]any{"id": float64(908), "displayName": "711media"},
},
},
},
}
res, err := DedupeOpportunityMembers(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Removed != 1 {
t.Fatalf("res %+v", res)
}
if len(f.removedMember) != 1 || f.removedMember[0][1] != "908" {
t.Fatalf("removed %v", f.removedMember)
}
}
func TestFixOpportunityTitlesOrchestration(t *testing.T) {
f := &fakeDedupeClient{
opportunities: []map[string]any{{"id": float64(231), "title": " @ 711media"}},
oppByID: map[string]map[string]any{"231": {"id": float64(231), "title": " @ 711media"}},
}
res, err := FixOpportunityTitles(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Renamed != 1 {
t.Fatalf("res %+v", res)
}
if f.renamed["231"] != "711media" {
t.Fatalf("renamed %v", f.renamed)
}
}
+129
View File
@@ -0,0 +1,129 @@
//go:build integration
package onlyoffice
import (
"context"
"fmt"
"strconv"
"strings"
"testing"
"time"
)
const testCRMPrefix = "go-onlyoffice-test-"
func TestIntegrationMergeContacts(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
name := testCRMPrefix + "merge-" + suffix
a, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany a: %v", err)
}
b, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany b: %v", err)
}
aID := strconv.FormatInt(flexInt(a["id"]), 10)
bID := strconv.FormatInt(flexInt(b["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteContact(ctx, aID) })
if _, err := c.MergeContacts(ctx, aID, bID); err != nil {
t.Fatalf("MergeContacts: %v", err)
}
got, err := c.GetContact(ctx, aID)
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if got == nil {
t.Fatal("primary contact missing after merge")
}
}
func TestIntegrationUpdateOpportunityTitle(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
stages, err := c.ListDealStages(ctx)
if err != nil || len(stages) == 0 {
t.Fatalf("ListDealStages: %v", err)
}
stageID := int(flexInt(stages[0]["id"]))
title := testCRMPrefix + "title-" + strconv.FormatInt(time.Now().UnixNano(), 10)
opp, err := c.CreateOpportunity(ctx, title, stageID, "", "EUR", "", 0)
if err != nil {
t.Fatalf("CreateOpportunity: %v", err)
}
id := strconv.FormatInt(flexInt(opp["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
newTitle := title + "-renamed"
if _, err := c.UpdateOpportunityTitle(ctx, id, newTitle); err != nil {
t.Fatalf("UpdateOpportunityTitle: %v", err)
}
got, err := c.GetOpportunity(ctx, id)
if err != nil {
t.Fatalf("GetOpportunity: %v", err)
}
if strings.TrimSpace(fmt.Sprint(got["title"])) != newTitle {
t.Fatalf("title %q want %q", got["title"], newTitle)
}
}
func TestIntegrationDedupeCompaniesSmoke(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
name := testCRMPrefix + "dedupe-" + suffix
co1, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany: %v", err)
}
co2, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany: %v", err)
}
t.Cleanup(func() {
id1 := strconv.FormatInt(flexInt(co1["id"]), 10)
_, _ = c.DeleteContact(ctx, id1)
})
res, err := DedupeCompanies(ctx, c)
if err != nil {
t.Fatalf("DedupeCompanies: %v", err)
}
if res.Merged < 1 {
t.Fatalf("expected merge, got %+v", res)
}
_ = co2
}
func TestIntegrationFixOpportunityTitleDeal231Pattern(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
stages, err := c.ListDealStages(ctx)
if err != nil || len(stages) == 0 {
t.Fatalf("ListDealStages: %v", err)
}
stageID := int(flexInt(stages[0]["id"]))
opp, err := c.CreateOpportunity(ctx, " @ 711media-test", stageID, "", "EUR", "", 0)
if err != nil {
t.Fatalf("CreateOpportunity: %v", err)
}
id := strconv.FormatInt(flexInt(opp["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
fixed := FixDealTitle(" @ 711media-test")
if _, err := c.UpdateOpportunityTitle(ctx, id, fixed); err != nil {
t.Fatalf("UpdateOpportunityTitle: %v", err)
}
got, err := c.GetOpportunity(ctx, id)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(got["title"]) != "711media-test" {
t.Fatalf("title %q", got["title"])
}
}
+84
View File
@@ -0,0 +1,84 @@
package onlyoffice
import (
"regexp"
"strings"
)
var multiSpace = regexp.MustCompile(`\s+`)
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
func NormalizeCompanyName(s string) string {
return collapseKey(s)
}
// NormalizePersonKey builds a grouping key from first and last name.
func NormalizePersonKey(first, last string) string {
return collapseKey(strings.TrimSpace(first) + " " + strings.TrimSpace(last))
}
// NormalizeOpportunityTitle lowercases and trims a deal title for exact dedupe.
func NormalizeOpportunityTitle(s string) string {
return collapseKey(s)
}
// StripCompanySuffix removes a trailing " @ Company" segment when present.
func StripCompanySuffix(title string) string {
title = strings.TrimSpace(title)
if i := strings.LastIndex(title, " @ "); i >= 0 {
return strings.TrimSpace(title[:i])
}
return title
}
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
// empty-position titles like " @ 711media" to "711media".
func FixDealTitle(s string) string {
s = strings.TrimSpace(s)
for strings.HasPrefix(s, "@") {
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
}
if s == "" {
return ""
}
if i := strings.Index(s, "@"); i >= 0 {
left := strings.TrimSpace(s[:i])
right := strings.TrimSpace(s[i+1:])
if left == "" && right != "" {
return right
}
if left != "" && right != "" {
return left + " @ " + right
}
}
return s
}
// ContactInfoKey groups contact info rows by type and normalized value.
func ContactInfoKey(infoType, value string) string {
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
}
// MemberDisplayKey normalizes a member displayName for duplicate detection.
func MemberDisplayKey(displayName string) string {
return NormalizeCompanyName(displayName)
}
func collapseKey(s string) string {
s = strings.TrimSpace(s)
s = multiSpace.ReplaceAllString(s, " ")
return strings.ToLower(s)
}
// DealTitleForApplication builds the sync deal title from position and company.
func DealTitleForApplication(position, company string) string {
position = strings.TrimSpace(position)
company = strings.TrimSpace(company)
if company == "" {
return position
}
if position == "" {
return company
}
return position + " @ " + company
}
+73
View File
@@ -0,0 +1,73 @@
package onlyoffice
import "testing"
func TestNormalizeCompanyName(t *testing.T) {
tests := []struct {
in, want string
}{
{" 711media ", "711media"},
{"711Media", "711media"},
{"Acme Corp", "acme corp"},
}
for _, tc := range tests {
if got := NormalizeCompanyName(tc.in); got != tc.want {
t.Errorf("NormalizeCompanyName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestNormalizePersonKey(t *testing.T) {
if got := NormalizePersonKey(" Jane ", " Doe "); got != "jane doe" {
t.Fatalf("got %q", got)
}
}
func TestFixDealTitle(t *testing.T) {
tests := []struct {
in, want string
}{
{" @ 711media", "711media"},
{"@ 711media", "711media"},
{"@Acme", "Acme"},
{"Dev@Acme", "Dev @ Acme"},
{"Dev @ Acme", "Dev @ Acme"},
{"Senior Dev @ Acme", "Senior Dev @ Acme"},
{"", ""},
}
for _, tc := range tests {
if got := FixDealTitle(tc.in); got != tc.want {
t.Errorf("FixDealTitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStripCompanySuffix(t *testing.T) {
if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" {
t.Fatalf("got %q", got)
}
if got := StripCompanySuffix("Dev"); got != "Dev" {
t.Fatalf("got %q", got)
}
}
func TestContactInfoKey(t *testing.T) {
if got := ContactInfoKey("Email", " A@B.COM "); got != "email|a@b.com" {
t.Fatalf("got %q", got)
}
}
func TestMemberDisplayKey(t *testing.T) {
if got := MemberDisplayKey(" 711media "); got != "711media" {
t.Fatalf("got %q", got)
}
}
func TestDealTitleForApplication(t *testing.T) {
if got := DealTitleForApplication("", "711media"); got != "711media" {
t.Fatalf("got %q", got)
}
if got := DealTitleForApplication("Dev", "Acme"); got != "Dev @ Acme" {
t.Fatalf("got %q", got)
}
}
+9
View File
@@ -220,6 +220,15 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e
return raw, nil return raw, nil
} }
// putJSONObject issues an authenticated PUT with JSON body and decodes response.
func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
raw, err := c.putJSON(ctx, path, body)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// putJSON issues an authenticated PUT with application/json body. // putJSON issues an authenticated PUT with application/json body.
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader() auth, err := c.authHeader()