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,10 +21,6 @@ var __anyHandler = {
21
21
  var __anyStub = new Proxy(() => void 0, __anyHandler);
22
22
  var defineLogicFunction = __defineFactoryStub;
23
23
 
24
- // src/logic-functions/retry.ts
25
- import { CoreApiClient } from "twenty-client-sdk/core";
26
- import { MetadataApiClient } from "twenty-client-sdk/metadata";
27
-
28
24
  // src/constants/universal-identifiers.ts
29
25
  var IDS = {
30
26
  APPLICATION: "96a389dd-384a-43a1-a376-9d3cf01b6ab7",
@@ -63,6 +59,7 @@ var IDS = {
63
59
  INTAKE_FIELD_RULE_FIELD_TYPE: "72d1f6d8-d63e-419e-a244-8127d7b4705f",
64
60
  INTAKE_FIELD_RULE_PRIORITY: "911df299-940b-44f9-92f7-3fd01fde01a7",
65
61
  INTAKE_FIELD_RULE_IS_ACTIVE: "6f9cd96d-aecd-437f-a7b5-d45548782771",
62
+ INTAKE_FIELD_RULE_TARGET_OBJECT: "7b8411a7-5e1d-40f2-a20c-683c07f67926",
66
63
  // Logic Functions
67
64
  WEBHOOK_LOGIC_FUNCTION: "8323c257-48fe-4bb6-b1dd-166844f575c0",
68
65
  HEALTH_LOGIC_FUNCTION: "4ece4e39-286b-4820-81f4-b7beee71d092",
@@ -88,9 +85,76 @@ var IDS = {
88
85
  // Views & Navigation
89
86
  SOURCES_VIEW: "4435cd43-5c13-472f-b4f9-b686c0f46285",
90
87
  LOGS_VIEW: "a2cd0a93-9778-4ba5-99d9-b8b89c4fb39d",
91
- INTAKE_NAV_ITEM: "e81bf288-5686-4713-86ae-e2947b124cab"
88
+ INTAKE_NAV_ITEM: "e81bf288-5686-4713-86ae-e2947b124cab",
89
+ // ── v0.4.0 ────────────────────────────────────────────────────────────────
90
+ // IntakeLog fields — quarantine, idempotency, replay lineage
91
+ INTAKE_LOG_IDEMPOTENCY_KEY: "bf91e4c0-6477-41f2-a9bd-fb159d3091d4",
92
+ INTAKE_LOG_SPAM_SCORE: "4e250af2-925f-4724-85f6-f353a6bb4cfd",
93
+ INTAKE_LOG_QUARANTINE_REASON: "323b3c3e-a111-45d1-a211-cf70efa6cd9e",
94
+ INTAKE_LOG_REPLAY_OF: "b68876b1-0045-4eaa-a4e7-f0634e110ef1",
95
+ // IntakeSource fields — silence detection, spam, merge policy
96
+ INTAKE_SOURCE_EXPECTED_CADENCE: "a97c3906-5555-4e60-a6d6-84155a4e00a6",
97
+ INTAKE_SOURCE_HEALTH_STATUS: "2ad13b20-5bd7-450a-b52d-bcf208373cf5",
98
+ INTAKE_SOURCE_SILENT_SINCE: "d3395601-f29f-4e57-98eb-561ab011cc72",
99
+ INTAKE_SOURCE_ALERT_WEBHOOK: "5a066d62-0426-41c1-b34c-1a7e881ce854",
100
+ INTAKE_SOURCE_LAST_ALERT_AT: "fc64a598-ccbe-44e3-bd42-716e32d626a2",
101
+ INTAKE_SOURCE_HONEYPOT_FIELD: "5c145686-50de-496c-bb45-a30c8b39034c",
102
+ INTAKE_SOURCE_MERGE_POLICY: "5a946f85-b1c2-493e-92c6-31c50e637a7e",
103
+ // IntakeFieldRule fields
104
+ INTAKE_FIELD_RULE_MERGE_STRATEGY: "40c6fdcb-527a-4691-8518-c40a3d3e34c6",
105
+ // Logic Functions
106
+ CONTRACT_LOGIC_FUNCTION: "2eea4152-1b08-4b8c-903c-f4c7ee7275d0",
107
+ REPLAY_LOG_LOGIC_FUNCTION: "8ff98390-7358-4a91-84f3-435f758182a3",
108
+ REPLAY_BULK_LOGIC_FUNCTION: "b82ed4c4-07bc-4095-8ee5-611200626dc0",
109
+ QUARANTINE_LIST_LOGIC_FUNCTION: "0203643c-2a97-4e7c-b4a5-b76d721cf23a",
110
+ QUARANTINE_RELEASE_LOGIC_FUNCTION: "5a10d4fe-c807-45c6-9e61-c191d4e37b4e",
111
+ QUARANTINE_DISCARD_LOGIC_FUNCTION: "1454a56e-6197-4dfd-9b41-5cc3b1436af7",
112
+ CHECK_SILENCE_LOGIC_FUNCTION: "e88cd5f2-ab96-4f89-ac55-78db66b32d66",
113
+ // Application variables
114
+ VAR_MERGE_POLICY: "a84fe9fd-3d7c-41d9-b0e8-dfdc1a87f348",
115
+ VAR_SPAM_FILTER_ENABLED: "570d3ba8-1904-4bad-a5ce-27e208e6414a",
116
+ VAR_SPAM_SCORE_THRESHOLD: "4395574d-24b4-4e0e-afa3-1e90fcd4f470",
117
+ VAR_HONEYPOT_FIELD: "2cf56c56-f5cb-47c5-aa43-84f301060a95",
118
+ VAR_RAW_PAYLOAD_RETENTION: "f3314ad6-05b9-4127-bb88-cea5f2018dc9",
119
+ VAR_RAW_PAYLOAD_MAX_BYTES: "b002ca76-df3d-47b4-8ac5-d3f562239c48",
120
+ VAR_REPLAY_MAX_BATCH: "cf55956f-177e-4afd-8d9f-7f42c614209c"
92
121
  };
93
122
 
123
+ // src/lib/rest-client.ts
124
+ var apiUrl = () => process.env["TWENTY_API_URL"] ?? "";
125
+ var token = () => process.env["TWENTY_APP_ACCESS_TOKEN"] ?? "";
126
+ async function request(base, method, path, body) {
127
+ const res = await fetch(`${base}${path}`, {
128
+ method,
129
+ headers: {
130
+ Authorization: `Bearer ${token()}`,
131
+ "Content-Type": "application/json"
132
+ },
133
+ body: body !== void 0 ? JSON.stringify(body) : void 0
134
+ });
135
+ const text = await res.text();
136
+ if (!res.ok) {
137
+ throw new Error(`${method} ${path} \u2192 ${res.status}: ${text}`);
138
+ }
139
+ return JSON.parse(text);
140
+ }
141
+ var coreApi = {
142
+ get: (path) => request(`${apiUrl()}/rest`, "GET", path),
143
+ post: (path, body) => request(`${apiUrl()}/rest`, "POST", path, body),
144
+ patch: (path, body) => request(`${apiUrl()}/rest`, "PATCH", path, body)
145
+ };
146
+ var metaApi = {
147
+ get: (path) => request(`${apiUrl()}/rest/metadata`, "GET", path),
148
+ post: (path, body) => request(`${apiUrl()}/rest/metadata`, "POST", path, body)
149
+ };
150
+ async function gql(query, variables) {
151
+ return request(`${apiUrl()}`, "POST", "/graphql", { query, variables });
152
+ }
153
+
154
+ // src/lib/ingest.ts
155
+ import { CoreApiClient } from "twenty-client-sdk/core";
156
+ import { MetadataApiClient } from "twenty-client-sdk/metadata";
157
+
94
158
  // src/lib/pipeline.ts
95
159
  var Pipeline = class {
96
160
  stages = [];
@@ -122,6 +186,10 @@ var ErrorCode = {
122
186
  SOURCE_NOT_FOUND: "SOURCE_NOT_FOUND",
123
187
  SOURCE_PAUSED: "SOURCE_PAUSED",
124
188
  DUPLICATE_PAYLOAD: "DUPLICATE_PAYLOAD",
189
+ /** Held by the spam filter — recorded, not written, and releasable. */
190
+ QUARANTINED: "QUARANTINED",
191
+ /** Retention is off or the payload was too large, so there is nothing to replay. */
192
+ NO_STORED_PAYLOAD: "NO_STORED_PAYLOAD",
125
193
  FIELD_CREATE_FAILED: "FIELD_CREATE_FAILED",
126
194
  RECORD_UPSERT_FAILED: "RECORD_UPSERT_FAILED",
127
195
  NOTE_CREATE_FAILED: "NOTE_CREATE_FAILED",
@@ -132,6 +200,21 @@ var ok = (value) => ({ ok: true, value });
132
200
  var err = (code, message, details) => ({ ok: false, error: { code, message, details } });
133
201
 
134
202
  // src/lib/stages/load-source.stage.ts
203
+ var BASE_FIELDS = {
204
+ id: true,
205
+ name: true,
206
+ slug: true,
207
+ targetObject: true,
208
+ webhookSecret: true,
209
+ status: true,
210
+ createOpportunity: true,
211
+ opportunityNameTemplate: true
212
+ };
213
+ var V4_FIELDS = {
214
+ ...BASE_FIELDS,
215
+ honeypotField: true,
216
+ mergePolicy: true
217
+ };
135
218
  var LoadSourceStage = class {
136
219
  constructor(client) {
137
220
  this.client = client;
@@ -139,24 +222,7 @@ var LoadSourceStage = class {
139
222
  client;
140
223
  name = "load-source";
141
224
  async execute(ctx) {
142
- const result = await this.client.query({
143
- intakeSources: {
144
- __args: { filter: { slug: { eq: ctx.sourceSlug } } },
145
- edges: {
146
- node: {
147
- id: true,
148
- name: true,
149
- slug: true,
150
- targetObject: true,
151
- webhookSecret: true,
152
- status: true,
153
- createOpportunity: true,
154
- opportunityNameTemplate: true
155
- }
156
- }
157
- }
158
- });
159
- const node = result.intakeSources?.edges?.[0]?.node;
225
+ const node = await this.fetch(ctx.sourceSlug);
160
226
  if (!node) {
161
227
  return err(ErrorCode.SOURCE_NOT_FOUND, `No Intake source found with slug "${ctx.sourceSlug}"`);
162
228
  }
@@ -173,11 +239,48 @@ var LoadSourceStage = class {
173
239
  webhookSecret: node.webhookSecret ?? null,
174
240
  status: node.status,
175
241
  createOpportunity: node.createOpportunity ?? true,
176
- opportunityNameTemplate: node.opportunityNameTemplate ?? "{{source}} \u2014 {{firstName}} {{lastName}}"
242
+ opportunityNameTemplate: node.opportunityNameTemplate ?? "{{source}} \u2014 {{firstName}} {{lastName}}",
243
+ honeypotField: node.honeypotField ?? null,
244
+ mergePolicy: parseMergePolicy(node.mergePolicy)
177
245
  }
178
246
  });
179
247
  }
248
+ /**
249
+ * Ask for the 0.4.0 columns, and fall back to the original set if they are not
250
+ * there yet. A workspace that upgraded the package but has not re-synced its
251
+ * schema would otherwise fail the query and reject every request as an unknown
252
+ * source — an upgrade that takes the webhook offline, which is not acceptable
253
+ * for something sitting in front of someone's contact form.
254
+ */
255
+ async fetch(slug) {
256
+ const args = { filter: { slug: { eq: slug } } };
257
+ try {
258
+ const result = await this.client.query({
259
+ intakeSources: { __args: args, edges: { node: V4_FIELDS } }
260
+ });
261
+ return result.intakeSources?.edges?.[0]?.node ?? null;
262
+ } catch {
263
+ try {
264
+ const result = await this.client.query({
265
+ intakeSources: { __args: args, edges: { node: BASE_FIELDS } }
266
+ });
267
+ return result.intakeSources?.edges?.[0]?.node ?? null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+ }
180
273
  };
274
+ function parseMergePolicy(raw) {
275
+ switch ((raw ?? "").toUpperCase()) {
276
+ case "PRESERVE":
277
+ return "PRESERVE";
278
+ case "NEWEST_WINS":
279
+ return "NEWEST_WINS";
280
+ default:
281
+ return null;
282
+ }
283
+ }
181
284
 
182
285
  // src/lib/stages/verify-signature.stage.ts
183
286
  import { createHmac, timingSafeEqual } from "node:crypto";
@@ -228,6 +331,48 @@ function withinWindow(processedAt, windowMs = 5 * 60 * 1e3) {
228
331
  return Date.now() - processedAt.getTime() < windowMs;
229
332
  }
230
333
 
334
+ // src/lib/settings.ts
335
+ function str(key, fallback) {
336
+ const v = process.env[key];
337
+ return v === void 0 || v === "" ? fallback : v;
338
+ }
339
+ function int(key, fallback) {
340
+ const parsed = parseInt(process.env[key] ?? "", 10);
341
+ return Number.isFinite(parsed) ? parsed : fallback;
342
+ }
343
+ function bool(key, fallback) {
344
+ const v = process.env[key];
345
+ if (v === void 0 || v === "") return fallback;
346
+ return v.toLowerCase() === "true";
347
+ }
348
+ function appLabel() {
349
+ return str("INTAKE_APP_LABEL", "Intake");
350
+ }
351
+ function defaultOpportunityStage() {
352
+ return str("INTAKE_DEFAULT_OPP_STAGE", "NEW");
353
+ }
354
+ function dedupWindowMinutes() {
355
+ return int("INTAKE_DEDUP_WINDOW_MINUTES", 5);
356
+ }
357
+ function mergePolicy() {
358
+ return str("INTAKE_MERGE_POLICY", "PRESERVE").toUpperCase() === "NEWEST_WINS" ? "NEWEST_WINS" : "PRESERVE";
359
+ }
360
+ function spamFilterEnabled() {
361
+ return bool("INTAKE_SPAM_FILTER_ENABLED", false);
362
+ }
363
+ function spamScoreThreshold() {
364
+ return int("INTAKE_SPAM_SCORE_THRESHOLD", 5);
365
+ }
366
+ function honeypotField() {
367
+ return str("INTAKE_HONEYPOT_FIELD", "");
368
+ }
369
+ function rawPayloadRetention() {
370
+ return str("INTAKE_RAW_PAYLOAD_RETENTION", "FULL").toUpperCase() === "NONE" ? "NONE" : "FULL";
371
+ }
372
+ function rawPayloadMaxBytes() {
373
+ return int("INTAKE_RAW_PAYLOAD_MAX_BYTES", 65e3);
374
+ }
375
+
231
376
  // src/lib/stages/idempotency.stage.ts
232
377
  var IdempotencyStage = class {
233
378
  constructor(client) {
@@ -237,34 +382,65 @@ var IdempotencyStage = class {
237
382
  name = "idempotency";
238
383
  async execute(ctx) {
239
384
  const hash = computePayloadHash(ctx.rawPayload);
240
- if (ctx.skipDedup) return ok({ ...ctx, payloadHash: hash });
241
- const existing = await this.client.query({
242
- intakeLogs: {
243
- __args: {
244
- filter: { payloadHash: { eq: hash } },
245
- orderBy: { processedAt: "DescNullsLast" },
246
- first: 1
247
- },
248
- edges: {
249
- node: {
250
- id: true,
251
- status: true,
252
- recordId: true,
253
- processedAt: true
254
- }
255
- }
385
+ const next = { ...ctx, payloadHash: hash };
386
+ if (ctx.skipDedup) return ok(next);
387
+ if (ctx.idempotencyKey) {
388
+ const prior2 = await this.findByKey(ctx.idempotencyKey);
389
+ if (prior2) {
390
+ return err(
391
+ ErrorCode.DUPLICATE_PAYLOAD,
392
+ `Idempotency key already processed (log ${prior2.id})`,
393
+ { priorLogId: prior2.id, priorRecordId: prior2.recordId, idempotencyKey: ctx.idempotencyKey }
394
+ );
256
395
  }
257
- });
258
- const prior = existing.intakeLogs?.edges?.[0]?.node;
259
- const windowMins = parseInt(process.env["INTAKE_DEDUP_WINDOW_MINUTES"] ?? "5", 10);
260
- if (prior && withinWindow(new Date(prior.processedAt), windowMins * 60 * 1e3)) {
396
+ }
397
+ const windowMins = dedupWindowMinutes();
398
+ if (windowMins <= 0) return ok(next);
399
+ const prior = await this.findByHash(hash);
400
+ if (prior?.processedAt && withinWindow(new Date(prior.processedAt), windowMins * 60 * 1e3)) {
261
401
  return err(
262
402
  ErrorCode.DUPLICATE_PAYLOAD,
263
- `Duplicate payload \u2014 already processed ${prior.status.toLowerCase()} (log ${prior.id})`,
403
+ `Duplicate payload \u2014 already processed ${String(prior.status).toLowerCase()} (log ${prior.id})`,
264
404
  { priorLogId: prior.id, priorRecordId: prior.recordId }
265
405
  );
266
406
  }
267
- return ok({ ...ctx, payloadHash: hash });
407
+ return ok(next);
408
+ }
409
+ async findByKey(key) {
410
+ try {
411
+ const res = await this.client.query({
412
+ intakeLogs: {
413
+ __args: {
414
+ filter: { idempotencyKey: { eq: key } },
415
+ orderBy: { processedAt: "DescNullsLast" },
416
+ first: 1
417
+ },
418
+ edges: { node: { id: true, status: true, recordId: true, processedAt: true } }
419
+ }
420
+ });
421
+ const node = res.intakeLogs?.edges?.[0]?.node ?? null;
422
+ if (node && (node.status === "QUARANTINED" || node.status === "DISCARDED")) return null;
423
+ return node;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+ async findByHash(hash) {
429
+ try {
430
+ const res = await this.client.query({
431
+ intakeLogs: {
432
+ __args: {
433
+ filter: { payloadHash: { eq: hash } },
434
+ orderBy: { processedAt: "DescNullsLast" },
435
+ first: 1
436
+ },
437
+ edges: { node: { id: true, status: true, recordId: true, processedAt: true } }
438
+ }
439
+ });
440
+ return res.intakeLogs?.edges?.[0]?.node ?? null;
441
+ } catch {
442
+ return null;
443
+ }
268
444
  }
269
445
  };
270
446
 
@@ -542,7 +718,113 @@ function classify(key, value) {
542
718
  return "field";
543
719
  }
544
720
 
721
+ // src/lib/merge.ts
722
+ function decideMerge(existing, incoming, policy, strategy = "INHERIT") {
723
+ if (isEmpty(incoming)) return { write: false, status: "preserved" };
724
+ if (isEmpty(existing)) return { write: true, status: "filled-empty" };
725
+ if (sameValue(existing, incoming)) return { write: false, status: "unchanged" };
726
+ const effective = strategy === "INHERIT" ? policy : strategy;
727
+ return effective === "NEWEST_WINS" ? { write: true, status: "set" } : { write: false, status: "preserved" };
728
+ }
729
+ function mergePatch(existing, incoming, policy, strategyFor = () => "INHERIT") {
730
+ const patch = {};
731
+ const decisions = {};
732
+ for (const [key, value] of Object.entries(incoming)) {
733
+ const decision = decideMerge(existing[key], value, policy, strategyFor(key));
734
+ decisions[key] = decision;
735
+ if (decision.write) patch[key] = value;
736
+ }
737
+ return { patch, decisions };
738
+ }
739
+ function parseFieldMergeStrategy(raw) {
740
+ switch ((raw ?? "").toUpperCase()) {
741
+ case "PRESERVE":
742
+ return "PRESERVE";
743
+ case "NEWEST_WINS":
744
+ return "NEWEST_WINS";
745
+ default:
746
+ return "INHERIT";
747
+ }
748
+ }
749
+ function isEmpty(value) {
750
+ if (value === null || value === void 0) return true;
751
+ if (typeof value === "string") return value.trim() === "";
752
+ if (Array.isArray(value)) return value.length === 0;
753
+ if (typeof value === "object") {
754
+ const entries = Object.entries(value);
755
+ if (entries.length === 0) return true;
756
+ const meaningful = entries.filter(([k]) => !IGNORED_COMPOSITE_KEYS.has(k));
757
+ if (meaningful.length === 0) return true;
758
+ return meaningful.every(([, v]) => isEmpty(v));
759
+ }
760
+ return false;
761
+ }
762
+ var IGNORED_COMPOSITE_KEYS = /* @__PURE__ */ new Set([
763
+ "currencyCode",
764
+ "primaryLinkLabel",
765
+ "primaryPhoneCountryCode",
766
+ "primaryPhoneCallingCode",
767
+ "additionalLinks",
768
+ "additionalEmails",
769
+ "additionalPhones"
770
+ ]);
771
+ function sameValue(a, b) {
772
+ if (a === b) return true;
773
+ if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
774
+ try {
775
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
776
+ } catch {
777
+ return false;
778
+ }
779
+ }
780
+ return String(a) === String(b);
781
+ }
782
+ function sortKeys(value) {
783
+ if (value === null || typeof value !== "object") return value;
784
+ if (Array.isArray(value)) return value.map(sortKeys);
785
+ const obj = value;
786
+ const out = {};
787
+ for (const key of Object.keys(obj).sort()) out[key] = sortKeys(obj[key]);
788
+ return out;
789
+ }
790
+
545
791
  // src/lib/normalizer.ts
792
+ function actionForRule(rule) {
793
+ if (rule.action) return rule.action;
794
+ switch ((rule.fieldType ?? "").toUpperCase()) {
795
+ case "SKIP":
796
+ return "skip";
797
+ case "NOTE":
798
+ return "note";
799
+ default:
800
+ return "field";
801
+ }
802
+ }
803
+ function typeForRule(rule) {
804
+ switch ((rule.fieldType ?? "").toUpperCase()) {
805
+ case "NOTE":
806
+ return "RICH_TEXT";
807
+ case "SKIP":
808
+ return "TEXT";
809
+ case "":
810
+ return "TEXT";
811
+ default:
812
+ return rule.fieldType;
813
+ }
814
+ }
815
+ var OPPORTUNITY_KEY_PREFIXES = ["opportunity_", "opp_", "deal_"];
816
+ function parseTargetObject(raw) {
817
+ switch ((raw ?? "").toUpperCase()) {
818
+ case "PERSON":
819
+ return "person";
820
+ case "COMPANY":
821
+ return "company";
822
+ case "OPPORTUNITY":
823
+ return "opportunity";
824
+ default:
825
+ return "auto";
826
+ }
827
+ }
546
828
  function normalizeFields(flat, rules = []) {
547
829
  const sorted = [...rules].sort((a, b) => b.priority - a.priority);
548
830
  return Object.entries(flat).map(
@@ -559,7 +841,8 @@ function normalizeField(key, value, rules) {
559
841
  twentyType: builtIn.twentyType,
560
842
  action: builtIn.action,
561
843
  value,
562
- source: "builtin"
844
+ source: "builtin",
845
+ targetObject: "auto"
563
846
  };
564
847
  }
565
848
  for (const rule of rules) {
@@ -573,23 +856,29 @@ function normalizeField(key, value, rules) {
573
856
  return {
574
857
  originalKey: key,
575
858
  canonicalName: rule.canonicalName,
576
- twentyType: rule.fieldType,
577
- action: rule.action,
859
+ twentyType: typeForRule(rule),
860
+ action: actionForRule(rule),
578
861
  value,
579
- source: "rule"
862
+ source: "rule",
863
+ targetObject: parseTargetObject(rule.targetObject),
864
+ mergeStrategy: parseFieldMergeStrategy(rule.mergeStrategy)
580
865
  };
581
866
  }
582
867
  }
583
868
  const detectedType = detectType(value);
584
869
  const classification = classify(key, value);
585
- const canonicalName = classification === "field" ? toExtFieldName(key) : lookupKey;
870
+ const prefix = OPPORTUNITY_KEY_PREFIXES.find((p) => lookupKey.startsWith(p));
871
+ const targetObject = prefix ? "opportunity" : "auto";
872
+ const bareKey = prefix ? lookupKey.slice(prefix.length) : key;
873
+ const canonicalName = classification === "field" ? toExtFieldName(bareKey) : lookupKey;
586
874
  return {
587
875
  originalKey: key,
588
876
  canonicalName,
589
877
  twentyType: detectedType,
590
878
  action: classification === "field" ? "field" : classification,
591
879
  value,
592
- source: "passthrough"
880
+ source: "passthrough",
881
+ targetObject
593
882
  };
594
883
  }
595
884
  function toExtFieldName(key) {
@@ -700,6 +989,32 @@ var NormalizeStage = class {
700
989
  });
701
990
  }
702
991
  async loadRules(sourceId) {
992
+ try {
993
+ const filter = sourceId ? { or: [{ intakeSource: { id: { eq: sourceId } } }, { intakeSource: { id: { is: "NULL" } } }] } : { intakeSource: { id: { is: "NULL" } } };
994
+ const result = await this.client.query({
995
+ intakeFieldRules: {
996
+ __args: {
997
+ filter: { and: [filter, { isActive: { eq: true } }] },
998
+ orderBy: { priority: "DescNullsLast" }
999
+ },
1000
+ edges: {
1001
+ node: {
1002
+ inputPattern: true,
1003
+ canonicalName: true,
1004
+ fieldType: true,
1005
+ priority: true,
1006
+ targetObject: true,
1007
+ mergeStrategy: true
1008
+ }
1009
+ }
1010
+ }
1011
+ });
1012
+ return (result.intakeFieldRules?.edges ?? []).map((e) => e.node);
1013
+ } catch {
1014
+ return this.loadRulesLegacy(sourceId);
1015
+ }
1016
+ }
1017
+ async loadRulesLegacy(sourceId) {
703
1018
  try {
704
1019
  const filter = sourceId ? { or: [{ intakeSource: { id: { eq: sourceId } } }, { intakeSource: { id: { is: "NULL" } } }] } : { intakeSource: { id: { is: "NULL" } } };
705
1020
  const result = await this.client.query({
@@ -725,36 +1040,203 @@ var NormalizeStage = class {
725
1040
  }
726
1041
  };
727
1042
 
728
- // src/lib/rest-client.ts
729
- var apiUrl = () => process.env["TWENTY_API_URL"] ?? "";
730
- var token = () => process.env["TWENTY_APP_ACCESS_TOKEN"] ?? "";
731
- async function request(base, method, path, body) {
732
- const res = await fetch(`${base}${path}`, {
733
- method,
734
- headers: {
735
- Authorization: `Bearer ${token()}`,
736
- "Content-Type": "application/json"
737
- },
738
- body: body !== void 0 ? JSON.stringify(body) : void 0
739
- });
740
- const text = await res.text();
741
- if (!res.ok) {
742
- throw new Error(`${method} ${path} \u2192 ${res.status}: ${text}`);
1043
+ // src/lib/spam.ts
1044
+ var HONEYPOT_SCORE = 100;
1045
+ var DISPOSABLE_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
1046
+ "mailinator.com",
1047
+ "guerrillamail.com",
1048
+ "guerrillamail.net",
1049
+ "sharklasers.com",
1050
+ "10minutemail.com",
1051
+ "10minutemail.net",
1052
+ "tempmail.com",
1053
+ "temp-mail.org",
1054
+ "throwawaymail.com",
1055
+ "yopmail.com",
1056
+ "yopmail.net",
1057
+ "trashmail.com",
1058
+ "dispostable.com",
1059
+ "getnada.com",
1060
+ "nada.email",
1061
+ "fakeinbox.com",
1062
+ "maildrop.cc",
1063
+ "mintemail.com",
1064
+ "spamgourmet.com",
1065
+ "mailnesia.com",
1066
+ "tempinbox.com",
1067
+ "emailondeck.com",
1068
+ "moakt.com",
1069
+ "mohmal.com",
1070
+ "inboxkitten.com",
1071
+ "mailcatch.com",
1072
+ "mytemp.email",
1073
+ "discard.email"
1074
+ ]);
1075
+ var PLACEHOLDER_EMAIL_DOMAINS = /* @__PURE__ */ new Set([
1076
+ "example.com",
1077
+ "example.org",
1078
+ "example.net",
1079
+ "test.com",
1080
+ "localhost",
1081
+ "domain.com",
1082
+ "email.com",
1083
+ "yourdomain.com"
1084
+ ]);
1085
+ var URL_RE2 = /https?:\/\/|www\./i;
1086
+ var GLOBAL_URL_RE = /https?:\/\/[^\s<>"']+/gi;
1087
+ var MARKUP_RE = /<\s*a\s|<\s*script|\[url[=\]]|\[link[=\]]|\]\(https?:/i;
1088
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
1089
+ var NAME_KEYS = /* @__PURE__ */ new Set([
1090
+ "name",
1091
+ "fullName",
1092
+ "firstName",
1093
+ "lastName",
1094
+ "contact_name",
1095
+ "your_name"
1096
+ ]);
1097
+ var MESSAGE_KEYS = /* @__PURE__ */ new Set([
1098
+ "message",
1099
+ "description",
1100
+ "comments",
1101
+ "notes",
1102
+ "body",
1103
+ "details",
1104
+ "content",
1105
+ "text",
1106
+ "inquiry",
1107
+ "summary",
1108
+ "feedback"
1109
+ ]);
1110
+ function scoreSpam(flat, honeypotKey) {
1111
+ const signals = [];
1112
+ if (honeypotKey) {
1113
+ const hit = Object.entries(flat).find(
1114
+ ([k, v]) => k.toLowerCase() === honeypotKey.toLowerCase() && isFilled(v)
1115
+ );
1116
+ if (hit) {
1117
+ return {
1118
+ score: HONEYPOT_SCORE,
1119
+ signals: [{ reason: `Honeypot field "${hit[0]}" was filled`, points: HONEYPOT_SCORE }],
1120
+ honeypot: true
1121
+ };
1122
+ }
743
1123
  }
744
- return JSON.parse(text);
1124
+ const add = (reason, points) => signals.push({ reason, points });
1125
+ for (const [key, value] of Object.entries(flat)) {
1126
+ if (typeof value !== "string" || value === "") continue;
1127
+ const bare = key.replace(/^(opportunity_|opp_|deal_)/i, "");
1128
+ if (NAME_KEYS.has(bare) || NAME_KEYS.has(key)) {
1129
+ if (URL_RE2.test(value)) add(`Name field "${key}" contains a URL`, 4);
1130
+ if (MARKUP_RE.test(value)) add(`Name field "${key}" contains markup or a link tag`, 4);
1131
+ }
1132
+ if (MESSAGE_KEYS.has(bare) || MESSAGE_KEYS.has(key)) {
1133
+ const urls = value.match(GLOBAL_URL_RE) ?? [];
1134
+ const distinct = new Set(urls.map((u) => u.toLowerCase()));
1135
+ if (distinct.size >= 5) add(`Message field "${key}" contains ${distinct.size} links`, 3);
1136
+ if (MARKUP_RE.test(value)) add(`Message field "${key}" contains anchor or BBCode markup`, 2);
1137
+ }
1138
+ }
1139
+ const email = firstEmail(flat);
1140
+ if (email !== null) {
1141
+ if (!EMAIL_RE2.test(email)) {
1142
+ add(`Email "${truncate(email)}" is not a valid address`, 3);
1143
+ } else {
1144
+ const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
1145
+ if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
1146
+ add(`Email domain "${domain}" is a disposable inbox`, 4);
1147
+ } else if (PLACEHOLDER_EMAIL_DOMAINS.has(domain)) {
1148
+ add(`Email domain "${domain}" is a placeholder domain`, 3);
1149
+ }
1150
+ }
1151
+ }
1152
+ const repeated = mostRepeatedValue(flat);
1153
+ if (repeated && repeated.count >= 4) {
1154
+ add(`The same value appears in ${repeated.count} different fields`, 2);
1155
+ }
1156
+ const phone = firstPhone(flat);
1157
+ if (phone && isImplausiblePhone(phone)) {
1158
+ add(`Phone "${truncate(phone)}" is a placeholder number`, 2);
1159
+ }
1160
+ const score = signals.reduce((sum, s) => sum + s.points, 0);
1161
+ return { score, signals, honeypot: false };
745
1162
  }
746
- var coreApi = {
747
- get: (path) => request(`${apiUrl()}/rest`, "GET", path),
748
- post: (path, body) => request(`${apiUrl()}/rest`, "POST", path, body),
749
- patch: (path, body) => request(`${apiUrl()}/rest`, "PATCH", path, body)
750
- };
751
- var metaApi = {
752
- get: (path) => request(`${apiUrl()}/rest/metadata`, "GET", path),
753
- post: (path, body) => request(`${apiUrl()}/rest/metadata`, "POST", path, body)
754
- };
755
- async function gql(query, variables) {
756
- return request(`${apiUrl()}`, "POST", "/graphql", { query, variables });
1163
+ function describeVerdict(verdict) {
1164
+ return verdict.signals.map((s) => s.reason).join("; ").slice(0, 500);
1165
+ }
1166
+ function isFilled(value) {
1167
+ if (value === null || value === void 0) return false;
1168
+ if (typeof value === "string") return value.trim() !== "";
1169
+ if (typeof value === "boolean") return value;
1170
+ return true;
1171
+ }
1172
+ function firstEmail(flat) {
1173
+ for (const [key, value] of Object.entries(flat)) {
1174
+ if (typeof value !== "string" || value === "") continue;
1175
+ if (/mail/i.test(key)) return value.trim();
1176
+ }
1177
+ return null;
1178
+ }
1179
+ function firstPhone(flat) {
1180
+ for (const [key, value] of Object.entries(flat)) {
1181
+ if (typeof value !== "string" || value === "") continue;
1182
+ if (/phone|mobile|tel|cell/i.test(key)) return value.trim();
1183
+ }
1184
+ return null;
757
1185
  }
1186
+ function isImplausiblePhone(raw) {
1187
+ const digits = raw.replace(/\D/g, "");
1188
+ if (digits.length < 7) return false;
1189
+ if (/^(\d)\1+$/.test(digits)) return true;
1190
+ const ascending = digits.split("").every((d, i, arr) => i === 0 || Number(d) === (Number(arr[i - 1]) + 1) % 10);
1191
+ return ascending;
1192
+ }
1193
+ function mostRepeatedValue(flat) {
1194
+ const counts = /* @__PURE__ */ new Map();
1195
+ for (const value of Object.values(flat)) {
1196
+ if (typeof value !== "string") continue;
1197
+ const v = value.trim();
1198
+ if (v.length < 8) continue;
1199
+ counts.set(v, (counts.get(v) ?? 0) + 1);
1200
+ }
1201
+ let best = null;
1202
+ for (const [value, count] of counts) {
1203
+ if (!best || count > best.count) best = { value, count };
1204
+ }
1205
+ return best;
1206
+ }
1207
+ function truncate(s) {
1208
+ return s.length > 60 ? `${s.slice(0, 60)}\u2026` : s;
1209
+ }
1210
+
1211
+ // src/lib/stages/quarantine.stage.ts
1212
+ var QuarantineStage = class {
1213
+ name = "quarantine";
1214
+ async execute(ctx) {
1215
+ const honeypot = ctx.source?.honeypotField || honeypotField();
1216
+ const filterOn = spamFilterEnabled();
1217
+ if (!honeypot && !filterOn) return ok(ctx);
1218
+ const verdict = scoreSpam(ctx.flat ?? {}, honeypot);
1219
+ const next = { ...ctx, spam: verdict };
1220
+ if (ctx.skipSpamFilter) {
1221
+ if (verdict.score > 0) {
1222
+ next.warnings.push(`Spam filter overruled by release \u2014 score was ${verdict.score}: ${describeVerdict(verdict)}`);
1223
+ }
1224
+ return ok(next);
1225
+ }
1226
+ const quarantine = verdict.honeypot || filterOn && verdict.score >= spamScoreThreshold();
1227
+ if (quarantine) {
1228
+ return err(
1229
+ ErrorCode.QUARANTINED,
1230
+ verdict.honeypot ? "Honeypot field was filled \u2014 payload quarantined" : `Spam score ${verdict.score} met the threshold of ${spamScoreThreshold()} \u2014 payload quarantined`,
1231
+ { spamScore: verdict.score, reason: describeVerdict(verdict), signals: verdict.signals }
1232
+ );
1233
+ }
1234
+ if (verdict.score > 0) {
1235
+ next.warnings.push(`Spam score ${verdict.score} (below threshold): ${describeVerdict(verdict)}`);
1236
+ }
1237
+ return ok(next);
1238
+ }
1239
+ };
758
1240
 
759
1241
  // src/lib/stages/sync-schema.stage.ts
760
1242
  var STANDARD_FIELDS = /* @__PURE__ */ new Set([
@@ -773,66 +1255,122 @@ var STANDARD_FIELDS = /* @__PURE__ */ new Set([
773
1255
  "city",
774
1256
  "employees",
775
1257
  "annualRecurringRevenue",
776
- "idealCustomerProfile"
1258
+ "idealCustomerProfile",
1259
+ ...Object.values(FIELD_MAP).map((e) => e.canonicalName)
777
1260
  ]);
1261
+ var STANDARD_OPPORTUNITY_FIELDS = /* @__PURE__ */ new Set(["name", "stage", "amount", "closeDate"]);
1262
+ var EXT_RE = /^ext[A-Z]/;
778
1263
  var SyncSchemaStage = class {
779
1264
  name = "sync-schema";
780
1265
  objectCache = /* @__PURE__ */ new Map();
781
1266
  TTL = 5 * 60 * 1e3;
782
1267
  async execute(ctx) {
783
1268
  if (!ctx.crmFields || ctx.crmFields.length === 0) return ok(ctx);
784
- if (process.env["INTAKE_FIELD_CREATION_ENABLED"] === "false") {
1269
+ const primary = this.primaryTarget(ctx);
1270
+ const creationEnabled = process.env["INTAKE_FIELD_CREATION_ENABLED"] !== "false";
1271
+ if (!creationEnabled) {
785
1272
  ctx.warnings.push("Field creation disabled by INTAKE_FIELD_CREATION_ENABLED setting \u2014 ext fields skipped");
786
- return ok(ctx);
787
1273
  }
788
- const targets = this.targetObjects(ctx);
1274
+ const resolved = ctx.crmFields.map((f) => ({
1275
+ ...f,
1276
+ targetObject: this.resolveTarget(f.targetObject, primary, ctx)
1277
+ }));
789
1278
  let fieldsCreated = 0;
790
1279
  let fieldsMatched = 0;
791
- for (const objectName of targets) {
1280
+ const demoted = [];
1281
+ for (const objectName of this.targetObjects(resolved)) {
1282
+ const fields = resolved.filter((f) => f.targetObject === objectName);
1283
+ if (fields.length === 0) continue;
1284
+ const standardForObject = objectName === "opportunity" ? STANDARD_OPPORTUNITY_FIELDS : STANDARD_FIELDS;
792
1285
  const meta = await this.getObjectMeta(objectName);
793
- if (!meta) continue;
1286
+ if (!meta) {
1287
+ for (const f of fields) {
1288
+ if (standardForObject.has(f.canonicalName)) {
1289
+ f.writable = true;
1290
+ fieldsMatched++;
1291
+ continue;
1292
+ }
1293
+ ctx.warnings.push(`Could not read schema for ${objectName} \u2014 "${f.canonicalName}" goes to note`);
1294
+ demoted.push(f);
1295
+ }
1296
+ continue;
1297
+ }
794
1298
  const maxExt = parseInt(process.env["INTAKE_MAX_EXT_FIELDS"] ?? "50", 10);
795
- const currentExtCount = [...meta.fields].filter((f) => /^ext[A-Z]/.test(f)).length;
796
- for (const field of ctx.crmFields) {
797
- if (STANDARD_FIELDS.has(field.canonicalName)) {
1299
+ let extCount = [...meta.fields].filter((f) => EXT_RE.test(f)).length;
1300
+ for (const field of fields) {
1301
+ const name = field.canonicalName;
1302
+ if (standardForObject.has(name)) {
1303
+ field.writable = true;
798
1304
  fieldsMatched++;
799
1305
  continue;
800
1306
  }
801
- if (!/^ext[A-Z]/.test(field.canonicalName)) {
1307
+ if (meta.fields.has(name)) {
1308
+ field.writable = true;
802
1309
  fieldsMatched++;
803
1310
  continue;
804
1311
  }
805
- if (meta.fields.has(field.canonicalName)) {
806
- fieldsMatched++;
1312
+ if (!EXT_RE.test(name)) {
1313
+ ctx.warnings.push(
1314
+ `Field "${name}" does not exist on ${objectName} and is not ext-prefixed \u2014 value routed to note. Create the field in Twenty first, or rename the rule's canonical name to ext${name.charAt(0).toUpperCase()}${name.slice(1)}.`
1315
+ );
1316
+ demoted.push(field);
1317
+ continue;
1318
+ }
1319
+ if (!creationEnabled) {
1320
+ demoted.push(field);
807
1321
  continue;
808
1322
  }
809
- if (currentExtCount >= maxExt) {
810
- ctx.warnings.push(`ext field limit (${maxExt}) reached for ${objectName} \u2014 "${field.canonicalName}" goes to note`);
1323
+ if (extCount >= maxExt) {
1324
+ ctx.warnings.push(`ext field limit (${maxExt}) reached for ${objectName} \u2014 "${name}" goes to note`);
1325
+ demoted.push(field);
811
1326
  continue;
812
1327
  }
813
- const created = await this.createField(meta.id, field.canonicalName, field.twentyType);
1328
+ const created = await this.createField(meta.id, name, field.twentyType);
814
1329
  if (created) {
815
- meta.fields.add(field.canonicalName);
816
- this.objectCache.get(objectName)?.fields.add(field.canonicalName);
1330
+ meta.fields.add(name);
1331
+ this.objectCache.get(objectName)?.fields.add(name);
1332
+ extCount++;
817
1333
  fieldsCreated++;
1334
+ field.writable = true;
818
1335
  } else {
819
- ctx.warnings.push(`Could not create field "${field.canonicalName}" on ${objectName} \u2014 will go to note`);
1336
+ ctx.warnings.push(`Could not create field "${name}" on ${objectName} \u2014 will go to note`);
1337
+ demoted.push(field);
820
1338
  }
821
1339
  }
822
1340
  }
823
- return ok({ ...ctx, fieldsCreated, fieldsMatched });
1341
+ const demotedSet = new Set(demoted);
1342
+ return ok({
1343
+ ...ctx,
1344
+ crmFields: resolved.filter((f) => !demotedSet.has(f)),
1345
+ noteFields: [...ctx.noteFields ?? [], ...demoted],
1346
+ fieldsCreated,
1347
+ fieldsMatched
1348
+ });
824
1349
  }
825
- targetObjects(ctx) {
1350
+ /** Where an `auto` field lands: the person, or the company for company-only payloads. */
1351
+ primaryTarget(ctx) {
826
1352
  const t = ctx.source?.targetObject ?? "AUTO";
827
- if (t === "PERSON") return ["person"];
828
- if (t === "COMPANY") return ["company"];
829
- if (ctx.structuredCompany) return ["company"];
1353
+ if (t === "PERSON") return "person";
1354
+ if (t === "COMPANY") return "company";
1355
+ if (ctx.structuredCompany) return "company";
830
1356
  const hasPersonData = ctx.crmFields?.some((f) => ["firstName", "lastName", "email", "phone", "fullName", "emails", "phones", "name"].includes(f.canonicalName));
831
1357
  const hasCompanyData = ctx.crmFields?.some((f) => ["companyName", "domainName"].includes(f.canonicalName));
832
- const out = [];
833
- if (hasPersonData) out.push("person");
834
- else if (hasCompanyData) out.push("company");
835
- return out.length > 0 ? out : ["person"];
1358
+ if (hasPersonData) return "person";
1359
+ if (hasCompanyData) return "company";
1360
+ return "person";
1361
+ }
1362
+ resolveTarget(target, primary, ctx) {
1363
+ if (target === "auto") return primary;
1364
+ if (target === "opportunity" && !ctx.source?.createOpportunity) {
1365
+ ctx.warnings.push("Field routed to opportunity but this source does not create opportunities \u2014 written to the primary record instead");
1366
+ return primary;
1367
+ }
1368
+ return target;
1369
+ }
1370
+ targetObjects(fields) {
1371
+ const seen = /* @__PURE__ */ new Set();
1372
+ for (const f of fields) if (f.targetObject !== "auto") seen.add(f.targetObject);
1373
+ return [...seen];
836
1374
  }
837
1375
  async getObjectMeta(objectName) {
838
1376
  const cached = this.objectCache.get(objectName);
@@ -905,7 +1443,69 @@ function formatValue(value) {
905
1443
  return String(value);
906
1444
  }
907
1445
 
1446
+ // src/lib/lookup.ts
1447
+ async function firstMatch(collection, query) {
1448
+ try {
1449
+ const res = await coreApi.get(`/${collection}?${query}&first=1`);
1450
+ return res.data?.[collection]?.edges?.[0]?.node ?? null;
1451
+ } catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+ async function findPersonByEmail(email) {
1456
+ if (!email) return null;
1457
+ return firstMatch("people", `filter=emails.primaryEmail[eq]:${encodeURIComponent(email)}`);
1458
+ }
1459
+ async function findCompanyByDomain(domain) {
1460
+ if (!domain) return null;
1461
+ const host = extractHost(domain);
1462
+ if (!host) return null;
1463
+ return firstMatch(
1464
+ "companies",
1465
+ `filter=domainName.primaryLinkUrl[like]:%25${encodeURIComponent(host)}%25`
1466
+ );
1467
+ }
1468
+ function extractHost(url) {
1469
+ try {
1470
+ return new URL(url).hostname.replace(/^www\./, "");
1471
+ } catch {
1472
+ return url;
1473
+ }
1474
+ }
1475
+
908
1476
  // src/lib/stages/upsert-record.stage.ts
1477
+ var STANDARD_BY_OBJECT = {
1478
+ person: /* @__PURE__ */ new Set([
1479
+ "name",
1480
+ "emails",
1481
+ "phones",
1482
+ "firstName",
1483
+ "lastName",
1484
+ "fullName",
1485
+ "email",
1486
+ "phone",
1487
+ "linkedInLink",
1488
+ "xLink",
1489
+ "jobTitle",
1490
+ "city",
1491
+ "companyName",
1492
+ "domainName",
1493
+ "address"
1494
+ ]),
1495
+ company: /* @__PURE__ */ new Set([
1496
+ "name",
1497
+ "domainName",
1498
+ "address",
1499
+ "companyName",
1500
+ "linkedInLink",
1501
+ "xLink",
1502
+ "employees",
1503
+ "annualRecurringRevenue",
1504
+ "idealCustomerProfile",
1505
+ "city"
1506
+ ]),
1507
+ opportunity: /* @__PURE__ */ new Set(["name", "stage", "amount", "closeDate"])
1508
+ };
909
1509
  var UpsertRecordStage = class {
910
1510
  name = "upsert-record";
911
1511
  async execute(ctx) {
@@ -916,25 +1516,50 @@ var UpsertRecordStage = class {
916
1516
  const structuredCompany = ctx.structuredCompany;
917
1517
  const structuredPerson = ctx.structuredPerson;
918
1518
  const rawCompanyName = structuredCompany?.["name"] ?? assembled["companyName"];
919
- const extFieldTypes = /* @__PURE__ */ new Map();
1519
+ const customFor = (object) => {
1520
+ const out = {};
1521
+ for (const f of ctx.crmFields ?? []) {
1522
+ if (f.targetObject !== object || f.writable === false || f.writable === void 0) continue;
1523
+ if (STANDARD_BY_OBJECT[object].has(f.canonicalName)) continue;
1524
+ out[f.canonicalName] = formatExtValue(f.value, f.twentyType);
1525
+ }
1526
+ return out;
1527
+ };
1528
+ const policy = ctx.source?.mergePolicy ?? mergePolicy();
1529
+ const strategyByField = /* @__PURE__ */ new Map();
920
1530
  for (const f of ctx.crmFields ?? []) {
921
- if (/^ext[A-Z]/.test(f.canonicalName)) extFieldTypes.set(f.canonicalName, f.twentyType);
1531
+ if (f.mergeStrategy && f.mergeStrategy !== "INHERIT") {
1532
+ strategyByField.set(f.canonicalName, f.mergeStrategy);
1533
+ }
922
1534
  }
1535
+ const strategyFor = (field) => strategyByField.get(field) ?? "INHERIT";
1536
+ const patchExisting = async (collection, object, existing, incoming) => {
1537
+ const { patch, decisions } = mergePatch(existing, incoming, policy, strategyFor);
1538
+ ctx.mergeDecisions = { ...ctx.mergeDecisions ?? {}, [object]: decisions };
1539
+ const preserved = Object.entries(decisions).filter(([, d]) => d.status === "preserved" && d.write === false).map(([field]) => field);
1540
+ if (preserved.length > 0 && policy === "PRESERVE") {
1541
+ ctx.warnings.push(
1542
+ `Kept the existing ${object} values for ${preserved.join(", ")} \u2014 INTAKE_MERGE_POLICY is PRESERVE`
1543
+ );
1544
+ }
1545
+ if (Object.keys(patch).length === 0) return;
1546
+ await coreApi.patch(`/${collection}/${existing.id}`, patch);
1547
+ };
923
1548
  try {
924
1549
  let companyId;
925
1550
  let personId;
926
1551
  const needsCompany = rawCompanyName || ctx.source?.targetObject === "COMPANY";
927
1552
  if (needsCompany) {
928
- companyId = await this.upsertCompany(assembled, structuredCompany ?? {}, rawCompanyName, extFieldTypes, ctx);
1553
+ companyId = await this.upsertCompany(assembled, structuredCompany ?? {}, rawCompanyName, customFor("company"), ctx, patchExisting);
929
1554
  }
930
1555
  const personData = structuredPerson ?? assembled;
931
1556
  const hasPersonData = personData["emails"] || personData["name"] || assembled["emails"] || assembled["name"];
932
1557
  if (hasPersonData || ctx.source?.targetObject === "PERSON") {
933
- personId = await this.upsertPerson(assembled, structuredPerson, companyId, extFieldTypes, ctx);
1558
+ personId = await this.upsertPerson(assembled, structuredPerson, companyId, customFor("person"), ctx, patchExisting);
934
1559
  }
935
1560
  let opportunityId;
936
1561
  if (ctx.source?.createOpportunity && (personId || companyId)) {
937
- opportunityId = await this.createOpportunity(assembled, personId, companyId, ctx);
1562
+ opportunityId = await this.createOpportunity(assembled, personId, companyId, ctx.crmFields ?? [], customFor("opportunity"), ctx);
938
1563
  }
939
1564
  let noteId;
940
1565
  const noteFields = ctx.noteFields ?? [];
@@ -955,7 +1580,7 @@ var UpsertRecordStage = class {
955
1580
  return err(ErrorCode.RECORD_UPSERT_FAILED, `Failed to upsert record: ${String(e)}`);
956
1581
  }
957
1582
  }
958
- async upsertCompany(assembled, structuredCompany, companyName, extFieldTypes, ctx) {
1583
+ async upsertCompany(assembled, structuredCompany, companyName, customData, ctx, patchExisting) {
959
1584
  const standard = { ...structuredCompany };
960
1585
  if (companyName && !standard["name"]) standard["name"] = companyName;
961
1586
  if (!standard["domainName"] && assembled["domainName"]) standard["domainName"] = assembled["domainName"];
@@ -963,17 +1588,10 @@ var UpsertRecordStage = class {
963
1588
  const domain = standard["domainName"]?.["primaryLinkUrl"];
964
1589
  let companyId;
965
1590
  if (domain) {
966
- const host = extractHost(domain);
967
- try {
968
- const found = await coreApi.get(
969
- `/companies?filter=domainName.primaryLinkUrl[like]:%25${encodeURIComponent(host)}%25&first=1`
970
- );
971
- const existing = found.data?.companies?.edges?.[0]?.node;
972
- if (existing) {
973
- await coreApi.patch(`/companies/${existing.id}`, standard);
974
- companyId = existing.id;
975
- }
976
- } catch {
1591
+ const existing = await findCompanyByDomain(domain);
1592
+ if (existing) {
1593
+ await patchExisting("companies", "company", existing, { ...standard, ...customData });
1594
+ return existing.id;
977
1595
  }
978
1596
  }
979
1597
  if (!companyId) {
@@ -994,20 +1612,14 @@ var UpsertRecordStage = class {
994
1612
  if (!companyId) throw e;
995
1613
  }
996
1614
  }
997
- if (Object.keys(structuredCompany).length > 0) {
998
- const extData = {};
999
- for (const [k, v] of Object.entries(assembled)) {
1000
- if (/^ext[A-Z]/.test(k)) extData[k] = formatExtValue(v, extFieldTypes.get(k));
1001
- }
1002
- if (Object.keys(extData).length > 0) {
1003
- await coreApi.patch(`/companies/${companyId}`, extData).catch((e) => {
1004
- ctx.warnings.push(`Could not set ext fields on company: ${e.message.slice(0, 120)}`);
1005
- });
1006
- }
1615
+ if (Object.keys(customData).length > 0) {
1616
+ await coreApi.patch(`/companies/${companyId}`, customData).catch((e) => {
1617
+ ctx.warnings.push(`Could not set custom fields on company: ${e.message.slice(0, 120)}`);
1618
+ });
1007
1619
  }
1008
1620
  return companyId;
1009
1621
  }
1010
- async upsertPerson(assembled, structuredPerson, companyId, extFieldTypes, ctx) {
1622
+ async upsertPerson(assembled, structuredPerson, companyId, customData, ctx, patchExisting) {
1011
1623
  const standard = structuredPerson ? { ...structuredPerson } : {};
1012
1624
  if (!standard["name"] && assembled["name"]) standard["name"] = assembled["name"];
1013
1625
  if (!standard["emails"] && assembled["emails"]) standard["emails"] = assembled["emails"];
@@ -1017,55 +1629,56 @@ var UpsertRecordStage = class {
1017
1629
  const email = emails?.["primaryEmail"];
1018
1630
  let personId;
1019
1631
  if (email) {
1020
- try {
1021
- const found = await coreApi.get(
1022
- `/people?filter=emails.primaryEmail[eq]:${encodeURIComponent(email)}&first=1`
1023
- );
1024
- const existing = found.data?.people?.edges?.[0]?.node;
1025
- if (existing) {
1026
- await coreApi.patch(`/people/${existing.id}`, standard);
1027
- personId = existing.id;
1028
- }
1029
- } catch {
1632
+ const existing = await findPersonByEmail(email);
1633
+ if (existing) {
1634
+ await patchExisting("people", "person", existing, { ...standard, ...customData });
1635
+ return existing.id;
1030
1636
  }
1031
1637
  }
1032
1638
  if (!personId) {
1033
1639
  const created = await coreApi.post("/people", standard);
1034
1640
  personId = created.data.createPerson.id;
1035
1641
  }
1036
- if (!structuredPerson) {
1037
- const extData = {};
1038
- for (const [k, v] of Object.entries(assembled)) {
1039
- if (/^ext[A-Z]/.test(k)) extData[k] = formatExtValue(v, extFieldTypes.get(k));
1040
- }
1041
- if (Object.keys(extData).length > 0) {
1042
- await coreApi.patch(`/people/${personId}`, extData).catch((e) => {
1043
- ctx.warnings.push(`Could not set ext fields on person: ${e.message.slice(0, 120)}`);
1044
- });
1045
- }
1642
+ if (Object.keys(customData).length > 0) {
1643
+ await coreApi.patch(`/people/${personId}`, customData).catch((e) => {
1644
+ ctx.warnings.push(`Could not set custom fields on person: ${e.message.slice(0, 120)}`);
1645
+ });
1046
1646
  }
1047
1647
  return personId;
1048
1648
  }
1049
- async createOpportunity(assembled, personId, companyId, ctx) {
1649
+ async createOpportunity(assembled, personId, companyId, crmFields, customData, ctx) {
1650
+ let opportunityId;
1050
1651
  try {
1051
1652
  const name = buildOpportunityName(ctx, assembled);
1052
- const stage = process.env["INTAKE_DEFAULT_OPP_STAGE"] ?? "NEW";
1653
+ const stage = defaultOpportunityStage();
1053
1654
  const data = { name, stage };
1054
1655
  if (personId) data["pointOfContactId"] = personId;
1055
1656
  if (companyId) data["companyId"] = companyId;
1657
+ for (const f of crmFields) {
1658
+ if (f.targetObject !== "opportunity" || !f.writable) continue;
1659
+ if (f.canonicalName === "amount" || f.canonicalName === "closeDate") {
1660
+ data[f.canonicalName] = formatExtValue(f.value, f.twentyType);
1661
+ }
1662
+ }
1056
1663
  const created = await coreApi.post("/opportunities", data);
1057
- return created.data.createOpportunity.id;
1664
+ opportunityId = created.data.createOpportunity.id;
1058
1665
  } catch {
1059
1666
  ctx.warnings.push("Opportunity creation failed \u2014 record was still created");
1060
1667
  return void 0;
1061
1668
  }
1669
+ if (Object.keys(customData).length > 0) {
1670
+ await coreApi.patch(`/opportunities/${opportunityId}`, customData).catch((e) => {
1671
+ ctx.warnings.push(`Could not set custom fields on opportunity: ${e.message.slice(0, 120)}`);
1672
+ });
1673
+ }
1674
+ return opportunityId;
1062
1675
  }
1063
1676
  async createNote(overflow, personId, companyId, ctx) {
1064
1677
  try {
1065
1678
  const markdown = formatNote(ctx.source?.name ?? ctx.sourceSlug, overflow);
1066
- const appLabel = process.env["INTAKE_APP_LABEL"] ?? "Intake";
1679
+ const label = appLabel();
1067
1680
  const created = await coreApi.post("/notes", {
1068
- title: `${ctx.source?.name ?? appLabel} \u2014 Lead`,
1681
+ title: `${ctx.source?.name ?? label} \u2014 Lead`,
1069
1682
  bodyV2: { markdown }
1070
1683
  });
1071
1684
  const noteId = created.data.createNote.id;
@@ -1083,92 +1696,257 @@ var UpsertRecordStage = class {
1083
1696
  }
1084
1697
  };
1085
1698
  function buildOpportunityName(ctx, assembled) {
1086
- const appLabel = process.env["INTAKE_APP_LABEL"] ?? "Intake";
1699
+ const label = appLabel();
1087
1700
  const defaultTemplate = `{{source}} \u2014 {{firstName}} {{lastName}}`;
1088
1701
  const template = ctx.source?.opportunityNameTemplate ?? defaultTemplate;
1089
1702
  const name = assembled["name"];
1090
- return template.replace("{{source}}", ctx.source?.name ?? appLabel).replace("{{firstName}}", name?.["firstName"] ?? "").replace("{{lastName}}", name?.["lastName"] ?? "").replace("{{email}}", assembled["emails"]?.["primaryEmail"] ?? "").replace("{{company}}", assembled["companyName"] ?? "").trim().replace(/\s+/g, " ").replace(/\s*—\s*$/, "");
1091
- }
1092
- function extractHost(url) {
1093
- try {
1094
- return new URL(url).hostname.replace(/^www\./, "");
1095
- } catch {
1096
- return url;
1097
- }
1703
+ return template.replace("{{source}}", ctx.source?.name ?? label).replace("{{firstName}}", name?.["firstName"] ?? "").replace("{{lastName}}", name?.["lastName"] ?? "").replace("{{email}}", assembled["emails"]?.["primaryEmail"] ?? "").replace("{{company}}", assembled["companyName"] ?? "").trim().replace(/\s+/g, " ").replace(/\s*—\s*$/, "");
1098
1704
  }
1099
1705
  function formatExtValue(value, type) {
1100
1706
  if (type === "LINKS" && typeof value === "string") {
1101
1707
  return { primaryLinkUrl: value, primaryLinkLabel: "" };
1102
1708
  }
1709
+ if (type === "CURRENCY") {
1710
+ const amount = typeof value === "number" ? value : Number(String(value ?? "").replace(/[^\d.]/g, ""));
1711
+ if (!Number.isFinite(amount) || amount <= 0) return void 0;
1712
+ return { amountMicros: Math.round(amount * 1e6), currencyCode: "USD" };
1713
+ }
1103
1714
  return value;
1104
1715
  }
1105
1716
 
1717
+ // src/lib/retention.ts
1718
+ var CREDENTIAL_KEY_PATTERNS = [
1719
+ "password",
1720
+ "passwd",
1721
+ "secret",
1722
+ "token",
1723
+ "apikey",
1724
+ "api_key",
1725
+ "authorization",
1726
+ "auth",
1727
+ "credential",
1728
+ "privatekey",
1729
+ "private_key",
1730
+ "session",
1731
+ "cookie",
1732
+ "ssn",
1733
+ "socialsecurity",
1734
+ "creditcard",
1735
+ "credit_card",
1736
+ "cardnumber",
1737
+ "card_number",
1738
+ "cvv",
1739
+ "cvc",
1740
+ "pin",
1741
+ "accesskey",
1742
+ "access_key"
1743
+ ];
1744
+ var REDACTED = "[redacted]";
1745
+ function isCredentialKey(key) {
1746
+ const norm = key.toLowerCase().replace(/[-\s]/g, "_");
1747
+ return CREDENTIAL_KEY_PATTERNS.some((p) => norm.includes(p));
1748
+ }
1749
+ function redactCredentials(value, depth = 0) {
1750
+ if (depth > 12 || value === null || typeof value !== "object") return value;
1751
+ if (Array.isArray(value)) return value.map((v) => redactCredentials(v, depth + 1));
1752
+ const out = {};
1753
+ for (const [key, v] of Object.entries(value)) {
1754
+ out[key] = isCredentialKey(key) ? REDACTED : redactCredentials(v, depth + 1);
1755
+ }
1756
+ return out;
1757
+ }
1758
+ function prepareForStorage(payload) {
1759
+ if (rawPayloadRetention() === "NONE") {
1760
+ return { stored: null, warning: null };
1761
+ }
1762
+ let json;
1763
+ try {
1764
+ json = JSON.stringify(redactCredentials(payload));
1765
+ } catch {
1766
+ return { stored: null, warning: "Payload could not be serialised \u2014 not stored, so replay is unavailable for this log" };
1767
+ }
1768
+ const max = rawPayloadMaxBytes();
1769
+ if (Buffer.byteLength(json, "utf8") > max) {
1770
+ return {
1771
+ stored: null,
1772
+ warning: `Payload exceeds INTAKE_RAW_PAYLOAD_MAX_BYTES (${max}) \u2014 not stored, so replay is unavailable for this log`
1773
+ };
1774
+ }
1775
+ return { stored: json, warning: null };
1776
+ }
1777
+
1778
+ // src/lib/intake-log-writer.ts
1779
+ async function writeIntakeLog(input) {
1780
+ const { ctx, status } = input;
1781
+ const retained = prepareForStorage(ctx.rawPayload);
1782
+ if (retained.warning) ctx.warnings.push(retained.warning);
1783
+ try {
1784
+ const created = await coreApi.post("/intakeLogs", {
1785
+ intakeSourceId: ctx.source?.id ?? null,
1786
+ status,
1787
+ payloadHash: ctx.payloadHash ?? "",
1788
+ idempotencyKey: ctx.idempotencyKey ?? null,
1789
+ rawPayload: retained.stored,
1790
+ recordId: ctx.personId ?? ctx.companyId ?? null,
1791
+ recordType: ctx.recordType ?? null,
1792
+ fieldsCreated: ctx.fieldsCreated,
1793
+ fieldsMatched: ctx.fieldsMatched,
1794
+ overflowCount: ctx.noteFields?.length ?? 0,
1795
+ processingMs: Date.now() - ctx.startedAt,
1796
+ processedAt: (/* @__PURE__ */ new Date()).toISOString(),
1797
+ error: input.error ?? null,
1798
+ spamScore: input.spamScore ?? ctx.spam?.score ?? null,
1799
+ quarantineReason: input.quarantineReason ?? null,
1800
+ replayOfLogId: ctx.replayOfLogId ?? null
1801
+ });
1802
+ return created.data?.createIntakeLog?.id ?? null;
1803
+ } catch {
1804
+ ctx.warnings.push("Intake log write failed");
1805
+ return null;
1806
+ }
1807
+ }
1808
+ async function bumpSourceCounters(sourceId, outcome) {
1809
+ try {
1810
+ const current = await coreApi.get(`/intakeSources/${sourceId}`);
1811
+ const node = current.data?.intakeSource;
1812
+ const patch = {};
1813
+ if (outcome === "ingested") {
1814
+ patch["totalIngested"] = (node?.totalIngested ?? 0) + 1;
1815
+ patch["lastIngestedAt"] = (/* @__PURE__ */ new Date()).toISOString();
1816
+ patch["healthStatus"] = "HEALTHY";
1817
+ patch["silentSince"] = null;
1818
+ } else {
1819
+ patch["totalFailed"] = (node?.totalFailed ?? 0) + 1;
1820
+ }
1821
+ await coreApi.patch(`/intakeSources/${sourceId}`, patch);
1822
+ } catch {
1823
+ }
1824
+ }
1825
+
1106
1826
  // src/lib/stages/write-log.stage.ts
1107
1827
  var WriteLogStage = class {
1108
1828
  name = "write-log";
1109
1829
  async execute(ctx) {
1110
- try {
1111
- const status = ctx.personId || ctx.companyId ? "SUCCESS" : "PARTIAL";
1112
- await coreApi.post("/intakeLogs", {
1113
- intakeSourceId: ctx.source?.id ?? null,
1114
- status,
1115
- payloadHash: ctx.payloadHash ?? "",
1116
- rawPayload: JSON.stringify(ctx.rawPayload).slice(0, 65e3),
1117
- // cap at 65k chars
1118
- recordId: ctx.personId ?? ctx.companyId ?? null,
1119
- recordType: ctx.recordType ?? null,
1120
- fieldsCreated: ctx.fieldsCreated,
1121
- fieldsMatched: ctx.fieldsMatched,
1122
- overflowCount: ctx.noteFields?.length ?? 0,
1123
- processingMs: Date.now() - ctx.startedAt,
1124
- processedAt: (/* @__PURE__ */ new Date()).toISOString()
1125
- });
1126
- } catch {
1127
- ctx.warnings.push("Intake log write failed");
1128
- }
1129
- if (ctx.source) {
1130
- try {
1131
- const current = await coreApi.get(
1132
- `/intakeSources/${ctx.source.id}`
1133
- );
1134
- const current_count = current.data?.intakeSource?.totalIngested ?? 0;
1135
- await coreApi.patch(`/intakeSources/${ctx.source.id}`, {
1136
- totalIngested: current_count + 1,
1137
- lastIngestedAt: (/* @__PURE__ */ new Date()).toISOString()
1138
- });
1139
- } catch {
1140
- }
1141
- }
1830
+ const status = ctx.personId || ctx.companyId ? "SUCCESS" : "PARTIAL";
1831
+ await writeIntakeLog({ ctx, status });
1832
+ if (ctx.source) await bumpSourceCounters(ctx.source.id, "ingested");
1142
1833
  return ok(ctx);
1143
1834
  }
1144
1835
  };
1145
1836
 
1146
1837
  // src/lib/pipeline-factory.ts
1147
1838
  function createPipeline(coreClient, metadataClient) {
1148
- return new Pipeline().use(new LoadSourceStage(coreClient)).use(new VerifySignatureStage()).use(new IdempotencyStage(coreClient)).use(new FlattenStage()).use(new NormalizeStage(coreClient)).use(new SyncSchemaStage(metadataClient)).use(new UpsertRecordStage(coreClient)).use(new WriteLogStage(coreClient));
1839
+ return new Pipeline().use(new LoadSourceStage(coreClient)).use(new VerifySignatureStage()).use(new IdempotencyStage(coreClient)).use(new FlattenStage()).use(new NormalizeStage(coreClient)).use(new QuarantineStage()).use(new SyncSchemaStage(metadataClient)).use(new UpsertRecordStage(coreClient)).use(new WriteLogStage(coreClient));
1840
+ }
1841
+
1842
+ // src/lib/ingest.ts
1843
+ async function runIngestion(options) {
1844
+ const ctx = {
1845
+ sourceSlug: options.sourceSlug,
1846
+ rawBody: options.rawBody,
1847
+ rawPayload: options.rawPayload,
1848
+ headers: options.headers ?? {},
1849
+ startedAt: Date.now(),
1850
+ fieldsCreated: 0,
1851
+ fieldsMatched: 0,
1852
+ warnings: [],
1853
+ events: [],
1854
+ ...options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {},
1855
+ ...options.skipDedup ? { skipDedup: true } : {},
1856
+ ...options.skipSpamFilter ? { skipSpamFilter: true } : {},
1857
+ ...options.replayOfLogId ? { replayOfLogId: options.replayOfLogId } : {}
1858
+ };
1859
+ const pipeline = createPipeline(new CoreApiClient(), new MetadataApiClient());
1860
+ const result = await pipeline.run(ctx);
1861
+ if (result.ok) return { ok: true, ctx: result.value };
1862
+ await recordFailure(ctx, result.error);
1863
+ return { ok: false, error: result.error, ctx };
1864
+ }
1865
+ async function recordFailure(ctx, error) {
1866
+ if (error.code === "SOURCE_NOT_FOUND") return;
1867
+ if (error.code === "DUPLICATE_PAYLOAD") return;
1868
+ const details = error.details ?? {};
1869
+ const quarantined = error.code === "QUARANTINED";
1870
+ const status = quarantined ? "QUARANTINED" : "FAILED";
1871
+ await writeIntakeLog({
1872
+ ctx,
1873
+ status,
1874
+ error: `${error.code}: ${error.message}`,
1875
+ spamScore: details.spamScore ?? null,
1876
+ quarantineReason: quarantined ? details.reason ?? error.message : null
1877
+ });
1878
+ if (ctx.source && !quarantined) await bumpSourceCounters(ctx.source.id, "failed");
1879
+ }
1880
+ function successBody(ctx, startedAt) {
1881
+ return {
1882
+ success: true,
1883
+ recordId: ctx.personId ?? ctx.companyId,
1884
+ recordType: ctx.recordType,
1885
+ personId: ctx.personId,
1886
+ companyId: ctx.companyId,
1887
+ opportunityId: ctx.opportunityId,
1888
+ noteId: ctx.noteId,
1889
+ fieldsCreated: ctx.fieldsCreated,
1890
+ fieldsMatched: ctx.fieldsMatched,
1891
+ overflowCount: ctx.noteFields?.length ?? 0,
1892
+ spamScore: ctx.spam?.score ?? null,
1893
+ mergeDecisions: ctx.mergeDecisions ?? null,
1894
+ processingMs: Date.now() - startedAt,
1895
+ warnings: ctx.warnings
1896
+ };
1897
+ }
1898
+
1899
+ // src/lib/replay.ts
1900
+ async function loadLog(logId) {
1901
+ try {
1902
+ const res = await coreApi.get(`/intakeLogs/${logId}`);
1903
+ return res.data?.intakeLog ?? null;
1904
+ } catch {
1905
+ return null;
1906
+ }
1907
+ }
1908
+ async function slugForSource(sourceId) {
1909
+ if (!sourceId) return null;
1910
+ try {
1911
+ const res = await coreApi.get(`/intakeSources/${sourceId}`);
1912
+ return res.data?.intakeSource?.slug ?? null;
1913
+ } catch {
1914
+ return null;
1915
+ }
1149
1916
  }
1150
1917
 
1151
1918
  // src/logic-functions/retry.ts
1152
1919
  var handler = async (params) => {
1920
+ const startedAt = Date.now();
1153
1921
  const logId = params.pathParameters?.["logId"];
1154
- if (!logId) {
1155
- return { statusCode: 400, body: { error: "Missing logId in URL" } };
1156
- }
1157
- const logResult = await coreApi.get(`/intakeLogs/${logId}`).catch(() => null);
1158
- const log = logResult?.data?.intakeLog;
1159
- if (!log) {
1160
- return { statusCode: 404, body: { error: `IntakeLog ${logId} not found` } };
1161
- }
1922
+ if (!logId) return { statusCode: 400, body: { error: "Missing logId in URL" } };
1923
+ const log = await loadLog(logId);
1924
+ if (!log) return { statusCode: 404, body: { error: `IntakeLog ${logId} not found` } };
1162
1925
  if (log.status === "SUCCESS") {
1163
1926
  return {
1164
1927
  statusCode: 400,
1165
- body: { error: "This ingestion already succeeded \u2014 no retry needed", status: log.status }
1928
+ body: {
1929
+ error: "This ingestion already succeeded \u2014 no retry needed. Use POST /s/intake/logs/:logId/replay to re-run it through the current rules.",
1930
+ status: log.status
1931
+ }
1932
+ };
1933
+ }
1934
+ if (log.status === "QUARANTINED") {
1935
+ return {
1936
+ statusCode: 400,
1937
+ body: {
1938
+ error: "This payload is quarantined. Use POST /s/intake/quarantine/:logId/release to let it through.",
1939
+ status: log.status
1940
+ }
1166
1941
  };
1167
1942
  }
1168
1943
  if (!log.rawPayload) {
1169
1944
  return {
1170
1945
  statusCode: 400,
1171
- body: { error: "No raw payload stored on this log \u2014 retry requires rawPayload field (logs created in v0.2.0+)" }
1946
+ body: {
1947
+ error: "No raw payload stored on this log \u2014 retry needs one. Check INTAKE_RAW_PAYLOAD_RETENTION and INTAKE_RAW_PAYLOAD_MAX_BYTES.",
1948
+ code: "NO_STORED_PAYLOAD"
1949
+ }
1172
1950
  };
1173
1951
  }
1174
1952
  let rawPayload;
@@ -1177,56 +1955,31 @@ var handler = async (params) => {
1177
1955
  } catch {
1178
1956
  return { statusCode: 400, body: { error: "rawPayload is not valid JSON" } };
1179
1957
  }
1180
- let sourceSlug = "unknown";
1181
- if (log.intakeSourceId) {
1182
- const srcResult = await coreApi.get(
1183
- `/intakeSources/${log.intakeSourceId}`
1184
- ).catch(() => null);
1185
- sourceSlug = srcResult?.data?.intakeSource?.slug ?? "unknown";
1958
+ const sourceSlug = await slugForSource(log.intakeSourceId);
1959
+ if (!sourceSlug) {
1960
+ return { statusCode: 400, body: { error: "The source for this log no longer exists" } };
1186
1961
  }
1187
- const startedAt = Date.now();
1188
- const coreClient = new CoreApiClient();
1189
- const metadataClient = new MetadataApiClient();
1190
- const pipeline = createPipeline(coreClient, metadataClient);
1191
- const ctx = {
1962
+ const outcome = await runIngestion({
1192
1963
  sourceSlug,
1193
- rawBody: void 0,
1194
- // HMAC skipped on retry — source already verified originally
1195
1964
  rawPayload,
1196
- headers: {},
1197
- startedAt,
1965
+ // The signature was verified when the payload first arrived and there is no
1966
+ // body to re-verify against; the caller here is already authenticated.
1198
1967
  skipDedup: true,
1199
- // Critical — bypass idempotency so the payload re-runs
1200
- fieldsCreated: 0,
1201
- fieldsMatched: 0,
1202
- warnings: [],
1203
- events: []
1204
- };
1205
- const result = await pipeline.run(ctx);
1206
- if (!result.ok) {
1968
+ replayOfLogId: log.id
1969
+ });
1970
+ if (!outcome.ok) {
1207
1971
  return {
1208
1972
  statusCode: 500,
1209
1973
  body: {
1210
- error: `Retry failed: ${result.error.message}`,
1211
- code: result.error.code,
1974
+ error: `Retry failed: ${outcome.error.message}`,
1975
+ code: outcome.error.code,
1212
1976
  originalLogId: logId
1213
1977
  }
1214
1978
  };
1215
1979
  }
1216
- const { value: finalCtx } = result;
1217
1980
  return {
1218
1981
  statusCode: 200,
1219
- body: {
1220
- success: true,
1221
- originalLogId: logId,
1222
- recordId: finalCtx.personId ?? finalCtx.companyId,
1223
- recordType: finalCtx.recordType,
1224
- personId: finalCtx.personId,
1225
- companyId: finalCtx.companyId,
1226
- fieldsCreated: finalCtx.fieldsCreated,
1227
- processingMs: Date.now() - startedAt,
1228
- warnings: finalCtx.warnings
1229
- }
1982
+ body: { originalLogId: logId, ...successBody(outcome.ctx, startedAt) }
1230
1983
  };
1231
1984
  };
1232
1985
  var retry_default = defineLogicFunction({