twenty-app-intake 0.3.0 → 0.4.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 (43) hide show
  1. package/README.md +292 -7
  2. package/manifest.json +389 -15
  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 +1974 -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 +2040 -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 +1987 -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 +1001 -248
  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 +616 -28
  37. package/src/logic-functions/test-ingest.mjs.map +4 -4
  38. package/src/logic-functions/test-ingest.ts +132 -28
  39. package/src/logic-functions/webhook.mjs +965 -209
  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 -1
  43. package/src/post-install.mjs.map +2 -2
@@ -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()) {
@@ -277,7 +280,113 @@ 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
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
+ }
281
390
  function normalizeFields(flat, rules = []) {
282
391
  const sorted = [...rules].sort((a, b) => b.priority - a.priority);
283
392
  return Object.entries(flat).map(
@@ -294,7 +403,8 @@ function normalizeField(key, value, rules) {
294
403
  twentyType: builtIn.twentyType,
295
404
  action: builtIn.action,
296
405
  value,
297
- source: "builtin"
406
+ source: "builtin",
407
+ targetObject: "auto"
298
408
  };
299
409
  }
300
410
  for (const rule of rules) {
@@ -308,23 +418,29 @@ function normalizeField(key, value, rules) {
308
418
  return {
309
419
  originalKey: key,
310
420
  canonicalName: rule.canonicalName,
311
- twentyType: rule.fieldType,
312
- action: rule.action,
421
+ twentyType: typeForRule(rule),
422
+ action: actionForRule(rule),
313
423
  value,
314
- source: "rule"
424
+ source: "rule",
425
+ targetObject: parseTargetObject(rule.targetObject),
426
+ mergeStrategy: parseFieldMergeStrategy(rule.mergeStrategy)
315
427
  };
316
428
  }
317
429
  }
318
430
  const detectedType = detectType(value);
319
431
  const classification = classify(key, value);
320
- const canonicalName = classification === "field" ? toExtFieldName(key) : lookupKey;
432
+ const prefix = OPPORTUNITY_KEY_PREFIXES.find((p) => lookupKey.startsWith(p));
433
+ const targetObject = prefix ? "opportunity" : "auto";
434
+ const bareKey = prefix ? lookupKey.slice(prefix.length) : key;
435
+ const canonicalName = classification === "field" ? toExtFieldName(bareKey) : lookupKey;
321
436
  return {
322
437
  originalKey: key,
323
438
  canonicalName,
324
439
  twentyType: detectedType,
325
440
  action: classification === "field" ? "field" : classification,
326
441
  value,
327
- source: "passthrough"
442
+ source: "passthrough",
443
+ targetObject
328
444
  };
329
445
  }
