twenty-app-intake 0.3.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.
Files changed (44) hide show
  1. package/README.md +384 -8
  2. package/manifest.json +424 -21
  3. package/package.json +1 -1
  4. package/src/front-components/settings-panel.mjs +7 -7
  5. package/src/front-components/settings-panel.mjs.map +2 -2
  6. package/src/front-components/settings-panel.tsx +49 -0
  7. package/src/logic-functions/check-silence.mjs +263 -0
  8. package/src/logic-functions/check-silence.mjs.map +7 -0
  9. package/src/logic-functions/check-silence.ts +52 -0
  10. package/src/logic-functions/contract.mjs +507 -0
  11. package/src/logic-functions/contract.mjs.map +7 -0
  12. package/src/logic-functions/contract.ts +172 -0
  13. package/src/logic-functions/health.mjs +125 -5
  14. package/src/logic-functions/health.mjs.map +3 -3
  15. package/src/logic-functions/health.ts +41 -5
  16. package/src/logic-functions/quarantine-discard.mjs +380 -0
  17. package/src/logic-functions/quarantine-discard.mjs.map +7 -0
  18. package/src/logic-functions/quarantine-discard.ts +65 -0
  19. package/src/logic-functions/quarantine-list.mjs +211 -0
  20. package/src/logic-functions/quarantine-list.mjs.map +7 -0
  21. package/src/logic-functions/quarantine-list.ts +93 -0
  22. package/src/logic-functions/quarantine-release.mjs +2188 -0
  23. package/src/logic-functions/quarantine-release.mjs.map +7 -0
  24. package/src/logic-functions/quarantine-release.ts +94 -0
  25. package/src/logic-functions/register.mjs +34 -1
  26. package/src/logic-functions/register.mjs.map +2 -2
  27. package/src/logic-functions/replay-bulk.mjs +2254 -0
  28. package/src/logic-functions/replay-bulk.mjs.map +7 -0
  29. package/src/logic-functions/replay-bulk.ts +83 -0
  30. package/src/logic-functions/replay-log.mjs +2201 -0
  31. package/src/logic-functions/replay-log.mjs.map +7 -0
  32. package/src/logic-functions/replay-log.ts +54 -0
  33. package/src/logic-functions/retry.mjs +1236 -269
  34. package/src/logic-functions/retry.mjs.map +4 -4
  35. package/src/logic-functions/retry.ts +45 -68
  36. package/src/logic-functions/test-ingest.mjs +851 -48
  37. package/src/logic-functions/test-ingest.mjs.map +4 -4
  38. package/src/logic-functions/test-ingest.ts +136 -28
  39. package/src/logic-functions/webhook.mjs +1200 -230
  40. package/src/logic-functions/webhook.mjs.map +4 -4
  41. package/src/logic-functions/webhook.ts +23 -48
  42. package/src/post-install.mjs +34 -8
  43. package/src/post-install.mjs.map +2 -2
  44. package/src/post-install.ts +8 -8
