twenty-app-intake 0.2.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.
@@ -0,0 +1,1213 @@
1
+ import { createRequire as __createRequire } from 'module';
2
+ const require = __createRequire(import.meta.url);
3
+
4
+ // twenty-sdk-define-stub:__twenty-sdk-define-stub__
5
+ var __defineFactoryStub = (config) => ({
6
+ success: true,
7
+ config,
8
+ errors: []
9
+ });
10
+ var __anyHandler = {
11
+ get(_target, prop) {
12
+ if (prop === "__esModule") return true;
13
+ if (prop === Symbol.toPrimitive) return () => "";
14
+ if (typeof prop === "symbol") return void 0;
15
+ return new Proxy(() => void 0, __anyHandler);
16
+ },
17
+ apply() {
18
+ return new Proxy(() => void 0, __anyHandler);
19
+ }
20
+ };
21
+ var __anyStub = new Proxy(() => void 0, __anyHandler);
22
+ var defineLogicFunction = __defineFactoryStub;
23
+
24
+ // src/logic-functions/webhook.ts
25
+ import { CoreApiClient } from "twenty-client-sdk/core";
26
+ import { MetadataApiClient } from "twenty-client-sdk/metadata";
27
+
28
+ // src/constants/universal-identifiers.ts
29
+ var IDS = {
30
+ APPLICATION: "96a389dd-384a-43a1-a376-9d3cf01b6ab7",
31
+ DEFAULT_ROLE: "3dd2cdb0-32c7-44b4-9f7b-0a9c667ec249",
32
+ // Objects
33
+ INTAKE_SOURCE_OBJECT: "8c1c227c-db7d-4834-b328-cacbe8af0bfc",
34
+ INTAKE_LOG_OBJECT: "b77a2439-1503-4ef6-ab8b-8f4293bc19da",
35
+ INTAKE_FIELD_RULE_OBJECT: "e74df817-ff25-4bfb-bdf0-651662ca327e",
36
+ // IntakeSource fields
37
+ INTAKE_SOURCE_NAME: "0deb7e4b-bb90-4c86-b903-5208b42041e2",
38
+ INTAKE_SOURCE_SLUG: "18f7645b-2dce-4145-b1b6-9cc3332c0578",
39
+ INTAKE_SOURCE_TARGET_OBJECT: "65f3d7f7-a601-4375-9c67-684d0044620b",
40
+ INTAKE_SOURCE_SECRET: "4c919402-07f5-4041-ade6-a42402f1881f",
41
+ INTAKE_SOURCE_STATUS: "6f1fa6fe-02b4-48ab-b1ee-97c600bbf483",
42
+ INTAKE_SOURCE_CREATE_OPP: "67ae59aa-2275-4ef2-b594-137f6bfafd99",
43
+ INTAKE_SOURCE_OPP_TEMPLATE: "3907b346-77a6-4c79-a4d7-6e1cc20bb82b",
44
+ INTAKE_SOURCE_TOTAL_INGESTED: "69655a93-bc70-48bd-874e-aeccc63ee1a8",
45
+ INTAKE_SOURCE_TOTAL_FAILED: "27b3a499-183e-4cc3-8e40-938f953a986d",
46
+ INTAKE_SOURCE_LAST_INGESTED: "e167f628-9865-468e-894a-b4b6150cec26",
47
+ // IntakeLog fields
48
+ INTAKE_LOG_SOURCE_RELATION: "b1c623ea-7a9a-47a5-bee4-35fdd668a637",
49
+ INTAKE_LOG_STATUS: "8b6d26d1-f89c-486c-a617-9f9f6a9ca36d",
50
+ INTAKE_LOG_PAYLOAD_HASH: "c43bb1ad-e78a-4c60-bd95-02475ede7f44",
51
+ INTAKE_LOG_RECORD_ID: "77a55116-a64b-4954-8369-341022dd5bdd",
52
+ INTAKE_LOG_RECORD_TYPE: "02154e96-5808-48ed-8838-51083f174550",
53
+ INTAKE_LOG_FIELDS_CREATED: "2295ca2b-b559-4b38-9771-12dd8d3d76d7",
54
+ INTAKE_LOG_FIELDS_MATCHED: "83d07a0f-5bcd-4fb1-9759-f9b04b9381d8",
55
+ INTAKE_LOG_OVERFLOW_COUNT: "4090acb1-f48a-4957-bb91-c642f8f434a7",
56
+ INTAKE_LOG_PROCESSING_MS: "a9191f27-4b40-42e9-8cce-20c75900e12c",
57
+ INTAKE_LOG_ERROR: "8d9979f1-9774-43a8-b958-d1b37bd4ecc7",
58
+ INTAKE_LOG_PROCESSED_AT: "a8d99fbd-722e-4987-8afe-b830ac026f0a",
59
+ // IntakeFieldRule fields
60
+ INTAKE_FIELD_RULE_SOURCE_RELATION: "396dd1f1-3873-4efa-a6bb-7b41eda14d83",
61
+ INTAKE_FIELD_RULE_INPUT_PATTERN: "757201a0-9f64-47b9-bf3a-a793074bf84e",
62
+ INTAKE_FIELD_RULE_CANONICAL_NAME: "fafebc55-a2cc-4c9f-975b-050c2676659d",
63
+ INTAKE_FIELD_RULE_FIELD_TYPE: "72d1f6d8-d63e-419e-a244-8127d7b4705f",
64
+ INTAKE_FIELD_RULE_PRIORITY: "911df299-940b-44f9-92f7-3fd01fde01a7",
65
+ INTAKE_FIELD_RULE_IS_ACTIVE: "6f9cd96d-aecd-437f-a7b5-d45548782771",
66
+ // Logic Functions
67
+ WEBHOOK_LOGIC_FUNCTION: "8323c257-48fe-4bb6-b1dd-166844f575c0",
68
+ HEALTH_LOGIC_FUNCTION: "4ece4e39-286b-4820-81f4-b7beee71d092",
69
+ REGISTER_LOGIC_FUNCTION: "56d46e18-6cae-4d99-9893-b7d70740c9db",
70
+ // IntakeSource reverse relation fields
71
+ INTAKE_SOURCE_LOGS_REVERSE: "6ecfa369-22e0-4812-bb75-4338cea8be65",
72
+ INTAKE_SOURCE_RULES_REVERSE: "776fefaf-0eee-4ec0-93e0-d11bd80c1e20",
73
+ // v0.2.0 — rawPayload + retry + post-install + UI
74
+ INTAKE_LOG_RAW_PAYLOAD: "b65c9e1c-f2c1-4c52-a5b3-1e1ac3139692",
75
+ RETRY_LOGIC_FUNCTION: "90f20396-a69a-4ae4-af20-6be65c565bb5",
76
+ POST_INSTALL_LOGIC_FUNCTION: "e836fdd6-fd54-4aa4-ba6d-87d5a02148ef",
77
+ LOG_RETRY_FRONT_COMPONENT: "0cb97f6d-30e2-48c8-90e1-241b82870b4d",
78
+ FIELD_RULES_TABLE_FRONT_COMPONENT: "c35faad9-3468-4746-9c1c-b13d73e5e09b",
79
+ SOURCE_PAGE_LAYOUT: "8c894ba8-5c8c-45ed-b0af-1d22ede93eb9",
80
+ SOURCE_LAYOUT_TAB_RULES: "80f5c49a-083c-4062-8bd5-ae158abb6c33",
81
+ SOURCE_LAYOUT_TAB_RULES_WIDGET: "26a9ebb7-1d59-4b03-a018-56968f6df224",
82
+ LOG_PAGE_LAYOUT: "b0972802-ede5-463c-9f59-cb05f04957db",
83
+ LOG_LAYOUT_TAB_RETRY: "6b109695-ba3a-43de-a31a-23119ccf87ba",
84
+ LOG_LAYOUT_TAB_RETRY_WIDGET: "11727c1a-5145-46de-9742-88d07d64c72e",
85
+ SAMPLE_RULE_PHONE: "8440da8d-8ac4-4f4b-baa2-4d62936cde35",
86
+ SAMPLE_RULE_EMAIL: "d123cde4-05aa-44e1-86f6-454be1d7048b",
87
+ SAMPLE_RULE_FULLNAME: "00e49fe8-1b48-4f5b-aaf4-414d0923c712",
88
+ // Views & Navigation
89
+ SOURCES_VIEW: "4435cd43-5c13-472f-b4f9-b686c0f46285",
90
+ LOGS_VIEW: "a2cd0a93-9778-4ba5-99d9-b8b89c4fb39d",
91
+ INTAKE_NAV_ITEM: "e81bf288-5686-4713-86ae-e2947b124cab"
92
+ };
93
+
94
+ // src/lib/pipeline.ts
95
+ var Pipeline = class {
96
+ stages = [];
97
+ use(stage) {
98
+ this.stages.push(stage);
99
+ return this;
100
+ }
101
+ async run(initial) {
102
+ let ctx = initial;
103
+ for (const stage of this.stages) {
104
+ const result = await stage.execute(ctx);
105
+ if (!result.ok) {
106
+ return {
107
+ ok: false,
108
+ error: { ...result.error, stage: stage.name }
109
+ };
110
+ }
111
+ ctx = result.value;
112
+ ctx.events.push({ stage: stage.name, message: "completed" });
113
+ }
114
+ return { ok: true, value: ctx };
115
+ }
116
+ };
117
+
118
+ // src/lib/result.ts
119
+ var ErrorCode = {
120
+ INVALID_SIGNATURE: "INVALID_SIGNATURE",
121
+ INVALID_PAYLOAD: "INVALID_PAYLOAD",
122
+ SOURCE_NOT_FOUND: "SOURCE_NOT_FOUND",
123
+ SOURCE_PAUSED: "SOURCE_PAUSED",
124
+ DUPLICATE_PAYLOAD: "DUPLICATE_PAYLOAD",
125
+ FIELD_CREATE_FAILED: "FIELD_CREATE_FAILED",
126
+ RECORD_UPSERT_FAILED: "RECORD_UPSERT_FAILED",
127
+ NOTE_CREATE_FAILED: "NOTE_CREATE_FAILED",
128
+ SCHEMA_FETCH_FAILED: "SCHEMA_FETCH_FAILED",
129
+ INTERNAL_ERROR: "INTERNAL_ERROR"
130
+ };
131
+ var ok = (value) => ({ ok: true, value });
132
+ var err = (code, message, details) => ({ ok: false, error: { code, message, details } });
133
+
134
+ // src/lib/stages/load-source.stage.ts
135
+ var LoadSourceStage = class {
136
+ constructor(client) {
137
+ this.client = client;
138
+ }
139
+ name = "load-source";
140
+ async execute(ctx) {
141
+ const result = await this.client.query({
142
+ intakeSources: {
143
+ __args: { filter: { slug: { eq: ctx.sourceSlug } } },
144
+ edges: {
145
+ node: {
146
+ id: true,
147
+ name: true,
148
+ slug: true,
149
+ targetObject: true,
150
+ webhookSecret: true,
151
+ status: true,
152
+ createOpportunity: true,
153
+ opportunityNameTemplate: true
154
+ }
155
+ }
156
+ }
157
+ });
158
+ const node = result.intakeSources?.edges?.[0]?.node;
159
+ if (!node) {
160
+ return err(ErrorCode.SOURCE_NOT_FOUND, `No Intake source found with slug "${ctx.sourceSlug}"`);
161
+ }
162
+ if (node.status === "PAUSED") {
163
+ return err(ErrorCode.SOURCE_PAUSED, `Source "${node.name}" is paused`);
164
+ }
165
+ return ok({
166
+ ...ctx,
167
+ source: {
168
+ id: node.id,
169
+ name: node.name,
170
+ slug: node.slug,
171
+ targetObject: node.targetObject,
172
+ webhookSecret: node.webhookSecret ?? null,
173
+ status: node.status,
174
+ createOpportunity: node.createOpportunity ?? true,
175
+ opportunityNameTemplate: node.opportunityNameTemplate ?? "{{source}} \u2014 {{firstName}} {{lastName}}"
176
+ }
177
+ });
178
+ }
179
+ };
180
+
181
+ // src/lib/stages/verify-signature.stage.ts
182
+ import { createHmac, timingSafeEqual } from "node:crypto";
183
+ var VerifySignatureStage = class {
184
+ name = "verify-signature";
185
+ async execute(ctx) {
186
+ const secret = ctx.source?.webhookSecret;
187
+ const requireHmac = process.env["INTAKE_REQUIRE_HMAC"] === "true";
188
+ if (!secret) {
189
+ if (requireHmac) return err(ErrorCode.INVALID_SIGNATURE, "This source has no webhook secret \u2014 INTAKE_REQUIRE_HMAC is enabled");
190
+ return ok(ctx);
191
+ }
192
+ const signature = ctx.headers["x-webhook-signature"];
193
+ if (!signature) {
194
+ return err(ErrorCode.INVALID_SIGNATURE, "Missing X-Webhook-Signature header");
195
+ }
196
+ if (!ctx.rawBody) {
197
+ ctx.warnings.push("rawBody unavailable \u2014 skipping HMAC verification");
198
+ return ok(ctx);
199
+ }
200
+ const expected = createHmac("sha256", secret).update(ctx.rawBody).digest("hex");
201
+ const sigBuf = Buffer.from(signature.replace(/^sha256=/, ""));
202
+ const expBuf = Buffer.from(expected);
203
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
204
+ return err(ErrorCode.INVALID_SIGNATURE, "Webhook signature verification failed");
205
+ }
206
+ return ok(ctx);
207
+ }
208
+ };
209
+
210
+ // src/lib/idempotency.ts
211
+ import { createHash } from "node:crypto";
212
+ function computePayloadHash(payload) {
213
+ const stable = stableStringify(payload);
214
+ return createHash("sha256").update(stable).digest("hex");
215
+ }
216
+ function stableStringify(value) {
217
+ if (value === null || value === void 0) return String(value);
218
+ if (typeof value !== "object") return JSON.stringify(value);
219
+ if (Array.isArray(value)) {
220
+ return "[" + value.map(stableStringify).join(",") + "]";
221
+ }
222
+ const obj = value;
223
+ const sorted = Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",");
224
+ return "{" + sorted + "}";
225
+ }
226
+ function withinWindow(processedAt, windowMs = 5 * 60 * 1e3) {
227
+ return Date.now() - processedAt.getTime() < windowMs;
228
+ }
229
+
230
+ // src/lib/stages/idempotency.stage.ts
231
+ var IdempotencyStage = class {
232
+ constructor(client) {
233
+ this.client = client;
234
+ }
235
+ name = "idempotency";
236
+ async execute(ctx) {
237
+ const hash = computePayloadHash(ctx.rawPayload);
238
+ if (ctx.skipDedup) return ok({ ...ctx, payloadHash: hash });
239
+ const existing = await this.client.query({
240
+ intakeLogs: {
241
+ __args: {
242
+ filter: { payloadHash: { eq: hash } },
243
+ orderBy: { processedAt: "DescNullsLast" },
244
+ first: 1
245
+ },
246
+ edges: {
247
+ node: {
248
+ id: true,
249
+ status: true,
250
+ recordId: true,
251
+ processedAt: true
252
+ }
253
+ }
254
+ }
255
+ });
256
+ const prior = existing.intakeLogs?.edges?.[0]?.node;
257
+ const windowMins = parseInt(process.env["INTAKE_DEDUP_WINDOW_MINUTES"] ?? "5", 10);
258
+ if (prior && withinWindow(new Date(prior.processedAt), windowMins * 60 * 1e3)) {
259
+ return err(
260
+ ErrorCode.DUPLICATE_PAYLOAD,
261
+ `Duplicate payload \u2014 already processed ${prior.status.toLowerCase()} (log ${prior.id})`,
262
+ { priorLogId: prior.id, priorRecordId: prior.recordId }
263
+ );
264
+ }
265
+ return ok({ ...ctx, payloadHash: hash });
266
+ }
267
+ };
268
+
269
+ // src/lib/flattener.ts
270
+ var MAX_DEPTH = 8;
271
+ function flatten(input, prefix = "", depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
272
+ if (depth > MAX_DEPTH) return {};
273
+ if (input === null || input === void 0) return {};
274
+ if (typeof input !== "object") {
275
+ return prefix ? { [prefix]: input } : {};
276
+ }
277
+ if (Array.isArray(input)) {
278
+ return prefix ? { [prefix]: input } : {};
279
+ }
280
+ const obj = input;
281
+ if (seen.has(obj)) return {};
282
+ seen.add(obj);
283
+ const result = {};
284
+ for (const [key, value] of Object.entries(obj)) {
285
+ const newKey = prefix ? `${prefix}${toPascalSegment(key)}` : toCamelSegment(key);
286
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
287
+ const nested = flatten(value, newKey, depth + 1, seen);
288
+ Object.assign(result, nested);
289
+ } else {
290
+ result[newKey] = value;
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+ function toCamelSegment(s) {
296
+ return s.replace(/[-_]([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
297
+ }
298
+ function toPascalSegment(s) {
299
+ const camel = toCamelSegment(s);
300
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
301
+ }
302
+ function detectStructure(raw) {
303
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
304
+ return { type: "flat", data: {} };
305
+ }
306
+ const obj = raw;
307
+ const hasObjectCompany = "company" in obj && typeof obj["company"] === "object" && obj["company"] !== null && !Array.isArray(obj["company"]);
308
+ const hasObjectPerson = "person" in obj && typeof obj["person"] === "object" && obj["person"] !== null && !Array.isArray(obj["person"]);
309
+ if (!hasObjectCompany && !hasObjectPerson) {
310
+ return { type: "flat", data: flatten(obj) };
311
+ }
312
+ const company = hasObjectCompany ? obj["company"] : null;
313
+ const person = hasObjectPerson ? obj["person"] : null;
314
+ const extra = {};
315
+ for (const [k, v] of Object.entries(obj)) {
316
+ if (k === "company" || k === "person") continue;
317
+ const nested = flatten({ [k]: v });
318
+ Object.assign(extra, nested);
319
+ }
320
+ return { type: "structured", company, person, extra };
321
+ }
322
+
323
+ // src/lib/stages/flatten.stage.ts
324
+ var FlattenStage = class {
325
+ name = "flatten";
326
+ async execute(ctx) {
327
+ const structure = detectStructure(ctx.rawPayload);
328
+ if (structure.type === "structured") {
329
+ const extraFlat = flatten(structure.extra ?? {});
330
+ return ok({
331
+ ...ctx,
332
+ structuredCompany: structure.company ?? void 0,
333
+ structuredPerson: structure.person ?? void 0,
334
+ flat: extraFlat
335
+ });
336
+ }
337
+ return ok({ ...ctx, flat: structure.data });
338
+ }
339
+ };
340
+
341
+ // src/constants/field-map.ts
342
+ function normaliseKey(key) {
343
+ return key.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/[-\s.]/g, "_").replace(/_{2,}/g, "_");
344
+ }
345
+ var FIELD_MAP = {
346
+ // ── Phone ─────────────────────────────────────────────────────────────────
347
+ phone: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
348
+ phone_number: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
349
+ tel: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
350
+ telephone: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
351
+ mobile: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
352
+ mobile_number: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
353
+ cell: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
354
+ cell_phone: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
355
+ contact_phone: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
356
+ phone1: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
357
+ primary_phone: { canonicalName: "phone", twentyType: "PHONES", action: "field" },
358
+ // ── Email ─────────────────────────────────────────────────────────────────
359
+ email: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
360
+ email_address: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
361
+ e_mail: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
362
+ contact_email: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
363
+ email1: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
364
+ user_email: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
365
+ primary_email: { canonicalName: "email", twentyType: "EMAILS", action: "field" },
366
+ // ── First name ────────────────────────────────────────────────────────────
367
+ first_name: { canonicalName: "firstName", twentyType: "TEXT", action: "field" },
368
+ fname: { canonicalName: "firstName", twentyType: "TEXT", action: "field" },
369
+ given_name: { canonicalName: "firstName", twentyType: "TEXT", action: "field" },
370
+ forename: { canonicalName: "firstName", twentyType: "TEXT", action: "field" },
371
+ // ── Last name ─────────────────────────────────────────────────────────────
372
+ last_name: { canonicalName: "lastName", twentyType: "TEXT", action: "field" },
373
+ lname: { canonicalName: "lastName", twentyType: "TEXT", action: "field" },
374
+ surname: { canonicalName: "lastName", twentyType: "TEXT", action: "field" },
375
+ family_name: { canonicalName: "lastName", twentyType: "TEXT", action: "field" },
376
+ // ── Full name (will be split on ingest) ───────────────────────────────────
377
+ name: { canonicalName: "fullName", twentyType: "FULL_NAME", action: "field" },
378
+ full_name: { canonicalName: "fullName", twentyType: "FULL_NAME", action: "field" },
379
+ contact_name: { canonicalName: "fullName", twentyType: "FULL_NAME", action: "field" },
380
+ your_name: { canonicalName: "fullName", twentyType: "FULL_NAME", action: "field" },
381
+ // ── Company ───────────────────────────────────────────────────────────────
382
+ company: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
383
+ company_name: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
384
+ business: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
385
+ business_name: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
386
+ organization: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
387
+ organisation: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
388
+ employer: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
389
+ firm: { canonicalName: "companyName", twentyType: "TEXT", action: "field" },
390
+ // ── Website / Domain ──────────────────────────────────────────────────────
391
+ website: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
392
+ website_url: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
393
+ url: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
394
+ domain: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
395
+ homepage: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
396
+ web: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
397
+ site: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
398
+ site_url: { canonicalName: "domainName", twentyType: "LINKS", action: "field" },
399
+ // ── Address ───────────────────────────────────────────────────────────────
400
+ street: { canonicalName: "addressStreet1", twentyType: "TEXT", action: "field" },
401
+ street_address: { canonicalName: "addressStreet1", twentyType: "TEXT", action: "field" },
402
+ address_line_1: { canonicalName: "addressStreet1", twentyType: "TEXT", action: "field" },
403
+ address1: { canonicalName: "addressStreet1", twentyType: "TEXT", action: "field" },
404
+ address_line_2: { canonicalName: "addressStreet2", twentyType: "TEXT", action: "field" },
405
+ address2: { canonicalName: "addressStreet2", twentyType: "TEXT", action: "field" },
406
+ city: { canonicalName: "addressCity", twentyType: "TEXT", action: "field" },
407
+ address_city: { canonicalName: "addressCity", twentyType: "TEXT", action: "field" },
408
+ town: { canonicalName: "addressCity", twentyType: "TEXT", action: "field" },
409
+ state: { canonicalName: "addressState", twentyType: "TEXT", action: "field" },
410
+ province: { canonicalName: "addressState", twentyType: "TEXT", action: "field" },
411
+ region: { canonicalName: "addressState", twentyType: "TEXT", action: "field" },
412
+ address_state: { canonicalName: "addressState", twentyType: "TEXT", action: "field" },
413
+ zip: { canonicalName: "addressPostcode", twentyType: "TEXT", action: "field" },
414
+ zip_code: { canonicalName: "addressPostcode", twentyType: "TEXT", action: "field" },
415
+ postal_code: { canonicalName: "addressPostcode", twentyType: "TEXT", action: "field" },
416
+ postcode: { canonicalName: "addressPostcode", twentyType: "TEXT", action: "field" },
417
+ country: { canonicalName: "addressCountry", twentyType: "TEXT", action: "field" },
418
+ address_country: { canonicalName: "addressCountry", twentyType: "TEXT", action: "field" },
419
+ // ── Social ────────────────────────────────────────────────────────────────
420
+ linkedin: { canonicalName: "linkedInLink", twentyType: "LINKS", action: "field" },
421
+ linkedin_url: { canonicalName: "linkedInLink", twentyType: "LINKS", action: "field" },
422
+ linkedin_profile: { canonicalName: "linkedInLink", twentyType: "LINKS", action: "field" },
423
+ twitter: { canonicalName: "xLink", twentyType: "LINKS", action: "field" },
424
+ twitter_url: { canonicalName: "xLink", twentyType: "LINKS", action: "field" },
425
+ x_profile: { canonicalName: "xLink", twentyType: "LINKS", action: "field" },
426
+ // ── Pipeline-specific (Google / business intel) ───────────────────────────
427
+ google_places_url: { canonicalName: "extGooglePlacesUrl", twentyType: "LINKS", action: "field" },
428
+ places_url: { canonicalName: "extGooglePlacesUrl", twentyType: "LINKS", action: "field" },
429
+ google_maps_url: { canonicalName: "extGooglePlacesUrl", twentyType: "LINKS", action: "field" },
430
+ maps_url: { canonicalName: "extGooglePlacesUrl", twentyType: "LINKS", action: "field" },
431
+ rating: { canonicalName: "extGoogleRating", twentyType: "NUMBER", action: "field" },
432
+ google_rating: { canonicalName: "extGoogleRating", twentyType: "NUMBER", action: "field" },
433
+ stars: { canonicalName: "extGoogleRating", twentyType: "NUMBER", action: "field" },
434
+ review_count: { canonicalName: "extReviewCount", twentyType: "NUMBER", action: "field" },
435
+ reviews_count: { canonicalName: "extReviewCount", twentyType: "NUMBER", action: "field" },
436
+ total_reviews: { canonicalName: "extReviewCount", twentyType: "NUMBER", action: "field" },
437
+ num_reviews: { canonicalName: "extReviewCount", twentyType: "NUMBER", action: "field" },
438
+ marketing_score: { canonicalName: "extMarketingScore", twentyType: "NUMBER", action: "field" },
439
+ lead_score: { canonicalName: "extLeadScore", twentyType: "NUMBER", action: "field" },
440
+ analyzed_url: { canonicalName: "extAnalyzedUrl", twentyType: "LINKS", action: "field" },
441
+ // ── UTM — always captured in note ────────────────────────────────────────
442
+ utm_source: { canonicalName: "utm_source", twentyType: "TEXT", action: "note" },
443
+ utm_medium: { canonicalName: "utm_medium", twentyType: "TEXT", action: "note" },
444
+ utm_campaign: { canonicalName: "utm_campaign", twentyType: "TEXT", action: "note" },
445
+ utm_content: { canonicalName: "utm_content", twentyType: "TEXT", action: "note" },
446
+ utm_term: { canonicalName: "utm_term", twentyType: "TEXT", action: "note" },
447
+ ref: { canonicalName: "ref", twentyType: "TEXT", action: "note" },
448
+ referrer: { canonicalName: "referrer", twentyType: "TEXT", action: "note" },
449
+ source_page: { canonicalName: "source_page", twentyType: "TEXT", action: "note" },
450
+ landing_page: { canonicalName: "landing_page", twentyType: "TEXT", action: "note" },
451
+ // ── Known note fields — always prose ─────────────────────────────────────
452
+ message: { canonicalName: "message", twentyType: "RICH_TEXT", action: "note" },
453
+ description: { canonicalName: "description", twentyType: "RICH_TEXT", action: "note" },
454
+ analysis: { canonicalName: "analysis", twentyType: "RICH_TEXT", action: "note" },
455
+ notes: { canonicalName: "notes", twentyType: "RICH_TEXT", action: "note" },
456
+ comments: { canonicalName: "comments", twentyType: "RICH_TEXT", action: "note" },
457
+ summary: { canonicalName: "summary", twentyType: "RICH_TEXT", action: "note" },
458
+ inquiry: { canonicalName: "inquiry", twentyType: "RICH_TEXT", action: "note" },
459
+ body: { canonicalName: "body", twentyType: "RICH_TEXT", action: "note" },
460
+ details: { canonicalName: "details", twentyType: "RICH_TEXT", action: "note" },
461
+ content: { canonicalName: "content", twentyType: "RICH_TEXT", action: "note" },
462
+ text: { canonicalName: "text", twentyType: "RICH_TEXT", action: "note" },
463
+ feedback: { canonicalName: "feedback", twentyType: "RICH_TEXT", action: "note" },
464
+ about: { canonicalName: "about", twentyType: "RICH_TEXT", action: "note" },
465
+ additional_info: { canonicalName: "additional_info", twentyType: "RICH_TEXT", action: "note" },
466
+ // ── System / internal — skip entirely ────────────────────────────────────
467
+ _id: { canonicalName: "_id", twentyType: "TEXT", action: "skip" },
468
+ __v: { canonicalName: "__v", twentyType: "TEXT", action: "skip" },
469
+ created_at: { canonicalName: "createdAt", twentyType: "DATE_TIME", action: "skip" },
470
+ updated_at: { canonicalName: "updatedAt", twentyType: "DATE_TIME", action: "skip" },
471
+ form_id: { canonicalName: "form_id", twentyType: "TEXT", action: "skip" },
472
+ submission_id: { canonicalName: "submission_id", twentyType: "TEXT", action: "skip" },
473
+ token: { canonicalName: "token", twentyType: "TEXT", action: "skip" },
474
+ password: { canonicalName: "password", twentyType: "TEXT", action: "skip" },
475
+ secret: { canonicalName: "secret", twentyType: "TEXT", action: "skip" }
476
+ };
477
+
478
+ // src/lib/type-detector.ts
479
+ var URL_RE = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
480
+ var EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]{2,}$/;
481
+ var PHONE_RE = /^[+]?[(]?[0-9]{1,4}[)]?[-\s.]?[(]?[0-9]{1,4}[)]?[-\s.]?[0-9]{3,9}([-\s.][0-9]{1,9})?$/;
482
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?)?$/;
483
+ function detectType(value) {
484
+ if (value === null || value === void 0) return "TEXT";
485
+ if (typeof value === "boolean") return "BOOLEAN";
486
+ if (typeof value === "number") return "NUMBER";
487
+ if (typeof value === "string") {
488
+ const s = value.trim();
489
+ if (!s) return "TEXT";
490
+ if (URL_RE.test(s)) return "LINKS";
491
+ if (EMAIL_RE.test(s)) return "EMAILS";
492
+ if (PHONE_RE.test(s)) return "PHONES";
493
+ if (DATE_RE.test(s)) return "DATE_TIME";
494
+ if (!isNaN(Number(s)) && s !== "") return "NUMBER";
495
+ return "TEXT";
496
+ }
497
+ if (Array.isArray(value)) return "RAW_JSON";
498
+ if (typeof value === "object") return "RAW_JSON";
499
+ return "TEXT";
500
+ }
501
+
502
+ // src/lib/classifier.ts
503
+ var PROSE_KEYS = /* @__PURE__ */ new Set([
504
+ "message",
505
+ "description",
506
+ "analysis",
507
+ "notes",
508
+ "comments",
509
+ "summary",
510
+ "inquiry",
511
+ "body",
512
+ "details",
513
+ "content",
514
+ "text",
515
+ "feedback",
516
+ "about",
517
+ "additional_info",
518
+ "info",
519
+ "context",
520
+ "requirements",
521
+ "goals",
522
+ "challenges",
523
+ "question",
524
+ "request"
525
+ ]);
526
+ var NOTE_KEY_PREFIXES = ["utm_", "ga_", "fb_", "gclid", "msclkid"];
527
+ var SENTENCE_RE = /[.!?]\s+[A-Z]/g;
528
+ var LINE_BREAK_RE = /\n|\r/;
529
+ function classify(key, value) {
530
+ const normKey = key.toLowerCase().replace(/[-\s]/g, "_");
531
+ if (Array.isArray(value)) return "note";
532
+ if (value !== null && typeof value === "object") return "note";
533
+ if (PROSE_KEYS.has(normKey)) return "note";
534
+ if (NOTE_KEY_PREFIXES.some((p) => normKey.startsWith(p))) return "note";
535
+ if (typeof value === "string") {
536
+ if (LINE_BREAK_RE.test(value)) return "note";
537
+ const matches = value.match(SENTENCE_RE);
538
+ if (matches && matches.length >= 2) return "note";
539
+ }
540
+ return "field";
541
+ }
542
+
543
+ // src/lib/normalizer.ts
544
+ function normalizeFields(flat, rules = []) {
545
+ const sorted = [...rules].sort((a, b) => b.priority - a.priority);
546
+ return Object.entries(flat).map(
547
+ ([key, value]) => normalizeField(key, value, sorted)
548
+ );
549
+ }
550
+ function normalizeField(key, value, rules) {
551
+ const lookupKey = normaliseKey(key);
552
+ const builtIn = FIELD_MAP[lookupKey];
553
+ if (builtIn) {
554
+ return {
555
+ originalKey: key,
556
+ canonicalName: builtIn.canonicalName,
557
+ twentyType: builtIn.twentyType,
558
+ action: builtIn.action,
559
+ value,
560
+ source: "builtin"
561
+ };
562
+ }
563
+ for (const rule of rules) {
564
+ let matches = false;
565
+ try {
566
+ matches = rule.inputPattern === key || rule.inputPattern === lookupKey || new RegExp(rule.inputPattern, "i").test(key);
567
+ } catch {
568
+ matches = rule.inputPattern === key;
569
+ }
570
+ if (matches) {
571
+ return {
572
+ originalKey: key,
573
+ canonicalName: rule.canonicalName,
574
+ twentyType: rule.fieldType,
575
+ action: rule.action,
576
+ value,
577
+ source: "rule"
578
+ };
579
+ }
580
+ }
581
+ const detectedType = detectType(value);
582
+ const classification = classify(key, value);
583
+ const canonicalName = classification === "field" ? toExtFieldName(key) : lookupKey;
584
+ return {
585
+ originalKey: key,
586
+ canonicalName,
587
+ twentyType: detectedType,
588
+ action: classification === "field" ? "field" : classification,
589
+ value,
590
+ source: "passthrough"
591
+ };
592
+ }
593
+ function toExtFieldName(key) {
594
+ const cleaned = key.replace(/[-_\s]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase()).replace(/[^a-zA-Z0-9]/g, "");
595
+ return "ext" + cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
596
+ }
597
+ function partition(fields) {
598
+ const crmFields = [];
599
+ const noteFields = [];
600
+ const skipped = [];
601
+ for (const f of fields) {
602
+ if (f.action === "skip") skipped.push(f);
603
+ else if (f.action === "note") noteFields.push(f);
604
+ else crmFields.push(f);
605
+ }
606
+ return { crmFields, noteFields, skipped };
607
+ }
608
+ function assembleComposites(crmFields) {
609
+ const assembled = {};
610
+ const consumed = /* @__PURE__ */ new Set();
611
+ const firstName = crmFields.find((f) => f.canonicalName === "firstName");
612
+ const lastName = crmFields.find((f) => f.canonicalName === "lastName");
613
+ const fullName = crmFields.find((f) => f.canonicalName === "fullName");
614
+ if (fullName && !firstName && !lastName) {
615
+ const parts = splitFullName(String(fullName.value ?? ""));
616
+ assembled["name"] = { firstName: parts[0], lastName: parts[1] };
617
+ consumed.add("fullName");
618
+ } else if (firstName || lastName) {
619
+ assembled["name"] = {
620
+ firstName: String(firstName?.value ?? ""),
621
+ lastName: String(lastName?.value ?? "")
622
+ };
623
+ if (firstName) consumed.add("firstName");
624
+ if (lastName) consumed.add("lastName");
625
+ }
626
+ const email = crmFields.find((f) => f.canonicalName === "email");
627
+ if (email) {
628
+ assembled["emails"] = { primaryEmail: String(email.value ?? "") };
629
+ consumed.add("email");
630
+ }
631
+ const phone = crmFields.find((f) => f.canonicalName === "phone");
632
+ if (phone) {
633
+ const raw = String(phone.value ?? "");
634
+ assembled["phones"] = {
635
+ primaryPhoneNumber: raw.replace(/\D/g, "").slice(-10),
636
+ primaryPhoneCountryCode: "US",
637
+ primaryPhoneCallingCode: "+1"
638
+ };
639
+ consumed.add("phone");
640
+ }
641
+ const domain = crmFields.find((f) => f.canonicalName === "domainName");
642
+ if (domain) {
643
+ assembled["domainName"] = {
644
+ primaryLinkUrl: String(domain.value ?? ""),
645
+ primaryLinkLabel: ""
646
+ };
647
+ consumed.add("domainName");
648
+ }
649
+ const addressFields = [
650
+ ["addressStreet1", "addressStreet1"],
651
+ ["addressStreet2", "addressStreet2"],
652
+ ["addressCity", "addressCity"],
653
+ ["addressState", "addressState"],
654
+ ["addressPostcode", "addressPostcode"],
655
+ ["addressCountry", "addressCountry"]
656
+ ];
657
+ const addressParts = {};
658
+ for (const [canonical, outKey] of addressFields) {
659
+ const f = crmFields.find((x) => x.canonicalName === canonical);
660
+ if (f) {
661
+ addressParts[outKey] = f.value;
662
+ consumed.add(canonical);
663
+ }
664
+ }
665
+ if (Object.keys(addressParts).length > 0) {
666
+ assembled["address"] = addressParts;
667
+ }
668
+ const remainder = crmFields.filter((f) => !consumed.has(f.canonicalName));
669
+ return { assembled, remainder };
670
+ }
671
+ function splitFullName(fullName) {
672
+ const parts = fullName.trim().split(/\s+/);
673
+ if (parts.length === 0) return ["", ""];
674
+ if (parts.length === 1) return [parts[0] ?? "", ""];
675
+ const lastName = parts.pop() ?? "";
676
+ return [parts.join(" "), lastName];
677
+ }
678
+
679
+ // src/lib/stages/normalize.stage.ts
680
+ var NormalizeStage = class {
681
+ constructor(client) {
682
+ this.client = client;
683
+ }
684
+ name = "normalize";
685
+ async execute(ctx) {
686
+ if (!ctx.flat) {
687
+ return err(ErrorCode.INTERNAL_ERROR, "FlattenStage must run before NormalizeStage");
688
+ }
689
+ const rules = await this.loadRules(ctx.source?.id);
690
+ const normalized = normalizeFields(ctx.flat, rules);
691
+ const { crmFields, noteFields } = partition(normalized);
692
+ return ok({
693
+ ...ctx,
694
+ normalizedFields: normalized,
695
+ crmFields,
696
+ noteFields
697
+ });
698
+ }
699
+ async loadRules(sourceId) {
700
+ try {
701
+ const filter = sourceId ? { or: [{ intakeSource: { id: { eq: sourceId } } }, { intakeSource: { id: { is: "NULL" } } }] } : { intakeSource: { id: { is: "NULL" } } };
702
+ const result = await this.client.query({
703
+ intakeFieldRules: {
704
+ __args: {
705
+ filter: { and: [filter, { isActive: { eq: true } }] },
706
+ orderBy: { priority: "DescNullsLast" }
707
+ },
708
+ edges: {
709
+ node: {
710
+ inputPattern: true,
711
+ canonicalName: true,
712
+ fieldType: true,
713
+ priority: true
714
+ }
715
+ }
716
+ }
717
+ });
718
+ return (result.intakeFieldRules?.edges ?? []).map((e) => e.node);
719
+ } catch {
720
+ return [];
721
+ }
722
+ }
723
+ };
724
+
725
+ // src/lib/rest-client.ts
726
+ var apiUrl = () => process.env["TWENTY_API_URL"] ?? "";
727
+ var token = () => process.env["TWENTY_APP_ACCESS_TOKEN"] ?? "";
728
+ async function request(base, method, path, body) {
729
+ const res = await fetch(`${base}${path}`, {
730
+ method,
731
+ headers: {
732
+ Authorization: `Bearer ${token()}`,
733
+ "Content-Type": "application/json"
734
+ },
735
+ body: body !== void 0 ? JSON.stringify(body) : void 0
736
+ });
737
+ const text = await res.text();
738
+ if (!res.ok) {
739
+ throw new Error(`${method} ${path} \u2192 ${res.status}: ${text}`);
740
+ }
741
+ return JSON.parse(text);
742
+ }
743
+ var coreApi = {
744
+ get: (path) => request(`${apiUrl()}/rest`, "GET", path),
745
+ post: (path, body) => request(`${apiUrl()}/rest`, "POST", path, body),
746
+ patch: (path, body) => request(`${apiUrl()}/rest`, "PATCH", path, body)
747
+ };
748
+ var metaApi = {
749
+ get: (path) => request(`${apiUrl()}/rest/metadata`, "GET", path),
750
+ post: (path, body) => request(`${apiUrl()}/rest/metadata`, "POST", path, body)
751
+ };
752
+ async function gql(query, variables) {
753
+ return request(`${apiUrl()}`, "POST", "/graphql", { query, variables });
754
+ }
755
+
756
+ // src/lib/stages/sync-schema.stage.ts
757
+ var STANDARD_FIELDS = /* @__PURE__ */ new Set([
758
+ "name",
759
+ "emails",
760
+ "phones",
761
+ "domainName",
762
+ "address",
763
+ "linkedInLink",
764
+ "xLink",
765
+ "companyName",
766
+ "firstName",
767
+ "lastName",
768
+ "fullName",
769
+ "jobTitle",
770
+ "city",
771
+ "employees",
772
+ "annualRecurringRevenue",
773
+ "idealCustomerProfile"
774
+ ]);
775
+ var SyncSchemaStage = class {
776
+ name = "sync-schema";
777
+ objectCache = /* @__PURE__ */ new Map();
778
+ TTL = 5 * 60 * 1e3;
779
+ async execute(ctx) {
780
+ if (!ctx.crmFields || ctx.crmFields.length === 0) return ok(ctx);
781
+ if (process.env["INTAKE_FIELD_CREATION_ENABLED"] === "false") {
782
+ ctx.warnings.push("Field creation disabled by INTAKE_FIELD_CREATION_ENABLED setting \u2014 ext fields skipped");
783
+ return ok(ctx);
784
+ }
785
+ const targets = this.targetObjects(ctx);
786
+ let fieldsCreated = 0;
787
+ let fieldsMatched = 0;
788
+ for (const objectName of targets) {
789
+ const meta = await this.getObjectMeta(objectName);
790
+ if (!meta) continue;
791
+ const maxExt = parseInt(process.env["INTAKE_MAX_EXT_FIELDS"] ?? "50", 10);
792
+ const currentExtCount = [...meta.fields].filter((f) => /^ext[A-Z]/.test(f)).length;
793
+ for (const field of ctx.crmFields) {
794
+ if (STANDARD_FIELDS.has(field.canonicalName)) {
795
+ fieldsMatched++;
796
+ continue;
797
+ }
798
+ if (!/^ext[A-Z]/.test(field.canonicalName)) {
799
+ fieldsMatched++;
800
+ continue;
801
+ }
802
+ if (meta.fields.has(field.canonicalName)) {
803
+ fieldsMatched++;
804
+ continue;
805
+ }
806
+ if (currentExtCount >= maxExt) {
807
+ ctx.warnings.push(`ext field limit (${maxExt}) reached for ${objectName} \u2014 "${field.canonicalName}" goes to note`);
808
+ continue;
809
+ }
810
+ const created = await this.createField(meta.id, field.canonicalName, field.twentyType);
811
+ if (created) {
812
+ meta.fields.add(field.canonicalName);
813
+ this.objectCache.get(objectName)?.fields.add(field.canonicalName);
814
+ fieldsCreated++;
815
+ } else {
816
+ ctx.warnings.push(`Could not create field "${field.canonicalName}" on ${objectName} \u2014 will go to note`);
817
+ }
818
+ }
819
+ }
820
+ return ok({ ...ctx, fieldsCreated, fieldsMatched });
821
+ }
822
+ targetObjects(ctx) {
823
+ const t = ctx.source?.targetObject ?? "AUTO";
824
+ if (t === "PERSON") return ["person"];
825
+ if (t === "COMPANY") return ["company"];
826
+ if (ctx.structuredCompany) return ["company"];
827
+ const hasPersonData = ctx.crmFields?.some((f) => ["firstName", "lastName", "email", "phone", "fullName", "emails", "phones", "name"].includes(f.canonicalName));
828
+ const hasCompanyData = ctx.crmFields?.some((f) => ["companyName", "domainName"].includes(f.canonicalName));
829
+ const out = [];
830
+ if (hasPersonData) out.push("person");
831
+ else if (hasCompanyData) out.push("company");
832
+ return out.length > 0 ? out : ["person"];
833
+ }
834
+ async getObjectMeta(objectName) {
835
+ const cached = this.objectCache.get(objectName);
836
+ if (cached && Date.now() - cached.at < this.TTL) return cached;
837
+ try {
838
+ const res = await metaApi.get("/objects");
839
+ const objects = Array.isArray(res) ? res : res.data ?? [];
840
+ const node = objects.find((o) => o.nameSingular === objectName);
841
+ if (!node) return null;
842
+ const fields = new Set((node.fields ?? []).map((f) => f.name));
843
+ const entry = { id: node.id, fields, at: Date.now() };
844
+ this.objectCache.set(objectName, entry);
845
+ return entry;
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+ async createField(objectMetadataId, name, type) {
851
+ try {
852
+ await metaApi.post("/fields", {
853
+ objectMetadataId,
854
+ name,
855
+ label: toLabel(name),
856
+ type,
857
+ description: "Auto-created by Intake",
858
+ icon: "IconPlug"
859
+ });
860
+ return true;
861
+ } catch {
862
+ return false;
863
+ }
864
+ }
865
+ };
866
+ function toLabel(name) {
867
+ return name.replace(/^ext_/, "").replace(/_/g, " ").replace(/([A-Z])/g, " $1").split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ").trim();
868
+ }
869
+
870
+ // src/lib/note-formatter.ts
871
+ function formatNote(sourceName, fields) {
872
+ const lines = [];
873
+ lines.push(`**Source:** ${sourceName}`);
874
+ const entries = buildEntries(fields);
875
+ for (const { label, value } of entries) {
876
+ const formatted = formatValue(value);
877
+ if (formatted !== null) {
878
+ lines.push(`**${label}:** ${formatted}`);
879
+ }
880
+ }
881
+ return lines.join("\n");
882
+ }
883
+ function buildEntries(fields) {
884
+ return Object.entries(fields).map(([key, value]) => ({
885
+ label: toLabel2(key),
886
+ value
887
+ }));
888
+ }
889
+ function toLabel2(key) {
890
+ return key.replace(/^ext_/, "").replace(/_/g, " ").replace(/([A-Z])/g, " $1").split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ").trim();
891
+ }
892
+ function formatValue(value) {
893
+ if (value === null || value === void 0 || value === "") return null;
894
+ if (Array.isArray(value)) return value.join(", ");
895
+ if (typeof value === "object") {
896
+ try {
897
+ return JSON.stringify(value, null, 2);
898
+ } catch {
899
+ return String(value);
900
+ }
901
+ }
902
+ return String(value);
903
+ }
904
+
905
+ // src/lib/stages/upsert-record.stage.ts
906
+ var UpsertRecordStage = class {
907
+ name = "upsert-record";
908
+ async execute(ctx) {
909
+ if (!ctx.crmFields) return err(ErrorCode.INTERNAL_ERROR, "No CRM fields to upsert");
910
+ const cleanFields = ctx.crmFields.filter((f) => !f.originalKey.startsWith("__"));
911
+ const { assembled, remainder } = assembleComposites(cleanFields);
912
+ for (const f of remainder) assembled[f.canonicalName] = f.value;
913
+ const structuredCompany = ctx.structuredCompany;
914
+ const structuredPerson = ctx.structuredPerson;
915
+ const rawCompanyName = structuredCompany?.["name"] ?? assembled["companyName"];
916
+ const extFieldTypes = /* @__PURE__ */ new Map();
917
+ for (const f of ctx.crmFields ?? []) {
918
+ if (/^ext[A-Z]/.test(f.canonicalName)) extFieldTypes.set(f.canonicalName, f.twentyType);
919
+ }
920
+ try {
921
+ let companyId;
922
+ let personId;
923
+ const needsCompany = rawCompanyName || ctx.source?.targetObject === "COMPANY";
924
+ if (needsCompany) {
925
+ companyId = await this.upsertCompany(assembled, structuredCompany ?? {}, rawCompanyName, extFieldTypes, ctx);
926
+ }
927
+ const personData = structuredPerson ?? assembled;
928
+ const hasPersonData = personData["emails"] || personData["name"] || assembled["emails"] || assembled["name"];
929
+ if (hasPersonData || ctx.source?.targetObject === "PERSON") {
930
+ personId = await this.upsertPerson(assembled, structuredPerson, companyId, extFieldTypes, ctx);
931
+ }
932
+ let opportunityId;
933
+ if (ctx.source?.createOpportunity && (personId || companyId)) {
934
+ opportunityId = await this.createOpportunity(assembled, personId, companyId, ctx);
935
+ }
936
+ let noteId;
937
+ const noteFields = ctx.noteFields ?? [];
938
+ if (noteFields.length > 0) {
939
+ const overflow = {};
940
+ for (const f of noteFields) overflow[f.canonicalName] = f.value;
941
+ noteId = await this.createNote(overflow, personId, companyId, ctx);
942
+ }
943
+ return ok({
944
+ ...ctx,
945
+ personId,
946
+ companyId,
947
+ opportunityId,
948
+ noteId,
949
+ recordType: personId ? "PERSON" : "COMPANY"
950
+ });
951
+ } catch (e) {
952
+ return err(ErrorCode.RECORD_UPSERT_FAILED, `Failed to upsert record: ${String(e)}`);
953
+ }
954
+ }
955
+ async upsertCompany(assembled, structuredCompany, companyName, extFieldTypes, ctx) {
956
+ const standard = { ...structuredCompany };
957
+ if (companyName && !standard["name"]) standard["name"] = companyName;
958
+ if (!standard["domainName"] && assembled["domainName"]) standard["domainName"] = assembled["domainName"];
959
+ if (!standard["address"] && assembled["address"]) standard["address"] = assembled["address"];
960
+ const domain = standard["domainName"]?.["primaryLinkUrl"];
961
+ let companyId;
962
+ if (domain) {
963
+ const host = extractHost(domain);
964
+ try {
965
+ const found = await coreApi.get(
966
+ `/companies?filter=domainName.primaryLinkUrl[like]:%25${encodeURIComponent(host)}%25&first=1`
967
+ );
968
+ const existing = found.data?.companies?.edges?.[0]?.node;
969
+ if (existing) {
970
+ await coreApi.patch(`/companies/${existing.id}`, standard);
971
+ companyId = existing.id;
972
+ }
973
+ } catch {
974
+ }
975
+ }
976
+ if (!companyId) {
977
+ try {
978
+ const created = await coreApi.post("/companies", standard);
979
+ companyId = created.data.createCompany.id;
980
+ } catch (e) {
981
+ if (String(e).toLowerCase().includes("duplicate") && standard["name"]) {
982
+ try {
983
+ const found = await gql(
984
+ `query FindCompany($name: String!) { companies(filter: { name: { eq: $name } }) { edges { node { id } } } }`,
985
+ { name: String(standard["name"]) }
986
+ );
987
+ companyId = found.data?.companies?.edges?.[0]?.node?.id;
988
+ } catch {
989
+ }
990
+ }
991
+ if (!companyId) throw e;
992
+ }
993
+ }
994
+ if (Object.keys(structuredCompany).length > 0) {
995
+ const extData = {};
996
+ for (const [k, v] of Object.entries(assembled)) {
997
+ if (/^ext[A-Z]/.test(k)) extData[k] = formatExtValue(v, extFieldTypes.get(k));
998
+ }
999
+ if (Object.keys(extData).length > 0) {
1000
+ await coreApi.patch(`/companies/${companyId}`, extData).catch((e) => {
1001
+ ctx.warnings.push(`Could not set ext fields on company: ${e.message.slice(0, 120)}`);
1002
+ });
1003
+ }
1004
+ }
1005
+ return companyId;
1006
+ }
1007
+ async upsertPerson(assembled, structuredPerson, companyId, extFieldTypes, ctx) {
1008
+ const standard = structuredPerson ? { ...structuredPerson } : {};
1009
+ if (!standard["name"] && assembled["name"]) standard["name"] = assembled["name"];
1010
+ if (!standard["emails"] && assembled["emails"]) standard["emails"] = assembled["emails"];
1011
+ if (!standard["phones"] && assembled["phones"]) standard["phones"] = assembled["phones"];
1012
+ if (companyId) standard["companyId"] = companyId;
1013
+ const emails = standard["emails"] ?? assembled["emails"];
1014
+ const email = emails?.["primaryEmail"];
1015
+ let personId;
1016
+ if (email) {
1017
+ try {
1018
+ const found = await coreApi.get(
1019
+ `/people?filter=emails.primaryEmail[eq]:${encodeURIComponent(email)}&first=1`
1020
+ );
1021
+ const existing = found.data?.people?.edges?.[0]?.node;
1022
+ if (existing) {
1023
+ await coreApi.patch(`/people/${existing.id}`, standard);
1024
+ personId = existing.id;
1025
+ }
1026
+ } catch {
1027
+ }
1028
+ }
1029
+ if (!personId) {
1030
+ const created = await coreApi.post("/people", standard);
1031
+ personId = created.data.createPerson.id;
1032
+ }
1033
+ if (!structuredPerson) {
1034
+ const extData = {};
1035
+ for (const [k, v] of Object.entries(assembled)) {
1036
+ if (/^ext[A-Z]/.test(k)) extData[k] = formatExtValue(v, extFieldTypes.get(k));
1037
+ }
1038
+ if (Object.keys(extData).length > 0) {
1039
+ await coreApi.patch(`/people/${personId}`, extData).catch((e) => {
1040
+ ctx.warnings.push(`Could not set ext fields on person: ${e.message.slice(0, 120)}`);
1041
+ });
1042
+ }
1043
+ }
1044
+ return personId;
1045
+ }
1046
+ async createOpportunity(assembled, personId, companyId, ctx) {
1047
+ try {
1048
+ const name = buildOpportunityName(ctx, assembled);
1049
+ const stage = process.env["INTAKE_DEFAULT_OPP_STAGE"] ?? "NEW";
1050
+ const data = { name, stage };
1051
+ if (personId) data["pointOfContactId"] = personId;
1052
+ if (companyId) data["companyId"] = companyId;
1053
+ const created = await coreApi.post("/opportunities", data);
1054
+ return created.data.createOpportunity.id;
1055
+ } catch {
1056
+ ctx.warnings.push("Opportunity creation failed \u2014 record was still created");
1057
+ return void 0;
1058
+ }
1059
+ }
1060
+ async createNote(overflow, personId, companyId, ctx) {
1061
+ try {
1062
+ const markdown = formatNote(ctx.source?.name ?? ctx.sourceSlug, overflow);
1063
+ const appLabel = process.env["INTAKE_APP_LABEL"] ?? "Intake";
1064
+ const created = await coreApi.post("/notes", {
1065
+ title: `${ctx.source?.name ?? appLabel} \u2014 Lead`,
1066
+ bodyV2: { markdown }
1067
+ });
1068
+ const noteId = created.data.createNote.id;
1069
+ if (personId) {
1070
+ await coreApi.post("/noteTargets", { noteId, personId }).catch(() => null);
1071
+ }
1072
+ if (companyId) {
1073
+ await coreApi.post("/noteTargets", { noteId, companyId }).catch(() => null);
1074
+ }
1075
+ return noteId;
1076
+ } catch {
1077
+ ctx.warnings.push("Note creation failed \u2014 record was still created");
1078
+ return void 0;
1079
+ }
1080
+ }
1081
+ };
1082
+ function buildOpportunityName(ctx, assembled) {
1083
+ const appLabel = process.env["INTAKE_APP_LABEL"] ?? "Intake";
1084
+ const defaultTemplate = `{{source}} \u2014 {{firstName}} {{lastName}}`;
1085
+ const template = ctx.source?.opportunityNameTemplate ?? defaultTemplate;
1086
+ const name = assembled["name"];
1087
+ 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*$/, "");
1088
+ }
1089
+ function extractHost(url) {
1090
+ try {
1091
+ return new URL(url).hostname.replace(/^www\./, "");
1092
+ } catch {
1093
+ return url;
1094
+ }
1095
+ }
1096
+ function formatExtValue(value, type) {
1097
+ if (type === "LINKS" && typeof value === "string") {
1098
+ return { primaryLinkUrl: value, primaryLinkLabel: "" };
1099
+ }
1100
+ return value;
1101
+ }
1102
+
1103
+ // src/lib/stages/write-log.stage.ts
1104
+ var WriteLogStage = class {
1105
+ name = "write-log";
1106
+ async execute(ctx) {
1107
+ try {
1108
+ const status = ctx.personId || ctx.companyId ? "SUCCESS" : "PARTIAL";
1109
+ await coreApi.post("/intakeLogs", {
1110
+ intakeSourceId: ctx.source?.id ?? null,
1111
+ status,
1112
+ payloadHash: ctx.payloadHash ?? "",
1113
+ rawPayload: JSON.stringify(ctx.rawPayload).slice(0, 65e3),
1114
+ // cap at 65k chars
1115
+ recordId: ctx.personId ?? ctx.companyId ?? null,
1116
+ recordType: ctx.recordType ?? null,
1117
+ fieldsCreated: ctx.fieldsCreated,
1118
+ fieldsMatched: ctx.fieldsMatched,
1119
+ overflowCount: ctx.noteFields?.length ?? 0,
1120
+ processingMs: Date.now() - ctx.startedAt,
1121
+ processedAt: (/* @__PURE__ */ new Date()).toISOString()
1122
+ });
1123
+ } catch {
1124
+ ctx.warnings.push("Intake log write failed");
1125
+ }
1126
+ if (ctx.source) {
1127
+ try {
1128
+ const current = await coreApi.get(
1129
+ `/intakeSources/${ctx.source.id}`
1130
+ );
1131
+ const current_count = current.data?.intakeSource?.totalIngested ?? 0;
1132
+ await coreApi.patch(`/intakeSources/${ctx.source.id}`, {
1133
+ totalIngested: current_count + 1,
1134
+ lastIngestedAt: (/* @__PURE__ */ new Date()).toISOString()
1135
+ });
1136
+ } catch {
1137
+ }
1138
+ }
1139
+ return ok(ctx);
1140
+ }
1141
+ };
1142
+
1143
+ // src/lib/pipeline-factory.ts
1144
+ function createPipeline(coreClient, metadataClient) {
1145
+ 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));
1146
+ }
1147
+
1148
+ // src/logic-functions/webhook.ts
1149
+ var handler = async (params) => {
1150
+ const startedAt = Date.now();
1151
+ const sourceSlug = params.pathParameters?.["slug"];
1152
+ if (!sourceSlug) {
1153
+ return { statusCode: 400, body: { error: "Missing source slug in URL", code: "INVALID_PAYLOAD" } };
1154
+ }
1155
+ let rawPayload = {};
1156
+ if (params.body && typeof params.body === "object" && !Array.isArray(params.body)) {
1157
+ rawPayload = params.body;
1158
+ }
1159
+ const coreClient = new CoreApiClient();
1160
+ const metadataClient = new MetadataApiClient();
1161
+ const pipeline = createPipeline(coreClient, metadataClient);
1162
+ const ctx = {
1163
+ sourceSlug,
1164
+ rawBody: params.rawBody,
1165
+ rawPayload,
1166
+ headers: params.headers ?? {},
1167
+ startedAt,
1168
+ fieldsCreated: 0,
1169
+ fieldsMatched: 0,
1170
+ warnings: [],
1171
+ events: []
1172
+ };
1173
+ const result = await pipeline.run(ctx);
1174
+ if (!result.ok) {
1175
+ const { code, message } = result.error;
1176
+ const statusCode = code === "INVALID_SIGNATURE" ? 401 : code === "SOURCE_NOT_FOUND" ? 404 : code === "SOURCE_PAUSED" ? 423 : code === "DUPLICATE_PAYLOAD" ? 200 : 500;
1177
+ return { statusCode, body: { error: message, code } };
1178
+ }
1179
+ const { value: finalCtx } = result;
1180
+ return {
1181
+ statusCode: 200,
1182
+ body: {
1183
+ success: true,
1184
+ recordId: finalCtx.personId ?? finalCtx.companyId,
1185
+ recordType: finalCtx.recordType,
1186
+ personId: finalCtx.personId,
1187
+ companyId: finalCtx.companyId,
1188
+ opportunityId: finalCtx.opportunityId,
1189
+ fieldsCreated: finalCtx.fieldsCreated,
1190
+ fieldsMatched: finalCtx.fieldsMatched,
1191
+ overflowCount: finalCtx.noteFields?.length ?? 0,
1192
+ processingMs: Date.now() - startedAt,
1193
+ warnings: finalCtx.warnings
1194
+ }
1195
+ };
1196
+ };
1197
+ var webhook_default = defineLogicFunction({
1198
+ universalIdentifier: IDS.WEBHOOK_LOGIC_FUNCTION,
1199
+ name: "intake-webhook",
1200
+ description: "Receives lead payloads from any source and ingests them into Twenty CRM.",
1201
+ timeoutSeconds: 30,
1202
+ handler,
1203
+ httpRouteTriggerSettings: {
1204
+ path: "/intake/:slug",
1205
+ httpMethod: "POST",
1206
+ isAuthRequired: false,
1207
+ forwardedRequestHeaders: ["x-webhook-signature", "x-intake-source", "content-type"]
1208
+ }
1209
+ });
1210
+ export {
1211
+ webhook_default as default
1212
+ };
1213
+ //# sourceMappingURL=webhook.mjs.map