330
446
  function toExtFieldName(key) {
@@ -488,54 +604,526 @@ var coreApi = {
488
604
  post: (path, body) => request(`${apiUrl()}/rest`, "POST", path, body),
489
605
  patch: (path, body) => request(`${apiUrl()}/rest`, "PATCH", path, body)
490
606
  };
607
+ var metaApi = {
608
+ get: (path) => request(`${apiUrl()}/rest/metadata`, "GET", path),
609
+ post: (path, body) => request(`${apiUrl()}/rest/metadata`, "POST", path, body)
610
+ };
611
+
612
+ // src/lib/lookup.ts
613
+ async function firstMatch(collection, query) {
614
+ try {
615
+ const res = await coreApi.get(`/${collection}?${query}&first=1`);
616
+ return res.data?.[collection]?.edges?.[0]?.node ?? null;
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
621
+ async function findPersonByEmail(email) {
622
+ if (!email) return null;
623
+ return firstMatch("people", `filter=emails.primaryEmail[eq]:${encodeURIComponent(email)}`);
624
+ }
625
+ async function findCompanyByDomain(domain) {
626
+ if (!domain) return null;
627
+ const host = extractHost(domain);
628
+ if (!host) return null;
629
+ return firstMatch(
630
+ "companies",
631
+ `filter=domainName.primaryLinkUrl[like]:%25${encodeURIComponent(host)}%25`
632
+ );
633
+ }
634
+ function extractHost(url) {
635
+ try {
636
+ return new URL(url).hostname.replace(/^www\./, "");
637
+ } catch {
638
+ return url;
639
+ }
640
+ }
641
+ function primaryEmailOf(assembled) {
642
+ const emails = assembled["emails"];
643
+ const value = emails?.["primaryEmail"];
644
+ return typeof value === "string" ? value : "";
645
+ }
646
+ function primaryDomainOf(standard) {
647
+ const domainName = standard["domainName"];
648
+ const value = domainName?.["primaryLinkUrl"];
649
+ return typeof value === "string" ? value : "";
650
+ }
651
+
652
+ // src/lib/diff.ts
653
+ var STANDARD_BY_OBJECT = {
654
+ person: /* @__PURE__ */ new Set([
655
+ "name",
656
+ "emails",
657
+ "phones",
658
+ "firstName",
659
+ "lastName",
660
+ "fullName",
661
+ "email",
662
+ "phone",
663
+ "linkedInLink",
664
+ "xLink",
665
+ "jobTitle",
666
+ "city",
667
+ "companyName",
668
+ "domainName",
669
+ "address"
670
+ ]),
671
+ company: /* @__PURE__ */ new Set([
672
+ "name",
673
+ "domainName",
674
+ "address",
675
+ "companyName",
676
+ "linkedInLink",
677
+ "xLink",
678
+ "employees",
679
+ "annualRecurringRevenue",
680
+ "idealCustomerProfile",
681
+ "city"
682
+ ]),
683
+ opportunity: /* @__PURE__ */ new Set(["name", "stage", "amount", "closeDate"])
684
+ };
685
+ async function buildDiff(input) {
686
+ const clean = input.crmFields.filter((f) => !f.originalKey.startsWith("__"));
687
+ const { assembled, remainder } = assembleComposites(clean);
688
+ for (const f of remainder) assembled[f.canonicalName] = f.value;
689
+ const objects = [];
690
+ const existingFields = await objectFieldNames();
691
+ const strategyFor = (field) => clean.find((f) => f.canonicalName === field)?.mergeStrategy ?? "INHERIT";
692
+ const companyName = input.structuredCompany?.["name"] ?? assembled["companyName"];
693
+ if (companyName || input.targetObject === "COMPANY") {
694
+ const standard = { ...input.structuredCompany ?? {} };
695
+ if (companyName && !standard["name"]) standard["name"] = companyName;
696
+ if (!standard["domainName"] && assembled["domainName"]) standard["domainName"] = assembled["domainName"];
697
+ if (!standard["address"] && assembled["address"]) standard["address"] = assembled["address"];
698
+ const domain = primaryDomainOf(standard);
699
+ const existing = domain ? await findCompanyByDomain(domain) : null;
700
+ objects.push(describe(
701
+ "company",
702
+ existing,
703
+ domain ? { field: "domainName", value: domain } : null,
704
+ { ...standard, ...customFor(clean, "company") },
705
+ clean,
706
+ existingFields["company"] ?? /* @__PURE__ */ new Set(),
707
+ input.policy,
708
+ strategyFor
709
+ ));
710
+ }
711
+ const hasPersonData = assembled["emails"] || assembled["name"] || input.structuredPerson;
712
+ if (hasPersonData || input.targetObject === "PERSON") {
713
+ const standard = { ...input.structuredPerson ?? {} };
714
+ if (!standard["name"] && assembled["name"]) standard["name"] = assembled["name"];
715
+ if (!standard["emails"] && assembled["emails"]) standard["emails"] = assembled["emails"];
716
+ if (!standard["phones"] && assembled["phones"]) standard["phones"] = assembled["phones"];
717
+ const email = primaryEmailOf(standard) || primaryEmailOf(assembled);
718
+ const existing = email ? await findPersonByEmail(email) : null;
719
+ objects.push(describe(
720
+ "person",
721
+ existing,
722
+ email ? { field: "emails.primaryEmail", value: email } : null,
723
+ { ...standard, ...customFor(clean, "person") },
724
+ clean,
725
+ existingFields["person"] ?? /* @__PURE__ */ new Set(),
726
+ input.policy,
727
+ strategyFor
728
+ ));
729
+ }
730
+ if (input.createOpportunity && objects.some((o) => o.operation !== "skip")) {
731
+ const oppCustom = customFor(clean, "opportunity");
732
+ const oppStandard = {};
733
+ for (const f of clean) {
734
+ if (f.targetObject === "opportunity" && (f.canonicalName === "amount" || f.canonicalName === "closeDate")) {
735
+ oppStandard[f.canonicalName] = f.value;
736
+ }
737
+ }
738
+ objects.push(describe(
739
+ "opportunity",
740
+ null,
741
+ null,
742
+ { ...oppStandard, ...oppCustom },
743
+ clean,
744
+ existingFields["opportunity"] ?? /* @__PURE__ */ new Set(),
745
+ input.policy,
746
+ strategyFor
747
+ ));
748
+ }
749
+ return {
750
+ objects,
751
+ noteFields: input.noteFields.map((f) => f.canonicalName),
752
+ skipped: input.skipped.map((f) => f.originalKey),
753
+ warnings: input.warnings
754
+ };
755
+ }
756
+ function customFor(fields, object) {
757
+ const out = {};
758
+ for (const f of fields) {
759
+ const target = f.targetObject === "auto" ? impliedPrimary(fields) : f.targetObject;
760
+ if (target !== object) continue;
761
+ if (STANDARD_BY_OBJECT[object]?.has(f.canonicalName)) continue;
762
+ out[f.canonicalName] = f.value;
763
+ }
764
+ return out;
765
+ }
766
+ function impliedPrimary(fields) {
767
+ const hasPerson = fields.some((f) => ["firstName", "lastName", "email", "phone", "fullName", "emails", "phones", "name"].includes(f.canonicalName));
768
+ return hasPerson ? "person" : "company";
769
+ }
770
+ function describe(object, existing, matchedBy, incoming, normalized, objectFields, policy, strategyFor) {
771
+ const { decisions } = existing ? mergePatch(existing, incoming, policy, strategyFor) : { decisions: {} };
772
+ const fields = [];
773
+ const fieldsToCreate = [];
774
+ for (const [name, value] of Object.entries(incoming)) {
775
+ const source = normalized.find((f) => f.canonicalName === name);
776
+ const isStandard = STANDARD_BY_OBJECT[object]?.has(name) ?? false;
777
+ const wouldCreate = !isStandard && !objectFields.has(name);
778
+ if (wouldCreate) fieldsToCreate.push({ name, type: source?.twentyType ?? "TEXT" });
779
+ let status = "create";
780
+ if (existing) {
781
+ const decided = decisions[name]?.status;
782
+ status = decided === "filled-empty" ? "fill-empty" : decided === "unchanged" ? "unchanged" : decided === "set" ? "overwrite" : "preserved";
783
+ }
784
+ fields.push({
785
+ name,
786
+ from: source?.source ?? "derived",
787
+ incoming: value,
788
+ ...existing ? { existing: existing[name] } : {},
789
+ status,
790
+ fieldWouldBeCreated: wouldCreate,
791
+ twentyType: source?.twentyType
792
+ });
793
+ }
794
+ return {
795
+ object,
796
+ operation: existing ? "update" : "create",
797
+ recordId: existing?.["id"] ?? null,
798
+ matchedBy,
799
+ fields,
800
+ fieldsToCreate
801
+ };
802
+ }
803
+ async function objectFieldNames() {
804
+ try {
805
+ const res = await metaApi.get("/objects");
806
+ const list = Array.isArray(res) ? res : res.data ?? [];
807
+ const out = {};
808
+ for (const node of list) {
809
+ out[node.nameSingular] = new Set((node.fields ?? []).map((f) => f.name));
810
+ }
811
+ return out;
812
+ } catch {
813
+ return {};
814
+ }
815
+ }
816
+
817
+ // src/lib/spam.ts
818
+ var HONEYPOT_SCORE = 100;
819
+ var DISPOSABLE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
820
+ "mailinator.com",
821
+ "guerrillamail.com",
822
+ "guerrillamail.net",
823
+ "sharklasers.com",
824
+ "10minutemail.com",
825
+ "10minutemail.net",
826
+ "tempmail.com",
827
+ "temp-mail.org",
828
+ "throwawaymail.com",
829
+ "yopmail.com",
830
+ "yopmail.net",
831
+ "trashmail.com",
832
+ "dispostable.com",
833
+ "getnada.com",
834
+ "nada.email",
835
+ "fakeinbox.com",
836
+ "maildrop.cc",
837
+ "mintemail.com",
838
+ "spamgourmet.com",
839
+ "mailnesia.com",
840
+ "tempinbox.com",
841
+ "emailondeck.com",
842
+ "moakt.com",
843
+ "mohmal.com",
844
+ "inboxkitten.com",
845
+ "mailcatch.com",
846
+ "mytemp.email",
847
+ "discard.email"
848
+ ]);
849
+ var PLACEHOLDER_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
850
+ "example.com",
851
+ "example.org",
852
+ "example.net",
853
+ "test.com",
854
+ "localhost",
855
+ "domain.com",
856
+ "email.com",
857
+ "yourdomain.com"
858
+ ]);
859
+ var URL_RE2 = /https?:\/\/|www\./i;
860
+ var GLOBAL_URL_RE = /https?:\/\/[^\s<>"']+/gi;
861
+ var MARKUP_RE = /<\s*a\s|<\s*script|\[url[=\]]|\[link[=\]]|\]\(https?:/i;
862
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
863
+ var NAME_KEYS = /* @__PURE__ */ new Set([
864
+ "name",
865
+ "fullName",
866
+ "firstName",
867
+ "lastName",
868
+ "contact_name",
869
+ "your_name"
870
+ ]);
871
+ var MESSAGE_KEYS = /* @__PURE__ */ new Set([
872
+ "message",
873
+ "description",
874
+ "comments",
875
+ "notes",
876
+ "body",
877
+ "details",
878
+ "content",
879
+ "text",
880
+ "inquiry",
881
+ "summary",
882
+ "feedback"
883
+ ]);
884
+ function scoreSpam(flat, honeypotKey) {
885
+ const signals = [];
886
+ if (honeypotKey) {
887
+ const hit = Object.entries(flat).find(
888
+ ([k, v]) => k.toLowerCase() === honeypotKey.toLowerCase() && isFilled(v)
889
+ );
890
+ if (hit) {
891
+ return {
892
+ score: HONEYPOT_SCORE,
893
+ signals: [{ reason: `Honeypot field "${hit[0]}" was filled`, points: HONEYPOT_SCORE }],
894
+ honeypot: true
895
+ };
896
+ }
897
+ }
898
+ const add = (reason, points) => signals.push({ reason, points });
899
+ for (const [key, value] of Object.entries(flat)) {
900
+ if (typeof value !== "string" || value === "") continue;
901
+ const bare = key.replace(/^(opportunity_|opp_|deal_)/i, "");
902
+ if (NAME_KEYS.has(bare) || NAME_KEYS.has(key)) {
903
+ if (URL_RE2.test(value)) add(`Name field "${key}" contains a URL`, 4);
904
+ if (MARKUP_RE.test(value)) add(`Name field "${key}" contains markup or a link tag`, 4);
905
+ }
906
+ if (MESSAGE_KEYS.has(bare) || MESSAGE_KEYS.has(key)) {
907
+ const urls = value.match(GLOBAL_URL_RE) ?? [];
908
+ const distinct = new Set(urls.map((u) => u.toLowerCase()));
909
+ if (distinct.size >= 5) add(`Message field "${key}" contains ${distinct.size} links`, 3);
910
+ if (MARKUP_RE.test(value)) add(`Message field "${key}" contains anchor or BBCode markup`, 2);
911
+ }
912
+ }
913
+ const email = firstEmail(flat);
914
+ if (email !== null) {
915
+ if (!EMAIL_RE2.test(email)) {
916
+ add(`Email "${truncate(email)}" is not a valid address`, 3);
917
+ } else {
918
+ const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
919
+ if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
920
+ add(`Email domain "${domain}" is a disposable inbox`, 4);
921
+ } else if (PLACEHOLDER_EMAIL_DOMAINS.has(domain)) {
922
+ add(`Email domain "${domain}" is a placeholder domain`, 3);
923
+ }
924
+ }
925
+ }
926
+ const repeated = mostRepeatedValue(flat);
927
+ if (repeated && repeated.count >= 4) {
928
+ add(`The same value appears in ${repeated.count} different fields`, 2);
929
+ }
930
+ const phone = firstPhone(flat);
931
+ if (phone && isImplausiblePhone(phone)) {
932
+ add(`Phone "${truncate(phone)}" is a placeholder number`, 2);
933
+ }
934
+ const score = signals.reduce((sum, s) => sum + s.points, 0);
935
+ return { score, signals, honeypot: false };
936
+ }
937
+ function describeVerdict(verdict) {
938
+ return verdict.signals.map((s) => s.reason).join("; ").slice(0, 500);
939
+ }
940
+ function isFilled(value) {
941
+ if (value === null || value === void 0) return false;
942
+ if (typeof value === "string") return value.trim() !== "";
943
+ if (typeof value === "boolean") return value;
944
+ return true;
945
+ }
946
+ function firstEmail(flat) {
947
+ for (const [key, value] of Object.entries(flat)) {
948
+ if (typeof value !== "string" || value === "") continue;
949
+ if (/mail/i.test(key)) return value.trim();
950
+ }
951
+ return null;
952
+ }
953
+ function firstPhone(flat) {
954
+ for (const [key, value] of Object.entries(flat)) {
955
+ if (typeof value !== "string" || value === "") continue;
956
+ if (/phone|mobile|tel|cell/i.test(key)) return value.trim();
957
+ }
958
+ return null;
959
+ }
960
+ function isImplausiblePhone(raw) {
961
+ const digits = raw.replace(/\D/g, "");
962
+ if (digits.length < 7) return false;
963
+ if (/^(\d)\1+$/.test(digits)) return true;
964
+ const ascending = digits.split("").every((d, i, arr) => i === 0 || Number(d) === (Number(arr[i - 1]) + 1) % 10);
965
+ return ascending;
966
+ }
967
+ function mostRepeatedValue(flat) {
968
+ const counts = /* @__PURE__ */ new Map();
969
+ for (const value of Object.values(flat)) {
970
+ if (typeof value !== "string") continue;
971
+ const v = value.trim();
972
+ if (v.length < 8) continue;
973
+ counts.set(v, (counts.get(v) ?? 0) + 1);
974
+ }
975
+ let best = null;
976
+ for (const [value, count] of counts) {
977
+ if (!best || count > best.count) best = { value, count };
978
+ }
979
+ return best;
980
+ }
981
+ function truncate(s) {
982
+ return s.length > 60 ? `${s.slice(0, 60)}\u2026` : s;
983
+ }
984
+
985
+ // src/lib/settings.ts
986
+ function str(key, fallback) {
987
+ const v = process.env[key];
988
+ return v === void 0 || v === "" ? fallback : v;
989
+ }
990
+ function int(key, fallback) {
991
+ const parsed = parseInt(process.env[key] ?? "", 10);
992
+ return Number.isFinite(parsed) ? parsed : fallback;
993
+ }
994
+ function bool(key, fallback) {
995
+ const v = process.env[key];
996
+ if (v === void 0 || v === "") return fallback;
997
+ return v.toLowerCase() === "true";
998
+ }
999
+ function mergePolicy() {
1000
+ return str("INTAKE_MERGE_POLICY", "PRESERVE").toUpperCase() === "NEWEST_WINS" ? "NEWEST_WINS" : "PRESERVE";
1001
+ }
1002
+ function spamFilterEnabled() {
1003
+ return bool("INTAKE_SPAM_FILTER_ENABLED", false);
1004
+ }
1005
+ function spamScoreThreshold() {
1006
+ return int("INTAKE_SPAM_SCORE_THRESHOLD", 5);
1007
+ }
1008
+ function honeypotField() {
1009
+ return str("INTAKE_HONEYPOT_FIELD", "");
1010
+ }
491
1011
 