@@ -21,6 +21,9 @@ var __anyHandler = {
21
21
  var __anyStub = new Proxy(() => void 0, __anyHandler);
22
22
  var defineLogicFunction = __defineFactoryStub;
23
23
 
24
+ // src/logic-functions/test-ingest.ts
25
+ import { CoreApiClient } from "twenty-client-sdk/core";
26
+
24
27
  // src/lib/flattener.ts
25
28
  var MAX_DEPTH = 8;
26
29
  function flatten(input, prefix = "", depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
@@ -77,7 +80,7 @@ function detectStructure(raw) {
77
80
 
78
81
  // src/constants/field-map.ts
79
82
  function normaliseKey(key) {
80
- return key.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/[-\s.]/g, "_").replace(/_{2,}/g, "_");
83
+ 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, "_");
81
84
  }
82
85
  var FIELD_MAP = {
83
86
  // ── Phone ─────────────────────────────────────────────────────────────────
@@ -277,16 +280,163 @@ function classify(key, value) {
277
280
  return "field";
278
281
  }
279
282
 
283
+ // src/lib/merge.ts
284
+ function decideMerge(existing, incoming, policy, strategy = "INHERIT") {
285
+ if (isEmpty(incoming)) return { write: false, status: "preserved" };
286
+ if (isEmpty(existing)) return { write: true, status: "filled-empty" };
287
+ if (sameValue(existing, incoming)) return { write: false, status: "unchanged" };
288
+ const effective = strategy === "INHERIT" ? policy : strategy;
289
+ return effective === "NEWEST_WINS" ? { write: true, status: "set" } : { write: false, status: "preserved" };
290
+ }
291
+ function mergePatch(existing, incoming, policy, strategyFor = () => "INHERIT") {
292
+ const patch = {};
293
+ const decisions = {};
294
+ for (const [key, value] of Object.entries(incoming)) {
295
+ const decision = decideMerge(existing[key], value, policy, strategyFor(key));
296
+ decisions[key] = decision;
297
+ if (decision.write) patch[key] = value;
298
+ }
299
+ return { patch, decisions };
300
+ }
301
+ function parseFieldMergeStrategy(raw) {
302
+ switch ((raw ?? "").toUpperCase()) {
303
+ case "PRESERVE":
304
+ return "PRESERVE";
305
+ case "NEWEST_WINS":
306
+ return "NEWEST_WINS";
307
+ default:
308
+ return "INHERIT";
309
+ }
310
+ }
311
+ function isEmpty(value) {
312
+ if (value === null || value === void 0) return true;
313
+ if (typeof value === "string") return value.trim() === "";
314
+ if (Array.isArray(value)) return value.length === 0;
315
+ if (typeof value === "object") {
316
+ const entries = Object.entries(value);
317
+ if (entries.length === 0) return true;
318
+ const meaningful = entries.filter(([k]) => !IGNORED_COMPOSITE_KEYS.has(k));
319
+ if (meaningful.length === 0) return true;
320
+ return meaningful.every(([, v]) => isEmpty(v));
321
+ }
322
+ return false;
323
+ }
324
+ var IGNORED_COMPOSITE_KEYS = /* @__PURE__ */ new Set([
325
+ "currencyCode",
326
+ "primaryLinkLabel",
327
+ "primaryPhoneCountryCode",
328
+ "primaryPhoneCallingCode",
329
+ "additionalLinks",
330
+ "additionalEmails",
331
+ "additionalPhones"
332
+ ]);
333
+ function sameValue(a, b) {
334
+ if (a === b) return true;
335
+ if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
336
+ try {
337
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+ return String(a) === String(b);
343
+ }
344
+ function sortKeys(value) {
345
+ if (value === null || typeof value !== "object") return value;
346
+ if (Array.isArray(value)) return value.map(sortKeys);
347
+ const obj = value;
348
+ const out = {};
349
+ for (const key of Object.keys(obj).sort()) out[key] = sortKeys(obj[key]);
350
+ return out;
351
+ }
352
+
280
353
  // src/lib/normalizer.ts
281
- function normalizeFields(flat, rules = []) {
282
- const sorted = [...rules].sort((a, b) => b.priority - a.priority);
354
+ function actionForRule(rule) {
355
+ if (rule.action) return rule.action;
356
+ switch ((rule.fieldType ?? "").toUpperCase()) {
357
+ case "SKIP":
358
+ return "skip";
359
+ case "NOTE":
360
+ return "note";
361
+ default:
362
+ return "field";
363
+ }
364
+ }
365
+ function typeForRule(rule) {
366
+ switch ((rule.fieldType ?? "").toUpperCase()) {
367
+ case "NOTE":
368
+ return "RICH_TEXT";
369
+ case "SKIP":
370
+ return "TEXT";
371
+ case "":
372
+ return "TEXT";
373
+ default:
374
+ return rule.fieldType;
375
+ }
376
+ }
377
+ var OPPORTUNITY_KEY_PREFIXES = ["opportunity_", "opp_", "deal_"];
378
+ function parseTargetObject(raw) {
379
+ switch ((raw ?? "").toUpperCase()) {
380
+ case "PERSON":
381
+ return "person";
382
+ case "COMPANY":
383
+ return "company";
384
+ case "OPPORTUNITY":
385
+ return "opportunity";
386
+ default:
387
+ return "auto";
388
+ }
389
+ }
390
+ var PROTECTED_CANONICAL_NAMES = /* @__PURE__ */ new Set([
391
+ "email",
392
+ "phone",
393
+ "firstName",
394
+ "lastName",
395
+ "fullName"
396
+ ]);
397
+ function compileRule(rule) {
398
+ try {
399
+ return { ...rule, matcher: new RegExp(rule.inputPattern, "i") };
400
+ } catch {
401
+ return { ...rule, matcher: null };
402
+ }
403
+ }
404
+ function ruleMatches(rule, key, lookupKey) {
405
+ if (rule.inputPattern === key || rule.inputPattern === lookupKey) return true;
406
+ if (!rule.matcher) return false;
407
+ return rule.matcher.test(key) || rule.matcher.test(lookupKey);
408
+ }
409
+ function normalizeFields(flat, rules = [], warnings = []) {
410
+ const compiled = [...rules].sort((a, b) => b.priority - a.priority).map(compileRule);
283
411
  return Object.entries(flat).map(
284
- ([key, value]) => normalizeField(key, value, sorted)
412
+ ([key, value]) => normalizeField(key, value, compiled, warnings)
285
413
  );
286
414
  }
287
- function normalizeField(key, value, rules) {
415
+ function normalizeField(key, value, rules, warnings) {
288
416
  const lookupKey = normaliseKey(key);
289
417
  const builtIn = FIELD_MAP[lookupKey];
418
+ for (const rule of rules) {
419
+ if (!ruleMatches(rule, key, lookupKey)) continue;
420
+ if (builtIn && PROTECTED_CANONICAL_NAMES.has(builtIn.canonicalName)) {
421
+ const displaces = rule.canonicalName !== builtIn.canonicalName || actionForRule(rule) !== "field";
422
+ if (displaces) {
423
+ warnings.push(
424
+ `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.`
425
+ );
426
+ continue;
427
+ }
428
+ }
429
+ return {
430
+ originalKey: key,
431
+ canonicalName: rule.canonicalName,
432
+ twentyType: typeForRule(rule),
433
+ action: actionForRule(rule),
434
+ value,
435
+ source: "rule",
436
+ targetObject: parseTargetObject(rule.targetObject),
437
+ mergeStrategy: parseFieldMergeStrategy(rule.mergeStrategy)
438
+ };
439
+ }
290
440
  if (builtIn) {
291
441
  return {
292
442
  originalKey: key,
@@ -294,37 +444,24 @@ function normalizeField(key, value, rules) {
294
444
  twentyType: builtIn.twentyType,
295
445
  action: builtIn.action,
296
446
  value,
297
- source: "builtin"
447
+ source: "builtin",
448
+ targetObject: "auto"
298
449
  };
299
450
  }
300
- for (const rule of rules) {
301
- let matches = false;
302
- try {
303
- matches = rule.inputPattern === key || rule.inputPattern === lookupKey || new RegExp(rule.inputPattern, "i").test(key);
304
- } catch {
305
- matches = rule.inputPattern === key;
306
- }
307
- if (matches) {
308
- return {
309
- originalKey: key,
310
- canonicalName: rule.canonicalName,
311
- twentyType: rule.fieldType,
312
- action: rule.action,
313
- value,
314
- source: "rule"
315
- };
316
- }
317
- }
318
451
  const detectedType = detectType(value);
319
452
  const classification = classify(key, value);
320
- const canonicalName = classification === "field" ? toExtFieldName(key) : lookupKey;
453
+ const prefix = OPPORTUNITY_KEY_PREFIXES.find((p) => lookupKey.startsWith(p));
454
+ const targetObject = prefix ? "opportunity" : "auto";
455
+ const bareKey = prefix ? lookupKey.slice(prefix.length) : key;
456
+ const canonicalName = classification === "field" ? toExtFieldName(bareKey) : lookupKey;
321
457
  return {
322
458
  originalKey: key,
323
459
  canonicalName,
324
460
  twentyType: detectedType,
325
461
  action: classification === "field" ? "field" : classification,
326
462
  value,
327
- source: "passthrough"
463
+ source: "passthrough",
464
+ targetObject
328
465
  };
329
466
  }
330
467
  function toExtFieldName(key) {
@@ -488,54 +625,720 @@ var coreApi = {
488
625
  post: (path, body) => request(`${apiUrl()}/rest`, "POST", path, body),
489
626
  patch: (path, body) => request(`${apiUrl()}/rest`, "PATCH", path, body)
490
627
  };
628
+ var metaApi = {
629
+ get: (path) => request(`${apiUrl()}/rest/metadata`, "GET", path),
630
+ post: (path, body) => request(`${apiUrl()}/rest/metadata`, "POST", path, body)
631
+ };
632
+
633
+ // src/constants/free-email-domains.ts
634
+ var FREE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
635
+ // Google
636
+ "gmail.com",
637
+ "googlemail.com",
638
+ // Microsoft
639
+ "outlook.com",
640
+ "outlook.co.uk",
641
+ "outlook.fr",
642
+ "outlook.de",
643
+ "outlook.es",
644
+ "hotmail.com",
645
+ "hotmail.co.uk",
646
+ "hotmail.fr",
647
+ "hotmail.de",
648
+ "hotmail.it",
649
+ "hotmail.es",
650
+ "live.com",
651
+ "live.co.uk",
652
+ "live.nl",
653
+ "msn.com",
654
+ "passport.com",
655
+ // Yahoo
656
+ "yahoo.com",
657
+ "yahoo.co.uk",
658
+ "yahoo.co.jp",
659
+ "yahoo.ca",
660
+ "yahoo.com.au",
661
+ "yahoo.de",
662
+ "yahoo.fr",
663
+ "yahoo.es",
664
+ "yahoo.it",
665
+ "yahoo.in",
666
+ "ymail.com",
667
+ "rocketmail.com",
668
+ // Apple
669
+ "icloud.com",
670
+ "me.com",
671
+ "mac.com",
672
+ // AOL / Verizon / legacy US ISPs — common on older consumer accounts
673
+ "aol.com",
674
+ "aim.com",
675
+ "verizon.net",
676
+ "att.net",
677
+ "sbcglobal.net",
678
+ "bellsouth.net",
679
+ "comcast.net",
680
+ "cox.net",
681
+ "charter.net",
682
+ "earthlink.net",
683
+ "juno.com",
684
+ "netzero.net",
685
+ "roadrunner.com",
686
+ "rr.com",
687
+ "optonline.net",
688
+ "frontier.com",
689
+ "windstream.net",
690
+ // Privacy-oriented providers
691
+ "proton.me",
692
+ "protonmail.com",
693
+ "protonmail.ch",
694
+ "pm.me",
695
+ "tutanota.com",
696
+ "tutanota.de",
697
+ "tuta.io",
698
+ "tuta.com",
699
+ "hushmail.com",
700
+ "fastmail.com",
701
+ "fastmail.fm",
702
+ "posteo.de",
703
+ "mailbox.org",
704
+ // Other large free providers
705
+ "gmx.com",
706
+ "gmx.net",
707
+ "gmx.de",
708
+ "gmx.at",
709
+ "gmx.ch",
710
+ "mail.com",
711
+ "email.com",
712
+ "usa.com",
713
+ "inbox.com",
714
+ "zoho.com",
715
+ "zohomail.com",
716
+ "yandex.com",
717
+ "yandex.ru",
718
+ "ya.ru",
719
+ "mail.ru",
720
+ "bk.ru",
721
+ "list.ru",
722
+ "inbox.ru",
723
+ "rambler.ru",
724
+ "qq.com",
725
+ "foxmail.com",
726
+ "163.com",
727
+ "126.com",
728
+ "sina.com",
729
+ "sina.cn",
730
+ "sohu.com",
731
+ "naver.com",
732
+ "hanmail.net",
733
+ "daum.net",
734
+ "nate.com",
735
+ "seznam.cz",
736
+ "wp.pl",
737
+ "o2.pl",
738
+ "onet.pl",
739
+ "interia.pl",
740
+ "libero.it",
741
+ "virgilio.it",
742
+ "alice.it",
743
+ "tiscali.it",
744
+ "orange.fr",
745
+ "wanadoo.fr",
746
+ "free.fr",
747
+ "laposte.net",
748
+ "sfr.fr",
749
+ "bbox.fr",
750
+ "neuf.fr",
751
+ "web.de",
752
+ "t-online.de",
753
+ "freenet.de",
754
+ "arcor.de",
755
+ "btinternet.com",
756
+ "sky.com",
757
+ "talktalk.net",
758
+ "virginmedia.com",
759
+ "ntlworld.com",
760
+ "bigpond.com",
761
+ "bigpond.net.au",
762
+ "optusnet.com.au",
763
+ "iinet.net.au",
764
+ "xtra.co.nz",
765
+ "terra.com.br",
766
+ "uol.com.br",
767
+ "bol.com.br",
768
+ "ig.com.br",
769
+ "rediffmail.com",
770
+ "sify.com",
771
+ "indiatimes.com",
772
+ // Throwaway / disposable services — never a real employer
773
+ "mailinator.com",
774
+ "guerrillamail.com",
775
+ "guerrillamail.net",
776
+ "sharklasers.com",
777
+ "10minutemail.com",
778
+ "temp-mail.org",
779
+ "tempmail.com",
780
+ "throwawaymail.com",
781
+ "yopmail.com",
782
+ "trashmail.com",
783
+ "getnada.com",
784
+ "dispostable.com",
785
+ "maildrop.cc",
786
+ "mintemail.com",
787
+ "fakeinbox.com",
788
+ "spamgourmet.com",
789
+ "mailnesia.com",
790
+ "moakt.com",
791
+ "emailondeck.com",
792
+ "burnermail.io",
793
+ "anonaddy.com",
794
+ "simplelogin.io",
795
+ "duck.com",
796
+ "relay.firefox.com",
797
+ // Placeholder domains that show up in test and pasted payloads
798
+ "example.com",
799
+ "example.org",
800
+ "example.net",
801
+ "test.com",
802
+ "localhost"
803
+ ]);
804
+ function isFreeEmailDomain(host) {
805
+ const normalised = host.trim().toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
806
+ if (!normalised) return false;
807
+ return FREE_EMAIL_DOMAINS.has(normalised);
808
+ }
809
+
810
+ // src/lib/lookup.ts
811
+ async function firstMatch(collection, query) {
812
+ try {
813
+ const res = await coreApi.get(`/${collection}?${query}&first=1`);
814
+ return res.data?.[collection]?.edges?.[0]?.node ?? null;
815
+ } catch {
816
+ return null;
817
+ }
818
+ }
819
+ async function findPersonByEmail(email) {
820
+ if (!email) return null;
821
+ return firstMatch("people", `filter=emails.primaryEmail[eq]:${encodeURIComponent(email)}`);
822
+ }
823
+ async function findCompanyByDomain(domain) {
824
+ if (!domain) return null;
825
+ const host = extractHost(domain);
826
+ if (!host) return null;
827
+ if (isFreeEmailDomain(host)) return null;
828
+ return firstMatch(
829
+ "companies",
830
+ `filter=domainName.primaryLinkUrl[like]:%25${encodeURIComponent(host)}%25`
831
+ );
832
+ }
833
+ function isCompanyDomain(domain) {
834
+ const host = extractHost(domain);
835
+ return host.length > 0 && !isFreeEmailDomain(host);
836
+ }
837
+ function extractHost(url) {
838
+ try {
839
+ return new URL(url).hostname.replace(/^www\./, "");
840
+ } catch {
841
+ return url;
842
+ }
843
+ }
844
+ function primaryEmailOf(assembled) {
845
+ const emails = assembled["emails"];
846
+ const value = emails?.["primaryEmail"];
847
+ return typeof value === "string" ? value : "";
848
+ }
849
+ function primaryDomainOf(standard) {
850
+ const domainName = standard["domainName"];
851
+ if (typeof domainName === "string") return domainName;
852
+ const value = domainName?.["primaryLinkUrl"];
853
+ return typeof value === "string" ? value : "";
854
+ }
855
+
856
+ // src/lib/diff.ts
857
+ var STANDARD_BY_OBJECT = {
858
+ person: /* @__PURE__ */ new Set([
859
+ "name",
860
+ "emails",
861
+ "phones",
862
+ "firstName",
863
+ "lastName",
864
+ "fullName",
865
+ "email",
866
+ "phone",
867
+ "linkedInLink",
868
+ "xLink",
869
+ "jobTitle",
870
+ "city",
871
+ "companyName",
872
+ "domainName",
873
+ "address"
874
+ ]),
875
+ company: /* @__PURE__ */ new Set([
876
+ "name",
877
+ "domainName",
878
+ "address",
879
+ "companyName",
880
+ "linkedInLink",
881
+ "xLink",
882
+ "employees",
883
+ "annualRecurringRevenue",
884
+ "idealCustomerProfile",
885
+ "city"
886
+ ]),
887
+ opportunity: /* @__PURE__ */ new Set(["name", "stage", "amount", "closeDate"])
888
+ };
889
+ async function buildDiff(input) {
890
+ const clean = input.crmFields.filter((f) => !f.originalKey.startsWith("__"));
891
+ const { assembled, remainder } = assembleComposites(clean);
892
+ for (const f of remainder) assembled[f.canonicalName] = f.value;
893
+ const objects = [];
894
+ const existingFields = await objectFieldNames();
895
+ const strategyFor = (field) => clean.find((f) => f.canonicalName === field)?.mergeStrategy ?? "INHERIT";
896
+ const companyName = input.structuredCompany?.["name"] ?? assembled["companyName"];
897
+ if (companyName || input.targetObject === "COMPANY") {
898
+ const standard = { ...input.structuredCompany ?? {} };
899
+ if (companyName && !standard["name"]) standard["name"] = companyName;
900
+ if (!standard["domainName"] && assembled["domainName"]) standard["domainName"] = assembled["domainName"];
901
+ if (!standard["address"] && assembled["address"]) standard["address"] = assembled["address"];
902
+ const proposedDomain = primaryDomainOf(standard);
903
+ if (proposedDomain && !isCompanyDomain(proposedDomain)) {
904
+ delete standard["domainName"];
905
+ input.warnings.push(
906
+ `"${extractHost(proposedDomain)}" is an email provider, not a company domain \u2014 it was left off the company record.`
907
+ );
908
+ }
909
+ if (!standard["name"] && !standard["domainName"]) {
910
+ input.warnings.push("No company name or domain in this payload \u2014 no company was created.");
911
+ } else {
912
+ const domain = primaryDomainOf(standard);
913
+ const existing = domain ? await findCompanyByDomain(domain) : null;
914
+ objects.push(describe(
915
+ "company",
916
+ existing,
917
+ domain ? { field: "domainName", value: domain } : null,
918
+ { ...standard, ...customFor(clean, "company") },
919
+ clean,
920
+ existingFields["company"] ?? /* @__PURE__ */ new Set(),
921
+ input.policy,
922
+ strategyFor
923
+ ));
924
+ }
925
+ }
926
+ const hasPersonData = assembled["emails"] || assembled["name"] || input.structuredPerson;
927
+ if (hasPersonData || input.targetObject === "PERSON") {
928
+ const standard = { ...input.structuredPerson ?? {} };
929
+ if (!standard["name"] && assembled["name"]) standard["name"] = assembled["name"];
930
+ if (!standard["emails"] && assembled["emails"]) standard["emails"] = assembled["emails"];
931
+ if (!standard["phones"] && assembled["phones"]) standard["phones"] = assembled["phones"];
932
+ const email = primaryEmailOf(standard) || primaryEmailOf(assembled);
933
+ const existing = email ? await findPersonByEmail(email) : null;
934
+ objects.push(describe(
935
+ "person",
936
+ existing,
937
+ email ? { field: "emails.primaryEmail", value: email } : null,
938
+ { ...standard, ...customFor(clean, "person") },
939
+ clean,
940
+ existingFields["person"] ?? /* @__PURE__ */ new Set(),
941
+ input.policy,
942
+ strategyFor
943
+ ));
944
+ }
945
+ if (input.createOpportunity && objects.some((o) => o.operation !== "skip")) {
946
+ const oppCustom = customFor(clean, "opportunity");
947
+ const oppStandard = {};
948
+ for (const f of clean) {
949
+ if (f.targetObject === "opportunity" && (f.canonicalName === "amount" || f.canonicalName === "closeDate")) {
950
+ oppStandard[f.canonicalName] = f.value;
951
+ }
952
+ }
953
+ objects.push(describe(
954
+ "opportunity",
955
+ null,
956
+ null,
957
+ { ...oppStandard, ...oppCustom },
958
+ clean,
959
+ existingFields["opportunity"] ?? /* @__PURE__ */ new Set(),
960
+ input.policy,
961
+ strategyFor
962
+ ));
963
+ }
964
+ return {
965
+ objects,
966
+ noteFields: input.noteFields.map((f) => f.canonicalName),
967
+ skipped: input.skipped.map((f) => f.originalKey),
968
+ warnings: input.warnings
969
+ };
970
+ }
971
+ function customFor(fields, object) {
972
+ const out = {};
973
+ for (const f of fields) {
974
+ const target = f.targetObject === "auto" ? impliedPrimary(fields) : f.targetObject;
975
+ if (target !== object) continue;
976
+ if (STANDARD_BY_OBJECT[object]?.has(f.canonicalName)) continue;
977
+ out[f.canonicalName] = f.value;
978
+ }
979
+ return out;
980
+ }
981
+ function impliedPrimary(fields) {
982
+ const hasPerson = fields.some((f) => ["firstName", "lastName", "email", "phone", "fullName", "emails", "phones", "name"].includes(f.canonicalName));
983
+ return hasPerson ? "person" : "company";
984
+ }
985
+ function describe(object, existing, matchedBy, incoming, normalized, objectFields, policy, strategyFor) {
986
+ const { decisions } = existing ? mergePatch(existing, incoming, policy, strategyFor) : { decisions: {} };
987
+ const fields = [];
988
+ const fieldsToCreate = [];
989
+ for (const [name, value] of Object.entries(incoming)) {
990
+ const source = normalized.find((f) => f.canonicalName === name);
991
+ const isStandard = STANDARD_BY_OBJECT[object]?.has(name) ?? false;
992
+ const wouldCreate = !isStandard && !objectFields.has(name);
993
+ if (wouldCreate) fieldsToCreate.push({ name, type: source?.twentyType ?? "TEXT" });
994
+ let status = "create";
995
+ if (existing) {
996
+ const decided = decisions[name]?.status;
997
+ status = decided === "filled-empty" ? "fill-empty" : decided === "unchanged" ? "unchanged" : decided === "set" ? "overwrite" : "preserved";
998
+ }
999
+ fields.push({
1000
+ name,
1001
+ from: source?.source ?? "derived",
1002
+ incoming: value,
1003
+ ...existing ? { existing: existing[name] } : {},
1004
+ status,
1005
+ fieldWouldBeCreated: wouldCreate,
1006
+ twentyType: source?.twentyType
1007
+ });
1008
+ }
1009
+ return {
1010
+ object,
1011
+ operation: existing ? "update" : "create",
1012
+ recordId: existing?.["id"] ?? null,
1013
+ matchedBy,
1014
+ fields,
1015
+ fieldsToCreate
1016
+ };
1017
+ }
1018
+ async function objectFieldNames() {
1019
+ try {
1020
+ const res = await metaApi.get("/objects");
1021
+ const list = Array.isArray(res) ? res : res.data ?? [];
1022
+ const out = {};
1023
+ for (const node of list) {
1024
+ out[node.nameSingular] = new Set((node.fields ?? []).map((f) => f.name));
1025
+ }
1026
+ return out;
1027
+ } catch {
1028
+ return {};
1029
+ }
1030
+ }
1031
+
1032
+ // src/lib/spam.ts
1033
+ var HONEYPOT_SCORE = 100;
1034
+ var DISPOSABLE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
1035
+ "mailinator.com",
1036
+ "guerrillamail.com",
1037
+ "guerrillamail.net",
1038
+ "sharklasers.com",
1039
+ "10minutemail.com",
1040
+ "10minutemail.net",
1041
+ "tempmail.com",
1042
+ "temp-mail.org",
1043
+ "throwawaymail.com",
1044
+ "yopmail.com",
1045
+ "yopmail.net",
1046
+ "trashmail.com",
1047
+ "dispostable.com",
1048
+ "getnada.com",
1049
+ "nada.email",
1050
+ "fakeinbox.com",
1051
+ "maildrop.cc",
1052
+ "mintemail.com",
1053
+ "spamgourmet.com",
1054
+ "mailnesia.com",
1055
+ "tempinbox.com",
1056
+ "emailondeck.com",
1057
+ "moakt.com",
1058
+ "mohmal.com",
1059
+ "inboxkitten.com",
1060
+ "mailcatch.com",
1061
+ "mytemp.email",
1062
+ "discard.email"
1063
+ ]);
1064
+ var PLACEHOLDER_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
1065
+ "example.com",
1066
+ "example.org",
1067
+ "example.net",
1068
+ "test.com",
1069
+ "localhost",
1070
+ "domain.com",
1071
+ "email.com",
1072
+ "yourdomain.com"
1073
+ ]);
1074
+ var URL_RE2 = /https?:\/\/|www\./i;
1075
+ var GLOBAL_URL_RE = /https?:\/\/[^\s<>"']+/gi;
1076
+ var MARKUP_RE = /<\s*a\s|<\s*script|\[url[=\]]|\[link[=\]]|\]\(https?:/i;
1077
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
1078
+ var NAME_KEYS = /* @__PURE__ */ new Set([
1079
+ "name",
1080
+ "fullName",
1081
+ "firstName",
1082
+ "lastName",
1083
+ "contact_name",
1084
+ "your_name"
1085
+ ]);
1086
+ var MESSAGE_KEYS = /* @__PURE__ */ new Set([
1087
+ "message",
1088
+ "description",
1089
+ "comments",
1090
+ "notes",
1091
+ "body",
1092
+ "details",
1093
+ "content",
1094
+ "text",
1095
+ "inquiry",
1096
+ "summary",
1097
+ "feedback"
1098
+ ]);
1099
+ function scoreSpam(flat, honeypotKey) {
1100
+ const signals = [];
1101
+ if (honeypotKey) {
1102
+ const hit = Object.entries(flat).find(
1103
+ ([k, v]) => k.toLowerCase() === honeypotKey.toLowerCase() && isFilled(v)
1104
+ );
1105
+ if (hit) {
1106
+ return {
1107
+ score: HONEYPOT_SCORE,
1108
+ signals: [{ reason: `Honeypot field "${hit[0]}" was filled`, points: HONEYPOT_SCORE }],
1109
+ honeypot: true
1110
+ };
1111
+ }
1112
+ }
1113
+ const add = (reason, points) => signals.push({ reason, points });
1114
+ for (const [key, value] of Object.entries(flat)) {
1115
+ if (typeof value !== "string" || value === "") continue;
1116
+ const bare = key.replace(/^(opportunity_|opp_|deal_)/i, "");
1117
+ if (NAME_KEYS.has(bare) || NAME_KEYS.has(key)) {
1118
+ if (URL_RE2.test(value)) add(`Name field "${key}" contains a URL`, 4);
1119
+ if (MARKUP_RE.test(value)) add(`Name field "${key}" contains markup or a link tag`, 4);
1120
+ }
1121
+ if (MESSAGE_KEYS.has(bare) || MESSAGE_KEYS.has(key)) {
1122
+ const urls = value.match(GLOBAL_URL_RE) ?? [];
1123
+ const distinct = new Set(urls.map((u) => u.toLowerCase()));
1124
+ if (distinct.size >= 5) add(`Message field "${key}" contains ${distinct.size} links`, 3);
1125
+ if (MARKUP_RE.test(value)) add(`Message field "${key}" contains anchor or BBCode markup`, 2);
1126
+ }
1127
+ }
1128
+ const email = firstEmail(flat);
1129
+ if (email !== null) {
1130
+ if (!EMAIL_RE2.test(email)) {
1131
+ add(`Email "${truncate(email)}" is not a valid address`, 3);
1132
+ } else {
1133
+ const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
1134
+ if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
1135
+ add(`Email domain "${domain}" is a disposable inbox`, 4);
1136
+ } else if (PLACEHOLDER_EMAIL_DOMAINS.has(domain)) {
1137
+ add(`Email domain "${domain}" is a placeholder domain`, 3);
1138
+ }
1139
+ }
1140
+ }
1141
+ const repeated = mostRepeatedValue(flat);
1142
+ if (repeated && repeated.count >= 4) {
1143
+ add(`The same value appears in ${repeated.count} different fields`, 2);
1144
+ }
1145
+ const phone = firstPhone(flat);
1146
+ if (phone && isImplausiblePhone(phone)) {
1147
+ add(`Phone "${truncate(phone)}" is a placeholder number`, 2);
1148
+ }
1149
+ const score = signals.reduce((sum, s) => sum + s.points, 0);
1150
+ return { score, signals, honeypot: false };
1151
+ }
1152
+ function describeVerdict(verdict) {
1153
+ return verdict.signals.map((s) => s.reason).join("; ").slice(0, 500);
1154
+ }
1155
+ function isFilled(value) {
1156
+ if (value === null || value === void 0) return false;
1157
+ if (typeof value === "string") return value.trim() !== "";
1158
+ if (typeof value === "boolean") return value;
1159
+ return true;
1160
+ }
1161
+ function firstEmail(flat) {
1162
+ for (const [key, value] of Object.entries(flat)) {
1163
+ if (typeof value !== "string" || value === "") continue;
1164
+ if (/mail/i.test(key)) return value.trim();
1165
+ }
1166
+ return null;
1167
+ }
1168
+ function firstPhone(flat) {
1169
+ for (const [key, value] of Object.entries(flat)) {
1170
+ if (typeof value !== "string" || value === "") continue;
1171
+ if (/phone|mobile|tel|cell/i.test(key)) return value.trim();
1172
+ }
1173
+ return null;
1174
+ }
1175
+ function isImplausiblePhone(raw) {
1176
+ const digits = raw.replace(/\D/g, "");
1177
+ if (digits.length < 7) return false;
1178
+ if (/^(\d)\1+$/.test(digits)) return true;
1179
+ const ascending = digits.split("").every((d, i, arr) => i === 0 || Number(d) === (Number(arr[i - 1]) + 1) % 10);
1180
+ return ascending;
1181
+ }
1182
+ function mostRepeatedValue(flat) {
1183
+ const counts = /* @__PURE__ */ new Map();
1184
+ for (const value of Object.values(flat)) {
1185
+ if (typeof value !== "string") continue;
1186
+ const v = value.trim();
1187
+ if (v.length < 8) continue;
1188
+ counts.set(v, (counts.get(v) ?? 0) + 1);
1189
+ }
1190
+ let best = null;
1191
+ for (const [value, count] of counts) {
1192
+ if (!best || count > best.count) best = { value, count };
1193
+ }
1194
+ return best;
1195
+ }
1196
+ function truncate(s) {
1197
+ return s.length > 60 ? `${s.slice(0, 60)}\u2026` : s;
1198
+ }
1199
+
1200
+ // src/lib/settings.ts
1201
+ function str(key, fallback) {
1202
+ const v = process.env[key];
1203
+ return v === void 0 || v === "" ? fallback : v;
1204
+ }
1205
+ function int(key, fallback) {
1206
+ const parsed = parseInt(process.env[key] ?? "", 10);
1207
+ return Number.isFinite(parsed) ? parsed : fallback;
1208
+ }
1209
+ function bool(key, fallback) {
1210
+ const v = process.env[key];
1211
+ if (v === void 0 || v === "") return fallback;
1212
+ return v.toLowerCase() === "true";
1213
+ }
1214
+ function mergePolicy() {
1215
+ return str("INTAKE_MERGE_POLICY", "PRESERVE").toUpperCase() === "NEWEST_WINS" ? "NEWEST_WINS" : "PRESERVE";
1216
+ }
1217
+ function spamFilterEnabled() {
1218
+ return bool("INTAKE_SPAM_FILTER_ENABLED", false);
1219
+ }
1220
+ function spamScoreThreshold() {
1221
+ return int("INTAKE_SPAM_SCORE_THRESHOLD", 5);
1222
+ }
1223
+ function honeypotField() {
1224
+ return str("INTAKE_HONEYPOT_FIELD", "");
1225
+ }
491
1226
 
492
1227
  // src/logic-functions/test-ingest.ts
493
1228
  var handler = async (params) => {
494
1229
  const sourceSlug = params.pathParameters?.["slug"];
495
- if (!sourceSlug) {
496
- return { statusCode: 400, body: { error: "Missing source slug" } };
497
- }
1230
+ if (!sourceSlug) return { statusCode: 400, body: { error: "Missing source slug" } };
498
1231
  let rawPayload = {};
499
1232
  if (params.body && typeof params.body === "object" && !Array.isArray(params.body)) {
500
1233
  rawPayload = params.body;
501
1234
  }
502
- const srcResult = await coreApi.get(
503
- `/intakeSources?filter=slug[eq]:${encodeURIComponent(sourceSlug)}&first=1`
504
- ).catch(() => null);
505
- const source = srcResult?.data?.intakeSources?.edges?.[0]?.node;
1235
+ const source = await loadSource(sourceSlug);
1236
+ const rules = await loadRules(source?.id);
506
1237
  const structure = detectStructure(rawPayload);
507
- const flat = structure.type === "flat" ? structure.data : flatten(structure.type === "structured" ? structure.extra ?? {} : rawPayload);
508
- const normalized = normalizeFields(flat);
1238
+ const flat = structure.type === "flat" ? structure.data : flatten(structure.extra ?? {});
1239
+ const warnings = [];
1240
+ const normalized = normalizeFields(flat, rules, warnings);
509
1241
  const { crmFields, noteFields, skipped } = partition(normalized);
510
- const { assembled, remainder } = assembleComposites(crmFields);
511
- for (const f of remainder) assembled[f.canonicalName] = f.value;
512
- const extFields = Object.keys(assembled).filter((k) => /^ext[A-Z]/.test(k));
513
- const standardFields = Object.keys(assembled).filter((k) => !/^ext[A-Z]/.test(k));
1242
+ const honeypot = source?.honeypotField || honeypotField();
1243
+ const verdict = scoreSpam(flat, honeypot);
1244
+ const wouldQuarantine = verdict.honeypot || spamFilterEnabled() && verdict.score >= spamScoreThreshold();
1245
+ if (!source) warnings.push(`No source registered with slug "${sourceSlug}" \u2014 previewing with default settings. A live request would return 404.`);
1246
+ if (source?.status === "PAUSED") warnings.push("This source is PAUSED \u2014 a live request would return 423.");
514
1247
  const noteOverflow = {};
515
1248
  for (const f of noteFields) noteOverflow[f.canonicalName] = f.value;
1249
+ const diff = wouldQuarantine ? null : await buildDiff({
1250
+ crmFields,
1251
+ noteFields,
1252
+ skipped,
1253
+ structuredCompany: structure.type === "structured" ? structure.company ?? void 0 : void 0,
1254
+ structuredPerson: structure.type === "structured" ? structure.person ?? void 0 : void 0,
1255
+ createOpportunity: source?.createOpportunity ?? true,
1256
+ targetObject: source?.targetObject ?? "AUTO",
1257
+ policy: parsePolicy(source?.mergePolicy) ?? mergePolicy(),
1258
+ warnings
1259
+ });
516
1260
  return {
517
1261
  statusCode: 200,
518
1262
  body: {
519
1263
  dryRun: true,
520
- source: source ? { id: source.id, name: source.name } : null,
1264
+ source: source ? { id: source.id, name: source.name, slug: source.slug, status: source.status } : null,
521
1265
  payloadHash: computePayloadHash(rawPayload),
522
1266
  payloadStructure: structure.type,
523
- wouldCreate: {
524
- standardFields,
525
- customFieldsToCreate: extFields,
526
- noteFields: noteFields.map((f) => f.canonicalName),
527
- skipped: skipped.map((f) => f.originalKey)
1267
+ spam: {
1268
+ score: verdict.score,
1269
+ threshold: spamScoreThreshold(),
1270
+ filterEnabled: spamFilterEnabled(),
1271
+ honeypotField: honeypot || null,
1272
+ wouldQuarantine,
1273
+ signals: verdict.signals,
1274
+ reason: verdict.signals.length > 0 ? describeVerdict(verdict) : null
528
1275
  },
529
- notePreview: noteFields.length > 0 ? formatNote(source?.name ?? sourceSlug, noteOverflow) : null,
1276
+ mergePolicy: parsePolicy(source?.mergePolicy) ?? mergePolicy(),
1277
+ /**
1278
+ * Per object: whether it would be created or updated, which record it
1279
+ * matched, what each field would do to the value already there, and which
1280
+ * fields do not exist yet and would be added to the schema.
1281
+ */
1282
+ diff,
1283
+ note: noteFields.length > 0 ? { fields: noteFields.map((f) => f.canonicalName), preview: formatNote(source?.name ?? sourceSlug, noteOverflow) } : null,
1284
+ rulesApplied: rules.length,
1285
+ warnings,
530
1286
  rawFlat: flat
531
1287
  }
532
1288
  };
533
1289
  };
1290
+ async function loadSource(slug) {
1291
+ try {
1292
+ const res = await coreApi.get(
1293
+ `/intakeSources?filter=slug[eq]:${encodeURIComponent(slug)}&first=1`
1294
+ );
1295
+ return res.data?.intakeSources?.edges?.[0]?.node ?? null;
1296
+ } catch {
1297
+ return null;
1298
+ }
1299
+ }
1300
+ async function loadRules(sourceId) {
1301
+ try {
1302
+ const client = new CoreApiClient();
1303
+ const filter = sourceId ? { or: [{ intakeSource: { id: { eq: sourceId } } }, { intakeSource: { id: { is: "NULL" } } }] } : { intakeSource: { id: { is: "NULL" } } };
1304
+ const result = await client.query({
1305
+ intakeFieldRules: {
1306
+ __args: {
1307
+ filter: { and: [filter, { isActive: { eq: true } }] },
1308
+ orderBy: { priority: "DescNullsLast" }
1309
+ },
1310
+ edges: {
1311
+ node: {
1312
+ inputPattern: true,
1313
+ canonicalName: true,
1314
+ fieldType: true,
1315
+ priority: true,
1316
+ targetObject: true,
1317
+ mergeStrategy: true
1318
+ }
1319
+ }
1320
+ }
1321
+ });
1322
+ return (result.intakeFieldRules?.edges ?? []).map((e) => e.node);
1323
+ } catch {
1324
+ return [];
1325
+ }
1326
+ }
1327
+ function parsePolicy(raw) {
1328
+ switch ((raw ?? "").toUpperCase()) {
1329
+ case "PRESERVE":
1330
+ return "PRESERVE";
1331
+ case "NEWEST_WINS":
1332
+ return "NEWEST_WINS";
1333
+ default:
1334
+ return null;
1335
+ }
1336
+ }
534
1337
  var test_ingest_default = defineLogicFunction({
535
1338
  universalIdentifier: "3f7a9c21-8d4b-4e6f-a1c2-5b8d0f2e7a4c",
536
1339
  name: "intake-test",
537
- description: "Dry-run: validates a payload and shows what would be created without writing to the CRM.",
538
- timeoutSeconds: 15,
1340
+ description: "Dry-run: returns a structured diff of what would be created or updated, writing nothing.",
1341
+ timeoutSeconds: 20,
539
1342
  handler,
540
1343
  httpRouteTriggerSettings: {
541
1344
  path: "/intake/:slug/test",