From 9c450a0352c25f12ac16d91062744ab026ffe660 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 21 Aug 2026 22:02:47 +0100 Subject: [PATCH] feat(crm): contact email & person-opportunity indexes, history entity whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuildContactEmailIndex: one-pass email→contact id map over all persons (O(N) instead of per-address FindPersonByEmail O(N×M)) - BuildPersonOpportunityIndex: person→lowest-id opportunity for history notes (OO has no person-level history; verified live) - HistoryEntity{Opportunity,Case} whitelist constants - IsCompany/ContactID/NumericIDLess/SortIDs helpers with unit tests --- contact_indexes.go | 96 +++++++++++++++++++++++++++++++++++++++++ contact_indexes_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 contact_indexes.go create mode 100644 contact_indexes_test.go diff --git a/contact_indexes.go b/contact_indexes.go new file mode 100644 index 0000000..e056476 --- /dev/null +++ b/contact_indexes.go @@ -0,0 +1,96 @@ +package onlyoffice + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" +) + +// History entities that OnlyOffice CRM actually accepts for history notes. +// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json +// returns 400 "Value does not fall within the expected range." for entityType +// contact/person/people/client/member. Verified against a live instance (#74). +const ( + HistoryEntityOpportunity = "opportunity" + HistoryEntityCase = "case" +) + +// IsCompany reports whether a CRM contact row is a company (vs a person). +// The field arrives as JSON bool; be liberal about what we accept. +func IsCompany(person map[string]any) bool { + b, _ := person["isCompany"].(bool) + return b +} + +// ContactID returns the CRM id of a contact row as a plain string. +func ContactID(row map[string]any) string { + return fmt.Sprint(row["id"]) +} + +// BuildContactEmailIndex scans all persons once and maps lowercase email → +// contact id. Use this instead of calling FindPersonByEmail per address: +// the index is O(N) over the whole CRM, the per-address lookup is O(N×M). +func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) { + all, err := c.ListAllContacts(ctx) + if err != nil { + return nil, err + } + index := make(map[string]string, len(all)*2) + for _, person := range all { + if IsCompany(person) { + continue + } + id := ContactID(person) + for _, row := range ContactInfoRows(person) { + if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" { + continue + } + email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"]))) + if email != "" && email != "" { + index[email] = id + } + } + } + return index, nil +} + +// BuildPersonOpportunityIndex maps every opportunity member's contact id to a +// deterministic representative opportunity: the one with the lowest numeric id. +// OnlyOffice has no person-level history, so notes for a person go on their +// deal — this index answers "which deal" in one pass. +func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) { + opps, err := c.ListAllOpportunities(ctx) + if err != nil { + return nil, err + } + index := map[string]string{} + for _, opp := range opps { + oppID := ContactID(opp) + for _, member := range OpportunityMembers(opp) { + pid := ContactID(member) + if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) { + index[pid] = oppID + } + } + } + return index, nil +} + +// NumericIDLess compares two string ids numerically when possible, falling +// back to lexicographic order so results stay deterministic either way. +func NumericIDLess(a, b string) bool { + na, errA := strconv.Atoi(strings.TrimSpace(a)) + nb, errB := strconv.Atoi(strings.TrimSpace(b)) + if errA == nil && errB == nil && na != nb { + return na < nb + } + return a < b +} + +// SortIDs orders id strings deterministically (numeric first, then lexical). +func SortIDs(ids []string) { + sort.Strings(ids) + sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) }) +} diff --git a/contact_indexes_test.go b/contact_indexes_test.go new file mode 100644 index 0000000..50677a9 --- /dev/null +++ b/contact_indexes_test.go @@ -0,0 +1,65 @@ +package onlyoffice + +import "testing" + +func TestIsCompany(t *testing.T) { + if IsCompany(map[string]any{"isCompany": true}) != true { + t.Fatal("true row not detected") + } + if IsCompany(map[string]any{"isCompany": false}) { + t.Fatal("false row detected as company") + } + if IsCompany(map[string]any{}) { + t.Fatal("missing field detected as company") + } + if IsCompany(nil) { + t.Fatal("nil row detected as company") + } +} + +func TestNumericIDLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"9", "10", true}, + {"1747", "1748", true}, + {"abc", "abd", true}, + {"10", "9", false}, + {" 12 ", "13", true}, + {"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2" + } + for _, c := range cases { + if got := NumericIDLess(c.a, c.b); got != c.want { + t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want) + } + } +} + +func TestSortIDs(t *testing.T) { + ids := []string{"20", "3", "100", "1"} + SortIDs(ids) + want := "1 3 20 100" + got := "" + for i, id := range ids { + if i > 0 { + got += " " + } + got += id + } + if got != want { + t.Fatalf("SortIDs=%q want %q", got, want) + } +} + +func TestContactID(t *testing.T) { + if ContactID(map[string]any{"id": float64(42)}) != "42" { + t.Fatal("numeric id formatting broken") + } +} + +func TestHistoryEntityConstants(t *testing.T) { + if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" { + t.Fatal("history entity whitelist drifted from live-verified values") + } +}