492
1012
  // src/logic-functions/test-ingest.ts
493
1013
  var handler = async (params) => {
494
1014
  const sourceSlug = params.pathParameters?.["slug"];
495
- if (!sourceSlug) {
496
- return { statusCode: 400, body: { error: "Missing source slug" } };
497
- }
1015
+ if (!sourceSlug) return { statusCode: 400, body: { error: "Missing source slug" } };
498
1016
  let rawPayload = {};
499
1017
  if (params.body && typeof params.body === "object" && !Array.isArray(params.body)) {
500
1018
  rawPayload = params.body;
501
1019
  }
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;
1020
+ const source = await loadSource(sourceSlug);
1021
+ const rules = await loadRules(source?.id);
506
1022
  const structure = detectStructure(rawPayload);
507
- const flat = structure.type === "flat" ? structure.data : flatten(structure.type === "structured" ? structure.extra ?? {} : rawPayload);
508
- const normalized = normalizeFields(flat);
1023
+ const flat = structure.type === "flat" ? structure.data : flatten(structure.extra ?? {});
1024
+ const normalized = normalizeFields(flat, rules);
509
1025
  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));
1026
+ const honeypot = source?.honeypotField || honeypotField();
1027
+ const verdict = scoreSpam(flat, honeypot);
1028
+ const wouldQuarantine = verdict.honeypot || spamFilterEnabled() && verdict.score >= spamScoreThreshold();
1029
+ const warnings = [];
1030
+ if (!source) warnings.push(`No source registered with slug "${sourceSlug}" \u2014 previewing with default settings. A live request would return 404.`);
1031
+ if (source?.status === "PAUSED") warnings.push("This source is PAUSED \u2014 a live request would return 423.");
514
1032
  const noteOverflow = {};
