twenty-app-intake 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -518,7 +518,7 @@ var FlattenStage = class {
518
518
 
519
519
  // src/constants/field-map.ts
520
520
  function normaliseKey(key) {
521
- return key.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/[-\s.]/g, "_").replace(/_{2,}/g, "_");
521
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toLowerCase().replace(/[-\s.]/g, "_").replace(/_{2,}/g, "_");
522
522
  }
523
523
  var FIELD_MAP = {
524
524
  // ── Phone ─────────────────────────────────────────────────────────────────
@@ -825,15 +825,56 @@ function parseTargetObject(raw) {
825
825
  return "auto";
826
826
  }
827
827
  }
828
- function normalizeFields(flat, rules = []) {
829
- const sorted = [...rules].sort((a, b) => b.priority - a.priority);
828
+ var PROTECTED_CANONICAL_NAMES = /* @__PURE__ */ new Set([
829
+ "email",
830
+ "phone",
831
+ "firstName",
832
+ "lastName",
833
+ "fullName"
834
+ ]);
835
+ function compileRule(rule) {
836
+ try {
837
+ return { ...rule, matcher: new RegExp(rule.inputPattern, "i") };
838
+ } catch {
839
+ return { ...rule, matcher: null };
840
+ }
841
+ }
842
+ function ruleMatches(rule, key, lookupKey) {
843
+ if (rule.inputPattern === key || rule.inputPattern === lookupKey) return true;
844
+ if (!rule.matcher) return false;
845
+ return rule.matcher.test(key) || rule.matcher.test(lookupKey);
846
+ }
847
+ function normalizeFields(flat, rules = [], warnings = []) {
848
+ const compiled = [...rules].sort((a, b) => b.priority - a.priority).map(compileRule);
830
849
  return Object.entries(flat).map(
831
- ([key, value]) => normalizeField(key, value, sorted)
850
+ ([key, value]) => normalizeField(key, value, compiled, warnings)
832
851
  );
833
852
  }
834
- function normalizeField(key, value, rules) {
853
+ function normalizeField(key, value, rules, warnings) {
835
854
  const lookupKey = normaliseKey(key);
836
855
  const builtIn = FIELD_MAP[lookupKey];
856
+ for (const rule of rules) {
857
+ if (!ruleMatches(rule, key, lookupKey)) continue;
858
+ if (builtIn && PROTECTED_CANONICAL_NAMES.has(builtIn.canonicalName)) {
859
+ const displaces = rule.canonicalName !== builtIn.canonicalName || actionForRule(rule) !== "field";
860
+ if (displaces) {
861
+ warnings.push(
862
+ `Rule "${rule.inputPattern}" matched "${key}", which the built-in map holds as the contact's ${builtIn.canonicalName}. The built-in mapping was kept: a contact with no ${builtIn.canonicalName} cannot be found again, so every submission would create a duplicate. Point the rule at a key the built-in map does not already own.`
863
+ );
864
+ continue;
865
+ }
866
+ }
867
+ return {
868
+ originalKey: key,
869
+ canonicalName: rule.canonicalName,
870
+ twentyType: typeForRule(rule),
871
+ action: actionForRule(rule),
872
+ value,
873
+ source: "rule",
874
+ targetObject: parseTargetObject(rule.targetObject),
875
+ mergeStrategy: parseFieldMergeStrategy(rule.mergeStrategy)
876
+ };
877
+ }
837
878
  if (builtIn) {
838
879
  return {
839
880
  originalKey: key,
@@ -845,26 +886,6 @@ function normalizeField(key, value, rules) {
845
886
  targetObject: "auto"
846
887
  };
847
888
  }
848
- for (const rule of rules) {
849
- let matches = false;
850
- try {
851
- matches = rule.inputPattern === key || rule.inputPattern === lookupKey || new RegExp(rule.inputPattern, "i").test(key);
852
- } catch {
853
- matches = rule.inputPattern === key;
854
- }
855
- if (matches) {
856
- return {
857
- originalKey: key,
858
- canonicalName: rule.canonicalName,
859
- twentyType: typeForRule(rule),
860
- action: actionForRule(rule),
861
- value,
862
- source: "rule",
863
- targetObject: parseTargetObject(rule.targetObject),
864
- mergeStrategy: parseFieldMergeStrategy(rule.mergeStrategy)
865
- };
866
- }
867
- }
868
889
  const detectedType = detectType(value);
869
890
  const classification = classify(key, value);
870
891
  const prefix = OPPORTUNITY_KEY_PREFIXES.find((p) => lookupKey.startsWith(p));
@@ -979,7 +1000,7 @@ var NormalizeStage = class {
979
1000
  return err(ErrorCode.INTERNAL_ERROR, "FlattenStage must run before NormalizeStage");
980
1001
  }
981
1002
  const rules = await this.loadRules(ctx.source?.id);
982
- const normalized = normalizeFields(ctx.flat, rules);
1003
+ const normalized = normalizeFields(ctx.flat, rules, ctx.warnings);
983
1004
  const { crmFields, noteFields } = partition(normalized);
984
1005
  return ok({
985
1006
  ...ctx,
@@ -1443,6 +1464,183 @@ function formatValue(value) {
1443
1464
  return String(value);
1444
1465
  }
1445
1466
 
1467
+ // src/constants/free-email-domains.ts
1468
+ var FREE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
1469
+ // Google
1470
+ "gmail.com",
1471
+ "googlemail.com",
1472
+ // Microsoft
1473
+ "outlook.com",
1474
+ "outlook.co.uk",
1475
+ "outlook.fr",
1476
+ "outlook.de",
1477
+ "outlook.es",
1478
+ "hotmail.com",
1479
+ "hotmail.co.uk",
1480
+ "hotmail.fr",
1481
+ "hotmail.de",
1482
+ "hotmail.it",
1483
+ "hotmail.es",
1484
+ "live.com",
1485
+ "live.co.uk",
1486
+ "live.nl",
1487
+ "msn.com",
1488
+ "passport.com",
1489
+ // Yahoo
1490
+ "yahoo.com",
1491
+ "yahoo.co.uk",
1492
+ "yahoo.co.jp",
1493
+ "yahoo.ca",
1494
+ "yahoo.com.au",
1495
+ "yahoo.de",
1496
+ "yahoo.fr",
1497
+ "yahoo.es",
1498
+ "yahoo.it",
1499
+ "yahoo.in",
1500
+ "ymail.com",
1501
+ "rocketmail.com",
1502
+ // Apple
1503
+ "icloud.com",
1504
+ "me.com",
1505
+ "mac.com",
1506
+ // AOL / Verizon / legacy US ISPs — common on older consumer accounts
1507
+ "aol.com",
1508
+ "aim.com",
1509
+ "verizon.net",
1510
+ "att.net",
1511
+ "sbcglobal.net",
1512
+ "bellsouth.net",
1513
+ "comcast.net",
1514
+ "cox.net",
1515
+ "charter.net",
1516
+ "earthlink.net",
1517
+ "juno.com",
1518
+ "netzero.net",
1519
+ "roadrunner.com",
1520
+ "rr.com",
1521
+ "optonline.net",
1522
+ "frontier.com",
1523
+ "windstream.net",
1524
+ // Privacy-oriented providers
1525
+ "proton.me",
1526
+ "protonmail.com",
1527
+ "protonmail.ch",
1528
+ "pm.me",
1529
+ "tutanota.com",
1530
+ "tutanota.de",
1531
+ "tuta.io",
1532
+ "tuta.com",
1533
+ "hushmail.com",
1534
+ "fastmail.com",
1535
+ "fastmail.fm",
1536
+ "posteo.de",
1537
+ "mailbox.org",
1538
+ // Other large free providers
1539
+ "gmx.com",
1540
+ "gmx.net",
1541
+ "gmx.de",
1542
+ "gmx.at",
1543
+ "gmx.ch",
1544
+ "mail.com",
1545
+ "email.com",
1546
+ "usa.com",
1547
+ "inbox.com",
1548
+ "zoho.com",
1549
+ "zohomail.com",
1550
+ "yandex.com",
1551
+ "yandex.ru",
1552
+ "ya.ru",
1553
+ "mail.ru",
1554
+ "bk.ru",
1555
+ "list.ru",
1556
+ "inbox.ru",
1557
+ "rambler.ru",
1558
+ "qq.com",
1559
+ "foxmail.com",
1560
+ "163.com",
1561
+ "126.com",
1562
+ "sina.com",
1563
+ "sina.cn",
1564
+ "sohu.com",
1565
+ "naver.com",
1566
+ "hanmail.net",
1567
+ "daum.net",
1568
+ "nate.com",
1569
+ "seznam.cz",
1570
+ "wp.pl",
1571
+ "o2.pl",
1572
+ "onet.pl",
1573
+ "interia.pl",
1574
+ "libero.it",
1575
+ "virgilio.it",
1576
+ "alice.it",
1577
+ "tiscali.it",
1578
+ "orange.fr",
1579
+ "wanadoo.fr",
1580
+ "free.fr",
1581
+ "laposte.net",
1582
+ "sfr.fr",
1583
+ "bbox.fr",
1584
+ "neuf.fr",
1585
+ "web.de",
1586
+ "t-online.de",
1587
+ "freenet.de",
1588
+ "arcor.de",
1589
+ "btinternet.com",
1590
+ "sky.com",
1591
+ "talktalk.net",
1592
+ "virginmedia.com",
1593
+ "ntlworld.com",
1594
+ "bigpond.com",
1595
+ "bigpond.net.au",
1596
+ "optusnet.com.au",
1597
+ "iinet.net.au",
1598
+ "xtra.co.nz",
1599
+ "terra.com.br",
1600
+ "uol.com.br",
1601
+ "bol.com.br",
1602
+ "ig.com.br",
1603
+ "rediffmail.com",
1604
+ "sify.com",
1605
+ "indiatimes.com",
1606
+ // Throwaway / disposable services — never a real employer
1607
+ "mailinator.com",
1608
+ "guerrillamail.com",
1609
+ "guerrillamail.net",
1610
+ "sharklasers.com",
1611
+ "10minutemail.com",
1612
+ "temp-mail.org",
1613
+ "tempmail.com",
1614
+ "throwawaymail.com",
1615
+ "yopmail.com",
1616
+ "trashmail.com",
1617
+ "getnada.com",
1618
+ "dispostable.com",
1619
+ "maildrop.cc",
1620
+ "mintemail.com",
1621
+ "fakeinbox.com",
1622
+ "spamgourmet.com",
1623
+ "mailnesia.com",
1624
+ "moakt.com",
1625
+ "emailondeck.com",
1626
+ "burnermail.io",
1627
+ "anonaddy.com",
1628
+ "simplelogin.io",
1629
+ "duck.com",
1630
+ "relay.firefox.com",
1631
+ // Placeholder domains that show up in test and pasted payloads
1632
+ "example.com",
1633
+ "example.org",
1634
+ "example.net",
1635
+ "test.com",
1636
+ "localhost"
1637
+ ]);
1638
+ function isFreeEmailDomain(host) {
1639
+ const normalised = host.trim().toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
1640
+ if (!normalised) return false;
1641
+ return FREE_EMAIL_DOMAINS.has(normalised);
1642
+ }
1643
+
1446
1644
  // src/lib/lookup.ts
1447
1645
  async function firstMatch(collection, query) {
1448
1646
  try {
@@ -1460,11 +1658,16 @@ async function findCompanyByDomain(domain) {
1460
1658
  if (!domain) return null;
1461
1659
  const host = extractHost(domain);
1462
1660
  if (!host) return null;
1661
+ if (isFreeEmailDomain(host)) return null;
1463
1662
  return firstMatch(
1464
1663
  "companies",
1465
1664
  `filter=domainName.primaryLinkUrl[like]:%25${encodeURIComponent(host)}%25`
1466
1665
  );
1467
1666
  }
1667
+ function isCompanyDomain(domain) {
1668
+ const host = extractHost(domain);
1669
+ return host.length > 0 && !isFreeEmailDomain(host);
1670
+ }
1468
1671
  function extractHost(url) {
1469
1672
  try {
1470
1673
  return new URL(url).hostname.replace(/^www\./, "");
@@ -1585,6 +1788,17 @@ var UpsertRecordStage = class {
1585
1788
  if (companyName && !standard["name"]) standard["name"] = companyName;
1586
1789
  if (!standard["domainName"] && assembled["domainName"]) standard["domainName"] = assembled["domainName"];
1587
1790
  if (!standard["address"] && assembled["address"]) standard["address"] = assembled["address"];
1791
+ const proposedDomain = typeof standard["domainName"] === "string" ? standard["domainName"] : standard["domainName"]?.["primaryLinkUrl"];
1792
+ if (typeof proposedDomain === "string" && proposedDomain && !isCompanyDomain(proposedDomain)) {
1793
+ delete standard["domainName"];
1794
+ ctx.warnings.push(
1795
+ `"${extractHost(proposedDomain)}" is an email provider, not a company domain \u2014 it was left off the company record.`
1796
+ );
1797
+ }
1798
+ if (!standard["name"] && !standard["domainName"]) {
1799
+ ctx.warnings.push("No company name or domain in this payload \u2014 no company was created.");
1800
+ return void 0;
1801
+ }
1588
1802
  const domain = standard["domainName"]?.["primaryLinkUrl"];
1589
1803
  let companyId;
1590
1804
  if (domain) {