From c77519fac5ae9dffaad3f1f0ae722d969e62dde9 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 24 Jun 2026 12:34:14 +0100 Subject: [PATCH] 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 --- .env.example | 7 +- CHANGELOG.md | 28 +++ README.md | 18 +- cmd/oo/applications/applications.go | 35 +-- cmd/oo/applications/applications_test.go | 30 +++ cmd/oo/cli_test.go | 94 ++++++++ cmd/oo/common.go | 26 ++- cmd/oo/common_test.go | 190 ++++++++++++++++ cmd/oo/contacts.go | 65 ++++++ cmd/oo/crm.go | 48 ++++ cmd/oo/main.go | 9 +- cmd/oo/opportunities.go | 52 +++++ crm.go | 273 +++++++++++++++++++++-- crm_dedupe.go | 272 ++++++++++++++++++++++ crm_dedupe_plan.go | 232 +++++++++++++++++++ crm_dedupe_plan_test.go | 71 ++++++ crm_dedupe_test.go | 133 +++++++++++ crm_integration_test.go | 129 +++++++++++ crm_normalize.go | 84 +++++++ crm_normalize_test.go | 73 ++++++ http.go | 9 + 21 files changed, 1832 insertions(+), 46 deletions(-) create mode 100644 cmd/oo/applications/applications_test.go create mode 100644 cmd/oo/cli_test.go create mode 100644 cmd/oo/common_test.go create mode 100644 cmd/oo/crm.go create mode 100644 crm_dedupe.go create mode 100644 crm_dedupe_plan.go create mode 100644 crm_dedupe_plan_test.go create mode 100644 crm_dedupe_test.go create mode 100644 crm_integration_test.go create mode 100644 crm_normalize.go create mode 100644 crm_normalize_test.go diff --git a/.env.example b/.env.example index deaa88c..c835c9b 100644 --- a/.env.example +++ b/.env.example @@ -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). # # Primary names: @@ -10,6 +10,11 @@ ONLYOFFICE_PASS= # ONLYOFFICE_HOST= # ONLYOFFICE_NAME= # ONLYOFFICE_PASSWORD= +# +# produktor.io operator aliases (CLI-only): +# OO_URL= +# OO_USER= +# OO_PASS= # Optional defaults for calendar/project helpers: ONLYOFFICE_CALENDAR_ID=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc2a63..3fb9e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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 ### Added — library diff --git a/README.md b/README.md index baefbdb..c3a4e6c 100644 --- a/README.md +++ b/README.md @@ -580,14 +580,23 @@ oo tasks files detach 208 12345 | `contacts` | `list`, `get`, `delete`, `info-add` | | `persons` | `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` | | `crm-tasks` | `list`, `create`, `delete`, `categories` | | `applications` | `sync` | -The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library -itself never loads dotfiles). Run `oo --help` or `oo --help` for -the full command reference. +The CLI reads only `.env` from the current working directory (godotenv is a +CLI-only concern — the library itself never loads dotfiles). + +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 --help` for the full command reference. > **0.5.0 migration note:** the command tree was flattened per-subject. Old > 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_CALENDAR_ID` | Default calendar id used when omitted (default `1`) | | `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 diff --git a/cmd/oo/applications/applications.go b/cmd/oo/applications/applications.go index a795380..b237c4c 100644 --- a/cmd/oo/applications/applications.go +++ b/cmd/oo/applications/applications.go @@ -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") const stageInitial = 1 for _, app := range apps { - dealTitle := app.Position - if app.Company != "" { - dealTitle = app.Position + " @ " + app.Company - } + dealTitle := onlyoffice.DealTitleForApplication(app.Position, app.Company) fmt.Println(strings.Repeat("─", 60)) fmt.Println(" ", app.Folder) fmt.Println(" ", dealTitle) @@ -344,10 +341,16 @@ func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, v st.Deals++ if !dryRun && dealID != 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 { - _, _ = 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 != "" { @@ -395,19 +398,21 @@ 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{} { - deals, total, _ := c.ListOpportunities(ctx, 100, 0) - for _, d := range deals { - if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { - return d + 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 } - } - if total > 100 { - deals2, _, _ := c.ListOpportunities(ctx, 100, 100) - for _, d := range deals2 { - if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { + for _, d := range deals { + if strings.TrimSpace(fmt.Sprint(d["title"])) == needle { return d } } + if start+page >= total || len(deals) == 0 { + break + } } return nil } diff --git a/cmd/oo/applications/applications_test.go b/cmd/oo/applications/applications_test.go new file mode 100644 index 0000000..62b4f35 --- /dev/null +++ b/cmd/oo/applications/applications_test.go @@ -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") + } +} diff --git a/cmd/oo/cli_test.go b/cmd/oo/cli_test.go new file mode 100644 index 0000000..635a14b --- /dev/null +++ b/cmd/oo/cli_test.go @@ -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) + } + } +} diff --git a/cmd/oo/common.go b/cmd/oo/common.go index cd8ad45..b8cc9d7 100644 --- a/cmd/oo/common.go +++ b/cmd/oo/common.go @@ -32,13 +32,13 @@ func init() { // execute runs the root command. Exported only to main.go in the same package. 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. func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) { - _ = godotenv.Load() + loadOOEnv() creds := onlyoffice.GetEnvironmentCredentials() 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.SetDefaults(onlyoffice.GetEnvironmentDefaults()) @@ -48,6 +48,26 @@ func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) { 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. func printJSON(v any) { enc := json.NewEncoder(os.Stdout) diff --git a/cmd/oo/common_test.go b/cmd/oo/common_test.go new file mode 100644 index 0000000..353ce88 --- /dev/null +++ b/cmd/oo/common_test.go @@ -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) + }) + } +} diff --git a/cmd/oo/contacts.go b/cmd/oo/contacts.go index 72cefa4..e70a029 100644 --- a/cmd/oo/contacts.go +++ b/cmd/oo/contacts.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + onlyoffice "github.com/eslider/go-onlyoffice" "github.com/spf13/cobra" ) @@ -39,16 +40,20 @@ func init() { contactsCmd.AddCommand(contactsGetCmd()) contactsCmd.AddCommand(contactsDeleteCmd()) contactsCmd.AddCommand(contactsInfoAddCmd()) + contactsCmd.AddCommand(contactsDedupeInfoCmd()) only := true personsCmd.AddCommand(contactsListCmd(&only)) // persons only personsCmd.AddCommand(personsCreateCmd()) personsCmd.AddCommand(contactsDeleteCmd()) + personsCmd.AddCommand(personsDedupeCmd()) onlyCo := false companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only companiesCmd.AddCommand(companiesCreateCmd()) companiesCmd.AddCommand(contactsDeleteCmd()) + companiesCmd.AddCommand(companiesDedupeCmd()) + companiesCmd.AddCommand(companiesDedupePersonsCmd()) } // contactsListCmd returns a `list` subcommand. @@ -246,3 +251,63 @@ func companiesCreateCmd() *cobra.Command { cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)") 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 + }), + } +} diff --git a/cmd/oo/crm.go b/cmd/oo/crm.go new file mode 100644 index 0000000..7514adc --- /dev/null +++ b/cmd/oo/crm.go @@ -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) + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index e186591..0660870 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -6,12 +6,13 @@ // 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 users list | self (alias: oo whoami) -// oo contacts list | get | delete | info-add -// oo persons list | create | delete -// oo companies list | create | delete -// oo opportunities list | get | create | delete | stages | member-add +// oo contacts list | get | delete | info-add | dedupe-info +// oo persons list | create | delete | dedupe +// oo companies list | create | delete | dedupe | dedupe-persons +// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles // oo cases list | create | delete | member-add // oo crm-tasks list | create | delete | categories +// oo crm cleanup // oo applications sync // // Every list supports `--output/-o json|table` (table is the default). diff --git a/cmd/oo/opportunities.go b/cmd/oo/opportunities.go index a9f8e6c..b20e8e5 100644 --- a/cmd/oo/opportunities.go +++ b/cmd/oo/opportunities.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + onlyoffice "github.com/eslider/go-onlyoffice" "github.com/spf13/cobra" ) @@ -21,6 +22,9 @@ func init() { opportunitiesCmd.AddCommand(oppDeleteCmd()) opportunitiesCmd.AddCommand(oppStagesCmd()) opportunitiesCmd.AddCommand(oppMemberAddCmd()) + opportunitiesCmd.AddCommand(oppDedupeCmd()) + opportunitiesCmd.AddCommand(oppDedupeMembersCmd()) + opportunitiesCmd.AddCommand(oppFixTitlesCmd()) } 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 + }), + } +} diff --git a/crm.go b/crm.go index 0c3ed4b..9fe2024 100644 --- a/crm.go +++ b/crm.go @@ -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 == "" { + 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 == "" { + 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{} diff --git a/crm_dedupe.go b/crm_dedupe.go new file mode 100644 index 0000000..b99d549 --- /dev/null +++ b/crm_dedupe.go @@ -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) diff --git a/crm_dedupe_plan.go b/crm_dedupe_plan.go new file mode 100644 index 0000000..608cc3a --- /dev/null +++ b/crm_dedupe_plan.go @@ -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 != "" { + return v + } + if v := strings.TrimSpace(fmt.Sprint(m["companyName"])); v != "" && v != "" { + 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 == "" { + value = fmt.Sprint(row["value"]) + } + key := ContactInfoKey(infoType, value) + if key == "|" || value == "" || value == "" { + 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 +} diff --git a/crm_dedupe_plan_test.go b/crm_dedupe_plan_test.go new file mode 100644 index 0000000..cd42667 --- /dev/null +++ b/crm_dedupe_plan_test.go @@ -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) + } +} diff --git a/crm_dedupe_test.go b/crm_dedupe_test.go new file mode 100644 index 0000000..1a7c61e --- /dev/null +++ b/crm_dedupe_test.go @@ -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) + } +} diff --git a/crm_integration_test.go b/crm_integration_test.go new file mode 100644 index 0000000..c086a86 --- /dev/null +++ b/crm_integration_test.go @@ -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"]) + } +} diff --git a/crm_normalize.go b/crm_normalize.go new file mode 100644 index 0000000..91d338a --- /dev/null +++ b/crm_normalize.go @@ -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 +} diff --git a/crm_normalize_test.go b/crm_normalize_test.go new file mode 100644 index 0000000..a37b7cd --- /dev/null +++ b/crm_normalize_test.go @@ -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) + } +} diff --git a/http.go b/http.go index 5e701e9..822a4a6 100644 --- a/http.go +++ b/http.go @@ -220,6 +220,15 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e 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. func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { auth, err := c.authHeader()