515
1033
  for (const f of noteFields) noteOverflow[f.canonicalName] = f.value;
1034
+ const diff = wouldQuarantine ? null : await buildDiff({
1035
+ crmFields,
1036
+ noteFields,
1037
+ skipped,
1038
+ structuredCompany: structure.type === "structured" ? structure.company ?? void 0 : void 0,
1039
+ structuredPerson: structure.type === "structured" ? structure.person ?? void 0 : void 0,
1040
+ createOpportunity: source?.createOpportunity ?? true,
1041
+ targetObject: source?.targetObject ?? "AUTO",
1042
+ policy: parsePolicy(source?.mergePolicy) ?? mergePolicy(),
1043
+ warnings
1044
+ });
516
1045
  return {
517
1046
  statusCode: 200,
518
1047
  body: {
519
1048
  dryRun: true,
520
- source: source ? { id: source.id, name: source.name } : null,
1049
+ source: source ? { id: source.id, name: source.name, slug: source.slug, status: source.status } : null,
521
1050
  payloadHash: computePayloadHash(rawPayload),
522
1051
  payloadStructure: structure.type,
523
- wouldCreate: {
524
- standardFields,
525
- customFieldsToCreate: extFields,
526
- noteFields: noteFields.map((f) => f.canonicalName),
527
- skipped: skipped.map((f) => f.originalKey)
1052
+ spam: {
1053
+ score: verdict.score,
1054
+ threshold: spamScoreThreshold(),
1055
+ filterEnabled: spamFilterEnabled(),
1056
+ honeypotField: honeypot || null,
1057
+ wouldQuarantine,
1058
+ signals: verdict.signals,
1059
+ reason: verdict.signals.length > 0 ? describeVerdict(verdict) : null
528
1060
  },
529
- notePreview: noteFields.length > 0 ? formatNote(source?.name ?? sourceSlug, noteOverflow) : null,
1061
+ mergePolicy: parsePolicy(source?.mergePolicy) ?? mergePolicy(),
1062
+ /**
1063
+ * Per object: whether it would be created or updated, which record it
1064
+ * matched, what each field would do to the value already there, and which
1065
+ * fields do not exist yet and would be added to the schema.
1066
+ */
1067
+ diff,
1068
+ note: noteFields.length > 0 ? { fields: noteFields.map((f) => f.canonicalName), preview: formatNote(source?.name ?? sourceSlug, noteOverflow) } : null,
1069
+ rulesApplied: rules.length,
1070
+ warnings,
530
1071
  rawFlat: flat
531
1072
  }
532
1073
  };
533
1074
  };
1075
+ async function loadSource(slug) {
1076
+ try {
1077
+ const res = await coreApi.get(
1078
+ `/intakeSources?filter=slug[eq]:${encodeURIComponent(slug)}&first=1`
1079
+ );
1080
+ return res.data?.intakeSources?.edges?.[0]?.node ?? null;
1081
+ } catch {
1082
+ return null;
1083
+ }
1084
+ }
1085
+ async function loadRules(sourceId) {
1086
+ try {
1087
+ const client = new CoreApiClient();
1088
+ const filter = sourceId ? { or: [{ intakeSource: { id: { eq: sourceId } } }, { intakeSource: { id: { is: "NULL" } } }] } : { intakeSource: { id: { is: "NULL" } } };
1089
+ const result = await client.query({
1090
+ intakeFieldRules: {
1091
+ __args: {
1092
+ filter: { and: [filter, { isActive: { eq: true } }] },
1093
+ orderBy: { priority: "DescNullsLast" }
1094
+ },
1095
+ edges: {
1096
+ node: {
1097
+ inputPattern: true,
1098
+ canonicalName: true,
1099
+ fieldType: true,
1100
+ priority: true,
1101
+ targetObject: true,
1102
+ mergeStrategy: true
1103
+ }
1104
+ }
1105
+ }
1106
+ });
1107
+ return (result.intakeFieldRules?.edges ?? []).map((e) => e.node);
1108
+ } catch {
1109
+ return [];
1110
+ }
1111
+ }
1112
+ function parsePolicy(raw) {
1113
+ switch ((raw ?? "").toUpperCase()) {
1114
+ case "PRESERVE":
1115
+ return "PRESERVE";
1116
+ case "NEWEST_WINS":
1117
+ return "NEWEST_WINS";
1118
+ default:
1119
+ return null;
1120
+ }
1121
+ }
534
1122
  var test_ingest_default = defineLogicFunction({
535
1123
  universalIdentifier: "3f7a9c21-8d4b-4e6f-a1c2-5b8d0f2e7a4c",
536
1124
  name: "intake-test",
537
- description: "Dry-run: validates a payload and shows what would be created without writing to the CRM.",
538
- timeoutSeconds: 15,
1125
+ description: "Dry-run: returns a structured diff of what would be created or updated, writing nothing.",
1126
+ timeoutSeconds: 20,
539
1127
  handler,
540
1128
  httpRouteTriggerSettings: {
541
1129
  path: "/intake/:slug/test",